mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
The daily briefing serialised the aggregated feature set as computed: weight, grip strength and the weight target in kilograms, waist in centimetres, glucose and the signals of the day in mg/dL (the glucose signal carried no unit at all), workout distances in kilometres. A reader on pounds or mmol/L got a briefing in units they do not use. One step now converts the feature set after all canonical arithmetic, on both the nightly and the on-demand path: absolute values through the full transform, differences and slopes through the factor alone, every converted block labelled, workout distances under unit-neutral keys. The prompt, the stored fingerprint and the grounding check all read the converted set, so a unit switch moves the fingerprint and the grounding check compares the reply against the figures the model was shown. The weight target sentence, the comparison block and the GLP-1 plateau context quote weight in the reader's unit; the comparison block also labelled sleep as hours while carrying minutes. A unit switch now also re-warms a cached briefing alongside the status notes and narratives. Refs #1067
229 lines
7.4 KiB
TypeScript
229 lines
7.4 KiB
TypeScript
/**
|
|
* The aggregated feature set as a model reads it: every quantity whose unit
|
|
* depends on a preference converted into the reader's units, and labelled.
|
|
*
|
|
* `extractFeatures` computes in canonical units (kilograms, centimetres,
|
|
* metres, mg/dL) because the classifications, the correlations and the
|
|
* cached feature set are shared by every surface. The briefing and the
|
|
* Coach then serialised that canonical set straight into their prompts, so a
|
|
* reader on pounds or mmol/L got a narrative in kilograms or mg/dL. This is
|
|
* the one step between the two: it runs after all canonical arithmetic and
|
|
* before the snapshot is hashed, serialised and checked for grounding, so
|
|
* the hash moves with a unit switch and the grounding check compares the
|
|
* model's numbers against the same figures the model was shown.
|
|
*
|
|
* Absolute values take the full transform, differences and slopes the
|
|
* factor alone. A metric reader on mg/dL gets every number back unchanged,
|
|
* with a unit label added.
|
|
*/
|
|
import {
|
|
applyDisplayTransform,
|
|
applyDisplayTransformDelta,
|
|
getQuantityTransform,
|
|
getReadingTransform,
|
|
type DisplayTransform,
|
|
type UnitPreferences,
|
|
} from "@/lib/measurements/display-transform";
|
|
import { buildWeightTargetFeature } from "@/lib/targets/weight-trend";
|
|
|
|
import type {
|
|
AggregatedFeatures,
|
|
BucketedSeries,
|
|
RawFeatures,
|
|
} from "./features";
|
|
import type { SignalOfDay } from "./signals-of-day";
|
|
|
|
type Num = number | null;
|
|
|
|
function abs(value: Num, transform: DisplayTransform): Num {
|
|
return typeof value === "number"
|
|
? applyDisplayTransform(value, transform)
|
|
: value;
|
|
}
|
|
|
|
function delta(value: Num, transform: DisplayTransform): Num {
|
|
return typeof value === "number"
|
|
? applyDisplayTransformDelta(value, transform)
|
|
: value;
|
|
}
|
|
|
|
/** The measurement type each signal of the day is read from. */
|
|
const SIGNAL_TYPE: Partial<Record<SignalOfDay["metric"], string>> = {
|
|
weight: "WEIGHT",
|
|
glucose: "BLOOD_GLUCOSE",
|
|
};
|
|
|
|
/** One signal of the day in the reader's units. */
|
|
export function signalInReaderUnits(
|
|
signal: SignalOfDay,
|
|
units: UnitPreferences,
|
|
): SignalOfDay {
|
|
const type = SIGNAL_TYPE[signal.metric];
|
|
if (!type) return signal;
|
|
const t = getReadingTransform(type, units);
|
|
return {
|
|
...signal,
|
|
unit: t.displayUnit,
|
|
latest: applyDisplayTransform(signal.latest, t),
|
|
avg7: abs(signal.avg7, t),
|
|
avg30: abs(signal.avg30, t),
|
|
deltaVs7: delta(signal.deltaVs7, t),
|
|
deltaVs30: delta(signal.deltaVs30, t),
|
|
spread30: delta(signal.spread30, t),
|
|
recentAnomaly: signal.recentAnomaly
|
|
? {
|
|
...signal.recentAnomaly,
|
|
value: applyDisplayTransform(signal.recentAnomaly.value, t),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/** The weight aggregate in the reader's units, its target included. */
|
|
export function weightFeatureInReaderUnits(
|
|
weight: NonNullable<AggregatedFeatures["weight"]>,
|
|
units: UnitPreferences,
|
|
): NonNullable<AggregatedFeatures["weight"]> & { unit: string } {
|
|
const t = getReadingTransform("WEIGHT", units);
|
|
// The target is rebuilt from the canonical band and the canonical trend
|
|
// reference, so its position is judged exactly as before and only the
|
|
// band it quotes changes unit.
|
|
const target = weight.target
|
|
? (buildWeightTargetFeature(
|
|
{ min: weight.target.min, max: weight.target.max },
|
|
{ avg7: weight.avg7, latest: weight.latest },
|
|
units.system,
|
|
) ?? undefined)
|
|
: undefined;
|
|
return {
|
|
...weight,
|
|
unit: t.displayUnit,
|
|
latest: applyDisplayTransform(weight.latest, t),
|
|
avg7: abs(weight.avg7, t),
|
|
avg30: abs(weight.avg30, t),
|
|
avg90: abs(weight.avg90, t),
|
|
allTimeAvg: abs(weight.allTimeAvg, t),
|
|
allTimeMin: abs(weight.allTimeMin, t),
|
|
allTimeMax: abs(weight.allTimeMax, t),
|
|
slope30: delta(weight.slope30, t),
|
|
...(target ? { target } : {}),
|
|
};
|
|
}
|
|
|
|
function bucketedInReaderUnits(
|
|
series: BucketedSeries,
|
|
units: UnitPreferences,
|
|
): BucketedSeries & { unit: string } {
|
|
const t = getReadingTransform(series.type, units);
|
|
return {
|
|
...series,
|
|
unit: t.displayUnit,
|
|
buckets: series.buckets.map((bucket) => ({
|
|
...bucket,
|
|
mean: applyDisplayTransform(bucket.mean, t),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The whole feature set in the reader's units. Pure: returns a new object
|
|
* and leaves the (possibly cached and shared) input untouched.
|
|
*/
|
|
export function featuresInReaderUnits<
|
|
T extends AggregatedFeatures | RawFeatures,
|
|
>(features: T, units: UnitPreferences): T {
|
|
const out: AggregatedFeatures & Partial<RawFeatures> = { ...features };
|
|
|
|
if (features.weight) {
|
|
out.weight = weightFeatureInReaderUnits(features.weight, units);
|
|
}
|
|
|
|
if (features.gripStrength) {
|
|
const t = getReadingTransform("GRIP_STRENGTH", units);
|
|
out.gripStrength = {
|
|
...features.gripStrength,
|
|
latest: abs(features.gripStrength.latest, t),
|
|
avg30: abs(features.gripStrength.avg30, t),
|
|
slope30: delta(features.gripStrength.slope30, t),
|
|
unit: t.displayUnit,
|
|
} as AggregatedFeatures["gripStrength"];
|
|
}
|
|
|
|
if (features.waist) {
|
|
const t = getReadingTransform("WAIST_CIRCUMFERENCE", units);
|
|
out.waist = {
|
|
...features.waist,
|
|
latest: abs(features.waist.latest, t),
|
|
avg30: abs(features.waist.avg30, t),
|
|
slope30: delta(features.waist.slope30, t),
|
|
unit: t.displayUnit,
|
|
} as AggregatedFeatures["waist"];
|
|
}
|
|
|
|
if (features.glucose) {
|
|
const t = getReadingTransform("BLOOD_GLUCOSE", units);
|
|
out.glucose = {
|
|
...features.glucose,
|
|
avg7: abs(features.glucose.avg7, t),
|
|
avg30: abs(features.glucose.avg30, t),
|
|
avg90: abs(features.glucose.avg90, t),
|
|
latest: abs(features.glucose.latest, t),
|
|
slope30: delta(features.glucose.slope30, t),
|
|
unit: t.displayUnit,
|
|
} as AggregatedFeatures["glucose"];
|
|
}
|
|
|
|
const weightHistory = features.historicalComparison?.weight;
|
|
if (features.historicalComparison && weightHistory) {
|
|
const t = getReadingTransform("WEIGHT", units);
|
|
out.historicalComparison = {
|
|
...features.historicalComparison,
|
|
weight: {
|
|
current7dAvg: abs(weightHistory.current7dAvg, t),
|
|
previous30dAvg: abs(weightHistory.previous30dAvg, t),
|
|
change: delta(weightHistory.change, t),
|
|
unit: t.displayUnit,
|
|
} as NonNullable<AggregatedFeatures["historicalComparison"]>["weight"],
|
|
};
|
|
}
|
|
|
|
if (features.signalsOfDay) {
|
|
out.signalsOfDay = features.signalsOfDay.map((signal) =>
|
|
signalInReaderUnits(signal, units),
|
|
);
|
|
}
|
|
|
|
if (features.workouts) {
|
|
const t = getQuantityTransform("distance", units.system);
|
|
// The block is computed in kilometres; the transform takes metres. The
|
|
// keys lose their "Km" so a mile figure never sits under a kilometre
|
|
// name.
|
|
const distance = (value: Num) =>
|
|
abs(typeof value === "number" ? value * 1000 : value, t);
|
|
const { last7, last30, latest } = features.workouts;
|
|
const period = ({ totalDistanceKm, ...rest }: typeof last7) => ({
|
|
...rest,
|
|
totalDistance: distance(totalDistanceKm),
|
|
});
|
|
out.workouts = {
|
|
last7: period(last7),
|
|
last30: period(last30),
|
|
latest: latest
|
|
? (({ distanceKm, ...rest }) => ({
|
|
...rest,
|
|
distance: distance(distanceKm),
|
|
}))(latest)
|
|
: latest,
|
|
distanceUnit: t.displayUnit,
|
|
} as unknown as AggregatedFeatures["workouts"];
|
|
}
|
|
|
|
if ("bucketedMeasurements" in features) {
|
|
out.bucketedMeasurements = features.bucketedMeasurements.map((series) =>
|
|
bucketedInReaderUnits(series, units),
|
|
);
|
|
}
|
|
|
|
return out as T;
|
|
}
|