Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import WhoopProtocol
import WhoopStore

// WorkoutDetector.swift — retroactive workout detection from the 1 Hz store.
//
Expand Down Expand Up @@ -368,6 +369,22 @@ public enum WorkoutDetector {
}
return sessions
}

/// #510: backfill ONLY the avgHr/maxHr/energyKcal/strain fields `real` doesn't already have, from a
/// detected bout's own computed values — never touching a field that's already present, whether
/// typed by the user, imported, or filled by an earlier pass. `real` unchanged (`==`) means it
/// already had everything, so the caller can tell whether a write is actually needed. Kotlin twin
/// IntelligenceEngine.backfillWorkoutFromDetectedBout.
public static func backfillWorkout(_ real: WorkoutRow, avgBpm: Int, peakHR: Int, caloriesKcal: Double?, strain: Double?) -> WorkoutRow {
WorkoutRow(
startTs: real.startTs, endTs: real.endTs, sport: real.sport, source: real.source,
durationS: real.durationS,
energyKcal: real.energyKcal ?? caloriesKcal,
avgHr: real.avgHr ?? avgBpm,
maxHr: real.maxHr ?? peakHR,
strain: real.strain ?? strain,
distanceM: real.distanceM, zonesJSON: real.zonesJSON, notes: real.notes)
}
}

// MARK: - Calories (calories.py)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import XCTest
@testable import StrandAnalytics
import WhoopStore

/// #510: a detected bout's own computed avgHR/calories/maxHR/strain must fill ONLY the fields a
/// colliding real (manual/imported) workout is missing — never override anything already present.
/// Regression for the report where manually-entered workouts silently showed no HR/calories even
/// though the detector had already computed a valid average for that exact window.
final class WorkoutDetectorBackfillTests: XCTestCase {

private func manualRow(avgHr: Int? = nil, maxHr: Int? = nil, energyKcal: Double? = nil, strain: Double? = nil) -> WorkoutRow {
WorkoutRow(startTs: 1_000, endTs: 1_600, sport: "Running", source: "manual",
durationS: 600, energyKcal: energyKcal, avgHr: avgHr, maxHr: maxHr,
strain: strain, distanceM: nil, zonesJSON: nil, notes: nil)
}

func testFillsAllMissingFields() {
let real = manualRow()
let filled = WorkoutDetector.backfillWorkout(real, avgBpm: 150, peakHR: 170, caloriesKcal: 80.0, strain: 9.5)
XCTAssertEqual(filled.avgHr, 150)
XCTAssertEqual(filled.maxHr, 170)
XCTAssertEqual(filled.energyKcal, 80.0)
XCTAssertEqual(filled.strain, 9.5)
// The rest of the row is carried over untouched.
XCTAssertEqual(filled.startTs, real.startTs)
XCTAssertEqual(filled.sport, real.sport)
XCTAssertEqual(filled.source, real.source)
}

func testNeverOverridesFieldsAlreadyPresent() {
// The user typed an Avg HR and calories by hand; only maxHr/strain are missing.
let real = manualRow(avgHr: 140, maxHr: nil, energyKcal: 50.0, strain: nil)
let filled = WorkoutDetector.backfillWorkout(real, avgBpm: 150, peakHR: 170, caloriesKcal: 80.0, strain: 9.5)
XCTAssertEqual(filled.avgHr, 140, "a user-typed value must never be overwritten")
XCTAssertEqual(filled.energyKcal, 50.0, "a user-typed value must never be overwritten")
XCTAssertEqual(filled.maxHr, 170, "a missing field must still be filled")
XCTAssertEqual(filled.strain, 9.5, "a missing field must still be filled")
}

func testRowWithEverythingAlreadyPresentIsUnchanged() {
let real = manualRow(avgHr: 140, maxHr: 160, energyKcal: 50.0, strain: 8.0)
let filled = WorkoutDetector.backfillWorkout(real, avgBpm: 150, peakHR: 170, caloriesKcal: 80.0, strain: 9.5)
XCTAssertEqual(filled, real, "nothing to fill -> byte-identical row, so the caller can skip the write")
}
}
31 changes: 30 additions & 1 deletion Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,10 @@ final class IntelligenceEngine: ObservableObject {
var dailies: [DailyMetric] = []
var cachedSleep: [CachedSleepSession] = []
var workoutRows: [WorkoutRow] = []
// #510: backfilled fields for a REAL (non-detected) row a dropped bout collided with, grouped by
// the deviceId it must be upserted under (see the collision branch below) — never mixed into
// `workoutRows`, which is always written under `computedId`.
var backfilledByDevice: [String: [WorkoutRow]] = [:]
// Rest composite (0–100) per computed night, persisted as the `sleep_performance` metric
// series so the dashboard's Rest score reflects the new composite, not raw efficiency.
var restPoints: [MetricPoint] = []
Expand Down Expand Up @@ -1023,9 +1027,28 @@ final class IntelligenceEngine: ObservableObject {
// manual session even though their SPORTS differ ("detected" vs the user's sport) , the
// #975 "two workouts, one vanished" seam. Find the collider so the trace can name its source.
if let hit = realWorkouts.first(where: { s.start < $0.endTs && $0.startTs < s.end }) {
// #510: the detected bout's own avgHR/calories/maxHR/strain come from the SAME
// motion+HR trace the detector used to find this activity's actual boundaries —
// often a tighter match than the colliding row's own [startTs,endTs] (e.g. a manual
// entry typed in afterward, whose guessed boundaries can clip most of the real
// HR-rich period and leave the display-time strap-HR fill's raw window read too
// thin, silently showing no HR/calories). Same natural key (deviceId, startTs,
// sport), so the upsert below updates the existing row in place rather than
// duplicating it.
let backfilled = WorkoutDetector.backfillWorkout(
hit, avgBpm: avgBpm, peakHR: s.peakHR, caloriesKcal: s.caloriesKcal, strain: s.strain)
let didBackfill = backfilled != hit
if didBackfill {
// realWorkouts merges TWO device groups (see above): the strap's own `deviceId`
// (imported WHOOP rows AND manual/re-labelled ones) and "apple-health" — the
// Swift WorkoutRow carries no deviceId of its own, so route by that same split.
let hitDeviceId = hit.source == "apple-health" ? "apple-health" : deviceId
backfilledByDevice[hitDeviceId, default: []].append(backfilled)
}
if workoutsTraceActive {
diagnosticSink?(WorkoutsTrace.detectedBoutLine(
verdict: "droppedOverlap", durMin: durMin, avgBpm: avgBpm,
verdict: didBackfill ? "droppedOverlapBackfilled" : "droppedOverlap",
durMin: durMin, avgBpm: avgBpm,
overlapSource: WorkoutSource.sourceLabel(hit)), .workouts)
}
continue
Expand Down Expand Up @@ -1403,6 +1426,12 @@ final class IntelligenceEngine: ObservableObject {
_ = try? await store.deleteWorkouts(deviceId: computedId, sport: "detected",
from: windowStart, to: now)
if !workoutRows.isEmpty { _ = try? await store.upsertWorkouts(workoutRows, deviceId: computedId) }
// #510: write back any real (manual/imported) rows a dropped detected bout backfilled, one
// upsert per owning deviceId (see the collision branch above for why these can't share the
// `computedId` batch above).
for (devId, rows) in backfilledByDevice {
_ = try? await store.upsertWorkouts(rows, deviceId: devId)
}

// #137: a manually-started workout is scored from sparse live HR at save time , near-zero
// calories/strain on a 5/MG. Now that offloaded HR may cover the window, re-score the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -904,9 +904,23 @@ object IntelligenceEngine {
// though their sports differ , the #975 "two workouts, one vanished" seam. Name the collider.
val collider = realWorkouts.firstOrNull { w -> s.start < w.endTs && w.startTs < s.end }
if (collider != null) {
// #510: the detected bout's own avgHR/calories/maxHR/strain come from the SAME
// motion+HR trace the detector used to find this activity's actual boundaries —
// often a tighter match than the colliding row's own [startTs,endTs] (e.g. a manual
// entry typed in afterward, whose guessed boundaries can clip most of the real
// HR-rich period and leave WhoopRepository.fillWorkoutHrFromStrap's raw window read
// too thin, silently showing no HR/calories). Same natural key (deviceId, startTs,
// sport), so the upsert below updates the existing row in place rather than
// duplicating it.
val backfilled = backfillWorkoutFromDetectedBout(
collider, avgBpm = avgBpm, peakHR = s.peakHR, caloriesKcal = s.caloriesKcal, strain = s.strain,
)
val didBackfill = backfilled != collider
if (didBackfill) workoutRows.add(backfilled)
workoutsTraceSink?.invoke(
WorkoutsTrace.detectedBoutLine(
verdict = "droppedOverlap", durMin = durMin, avgBpm = avgBpm,
verdict = if (didBackfill) "droppedOverlapBackfilled" else "droppedOverlap",
durMin = durMin, avgBpm = avgBpm,
overlapSource = colliderSourceLabel(collider.source),
),
)
Expand Down Expand Up @@ -1236,6 +1250,25 @@ object IntelligenceEngine {
}
}

/**
* #510: backfill ONLY the avgHr/maxHr/energyKcal/strain fields [real] doesn't already have, from a
* detected bout's own computed values — never touching a field that's already present, whether
* typed by the user, imported, or filled by an earlier pass. Returns [real] unchanged (`==`) when it
* already had everything, so the caller can tell whether a write is actually needed.
*/
internal fun backfillWorkoutFromDetectedBout(
real: WorkoutRow,
avgBpm: Int,
peakHR: Int,
caloriesKcal: Double?,
strain: Double?,
): WorkoutRow = real.copy(
avgHr = real.avgHr ?: avgBpm,
maxHr = real.maxHr ?: peakHR,
energyKcal = real.energyKcal ?: caloriesKcal,
strain = real.strain ?: strain,
)

/**
* #137: re-score under-sampled manual workouts. Conservative + idempotent: only `manual` rows that
* look under-scored (negligible calories), and only when the recompute from the now-denser HR
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.noop.analytics

import com.noop.data.WorkoutRow
import org.junit.Assert.assertEquals
import org.junit.Test

/**
* #510: a detected bout's own computed avgHR/calories/maxHR/strain must fill ONLY the fields a
* colliding real (manual/imported) workout is missing — never override anything already present.
* Regression for the report where manually-entered workouts silently showed no HR/calories even
* though the detector had already computed a valid average for that exact window.
*/
class IntelligenceEngineWorkoutBackfillTest {

private fun manualRow(avgHr: Int? = null, maxHr: Int? = null, energyKcal: Double? = null, strain: Double? = null) =
WorkoutRow(
deviceId = "my-whoop", startTs = 1_000L, endTs = 1_600L, sport = "Running", source = "manual",
durationS = 600.0, energyKcal = energyKcal, avgHr = avgHr, maxHr = maxHr, strain = strain,
)

@Test
fun fillsAllMissingFields() {
val real = manualRow()
val filled = IntelligenceEngine.backfillWorkoutFromDetectedBout(
real, avgBpm = 150, peakHR = 170, caloriesKcal = 80.0, strain = 9.5,
)
assertEquals(150, filled.avgHr)
assertEquals(170, filled.maxHr)
assertEquals(80.0, filled.energyKcal)
assertEquals(9.5, filled.strain)
// The rest of the row is carried over untouched.
assertEquals(real.startTs, filled.startTs)
assertEquals(real.sport, filled.sport)
assertEquals(real.source, filled.source)
}

@Test
fun neverOverridesFieldsAlreadyPresent() {
// The user typed an Avg HR and calories by hand; only maxHr/strain are missing.
val real = manualRow(avgHr = 140, maxHr = null, energyKcal = 50.0, strain = null)
val filled = IntelligenceEngine.backfillWorkoutFromDetectedBout(
real, avgBpm = 150, peakHR = 170, caloriesKcal = 80.0, strain = 9.5,
)
assertEquals("a user-typed value must never be overwritten", 140, filled.avgHr)
assertEquals("a user-typed value must never be overwritten", 50.0, filled.energyKcal)
assertEquals("a missing field must still be filled", 170, filled.maxHr)
assertEquals("a missing field must still be filled", 9.5, filled.strain)
}

@Test
fun rowWithEverythingAlreadyPresentIsUnchanged() {
val real = manualRow(avgHr = 140, maxHr = 160, energyKcal = 50.0, strain = 8.0)
val filled = IntelligenceEngine.backfillWorkoutFromDetectedBout(
real, avgBpm = 150, peakHR = 170, caloriesKcal = 80.0, strain = 9.5,
)
assertEquals("nothing to fill -> byte-identical row, so the caller can skip the write", real, filled)
}
}
Loading