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
10 changes: 7 additions & 3 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1082,23 +1082,27 @@ final class IntelligenceEngine: ObservableObject {
// over-count's MECHANISM is readable from the always-on log — clean nights stay quiet.
// srcChannel rides from the read model. Byte-identical to the Kotlin `hrv rrsample` line.
if verdict == .crossSecondOverCount || verdict == .sameSecondOverCount {
// Built ONCE for all three diagnostics below. Each call materialised its own copy
// of the night's ords — ~70k elements, on a path that runs ~21 times per
// analyzeRecent, every 15 minutes — so two of the three were pure waste. (#1510)
let rrOrds = sleepRrRows.map { $0.ord }
let sample = HRVAnalyzer.densestSecondWindowSample(
tsSec: ts, rrMs: sleepRr, srcCodes: sleepRrRows.map { $0.srcChannel?.rawValue },
ords: sleepRrRows.map { $0.ord })
ords: rrOrds)
if !sample.isEmpty { diagLine += "\nhrv rrsample day=\(res.daily.day) \(sample)" }
// #1331/#1008: the sample above shows ords for a handful of seconds; this counts
// them across the WHOLE night, which is what decides whether the fix belongs at
// ingest. Kotlin twin.
let deliveries = HRVAnalyzer.deliveryHistogram(
tsSec: ts, rrMs: sleepRr, ords: sleepRrRows.map { $0.ord })
tsSec: ts, rrMs: sleepRr, ords: rrOrds)
if !deliveries.isEmpty { diagLine += "\n\(deliveries) day=\(res.daily.day)" }
// #1505: the histogram above counts deliveries per second but never compares what
// they wrote. If the live and historical copies are the same beat in two units, a
// duplicated second holds two values 1024/1000 apart; if they are different beats,
// the ratios scatter. One pair cannot tell those apart — a night's worth can.
// Measurement only, takes no view on the answer. Kotlin twin.
let dupPairs = HRVAnalyzer.duplicatePairRatios(
tsSec: ts, rrMs: sleepRr, ords: sleepRrRows.map { $0.ord })
tsSec: ts, rrMs: sleepRr, ords: rrOrds)
if !dupPairs.isEmpty { diagLine += "\n\(dupPairs) day=\(res.daily.day)" }
// #1331/#1008/#1118 SHADOW: log the DEDUPED stream's HRV + coverage + beat-accuracy
// beside the raw (above), so the candidate two-channel de-dup can be validated
Expand Down
31 changes: 23 additions & 8 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@ struct TodayView: View {
// Editable Key-Metrics layout (#251), an ordered list of the enabled tiles, persisted display-only.
// Empty/unset shows the full default order. Every edit affordance routes into one customization sheet.
@AppStorage(KeyMetricPrefs.layoutKey) private var keyMetricsRaw = ""
// #1512: the strap family behind the steps-calibration prompt. Read through @AppStorage rather
// than `WhoopModel.persisted` because `stepsPipelineActive` is evaluated inside `keyMetricTile`, once
// per metric per body pass, and this body recomposes with live heart rate — that made it a
// UserDefaults lookup on a ~1 Hz path. Android memoises the analogous preference reads on this same
// body for the same reason. Also makes the value observed, so a strap-model change re-renders the
// tile rather than waiting on an unrelated recomposition.
@AppStorage("selectedWhoopModel") private var selectedWhoopModelRaw = ""
@AppStorage("today.keyMetricsDetailed") private var keyMetricsDetailed = false
@AppStorage("today.keyMetricsWindowDays") private var keyMetricsWindowDays = 14
private var enabledKeyMetrics: [KeyMetric] { KeyMetricPrefs.decodeEnabled(keyMetricsRaw) }
Expand Down Expand Up @@ -3881,16 +3888,24 @@ struct TodayView: View {
/// always estimates — and it answers it on day one. The calibration state it is OR-ed with is
/// profile-global rather than per-strap, so both halves are measuring the same user either way.
///
/// Read straight off the persisted selection rather than through `BLEManager.isWhoop4`: `TodayView`
/// holds no `AppModel`, and `BLEManager` writes this same key whenever the model changes
/// (`BLEManager.persistSelectedModel`), so the static is the same answer without the dependency.
/// Read off the persisted selection rather than through `BLEManager.isWhoop4`: `TodayView` holds no
/// `AppModel`, and `BLEManager` writes this same key whenever the model changes
/// (`BLEManager.persistSelectedModel`), so the stored value is the same answer without the dependency.
///
/// [hasDayData] is why the family term is not used alone: `WhoopModel.persisted` falls back to
/// `.whoop4` when nothing has been selected, so a user who has never paired anything reads as a 4.0
/// owner. Requiring a scored day for the date being shown keeps the "no strap at all" case the
/// original gate protected — a user with nothing recorded still sees no prompt.
/// The family term requires the key to have actually been SET. It is written by
/// `BLEManager.persistSelectedModel` the moment a strap is identified, so every real 4.0 owner has one
/// and #1491's intent is untouched — what it excludes is the user who has never paired anything, whose
/// unset key used to read as a 4.0 through the default and earn them a prompt to calibrate a strap
/// nobody has seen. Android's twin has always required the key (`?: return null` in
/// `stepsCalibrationPrompt`); this is the Apple side matching it — the two halves of #1512 disagreed
/// about the unset case from the day it landed.
///
/// [hasDayData] stays as the second guard: it is what keeps the prompt off a date with nothing scored
/// on it, which is a different question from whether a strap is known.
private func stepsPipelineActive(hasDayData: Bool) -> Bool {
(WhoopModel.persisted.deviceFamily == .whoop4 && hasDayData)
// Optional-chained deliberately: an unset (or unparseable) key is NOT a 4.0. The key only ever
// holds a `WhoopModel` rawValue, so nil here means "no strap has been identified", not "4.0".
(WhoopModel(rawValue: selectedWhoopModelRaw)?.deviceFamily == .whoop4 && hasDayData)
|| profile.stepsCalibrationCoefficient > 0
|| profile.stepsManualCoefficient > 0
|| profile.stepsCalibrationSampleDays > 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -891,21 +891,25 @@ object IntelligenceEngine {
verdict == HrvAnalyzer.RrCoverageVerdict.SAME_SECOND_OVER_COUNT)
if (verdict == HrvAnalyzer.RrCoverageVerdict.CROSS_SECOND_OVER_COUNT ||
verdict == HrvAnalyzer.RrCoverageVerdict.SAME_SECOND_OVER_COUNT) {
// Built ONCE for all three diagnostics below. Each call materialised its own copy of
// the night's ords — ~70k elements, on a path that runs ~21 times per analyzeRecent,
// every 15 minutes — so two of the three were pure waste. (#1510) Twin of the Swift hoist.
val rrOrds = sleepRrRows.map { it.ord }
val sample = HrvAnalyzer.densestSecondWindowSample(
ts, sleepRr, sleepRrRows.map { it.srcChannel },
sleepRrRows.map { it.ord },
rrOrds,
)
if (sample.isNotEmpty()) dayDiag("hrv rrsample day=${res.daily.day} $sample")
// #1331/#1008: the sample above shows ords for a handful of seconds; this counts them
// across the WHOLE night, which is what decides whether the fix belongs at ingest.
val deliveries = HrvAnalyzer.deliveryHistogram(ts, sleepRr, sleepRrRows.map { it.ord })
val deliveries = HrvAnalyzer.deliveryHistogram(ts, sleepRr, rrOrds)
if (deliveries.isNotEmpty()) dayDiag("$deliveries day=${res.daily.day}")
// #1505: the histogram above counts deliveries per second but never compares what they
// wrote. If the live and historical copies are the same beat in two units, a duplicated
// second holds two values 1024/1000 apart; if they are different beats, the ratios
// scatter. One pair cannot tell those apart — a night's worth can. Measurement only,
// takes no view on the answer. Swift twin.
val dupPairs = HrvAnalyzer.duplicatePairRatios(ts, sleepRr, sleepRrRows.map { it.ord })
val dupPairs = HrvAnalyzer.duplicatePairRatios(ts, sleepRr, rrOrds)
if (dupPairs.isNotEmpty()) dayDiag("$dupPairs day=${res.daily.day}")
// #1331/#1008/#1118 SHADOW: log the DEDUPED stream's HRV + coverage + beat-accuracy
// beside the raw so the candidate de-dup can be validated vs WHOOP + @artemc's Polar
Expand Down
9 changes: 9 additions & 0 deletions android/app/src/main/java/com/noop/ui/TodayScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1528,10 +1528,19 @@ fun TodayScreen(
// Remembered on the calibration state it reads, matching the same
// preference lookup in SettingsScreen: this is a SharedPreferences
// read and the Today body recomposes with live HR.
//
// The strap model is a key too, because the prompt returns null for
// anything but a 4.0 -- and that key is rewritten under a composed
// Today, by Settings and by live detection in WhoopBleClient. Keyed
// only on the calibration values, a strap switch left the previous
// answer on screen until one of those happened to move. Re-reading
// the key per pass is a loaded-map lookup; what stays memoised is
// the rest of the prompt.
stepsCalibrationPrompt = remember(
profileStore.stepsCalibrationSampleDays,
profileStore.stepsCalibrationCoefficient,
profileStore.stepsCalibrationManual,
NoopPrefs.of(context).getString("noop.selectedWhoopModel", null),
) { stepsCalibrationPrompt(context, profileStore) },
restScore = restScoreForDay,
restSpark = restCompositeSpark,
Expand Down
Loading