La montre enregistre enfin sa trace GPS
La cible watchOS ne contenait pas une ligne de localisation : ni CLLocationManager, ni HKWorkoutRouteBuilder, et aucune clé NSLocation dans son Info.plist. Tant que les séances partaient de l'app Exercice d'Apple, c'est elle qui écrivait la trace et CoachHealthRoute la relisait après coup. Dès qu'une séance démarre depuis CoachWatch, plus personne ne l'écrit : la sortie n'aurait ni carte, ni parcours dans Santé. Le filtrage vit dans `RouteFilter`, en Foundation pur, donc testable ici : rejet des points imprécis (> 50 m, seuil de l'exemple Apple), des sauts impossibles, des doublons et des points arrivés dans le désordre, puis cumul de la distance (haversine) et du dénivelé. Le dénivelé a demandé deux passes. L'hystérésis seule laissait passer 297 m de D+ sur un parcours PLAT : une oscillation d'amplitude égale au seuil est comptée à chaque alternance, ce qui est inhérent à tout seuil. D'où un lissage préalable, qui annule le bruit alternant. Contrepartie assumée et testée : une pointe franchie en quelques points est écrêtée, donc sous-comptée — sans conséquence, le D+ qui fait foi restant celui, barométrique, qu'Apple écrit dans les métadonnées de la séance. Quatre pièges documentés, tous traités dans le code : - `allowsBackgroundLocationUpdates` sans `UIBackgroundModes` = `location` TERMINE l'app. Un garde-fou vérifie le plist avant d'armer le drapeau. - Ne jamais demander « Always » sur watchOS : Apple décrit ce prompt comme « mostly a placeholder » et le parcours comme un comportement indéfini. - Interdit de relancer la localisation depuis l'arrière-plan : la pause garde le flux ouvert et ignore les points, au lieu de couper le manager. - Le CPU tue le GPS — d'où l'insertion des points par lots et la trace d'affichage bornée à 1500 points. Corrigé après lecture de la doc : `finishRoute` s'appelle APRÈS `finishWorkout` et reçoit le workout pour s'y associer. J'avais écrit l'inverse, avec nil, ce qui aurait perdu l'association à chaque sortie. Et `finishWorkout()` rend nil sans erreur quand la montre est verrouillée : on sauvegarde alors la trace sans association plutôt que de la jeter. 15 tests, dont 4 vérifiés rouges en désactivant le lissage. Les trois fichiers sont déclarés dans la cible CoachWatch (pbxproj sauvegardé en .bak-outdoor) : aucun « Add Files » à faire sur le Mac. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
tests-linux/Sources/CoachModel/RouteFilter.swift
Symbolic link
1
tests-linux/Sources/CoachModel/RouteFilter.swift
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ios/App/CoachWatch/RouteFilter.swift
|
||||
206
tests-linux/Tests/CoachModelTests/RouteFilterTests.swift
Normal file
206
tests-linux/Tests/CoachModelTests/RouteFilterTests.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
import XCTest
|
||||
@testable import CoachModel
|
||||
|
||||
/* Filtrage de la trace GPS.
|
||||
*
|
||||
* Les cas testés ne sont pas théoriques : ce sont les quatre façons dont une
|
||||
* trace se salit réellement — acquisition GPS des premières secondes, couvert
|
||||
* forestier, points livrés dans le désordre après une reprise, et bruit
|
||||
* d'altimètre sur terrain plat.
|
||||
*/
|
||||
final class RouteFilterTests: XCTestCase {
|
||||
|
||||
private let t0 = Date(timeIntervalSince1970: 1_755_000_000)
|
||||
|
||||
private func fix(_ lat: Double, _ lon: Double,
|
||||
at seconds: TimeInterval,
|
||||
alt: Double = 600,
|
||||
hAcc: Double = 5,
|
||||
vAcc: Double = 3) -> GeoFix {
|
||||
GeoFix(lat: lat, lon: lon, altitude: alt,
|
||||
horizontalAccuracy: hAcc, verticalAccuracy: vAcc,
|
||||
speed: -1, timestamp: t0.addingTimeInterval(seconds))
|
||||
}
|
||||
|
||||
// MARK: Distance
|
||||
|
||||
func testHaversineMatchesAKnownDistance() {
|
||||
// Blonay → Vevey, deux points relevés sur la carte. La distance réelle
|
||||
// à vol d'oiseau est d'environ 3,4 km.
|
||||
let blonay = fix(46.4667, 6.9167, at: 0)
|
||||
let vevey = fix(46.4628, 6.8419, at: 60)
|
||||
let d = RouteFilter.distance(from: blonay, to: vevey)
|
||||
XCTAssertEqual(d, 5760, accuracy: 200,
|
||||
"Écart Blonay–Vevey en longitude : ~5,8 km")
|
||||
}
|
||||
|
||||
func testDistanceIsZeroForTheSamePoint() {
|
||||
let a = fix(46.4667, 6.9167, at: 0)
|
||||
XCTAssertEqual(RouteFilter.distance(from: a, to: a), 0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testDistanceAccumulatesAlongATrack() {
|
||||
var filter = RouteFilter()
|
||||
// Cinq points espacés d'environ 100 m vers l'est.
|
||||
for i in 0..<5 {
|
||||
let lon = 6.9167 + Double(i) * 0.0013
|
||||
XCTAssertEqual(filter.add(fix(46.4667, lon, at: Double(i) * 30)), .accepted)
|
||||
}
|
||||
XCTAssertEqual(filter.acceptedCount, 5)
|
||||
XCTAssertEqual(filter.distanceM, 400, accuracy: 40)
|
||||
}
|
||||
|
||||
// MARK: Rejets
|
||||
|
||||
func testFixWithNegativeAccuracyIsRejected() {
|
||||
var filter = RouteFilter()
|
||||
// Convention Apple : une précision négative signale un fix inutilisable.
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: -1)),
|
||||
.rejected(.invalidAccuracy))
|
||||
XCTAssertEqual(filter.acceptedCount, 0)
|
||||
XCTAssertEqual(filter.distanceM, 0)
|
||||
}
|
||||
|
||||
func testImpreciseFixesAreRejected() {
|
||||
var filter = RouteFilter()
|
||||
// Les premières secondes d'une séance : le GPS n'a pas encore convergé.
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: 165)),
|
||||
.rejected(.tooImprecise))
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 3, hAcc: 65)),
|
||||
.rejected(.tooImprecise))
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 8, hAcc: 12)), .accepted)
|
||||
XCTAssertEqual(filter.rejectedCount, 2)
|
||||
XCTAssertEqual(filter.acceptedCount, 1)
|
||||
}
|
||||
|
||||
func testTeleportationIsRejected() {
|
||||
var filter = RouteFilter()
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted)
|
||||
// Point sauté de ~5,8 km en une seconde : impossible à pied.
|
||||
XCTAssertEqual(filter.add(fix(46.4628, 6.8419, at: 1)),
|
||||
.rejected(.implausibleSpeed))
|
||||
XCTAssertEqual(filter.distanceM, 0, accuracy: 0.001,
|
||||
"Un saut GPS ne doit pas gonfler la distance")
|
||||
// Le point suivant, plausible, repart du dernier point ACCEPTÉ.
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9180, at: 10)), .accepted)
|
||||
XCTAssertEqual(filter.distanceM, 100, accuracy: 20)
|
||||
}
|
||||
|
||||
func testOutOfOrderAndDuplicateFixesAreRejected() {
|
||||
var filter = RouteFilter()
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 100)), .accepted)
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 100)), .rejected(.duplicate))
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 90)), .rejected(.outOfOrder))
|
||||
XCTAssertEqual(filter.acceptedCount, 1)
|
||||
}
|
||||
|
||||
// MARK: Dénivelé
|
||||
|
||||
func testFlatNoisyTerrainProducesNoElevationGain() {
|
||||
// Le piège central : ±1,5 m de bruit d'altimètre sur 200 points. Sans
|
||||
// hystérésis, on cumulerait des centaines de mètres de D+ sur du plat.
|
||||
var filter = RouteFilter()
|
||||
for i in 0..<200 {
|
||||
let noise = (i % 2 == 0) ? 1.5 : -1.5
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0002,
|
||||
at: Double(i) * 5, alt: 600 + noise))
|
||||
}
|
||||
XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001,
|
||||
"Le bruit d'altimètre ne doit pas produire de D+")
|
||||
XCTAssertEqual(filter.descentM, 0, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testRealClimbIsCounted() {
|
||||
// Cadence réaliste : un point par seconde, +0,5 m par point — soit une
|
||||
// montée de 100 m en 3 min 20, ce qui est déjà raide.
|
||||
var filter = RouteFilter()
|
||||
for i in 0...200 {
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005,
|
||||
at: Double(i), alt: 600 + Double(i) * 0.5))
|
||||
}
|
||||
XCTAssertEqual(filter.ascentM, 100, accuracy: 5)
|
||||
XCTAssertEqual(filter.descentM, 0, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testDescentIsCountedSeparately() {
|
||||
var filter = RouteFilter()
|
||||
for i in 0...200 { // 600 → 700
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005,
|
||||
at: Double(i), alt: 600 + Double(i) * 0.5))
|
||||
}
|
||||
for i in 1...200 { // 700 → 600
|
||||
_ = filter.add(fix(46.4667, 6.9267 + Double(i) * 0.00005,
|
||||
at: 200 + Double(i), alt: 700 - Double(i) * 0.5))
|
||||
}
|
||||
XCTAssertEqual(filter.ascentM, 100, accuracy: 5)
|
||||
XCTAssertEqual(filter.descentM, 100, accuracy: 5)
|
||||
}
|
||||
|
||||
func testSmoothingClipsASharpPeak() {
|
||||
// Comportement assumé, pas un bug : une pointe franchie en quelques
|
||||
// points est arrondie par la moyenne glissante, donc sous-comptée. Ce
|
||||
// test existe pour que la perte soit connue et surveillée — le D+ qui
|
||||
// fait foi reste celui, barométrique, qu'Apple écrit dans les
|
||||
// métadonnées de la séance.
|
||||
var filter = RouteFilter()
|
||||
for i in 0...10 {
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005,
|
||||
at: Double(i) * 20, alt: 600 + Double(i) * 10))
|
||||
}
|
||||
for i in 1...10 {
|
||||
_ = filter.add(fix(46.4667, 6.9217 + Double(i) * 0.0005,
|
||||
at: 200 + Double(i) * 20, alt: 700 - Double(i) * 10))
|
||||
}
|
||||
XCTAssertLessThan(filter.ascentM, 100)
|
||||
XCTAssertLessThan(filter.descentM, 100)
|
||||
XCTAssertGreaterThan(filter.ascentM, 60,
|
||||
"L'écrêtage doit rester partiel, pas effacer la montée")
|
||||
}
|
||||
|
||||
func testAltitudeWithoutVerticalAccuracyIsIgnored() {
|
||||
// Le GPS seul dérive verticalement de plusieurs mètres. Apple signale
|
||||
// une altitude non fiable par une incertitude négative.
|
||||
var filter = RouteFilter()
|
||||
for i in 0...20 {
|
||||
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005,
|
||||
at: Double(i) * 20,
|
||||
alt: 600 + Double(i) * 5, vAcc: -1))
|
||||
}
|
||||
XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001)
|
||||
XCTAssertEqual(filter.acceptedCount, 21,
|
||||
"Le point reste valide pour la trace, seule l'altitude est écartée")
|
||||
}
|
||||
|
||||
// MARK: Robustesse
|
||||
|
||||
func testFirstFixEstablishesTheOriginWithoutDistance() {
|
||||
var filter = RouteFilter()
|
||||
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted)
|
||||
XCTAssertEqual(filter.distanceM, 0)
|
||||
XCTAssertNotNil(filter.lastAccepted)
|
||||
}
|
||||
|
||||
func testReplayingTheSameTrackGivesTheSameResult() {
|
||||
let track = (0..<50).map { i in
|
||||
fix(46.4667 + Double(i) * 0.0001, 6.9167 + Double(i) * 0.0003,
|
||||
at: Double(i) * 4, alt: 600 + Double(i) * 2)
|
||||
}
|
||||
var a = RouteFilter(), b = RouteFilter()
|
||||
for f in track { a.add(f) }
|
||||
for f in track { b.add(f) }
|
||||
XCTAssertEqual(a, b)
|
||||
}
|
||||
|
||||
func testThresholdsAreConfigurable() {
|
||||
// Une sortie à VTT électrique dépasse allègrement les 30 km/h : le
|
||||
// seuil doit pouvoir s'ajuster sans toucher au filtre.
|
||||
var strict = RouteFilter(maxPlausibleSpeed: 5)
|
||||
strict.add(fix(46.4667, 6.9167, at: 0))
|
||||
XCTAssertEqual(strict.add(fix(46.4667, 6.9180, at: 1)),
|
||||
.rejected(.implausibleSpeed))
|
||||
|
||||
var loose = RouteFilter(maxPlausibleSpeed: 200)
|
||||
loose.add(fix(46.4667, 6.9167, at: 0))
|
||||
XCTAssertEqual(loose.add(fix(46.4667, 6.9180, at: 1)), .accepted)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user