From 0e970e421f7699adb5ba6abb693694401c0bb2db Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 9 Jul 2026 10:42:46 +1200 Subject: [PATCH] =?UTF-8?q?Skin=20temp:=20fix=20Fahrenheit=20=E2=80=94=20a?= =?UTF-8?q?=20deviation=20must=20not=20get=20the=20+32=20offset=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skin-temp metric formatted its Celsius value with the ABSOLUTE C→F formula (×9/5 + 32) even when the value is a signed DEVIATION from baseline. Adding +32 to a deviation is nonsense, and only shows in Fahrenheit (Celsius appends the raw deviation, so it looked fine) — a −4.2 °C deviation rendered as "24.4 °F" (the report), and the graph axis inherited the same corruption. Fix: MetricDescriptor.format picks per value via VitalBands.isAbsoluteSkinTemp (v >= 20 °C) — a DEVIATION uses temperatureDeltaFromCelsius (×9/5, no +32), an ABSOLUTE reading (WHOOP export gives absolute °C) keeps the full C→F. This mirrors the Android HealthScreen per-value branch, so the two platforms now agree. Fixes the hero value, chart readout, and the min/max/avg stat tiles (all route through `format`). Celsius output is unchanged. Test: skin_temp descriptor formats −4.2 → "-7.6 °F", 0.6 → "1.1 °F" (delta), and 34.0 → "93.2 °F" (absolute); Celsius unchanged. NB the reporter's underlying ~−4.2 °C deviation on a live WHOOP MG is itself large (possibly no baseline yet / calibrating) — the reply asks for a debug export to sanity-check the raw MG reading; the +32 corruption is fixed regardless. --- Strand/Data/MetricCatalog.swift | 18 +++++++++++++++++- StrandTests/UnitFormatterTests.swift | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Strand/Data/MetricCatalog.swift b/Strand/Data/MetricCatalog.swift index ac0463160..2f3cd633f 100644 --- a/Strand/Data/MetricCatalog.swift +++ b/Strand/Data/MetricCatalog.swift @@ -1,4 +1,5 @@ import Foundation +import StrandAnalytics /// One interrogable metric: how to fetch it (key+source), how to label/format it, and whether /// higher is better (drives delta tinting). The Metric Explorer + Compare are built from this list. @@ -32,6 +33,15 @@ struct MetricDescriptor: Identifiable, Hashable { /// gate (`key == "strain"`) — the only value-converting metric in the catalog. private var isEffort: Bool { key == "strain" } + /// #111: the skin_temp metric's stored value is EITHER an absolute skin temperature (WHOOP export gives + /// absolute °C) OR a signed DEVIATION from the personal baseline (±°C, the live/computed pipeline) — + /// `VitalBands.isAbsoluteSkinTemp(v)` (v >= 20 °C) tells them apart per value. A DEVIATION must convert + /// to °F by ×9/5 with NO +32 offset; adding +32 to a deviation produced nonsense ONLY in Fahrenheit + /// (a −4.2 °C deviation rendered as "24.4 °F" — the bug), while Celsius appended the raw deviation and + /// looked fine. An ABSOLUTE reading still uses the full C→F (×9/5 + 32). Key-based, mirroring `isEffort` + /// and the Android HealthScreen per-value branch. + private var isSkinTemp: Bool { key == "skin_temp" } + func format(_ v: Double) -> String { let n = decimals == 0 ? String(Int(v.rounded())) : String(format: "%.\(decimals)f", v) return unit.isEmpty ? n : "\(n) \(unit)" @@ -55,7 +65,13 @@ struct MetricDescriptor: Identifiable, Hashable { effortScale: EffortScale = .hundred) -> String { switch unit { case "kg": return UnitFormatter.massFromKilograms(v, system: system) - case "°C": return UnitFormatter.temperatureFromCelsius(v, unit: temperature, decimals: decimals) + case "°C": + // #111: a skin-temp DEVIATION (v < 20 °C) scales without the +32 offset; an absolute reading + // (WHOOP export, v >= 20 °C) keeps the full C→F. Every other °C metric is absolute. + if isSkinTemp && !VitalBands.isAbsoluteSkinTemp(v) { + return UnitFormatter.temperatureDeltaFromCelsius(v, unit: temperature, decimals: decimals) + } + return UnitFormatter.temperatureFromCelsius(v, unit: temperature, decimals: decimals) default: return isEffort ? format(v, effortScale: effortScale) : format(v) } } diff --git a/StrandTests/UnitFormatterTests.swift b/StrandTests/UnitFormatterTests.swift index e80bd180b..032fcd8a8 100644 --- a/StrandTests/UnitFormatterTests.swift +++ b/StrandTests/UnitFormatterTests.swift @@ -101,6 +101,23 @@ final class UnitFormatterTests: XCTestCase { XCTAssertEqual(UnitFormatter.temperatureDeltaFromCelsius(0.6, unit: .celsius), "0.6 °C") } + // #111: skin_temp holds EITHER a signed deviation (v < 20 °C) or an absolute reading (v >= 20 °C). + // A DEVIATION must convert ×9/5 with NO +32 — the absolute formula turned a −4.2 °C deviation into the + // reported nonsense "24.4 °F". An ABSOLUTE reading must still get the full C→F. Pin both branches. + func testSkinTempMetricPicksDeltaVsAbsoluteByValue() { + guard let skin = MetricCatalog.all.first(where: { $0.key == "skin_temp" }) else { + return XCTFail("skin_temp descriptor missing") + } + // DEVIATION (< 20 °C): ×9/5, no +32 — NOT the bogus absolute 24.4 °F. + XCTAssertEqual(skin.format(-4.2, system: .imperial, temperature: .fahrenheit), "-7.6 °F") + XCTAssertEqual(skin.format(0.6, system: .imperial, temperature: .fahrenheit), "1.1 °F") + // ABSOLUTE (>= 20 °C, e.g. an imported WHOOP export reading): full C→F with +32 — 34 °C = 93.2 °F. + XCTAssertEqual(skin.format(34.0, system: .imperial, temperature: .fahrenheit), "93.2 °F") + // Celsius is unchanged for both — this always looked right; only °F was broken. + XCTAssertEqual(skin.format(0.6, system: .metric, temperature: .celsius), "0.6 °C") + XCTAssertEqual(skin.format(34.0, system: .metric, temperature: .celsius), "34.0 °C") + } + // MARK: - Preference resolution func testTemperatureOverrideResolution() {