Files
coach-ios/ios/App/CoachWatch/IntervalEngine.swift
Sylvain Bettinelli f2fa3cccb0 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>
2026-08-20 07:50:27 +00:00

361 lines
14 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}