mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(insights): anchor STRAIN to the user's own training load
Map day-total Banister TRIMP to 0-100 against a personal reference -- the EWMA-smoothed 75th percentile of the user's own training-day TRIMP over a 42-day chronic window -- instead of the fixed population anchor that pinned a deconditioned or chronic-condition user near 0 regardless of their effort. Below a 7-training-day cold-start floor the engine falls back to the population anchor, recorded as anchor: population with lower confidence; at or above it the personal anchor takes over. Always writes a score row -- no new null gate. The anchor is derived from the TRIMP input, never the 0-100 output, so there is no circular feedback; rest-day zeros stay out of the training-day distribution. Add a server-internal per-(user, day) strain_trimp_cache (day-total TRIMP + running EWMA reference + anchor) so the chronic window reads cheap cached values instead of re-integrating 42 days of HR series each night. The cache is not a Measurement -- it stays out of every user-facing surface, the doctor PDF, and the FHIR bundle by construction. No backfill: the nightly idempotent recompute populates the cache forward and self-heals, falling back to the population anchor until enough personal history accrues. Keep the Banister/Morton/Tanaka citations and the doctor-PDF exclusion; the honesty copy now states the scale is relative to the user's typical effort, with a general reference during cold start.
This commit is contained in:
1 parent
059ba571e3
commit
f1ca0e28eb
5 files changed
+656
-13
No files matched your search
+2
-2
@@ -2618,9 +2618,9 @@
|
||||
"description": "A nightly stress proxy from the shape of your intra-day HRV."
|
||||
},
|
||||
"STRAIN_SCORE": {
|
||||
"method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.",
|
||||
"method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day. The scale is anchored to your own recent training-day load — relative to your typical effort — so a genuinely hard day for you reads high. While you are still building history it uses a general reference instead.",
|
||||
"caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.",
|
||||
"description": "A nightly activity-load proxy from your heart-rate-weighted effort."
|
||||
"description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort."
|
||||
},
|
||||
"WRIST_TEMPERATURE_BASELINE": {
|
||||
"method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- v1.10.3 — Strain personal-anchor cache.
|
||||
--
|
||||
-- A server-internal per-(user, day) cache that lets the nightly Strain engine
|
||||
-- anchor the 0–100 map to the user's OWN recent training-day load (an
|
||||
-- EWMA-smoothed P75 of day-total TRIMP over a 42-day chronic window) instead
|
||||
-- of the fixed population reference. Persisting the day-total TRIMP + the
|
||||
-- running EWMA reference means the chronic window reads cheap cached values
|
||||
-- rather than re-integrating 42 days of HR series every night.
|
||||
--
|
||||
-- Purely additive: one new table, no enum changes, no backfill. The nightly
|
||||
-- idempotent recompute populates the cache forward; until a user accrues
|
||||
-- enough personal training history the engine falls back to the population
|
||||
-- anchor, so behaviour matches today until the cache fills in — a clean,
|
||||
-- self-healing rollout.
|
||||
--
|
||||
-- NOT a Measurement: never read through the derived registry, never on a
|
||||
-- user-facing surface, never in the doctor PDF or FHIR bundle, never ingested
|
||||
-- from a client. A dedicated table keeps it server-internal and out of every
|
||||
-- MeasurementType completeness wall.
|
||||
--
|
||||
-- Reversibility: forward-only; dropping the table loses only the cache, and
|
||||
-- the nightly job rebuilds it (falling back to the population anchor in the
|
||||
-- interim).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "strain_trimp_cache" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"day" TEXT NOT NULL,
|
||||
"day_trimp" DOUBLE PRECISION NOT NULL,
|
||||
"ref_personal" DOUBLE PRECISION NOT NULL,
|
||||
"anchor" TEXT NOT NULL,
|
||||
"training_days" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "strain_trimp_cache_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "strain_trimp_cache_user_id_day_key"
|
||||
ON "strain_trimp_cache" ("user_id", "day");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "strain_trimp_cache_user_id_day_idx"
|
||||
ON "strain_trimp_cache" ("user_id", "day" DESC);
|
||||
|
||||
ALTER TABLE "strain_trimp_cache"
|
||||
ADD CONSTRAINT "strain_trimp_cache_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -404,6 +404,11 @@ model User {
|
||||
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
|
||||
// `reminder-worker.ts`).
|
||||
pushAttempts PushAttempt[]
|
||||
// v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
|
||||
// TRIMP + running EWMA reference) so the nightly Strain engine anchors
|
||||
// the 0–100 map to the user's own training load without re-integrating
|
||||
// the 42-day chronic window of HR series each night.
|
||||
strainTrimpCache StrainTrimpCache[]
|
||||
|
||||
// v1.7.0 profile — optional patient-identity fields surfaced on the
|
||||
// health-record export cover (PDF) and the FHIR `Patient` resource.
|
||||
@@ -933,6 +938,50 @@ model WorkoutSamples {
|
||||
@@map("workout_samples")
|
||||
}
|
||||
|
||||
// ─── Strain personal anchor cache (v1.10.3) ─────────────────
|
||||
//
|
||||
// Server-internal per-(user, day) cache that lets the nightly Strain
|
||||
// engine anchor the 0–100 map to the USER'S OWN recent training-day load
|
||||
// instead of a fixed population reference. Each row records the scored
|
||||
// day's day-total Banister TRIMP plus the running EWMA-smoothed personal
|
||||
// reference and which anchor produced that night's score (personal once
|
||||
// the cold-start floor is met, population before). Persisting day-total
|
||||
// TRIMP means the 42-day chronic window reads cheap cached values instead
|
||||
// of re-integrating 42 days of HR series every night.
|
||||
//
|
||||
// NOT a Measurement: it is never read through the derived registry, never
|
||||
// surfaced on any user-facing surface, never in the doctor PDF or FHIR
|
||||
// bundle, and never ingested from a client. Reusing the Measurement enum
|
||||
// would have pulled an internal cache type through every MeasurementType
|
||||
// completeness wall (chart tokens, categories, PR direction); a dedicated
|
||||
// table keeps it cleanly server-internal. Idempotent on `(userId, day)` so
|
||||
// a re-fired nightly tick overwrites the day's cache row in place.
|
||||
model StrainTrimpCache {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// The scored UTC calendar day (`YYYY-MM-DD`), matching the score row's day key.
|
||||
day String
|
||||
/// Day-total Banister TRIMP for the day (0 when no usable HR series).
|
||||
dayTrimp Float @map("day_trimp")
|
||||
/// EWMA-smoothed personal reference TRIMP in effect for this day's score.
|
||||
refPersonal Float @map("ref_personal")
|
||||
/// Which anchor produced this day's score: `personal` once enough personal
|
||||
/// training history accrued, `population` during cold start.
|
||||
anchor String
|
||||
/// Distinct training days (TRIMP > 0) seen in the chronic window — drives
|
||||
/// the cold-start gate + the confidence label.
|
||||
trainingDays Int @map("training_days")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, day])
|
||||
@@index([userId, day(sort: Desc)])
|
||||
@@map("strain_trimp_cache")
|
||||
}
|
||||
|
||||
// ─── Personal records (v1.4.25 W8d — schema only) ──────────
|
||||
//
|
||||
// MAX vs MIN direction is stored on the row so the future detection
|
||||
|
||||
@@ -17,8 +17,15 @@ import {
|
||||
tanakaHrMax,
|
||||
banisterTrimp,
|
||||
saturateToScore,
|
||||
percentile,
|
||||
resolvePersonalReference,
|
||||
persistStrainScore,
|
||||
STRAIN_SCORE_EXTERNAL_ID_PREFIX,
|
||||
STRAIN_TRIMP_REFERENCE,
|
||||
STRAIN_MIN_TRAINING_DAYS,
|
||||
STRAIN_PERSONAL_REF_FLOOR,
|
||||
STRAIN_PERSONAL_REF_PERCENTILE,
|
||||
STRAIN_EWMA_ALPHA,
|
||||
} from "../strain-score";
|
||||
|
||||
const NOW = new Date("2026-06-02T08:30:00Z");
|
||||
@@ -118,6 +125,13 @@ function makePrisma(opts: {
|
||||
rhr?: number | null;
|
||||
gender?: string;
|
||||
dateOfBirth?: Date | null;
|
||||
/** Cached training-day TRIMP history rows (newest day first). */
|
||||
cacheRows?: Array<{
|
||||
day: string;
|
||||
dayTrimp: number;
|
||||
refPersonal: number;
|
||||
trainingDays?: number;
|
||||
}>;
|
||||
}) {
|
||||
const energyRows = (opts.energy ?? []).map((value, i) => ({
|
||||
value,
|
||||
@@ -136,6 +150,15 @@ function makePrisma(opts: {
|
||||
: new Date("1986-01-01T00:00:00Z"),
|
||||
gender: opts.gender ?? "MALE",
|
||||
});
|
||||
const cacheFindMany = vi.fn().mockResolvedValue(
|
||||
(opts.cacheRows ?? []).map((r) => ({
|
||||
// A cached row with TRIMP > 0 was a training day; default its window
|
||||
// training-day count to 1 so it reads as a warmed EWMA unless overridden.
|
||||
trainingDays: r.dayTrimp > 0 ? 1 : 0,
|
||||
...r,
|
||||
})),
|
||||
);
|
||||
const cacheUpsert = vi.fn().mockResolvedValue({});
|
||||
return {
|
||||
prisma: {
|
||||
measurement: {
|
||||
@@ -145,8 +168,11 @@ function makePrisma(opts: {
|
||||
},
|
||||
workout: { findMany: workoutFindMany },
|
||||
user: { findUnique },
|
||||
strainTrimpCache: { findMany: cacheFindMany, upsert: cacheUpsert },
|
||||
} as unknown as Parameters<typeof persistStrainScore>[0],
|
||||
upsert,
|
||||
cacheFindMany,
|
||||
cacheUpsert,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,3 +284,207 @@ describe("persistStrainScore", () => {
|
||||
expect(secondKey).toEqual(firstKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── v1.10.3 personal-relative anchor ─────────────────────────
|
||||
|
||||
describe("percentile", () => {
|
||||
it("returns 0 for an empty sample and the single value for one", () => {
|
||||
expect(percentile([], 75)).toBe(0);
|
||||
expect(percentile([42], 75)).toBe(42);
|
||||
});
|
||||
|
||||
it("interpolates between order statistics", () => {
|
||||
// P50 of {10,20,30,40} → rank 1.5 → 20 + 0.5·(30−20) = 25.
|
||||
expect(percentile([40, 10, 30, 20], 50)).toBeCloseTo(25, 6);
|
||||
// P75 of {10,20,30,40} → rank 2.25 → 30 + 0.25·(40−30) = 32.5.
|
||||
expect(percentile([10, 20, 30, 40], 75)).toBeCloseTo(32.5, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePersonalReference", () => {
|
||||
it("falls back to the population anchor below the cold-start floor", () => {
|
||||
// Only a handful of training days — under STRAIN_MIN_TRAINING_DAYS.
|
||||
const trimps = Array.from(
|
||||
{ length: STRAIN_MIN_TRAINING_DAYS - 1 },
|
||||
() => 25,
|
||||
);
|
||||
const r = resolvePersonalReference({
|
||||
trainingDayTrimps: trimps,
|
||||
priorRefPersonal: null,
|
||||
});
|
||||
expect(r.anchor).toBe("population");
|
||||
expect(r.reference).toBe(STRAIN_TRIMP_REFERENCE);
|
||||
expect(r.trainingDays).toBe(STRAIN_MIN_TRAINING_DAYS - 1);
|
||||
// The EWMA still warms up so it is ready the night the user qualifies.
|
||||
expect(r.refPersonalToPersist).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uses the personal P75 anchor once the floor is met (seed run)", () => {
|
||||
// Eight identical-ish training days at ~25 TRIMP — a deconditioned user.
|
||||
const trimps = [20, 22, 24, 25, 25, 26, 28, 30];
|
||||
const r = resolvePersonalReference({
|
||||
trainingDayTrimps: trimps,
|
||||
priorRefPersonal: null,
|
||||
});
|
||||
expect(r.anchor).toBe("personal");
|
||||
expect(r.trainingDays).toBe(8);
|
||||
// Seed run (no prior EWMA) → reference is the window P75 of the trimps.
|
||||
const expectedP75 = percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE);
|
||||
expect(r.reference).toBeCloseTo(expectedP75, 6);
|
||||
// The deconditioned user's hard day (~30) now scores meaningfully high
|
||||
// instead of being pinned near 0 against the population 150.
|
||||
expect(saturateToScore(30, r.reference)).toBeGreaterThan(60);
|
||||
expect(saturateToScore(30, STRAIN_TRIMP_REFERENCE)).toBeLessThan(25);
|
||||
});
|
||||
|
||||
it("EWMA-blends this window's P75 with the prior reference", () => {
|
||||
const trimps = [40, 45, 50, 55, 60, 65, 70, 80];
|
||||
const prior = 100;
|
||||
const r = resolvePersonalReference({
|
||||
trainingDayTrimps: trimps,
|
||||
priorRefPersonal: prior,
|
||||
});
|
||||
const windowP75 = percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE);
|
||||
const expected =
|
||||
STRAIN_EWMA_ALPHA * windowP75 + (1 - STRAIN_EWMA_ALPHA) * prior;
|
||||
expect(r.reference).toBeCloseTo(expected, 6);
|
||||
// Smoothing keeps the reference between the prior and the window value.
|
||||
expect(r.reference).toBeLessThan(prior);
|
||||
expect(r.reference).toBeGreaterThan(windowP75);
|
||||
});
|
||||
|
||||
it("floors the personal reference so trivial days do not all score 100", () => {
|
||||
// All training days are very light — P75 would sit below the floor.
|
||||
const trimps = [2, 3, 3, 4, 4, 5, 5, 6];
|
||||
const r = resolvePersonalReference({
|
||||
trainingDayTrimps: trimps,
|
||||
priorRefPersonal: null,
|
||||
});
|
||||
expect(r.anchor).toBe("personal");
|
||||
expect(r.reference).toBe(STRAIN_PERSONAL_REF_FLOOR);
|
||||
});
|
||||
|
||||
it("ignores rest-day zeros — distribution is training-days only", () => {
|
||||
// Zeros must never enter the percentile (they would crush the anchor).
|
||||
const withZeros = [0, 0, 0, 20, 22, 24, 25, 26, 28, 30];
|
||||
const r = resolvePersonalReference({
|
||||
trainingDayTrimps: withZeros,
|
||||
priorRefPersonal: null,
|
||||
});
|
||||
expect(r.trainingDays).toBe(7); // only the seven > 0 days count
|
||||
expect(r.reference).toBeCloseTo(
|
||||
percentile([20, 22, 24, 25, 26, 28, 30], STRAIN_PERSONAL_REF_PERCENTILE),
|
||||
6,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistStrainScore — personal anchor + cache", () => {
|
||||
const hardSeries = {
|
||||
samples: [
|
||||
{ t: "2026-06-01T08:00:00.000Z", hr: 150 },
|
||||
{ t: "2026-06-01T08:30:00.000Z", hr: 155 },
|
||||
{ t: "2026-06-01T09:00:00.000Z", hr: 150 },
|
||||
],
|
||||
};
|
||||
|
||||
it("cold start (empty cache) scores against the population anchor + writes the cache row", async () => {
|
||||
const { prisma, cacheUpsert } = makePrisma({
|
||||
rhr: 50,
|
||||
workouts: [{ id: "w1", samples: hardSeries }],
|
||||
cacheRows: [], // no personal history yet
|
||||
});
|
||||
|
||||
const result = await persistStrainScore(prisma, "user-1", NOW);
|
||||
|
||||
expect(result.outcome).toBe("stored");
|
||||
expect(result.anchor).toBe("population");
|
||||
// The cache row is always written so the distribution fills forward.
|
||||
expect(cacheUpsert).toHaveBeenCalledTimes(1);
|
||||
const cacheArg = cacheUpsert.mock.calls[0][0];
|
||||
expect(cacheArg.where.userId_day).toEqual({
|
||||
userId: "user-1",
|
||||
day: "2026-06-01",
|
||||
});
|
||||
expect(cacheArg.create.dayTrimp).toBeGreaterThan(0);
|
||||
expect(cacheArg.create.anchor).toBe("population");
|
||||
});
|
||||
|
||||
it("uses the personal anchor once the cache holds enough training days", async () => {
|
||||
// Seven prior training days of light load (~25 TRIMP) → with today's
|
||||
// session that is ≥ the cold-start floor, so the personal anchor fires.
|
||||
const cacheRows = Array.from({ length: 7 }, (_, i) => ({
|
||||
day: `2026-05-${String(25 - i).padStart(2, "0")}`,
|
||||
dayTrimp: 25,
|
||||
refPersonal: 25,
|
||||
}));
|
||||
const { prisma, cacheUpsert } = makePrisma({
|
||||
rhr: 50,
|
||||
workouts: [{ id: "w1", samples: hardSeries }],
|
||||
cacheRows,
|
||||
});
|
||||
|
||||
const result = await persistStrainScore(prisma, "user-1", NOW);
|
||||
|
||||
expect(result.outcome).toBe("stored");
|
||||
expect(result.anchor).toBe("personal");
|
||||
const cacheArg = cacheUpsert.mock.calls[0][0];
|
||||
expect(cacheArg.create.anchor).toBe("personal");
|
||||
// refPersonal is the EWMA-blended personal reference, not the population 150.
|
||||
expect(cacheArg.create.refPersonal).toBeLessThan(STRAIN_TRIMP_REFERENCE);
|
||||
});
|
||||
|
||||
it("self-heals with no backfill — a re-run reads the prior EWMA, not its own write", async () => {
|
||||
// A prior cache row strictly before the scored day carries the prior EWMA.
|
||||
const cacheRows = [
|
||||
{ day: "2026-05-31", dayTrimp: 40, refPersonal: 60 },
|
||||
...Array.from({ length: 7 }, (_, i) => ({
|
||||
day: `2026-05-${String(24 - i).padStart(2, "0")}`,
|
||||
dayTrimp: 40,
|
||||
refPersonal: 60,
|
||||
})),
|
||||
];
|
||||
const { prisma, cacheFindMany, cacheUpsert } = makePrisma({
|
||||
rhr: 50,
|
||||
workouts: [{ id: "w1", samples: hardSeries }],
|
||||
cacheRows,
|
||||
});
|
||||
|
||||
await persistStrainScore(prisma, "user-1", NOW);
|
||||
|
||||
// The history read excludes the scored day itself (lt: dayKey) so a
|
||||
// re-run never blends against its own write — the nightly idempotent
|
||||
// recompute converges instead of compounding.
|
||||
const findArg = cacheFindMany.mock.calls[0][0];
|
||||
expect(findArg.where.day.lt).toBe("2026-06-01");
|
||||
expect(cacheUpsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not adopt a pure energy-only seed row as the EWMA prior", async () => {
|
||||
// Seven prior training days (warm the personal anchor) plus an
|
||||
// energy-only placeholder row (trainingDays: 0, refPersonal: population)
|
||||
// that must NOT be blended in as the prior — it would stall activation.
|
||||
const cacheRows = [
|
||||
// newest is the energy-only placeholder.
|
||||
{ day: "2026-05-31", dayTrimp: 0, refPersonal: STRAIN_TRIMP_REFERENCE },
|
||||
...Array.from({ length: 7 }, (_, i) => ({
|
||||
day: `2026-05-${String(24 - i).padStart(2, "0")}`,
|
||||
dayTrimp: 25,
|
||||
refPersonal: 25,
|
||||
})),
|
||||
];
|
||||
const { prisma, cacheUpsert } = makePrisma({
|
||||
rhr: 50,
|
||||
workouts: [{ id: "w1", samples: hardSeries }],
|
||||
cacheRows,
|
||||
});
|
||||
|
||||
await persistStrainScore(prisma, "user-1", NOW);
|
||||
|
||||
const cacheArg = cacheUpsert.mock.calls[0][0];
|
||||
expect(cacheArg.create.anchor).toBe("personal");
|
||||
// The blended reference rides the warmed ~25-TRIMP prior, NOT the
|
||||
// population 150 carried by the energy-only placeholder row.
|
||||
expect(cacheArg.create.refPersonal).toBeLessThan(80);
|
||||
});
|
||||
});
|
||||
@@ -23,12 +23,26 @@
|
||||
* Tanaka, Monahan & Seals 2001, J. Am. Coll. Cardiol. 37(1):153–156.
|
||||
*
|
||||
* The per-workout TRIMP values are summed across the scored day to a
|
||||
* day-total TRIMP, then mapped to the 0–100 scale by a saturating curve
|
||||
* anchored at a reference daily load (`STRAIN_TRIMP_REFERENCE` ≈ a hard
|
||||
* hour). Days with NO usable HR series but WITH active energy fall back to
|
||||
* an active-energy-only proxy so a "logged but no series" workout still
|
||||
* registers some strain (clearly the weaker signal — the provenance records
|
||||
* which path produced the score).
|
||||
* day-total TRIMP, then mapped to the 0–100 scale by a saturating curve.
|
||||
*
|
||||
* v1.10.3 — PERSONAL-RELATIVE ANCHOR. The 0–100 map is anchored to the
|
||||
* USER'S OWN recent training-day load, not a fixed population reference: the
|
||||
* reference that maps to score ≈ 63 is the EWMA-smoothed 75th percentile of
|
||||
* the user's own training-day (TRIMP > 0) day-total TRIMP over a 42-day
|
||||
* chronic window (the acute-vs-chronic framing Garmin Training Load uses,
|
||||
* the personal-zone idea WHOOP uses). This makes the score meaningful for a
|
||||
* deconditioned or chronic-condition user, who would otherwise be pinned near
|
||||
* 0 against the population anchor regardless of how hard *they* worked. Below
|
||||
* a 7-training-day cold-start floor the engine falls back to the fixed
|
||||
* population anchor (`STRAIN_TRIMP_REFERENCE` ≈ a hard hour), recorded as
|
||||
* `anchor: "population"`. The anchor is derived from the TRIMP INPUT, never
|
||||
* from the 0–100 OUTPUT, so there is no circular feedback.
|
||||
*
|
||||
* Days with NO usable HR series but WITH active energy fall back to an
|
||||
* active-energy-only proxy so a "logged but no series" workout still
|
||||
* registers some strain (clearly the weaker signal, and with no personal
|
||||
* intensity distribution it stays on the population anchor — the provenance
|
||||
* records which path + which anchor produced the score).
|
||||
*
|
||||
* Honest confidence: the score is only stored when the day carried at least
|
||||
* one workout HR series with usable samples OR a non-trivial active-energy
|
||||
@@ -76,6 +90,59 @@ export const STRAIN_TRIMP_REFERENCE = 150;
|
||||
*/
|
||||
export const STRAIN_ACTIVE_ENERGY_REFERENCE = 600;
|
||||
|
||||
/**
|
||||
* v1.10.3 — personal-relative anchor.
|
||||
*
|
||||
* The fixed `STRAIN_TRIMP_REFERENCE` is a POPULATION anchor: for a fit user a
|
||||
* hard hour ≈ 150 TRIMP → score ≈ 63, but for a deconditioned / chronic user
|
||||
* whose hardest realistic effort is, say, 25 TRIMP the score saturates near 15
|
||||
* — they gave *their* maximum and the headline reads 15. STRESS + RECOVERY are
|
||||
* already personal-relative; this brings STRAIN into line by anchoring the
|
||||
* 0–100 map to the USER'S OWN recent training-day load.
|
||||
*
|
||||
* The anchor is the EWMA-smoothed 75th percentile of the user's own
|
||||
* training-day (TRIMP > 0) day-total TRIMP over a 42-day chronic window — the
|
||||
* acute-vs-chronic framing Garmin Training Load uses, the personal-zone idea
|
||||
* WHOOP uses, gated like Oura's "a few days before it appears". We anchor on
|
||||
* the TRIMP INPUT, never on the 0–100 OUTPUT, so there is no circularity.
|
||||
*/
|
||||
|
||||
/** Chronic window (days) the personal training-day distribution is read over. */
|
||||
export const STRAIN_CHRONIC_WINDOW_DAYS = 42;
|
||||
|
||||
/** Percentile of the user's own training-day TRIMP that maps to score ≈ 63. */
|
||||
export const STRAIN_PERSONAL_REF_PERCENTILE = 75;
|
||||
|
||||
/**
|
||||
* EWMA effective span (Nₑ) for smoothing the personal reference across nights,
|
||||
* α = 2/(Nₑ+1) ≈ 0.13. Mirrors Garmin's chronic moving average — the anchor
|
||||
* tracks fitness changes over weeks without day-to-day jitter.
|
||||
*/
|
||||
export const STRAIN_EWMA_N = 14;
|
||||
|
||||
/** EWMA smoothing factor derived from {@link STRAIN_EWMA_N}. */
|
||||
export const STRAIN_EWMA_ALPHA = 2 / (STRAIN_EWMA_N + 1);
|
||||
|
||||
/**
|
||||
* Cold-start floor: distinct training days (TRIMP > 0) the user must have in
|
||||
* the chronic window before the personal anchor activates. Below it the engine
|
||||
* falls back to the population anchor, labelled lower-confidence. Counting
|
||||
* TRAINING days (not calendar days) is deliberate — the anchor describes how
|
||||
* hard the user trains *when they train*, so a twice-a-week trainer needs
|
||||
* ~3.5 weeks to qualify, which is the correct semantics.
|
||||
*/
|
||||
export const STRAIN_MIN_TRAINING_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Floor on the personal reference (TRIMP). Guards against a near-zero anchor
|
||||
* (a user whose training days are all very light) inflating every trivial day
|
||||
* toward 100.
|
||||
*/
|
||||
export const STRAIN_PERSONAL_REF_FLOOR = 10;
|
||||
|
||||
/** Which anchor produced a day's score. */
|
||||
export type StrainAnchor = "personal" | "population";
|
||||
|
||||
/**
|
||||
* The UTC calendar day a Strain run scores — the PREVIOUS day relative to
|
||||
* `now`. The cron fires in the small hours; scoring the just-ended day is what
|
||||
@@ -161,6 +228,105 @@ export function saturateToScore(load: number, reference: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `p`-th percentile (0–100) of a numeric sample via linear interpolation
|
||||
* between order statistics. Returns 0 for an empty sample. Pure — exported for
|
||||
* unit testing.
|
||||
*/
|
||||
export function percentile(values: readonly number[], p: number): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const rank = (Math.min(100, Math.max(0, p)) / 100) * (sorted.length - 1);
|
||||
const lo = Math.floor(rank);
|
||||
const hi = Math.ceil(rank);
|
||||
if (lo === hi) return sorted[lo];
|
||||
const frac = rank - lo;
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
|
||||
}
|
||||
|
||||
/** The training-day TRIMP history backing the personal anchor. */
|
||||
export interface StrainAnchorHistory {
|
||||
/** Day-total TRIMP for each TRAINING day (TRIMP > 0) in the chronic window. */
|
||||
trainingDayTrimps: readonly number[];
|
||||
/** The EWMA reference persisted by the previous night's run, or null on first run. */
|
||||
priorRefPersonal: number | null;
|
||||
}
|
||||
|
||||
/** The resolved reference + the provenance the score row's cache should record. */
|
||||
export interface ResolvedStrainReference {
|
||||
/** The reference to feed `saturateToScore` for the TRIMP path. */
|
||||
reference: number;
|
||||
/** Which anchor was used. */
|
||||
anchor: StrainAnchor;
|
||||
/** Distinct training days seen in the chronic window. */
|
||||
trainingDays: number;
|
||||
/**
|
||||
* The EWMA-smoothed personal reference to persist for the next night. Always
|
||||
* the personal reference even when the population anchor was used this night
|
||||
* (so the EWMA keeps warming up toward activation); null only when there is
|
||||
* no training-day history at all.
|
||||
*/
|
||||
refPersonalToPersist: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the day's strain reference from the user's own training-day TRIMP
|
||||
* history. Below the cold-start floor → the fixed population anchor (labelled
|
||||
* `population`); at or above it → the EWMA-smoothed P75 of the user's own
|
||||
* training-day TRIMP (labelled `personal`), floored at
|
||||
* {@link STRAIN_PERSONAL_REF_FLOOR}.
|
||||
*
|
||||
* The EWMA blends this window's P75 with the prior night's reference
|
||||
* (`ref = α·P75 + (1−α)·prev`); on the first night with any history it seeds
|
||||
* from the window P75. Anchors on the TRIMP INPUT, never the score OUTPUT, so
|
||||
* there is no circular feedback. Pure — no Prisma. Exported for unit testing.
|
||||
*/
|
||||
export function resolvePersonalReference(
|
||||
history: StrainAnchorHistory,
|
||||
populationReference: number = STRAIN_TRIMP_REFERENCE,
|
||||
): ResolvedStrainReference {
|
||||
const trimps = history.trainingDayTrimps.filter((t) => t > 0);
|
||||
const trainingDays = trimps.length;
|
||||
|
||||
// The EWMA reference warms up regardless of the cold-start gate so it is
|
||||
// ready the night the user crosses the floor.
|
||||
let refPersonalToPersist: number | null = null;
|
||||
if (trainingDays > 0) {
|
||||
const windowP75 = Math.max(
|
||||
STRAIN_PERSONAL_REF_FLOOR,
|
||||
percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE),
|
||||
);
|
||||
refPersonalToPersist =
|
||||
history.priorRefPersonal != null
|
||||
? STRAIN_EWMA_ALPHA * windowP75 +
|
||||
(1 - STRAIN_EWMA_ALPHA) * history.priorRefPersonal
|
||||
: windowP75;
|
||||
// Keep the persisted EWMA above the floor too (a long stretch of light
|
||||
// days should never drag the anchor below it).
|
||||
refPersonalToPersist = Math.max(
|
||||
STRAIN_PERSONAL_REF_FLOOR,
|
||||
refPersonalToPersist,
|
||||
);
|
||||
}
|
||||
|
||||
if (trainingDays < STRAIN_MIN_TRAINING_DAYS) {
|
||||
return {
|
||||
reference: populationReference,
|
||||
anchor: "population",
|
||||
trainingDays,
|
||||
refPersonalToPersist,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reference: refPersonalToPersist as number,
|
||||
anchor: "personal",
|
||||
trainingDays,
|
||||
refPersonalToPersist,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StrainComputeResult {
|
||||
/** The 0..100 score to persist, or null when the inputs gate. */
|
||||
score: number | null;
|
||||
@@ -176,6 +342,17 @@ export interface StrainComputeResult {
|
||||
dayActiveEnergy: number;
|
||||
/** Workouts with a usable HR series on the day. */
|
||||
workoutsWithSeries: number;
|
||||
/**
|
||||
* v1.10.3 — which anchor the score was mapped against. `personal` once the
|
||||
* user has ≥ `STRAIN_MIN_TRAINING_DAYS` training days of history (TRIMP
|
||||
* path only); `population` during cold start and always for the
|
||||
* active-energy fallback (which has no personal intensity distribution).
|
||||
*/
|
||||
anchor: StrainAnchor;
|
||||
/** Distinct training days (TRIMP > 0) in the chronic window. */
|
||||
trainingDays: number;
|
||||
/** EWMA personal reference to persist for the next night, or null. */
|
||||
refPersonalToPersist: number | null;
|
||||
}
|
||||
|
||||
export interface StrainProfile {
|
||||
@@ -196,6 +373,10 @@ export async function computeStrainScore(
|
||||
profile: StrainProfile,
|
||||
hrRest: number | null,
|
||||
now: Date,
|
||||
anchorHistory: StrainAnchorHistory = {
|
||||
trainingDayTrimps: [],
|
||||
priorRefPersonal: null,
|
||||
},
|
||||
): Promise<StrainComputeResult> {
|
||||
const dayKey = strainDayKey(now);
|
||||
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
|
||||
@@ -240,19 +421,37 @@ export async function computeStrainScore(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.10.3 — resolve the personal-vs-population reference from the user's
|
||||
// own training-day TRIMP history. Below the cold-start floor (or with no
|
||||
// history) this returns the population anchor labelled `population`. The
|
||||
// scored day's own TRIMP is included in the distribution it is judged
|
||||
// against — the anchor is "your typical hard day", today included.
|
||||
const ref = resolvePersonalReference({
|
||||
trainingDayTrimps:
|
||||
dayTrimp > 0
|
||||
? [...anchorHistory.trainingDayTrimps, dayTrimp]
|
||||
: anchorHistory.trainingDayTrimps,
|
||||
priorRefPersonal: anchorHistory.priorRefPersonal,
|
||||
});
|
||||
|
||||
if (dayTrimp > 0) {
|
||||
return {
|
||||
score: saturateToScore(dayTrimp, STRAIN_TRIMP_REFERENCE),
|
||||
score: saturateToScore(dayTrimp, ref.reference),
|
||||
reason: "trimp",
|
||||
dayTrimp,
|
||||
dayActiveEnergy,
|
||||
workoutsWithSeries,
|
||||
anchor: ref.anchor,
|
||||
trainingDays: ref.trainingDays,
|
||||
refPersonalToPersist: ref.refPersonalToPersist,
|
||||
};
|
||||
}
|
||||
|
||||
// No usable HR series. Fall back to the active-energy-only proxy when
|
||||
// there is a non-trivial active-energy total. A profile with no HRmax /
|
||||
// resting HR can still get this fallback because it needs neither.
|
||||
// resting HR can still get this fallback because it needs neither. The
|
||||
// fallback has no personal intensity distribution, so it stays on the
|
||||
// population anchor (honestly labelled `population`).
|
||||
if (dayActiveEnergy > 0) {
|
||||
return {
|
||||
score: saturateToScore(dayActiveEnergy, STRAIN_ACTIVE_ENERGY_REFERENCE),
|
||||
@@ -260,6 +459,9 @@ export async function computeStrainScore(
|
||||
dayTrimp: 0,
|
||||
dayActiveEnergy,
|
||||
workoutsWithSeries: 0,
|
||||
anchor: "population",
|
||||
trainingDays: ref.trainingDays,
|
||||
refPersonalToPersist: ref.refPersonalToPersist,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,6 +476,9 @@ export async function computeStrainScore(
|
||||
dayTrimp: 0,
|
||||
dayActiveEnergy,
|
||||
workoutsWithSeries: 0,
|
||||
anchor: "population",
|
||||
trainingDays: ref.trainingDays,
|
||||
refPersonalToPersist: ref.refPersonalToPersist,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,10 +516,101 @@ export async function loadStrainInputs(
|
||||
return { profile, hrRest: rhr?.value ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.10.3 — read the trailing training-day TRIMP distribution + the prior
|
||||
* EWMA reference from the server-internal `strainTrimpCache`. Reads cheap
|
||||
* cached day-total TRIMP instead of re-integrating 42 days of HR series. The
|
||||
* cache populates itself forward (no backfill); a user with no cache rows yet
|
||||
* returns an empty history → the cold-start population anchor.
|
||||
*
|
||||
* `priorRefPersonal` = the most recent cached row's `refPersonal` STRICTLY
|
||||
* before the scored day, so a re-run for the same day (idempotent recompute)
|
||||
* blends against the prior night, never against its own previous write.
|
||||
*/
|
||||
export async function loadStrainAnchorHistory(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
): Promise<StrainAnchorHistory> {
|
||||
const dayKey = strainDayKey(now);
|
||||
const windowStart = new Date(
|
||||
new Date(`${dayKey}T00:00:00.000Z`).getTime() -
|
||||
STRAIN_CHRONIC_WINDOW_DAYS * MS_PER_DAY,
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
// The chronic window of cached rows, excluding the scored day itself (its
|
||||
// TRIMP is added at compute time so the distribution always reflects the
|
||||
// freshly-computed day).
|
||||
const rows = await prisma.strainTrimpCache.findMany({
|
||||
where: { userId, day: { gte: windowStart, lt: dayKey } },
|
||||
select: {
|
||||
day: true,
|
||||
dayTrimp: true,
|
||||
refPersonal: true,
|
||||
trainingDays: true,
|
||||
},
|
||||
orderBy: { day: "desc" },
|
||||
});
|
||||
|
||||
const trainingDayTrimps = rows
|
||||
.map((r) => r.dayTrimp)
|
||||
.filter((t) => t > 0);
|
||||
// The prior EWMA = the most recent cached row that actually carried personal
|
||||
// training history (`trainingDays > 0`). A row written on a pure rest /
|
||||
// energy-only day stores the population seed only to keep the column
|
||||
// non-null; adopting it would pollute the EWMA and stall personal
|
||||
// activation, so it is skipped — the seed stays unset (null) until a real
|
||||
// training day has warmed the reference.
|
||||
const warmedRow = rows.find((r) => r.trainingDays > 0);
|
||||
const priorRefPersonal = warmedRow ? warmedRow.refPersonal : null;
|
||||
|
||||
return { trainingDayTrimps, priorRefPersonal };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.10.3 — upsert the scored day's Strain anchor cache row (day-total TRIMP +
|
||||
* the EWMA reference + the anchor used + the training-day count). Idempotent on
|
||||
* `(userId, day)` so a re-fired nightly tick overwrites in place. Server-only.
|
||||
*/
|
||||
export async function upsertStrainTrimpCache(
|
||||
prisma: PrismaClient,
|
||||
args: {
|
||||
userId: string;
|
||||
now: Date;
|
||||
dayTrimp: number;
|
||||
refPersonal: number;
|
||||
anchor: StrainAnchor;
|
||||
trainingDays: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
const day = strainDayKey(args.now);
|
||||
await prisma.strainTrimpCache.upsert({
|
||||
where: { userId_day: { userId: args.userId, day } },
|
||||
create: {
|
||||
userId: args.userId,
|
||||
day,
|
||||
dayTrimp: args.dayTrimp,
|
||||
refPersonal: args.refPersonal,
|
||||
anchor: args.anchor,
|
||||
trainingDays: args.trainingDays,
|
||||
},
|
||||
update: {
|
||||
dayTrimp: args.dayTrimp,
|
||||
refPersonal: args.refPersonal,
|
||||
anchor: args.anchor,
|
||||
trainingDays: args.trainingDays,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface PersistStrainResult {
|
||||
outcome: "stored" | "insufficient";
|
||||
score: number | null;
|
||||
reason: StrainComputeResult["reason"];
|
||||
/** v1.10.3 — which anchor produced the score (drives the honesty label). */
|
||||
anchor: StrainAnchor;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,16 +624,20 @@ export async function persistStrainScore(
|
||||
now: Date,
|
||||
): Promise<PersistStrainResult> {
|
||||
const { profile, hrRest } = await loadStrainInputs(prisma, userId, now);
|
||||
const { score, reason } = await computeStrainScore(
|
||||
const anchorHistory = await loadStrainAnchorHistory(prisma, userId, now);
|
||||
const result = await computeStrainScore(
|
||||
prisma,
|
||||
userId,
|
||||
profile,
|
||||
hrRest,
|
||||
now,
|
||||
anchorHistory,
|
||||
);
|
||||
const { score, reason, dayTrimp, anchor, trainingDays, refPersonalToPersist } =
|
||||
result;
|
||||
|
||||
if (score === null) {
|
||||
return { outcome: "insufficient", score: null, reason };
|
||||
return { outcome: "insufficient", score: null, reason, anchor };
|
||||
}
|
||||
|
||||
await upsertScoreRow(prisma, {
|
||||
@@ -348,5 +648,21 @@ export async function persistStrainScore(
|
||||
now,
|
||||
});
|
||||
|
||||
return { outcome: "stored", score, reason };
|
||||
// Persist the day's TRIMP + the EWMA reference so the next night's chronic
|
||||
// window reads cheap cached values. Only the TRIMP path contributes a
|
||||
// training day; the active-energy fallback (dayTrimp === 0) still writes a
|
||||
// row so the cache reflects a non-training day, but it never counts toward
|
||||
// the personal distribution. `refPersonalToPersist` is null only when there
|
||||
// is no training history at all — fall back to the population reference for
|
||||
// the stored EWMA seed so the column stays non-null.
|
||||
await upsertStrainTrimpCache(prisma, {
|
||||
userId,
|
||||
now,
|
||||
dayTrimp,
|
||||
refPersonal: refPersonalToPersist ?? STRAIN_TRIMP_REFERENCE,
|
||||
anchor,
|
||||
trainingDays,
|
||||
});
|
||||
|
||||
return { outcome: "stored", score, reason, anchor };
|
||||
}
|
||||
Reference in new issue
Block a user