diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift index ff4fa689cd..9d69f540d4 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift @@ -613,6 +613,13 @@ public enum Baselines { return Deviation(z: z, delta: delta, ratio: ratio, inNormalRange: abs(z) <= 1.0) } + /// `deviation`'s `delta` rounded to 2 dp, ties away from zero (`Double.rounded()`): the stored skin-temp + /// deviation. One helper so the computed and the imported deviation cannot round differently. Twin of + /// Kotlin `Baselines.roundedDelta2dp`, which spells out the half-away-from-zero form `Math.round` lacks. + public static func roundedDelta2dp(_ value: Double, state: BaselineState) -> Double { + (deviation(value, state: state).delta * 100.0).rounded() / 100.0 + } + // MARK: - Trailing-window mean/SD (simple, auditable) /// Rolling personal baseline from the trailing `window` valid nights, as a diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesTests.swift index 418bd97420..21f36da965 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesTests.swift @@ -350,4 +350,14 @@ final class BaselinesTests: XCTestCase { XCTAssertNotEqual(Baselines.daytimeHRCfg, Baselines.restingHRCfg) XCTAssertNotEqual(Baselines.daytimeRMSSDCfg, Baselines.hrvCfg) } + + /// The stored skin-temp deviation rounds ties away from zero: −0.125 °C (an exact binary tie at 2 dp) + /// stores −0.13. Pinned against the Kotlin twin's `roundedDelta2dpRoundsNegativeTiesAwayFromZero`. + func testRoundedDelta2dpRoundsTiesAwayFromZero() throws { + let cfg = try XCTUnwrap(Baselines.metricCfg["skin_temp"]) + let s = Baselines.foldHistory(Array(repeating: 34.0, count: 14), cfg: cfg) + XCTAssertEqual(s.baseline, 34.0) + XCTAssertEqual(Baselines.roundedDelta2dp(33.875, state: s), -0.13) + XCTAssertEqual(Baselines.roundedDelta2dp(34.125, state: s), 0.13) + } } diff --git a/Packages/StrandImport/Sources/StrandImport/WhoopCsvExporter.swift b/Packages/StrandImport/Sources/StrandImport/WhoopCsvExporter.swift index 423c0fde08..4ff8908241 100644 --- a/Packages/StrandImport/Sources/StrandImport/WhoopCsvExporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/WhoopCsvExporter.swift @@ -149,7 +149,7 @@ public enum WhoopCsvExporter { let s = series[d.day] ?? [:] let cols: [String] = [ d.day + " 00:00:00", "", "UTC+00:00", - num(d.recovery), num(d.restingHr), num(d.avgHrv), num(d.skinTempDevC), num(d.spo2Pct), + num(d.recovery), num(d.restingHr), num(d.avgHrv), num(exportedSkinTempCelsius(d)), num(d.spo2Pct), // Day Strain column is WHOOP's 0–21 scale → convert our 0–100 Effort down so the CSV is // WHOOP-format and a NOOP→NOOP round-trip is lossless (importer scales it back up). num(WhoopExportImporter.whoopDayStrainFromEffort(d.strain)), num(s["energy_kcal"]), num(s["max_hr"]), num(s["avg_hr"]), @@ -269,4 +269,14 @@ public enum WhoopCsvExporter { }) } } + + /// The absolute skin temperature for the `Skin temp (celsius)` column. `skinTempC` when the row has it; + /// otherwise `skinTempDevC` only when it is itself an absolute, the shape older WHOOP imports stored + /// (at or above 20 °C, the same rule as `VitalBands.isAbsoluteSkinTemp`, which this package cannot + /// import). A true deviation is never written as a temperature: it exported a 0.2 °C deviation as a + /// 0.2 °C skin temperature, which a re-import then read as the wearer's absolute. + static func exportedSkinTempCelsius(_ d: DailyMetric) -> Double? { + if let celsius = d.skinTempC { return celsius } + return d.skinTempDevC.flatMap { $0 >= 20 ? $0 : nil } + } } diff --git a/Packages/StrandImport/Tests/StrandImportTests/WhoopCsvExporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/WhoopCsvExporterTests.swift index c54ea8dce1..9c14259b57 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/WhoopCsvExporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/WhoopCsvExporterTests.swift @@ -165,4 +165,15 @@ final class WhoopCsvExporterTests: XCTestCase { XCTAssertEqual(result.workouts.count, 0) XCTAssertEqual(result.journal.count, 0) } + + func testTheSkinColumnCarriesTheAbsoluteNeverTheDeviation() { + func row(dev: Double?, celsius: Double?) -> DailyMetric { + DailyMetric(day: "2026-06-01", totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil, + lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil, recovery: nil, + strain: nil, exerciseCount: nil, skinTempDevC: dev, skinTempC: celsius) + } + XCTAssertEqual(WhoopCsvExporter.exportedSkinTempCelsius(row(dev: 0.2, celsius: 33.4)), 33.4) + XCTAssertNil(WhoopCsvExporter.exportedSkinTempCelsius(row(dev: 0.2, celsius: nil))) + XCTAssertEqual(WhoopCsvExporter.exportedSkinTempCelsius(row(dev: 33.1, celsius: nil)), 33.1) + } } diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 7f800cfb00..4094bb6993 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -450,6 +450,12 @@ final class AppModel: ObservableObject { // BEFORE the Effort rescore + analyzeRecent loop so both operate on a cleaned DB. Persisted // flag → no-op on every subsequent launch; idempotent on a clean DB. await self.intelligence.runTimestampHealIfNeeded() + // One-shot repair: WHOOP rows imported before `skinTempC` was filled carry the export's absolute °C in + // the deviation column. Move it and recompute the deviation; no-op once done. + if let store = await self.repo.storeHandle(), + await WhoopImporter.repairAbsoluteSkinTempIfNeeded(store: store, deviceId: self.deviceId) { + await self.repo.refresh() + } // One-shot on-upgrade Effort rescore (#313): recompute strain from source across the FULL // history once, so any deep-history rows an older build left on the 0–21 axis regenerate on // the 0–100 axis. Guarded by a persisted flag, so this is a no-op on every subsequent launch. diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 0b44ebdb41..3c2c068ce0 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -3296,7 +3296,7 @@ final class IntelligenceEngine: ObservableObject { /// to match the imported/demo precision. APPROXIMATE. private static func recomputeSkinTempDev(_ nightly: Double?, _ base: BaselineState?) -> Double? { guard let v = nightly, let b = base, b.usable else { return nil } - return (Baselines.deviation(v, state: b).delta * 100.0).rounded() / 100.0 + return Baselines.roundedDelta2dp(v, state: b) } /// The user's habitual midsleep (local time-of-day seconds), or nil under `habitualMinDays` of diff --git a/Strand/Data/WhoopImporter.swift b/Strand/Data/WhoopImporter.swift index 06b6aecf76..1ba8d796f7 100644 --- a/Strand/Data/WhoopImporter.swift +++ b/Strand/Data/WhoopImporter.swift @@ -1,10 +1,60 @@ import Foundation import WhoopStore import StrandImport +import StrandAnalytics /// Maps a parsed Whoop CSV export into the on-device WhoopStore tables the UI reads /// (dailyMetric + sleepSession), so importing lights up the full history immediately. enum WhoopImporter { + /// UserDefaults flag for the one-shot repair of rows imported before `skinTempC` was filled + /// (`repairAbsoluteSkinTempIfNeeded`). + static let skinTempRepairFlagKey = "noop.whoopImport.skinTempDeviationRepair.v1.done" + + /// Fill each row's `skinTempDevC` as its absolute `skinTempC` minus the personal skin-temp baseline folded + /// over the nights BEFORE it (`Baselines.metricCfg["skin_temp"]`, the absolute-°C config), rounded to 0.01 °C + /// like the on-device deviation. Nil until that baseline is usable. Rows without an absolute keep their + /// `skinTempDevC`. The WHOOP export ships absolute °C only; storing it as the deviation read an imported + /// night as +33 °C wherever a caller trusts the column (Charge breakdown, Trends report). + /// + /// The column is not homogeneous: an imported night's deviation is against a baseline folded over the + /// imported absolutes, a computed night's against the on-device baseline. A window spanning both (the + /// Coach's 30-day average, `AICoach`) mixes the two. + static func withSkinTempDeviations(_ rows: [DailyMetric]) -> [DailyMetric] { + guard let cfg = Baselines.metricCfg["skin_temp"] else { return rows } + var state: BaselineState? + return rows.sorted { $0.day < $1.day }.map { row in + guard let celsius = row.skinTempC else { return row } + let deviation = state.flatMap { $0.usable ? $0 : nil } + .map { Baselines.roundedDelta2dp(celsius, state: $0) } + state = Baselines.update(state, value: celsius, cfg: cfg) + return row.with(recovery: row.recovery, skinTempDevC: deviation, skinTempC: celsius) + } + } + + /// One-shot repair for WHOOP rows imported with the absolute °C in `skinTempDevC` and no `skinTempC`: + /// move the absolute into `skinTempC` and recompute the deviation across the imported history. + /// Returns whether any row changed. Idempotent; the flag only skips the read on later launches. + @discardableResult + static func repairAbsoluteSkinTempIfNeeded(store: WhoopStore, deviceId: String, + defaults: UserDefaults = .standard) async -> Bool { + guard !defaults.bool(forKey: skinTempRepairFlagKey) else { return false } + guard let rows = try? await store.dailyMetrics(deviceId: deviceId, from: "0000-01-01", to: "9999-12-31") + else { return false } + let moved = rows.map { row -> DailyMetric in + guard row.skinTempC == nil, let dev = row.skinTempDevC, VitalBands.isAbsoluteSkinTemp(dev) else { return row } + return row.with(recovery: row.recovery, skinTempDevC: nil, skinTempC: dev) + } + let original = Dictionary(uniqueKeysWithValues: rows.map { ($0.day, $0) }) + let changed = withSkinTempDeviations(moved).filter { + original[$0.day]?.skinTempC != $0.skinTempC || original[$0.day]?.skinTempDevC != $0.skinTempDevC + } + if !changed.isEmpty { + guard (try? await store.upsertDailyMetrics(changed, deviceId: deviceId)) != nil else { return false } + } + defaults.set(true, forKey: skinTempRepairFlagKey) + return !changed.isEmpty + } + /// The WHOOP CSV mapping revision, stamped into the Import test-mode parser line. Bump when this /// importer's column->store mapping changes so a shared report's parser version is unambiguous. @@ -36,9 +86,11 @@ enum WhoopImporter { strain: WhoopExportImporter.effortFromImportedDayStrain(c.dayStrain), exerciseCount: nil, spo2Pct: c.bloodOxygenPct, - skinTempDevC: c.skinTempCelsius, // NOTE: Whoop export gives absolute °C, not a baseline deviation - respRateBpm: c.respiratoryRate)) + respRateBpm: c.respiratoryRate, + // The export gives ABSOLUTE °C. `skinTempDevC` is filled below from the nights before it. + skinTempC: c.skinTempCelsius)) } + metrics = withSkinTempDeviations(metrics) // sleeps → CachedSleepSession (stage durations encoded as JSON; export has no per-epoch timeline) var sessions: [CachedSleepSession] = [] diff --git a/StrandTests/WhoopImportSkinTempTests.swift b/StrandTests/WhoopImportSkinTempTests.swift new file mode 100644 index 0000000000..78063bddf7 --- /dev/null +++ b/StrandTests/WhoopImportSkinTempTests.swift @@ -0,0 +1,54 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// The WHOOP export ships absolute skin temperature. It belongs in `skinTempC`, with `skinTempDevC` the +/// deviation from the nights before, never the absolute itself. +final class WhoopImportSkinTempTests: XCTestCase { + + private func row(_ day: String, dev: Double? = nil, abs: Double? = nil) -> DailyMetric { + DailyMetric(day: day, totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil, lightMin: nil, + disturbances: nil, restingHr: nil, avgHrv: nil, recovery: nil, strain: nil, exerciseCount: nil, + skinTempDevC: dev, skinTempC: abs) + } + + private func nights(_ temps: [Double]) -> [DailyMetric] { + temps.enumerated().map { row(String(format: "2026-07-%02d", $0.offset + 1), abs: $0.element) } + } + + func testTheDeviationIsMeasuredAgainstPriorNightsAndNeverTheAbsolute() { + let out = WhoopImporter.withSkinTempDeviations(nights(Array(repeating: 33.5, count: 10) + [34.3])) + XCTAssertNil(out[0].skinTempDevC, "no baseline before the first night") + XCTAssertEqual(out.last?.skinTempC, 34.3) + XCTAssertEqual(out.last?.skinTempDevC ?? .nan, 0.8, accuracy: 0.01) + XCTAssertTrue(out.compactMap(\.skinTempDevC).allSatisfy { abs($0) < 2 }) + } + + func testARowWithoutAnAbsoluteKeepsItsDeviation() { + let out = WhoopImporter.withSkinTempDeviations([row("2026-07-01", dev: 0.2)]) + XCTAssertEqual(out[0].skinTempDevC, 0.2) + XCTAssertNil(out[0].skinTempC) + } + + func testTheRepairMovesAnImportedAbsoluteOutOfTheDeviationColumnOnce() async throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let store = try await WhoopStore(path: dir.appendingPathComponent("t.sqlite").path) + let defaults = UserDefaults(suiteName: "WhoopImportSkinTempTests")! + defaults.removePersistentDomain(forName: "WhoopImportSkinTempTests") + let broken = (1...10).map { row(String(format: "2026-07-%02d", $0), dev: 33.5) } + [row("2026-07-11", dev: 34.3)] + _ = try await store.upsertDailyMetrics(broken, deviceId: "my-whoop") + + let changed = await WhoopImporter.repairAbsoluteSkinTempIfNeeded(store: store, deviceId: "my-whoop", + defaults: defaults) + XCTAssertTrue(changed) + let rows = try await store.dailyMetrics(deviceId: "my-whoop", from: "0000-01-01", to: "9999-12-31") + XCTAssertEqual(rows.last?.skinTempC, 34.3) + XCTAssertEqual(rows.last?.skinTempDevC ?? .nan, 0.8, accuracy: 0.01) + XCTAssertTrue(rows.allSatisfy { ($0.skinTempDevC.map { abs($0) < 2 }) ?? true }) + let again = await WhoopImporter.repairAbsoluteSkinTempIfNeeded(store: store, deviceId: "my-whoop", + defaults: defaults) + XCTAssertFalse(again) + } +} diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 3dcf98fd10..a73f3d74d3 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -24,6 +24,14 @@ "identity_sha256": "88a1bdfb13c227664f15d962fb6d41a5733fb4c2956e445e42c5e4d495bde0cb", "platform": "swift", "rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt::repairAbsoluteSkinTempIfNeeded/4#1", + "identity_sha256": "479a15d4f55eb6de209bcaec93dd2cb09abfb67b81c6e810782647b92a416351", + "platform": "kotlin", + "rationale": "Twin of the Swift WhoopImporter.repairAbsoluteSkinTempIfNeeded, which lives in the app layer (Strand/Data/WhoopImporter.swift) outside the governed Swift roots, so the ledger cannot pair them." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 53f3770c23..1fcf37d1f1 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,15 +19,15 @@ }, "authority": { "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, - "functions": {"count": 4455, "sha256": "a07ca22b2e581e8b6feadae9bdb1e4d645d46196921796df3a2be6cdf8c2ef7b"}, + "functions": {"count": 4462, "sha256": "8fd35521a40e023a73fc632e5215af0f9dda7534945de8f92df999b918eb2b02"}, "properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"}, "constants": {"count": 1951, "sha256": "aa7ce58efe6a8d3409abd3ccad24d22889514d4555faca323a0ee751ff7925b5"}, - "file_pairs": {"count": 68, "sha256": "414dbafb27e1e35cf65cff54f6ff780f102f009762c1dfb12d91b0980fe30854"}, - "function_pairs": {"count": 176, "sha256": "e3a74634d5a9381cf6e3491df839dad5ab33ec6cee8f29eb77c47ba7974b2e76"}, + "file_pairs": {"count": 70, "sha256": "9bdf79bc40f095bea2b41a297684590b4732fbe1fa898e537db19d98e808ca6d"}, + "function_pairs": {"count": 180, "sha256": "d20019de3071aa3b4c17b4926e9bad347005062f1de0195e91d0a81e19265f5b"}, "property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"}, "constant_pairs": {"count": 678, "sha256": "350d339d5fca3416a600ca96939a8ae8d269201e83ab5b05a8accbc6d3f85aa2"}, - "unpaired_files": {"count": 384, "sha256": "17285ce29f015a373969bbcb13042b100a15d580825785f824680e0880b5d777"}, - "unpaired_functions": {"count": 4109, "sha256": "a3ec845a9b802edc70c7f018b6ecd56889022b7d95e9738c29dbe0fde6d4af5e"}, + "unpaired_files": {"count": 381, "sha256": "36ebcbe675e119ff74f00451cb0982abeedd8aa27afcfb21b832c08eeb26438e"}, + "unpaired_functions": {"count": 4110, "sha256": "48a9dca7bb73c8ad7d2a52f08fdd4942c1f42f8e8d101a81c4e25fb5b5d65a4e"}, "unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"}, "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"} } diff --git a/android/app/src/main/java/com/noop/analytics/Baselines.kt b/android/app/src/main/java/com/noop/analytics/Baselines.kt index 4b2c7b6e7e..9887ec8b22 100644 --- a/android/app/src/main/java/com/noop/analytics/Baselines.kt +++ b/android/app/src/main/java/com/noop/analytics/Baselines.kt @@ -574,6 +574,17 @@ object Baselines { return Deviation(z = z, delta = delta, ratio = ratio, inNormalRange = abs(z) <= 1.0) } + /** + * [deviation]'s `delta` rounded to 2 dp HALF-AWAY-FROM-ZERO, the stored skin-temp deviation. Swift's + * `Double.rounded()` rounds ties away from zero; `Math.round` rounds them up, so on a negative tie + * (−0.005 → −0.01 in Swift, +0.00 with `Math.round`) the platforms would store different values. Every + * stored skin-temp deviation goes through here so a new caller cannot reintroduce `Math.round`. + */ + fun roundedDelta2dp(value: Double, state: BaselineState): Double { + val scaled = deviation(value, state).delta * 100.0 + return (if (scaled >= 0) Math.floor(scaled + 0.5) else Math.ceil(scaled - 0.5)) / 100.0 + } + // ───────────────────────────────────────────────────────────────────────── // Trailing-window mean/SD (simple, auditable) // ───────────────────────────────────────────────────────────────────────── diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 1527212d37..bdc84506d8 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -2757,12 +2757,8 @@ object IntelligenceEngine { private fun recomputeSkinTempDev(nightly: Double?, base: BaselineState?): Double? { val v = nightly ?: return null val b = base?.takeIf { it.usable } ?: return null - // Round HALF-AWAY-FROM-ZERO to 2 dp to match Swift's Double.rounded() - // (IntelligenceEngine.swift:291). Math.round() is half-UP and would diverge on negative - // .5 ties (e.g. −2.5 → −2 here vs Swift's −3). (Cross-platform parity.) - val scaled = Baselines.deviation(v, b).delta * 100.0 - val r = if (scaled >= 0) Math.floor(scaled + 0.5) else Math.ceil(scaled - 0.5) - return r / 100.0 + // Half-away-from-zero to 2 dp, the Swift twin's Double.rounded(); see Baselines.roundedDelta2dp. + return Baselines.roundedDelta2dp(v, b) } private fun medianOfDoubles(xs: List): Double { diff --git a/android/app/src/main/java/com/noop/ingest/WhoopCsvExporter.kt b/android/app/src/main/java/com/noop/ingest/WhoopCsvExporter.kt index dda39fda81..9c78942cb4 100644 --- a/android/app/src/main/java/com/noop/ingest/WhoopCsvExporter.kt +++ b/android/app/src/main/java/com/noop/ingest/WhoopCsvExporter.kt @@ -2,6 +2,7 @@ package com.noop.ingest import android.content.Context import android.net.Uri +import com.noop.analytics.VitalBands import com.noop.data.DailyMetric import com.noop.data.JournalEntry import com.noop.data.MetricSeriesRow @@ -174,7 +175,7 @@ object WhoopCsvExporter { sb.append( listOf( d.day + " 00:00:00", "", "UTC+00:00", - num(d.recovery), num(d.restingHr), num(d.avgHrv), num(d.skinTempDevC), + num(d.recovery), num(d.restingHr), num(d.avgHrv), num(exportedSkinTempCelsius(d)), // Day Strain column is WHOOP's 0–21 scale → down-convert our 0–100 Effort so the CSV // is WHOOP-format and a NOOP→NOOP round-trip is lossless (import scales back ×100/21). // Divide by the SAME 100.0/21.0 constant the importer multiplies by (and that Swift's @@ -423,4 +424,13 @@ object WhoopCsvExporter { return "Exported ${daily.size} days, ${sleeps.size} sleeps, ${workouts.size} workouts, " + "${journal.size} journal entries." } + + /** + * The absolute skin temperature for the `Skin temp (celsius)` column: [DailyMetric.skinTempC] when the row + * has it, otherwise [DailyMetric.skinTempDevC] only when it is itself an absolute (the shape older WHOOP + * imports stored). A true deviation is never written as a temperature. Twin of the Swift + * `WhoopCsvExporter.exportedSkinTempCelsius`. + */ + internal fun exportedSkinTempCelsius(d: DailyMetric): Double? = + d.skinTempC ?: d.skinTempDevC?.takeIf { VitalBands.isAbsoluteSkinTemp(it) } } diff --git a/android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt b/android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt index a1011a0d01..4cd824bcb8 100644 --- a/android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt +++ b/android/app/src/main/java/com/noop/ingest/WhoopCsvImporter.kt @@ -2,6 +2,9 @@ package com.noop.ingest import android.content.Context import android.net.Uri +import com.noop.analytics.BaselineState +import com.noop.analytics.Baselines +import com.noop.analytics.VitalBands import com.noop.data.DailyMetric import com.noop.data.ImportSummary import com.noop.data.JournalEntry @@ -119,7 +122,7 @@ object WhoopCsvImporter { // Merge cycle-derived and sleep-derived daily rows on (deviceId, day): cycle fields // (recovery / strain / RHR / HRV / SpO2 / skin-temp / resp) win where present, sleep // fields fill the architecture columns. One DailyMetric per day, matching the PK. - val daily = mergeDaily(cycles, sleepDaily) + val daily = withSkinTempDeviations(mergeDaily(cycles, sleepDaily)) if (daily.isEmpty() && sleepSessions.isEmpty() && workouts.isEmpty() && journal.isEmpty()) { return ImportSummary.failure(SOURCE_LABEL, "Export contained no usable WHOOP rows.") @@ -326,6 +329,67 @@ object WhoopCsvImporter { // MARK: - physiological_cycles.csv -> DailyMetric + /** + * Fill each row's [DailyMetric.skinTempDevC] as its absolute [DailyMetric.skinTempC] minus the personal + * skin-temp baseline folded over the nights BEFORE it (`Baselines.metricCfg["skin_temp"]`, the absolute-°C + * config), rounded to 0.01 °C like the on-device deviation, and null until that baseline is usable. Rows + * without an absolute keep their deviation. Storing the export's absolute as the deviation read an + * imported night as +33 °C wherever the column is trusted. Twin of the Swift + * `WhoopImporter.withSkinTempDeviations`. + * + * The column is not homogeneous: an imported night's deviation is against a baseline folded over the + * imported absolutes, a computed night's against the on-device baseline. A window spanning both (the + * Coach's 30-day average) mixes the two. + */ + internal fun withSkinTempDeviations(rows: List): List { + val cfg = Baselines.metricCfg["skin_temp"] ?: return rows + var state: BaselineState? = null + return rows.sortedBy { it.day }.map { row -> + val celsius = row.skinTempC ?: return@map row + val deviation = state?.takeIf { it.usable } + ?.let { Baselines.roundedDelta2dp(celsius, it) } + state = Baselines.update(state, celsius, cfg) + row.copy(skinTempDevC = deviation, skinTempC = celsius) + } + } + + /** + * The rows a one-time repair changes: a WHOOP row imported before [DailyMetric.skinTempC] was filled + * carries the export's absolute °C in [DailyMetric.skinTempDevC]. Moves it into `skinTempC` and recomputes + * every deviation across the history; returns only the rows that differ. Twin of the Swift + * `WhoopImporter.repairAbsoluteSkinTempIfNeeded`. + */ + internal fun skinTempRepair(rows: List): List { + val moved = rows.map { row -> + val dev = row.skinTempDevC + if (row.skinTempC == null && dev != null && VitalBands.isAbsoluteSkinTemp(dev)) { + row.copy(skinTempDevC = null, skinTempC = dev) + } else row + } + val original = rows.associateBy { it.day } + return withSkinTempDeviations(moved).filter { + original[it.day]?.skinTempC != it.skinTempC || original[it.day]?.skinTempDevC != it.skinTempDevC + } + } + + /** + * One-time repair of rows imported with the absolute in the deviation column (see [skinTempRepair]). + * Returns whether any row changed. Idempotent; [flagGet]/[flagSet] only skip the read on later launches, + * and the flag is set only after the upsert succeeds, so an interrupted repair runs again. + */ + suspend fun repairAbsoluteSkinTempIfNeeded( + repo: WhoopRepository, + deviceId: String = WHOOP_DEVICE, + flagGet: () -> Boolean, + flagSet: () -> Unit, + ): Boolean { + if (flagGet()) return false + val changed = skinTempRepair(repo.dailyMetrics(deviceId, "0000-01-01", "9999-12-31")) + if (changed.isNotEmpty()) repo.upsertDailyMetrics(changed) + flagSet() + return changed.isNotEmpty() + } + internal fun parseCycles(table: CsvTable, deviceId: String): List { val out = ArrayList(table.rows.size) for (row in table.rows) { @@ -384,7 +448,9 @@ object WhoopCsvImporter { strain = strain?.let { it * DAY_STRAIN_TO_EFFORT_SCALE }, exerciseCount = null, // not present in physiological_cycles.csv spo2Pct = spo2, - skinTempDevC = skinTemp, + // The export gives ABSOLUTE °C. skinTempDevC is filled from the nights before it + // (withSkinTempDeviations), never with the absolute itself. + skinTempC = skinTemp, respRateBpm = resp, ) ) diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index d66d6d0f4e..9b0c0b2329 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -10,6 +10,7 @@ import com.noop.alarm.SmartAlarmStore import com.noop.alarm.WindDownScheduler import com.noop.alarm.WindDownStore import com.noop.analytics.Baselines +import com.noop.ingest.WhoopCsvImporter import com.noop.analytics.IllnessSignalEngine import com.noop.analytics.IllnessWatch import com.noop.analytics.IntelligenceEngine @@ -1144,6 +1145,16 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { NoopPrefs.setTsHealPending(appContext, false) } }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } + // One-time repair: WHOOP rows imported before skinTempC was filled carry the export's absolute °C + // in the deviation column. Move it and recompute the deviation; a no-op once done. + runCatching { + WhoopCsvImporter.repairAbsoluteSkinTempIfNeeded( + repo = repository, + deviceId = deviceId, + flagGet = { NoopPrefs.skinTempRepairDone(appContext) }, + flagSet = { NoopPrefs.setSkinTempRepairDone(appContext) }, + ) + }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } // One-shot on-upgrade Effort rescore (#313): recompute strain from source across the FULL // history once, so any deep-history rows an older build left on the 0–21 axis regenerate on // the 0–100 axis. Guarded by a persisted flag, so it's a no-op on every subsequent launch. diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index ba4d978eff..d4395390f5 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -1401,6 +1401,17 @@ object NoopPrefs { of(context).edit().putInt(KEY_CAFFEINE_BEDTIME_MIN, minutes.coerceIn(0, 24 * 60 - 1)).apply() } + /** Whether the one-time repair of WHOOP rows imported with the absolute skin temperature in the + * deviation column has run (`WhoopCsvImporter.repairAbsoluteSkinTempIfNeeded`). */ + const val KEY_SKIN_TEMP_REPAIR_DONE = "noop.whoopImport.skinTempDeviationRepair.v1.done" + + fun skinTempRepairDone(context: Context): Boolean = + of(context).getBoolean(KEY_SKIN_TEMP_REPAIR_DONE, false) + + fun setSkinTempRepairDone(context: Context) { + of(context).edit().putBoolean(KEY_SKIN_TEMP_REPAIR_DONE, true).apply() + } + /** Whether the one-shot #313 full-history Effort rescore has run. Set true once it completes so the * on-upgrade pass that regenerates deep-history strain on the 0–100 axis never re-runs. */ const val KEY_EFFORT_RESCORE_DONE = "noop.effortRescore.v313.done" diff --git a/android/app/src/test/java/com/noop/analytics/BaselinesSigmaDaytimeTest.kt b/android/app/src/test/java/com/noop/analytics/BaselinesSigmaDaytimeTest.kt index 8a407912cc..056f43c0ec 100644 --- a/android/app/src/test/java/com/noop/analytics/BaselinesSigmaDaytimeTest.kt +++ b/android/app/src/test/java/com/noop/analytics/BaselinesSigmaDaytimeTest.kt @@ -36,4 +36,15 @@ class BaselinesSigmaDaytimeTest { assertNotEquals(Baselines.daytimeHRCfg, Baselines.restingHRCfg) assertNotEquals(Baselines.daytimeRMSSDCfg, Baselines.hrvCfg) } + + /** The stored skin-temp deviation rounds ties AWAY from zero like Swift's Double.rounded(): a −0.125 °C + * delta (an exact binary tie at 2 dp) stores −0.13, where Math.round would store −0.12. */ + @Test + fun roundedDelta2dpRoundsNegativeTiesAwayFromZero() { + val s = Baselines.foldHistory(List(14) { 34.0 }, Baselines.metricCfg.getValue("skin_temp")) + assertEquals(34.0, s.baseline, 0.0) + assertEquals(-0.13, Baselines.roundedDelta2dp(33.875, s), 0.0) + assertEquals(0.13, Baselines.roundedDelta2dp(34.125, s), 0.0) + assertEquals(-0.12, Math.round(-0.125 * 100.0) / 100.0, 0.0) // the divergence being pinned + } } diff --git a/android/app/src/test/java/com/noop/ingest/WhoopCsvExporterTest.kt b/android/app/src/test/java/com/noop/ingest/WhoopCsvExporterTest.kt index 494cdb86fa..537259a5c4 100644 --- a/android/app/src/test/java/com/noop/ingest/WhoopCsvExporterTest.kt +++ b/android/app/src/test/java/com/noop/ingest/WhoopCsvExporterTest.kt @@ -228,4 +228,13 @@ class WhoopCsvExporterTest { } assertEquals(listOf("a.csv", "noop_metric_series.json"), names) } + + @Test + fun theSkinColumnCarriesTheAbsoluteNeverTheDeviation() { + fun row(dev: Double?, celsius: Double?) = + DailyMetric(deviceId = "my-whoop", day = "2026-06-01", skinTempDevC = dev, skinTempC = celsius) + assertEquals(33.4, WhoopCsvExporter.exportedSkinTempCelsius(row(0.2, 33.4))!!, 1e-9) + assertEquals(null, WhoopCsvExporter.exportedSkinTempCelsius(row(0.2, null))) + assertEquals(33.1, WhoopCsvExporter.exportedSkinTempCelsius(row(33.1, null))!!, 1e-9) + } } diff --git a/android/app/src/test/java/com/noop/ingest/WhoopCsvImporterTest.kt b/android/app/src/test/java/com/noop/ingest/WhoopCsvImporterTest.kt index faf14529f5..df1d54c6ea 100644 --- a/android/app/src/test/java/com/noop/ingest/WhoopCsvImporterTest.kt +++ b/android/app/src/test/java/com/noop/ingest/WhoopCsvImporterTest.kt @@ -2,6 +2,8 @@ package com.noop.ingest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import com.noop.data.DailyMetric import org.junit.Test /** @@ -136,7 +138,7 @@ class WhoopCsvImporterTest { ) assertEquals(1, rows.size) // 91.58 °F → (91.58 − 32) × 5/9 = 33.1 °C — the same stored value a Celsius import produces. - assertEquals(33.1, rows.single().skinTempDevC!!, 1e-3) + assertEquals(33.1, rows.single().skinTempC!!, 1e-3) } /** @@ -152,7 +154,7 @@ class WhoopCsvImporterTest { """ ) assertEquals(1, rows.size) - assertEquals(33.1, rows.single().skinTempDevC!!, 1e-3) + assertEquals(33.1, rows.single().skinTempC!!, 1e-3) } // --- #136: imported journal keys to the WAKE day, not the onset evening ------------------- @@ -266,4 +268,34 @@ class WhoopCsvImporterTest { // Day Strain 12.5 is rescaled onto NOOP's 0–100 Effort axis (×100/21). assertEquals(12.5 * (100.0 / 21.0), r.strain!!, 1e-9) } + + // --- The export's absolute skin temperature is skinTempC, never the deviation --------------------- + + private fun night(day: String, celsius: Double?, dev: Double? = null) = + DailyMetric(deviceId = "my-whoop", day = day, skinTempC = celsius, skinTempDevC = dev) + + @Test + fun theDeviationIsMeasuredAgainstPriorNightsAndIsNeverTheAbsolute() { + val rows = WhoopCsvImporter.withSkinTempDeviations( + (1..10).map { night("2026-06-%02d".format(it), 33.5) } + night("2026-06-11", 34.0)) + rows.forEach { assertTrue((it.skinTempDevC ?: 0.0) < 20.0) } + assertEquals(null, rows.first().skinTempDevC) + assertEquals(0.5, rows.last().skinTempDevC!!, 0.01) + } + + @Test + fun aRowWithoutAnAbsoluteKeepsItsDeviation() { + val rows = WhoopCsvImporter.withSkinTempDeviations(listOf(night("2026-06-01", null, dev = -0.2))) + assertEquals(-0.2, rows.single().skinTempDevC!!, 1e-9) + } + + @Test + fun theRepairMovesAnImportedAbsoluteOutOfTheDeviationColumnOnce() { + val imported = (1..10).map { night("2026-06-%02d".format(it), null, dev = 33.5) } + val changed = WhoopCsvImporter.skinTempRepair(imported) + assertEquals(10, changed.size) + changed.forEach { assertEquals(33.5, it.skinTempC!!, 1e-9); assertTrue((it.skinTempDevC ?: 0.0) < 20.0) } + // Running it again over the repaired rows changes nothing. + assertTrue(WhoopCsvImporter.skinTempRepair(changed).isEmpty()) + } }