Le moteur d'intervalles, pour guider une séance dans notre app

WorkoutKit ne sait pas exécuter une séance structurée dans une app tierce :
son seul point d'exécution public est `WorkoutPlan.openInWorkoutApp()`, qui
ouvre l'app Exercice d'Apple. Guider les intervalles du plan coach au poignet
impose donc d'écrire la machine à états nous-mêmes.

`IntervalEngine` est volontairement pur — aucun HealthKit, aucun timer, aucune
horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et de la
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
qui exclut déjà les pauses : le moteur n'a rien à savoir de la pause ni de
l'auto-pause. Et comme il est une fonction de la suite des ticks, rejouer
celle-ci redonne le même déroulé — ce qui rend la reprise après crash triviale.

Le point délicat est la dérive. Quand un tick arrive en retard (app suspendue
poignet baissé, collecte irrégulière), l'étape suivante démarre à la frontière
THÉORIQUE de la précédente, jamais à l'instant du tick. Sinon chaque
répétition offre quelques secondes de rab et l'erreur s'accumule : sur un
9×(1'/1'), le décalage final se compte en dizaines de secondes. Pour la même
raison `update` boucle au lieu de tester une fois — un tick manqué peut
franchir plusieurs étapes courtes d'un coup.

17 tests, dont 5 vérifiés rouges en neutralisant les frontières théoriques.
Rien n'est encore câblé : le fichier n'est pas membre de la cible CoachWatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-20 07:50:27 +00:00
parent 852e12526a
commit f2fa3cccb0
3 changed files with 628 additions and 0 deletions

View File

@@ -0,0 +1,360 @@
import Foundation
/* Déroulé d'une séance structurée au poignet.
*
* **Pourquoi ce fichier existe.** WorkoutKit ne sait pas exécuter une séance
* dans une app tierce : son seul point d'exécution public est
* `WorkoutPlan.openInWorkoutApp()`, qui ouvre l'app *Exercice* d'Apple. Pour
* guider les intervalles du plan coach dans NOTRE app, la machine à états doit
* être écrite ici.
*
* Le moteur est **volontairement pur** aucun import HealthKit, aucun timer,
* aucune horloge interne. Il ne fait que répondre à la question « où en
* sommes-nous ? » à partir du temps et de la distance que l'appelant lui
* pousse. Trois conséquences :
*
* - il se teste intégralement sur Linux (cf. `tests-linux/`), là où
* `WorkoutManager` ne le peut pas ;
* - le temps de référence est `HKLiveWorkoutBuilder.elapsedTime`, qui
* **exclut déjà les pauses** : le moteur n'a donc rien à savoir de la
* pause, ni de l'auto-pause de la montre ;
* - il est rejouable : réinjecter la même suite de ticks redonne le même
* déroulé, ce qui rend la reprise après crash triviale
* (`handleActiveWorkoutRecovery`).
*/
// MARK: - Modèle
/// Objectif de fin d'une étape.
///
/// `open` décrit une étape qui ne se termine que sur ordre de l'utilisateur
/// l'échauffement libre « pars quand tu es prêt », ou le retour au calme.
public enum StepGoal: Equatable, Sendable {
case time(TimeInterval)
case distance(Double) // mètres
case open
}
/// Une étape du plan, telle qu'elle arrive de l'iPhone.
///
/// Les bornes de FC sont transportées **en bpm ET en numéro de zone**. Les deux
/// sont nécessaires : la zone sert à l'affichage (« Z2 »), les bpm sont la seule
/// cible exécutable, et elles proviennent de `app.current_zones()` zones
/// Karvonen sous bêtabloquant, jamais un % de FCmax générique.
public struct CoachPlanStep: Codable, Equatable, Sendable {
public enum Kind: String, Codable, Sendable {
case warmup, work, recovery, cooldown
}
public var kind: Kind
public var durationSec: Double?
public var distanceM: Double?
public var hrZone: Int?
public var hrMinBpm: Double?
public var hrMaxBpm: Double?
public var label: String?
enum CodingKeys: String, CodingKey {
case kind
case durationSec = "duration_sec"
case distanceM = "distance_m"
case hrZone = "hr_zone"
case hrMinBpm = "hr_bpm_min"
case hrMaxBpm = "hr_bpm_max"
case label
}
public init(kind: Kind,
durationSec: Double? = nil,
distanceM: Double? = nil,
hrZone: Int? = nil,
hrMinBpm: Double? = nil,
hrMaxBpm: Double? = nil,
label: String? = nil) {
self.kind = kind
self.durationSec = durationSec
self.distanceM = distanceM
self.hrZone = hrZone
self.hrMinBpm = hrMinBpm
self.hrMaxBpm = hrMaxBpm
self.label = label
}
/// La durée prime sur la distance quand les deux sont fournies : le plan
/// coach est écrit en temps, la distance n'est qu'une alternative.
public var goal: StepGoal {
if let d = durationSec, d > 0 { return .time(d) }
if let m = distanceM, m > 0 { return .distance(m) }
return .open
}
/// Libellé affichable, avec repli sur le type d'étape. Le plan porte les
/// consignes réelles dans `label` (« cadence 170+, foulée courte ») : c'est
/// cette intention qu'on veut au poignet, pas un « Course » générique.
public var displayLabel: String {
if let l = label?.trimmingCharacters(in: .whitespacesAndNewlines), !l.isEmpty {
return l
}
switch kind {
case .warmup: return "Échauffement"
case .work: return "Effort"
case .recovery: return "Récupération"
case .cooldown: return "Retour au calme"
}
}
}
/// La séance du jour, poussée par l'iPhone.
public struct CoachSessionPlan: Codable, Equatable, Sendable {
public var date: String
public var sport: String
public var title: String?
public var steps: [CoachPlanStep]
enum CodingKeys: String, CodingKey {
case date, sport, title, steps
}
public init(date: String, sport: String, title: String? = nil, steps: [CoachPlanStep]) {
self.date = date
self.sport = sport
self.title = title
self.steps = steps
}
/// Durée totale prévue. `nil` dès qu'une étape est ouverte ou en distance :
/// mieux vaut ne rien annoncer qu'annoncer un total faux.
public var plannedDurationSec: TimeInterval? {
var total: TimeInterval = 0
for step in steps {
guard case .time(let d) = step.goal else { return nil }
total += d
}
return total
}
}
// MARK: - Moteur
/// Ce que le moteur signale à l'appelant entre deux ticks.
///
/// Les événements sont **rendus, jamais joués** par le moteur : c'est
/// `WorkoutManager` qui décide d'un haptique ou d'un `HKWorkoutEvent`. Cette
/// séparation est ce qui garde le moteur testable.
public enum IntervalEvent: Equatable, Sendable {
case stepStarted(index: Int)
case stepFinished(index: Int)
case planFinished
/// Émis une seule fois par étape, à mi-parcours d'une étape mesurable.
case halfway(index: Int)
/// Vrai si l'événement fait changer d'étape.
///
/// L'annonce de mi-parcours voyage dans le même flux que les transitions
/// pratique pour l'appelant, qui n'a qu'une boucle à écrire mais elle ne
/// déplace rien. Sans ce distinguo, « rien n'a bougé » et « aucun événement »
/// se confondent, et c'est exactement l'erreur que les premiers tests de ce
/// fichier ont commise.
public var isTransition: Bool {
switch self {
case .stepStarted, .stepFinished, .planFinished: return true
case .halfway: return false
}
}
}
extension Array where Element == IntervalEvent {
/// Les seuls événements qui déplacent le curseur de séance.
public var transitions: [IntervalEvent] { filter(\.isTransition) }
}
/// Instantané de progression, destiné à l'affichage.
public struct IntervalProgress: Equatable, Sendable {
public var stepIndex: Int
public var step: CoachPlanStep?
public var nextStep: CoachPlanStep?
/// Temps passé dans l'étape courante (secondes actives).
public var elapsedInStep: TimeInterval
/// Temps restant, `nil` pour une étape ouverte ou en distance.
public var remainingInStep: TimeInterval?
/// Distance restante en mètres, `nil` si l'étape n'est pas en distance.
public var remainingDistanceM: Double?
/// 01, `nil` pour une étape ouverte.
public var fraction: Double?
public var isFinished: Bool
}
/// Machine à états du déroulé. `struct` mutable : pas d'état caché, copiable,
/// donc inspectable dans les tests.
public struct IntervalEngine: Equatable, Sendable {
public let steps: [CoachPlanStep]
public private(set) var index: Int = 0
public private(set) var isFinished: Bool = false
/// Origine de l'étape courante, dans le référentiel du builder.
private var stepStartElapsed: TimeInterval = 0
private var stepStartDistance: Double = 0
private var lastElapsed: TimeInterval = 0
private var lastDistance: Double = 0
private var halfwayAnnounced = false
public init(steps: [CoachPlanStep]) {
self.steps = steps
self.isFinished = steps.isEmpty
}
public init(plan: CoachSessionPlan) {
self.init(steps: plan.steps)
}
public var currentStep: CoachPlanStep? {
guard !isFinished, steps.indices.contains(index) else { return nil }
return steps[index]
}
public var nextStep: CoachPlanStep? {
let n = index + 1
guard !isFinished, steps.indices.contains(n) else { return nil }
return steps[n]
}
/// Pousse l'état de la séance et récupère les transitions franchies.
///
/// `elapsed` est le temps **actif** (`HKLiveWorkoutBuilder.elapsedTime`),
/// `distance` la distance cumulée en mètres depuis le départ.
///
/// Une boucle, pas un `if` : un tick peut arriver en retard l'app est
/// suspendue poignet baissé, la collecte HealthKit est irrégulière et
/// franchir **plusieurs** étapes courtes d'un coup. Traiter une seule
/// transition par tick ferait dériver le déroulé sans que rien ne le dise.
@discardableResult
public mutating func update(elapsed: TimeInterval, distance: Double) -> [IntervalEvent] {
guard !isFinished else { return [] }
// Le temps actif ne recule pas ; la distance non plus. Un recul signale
// un appelant fautif : on borne plutôt que de produire des durées
// négatives qui se propageraient dans l'affichage.
lastElapsed = max(elapsed, lastElapsed)
lastDistance = max(distance, lastDistance)
var events: [IntervalEvent] = []
if !hasStarted {
hasStarted = true
events.append(.stepStarted(index: 0))
}
while !isFinished, let step = currentStep, isComplete(step) {
events.append(.stepFinished(index: index))
advanceIndex(at: lastElapsed, distance: lastDistance)
if isFinished {
events.append(.planFinished)
} else {
events.append(.stepStarted(index: index))
}
}
if !halfwayAnnounced, let f = progress().fraction, f >= 0.5, !isFinished {
halfwayAnnounced = true
events.append(.halfway(index: index))
}
return events
}
/// Termine l'étape courante sur ordre de l'utilisateur (bouton « suivant »,
/// ou fin d'une étape ouverte).
@discardableResult
public mutating func advanceManually() -> [IntervalEvent] {
guard !isFinished, currentStep != nil else { return [] }
var events: [IntervalEvent] = [.stepFinished(index: index)]
advanceIndex(at: lastElapsed, distance: lastDistance)
events.append(isFinished ? .planFinished : .stepStarted(index: index))
return events
}
public func progress() -> IntervalProgress {
guard !isFinished, let step = currentStep else {
return IntervalProgress(stepIndex: index, step: nil, nextStep: nil,
elapsedInStep: 0, remainingInStep: nil,
remainingDistanceM: nil, fraction: nil,
isFinished: true)
}
let inStep = max(0, lastElapsed - stepStartElapsed)
var remaining: TimeInterval?
var remainingDistance: Double?
var fraction: Double?
switch step.goal {
case .time(let target):
remaining = max(0, target - inStep)
fraction = target > 0 ? min(1, inStep / target) : nil
case .distance(let target):
let done = max(0, lastDistance - stepStartDistance)
remainingDistance = max(0, target - done)
fraction = target > 0 ? min(1, done / target) : nil
case .open:
break
}
return IntervalProgress(stepIndex: index,
step: step,
nextStep: nextStep,
elapsedInStep: inStep,
remainingInStep: remaining,
remainingDistanceM: remainingDistance,
fraction: fraction,
isFinished: false)
}
// MARK: Privé
private var hasStarted = false
private func isComplete(_ step: CoachPlanStep) -> Bool {
switch step.goal {
case .time(let target):
return (lastElapsed - stepStartElapsed) >= target
case .distance(let target):
return (lastDistance - stepStartDistance) >= target
case .open:
// Une étape ouverte ne se termine JAMAIS toute seule : seul
// `advanceManually()` la clôt. Sinon la séance défilerait d'un coup.
return false
}
}
/// Fait démarrer l'étape suivante à la frontière **théorique** de l'étape
/// qui vient de finir, pas à l'instant du tick.
///
/// C'est la subtilité de tout ce fichier. Si un tick arrive 3 s après la
/// fin d'un intervalle d'une minute, caler le départ de la suivante sur ce
/// tick lui offre 3 s de rab et l'erreur **s'accumule** à chaque
/// répétition. Sur un 9×(1'/1'), le décalage final se compte en dizaines de
/// secondes.
private mutating func advanceIndex(at elapsed: TimeInterval, distance: Double) {
if let step = currentStep {
switch step.goal {
case .time(let target):
stepStartElapsed += target
stepStartDistance = distance
case .distance(let target):
stepStartDistance += target
stepStartElapsed = elapsed
case .open:
stepStartElapsed = elapsed
stepStartDistance = distance
}
}
// Une étape ouverte ou terminée à la main ne peut pas laisser l'origine
// derrière le tick courant.
stepStartElapsed = min(stepStartElapsed, elapsed)
stepStartDistance = min(stepStartDistance, distance)
index += 1
halfwayAnnounced = false
if index >= steps.count {
isFinished = true
}
}
}