mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(workouts): hold training load through unscored days
A day without a stored training impulse was read as a rest day, so a watch left off or a nightly run that had not happened yet pulled fitness and fatigue toward zero. Such a day is now not rated: the averages hold through it, the series marks it, and the card says how many of the last 42 days were rated. A scored day of 0 stays rest. The figures describe the last stored day rather than always yesterday.
This commit is contained in:
1 parent
2723ac5b98
commit
99dfd68aad
15 files changed
+145
-38
No files matched your search
@@ -461,6 +461,8 @@ describe("GET /api/workouts — training load (v1.44)", () => {
|
||||
asOf: yesterday,
|
||||
daysCovered: 50,
|
||||
trainingDays: 50,
|
||||
recentRatedDays: 42,
|
||||
recentDays: 42,
|
||||
warmingUp: false,
|
||||
});
|
||||
const later = await (await GET(makeRequest("?offset=100"))).json();
|
||||
|
||||
@@ -17,6 +17,8 @@ const OK: TrainingLoad = {
|
||||
tsb: -7.6,
|
||||
daysCovered: 30,
|
||||
trainingDays: 18,
|
||||
recentRatedDays: 30,
|
||||
recentDays: 30,
|
||||
warmingUp: true,
|
||||
series: [],
|
||||
};
|
||||
@@ -36,6 +38,13 @@ describe("<TrainingLoadCard>", () => {
|
||||
expect(html).toMatch(/training-load-fatigue[^]*?>62</);
|
||||
expect(html).toContain("-8");
|
||||
expect(html).toContain("Based on 30 days so far");
|
||||
expect(html).not.toContain('data-slot="training-load-coverage"');
|
||||
});
|
||||
|
||||
it("says how many recent days were rated when some were not", () => {
|
||||
const html = render({ ...OK, recentRatedDays: 25, recentDays: 42 });
|
||||
expect(html).toContain('data-slot="training-load-coverage"');
|
||||
expect(html).toContain("25 of the last 42 days have heart-rate data");
|
||||
});
|
||||
|
||||
it("signs a positive form and drops the warm-up note once covered", () => {
|
||||
|
||||
@@ -47,6 +47,17 @@ export function TrainingLoadCard({
|
||||
))}
|
||||
</dl>
|
||||
<p className="text-sm">{t("insights.workouts.load.explainer")}</p>
|
||||
{trainingLoad.recentRatedDays < trainingLoad.recentDays ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="training-load-coverage"
|
||||
>
|
||||
{t("insights.workouts.load.coverage", {
|
||||
rated: trainingLoad.recentRatedDays,
|
||||
days: trainingLoad.recentDays,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{trainingLoad.warmingUp ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.workouts.load.warmingUp", {
|
||||
|
||||
@@ -31,23 +31,29 @@ describe("computeTrainingLoad", () => {
|
||||
ctl: 2.4,
|
||||
atl: 14.3,
|
||||
tsb: 0,
|
||||
rated: true,
|
||||
});
|
||||
expect(load.recentRatedDays).toBe(42);
|
||||
expect(load.recentDays).toBe(42);
|
||||
});
|
||||
|
||||
it("counts a day without a stored row as a rest day", () => {
|
||||
// Train on day 0 only, then 29 empty days up to day 29.
|
||||
const load = computeTrainingLoad(
|
||||
[
|
||||
{ day: day(0), dayTrimp: 84 },
|
||||
{ day: day(1), dayTrimp: 42 },
|
||||
{ day: day(2), dayTrimp: 42 },
|
||||
],
|
||||
day(29),
|
||||
);
|
||||
it("counts a scored day of 0 as rest, and holds through a day never scored", () => {
|
||||
// Three training days, then scored rest days 3..29 except a gap of
|
||||
// unscored days 10..19 (no heart-rate data reached the server).
|
||||
const rows = [
|
||||
{ day: day(0), dayTrimp: 84 },
|
||||
{ day: day(1), dayTrimp: 42 },
|
||||
{ day: day(2), dayTrimp: 42 },
|
||||
];
|
||||
for (let i = 3; i < 30; i += 1) {
|
||||
if (i < 10 || i > 19) rows.push({ day: day(i), dayTrimp: 0 });
|
||||
}
|
||||
const load = computeTrainingLoad(rows, day(29));
|
||||
if (load.status !== "ok") throw new Error("expected ok");
|
||||
let ctl = 0;
|
||||
let atl = 0;
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
if (i >= 10 && i <= 19) continue; // held
|
||||
const l = i === 0 ? 84 : i < 3 ? 42 : 0;
|
||||
ctl += (l - ctl) / 42;
|
||||
atl += (l - atl) / 7;
|
||||
@@ -55,9 +61,26 @@ describe("computeTrainingLoad", () => {
|
||||
expect(load.ctl).toBe(Math.round(ctl * 10) / 10);
|
||||
expect(load.atl).toBe(Math.round(atl * 10) / 10);
|
||||
expect(load.daysCovered).toBe(30);
|
||||
expect(load.recentRatedDays).toBe(20);
|
||||
expect(load.recentDays).toBe(30);
|
||||
expect(load.series.find((p) => p.day === day(12))?.rated).toBe(false);
|
||||
expect(load.series.find((p) => p.day === day(25))?.rated).toBe(true);
|
||||
expect(load.warmingUp).toBe(true);
|
||||
});
|
||||
|
||||
it("describes the last stored day, so a run not yet made never zeroes it", () => {
|
||||
// Rows through day 39; asked for day 41 (the nightly run for 40 and 41
|
||||
// has not happened). The figures are those of day 39.
|
||||
const rows = Array.from({ length: 40 }, (_, i) => ({
|
||||
day: day(i),
|
||||
dayTrimp: 60,
|
||||
}));
|
||||
const late = computeTrainingLoad(rows, day(41));
|
||||
const exact = computeTrainingLoad(rows, day(39));
|
||||
expect(late).toEqual(exact);
|
||||
expect(late).toMatchObject({ status: "ok", asOf: day(39) });
|
||||
});
|
||||
|
||||
it("answers insufficient without impulse or with too little history", () => {
|
||||
expect(computeTrainingLoad([], day(10))).toMatchObject({
|
||||
status: "insufficient",
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
* training impulse.
|
||||
*
|
||||
* The impulse is the Banister TRIMP the nightly strain run already writes to
|
||||
* `strain_trimp_cache` (one row per scored UTC day; a day without a row
|
||||
* carried no impulse). Over it run the two exponentially weighted averages
|
||||
* `strain_trimp_cache`: one row per scored UTC day, 0 on a scored day without
|
||||
* training. A day WITHOUT a row was never scored (no heart-rate data reached
|
||||
* the server, or the run has not happened yet), so it is not rated rather
|
||||
* than read as rest: the averages hold through it instead of decaying toward
|
||||
* zero, and the figures report how many recent days were rated. The figures
|
||||
* describe the last stored day, so a nightly run that has not happened yet
|
||||
* never turns yesterday into a rest day. Over it run the two exponentially weighted averages
|
||||
* of the fitness-fatigue model (Banister, Calvert, Savage & Bach 1975; in the
|
||||
* time-constant form popularised as chronic and acute training load):
|
||||
*
|
||||
@@ -37,6 +42,8 @@ export interface TrainingLoadPoint {
|
||||
ctl: number;
|
||||
atl: number;
|
||||
tsb: number;
|
||||
/** False for a day without a stored impulse: the averages held through it. */
|
||||
rated: boolean;
|
||||
}
|
||||
|
||||
export type TrainingLoad =
|
||||
@@ -50,6 +57,10 @@ export type TrainingLoad =
|
||||
tsb: number;
|
||||
daysCovered: number;
|
||||
trainingDays: number;
|
||||
/** Days of the trailing {@link CTL_DAYS} (or fewer, when covered less) with a stored impulse. */
|
||||
recentRatedDays: number;
|
||||
/** The trailing window `recentRatedDays` counts over. */
|
||||
recentDays: number;
|
||||
warmingUp: boolean;
|
||||
series: TrainingLoadPoint[];
|
||||
}
|
||||
@@ -63,16 +74,17 @@ export type TrainingLoad =
|
||||
const round1 = (x: number) => Math.round(x * 10) / 10;
|
||||
|
||||
/**
|
||||
* Run the model from the first stored day through `asOf` (inclusive), a
|
||||
* missing day counting as zero impulse. Rows after `asOf` are ignored.
|
||||
* Run the model from the first stored day through the last stored day on or
|
||||
* before `until`. A day without a row is not rated: the averages hold through
|
||||
* it. Rows after `until` are ignored.
|
||||
*/
|
||||
export function computeTrainingLoad(
|
||||
rows: ReadonlyArray<{ day: string; dayTrimp: number }>,
|
||||
asOf: string,
|
||||
until: string,
|
||||
): TrainingLoad {
|
||||
const byDay = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (row.day > asOf || !Number.isFinite(row.dayTrimp)) continue;
|
||||
if (row.day > until || !Number.isFinite(row.dayTrimp)) continue;
|
||||
byDay.set(row.day, Math.max(0, row.dayTrimp));
|
||||
}
|
||||
const days = [...byDay.keys()].sort();
|
||||
@@ -86,18 +98,23 @@ export function computeTrainingLoad(
|
||||
};
|
||||
}
|
||||
|
||||
const asOf = days[days.length - 1];
|
||||
const series: TrainingLoadPoint[] = [];
|
||||
let ctl = 0;
|
||||
let atl = 0;
|
||||
let daysCovered = 0;
|
||||
for (let day = days[0]; day <= asOf; day = shiftDateKey(day, 1)) {
|
||||
const tsb = ctl - atl;
|
||||
const load = byDay.get(day) ?? 0;
|
||||
ctl += (load - ctl) / CTL_DAYS;
|
||||
atl += (load - atl) / ATL_DAYS;
|
||||
const load = byDay.get(day);
|
||||
if (load !== undefined) {
|
||||
ctl += (load - ctl) / CTL_DAYS;
|
||||
atl += (load - atl) / ATL_DAYS;
|
||||
}
|
||||
daysCovered += 1;
|
||||
series.push({ day, ctl, atl, tsb });
|
||||
series.push({ day, ctl, atl, tsb, rated: load !== undefined });
|
||||
}
|
||||
const recent = series.slice(-CTL_DAYS);
|
||||
const recentRatedDays = recent.filter((p) => p.rated).length;
|
||||
|
||||
if (
|
||||
daysCovered < TRAINING_LOAD_MIN_DAYS ||
|
||||
@@ -119,17 +136,23 @@ export function computeTrainingLoad(
|
||||
tsb: round1(ctl - atl),
|
||||
daysCovered,
|
||||
trainingDays,
|
||||
recentRatedDays,
|
||||
recentDays: recent.length,
|
||||
warmingUp: daysCovered < CTL_DAYS,
|
||||
series: series.slice(-TRAINING_LOAD_SERIES_DAYS).map((p) => ({
|
||||
day: p.day,
|
||||
ctl: round1(p.ctl),
|
||||
atl: round1(p.atl),
|
||||
tsb: round1(p.tsb),
|
||||
rated: p.rated,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the stored impulse and run the model up to the last scored day. */
|
||||
/**
|
||||
* Read the stored impulse and run the model up to the last stored day, at
|
||||
* most the day the strain run scores (yesterday in UTC).
|
||||
*/
|
||||
export async function readTrainingLoad(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
@@ -138,15 +161,15 @@ export async function readTrainingLoad(
|
||||
// here rather than imported: `score-row` sits on the score writers' import
|
||||
// graph, and this read stays off it.
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day
|
||||
const asOf = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);
|
||||
const until = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);
|
||||
const rows = await prisma.strainTrimpCache.findMany({
|
||||
where: {
|
||||
userId,
|
||||
day: { gte: shiftDateKey(asOf, -TRAINING_LOAD_READ_DAYS), lte: asOf },
|
||||
day: { gte: shiftDateKey(until, -TRAINING_LOAD_READ_DAYS), lte: until },
|
||||
},
|
||||
orderBy: { day: "asc" },
|
||||
take: TRAINING_LOAD_READ_DAYS + 1,
|
||||
select: { day: true, dayTrimp: true },
|
||||
});
|
||||
return computeTrainingLoad(rows, asOf);
|
||||
return computeTrainingLoad(rows, until);
|
||||
}
|
||||
@@ -115,6 +115,11 @@ const trainingLoadPoint = z.object({
|
||||
ctl: z.number(),
|
||||
atl: z.number(),
|
||||
tsb: z.number(),
|
||||
rated: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False for a day without a stored impulse (never scored): the averages held through it rather than counting it as rest.",
|
||||
),
|
||||
});
|
||||
|
||||
const trainingLoad = z
|
||||
@@ -123,12 +128,21 @@ const trainingLoad = z
|
||||
status: z.literal("ok"),
|
||||
asOf: z
|
||||
.string()
|
||||
.describe("The last scored UTC day the figures describe."),
|
||||
.describe(
|
||||
"The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never reads as a rest day.",
|
||||
),
|
||||
ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."),
|
||||
atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."),
|
||||
tsb: z.number().describe("Form: fitness minus fatigue at `asOf`."),
|
||||
daysCovered: z.number().int(),
|
||||
trainingDays: z.number().int(),
|
||||
recentRatedDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.",
|
||||
),
|
||||
recentDays: z.number().int(),
|
||||
warmingUp: z
|
||||
.boolean()
|
||||
.describe("True below 42 covered days: both averages start at zero."),
|
||||
@@ -148,7 +162,7 @@ const trainingLoad = z
|
||||
.meta({
|
||||
id: "WorkoutTrainingLoad",
|
||||
description:
|
||||
"v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.",
|
||||
"v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.",
|
||||
});
|
||||
|
||||
const workoutListResponse = z
|
||||
|
||||
Reference in new issue
Block a user