Skip to content
Open
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
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
6 changes: 6 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions Strand/Data/WhoopImporter.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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] = []
Expand Down
54 changes: 54 additions & 0 deletions StrandTests/WhoopImportSkinTempTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
10 changes: 5 additions & 5 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}
Expand Down
11 changes: 11 additions & 0 deletions android/app/src/main/java/com/noop/analytics/Baselines.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ─────────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>): Double {
Expand Down
12 changes: 11 additions & 1 deletion android/app/src/main/java/com/noop/ingest/WhoopCsvExporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
Loading
Loading