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
60 changes: 58 additions & 2 deletions lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1471,8 +1471,64 @@ import 'substrate.dart';
// trace already priced is never double-billed. THIS CAN RAISE `calories` /
// `calories_total` for a day with a workout the day trace fully missed;
// every other day is unaffected.
const int kAlgoVersion = 81;

// v82 — THE ZONE CEILING IS `max(observed, age estimate)`.
// `trainingZones` (compute/hr_max.dart) preferred `observedCeilingBpm` whenever
// one existed, in either direction. But that number is one-sided evidence:
// holding 195 proves the ceiling is at least 195, while holding 166 cannot tell
// "never went maximal" from "maxes at 166" — and on a wrist the first is far
// commoner. So a 25-year-old whose hardest HELD effort was 166 was banded at
// Z5 = 0.9·166 ≈ 149 bpm and banked 13 of the 16 minutes of a steady run in
// "Max effort". On the session-detail path the ceiling is even set by the
// session being graded — `_zoneAnchors` takes the all-time max including today
// (the day pipeline and the live tick do NOT: `observedHrCeilingBpm` is
// strictly-before, and `LiveWorkoutState.zoneSet` is pinned at start).
//
// The observed ceiling now anchors zones only when it REACHES the age line,
// which is the only direction in which it bounds anything. Below it, zones stay
// on `208 − 0.7·age`, LABELLED as the estimate. There is deliberately no
// tolerance band: a switch at `estimate − k` would move every edge k bpm on a
// 0.1 bpm change in a ceiling that creeps up over months, while `max` is
// continuous. `observed`/`karvonen` therefore now mean "this user beat the
// population line", which is a narrower and truer claim than before.
//
// WHAT ACTUALLY MOVES, for affected users only — narrower than it first looks,
// and the two exceptions are both worth knowing:
//
// * `zone_timeline` and `zone_source` move: both come off the pure pipeline's
// `trainingZones` set (onehz_pipeline.dart:630).
// * The day's `zones` DO NOT, because they never sat on the observed ceiling
// in the first place. The second derivation half recomputes them from
// `estimatedMaxHr` alone (`_wakeZoneMinutes` at :4662, `zonesFromMaxHr` at
// :4680) and `bundle['zones'] = wake['zones']` at :4920 overwrites the
// pipeline's. So the day BARS have always been Tanaka-binned while the
// footnote beside them read `zone_source`. That is a separate, pre-existing
// one-source-per-concern break (§3.8) and it is NOT fixed here; fixing it
// means threading the anchors through `_DayBlocksInput` across the isolate
// boundary, which moves `zones` for every user and is its own change.
// * Sessions rebin only where they still can. `getWorkout` recomputes
// `zone_bands` on every open, from the substrate or the frozen trace, so
// the DETAIL card is always current. The persisted `zone_min` behind the
// bars is rewritten by `rescoreRecentSessions(sinceDays: 3)` and by the
// rescore on open — but a session whose raw aged out past
// `rawRetentionDays` keeps the split it was scored with. Old cards are not
// healed by this bump, and no bump can heal them.
//
// TS-05's 28-day distribution disappears for anyone it was drawn for off a
// below-line ceiling, which is that gate working: `zonesAreMeasured` was never
// true of bands whose 100 % nobody reached. Strain, TRIMP and calories DO NOT
// MOVE — they anchor on `estimatedMaxHr`, never on the observed ceiling.
//
// `hr_ceiling_bpm` itself is untouched: the ceiling and its date are still
// measured, still stored and still served, so the zones screen keeps saying
// "highest we have seen: 166 on 3 Aug". It just no longer becomes everyone's
// 100 %. No sibling pin moves — this is entirely an edge-side anchor choice.
//
// RENUMBERED ON THE MERGE, 81 → 82. Both sides of this merge bumped to 81:
// main for the active-energy workout-gap credit above, this branch for the
// zone anchor. Two different derivations cannot share one number — that is
// the whole contract of this constant, and main's 81 is the one already on
// main, so it keeps it. Nothing about the zone change itself moved.
const int kAlgoVersion = 82;
/// The sibling SHAs this version was derived against, asserted against
/// pubspec.yaml in test/db_serve_version_and_reads_test.dart.
///
Expand Down
40 changes: 35 additions & 5 deletions lib/compute/hr_max.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,37 @@ double? estimatedMaxHr(num? age, String? deviceFamily) {
/// * `observed` — the ceiling is measured, the resting-HR history is still too
/// short for a reserve anchor ([HeartRateZones.reserveMinDays]), so these are
/// %HRmax bands off the observed ceiling.
/// * `tanaka` — no observed ceiling yet: %HRmax off `208 − 0.7·age`, i.e. the
/// AGE ESTIMATE. Byte-for-byte what this app did before TS-03 landed, so a
/// user with no observed ceiling sees no number move.
/// * `tanaka` — no observed ceiling yet, OR one that does not reach the age
/// line: %HRmax off `208 − 0.7·age`, i.e. the AGE ESTIMATE. Byte-for-byte
/// what this app did before TS-03 landed.
///
/// THE CEILING IS `max(observed, estimate)`, AND THAT ASYMMETRY IS THE WHOLE
/// RULE. An observed ceiling is ONE-SIDED evidence. Holding 195 proves the
/// ceiling is AT LEAST 195 — real information the age line does not have, and
/// it wins. Holding 166 proves nothing about the top: "has never gone maximal"
/// and "genuinely maxes at 166" produce the identical number, and on a wrist
/// the first is by far the commoner. Preferring it in BOTH directions is how a
/// 25-year-old whose hardest HELD effort was 166 got Z5 at 0.9·166 ≈ 149 bpm
/// and banked 13 of the 16 minutes of a steady run in "Max effort", captioned
/// as the age estimate.
///
/// On the session-detail path the ceiling was even set by the session being
/// graded — `_zoneAnchors` takes the all-time max, today included. The day
/// pipeline and the live tick do not: `observedHrCeilingBpm` is
/// strictly-before-today by design, and the live `zoneSet` is pinned at start.
///
/// There is deliberately NO tolerance band below the estimate. A threshold at
/// `estimate − k` puts a cliff in the middle of the range the ceiling creeps
/// through: at 30 an observed 177 would anchor at 177 and 176.9 at 187, moving
/// every edge 10 bpm on a 0.1 bpm change, with nothing on screen to explain it.
/// `max` is continuous — at 186.9 vs 187.1 only the LABEL moves.
///
/// The cost, which is chosen and not overlooked: someone whose HRmax is
/// genuinely below their age line gets a Z5 they cannot reach. That is an
/// under-report — an absence — where the old behaviour was an affirmative
/// "max effort" the data did not support, and this app abstains before it
/// asserts. The set says `tanaka`, and the zones screen names the held ceiling
/// and why it is not being used (`maximal_effort`).
///
/// Null only when there is no ceiling at all — no observed ceiling AND no age.
/// An unstamped strap is no longer one of those cases: Tanaka does not read a
Expand All @@ -99,7 +127,10 @@ ana.HeartRateZoneSet? trainingZones({
double? observedCeilingBpm,
List<double> restingHrHistory = const [],
}) {
if (observedCeilingBpm != null && observedCeilingBpm > 0) {
final est = estimatedMaxHr(age, deviceFamily);
if (observedCeilingBpm != null &&
observedCeilingBpm > 0 &&
(est == null || observedCeilingBpm >= est)) {
return ana.HeartRateZones.reserveZones(
restingHrHistory: restingHrHistory,
maxHr: observedCeilingBpm,
Expand All @@ -109,7 +140,6 @@ ana.HeartRateZoneSet? trainingZones({
source: 'observed',
);
}
final est = estimatedMaxHr(age, deviceFamily);
return est == null
? null
: ana.HeartRateZones.zonesFromMaxHr(est, source: 'tanaka');
Expand Down
52 changes: 44 additions & 8 deletions lib/data/local_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2089,6 +2089,12 @@ class LocalRepositoryImpl extends LocalRepository {
// list and the share card see the corrected value too.
final rescored = await _rescoreSessionFromSubstrate(stored);
final w = _workoutOf(rescored.row);
// TS-04 — whether `zone_min` below and the `zone_bands` added further down
// describe the SAME zone set. They are recomputed from the current anchors
// while the minutes can be a kept live split binned against an older
// ceiling, and the detail card names the bands' ceiling under the minutes'
// bars. False means it must not.
w['zone_min_rebinned'] = rescored.zoneMinutesRebinned;
final startTs = w['start_ts'] as int?;
if (startTs == null) return w;
final endTs =
Expand Down Expand Up @@ -2651,7 +2657,22 @@ class LocalRepositoryImpl extends LocalRepository {
/// improves on each pass and converges. Returns the row with the reconciled
/// values applied (never null-out a stored value), writing back only on a
/// real change. Best-effort — never throws into a read path.
Future<({Map<String, dynamic> row, List<Map<String, dynamic>>? hrRows})>
/// [zoneMinutesRebinned] answers ONE question for the caller: were the zone
/// minutes on the returned row binned by THIS pass, i.e. by the same zone set
/// [_zoneBands] is about to band the detail card's bars with? False when the
/// reconcile kept the LIVE split — a session the band only partly handed over
/// keeps whichever side saw more minutes, and that side was binned against
/// whatever ceiling was current when it was written. True on every path that
/// did not run the reconcile at all (no substrate, unfinished, row moved):
/// those serve the FROZEN trace, whose bands were banked beside the same
/// minutes, so there is nothing for the caller to correct for.
Future<
({
Map<String, dynamic> row,
List<Map<String, dynamic>>? hrRows,
bool zoneMinutesRebinned,
})
>
_rescoreSessionFromSubstrate(Map<String, dynamic> row) async {
final id = row['id'];
final startTs = (row['start_ts'] as num?)?.toInt();
Expand All @@ -2662,13 +2683,15 @@ class LocalRepositoryImpl extends LocalRepository {
endTs == null ||
endTs <= startTs ||
(row['status']?.toString() ?? '') != 'done') {
return (row: row, hrRows: null);
return (row: row, hrRows: null, zoneMinutesRebinned: true);
}
try {
// Returned to the caller: `getWorkout` enriches from the SAME 1 Hz window
// straight after this, and a two-hour session is ~7200 rows to scan twice.
final hrRows = await LocalDb.hrSamplesInRange(startTs, endTs);
if (hrRows.isEmpty) return (row: row, hrRows: hrRows);
if (hrRows.isEmpty) {
return (row: row, hrRows: hrRows, zoneMinutesRebinned: true);
}

final profile = Profile.fromMap(getProfileMap());
final hrBpm = [for (final e in hrRows) (e['hr'] as num).toInt()];
Expand Down Expand Up @@ -2719,8 +2742,12 @@ class LocalRepositoryImpl extends LocalRepository {
final storedSamples = (row['trace_samples'] as num?)?.toInt();
final needsTrace =
storedSamples == null || stats.hrSampleCount > storedSamples;
// `identical`, not `==`: `reconcileSessionScore` returns one of the two
// vectors it was handed, so identity IS the answer to which side won —
// the same test its own `changed` flag is built on.
final rebinned = identical(merged.zoneMinutes, stats.zoneMinutes);
if (!merged.changed && !needsAvgBackfill && !needsTrace) {
return (row: row, hrRows: hrRows);
return (row: row, hrRows: hrRows, zoneMinutesRebinned: rebinned);
}

// `putSession` is INSERT-OR-REPLACE on the whole row, and everything
Expand All @@ -2738,7 +2765,7 @@ class LocalRepositoryImpl extends LocalRepository {
// read — they describe the old window, and `getWorkout` would enrich
// the new one with them (a negative time-to-peak, zones over the wrong
// span). The next pass scores the new window.
return (row: current ?? row, hrRows: null);
return (row: current ?? row, hrRows: null, zoneMinutesRebinned: true);
}

final zoneJson = jsonEncode(
Expand Down Expand Up @@ -2793,9 +2820,10 @@ class LocalRepositoryImpl extends LocalRepository {
'trace_json': ?traceJson,
if (traceJson != null) 'trace_samples': stats.hrSampleCount,
};
return (row: updated, hrRows: hrRows);
return (row: updated, hrRows: hrRows, zoneMinutesRebinned: rebinned);
} catch (_) {
return (row: row, hrRows: null); // best-effort: the stored row renders
// best-effort: the stored row renders
return (row: row, hrRows: null, zoneMinutesRebinned: true);
}
}

Expand Down Expand Up @@ -3806,7 +3834,15 @@ class LocalRepositoryImpl extends LocalRepository {
// The ceiling's OWN reason outranks "no hard session yet" —
// on all three real databases it refused for an unstamped
// strap, which a hard session cannot fix.
? ceilingNote ?? needInputNote('observed_ceiling')
? ceilingNote ??
// A ceiling that EXISTS and was rejected as an anchor
// ([kCeilingCredibleGapBpm]) is not a missing one, and
// this card shows it two rows up with its date. Asking
// for the number already on screen is the false reason
// the note grammar exists to prevent.
needInputNote(
ceiling != null ? 'maximal_effort' : 'observed_ceiling',
)
: !measured
? needInputNote(
'resting_hr_days',
Expand Down
8 changes: 8 additions & 0 deletions lib/models/metric.dart
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,14 @@ const _inputWhy = {
'observed_ceiling':
'The band has not yet held a high enough heart rate through a hard '
'effort to measure a ceiling from.',
// DISTINCT from `observed_ceiling`, and the distinction is the whole point:
// there IS a held ceiling, it is on the screen with its date, and the card
// would otherwise ask for the thing it is simultaneously showing.
'maximal_effort':
'The highest heart rate held so far sits well below what your age '
'predicts, so it reads as an effort that was never maximal rather '
'than as your ceiling — the zones stay on the age estimate until the '
'band sees a harder one.',
'resting_hr_days':
'Not enough nights of resting heart rate behind the reserve yet.',
'sessions':
Expand Down
33 changes: 32 additions & 1 deletion lib/ui2/activity/catalogue.dart
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,39 @@ const kCalorieWhy = 'MET value × your weight, refined by heart rate.';
///
/// Never "fat burning zone", never "aerobic threshold": these are convention
/// edges on a guessed ceiling, not measurements of anything metabolic.
/// NOT "your age and your strap": [estimatedMaxHr] takes `deviceFamily` and
/// DELIBERATELY IGNORES IT (hr_max.dart) — Tanaka is a population regression on
/// age alone, and swapping the strap does not move it by one bpm. The sentence
/// named an input that provably has no effect on the number it describes.
const kZonesWhy = 'Zone edges are percentages of a maximum heart rate '
'estimated from your age and your strap — not one measured on you.';
'estimated from your age — not one measured on you.';

/// THE sentence a zone chart carries, for the anchors THAT chart was banded on.
///
/// [source] is the set's own stamp (`karvonen` · `observed` · `tanaka`) and
/// [maxHr] the ceiling it is 100 % of. One function because the day-strain
/// detail and a session's summary card draw the same bands off the same
/// `trainingZones` set, and only one of them was reading the stamp: the summary
/// hard-coded [kZonesWhy] and so told a user whose zones were banded on a
/// MEASURED ceiling that they came from their age. A card that reports 13
/// minutes above a boundary its own footnote misattributes is unanswerable —
/// there is no number on screen to check it against.
///
/// [kZonesWhy] is the fallback rather than a fourth branch: an unknown stamp
/// and a measured stamp with no ceiling to name are both "we cannot say this
/// was measured on you", which is what the estimate sentence already says.
String zonesWhy(String? source, num? maxHr) => maxHr == null
? kZonesWhy
: switch (source) {
'karvonen' =>
'Zone edges span the gap between your measured resting heart rate '
'and the highest we have seen (${maxHr.round()} bpm). Both '
'measured on you.',
'observed' =>
'Zone edges are percentages of the highest heart rate we have seen '
'(${maxHr.round()} bpm) — measured, not estimated.',
_ => kZonesWhy,
};

/// The row that means most people never open the catalogue.
const quickStart = <Activity>[
Expand Down
14 changes: 2 additions & 12 deletions lib/ui2/activity/day_strain.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import '../../models/metric.dart' show whyFromNote;
import '../screens/home_screen.dart' show repoOf;
import '../screens/metric_detail.dart' show detailScaffold;
import '../ui2.dart';
import 'catalogue.dart' show kZonesWhy;
import 'catalogue.dart' show zonesWhy;
import 'zones.dart' show ZonesDetail;

/// Below this the day is not comparable to a full one and the screen says so.
Expand Down Expand Up @@ -333,17 +333,7 @@ class _DayStrainDetailState extends State<DayStrainDetail> {
// "estimated from your age" would then be false. The 28-day
// distribution is NOT here — it lives one tap away and is gated on
// the same anchors (TS-05).
footnote: switch (d.zoneSource) {
'karvonen' =>
'Zone edges span the gap between your measured resting heart '
'rate and the highest we have seen (${d.zoneMaxHr?.round()} '
'bpm). Both measured on you.',
'observed' =>
'Zone edges are percentages of the highest heart rate we have '
'seen (${d.zoneMaxHr?.round()} bpm) — measured, not '
'estimated.',
_ => kZonesWhy,
},
footnote: zonesWhy(d.zoneSource, d.zoneMaxHr),
child: CustomPaint(
size: Size.infinite,
painter: ZoneBar([for (final v in z) v / total], p),
Expand Down
11 changes: 11 additions & 0 deletions lib/ui2/activity/live.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ class LiveFeed {
final int? steps;
final List<double> zoneMinutes; // five, Z1..Z5

/// The stamp on the set [zoneMinutes] is binned with, and the ceiling it is
/// 100 % of — `LiveWorkoutState.zoneSet`, pinned at session start. Carried so
/// the summary this screen hands over on stop describes the same anchors the
/// live bar was drawn against.
final String? zoneSource;
final num? zoneMaxHr;

/// Per-minute mean heart rate for the session so far, DENSE — one slot per
/// session minute, `null` where the band recorded nothing.
///
Expand Down Expand Up @@ -97,6 +104,8 @@ class LiveFeed {
this.distanceKm,
this.steps,
this.zoneMinutes = const [],
this.zoneSource,
this.zoneMaxHr,
this.hrCurve = const [],
this.route = const [],
this.gpsActive = false,
Expand Down Expand Up @@ -929,6 +938,8 @@ ActivityResult _baseResult(
strain: feed.strain,
hr: feed.hrCurve,
zoneMinutes: feed.zoneMinutes,
zoneSource: feed.zoneSource,
zoneMaxHr: feed.zoneMaxHr,
// The same count the live screen has printed all session, carried onto
// the summary it hands over to. Null stays null: `stopWorkout` only banks
// `sessions.steps` when there is one, so an unmeasured session reads the
Expand Down
Loading
Loading