Skip to content

fix(health): feed the Android body clock, closing a live cross-platform gap - #855

Merged
ryanbr merged 4 commits into
mainfrom
fix/852-android-body-clock
Jul 27, 2026
Merged

fix(health): feed the Android body clock, closing a live cross-platform gap#855
ryanbr merged 4 commits into
mainfrom
fix/852-android-body-clock

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #852. Supersedes #853, which I closed — that PR proposed deleting this, on the mistaken belief there was no Swift twin.

The gap

Body Clock has shipped on iOS/macOS all along. AppModel.computeCircadianPhase pools HR by local hour-of-day as the activity proxy, fits the cosinor, publishes circadianPhase, and SkinTempCardsView renders it.

Android has a byte-for-byte twin of the engine, its own full test suite, and a finished BodyClockCard — and never fed any of it. V5HealthSignals hardcoded bodyClock = null, so HealthScreen's ?.let skipped the card on every device.

The comment guarding it was wrong. It said we lack "per-hour rest-activity bins we don't bank here". Swift doesn't bank them either — it derives them from HR buckets Android also has. The pipe was never built on this side, not missing.

The change

Two hunks, ~30 lines. Mirrors the Swift maths exactly: 3600 s buckets over 14 days, the >= 24-bucket floor, pooling by local hour, mean bpm per populated hour, distinct local days as daysObserved, and the >= 6-bin floor before a fit is attempted.

The binning lives in AppViewModel, not CircadianEngine, because Swift keeps it in AppModel — the engine stays an untouched byte-for-byte mirror on both platforms, which is the point.

Its pure half is a top-level circadianBinsFrom so it is testable with no Context, no ViewModel and no database.

Verification

Six tests pin the floors, local-hour pooling, mean-not-sum, the negative-offset wrap and sparse coverage. Every expectation was cross-checked against an independent implementation of the algorithm before commit:

OK under24     bins=0  days=0      OK plus1h48   bins=24 days=3
OK exactly24   bins=24 days=1      OK neg5h      bins=24 (hours stay 0..23)
OK meanNotSum  bins=24 mean=60.0   OK sparse6x5  bins=6  days=5

Both new parameters default, so every existing evaluate() caller is byte-identical. The synchronous settings-toggle re-evaluate reuses the collector pass's cached bins rather than blanking the estimate — it has no coroutine to read the store from.

Known divergence, deliberately not closed here

Swift derives habitualWakeHour from the most recent banked wake, falling back to 07:00. Kotlin has no such helper, so this uses the existing 7.0 default. That shifts offsetVsScheduleMinutes for anyone who doesn't wake at 07:00 — real and bounded, worth a follow-up, but a wake-time derivation is a separate change.

What this does NOT settle

Whether this should ship at all. Without an observedTempMinHour, estimatePhase derives the CBT minimum as activityAcrophase − 12.0h — a population constant — and the confidence bands key on data sufficiency, not on whether that constant holds for this person.

That question is about the already-shipped Apple card. Leaving Android silently inert was never an answer to it, which is why this lands first: it makes the platforms consistent so the real decision can be made once, for both.

Not compiled — android.yml is disabled.

ryanbr added 2 commits July 26, 2026 19:02
…rm gap (#852)

Body Clock has shipped on iOS/macOS all along: AppModel.computeCircadianPhase pools HR
by local hour-of-day as the activity proxy, fits the cosinor, publishes circadianPhase,
and SkinTempCardsView renders it. Android has a byte-for-byte twin of the engine, its
own full test suite and a finished BodyClockCard — and never fed any of it.
V5HealthSignals hardcoded `bodyClock = null`, so HealthScreen's `?.let` skipped the card
on every device.

The comment that guarded it was wrong: it said we lack "per-hour rest-activity bins we
don't bank here". Swift does not bank them either — it derives them from HR buckets
Android also has. The pipe was never built on this side, not missing.

Mirrors the Swift maths exactly: 3600 s buckets over 14 days, the >= 24-bucket floor,
pooling by LOCAL hour, mean bpm per populated hour, distinct local days as daysObserved,
and the >= 6-bin floor before a fit is attempted. The binning lives in AppViewModel
rather than CircadianEngine precisely because Swift keeps it in AppModel — the engine
stays an untouched byte-for-byte mirror on both platforms.

Its pure half is a top-level `circadianBinsFrom` so it is unit-testable with no Context,
no ViewModel and no database; six tests pin the floors, local-hour pooling, mean-not-sum,
the negative-offset wrap and sparse coverage. Every expectation was cross-checked against
an independent implementation before commit.

Both new parameters default, so every existing evaluate() caller is byte-identical. The
synchronous settings-toggle re-evaluate reuses the collector pass's cached bins rather
than blanking the estimate — it has no coroutine to read the store from.

KNOWN DIVERGENCE, deliberately not closed here: Swift derives habitualWakeHour from the
most recent banked wake and falls back to 07:00; Kotlin has no such helper, so this uses
the existing 7.0 default. That shifts offsetVsScheduleMinutes for anyone who does not
wake at 07:00. Worth a follow-up; adding a wake-time derivation is a separate change.

Does NOT settle whether this should ship at all. Without an observedTempMinHour the
estimate derives the CBT minimum as `activityAcrophase - 12.0h`, a population constant,
and the confidence bands key on data sufficiency rather than on whether that constant
holds for this person. That question is about the ALREADY-SHIPPED Apple card; leaving
Android silently inert was not an answer to it.
Re-reviewing my own PR: the Swift twin's repo.hrBuckets(from:to:bucketSeconds:) UNIONs
the active strap with the canonical "my-whoop" and dedupes by bucket start. I called
Kotlin's single-id hrBuckets, so after a strap re-add Android would read strictly less
data than Apple — plausibly missing the >= 24-bucket floor and rendering no estimate
where Swift renders one.

hrBucketsUnion is documented as the twin of exactly that overload. Single-WHOOP install
resolves to one id either way, so nothing changes for most users; the divergence only
appears on the multi-namespace case the read spine exists for.

A parity bug in the parity fix.
ryanbr added 2 commits July 26, 2026 19:09
A plain var written from a coroutine and read from a UI callback looks like a race, and
a reviewer would reasonably stop on it. It is not one: the write resumes on
viewModelScope (Dispatchers.Main.immediate) and the read is a main-thread settings
callback, so both touch it on the main thread. The Swift twin gets the same guarantee
from @mainactor on AppModel.

Recording the reasoning rather than making the next reader re-derive it.
java.util.TimeZone sorted before kotlin.math.roundToInt, matching the java-then-kotlin
order used elsewhere in the tree (HealthConnectImporter, DataBackup).

Stopped round-tripping the clock: it divided currentTimeMillis to seconds and multiplied
back to look up the timezone offset. Keeps nowMs and uses it directly. No behaviour
change — the offset is identical anywhere inside the same second.
@ryanbr

ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Cleared the two nits from the last pass, both cosmetic:

  • java.util.TimeZone now sorts before kotlin.math.roundToInt, matching the java-then-kotlin order used elsewhere in the tree (HealthConnectImporter, DataBackup).
  • Stopped round-tripping the clock — it divided currentTimeMillis() to seconds and multiplied back to look up the timezone offset. Keeps nowMs and uses it directly. No behaviour change; the offset is identical anywhere inside the same second.

Also documented, from the pass before: lastCircadianBins is a plain var written from a coroutine and read from a UI callback, which reads as a race and is not one — the write resumes on viewModelScope (Dispatchers.Main.immediate) and the read is a main-thread settings callback. The Swift twin gets the same guarantee from @MainActor. Recorded so the next reader does not have to re-derive it.

Both gates green. Nothing else outstanding from my side — the two limitations in the description (habitualWakeHour defaulting to 7.0, and the unresolved population-constant question) are deliberate and stated, not oversights.

@ryanbr
ryanbr merged commit ce94e69 into main Jul 27, 2026
2 checks passed
@ryanbr
ryanbr deleted the fix/852-android-body-clock branch July 27, 2026 02:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[health][parity] Body Clock ships on iOS/macOS but is inert on Android — the Kotlin twin is never fed

1 participant