Skip to content

Day zone bars are binned on the age estimate while zone_source/zone_timeline describe the measured ceiling #333

Description

@DropTabl

Found while fixing #290 (PR #293) and deliberately left out of that PR, which only had room for the session card. Line numbers are against 855ddd7.

The day's zone bars are binned on 208 − 0.7·age, under a footnote that names whatever anchors trainingZones chose. The same persisted bundle carries zones from one zone set and zone_timeline / zone_source / zone_max_hr from a different one.

The two producers

The pure pipeline bins everything on the one set (lib/compute/onehz_pipeline.dart):

final zoneSet = trainingZones(…, observedCeilingBpm: d.observedHrCeilingBpm, …);  // :664
hrZones      = _wakeZoneMinutesFromSeries(wakeHr, zoneSet);                       // :747
zoneTimeline = _zoneTimeline(wakeHr, zoneSet);                                    // :854
'zone_source': zoneSet?.source,                                                   // :1271
'zone_max_hr': …zoneSet.maxHr…,                                                   // :1272

The coordinator then recomputes zones alone, from the age estimate only (lib/compute/derivation_engine.dart):

final hrMax = estimatedMaxHr(profile.ageYears, daySub.deviceFamily);   // :5800 — Tanaka
// "Zones are pure %HRmax bands — real as soon as HRmax is real."
zones = _wakeZoneMinutes(daySub, sleepOnsetSec, sleepOffsetSec, hrMax); // :5902final zoneSet = ana.HeartRateZones.zonesFromMaxHr(hrMax);          // :5042

and overwrites the pipeline's bars in the merge:

bundle['zones'] = wake['zones'];   // :5377

Nothing overwrites zone_source, zone_max_hr or zone_timeline, so the pipeline's values survive beside bars that no longer match them. That comment at :5901 is the tell — it predates TS-03/TS-04, when %HRmax off the age estimate was the only zone set.

What the user sees

lib/ui2/activity/day_strain.dart reads the bars from s['zones'] (:131:142:314) and the footnote from s['zone_source'] / s['zone_max_hr'] (:144-145). So for anyone with a measured ceiling the day screen draws Tanaka-binned bars under "Zone edges span the gap between your measured resting heart rate and the highest we have seen (196 bpm). Both measured on you."

Reachable exactly when trainingZones returns a non-tanaka set — i.e. an observed ceiling at or above the age line, which since #293 is the only way to earn observed/karvonen at all. Before #293 it was reachable for anyone with any observed ceiling, which is most users.

Also worth noting: zones and zone_timeline are two views of the same day, and they are binned by different sets. A day whose timeline is entirely Z1 can have its bar chart report Z2.

Why it hasn't been caught

test/hr_ceiling_zones_test.dart:292 asserts precisely this invariant:

test('the zone timeline is binned by the same set as the zone minutes', () {
  final tl = (bundle(ceiling: 196)['series'] as Map)['zone_timeline'];

but bundle(…) is deriveDayBundle(…) — the pure pipeline in isolation. The coordinator's overwrite never runs, so the guard passes on a path where the bug cannot appear. No test drives _computeDayBlocks' zones.

Fix sketch

_DayBlocksInput carries neither the observed ceiling nor the resting-HR history, so the coordinator has no way to build the real set. Either:

  • thread both anchors through _DayBlocksInput and have _wakeZoneMinutes take a HeartRateZoneSet instead of a double hrMax — the anchors cross the isolate boundary as two plain values; or
  • drop bundle['zones'] = wake['zones'] and keep the pipeline's bars, if the second half's recompute has no other reason to own them (worth checking against the trimp carve-out immediately above it at :3500, where the halves' ownership is already split by key).

Either way it moves zones for every user with a measured ceiling, so it needs a kAlgoVersion bump, and the regression test has to go through the engine rather than deriveDayBundle.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions