Files
HealthLog/src/lib/environment/fields.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

229 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.25 — the canonical environmental-exposure field vocabulary.
*
* One declarative table that names each daily weather/daylight field HealthLog
* stores AND correlates. It is the single source the two surfaces speak:
*
* - the SIGNAL REGISTRY (`src/lib/signals/registry.ts`) registers one
* `kind:"environment"` signal per field here, so each gets a stable signal
* key + display metadata for free; and
* - the CORRELATION ENGINE reads {@link ENVIRONMENT_FIELDS} to fold the
* same fields in as lagged BEHAVIOUR (exposure) channels against mood /
* sleep / vitals.
*
* `key` doubles as the registry signal key AND the correlation channel key —
* the `ENV_` prefix keeps it from colliding with a `MeasurementType` and lets
* `metricFamily()` collapse all env channels into one `ENVIRONMENT` family (so
* the engine never lag-correlates two same-day weather fields against each
* other — that is a near-tautology, not an insight).
*
* `column` is the `EnvironmentContext` numeric column the field reads. Leaf
* module (no imports) so both the registry and the pure correlation engine can
* depend on it without a cycle.
*
* Lag semantics (v1.42). Every field here is tested as ONE hypothesis per
* outcome: the exposure is the mean of the day before and the day itself
* ("lag 0–1"), paired with the outcome on the same day. The correlation read
* in `correlation-channel-series.ts` builds that mean, emits a point only for
* a day whose previous day is stored too, and tags the series `lagDays: 0`; a
* discovered pair therefore reports `lagDays: 0` and narrates "on the day and
* the day before". Two reasons, both about getting the night right:
*
* - sleep is keyed on the day you wake up, so last night's sleep sits on day
* D, and the overnight low that shaped it is the early morning of D (and the
* evening of D−1). The old fixed next-day lag paired the night's low with
* the FOLLOWING night;
* - testing lag 0 and lag 1 separately would double the environmental tests
* in the false-discovery family (and taking whichever looked better would
* be selection without correction). The averaged window covers both at the
* cost of one test.
*
* Season and trend are removed from both series before any of this is
* correlated (see `src/lib/insights/seasonal-adjust.ts`), so a field that only
* follows the calendar does not surface. A new field added below inherits all
* of it with no further wiring.
*/
/** The `EnvironmentContext` numeric columns a correlation field can read. */
export type EnvironmentContextNumericColumn =
| "tempMean"
| "tempMin"
| "tempMax"
| "apparentMean"
| "sunshineSec"
| "daylightSec"
| "precipSum"
| "pressureMean"
| "pressureDelta"
| "humidityMean"
| "cloudMean"
// v1.42 (#615) — air quality.
| "pm25Mean"
| "o3Max8h";
/**
* A channel value that is not one column: the day's highest pollen count over
* the six kinds (`pollenAlderMax` … `pollenRagweedMax`), null when no kind was
* covered. Read by {@link environmentFieldValue}.
*/
export type EnvironmentDerivedColumn = "pollenMax";
/** The six pollen columns `pollenMax` folds. */
export const POLLEN_COLUMNS = [
"pollenAlderMax",
"pollenBirchMax",
"pollenGrassMax",
"pollenMugwortMax",
"pollenOliveMax",
"pollenRagweedMax",
] as const;
export interface EnvironmentField {
/** Stable key — the registry signal key AND the correlation channel key. */
key: string;
/** The `EnvironmentContext` column this field's daily value comes from. */
column: EnvironmentContextNumericColumn | EnvironmentDerivedColumn;
/**
* v1.42 — the field comes from the air-quality feed, so it is read only
* while the account's air-quality part is on (and the operator's is not
* off). The weather fields do not set it.
*/
airQuality?: true;
/** Canonical unit (English, for prose + registry metadata). */
unit: string;
/** Stable English display name (the UI localises via `i18nLabelKey`). */
displayName: string;
/** Lower-cased phrase the correlation narration ("humanise") reads. */
narrationLabel: string;
/** i18n label key resolved by the correlation / settings surfaces. */
i18nLabelKey: string;
}
/**
* The exposure fields, ranked by evidence in the W-ENV research review:
* temperature (sleep / BP / recovery), sunshine + daylight (mood), precipitation
* (activity), pressure mean + intraday swing (headache/symptom — honestly
* caveated, optional-strength). All are BEHAVIOUR (lag-source) channels.
*/
export const ENVIRONMENT_FIELDS: readonly EnvironmentField[] = [
{
key: "ENV_TEMP_MEAN",
column: "tempMean",
unit: "°C",
displayName: "Daily temperature",
narrationLabel: "daily temperature",
i18nLabelKey: "environment.fields.tempMean",
},
{
key: "ENV_TEMP_MIN",
column: "tempMin",
unit: "°C",
displayName: "Overnight low temperature",
narrationLabel: "overnight low temperature",
i18nLabelKey: "environment.fields.tempMin",
},
{
key: "ENV_SUNSHINE",
column: "sunshineSec",
unit: "h",
displayName: "Sunshine duration",
narrationLabel: "sunshine",
i18nLabelKey: "environment.fields.sunshine",
},
{
key: "ENV_DAYLIGHT",
column: "daylightSec",
unit: "h",
displayName: "Daylight length",
narrationLabel: "daylight",
i18nLabelKey: "environment.fields.daylight",
},
{
key: "ENV_PRECIP",
column: "precipSum",
unit: "mm",
displayName: "Precipitation",
narrationLabel: "precipitation",
i18nLabelKey: "environment.fields.precip",
},
{
key: "ENV_PRESSURE_MEAN",
column: "pressureMean",
unit: "hPa",
displayName: "Barometric pressure",
narrationLabel: "barometric pressure",
i18nLabelKey: "environment.fields.pressureMean",
},
{
key: "ENV_PRESSURE_DELTA",
column: "pressureDelta",
unit: "hPa",
displayName: "Pressure swing",
narrationLabel: "intraday pressure swing",
i18nLabelKey: "environment.fields.pressureDelta",
},
// v1.42 (#615) — three air-quality channels, deliberately no more: every
// channel widens the false-discovery family for all the others. Fine
// particles and the ozone high carry the evidence for blood pressure, HRV
// and sleep; the pollen high is the one with a plausible personal signal,
// against symptoms. UV, NO2 and dust are shown and read by the Coach but
// not correlated (UV is almost pure season, NO2 moves with PM2.5). The
// night-time heat channel is the existing overnight low above.
{
key: "ENV_PM25",
column: "pm25Mean",
unit: "µg/m³",
displayName: "Fine particles (PM2.5)",
narrationLabel: "fine particles (PM2.5)",
i18nLabelKey: "environment.fields.pm25",
airQuality: true,
},
{
key: "ENV_OZONE_8H",
column: "o3Max8h",
unit: "µg/m³",
displayName: "Ozone (8-hour high)",
narrationLabel: "ozone",
i18nLabelKey: "environment.fields.ozone8h",
airQuality: true,
},
{
key: "ENV_POLLEN_MAX",
column: "pollenMax",
unit: "grains/m³",
displayName: "Pollen (highest)",
narrationLabel: "pollen",
i18nLabelKey: "environment.fields.pollenMax",
airQuality: true,
},
] as const;
/** The columns a day row must carry for {@link environmentFieldValue}. */
export type EnvironmentFieldRow = Readonly<
Record<EnvironmentContextNumericColumn, number | null> &
Record<(typeof POLLEN_COLUMNS)[number], number | null>
>;
/**
* One field's raw value on one stored day (before any unit conversion), or
* null when the day does not cover it. The pollen high is the maximum over
* the kinds that were covered, never a zero for the ones that were not.
*/
export function environmentFieldValue(
field: EnvironmentField,
row: EnvironmentFieldRow,
): number | null {
if (field.column === "pollenMax") {
let best: number | null = null;
for (const column of POLLEN_COLUMNS) {
const value = row[column];
if (value != null && Number.isFinite(value)) {
best = best === null ? value : Math.max(best, value);
}
}
return best;
}
const value = row[field.column];
return value != null && Number.isFinite(value) ? value : null;
}