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
422 changes: 396 additions & 26 deletions lib/compute/derivation_engine.dart

Large diffs are not rendered by default.

46 changes: 29 additions & 17 deletions lib/compute/manual_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -251,33 +251,39 @@ List<double> zoneMinutesFor(
/// ceiling is a property of the strap that measured the window as well as the
/// athlete's age (`estimatedMaxHr`), and this scorer is device-agnostic. Null
/// — no age, or an uncalibrated/unstamped strap — abstains.
///
/// [quietHrr] is THIS user's quiet-waking level for the day the window sits in
/// (`personalQuietLevelBefore`) — the level that day's own strain was priced
/// on, so a bout and its day are on one baseline. Null abstains: the 0.20
/// population reference billed being awake as load for some users and erased
/// real workouts for others. [quietSettled] false only lowers confidence
/// inside analytics; the value is the same.
double? strainFromPerMinuteHr(
List<double> perMinuteHr, {
required Profile profile,
required double? restingHr,
required double? hrMax,
required double? quietHrr,
bool quietSettled = true,
}) {
final sex = profile.sex?.toLowerCase();
if (perMinuteHr.isEmpty || hrMax == null || restingHr == null || sex == null) {
if (perMinuteHr.isEmpty ||
hrMax == null ||
restingHr == null ||
sex == null ||
quietHrr == null) {
return null;
}
final trimp = ana.banisterTrimp(
// Banister TRIMP above the quiet level over the window's own minutes, with
// exercise minutes never offset by quiet ones — the same analytics call the
// day headline makes, so "workout strain" and "daily strain" are one method.
final score = ana.strainScoreFromSeries(
perMinuteHr,
restingHr: restingHr,
maxHr: hrMax,
quietHrr: quietHrr,
sex: workoutSex(sex) == 'female' ? ana.Sex.female : ana.Sex.male,
);
if (!trimp.present || trimp.value == null) return null;
// The window's own length is the baseline window: strain is the load earned
// ABOVE quiet waking, and the same sex constant has to price the baseline as
// priced the TRIMP or the subtraction is off by the male/female coefficient.
final score = ana.strainScoreMetric(
trimp.value,
wakeMinutes: perMinuteHr.length.toDouble(),
// Reference level, not this user's — see onehz_pipeline's
// `strainMetric` for why, and edge#226 for the fix.
quietHrr: ana.quietWakingHrr,
female: workoutSex(sex) == 'female',
quietSettled: quietSettled,
);
return score.present ? score.value : null;
}
Expand All @@ -289,8 +295,8 @@ double? strainFromPerMinuteHr(
/// [restingHr] is the nightly/user RHR; [profile] supplies age/weight/sex.
///
/// Uses the SAME published methods as the day-level derivation so a manual
/// session and the day it sits in are on one scale: `ana.banisterTrimp` ->
/// `ana.strainScoreMetric` for strain, `ana.Calories.estimateBoutCalories`
/// session and the day it sits in are on one scale: `ana.strainScoreFromSeries`
/// against [quietHrr] (the session day's level) for strain, `ana.Calories.estimateBoutCalories`
/// (Keytel 2005) for kcal. A missing anchor makes the dependent metric null.
///
/// [hrMax] is THE ceiling for this window — `estimatedMaxHr(age, family)`,
Expand All @@ -308,6 +314,8 @@ ManualSessionStats computeManualSessionStats({
required double? hrMax,
double? restingHr,
ana.HeartRateZoneSet? zoneSet,
double? quietHrr,
bool quietSettled = true,
}) {
if (hrTs.isEmpty || hrTs.length != hrBpm.length) {
return const ManualSessionStats();
Expand Down Expand Up @@ -344,7 +352,11 @@ ManualSessionStats computeManualSessionStats({
final sex = profile.sex?.toLowerCase();

final strain = strainFromPerMinuteHr(perMin,
profile: profile, restingHr: restingHr, hrMax: hrMax);
profile: profile,
restingHr: restingHr,
hrMax: hrMax,
quietHrr: quietHrr,
quietSettled: quietSettled);

double? calories;
if (profile.hasCalorieAnchors &&
Expand Down
117 changes: 54 additions & 63 deletions lib/compute/onehz_pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,12 @@ class DayBundleInput {
/// matches today's raw mean (the old z-vs-z series was a unit mismatch bug).
final List<double> skinTempAdcHistory;

/// Trailing per-day quiet-waking levels (`quiet_hrr`, %HRR fraction),
/// strictly before this day, oldest→newest. Strain is priced against their
/// median ([personalQuietWakingHrr]) — never against this day's own level,
/// which would subtract the day's own living from itself.
final List<double> quietHrrHistory;

/// TS-03 — the highest heart rate the band has OBSERVED (held >=15 s with
/// corroborating motion, `observed_max_hr.dart`) on any day STRICTLY BEFORE
/// this one, or null when there is none. Not a physiological HRmax: if the
Expand Down Expand Up @@ -350,6 +356,7 @@ class DayBundleInput {
this.respHistory = const [],
this.rmssdHistory = const [],
this.skinTempAdcHistory = const [],
this.quietHrrHistory = const [],
this.observedHrCeilingBpm,
this.dayConfidence = 0,
this.dayFlags = const [],
Expand Down Expand Up @@ -381,6 +388,7 @@ class DayBundleInput {
'resp_history': respHistory,
'rmssd_history': rmssdHistory,
'skin_temp_adc_history': skinTempAdcHistory,
'quiet_hrr_history': quietHrrHistory,
'observed_hr_ceiling_bpm': observedHrCeilingBpm,
'day_confidence': dayConfidence,
'day_flags': dayFlags,
Expand Down Expand Up @@ -431,6 +439,7 @@ class DayBundleInput {
respHistory: dbls('resp_history'),
rmssdHistory: dbls('rmssd_history'),
skinTempAdcHistory: dbls('skin_temp_adc_history'),
quietHrrHistory: dbls('quiet_hrr_history'),
observedHrCeilingBpm: (m['observed_hr_ceiling_bpm'] as num?)?.toDouble(),
dayConfidence: (m['day_confidence'] as num?)?.toDouble() ?? 0,
dayFlags: strs('day_flags'),
Expand Down Expand Up @@ -823,6 +832,11 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> inputJson) {
// TRIMP, HR zones, and calories so all three see the same wake series).
final wakeHr = _perMinuteWakeSeries(d);
final perMin = [for (final p in wakeHr) p.hr];
// THIS user's quiet-waking level: the median of the trailing days' own
// levels, strictly before today. Absent below three days — strain then
// abstains with the baseline grammar rather than pricing being awake at a
// population constant.
final quiet = personalQuietWakingHrr(d.quietHrrHistory);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
// ── WHY the activity family is absent, named AT THE GATE THAT CAUSED IT ────
//
// These four figures — strain/TRIMP, the zone minutes, the ceiling they were
Expand Down Expand Up @@ -857,6 +871,8 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> inputJson) {
? needInputNote('resting_hr')
: sex == null
? needInputNote('sex')
: !quiet.present
? (quiet.note ?? kUnknownAbsenceNote)
: null;
final caloriesAbsentNote = perMin.isEmpty
? needInputNote('wake_hr')
Expand Down Expand Up @@ -950,26 +966,25 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> inputJson) {
}
}

// HEADLINE STRAIN = 0–21 map of the TRIMP earned ABOVE the quiet-waking
// baseline; raw TRIMP kept as a detail. `perMin` is the wake window the TRIMP
// was accumulated over, so it sets the baseline that gets subtracted.
final rawTrimp = trimp.present ? trimp.value : null;
final strainMetric = strainScoreMetric(
rawTrimp,
wakeMinutes: perMin.isEmpty ? null : perMin.length.toDouble(),
// THE REFERENCE LEVEL, NOT THIS USER'S (edge#226 is still open). analytics
// stopped defaulting the quiet-waking level so every caller has to state
// which one it means; `quietWakingHrr` is the constant the anchor table was
// generated at, so passing it reproduces the strain this app ships today
// and nobody's number moves on this commit. The real level is
// `dailyQuietWakingHrr` fed through a rolling personal median — a trait,
// not a day, and the workout scorers need the same one the day uses or a
// bout subtracts its own effort away. That plumbing is edge#226.
// ponytail: population constant, swap for the rolling personal median when
// edge#226 lands — see the same comment at the other four call sites.
quietHrr: quietWakingHrr,
female: workoutSex(sex) == 'female',
);
// HEADLINE STRAIN = 0–21 map of the TRIMP earned ABOVE this user's
// quiet-waking level, exercise minutes (≥ 40 % HRR) never offset by quiet
// ones; raw TRIMP kept as a detail. Same analytics arithmetic as the curve
// below and the engine's recompute, so the three cannot disagree.
final sexEnum = workoutSex(sex) == 'female' ? Sex.female : Sex.male;
final strainMetric = strainAbsentNote != null || !quiet.present
? Metric<double>.absent(
tier: Tier.estimate,
inputs_used: const ['hr_1hz', 'profile', 'quiet_waking_hrr_history'],
note: strainAbsentNote ?? kUnknownAbsenceNote,
)
: strainScoreFromSeries(
perMin,
restingHr: rhrForTrimp,
maxHr: hrMax,
quietHrr: quiet.value!.hrr,
sex: sexEnum,
quietSettled: quiet.value!.settled,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── curve series for the UI ────────────────────────────────────────────────
final hrCurve = _downsampleHr(d.dayTsSec, d.dayHr);
Expand Down Expand Up @@ -1002,6 +1017,7 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> inputJson) {
restingHr: rhrForTrimp,
maxHr: hrMax,
sex: sex,
quietHrr: quiet.value?.hrr,
);
final zoneTimeline = zoneSet == null
? const <Map<String, num>>[]
Expand Down Expand Up @@ -1802,50 +1818,25 @@ List<Map<String, num>> _strainCurve(
required double? restingHr,
required double? maxHr,
required String? sex,
required double? quietHrr,
}) {
if (wakeHr.isEmpty ||
restingHr == null ||
maxHr == null ||
maxHr <= restingHr ||
sex == null) {
return const [];
}
// Banister's sex constants, via the ONE shared weighting factor. This used to
// inline `exp(b·hrr)` and drop the 0.64/0.86 scale coefficient entirely, so
// the curve accumulated a TRIMP 1.5625× the day's own — the curve and the
// headline were never on the same scale. It matters more now: the headline
// subtracts a baseline priced with `banisterY`, so a curve accumulating
// without it would be netted against an allowance from a different formula.
final female = workoutSex(sex) == 'female';
final reserve = maxHr - restingHr;
var trimp = 0.0;
var wakeMin = 0.0;
final out = <Map<String, num>>[];
for (final p in wakeHr) {
var hrr = (p.hr - restingHr) / reserve;
if (hrr < 0) hrr = 0;
if (hrr > 1) hrr = 1;
trimp += hrr * StrainScorer.banisterY(hrr, female: female);
// The baseline grows with the wake window ALREADY elapsed, so the curve
// stays flat through quiet waking and climbs only on real effort — rather
// than charging a whole day's allowance against the first minute.
wakeMin += 1;
out.add({
't': p.tsSec,
'v': _round(
strainScore(
trimp,
wakeMinutes: wakeMin,
// Reference level, not this user's — see onehz_pipeline's
// `strainMetric` for why, and edge#226 for the fix.
quietHrr: quietWakingHrr,
female: female,
),
2,
),
});
}
return out;
if (wakeHr.isEmpty || sex == null) return const [];
// The headline's own arithmetic, minute by minute (`strainCurveFromSeries`),
// so the last point IS the headline and exercise already banked never falls
// back out through a quiet afternoon. Null — no level, or bad anchors —
// leaves the curve empty, which the strain screen explains from the note.
final curve = strainCurveFromSeries(
[for (final p in wakeHr) p.hr],
restingHr: restingHr,
maxHr: maxHr,
quietHrr: quietHrr,
sex: workoutSex(sex) == 'female' ? Sex.female : Sex.male,
);
if (curve == null) return const [];
return [
for (var i = 0; i < wakeHr.length; i++)
{'t': wakeHr[i].tsSec, 'v': _round(curve[i], 2)},
];
}

double? _mean(List<double> xs) {
Expand Down
29 changes: 29 additions & 0 deletions lib/compute/quiet_level.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// THE personal quiet-waking level for scoring anything OUTSIDE a derive sweep
// — a saved/edited session, the on-read re-score, the live gauge, the strain
// rescale. A day's own derive reads the same window from its sweep snapshot
// (`_BaselineHistoryCache.valuesBefore('quiet_hrr', date)`); this is that read
// against the database, so a session and the day it sits in are priced on one
// level.

import 'package:openstrap_analytics/onehz.dart' as ana;

import '../data/db.dart';

/// The median of the trailing 28 measured `quiet_hrr` days strictly before
/// [dayLabel] (a LOCAL day label). Absent — never a stand-in — below three
/// days, or when the read fails.
Future<ana.Metric<ana.QuietLevel>> personalQuietLevelBefore(
String dayLabel,
) async {
try {
return ana.personalQuietWakingHrr(
await LocalDb.trailingSeriesValues(
'quiet_hrr',
ana.quietHrrWindowDays,
before: dayLabel,
),
);
} catch (_) {
return ana.personalQuietWakingHrr(const []); // abstains, never a stand-in
}
}
Loading