Files
coach-ios/ios/App/CoachWatch/IntervalEngine.swift
Sylvain Bettinelli 891a8bddce IntervalEngine : le commentaire sur les pauses disait l'inverse d'Apple
L'en-tête affirmait que `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les
pauses », et en tirait que le moteur n'avait rien à savoir de la pause ni de
l'auto-pause.

La doc Apple dit le contraire, vérifié à la source le 2026-08-20 :
« The elapsed time for the workout based on the builder's current contents,
including pauses. »

Conséquence si on câblait le moteur dessus telle quelle : une pause de 5 min
ferait avancer le déroulé de 5 min d'effort — un fractionné mis en pause pour
traverser une route se déroulerait à l'arrêt.

La propriété qui exclut réellement les pauses est
`HKWorkoutBuilder.elapsedTime(at:)` : « The duration of a workout doesn't
include intervals between pause and resume events. » Les deux textes d'Apple
se contredisent frontalement ; le choix de la source de temps reste à faire
avant le câblage.

Le moteur lui-même reste correct : il n'intègre que ce qu'on lui pousse.
Aucun comportement modifié, 57 tests Swift toujours verts.

COWORK gagne l'analyse complète du câblage (3 couches, le serveur sait déjà
produire les étapes via _blocks_from_session) pour reprise à froid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:52:21 +00:00

384 lines
15 KiB
Swift
Raw Permalink 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 poussé par l'appelant ;
*
* **CE COMMENTAIRE AFFIRMAIT LE CONTRAIRE DE SA SOURCE** (relevé le
* 2026-08-20, avant tout câblage). Il disait que
* `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses », donc que
* le moteur n'avait rien à savoir de la pause. La doc Apple dit
* l'inverse, mot pour mot : « The elapsed time for the workout based on
* the builder's current contents, **including pauses**. »
* (developer.apple.com/documentation/healthkit/hkliveworkoutbuilder/elapsedtime)
*
* Conséquence si on câble le moteur sur cette propriété telle quelle :
* une pause de 5 min ferait avancer le déroulé de 5 min d'effort. Un
* fractionné mis en pause pour traverser une route se déroulerait tout
* seul, à l'arrêt.
*
* La propriété qui exclut réellement les pauses est
* `HKWorkoutBuilder.elapsedTime(at:)` « The duration of a workout
* doesn't include intervals between pause and resume events. » Ce n'est
* PAS la même API, et les deux textes d'Apple se contredisent
* frontalement sur ce point : à trancher avant le câblage.
*
* Le moteur, lui, reste correct : il ne fait qu'intégrer ce qu'on lui
* pousse. C'est l'appelant qui devra fournir un temps réellement actif ;
*
* - il ignore donc l'auto-pause de la montre, à condition que la source de
* temps ci-dessus soit correcte ;
* - 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
}
}
}