Marche 6 minutes, et la vraie recuperation cardiaque
Deux ajouts lies par le meme constat : ce qu'on peut mesurer honnetement. MARCHE 6 MINUTES — troisieme protocole de la vue de test. Sous-maximal, sans risque, c'est la reference en cardiologie et le seul des trois qui ne demande aucun effort maximal. Type d'activite HealthKit .walking (la distance et la vitesse ne vivent pas sur le meme quantity type que la course). RECUPERATION CARDIAQUE — le pont collecte desormais heartRateRecoveryOneMinute, la valeur calculee par la Watch elle-meme. Pourquoi ne pas la deriver de la serie de FC post-seance, deja en base : mesure faite sur les 19 seances exploitables de la prod, la baisse mediane a 1 minute ressort a 2 bpm, avec 7 valeurs nulles ou negatives (min -10, max 15). Ce n'est pas physiologique : la fin de seance HealthKit ne coincide pas avec l'arret de l'effort. Une courbe batie la-dessus monterait et descendrait sans rien mesurer. ⚠️ Le sample n'est PAS rattache au workout : predicateForObjects(from:) ne le rendrait jamais. Apple l'ecrit comme un echantillon independant horodate juste apres la seance, d'ou une requete par fenetre temporelle. ⚠️ NON COMPILE. La courbe de tendance cote web attend ce build : sans lui, le champ reste absent et il n'y a rien a tracer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,14 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
for id in Self.runningMetricIdentifiers {
|
for id in Self.runningMetricIdentifiers {
|
||||||
types.insert(HKQuantityType(id))
|
types.insert(HKQuantityType(id))
|
||||||
}
|
}
|
||||||
|
// Récupération cardiaque à 1 minute, calculée par la Watch elle-même
|
||||||
|
// (iOS 16+). La dériver de la série de FC post-séance donne un résultat
|
||||||
|
// inexploitable : mesuré sur 19 séances réelles, la baisse médiane
|
||||||
|
// ressort à 2 bpm et 7 valeurs sont nulles ou négatives, parce que la
|
||||||
|
// fin de séance HealthKit ne coïncide pas avec l'arrêt de l'effort.
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
types.insert(HKQuantityType(.heartRateRecoveryOneMinute))
|
||||||
|
}
|
||||||
if let steps = HKObjectType.quantityType(forIdentifier: .stepCount) { types.insert(steps) }
|
if let steps = HKObjectType.quantityType(forIdentifier: .stepCount) { types.insert(steps) }
|
||||||
// Share workoutType requis pour deleteWorkout (HKHealthStore.delete
|
// Share workoutType requis pour deleteWorkout (HKHealthStore.delete
|
||||||
// exige share auth). Read-only pour les autres types.
|
// exige share auth). Read-only pour les autres types.
|
||||||
@@ -454,6 +462,31 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}
|
}
|
||||||
self.store.execute(q)
|
self.store.execute(q)
|
||||||
}
|
}
|
||||||
|
// ⚠️ `heartRateRecoveryOneMinute` n'est PAS rattaché au workout :
|
||||||
|
// `predicateForObjects(from:)` ne le retournerait jamais. Apple l'écrit
|
||||||
|
// comme un échantillon indépendant, horodaté juste après la séance —
|
||||||
|
// d'où une requête par fenêtre temporelle.
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
let hrrType = HKQuantityType(.heartRateRecoveryOneMinute)
|
||||||
|
let window = HKQuery.predicateForSamples(
|
||||||
|
withStart: workout.endDate,
|
||||||
|
end: workout.endDate.addingTimeInterval(600),
|
||||||
|
options: [.strictStartDate]
|
||||||
|
)
|
||||||
|
group.enter()
|
||||||
|
let hrrQuery = HKSampleQuery(sampleType: hrrType, predicate: window,
|
||||||
|
limit: 1, sortDescriptors: nil) { _, samples, _ in
|
||||||
|
defer { group.leave() }
|
||||||
|
guard let s = (samples?.first as? HKQuantitySample) else { return }
|
||||||
|
let bpm = s.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))
|
||||||
|
guard bpm > 0 else { return }
|
||||||
|
lock.lock()
|
||||||
|
out["hr_recovery_1min"] = bpm.rounded()
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
self.store.execute(hrrQuery)
|
||||||
|
}
|
||||||
|
|
||||||
group.notify(queue: .global()) { completion(out) }
|
group.notify(queue: .global()) { completion(out) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import WatchKit
|
|||||||
struct VmaTestView: View {
|
struct VmaTestView: View {
|
||||||
enum Protocole: String, CaseIterable {
|
enum Protocole: String, CaseIterable {
|
||||||
case stages = "Paliers"
|
case stages = "Paliers"
|
||||||
case sixMin = "6 minutes"
|
case sixMin = "6 min course"
|
||||||
|
// Test de marche 6 minutes : sous-maximal, référence en cardiologie.
|
||||||
|
// Le seul des trois qui ne demande aucun effort maximal.
|
||||||
|
case walk6 = "6 min marche"
|
||||||
}
|
}
|
||||||
|
|
||||||
@StateObject private var workout = WorkoutManager.shared
|
@StateObject private var workout = WorkoutManager.shared
|
||||||
@@ -80,9 +83,7 @@ struct VmaTestView: View {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.frame(height: 60)
|
.frame(height: 60)
|
||||||
|
|
||||||
Text(proto == .stages
|
Text(protocolHelp)
|
||||||
? "Paliers d'une minute, de 8 km/h par pas de 0,5. La montre vibre à chaque changement. Arrête-toi quand tu ne tiens plus l'allure."
|
|
||||||
: "Six minutes à allure maximale régulière, sur terrain plat. La distance parcourue donne la VMA.")
|
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
@@ -94,6 +95,17 @@ struct VmaTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var protocolHelp: String {
|
||||||
|
switch proto {
|
||||||
|
case .stages:
|
||||||
|
return "Paliers d'une minute, de 8 km/h par pas de 0,5. La montre vibre à chaque changement. Arrête-toi quand tu ne tiens plus l'allure."
|
||||||
|
case .sixMin:
|
||||||
|
return "Six minutes à allure maximale régulière, sur terrain plat. La distance parcourue donne la VMA."
|
||||||
|
case .walk6:
|
||||||
|
return "Marche six minutes à ton pas le plus rapide, sans courir. Sous-maximal : c'est le test de référence en cardiologie."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Pendant le test
|
// MARK: - Pendant le test
|
||||||
|
|
||||||
private var runningView: some View {
|
private var runningView: some View {
|
||||||
@@ -162,7 +174,8 @@ struct VmaTestView: View {
|
|||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text(String(format: "%.0f m", workout.distanceMeters)).font(.title3)
|
Text(String(format: "%.0f m", workout.distanceMeters)).font(.title3)
|
||||||
Text("en 6 minutes").font(.caption2).foregroundStyle(.secondary)
|
Text(proto == .walk6 ? "en 6 minutes de marche" : "en 6 minutes")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxHr > 0 {
|
if maxHr > 0 {
|
||||||
@@ -196,7 +209,12 @@ struct VmaTestView: View {
|
|||||||
finished = false
|
finished = false
|
||||||
started = true
|
started = true
|
||||||
// startWorkout est async (@MainActor) : même appel que ContentView.
|
// startWorkout est async (@MainActor) : même appel que ContentView.
|
||||||
Task { await workout.startWorkout(activityType: .running, locationType: .outdoor) }
|
// Marche et course sont deux types HealthKit distincts : la distance
|
||||||
|
// et la vitesse ne sont pas collectées sur le même quantity type.
|
||||||
|
Task {
|
||||||
|
await workout.startWorkout(activityType: proto == .walk6 ? .walking : .running,
|
||||||
|
locationType: .outdoor)
|
||||||
|
}
|
||||||
WKInterfaceDevice.current().play(.start)
|
WKInterfaceDevice.current().play(.start)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +246,7 @@ struct VmaTestView: View {
|
|||||||
sendError = nil
|
sendError = nil
|
||||||
var payload: [String: Any] = [
|
var payload: [String: Any] = [
|
||||||
"vmaTest": true,
|
"vmaTest": true,
|
||||||
"protocol": proto == .stages ? "stages" : "cooper6",
|
"protocol": protocolKey,
|
||||||
"maxHr": Int(maxHr.rounded()),
|
"maxHr": Int(maxHr.rounded()),
|
||||||
]
|
]
|
||||||
if proto == .stages {
|
if proto == .stages {
|
||||||
@@ -248,6 +266,14 @@ struct VmaTestView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var protocolKey: String {
|
||||||
|
switch proto {
|
||||||
|
case .stages: return "stages"
|
||||||
|
case .sixMin: return "cooper6"
|
||||||
|
case .walk6: return "walk6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func timeString(_ s: Double) -> String {
|
private func timeString(_ s: Double) -> String {
|
||||||
let t = Int(s.rounded())
|
let t = Int(s.rounded())
|
||||||
return String(format: "%d:%02d", t / 60, t % 60)
|
return String(format: "%d:%02d", t / 60, t % 60)
|
||||||
|
|||||||
Reference in New Issue
Block a user