Compare commits
3 Commits
be1ffec5c1
...
19fbfd116e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19fbfd116e | ||
|
|
eb523ff205 | ||
|
|
acbb2c6896 |
55
COWORK.md
55
COWORK.md
@@ -76,10 +76,11 @@ ouvrait Safari**, où le cookie d'auth injecté dans la WKWebView n'existe pas :
|
||||
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
|
||||
`openNativeLive`). C'est le point d'entrée qui manquait.
|
||||
|
||||
## 🆕 Moteur d'intervalles (2026-08-20, dev — non câblé, non compilé)
|
||||
## 🆕 Chantier montre outdoor (2026-08-20, dev — non compilé)
|
||||
|
||||
Branche **`feat/watch-outdoor`** (poussée sur Gitea le 2026-08-20), commit
|
||||
`f2fa3cc`. `IntervalEngine` guide les intervalles d'une séance au poignet.
|
||||
Branche **`feat/watch-outdoor`**, commits `f2fa3cc` (moteur d'intervalles) et
|
||||
`a04dfc4` (trace GPS). `IntervalEngine` guide les intervalles d'une séance au
|
||||
poignet ; `RouteFilter` + `LocationTracker` enregistrent le parcours.
|
||||
|
||||
**Pourquoi ce code existe** : WorkoutKit ne sait pas exécuter une séance
|
||||
structurée dans une app tierce — son seul point d'exécution public est
|
||||
@@ -91,22 +92,42 @@ horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et
|
||||
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
|
||||
qui exclut déjà les pauses.
|
||||
|
||||
✅ **42 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
|
||||
`~/workspace/toolchains/bin`), dont les 17 d'`IntervalEngine`. La logique est
|
||||
donc validée — ce qui reste est l'intégration, que Linux ne peut pas compiler.
|
||||
✅ **57 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
|
||||
`~/workspace/toolchains/bin`) : 17 pour `IntervalEngine`, 15 pour `RouteFilter`.
|
||||
Les deux ont été **vérifiés rouges** en neutralisant leur correction (5 échecs
|
||||
sans les frontières théoriques du moteur, 4 sans le lissage du dénivelé). La
|
||||
logique est donc validée — reste l'intégration, que Linux ne peut pas compiler.
|
||||
|
||||
⚠️ **ÉTAPE MAC OBLIGATOIRE — le fichier n'est PAS dans le projet Xcode.**
|
||||
Vérifié le 2026-08-20 : `grep -c "IntervalEngine" ios/App/App.xcodeproj/project.pbxproj`
|
||||
rend **0**. Xcode l'ignore donc totalement, et **ça compile très bien sans lui** —
|
||||
même piège que `VmaTestView.swift`. À faire :
|
||||
✅ **Plus d'« Add Files » à faire** — correction du 2026-08-20 après-midi. Une
|
||||
note antérieure de ce fichier annonçait `IntervalEngine` absent du projet : ce
|
||||
n'est plus vrai. Les **trois** fichiers sont déclarés dans la cible `CoachWatch`
|
||||
directement dans le `pbxproj` (sauvegarde `project.pbxproj.bak-outdoor`).
|
||||
|
||||
1. `cp ios/App/App.xcodeproj/project.pbxproj ios/App/App.xcodeproj/project.pbxproj.bak-interval`
|
||||
2. Clic droit sur le dossier **CoachWatch** → *Add Files to "App"…* →
|
||||
`ios/App/CoachWatch/IntervalEngine.swift`
|
||||
— **décocher** « Copy items if needed », **cocher la cible `CoachWatch`**.
|
||||
3. Contrôle : `grep -c "IntervalEngine.swift in Sources" ios/App/App.xcodeproj/project.pbxproj`
|
||||
doit rendre **1**.
|
||||
4. Commit + push du `pbxproj` immédiatement (règle 3 de ce fichier).
|
||||
Contrôle avant de builder — chacun doit rendre **2** (déclaration + phase de
|
||||
compilation, soit 1 cible) :
|
||||
```bash
|
||||
for f in IntervalEngine RouteFilter LocationTracker; do
|
||||
echo "$f: $(grep -c "$f.swift in Sources" ios/App/App.xcodeproj/project.pbxproj)"
|
||||
done
|
||||
```
|
||||
|
||||
⚠️ **À vérifier au premier build, dans cet ordre** :
|
||||
1. **Une séance extérieure demande la localisation** (feuille « lorsque l'app
|
||||
est active »). Si rien n'apparaît, la trace ne partira pas.
|
||||
2. **La sortie a bien un parcours dans Santé** à la fin. C'est le test qui
|
||||
compte : `finishRoute` doit être appelé APRÈS `finishWorkout`, sinon la
|
||||
trace existe sans être associée à la séance.
|
||||
3. **La FC remonte toujours sur `/live`** — `WorkoutManager` a été modifié,
|
||||
c'est le test de non-régression prioritaire.
|
||||
4. Écran éteint, poignet baissé : les positions continuent d'arriver. Si elles
|
||||
s'arrêtent, chercher du côté du CPU (une boucle d'affichage trop rapide fait
|
||||
suspendre l'app), pas du côté des autorisations.
|
||||
|
||||
⚠️ `Info.plist` de `CoachWatch` gagne `UIBackgroundModes = [location]` et
|
||||
`NSLocationWhenInUseUsageDescription`. **La clé de background est vitale** :
|
||||
armer `allowsBackgroundLocationUpdates` sans elle termine l'app. Un garde-fou
|
||||
la vérifie au démarrage, mais si elle disparaissait du build, le suivi
|
||||
retomberait silencieusement au premier plan seul.
|
||||
|
||||
## 👉 À reprendre
|
||||
- ✅ **Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
|
||||
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
|
||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
@@ -162,6 +164,8 @@
|
||||
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
|
||||
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
||||
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
|
||||
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = "<group>"; };
|
||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||
@@ -336,6 +340,8 @@
|
||||
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
|
||||
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
||||
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */,
|
||||
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */,
|
||||
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
||||
);
|
||||
path = CoachWatch;
|
||||
@@ -598,6 +604,8 @@
|
||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
|
||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */,
|
||||
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
1093
ios/App/App.xcodeproj/project.pbxproj.bak-outdoor
Normal file
1093
ios/App/App.xcodeproj/project.pbxproj.bak-outdoor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,18 @@
|
||||
<string>Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>Coach Hypnotruck enregistre ta séance dans l'app Santé.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Coach Hypnotruck suit ta position pendant la séance pour tracer ton parcours, afficher la carte et mesurer le dénivelé.</string>
|
||||
<!-- ⚠️ `location` est requis par `allowsBackgroundLocationUpdates` : armer ce
|
||||
drapeau sans la clé TERMINE l'app (« is a fatal error that terminates
|
||||
the app », doc Apple). `LocationTracker.backgroundModeDeclared` vérifie
|
||||
donc cette clé avant d'armer quoi que ce soit. La clé qui porte
|
||||
`location` sur watchOS est `UIBackgroundModes`, distincte de
|
||||
`WKBackgroundModes` qui porte `workout-processing`. -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>location</string>
|
||||
</array>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKBackgroundModes</key>
|
||||
|
||||
318
ios/App/CoachWatch/LocationTracker.swift
Normal file
318
ios/App/CoachWatch/LocationTracker.swift
Normal file
@@ -0,0 +1,318 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import HealthKit
|
||||
import os
|
||||
|
||||
private let locationLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "location")
|
||||
|
||||
/* GPS de la séance : flux de positions, trace HealthKit, distance et D+.
|
||||
*
|
||||
* Le filtrage vit dans `RouteFilter` (Foundation pur, testé sur Linux). Ce
|
||||
* fichier ne fait que le brancher sur CoreLocation et HealthKit — c'est la
|
||||
* seule partie qui exige un poignet pour être validée.
|
||||
*
|
||||
* **Pourquoi enregistrer la trace nous-mêmes.** Jusqu'ici les traces venaient
|
||||
* de l'app *Exercice* d'Apple, relues après coup côté iPhone par
|
||||
* `CoachHealthRoute`. Dès qu'une séance est lancée depuis CoachWatch, plus
|
||||
* personne n'écrit de `HKWorkoutRoute` : sans ce fichier, la sortie n'aurait
|
||||
* aucune trace, ni sur la carte ni dans Santé.
|
||||
*
|
||||
* ⚠️ **Quatre pièges, tous documentés et tous coûteux.**
|
||||
*
|
||||
* 1. `allowsBackgroundLocationUpdates = true` **sans** `UIBackgroundModes` =
|
||||
* `location` dans l'Info.plist **termine l'app** — Apple : « is a fatal
|
||||
* error that terminates the app ». D'où le garde-fou `backgroundModeDeclared`
|
||||
* ci-dessous, qui vérifie le plist avant d'armer le drapeau.
|
||||
* 2. **Ne jamais demander « Always » sur watchOS.** Un ingénieur DTS d'Apple
|
||||
* décrit ce prompt comme « mostly a placeholder » et le parcours comme
|
||||
* « undefined behavior » : le statut boucle jusqu'à revenir à
|
||||
* `.notDetermined`. `requestWhenInUseAuthorization()` suffit pour continuer
|
||||
* à recevoir des positions en arrière-plan.
|
||||
* 3. **Démarrer au premier plan, et ne plus jamais arrêter.** watchOS interdit
|
||||
* de relancer les mises à jour depuis l'arrière-plan : une pause qui
|
||||
* couperait le GPS ne pourrait pas le rallumer avant le retour à l'écran.
|
||||
* On garde donc le flux ouvert et on ignore les points pendant la pause.
|
||||
* 4. **Le CPU tue le GPS.** watchOS suspend une app qui consomme trop en
|
||||
* arrière-plan, et les positions s'arrêtent sans la moindre erreur — un
|
||||
* développeur a perdu les siennes à cause d'un rafraîchissement d'écran à
|
||||
* 1/100 s. D'où l'insertion des points par lots plutôt qu'un par un.
|
||||
*/
|
||||
@MainActor
|
||||
final class LocationTracker: NSObject, ObservableObject {
|
||||
static let shared = LocationTracker()
|
||||
|
||||
/// Trace décimée pour l'affichage carte. Bornée : une sortie de 3 h à 1 Hz
|
||||
/// produirait plus de 10 000 points, que la montre ne peut pas redessiner
|
||||
/// à chaque rafraîchissement sans se faire suspendre (piège 4).
|
||||
@Published private(set) var trackForMap: [GeoFix] = []
|
||||
@Published private(set) var lastFix: GeoFix?
|
||||
@Published private(set) var distanceMeters: Double = 0
|
||||
@Published private(set) var ascentMeters: Double = 0
|
||||
@Published private(set) var descentMeters: Double = 0
|
||||
/// Rayon d'incertitude du dernier point reçu, accepté ou non — c'est la
|
||||
/// jauge « qualité GPS » de l'écran.
|
||||
@Published private(set) var horizontalAccuracy: Double?
|
||||
@Published private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
||||
@Published private(set) var isTracking = false
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var filter = RouteFilter()
|
||||
private var routeBuilder: HKWorkoutRouteBuilder?
|
||||
/// Points en attente d'écriture dans HealthKit.
|
||||
private var pendingRoute: [CLLocation] = []
|
||||
/// Positions ignorées tant que la séance est en pause : le flux reste
|
||||
/// ouvert (piège 3) mais la trace ne doit pas traverser l'arrêt.
|
||||
private var isPaused = false
|
||||
|
||||
private let maxMapPoints = 1_500
|
||||
private let routeFlushThreshold = 25
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
authorizationStatus = manager.authorizationStatus
|
||||
}
|
||||
|
||||
// MARK: Autorisation
|
||||
|
||||
/// À appeler **au premier plan**, avant de démarrer la séance.
|
||||
func requestAuthorization() {
|
||||
// Volontairement pas `requestAlwaysAuthorization()` : voir piège 2.
|
||||
guard manager.authorizationStatus == .notDetermined else { return }
|
||||
manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
|
||||
var isAuthorized: Bool {
|
||||
switch authorizationStatus {
|
||||
case .authorizedWhenInUse, .authorizedAlways: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Cycle de vie
|
||||
|
||||
/// Démarre le suivi. **Doit être appelé pendant que l'app est au premier
|
||||
/// plan** — watchOS refuse de démarrer la localisation depuis l'arrière-plan.
|
||||
///
|
||||
/// - Parameter routeBuilder: fourni par `HKLiveWorkoutBuilder`, via
|
||||
/// `seriesBuilder(for: HKSeriesType.workoutRoute())`. `nil` pour un suivi
|
||||
/// d'affichage seul, sans écriture dans Santé.
|
||||
func start(activity: HKWorkoutActivityType, routeBuilder: HKWorkoutRouteBuilder?) {
|
||||
guard !isTracking else { return }
|
||||
// On n'exige pas que l'autorisation soit DÉJÀ accordée : quand elle est
|
||||
// encore en attente, le manager reste armé et CoreLocation délivre les
|
||||
// positions dès l'acceptation. Refuser ici priverait de trace toute
|
||||
// première séance, celle-là même où la demande apparaît.
|
||||
if authorizationStatus == .denied || authorizationStatus == .restricted {
|
||||
locationLog.error("localisation refusée par l'utilisateur : séance sans trace")
|
||||
return
|
||||
}
|
||||
|
||||
self.routeBuilder = routeBuilder
|
||||
filter = RouteFilter(maxPlausibleSpeed: Self.maxSpeed(for: activity))
|
||||
trackForMap.removeAll()
|
||||
pendingRoute.removeAll()
|
||||
distanceMeters = 0
|
||||
ascentMeters = 0
|
||||
descentMeters = 0
|
||||
isPaused = false
|
||||
|
||||
// Le défaut de watchOS est `kCLLocationAccuracyHundredMeters` — inutile
|
||||
// pour une trace. `BestForNavigation` est réservé par Apple aux
|
||||
// appareils branchés : trop gourmand pour une séance au poignet.
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
manager.distanceFilter = kCLDistanceFilterNone
|
||||
manager.activityType = Self.activityType(for: activity)
|
||||
|
||||
if Self.backgroundModeDeclared {
|
||||
manager.allowsBackgroundLocationUpdates = true
|
||||
} else {
|
||||
// Armer le drapeau sans la clé Info.plist tuerait l'app (piège 1).
|
||||
// Mieux vaut une trace qui s'arrête écran éteint qu'un crash.
|
||||
locationLog.error("UIBackgroundModes/location absent du plist : suivi limité au premier plan")
|
||||
}
|
||||
|
||||
manager.startUpdatingLocation()
|
||||
isTracking = true
|
||||
locationLog.info("suivi GPS démarré")
|
||||
}
|
||||
|
||||
/// Suspend l'enregistrement **sans couper le flux** (piège 3).
|
||||
func setPaused(_ paused: Bool) {
|
||||
guard isTracking else { return }
|
||||
isPaused = paused
|
||||
if paused {
|
||||
// Le point qui suivra la reprise serait à des centaines de mètres :
|
||||
// on repart d'une origine neuve pour ne pas tracer la pause.
|
||||
pendingRoute.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Coupe le flux GPS. À appeler dès la fin de séance ; la trace, elle, ne
|
||||
/// se clôt qu'après la sauvegarde du workout — voir `finishRoute(with:)`.
|
||||
func stopTracking() {
|
||||
guard isTracking else { return }
|
||||
manager.stopUpdatingLocation()
|
||||
isTracking = false
|
||||
locationLog.info("suivi GPS arrete")
|
||||
}
|
||||
|
||||
/// Clôt la trace et l'associe à la séance sauvegardée.
|
||||
///
|
||||
/// ⚠️ **L'ordre n'est pas négociable.** Apple : « After saving the workout,
|
||||
/// add any remaining locations to the route builder and call finishRoute. »
|
||||
/// Appeler `finishRoute` avant `finishWorkout` empêche l'association, et ne
|
||||
/// pas l'appeler du tout fait perdre **toute** la trace — le builder est
|
||||
/// invalidé à sa libération.
|
||||
///
|
||||
/// ⚠️ `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend
|
||||
/// pas l'objet **quand la montre est verrouillée** (confirmé par un
|
||||
/// ingénieur Apple). On sauvegarde alors la trace sans association plutôt
|
||||
/// que de la jeter — une route orpheline vaut mieux qu'une sortie sans
|
||||
/// parcours. Une route ne peut être associée qu'une fois, et jamais après
|
||||
/// coup.
|
||||
///
|
||||
/// Échoue aussi si aucune position n'a été insérée — cas normal d'une
|
||||
/// séance en salle, à ne pas remonter comme une anomalie.
|
||||
func finishRoute(with workout: HKWorkout?) async {
|
||||
await flushPendingRoute()
|
||||
guard let builder = routeBuilder else { return }
|
||||
self.routeBuilder = nil
|
||||
|
||||
guard filter.acceptedCount > 0 else {
|
||||
locationLog.info("aucune position retenue : pas de trace a clore")
|
||||
return
|
||||
}
|
||||
do {
|
||||
_ = try await builder.finishRoute(with: workout, metadata: nil)
|
||||
locationLog.info("trace close : \(self.filter.acceptedCount) points, \(Int(self.filter.distanceM)) m")
|
||||
} catch {
|
||||
locationLog.error("finishRoute a echoue : \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Écriture HealthKit
|
||||
|
||||
/// Écrit les points en attente. Par lots : une écriture par position
|
||||
/// gaspillerait le CPU que watchOS surveille (piège 4).
|
||||
private func flushPendingRoute() async {
|
||||
guard let builder = routeBuilder, !pendingRoute.isEmpty else { return }
|
||||
let batch = pendingRoute
|
||||
pendingRoute.removeAll()
|
||||
do {
|
||||
try await builder.insertRouteData(batch)
|
||||
} catch {
|
||||
// Les points sont perdus, pas la séance : on ne remet pas le lot
|
||||
// dans la file, sinon un échec persistant la ferait enfler
|
||||
// indéfiniment en mémoire.
|
||||
locationLog.error("insertRouteData a echoue (\(batch.count) points) : \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Réglages par activité
|
||||
|
||||
/// Plafond de vitesse plausible, en m/s. Le VTT électrique descend
|
||||
/// largement au-dessus d'un coureur : un seuil unique écarterait des points
|
||||
/// parfaitement valides.
|
||||
private static func maxSpeed(for activity: HKWorkoutActivityType) -> Double {
|
||||
switch activity {
|
||||
case .cycling: return 30 // 108 km/h, descente comprise
|
||||
case .running: return 12 // 43 km/h
|
||||
default: return 8 // marche, randonnée
|
||||
}
|
||||
}
|
||||
|
||||
private static func activityType(for activity: HKWorkoutActivityType) -> CLActivityType {
|
||||
switch activity {
|
||||
// `.fitness` fait désactiver le positionnement intérieur et peut
|
||||
// provoquer des pauses automatiques ; Apple recommande
|
||||
// `.otherNavigation` pour le vélo et le hors-route.
|
||||
case .cycling: return .otherNavigation
|
||||
default: return .fitness
|
||||
}
|
||||
}
|
||||
|
||||
/// Le plist déclare-t-il le mode de fond `location` ?
|
||||
///
|
||||
/// watchOS porte deux clés distinctes : `WKBackgroundModes` pour
|
||||
/// `workout-processing`, et `UIBackgroundModes` pour `location`. On accepte
|
||||
/// les deux, la documentation d'Apple n'étant pas univoque sur ce point.
|
||||
private static var backgroundModeDeclared: Bool {
|
||||
let keys = ["UIBackgroundModes", "WKBackgroundModes"]
|
||||
for key in keys {
|
||||
if let modes = Bundle.main.object(forInfoDictionaryKey: key) as? [String],
|
||||
modes.contains("location") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension LocationTracker: CLLocationManagerDelegate {
|
||||
nonisolated func locationManager(_ manager: CLLocationManager,
|
||||
didUpdateLocations locations: [CLLocation]) {
|
||||
let fixes = locations.map { GeoFix(location: $0) }
|
||||
let raw = locations
|
||||
Task { @MainActor in
|
||||
self.ingest(fixes: fixes, raw: raw)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager,
|
||||
didFailWithError error: Error) {
|
||||
locationLog.error("CoreLocation a echoue : \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.authorizationStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
private func ingest(fixes: [GeoFix], raw: [CLLocation]) {
|
||||
horizontalAccuracy = fixes.last?.horizontalAccuracy
|
||||
guard !isPaused else { return }
|
||||
|
||||
for (fix, location) in zip(fixes, raw) {
|
||||
guard case .accepted = filter.add(fix) else { continue }
|
||||
lastFix = fix
|
||||
pendingRoute.append(location)
|
||||
appendToMap(fix)
|
||||
}
|
||||
|
||||
distanceMeters = filter.distanceM
|
||||
ascentMeters = filter.ascentM
|
||||
descentMeters = filter.descentM
|
||||
|
||||
if pendingRoute.count >= routeFlushThreshold {
|
||||
Task { await flushPendingRoute() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute à la trace d'affichage, en décimant une fois le plafond atteint :
|
||||
/// on garde un point sur deux, ce qui divise la densité sans déformer le
|
||||
/// tracé ni faire enfler la mémoire sur une longue sortie.
|
||||
private func appendToMap(_ fix: GeoFix) {
|
||||
trackForMap.append(fix)
|
||||
if trackForMap.count > maxMapPoints {
|
||||
trackForMap = trackForMap.enumerated()
|
||||
.compactMap { $0.offset.isMultiple(of: 2) ? $0.element : nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GeoFix {
|
||||
/// Conversion depuis CoreLocation. Isolée ici pour que `RouteFilter` reste
|
||||
/// testable hors d'un Mac.
|
||||
init(location: CLLocation) {
|
||||
self.init(lat: location.coordinate.latitude,
|
||||
lon: location.coordinate.longitude,
|
||||
altitude: location.altitude,
|
||||
horizontalAccuracy: location.horizontalAccuracy,
|
||||
verticalAccuracy: location.verticalAccuracy,
|
||||
speed: location.speed,
|
||||
timestamp: location.timestamp)
|
||||
}
|
||||
}
|
||||
211
ios/App/CoachWatch/RouteFilter.swift
Normal file
211
ios/App/CoachWatch/RouteFilter.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
|
||||
/* Filtrage de la trace GPS et dénivelé, en Foundation pur.
|
||||
*
|
||||
* **Pourquoi ce fichier est séparé de CoreLocation.** `CLLocation` n'existe pas
|
||||
* sur Linux : tant que le filtrage vit dans la classe qui parle au GPS, il ne
|
||||
* se teste que sur un poignet, en marchant. Ici, `GeoFix` recopie les seuls
|
||||
* champs dont le filtrage a besoin ; `LocationTracker` fait la conversion et
|
||||
* reste, lui, non testable hors Xcode.
|
||||
*
|
||||
* **Ce que ce fichier NE fait PAS.** Il ne produit pas la distance officielle
|
||||
* de la séance. HealthKit collecte `distanceWalkingRunning` / `distanceCycling`
|
||||
* par fusion GPS + accéléromètre, et fait autorité — c'est déjà ce que lit
|
||||
* `WorkoutManager`. La distance calculée ici sert à la carte et au découpage
|
||||
* des étapes en distance, où l'on a besoin d'une valeur à chaque point plutôt
|
||||
* qu'à chaque échantillon HealthKit.
|
||||
*/
|
||||
|
||||
/// Un point GPS, réduit à ce dont le filtrage a besoin.
|
||||
public struct GeoFix: Equatable, Sendable {
|
||||
public var lat: Double
|
||||
public var lon: Double
|
||||
/// Mètres au-dessus du niveau de la mer.
|
||||
public var altitude: Double
|
||||
/// Rayon d'incertitude horizontale en mètres. **Négatif = fix invalide**,
|
||||
/// convention Apple.
|
||||
public var horizontalAccuracy: Double
|
||||
/// Incertitude verticale. Négatif = altitude non fiable.
|
||||
public var verticalAccuracy: Double
|
||||
/// Vitesse instantanée en m/s. **Négatif = inconnue**, convention Apple.
|
||||
public var speed: Double
|
||||
public var timestamp: Date
|
||||
|
||||
public init(lat: Double, lon: Double, altitude: Double = 0,
|
||||
horizontalAccuracy: Double = 5, verticalAccuracy: Double = 5,
|
||||
speed: Double = -1, timestamp: Date) {
|
||||
self.lat = lat
|
||||
self.lon = lon
|
||||
self.altitude = altitude
|
||||
self.horizontalAccuracy = horizontalAccuracy
|
||||
self.verticalAccuracy = verticalAccuracy
|
||||
self.speed = speed
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
/// Pourquoi un point a été écarté. Renvoyé plutôt que jeté en silence : sur le
|
||||
/// terrain, « la trace est trouée » sans plus d'explication ne se diagnostique
|
||||
/// pas — la répartition des motifs, elle, se lit.
|
||||
public enum FixRejection: String, Equatable, Sendable {
|
||||
case invalidAccuracy // horizontalAccuracy <= 0 : fix inutilisable
|
||||
case tooImprecise // au-delà du rayon toléré
|
||||
case outOfOrder // arrivé après un point plus récent
|
||||
case duplicate // même horodatage que le précédent
|
||||
case implausibleSpeed // saut impossible entre deux points
|
||||
}
|
||||
|
||||
public enum FixOutcome: Equatable, Sendable {
|
||||
case accepted
|
||||
case rejected(FixRejection)
|
||||
}
|
||||
|
||||
/// Accumule une trace propre : filtre les points douteux, cumule distance et
|
||||
/// dénivelé.
|
||||
///
|
||||
/// `struct` mutable et sans horloge interne, comme `IntervalEngine` : rejouer
|
||||
/// la même suite de points redonne le même résultat.
|
||||
public struct RouteFilter: Equatable, Sendable {
|
||||
|
||||
/// Rayon d'incertitude maximum toléré, en mètres.
|
||||
///
|
||||
/// 50 m est le seuil retenu par l'exemple de trace d'Apple. Plus strict
|
||||
/// perdrait les premières secondes sous couvert forestier ; plus laxiste
|
||||
/// laisserait entrer les points « sautés » qui gonflent la distance.
|
||||
public var maxHorizontalAccuracy: Double
|
||||
|
||||
/// Vitesse au-delà de laquelle un déplacement entre deux points est jugé
|
||||
/// impossible, en m/s. 30 m/s ≈ 108 km/h : au-dessus, c'est une
|
||||
/// téléportation GPS ou un trajet en voiture, pas une séance.
|
||||
public var maxPlausibleSpeed: Double
|
||||
|
||||
/// Écart vertical à franchir avant de compter une montée ou une descente.
|
||||
///
|
||||
/// ⚠️ Sans ce seuil, le bruit de l'altimètre s'accumule : quelques
|
||||
/// dizaines de centimètres d'oscillation à chaque point produisent des
|
||||
/// centaines de mètres de D+ sur une sortie plate. L'hystérésis ne compte
|
||||
/// que les changements de sens confirmés.
|
||||
///
|
||||
/// ⚠️ **Le seuil seul ne suffit pas** — mesuré en test : un bruit alternant
|
||||
/// de ±1,5 m, soit exactement l'amplitude du seuil, était compté à chaque
|
||||
/// alternance et produisait 297 m de D+ sur un parcours plat. C'est
|
||||
/// inhérent à tout seuil : une oscillation d'amplitude ≥ seuil passe. D'où
|
||||
/// le lissage préalable ci-dessous, qui est le vrai remède.
|
||||
public var elevationThreshold: Double
|
||||
|
||||
/// Nombre de points de la moyenne glissante appliquée à l'altitude avant
|
||||
/// l'hystérésis. `1` désactive le lissage.
|
||||
///
|
||||
/// Le bruit alternant s'annule dans la moyenne, une vraie pente la
|
||||
/// traverse. Contrepartie assumée : la moyenne **retarde** le signal d'une
|
||||
/// demi-fenêtre, ce qui ampute le D+ de quelques mètres en fin de trace.
|
||||
/// Sans conséquence ici — ce D+ sert l'affichage pendant la séance, alors
|
||||
/// que la valeur qui fait foi est le D+ barométrique d'Apple, relu après
|
||||
/// coup depuis les métadonnées HealthKit.
|
||||
public var elevationSmoothing: Int
|
||||
|
||||
public private(set) var distanceM: Double = 0
|
||||
public private(set) var ascentM: Double = 0
|
||||
public private(set) var descentM: Double = 0
|
||||
public private(set) var acceptedCount: Int = 0
|
||||
public private(set) var rejectedCount: Int = 0
|
||||
public private(set) var lastAccepted: GeoFix?
|
||||
|
||||
/// Altitude de référence de l'hystérésis.
|
||||
private var pivotAltitude: Double?
|
||||
/// Fenêtre glissante des altitudes retenues.
|
||||
private var altWindow: [Double] = []
|
||||
|
||||
public init(maxHorizontalAccuracy: Double = 50,
|
||||
maxPlausibleSpeed: Double = 30,
|
||||
elevationThreshold: Double = 3,
|
||||
elevationSmoothing: Int = 5) {
|
||||
self.maxHorizontalAccuracy = maxHorizontalAccuracy
|
||||
self.maxPlausibleSpeed = maxPlausibleSpeed
|
||||
self.elevationThreshold = elevationThreshold
|
||||
self.elevationSmoothing = max(1, elevationSmoothing)
|
||||
}
|
||||
|
||||
/// Soumet un point. Renvoie s'il a été retenu, et pourquoi sinon.
|
||||
@discardableResult
|
||||
public mutating func add(_ fix: GeoFix) -> FixOutcome {
|
||||
if fix.horizontalAccuracy <= 0 {
|
||||
rejectedCount += 1
|
||||
return .rejected(.invalidAccuracy)
|
||||
}
|
||||
if fix.horizontalAccuracy > maxHorizontalAccuracy {
|
||||
rejectedCount += 1
|
||||
return .rejected(.tooImprecise)
|
||||
}
|
||||
|
||||
if let previous = lastAccepted {
|
||||
let dt = fix.timestamp.timeIntervalSince(previous.timestamp)
|
||||
if dt == 0 {
|
||||
rejectedCount += 1
|
||||
return .rejected(.duplicate)
|
||||
}
|
||||
if dt < 0 {
|
||||
// CoreLocation peut livrer des points dans le désordre après
|
||||
// une reprise ; les intégrer ferait reculer la distance.
|
||||
rejectedCount += 1
|
||||
return .rejected(.outOfOrder)
|
||||
}
|
||||
let step = Self.distance(from: previous, to: fix)
|
||||
if step / dt > maxPlausibleSpeed {
|
||||
rejectedCount += 1
|
||||
return .rejected(.implausibleSpeed)
|
||||
}
|
||||
distanceM += step
|
||||
}
|
||||
|
||||
accumulateElevation(fix)
|
||||
lastAccepted = fix
|
||||
acceptedCount += 1
|
||||
return .accepted
|
||||
}
|
||||
|
||||
/// Distance orthodromique entre deux points, en mètres (formule de
|
||||
/// haversine). Volontairement 2D : c'est la convention de toutes les
|
||||
/// plateformes, et intégrer la pente gonflerait la distance par rapport à
|
||||
/// ce qu'affichent Santé, Strava ou la montre.
|
||||
public static func distance(from a: GeoFix, to b: GeoFix) -> Double {
|
||||
// Rayon moyen terrestre WGS84.
|
||||
let radius = 6_371_008.8
|
||||
let φ1 = a.lat * .pi / 180
|
||||
let φ2 = b.lat * .pi / 180
|
||||
let dφ = (b.lat - a.lat) * .pi / 180
|
||||
let dλ = (b.lon - a.lon) * .pi / 180
|
||||
let h = sin(dφ / 2) * sin(dφ / 2)
|
||||
+ cos(φ1) * cos(φ2) * sin(dλ / 2) * sin(dλ / 2)
|
||||
return 2 * radius * asin(min(1, sqrt(h)))
|
||||
}
|
||||
|
||||
/// Hystérésis du dénivelé : on ne valide un sens qu'après avoir franchi le
|
||||
/// seuil depuis le dernier point de bascule.
|
||||
private mutating func accumulateElevation(_ fix: GeoFix) {
|
||||
// Une altitude sans incertitude fiable ne doit pas nourrir le D+ : le
|
||||
// GPS seul dérive de plusieurs mètres, l'altimètre barométrique non.
|
||||
guard fix.verticalAccuracy > 0 else { return }
|
||||
|
||||
// Lissage d'abord : le bruit alternant s'annule dans la moyenne. On
|
||||
// n'attend pas que la fenêtre soit pleine — sinon les premiers points
|
||||
// seraient perdus, et avec eux le début de la première montée.
|
||||
altWindow.append(fix.altitude)
|
||||
if altWindow.count > elevationSmoothing { altWindow.removeFirst() }
|
||||
let smoothed = altWindow.reduce(0, +) / Double(altWindow.count)
|
||||
|
||||
guard let pivot = pivotAltitude else {
|
||||
pivotAltitude = smoothed
|
||||
return
|
||||
}
|
||||
let delta = smoothed - pivot
|
||||
if delta >= elevationThreshold {
|
||||
ascentM += delta
|
||||
pivotAltitude = smoothed
|
||||
} else if delta <= -elevationThreshold {
|
||||
descentM += -delta
|
||||
pivotAltitude = smoothed
|
||||
}
|
||||
// Entre les deux : oscillation, on ne bouge pas le pivot.
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,18 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
// ne la produit pas, les stats restent vides : `ingest` retombe alors
|
||||
// sur la vitesse moyenne dérivée de la distance.
|
||||
let speedType = Self.speedType(for: activityType)
|
||||
let read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
|
||||
let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
|
||||
// La trace exige d'être demandée en lecture ET en écriture — Apple :
|
||||
// « you must request permission to read and share both HKWorkout and
|
||||
// HKWorkoutRoute samples ». Sans elle, `finishRoute` échoue et la
|
||||
// sortie n'a pas de parcours.
|
||||
let route = HKSeriesType.workoutRoute()
|
||||
let outdoor = (locationType == .outdoor)
|
||||
var read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
|
||||
var share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
|
||||
if outdoor {
|
||||
read.insert(route)
|
||||
share.insert(route)
|
||||
}
|
||||
|
||||
do {
|
||||
try await healthStore.requestAuthorization(toShare: share, read: read)
|
||||
@@ -66,6 +76,12 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Demandée ici, donc au premier plan : watchOS n'affiche pas la feuille
|
||||
// d'autorisation depuis l'arrière-plan. Volontairement « quand
|
||||
// l'app est active » et jamais « toujours » — sur watchOS ce dernier
|
||||
// mène à un parcours d'autorisation qui boucle.
|
||||
if outdoor { LocationTracker.shared.requestAuthorization() }
|
||||
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = activityType
|
||||
config.locationType = locationType
|
||||
@@ -87,6 +103,19 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
isRunning = true
|
||||
statusMessage = nil
|
||||
workoutLog.info("beginCollection OK, isRunning=true")
|
||||
|
||||
// GPS uniquement en extérieur : en salle il ne ferait que vider la
|
||||
// batterie pour une trace immobile.
|
||||
if outdoor {
|
||||
// Apple déconseille d'instancier un HKWorkoutRouteBuilder
|
||||
// directement : celui du builder de séance associe la trace.
|
||||
let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute())
|
||||
as? HKWorkoutRouteBuilder
|
||||
if routeBuilder == nil {
|
||||
workoutLog.error("seriesBuilder n'a pas rendu un HKWorkoutRouteBuilder")
|
||||
}
|
||||
LocationTracker.shared.start(activity: activityType, routeBuilder: routeBuilder)
|
||||
}
|
||||
} catch {
|
||||
self.session = nil
|
||||
self.builder = nil
|
||||
@@ -264,14 +293,26 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
||||
let paused = (toState == .paused)
|
||||
Task { @MainActor in
|
||||
self.isPaused = paused
|
||||
// Le flux GPS reste ouvert pendant la pause : watchOS interdit
|
||||
// de redémarrer la localisation depuis l'arrière-plan, donc on
|
||||
// ignore les points au lieu de couper le manager.
|
||||
LocationTracker.shared.setPaused(paused)
|
||||
ConnectivityManager.shared.sendPaused(paused)
|
||||
}
|
||||
}
|
||||
guard toState == .ended else { return }
|
||||
let builder = workoutSession.associatedWorkoutBuilder()
|
||||
Task {
|
||||
Task { @MainActor in
|
||||
// Ordre imposé par Apple : couper le GPS, clore la collecte,
|
||||
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
|
||||
// l'associant au workout obtenu.
|
||||
LocationTracker.shared.stopTracking()
|
||||
try? await builder.endCollection(at: date)
|
||||
_ = try? await builder.finishWorkout()
|
||||
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
|
||||
// verrouillée : ce n'est pas un échec. On poursuit avec `nil`, la
|
||||
// trace sera sauvegardée sans association plutôt que perdue.
|
||||
let workout = try? await builder.finishWorkout()
|
||||
await LocationTracker.shared.finishRoute(with: workout)
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.isRunning = false
|
||||
|
||||
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