From cdee3e3efc1cb6f70ebda01cb0597e379856476d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:58:30 +0200 Subject: [PATCH] feat(environment): register env signals and wire weather into correlation discovery Register the seven environmental-exposure signals through the signal registry as a new "environment" kind (correlation-eligible; off Coach/MCP/FHIR in v1), and fold their daily series into the correlation engine as lagged behaviour channels. Weather/daylight now pair against mood, sleep, and vitals through the existing FDR / effect-size / shrinkage gates, so a thin series degrades to absent rather than a spurious link. All env channels share one ENVIRONMENT family so two same-day fields never lag-correlate against each other. --- .../correlations/__tests__/route.test.ts | 7 + src/app/api/insights/correlations/route.ts | 29 +++- .../mood/mood-discovered-relations.tsx | 9 ++ .../insights/correlation-channel-series.ts | 55 +++++++ src/lib/insights/correlation-discovery.ts | 15 ++ .../__tests__/registry-invariants.test.ts | 4 +- src/lib/signals/adapters/correlation.ts | 5 +- src/lib/signals/adapters/fhir.ts | 4 +- src/lib/signals/adapters/metric-status.ts | 2 +- src/lib/signals/registry.ts | 147 +++++++++++++++++- 10 files changed, 265 insertions(+), 12 deletions(-) diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index a4d8b711f..a584897c9 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -20,6 +20,10 @@ vi.mock("@/lib/db", () => ({ illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) }, // v1.22 — labs ↔ outcome pass fetches lab draws; default empty. labResult: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.25 (W-ENV) — the route folds the environmental-exposure behaviour + // channels in (read from EnvironmentContext); default empty so no env + // channel produces a point. + environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -113,6 +117,9 @@ beforeEach(() => { ); // v1.22 — labs ↔ outcome pass reads lab draws; default empty. (prisma.labResult.findMany as ReturnType).mockResolvedValue([]); + ( + prisma.environmentContext.findMany as ReturnType + ).mockResolvedValue([]); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 33cf3329a..4256e6274 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -39,6 +39,7 @@ import { } from "@/lib/insights/correlation-discovery"; import { fetchComplianceSeries, + fetchEnvironmentSeries, fetchLabDraws, fetchSymptomSeries, } from "@/lib/insights/correlation-channel-series"; @@ -152,11 +153,16 @@ export const GET = apiHandler(async () => { // their own sources. Each degrades to an empty series when the user has no // data, so the discovery loop drops the channel (it cannot clear n ≥ 20). // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside. - const [complianceSeries, symptomSeries, labDraws] = await Promise.all([ - fetchComplianceSeries(user.id, tz, since), - fetchSymptomSeries(user.id, tz, since), - fetchLabDraws(user.id, tz, since), - ]); + const [complianceSeries, symptomSeries, labDraws, environmentSeries] = + await Promise.all([ + fetchComplianceSeries(user.id, tz, since), + fetchSymptomSeries(user.id, tz, since), + fetchLabDraws(user.id, tz, since), + // v1.25 (W-ENV) — environmental-exposure behaviour channels (weather / + // daylight). Empty when the module is off / no home set, so the channels + // degrade to absent. The module gate is implicit: no rows ⇒ no channels. + fetchEnvironmentSeries(user.id, since), + ]); const points = (key: string): DailySeriesPoint[] => key === "MOOD" @@ -180,6 +186,12 @@ export const GET = apiHandler(async () => { series.push({ key, role: "outcome", points: points(key) }); } } + // v1.25 (W-ENV) — fold the environmental-exposure behaviour channels in. They + // pair (lag D → D+1) against every outcome above; the n ≥ 20 / FDR / effect- + // size gates apply unchanged, so a thin weather series degrades to absent. + for (const envSeries of environmentSeries) { + series.push(envSeries); + } const result = discoverCorrelations(series); @@ -213,6 +225,13 @@ export const GET = apiHandler(async () => { emerging_window_days: emerging.windowDays, lab_draws: labDraws.length, lab_correlations: labCorrelations.discovered.length, + // v1.25 (W-ENV) — env channel reach (sum of stored daily points across + // the exposure channels) so a dashboard can see whether weather was + // available for the scan. + environment_days: environmentSeries.reduce( + (sum, s) => sum + s.points.length, + 0, + ), }, }); diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 4b2283d96..b56b70d1e 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -52,6 +52,15 @@ const CHANNEL_LABEL_KEY: Record = { HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", RESTING_HEART_RATE: "measurements.typeRestingHeartRate", WEIGHT: "measurements.typeWeight", + // v1.25 (W-ENV) — environmental-exposure channels, so a "daylight → mood" + // relation reads with its descriptive label instead of the raw ENV_ key. + ENV_TEMP_MEAN: "environment.fields.tempMean", + ENV_TEMP_MIN: "environment.fields.tempMin", + ENV_SUNSHINE: "environment.fields.sunshine", + ENV_DAYLIGHT: "environment.fields.daylight", + ENV_PRECIP: "environment.fields.precip", + ENV_PRESSURE_MEAN: "environment.fields.pressureMean", + ENV_PRESSURE_DELTA: "environment.fields.pressureDelta", }; /** Keep only the discovered pairs that involve the mood channel. */ diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 98751db43..fa3d4f90e 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -24,11 +24,13 @@ import { import { MEDICATION_COMPLIANCE_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, + type DailySeriesPoint, type LabDrawPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { resolveLabFields } from "@/lib/labs/serialise"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; import { buildComplianceDailySeries, buildSymptomSeverityDailySeries, @@ -183,6 +185,59 @@ export async function fetchSymptomSeries( }); } +/** + * v1.25 (W-ENV) — build the user's environmental-exposure BEHAVIOUR channels. + * + * One {@link NamedSeries} per registered env field (temperature, daylight, + * sunshine, precipitation, pressure mean + intraday swing), read from the daily + * `EnvironmentContext` rows the nightly job stores. Each row's `date` is already + * a YYYY-MM-DD key, so points need no re-keying. Sunshine / daylight are stored + * in seconds and surfaced as hours (correlation r is scale-invariant; hours just + * keep the series readable). A field with no non-null values yields an empty + * series that degrades to absent — never a fabricated constant. The whole set is + * empty when the user has no environment rows (module off / no home set). + */ +export async function fetchEnvironmentSeries( + userId: string, + since: Date, +): Promise { + const sinceKey = since.toISOString().slice(0, 10); + const rows = await prisma.environmentContext.findMany({ + where: { userId, date: { gte: sinceKey } }, + orderBy: { date: "asc" }, + take: 1000, + select: { + date: true, + tempMean: true, + tempMin: true, + tempMax: true, + apparentMean: true, + sunshineSec: true, + daylightSec: true, + precipSum: true, + pressureMean: true, + pressureDelta: true, + humidityMean: true, + cloudMean: true, + }, + }); + + return ENVIRONMENT_FIELDS.map((field) => { + const points: DailySeriesPoint[] = []; + for (const row of rows) { + const raw = row[field.column]; + if (raw == null || !Number.isFinite(raw)) continue; + // Seconds → hours for the duration fields; pass through otherwise. + const value = + field.column === "sunshineSec" || field.column === "daylightSec" + ? raw / 3600 + : raw; + points.push({ day: row.date, value }); + } + return { key: field.key, role: "behaviour" as const, points }; + }); +} + /** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ function tzDayKey(at: Date, tz: string): string { const { year, month, day } = wallClockInTz(at, tz); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index f436fd407..279944fd7 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -39,6 +39,10 @@ * link. The series builders live in `correlation-series-builders.ts`. */ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; +import { + getEnvironmentField, + isEnvironmentChannelKey, +} from "@/lib/environment/fields"; /** Default Benjamini-Hochberg target false-discovery rate. */ export const FDR_Q = 0.1; @@ -102,6 +106,12 @@ export function metricFamily(key: string): string { // tautology as MOOD→MOOD and is excluded too. if (key.startsWith(FACTOR_CHANNEL_PREFIX) || key === "MOOD") return "MOOD"; if (key.startsWith("BLOOD_PRESSURE")) return "BLOOD_PRESSURE"; + // v1.25 (W-ENV) — every environmental-exposure channel (ENV_TEMP_MEAN, + // ENV_DAYLIGHT, …) shares ONE family so the same-family guard never + // lag-correlates two same-day weather fields against each other (a + // near-tautology). They stay free to pair cross-domain against mood / sleep / + // vitals — the whole point of the module. + if (key.startsWith("ENV_")) return "ENVIRONMENT"; // v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form // their OWN single-channel family, so the loop's same-family guard collapses // only the self-lag (compliance→compliance, symptom→symptom). The returned @@ -312,6 +322,11 @@ function humanise(key: string): string { if (key.startsWith("FACTOR:")) { return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`; } + // v1.25 (W-ENV) — env channels read with their descriptive label ("daily + // temperature", "daylight") rather than the raw "env temp mean" key. + if (isEnvironmentChannelKey(key)) { + return getEnvironmentField(key)?.narrationLabel ?? key.toLowerCase(); + } return key.replace(/_/g, " ").toLowerCase(); } diff --git a/src/lib/signals/__tests__/registry-invariants.test.ts b/src/lib/signals/__tests__/registry-invariants.test.ts index d1538ba12..337b17f27 100644 --- a/src/lib/signals/__tests__/registry-invariants.test.ts +++ b/src/lib/signals/__tests__/registry-invariants.test.ts @@ -40,6 +40,8 @@ describe("signal registry — structural sanity", () => { for (const signal of allSignals()) { if (signal.kind === "biomarker") { expect(signal.source.biomarkerKey.length).toBeGreaterThan(0); + } else if (signal.kind === "environment") { + expect(signal.source.environmentChannelKey.length).toBeGreaterThan(0); } else { expect(signal.source.measurementType.length).toBeGreaterThan(0); } @@ -57,7 +59,7 @@ describe("signal registry — structural sanity", () => { it("signalForMeasurementType round-trips every measurement/score signal", () => { for (const signal of allSignals()) { - if (signal.kind === "biomarker") continue; + if (signal.kind !== "measurement" && signal.kind !== "score") continue; expect(signalForMeasurementType(signal.source.measurementType)).toBe( signal, ); diff --git a/src/lib/signals/adapters/correlation.ts b/src/lib/signals/adapters/correlation.ts index 777399d0a..a78cc582a 100644 --- a/src/lib/signals/adapters/correlation.ts +++ b/src/lib/signals/adapters/correlation.ts @@ -13,7 +13,10 @@ import { allSignals } from "@/lib/signals/registry"; export function deriveBucketedTypes(): MeasurementType[] { const types: MeasurementType[] = []; for (const signal of allSignals()) { - if (signal.kind === "biomarker") continue; + // biomarker (labs path) + environment (W-ENV exposure channels) are not + // `MeasurementType`-backed — env feeds the engine through its own series + // builder, not the measurement-keyed bucketed types. + if (signal.kind === "biomarker" || signal.kind === "environment") continue; if (!signal.surfaces.correlationEligible) continue; types.push(signal.source.measurementType); } diff --git a/src/lib/signals/adapters/fhir.ts b/src/lib/signals/adapters/fhir.ts index 1daafb530..d3dc44ffd 100644 --- a/src/lib/signals/adapters/fhir.ts +++ b/src/lib/signals/adapters/fhir.ts @@ -13,7 +13,9 @@ import { allSignals } from "@/lib/signals/registry"; export function deriveMeasurementLoinc(): Record { const entries: Array<[string, LoincMapping]> = []; for (const signal of allSignals()) { - if (signal.kind === "biomarker") continue; + // Only `MeasurementType`-backed signals key `MEASUREMENT_LOINC`. biomarker + // (labs) + environment (W-ENV) signals are never Measurement rows. + if (signal.kind === "biomarker" || signal.kind === "environment") continue; if (!signal.fhir) continue; entries.push([ signal.source.measurementType, diff --git a/src/lib/signals/adapters/metric-status.ts b/src/lib/signals/adapters/metric-status.ts index 2470a6733..ec0d18522 100644 --- a/src/lib/signals/adapters/metric-status.ts +++ b/src/lib/signals/adapters/metric-status.ts @@ -15,7 +15,7 @@ import { getSignal } from "@/lib/signals/registry"; /** Build the `MetricStatusMeta` for one registered metric-status id. */ function metaFor(id: MetricStatusMetricId): MetricStatusMeta { const signal = getSignal(id); - if (!signal || signal.kind === "biomarker") { + if (!signal || signal.kind === "biomarker" || signal.kind === "environment") { throw new Error(`signal registry: no measurement signal for "${id}"`); } // Build field-by-field, omitting the optional keys when absent so the diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 70d472f4f..3c51b5049 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -30,8 +30,15 @@ import type { import type { FhirObservationCategory } from "@/lib/fhir/loinc-map"; import { FEVER_BAND_C } from "@/lib/clinical-floors"; -/** Discriminator: a device/manual reading, a lab biomarker, or a computed score. */ -export type SignalKind = "measurement" | "biomarker" | "score"; +/** + * Discriminator: a device/manual reading, a lab biomarker, a computed score, or + * an environmental exposure. `environment` (v1.25 W-ENV) is NOT backed by a + * `MeasurementType` — its daily value lives in `EnvironmentContext` and feeds + * the correlation engine through the env channel series builder, not the + * measurement-keyed `BUCKETED_TYPES`. It registers here so the env signals get + * a stable key + display metadata in the one cross-surface table. + */ +export type SignalKind = "measurement" | "biomarker" | "score" | "environment"; /** Reuse the assessment registry's direction vocabulary verbatim. */ export type SignalDirection = MetricDirection; @@ -114,6 +121,17 @@ export interface BiomarkerSignalSource { biomarkerKey: string; } +/** + * How an `environment` signal is sourced (v1.25 W-ENV). The value is a daily + * `EnvironmentContext` column, surfaced to the correlation engine under + * `environmentChannelKey` (the `ENV_*` channel key from + * `@/lib/environment/fields`). No `MeasurementType` — env data is never a + * Measurement row. + */ +export interface EnvironmentSignalSource { + environmentChannelKey: string; +} + /** * The canonical cross-surface signal definition, discriminated on `kind`. * `measurement` and `score` both resolve to a `MeasurementType`; `biomarker` @@ -122,7 +140,8 @@ export interface BiomarkerSignalSource { export type SignalDefinition = | (SignalCommon & { kind: "measurement"; source: MeasurementSignalSource }) | (SignalCommon & { kind: "score"; source: MeasurementSignalSource }) - | (SignalCommon & { kind: "biomarker"; source: BiomarkerSignalSource }); + | (SignalCommon & { kind: "biomarker"; source: BiomarkerSignalSource }) + | (SignalCommon & { kind: "environment"; source: EnvironmentSignalSource }); /** Convenience: build a Coach-snapshot facet from a scope token. */ function coachScope(scope: string): { scope: string } { @@ -1504,6 +1523,128 @@ export const SIGNALS: Record = { mcp: false, }, }, + // ── v1.25 (W-ENV) environmental-context module — exposure signals ───────── + // Daily weather/daylight, fetched server-side from Open-Meteo for the user's + // coarse home (or a travel override) and stored in `EnvironmentContext`. NOT + // `MeasurementType`-backed: `correlationEligible:true` declares intent, but + // the wiring runs through the env channel series builder, not the + // measurement-keyed `BUCKETED_TYPES` (the correlation adapter skips + // `kind:"environment"`). Off the Coach snapshot + MCP surfaces in v1 + // (`coachSnapshot:false ⇒ mcp:false`); no FHIR facet (env observations are + // not Measurement rows, so they never enter `MEASUREMENT_LOINC`). Direction is + // `target-band` — a neutral exposure, not a "better/worse" vital. + ENV_TEMP_MEAN: { + key: "ENV_TEMP_MEAN", + kind: "environment", + source: { environmentChannelKey: "ENV_TEMP_MEAN" }, + i18nPrefix: "environment.fields.tempMean", + displayName: "Daily temperature", + unit: "°C", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_TEMP_MIN: { + key: "ENV_TEMP_MIN", + kind: "environment", + source: { environmentChannelKey: "ENV_TEMP_MIN" }, + i18nPrefix: "environment.fields.tempMin", + displayName: "Overnight low temperature", + unit: "°C", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_SUNSHINE: { + key: "ENV_SUNSHINE", + kind: "environment", + source: { environmentChannelKey: "ENV_SUNSHINE" }, + i18nPrefix: "environment.fields.sunshine", + displayName: "Sunshine duration", + unit: "h", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_DAYLIGHT: { + key: "ENV_DAYLIGHT", + kind: "environment", + source: { environmentChannelKey: "ENV_DAYLIGHT" }, + i18nPrefix: "environment.fields.daylight", + displayName: "Daylight length", + unit: "h", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_PRECIP: { + key: "ENV_PRECIP", + kind: "environment", + source: { environmentChannelKey: "ENV_PRECIP" }, + i18nPrefix: "environment.fields.precip", + displayName: "Precipitation", + unit: "mm", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_PRESSURE_MEAN: { + key: "ENV_PRESSURE_MEAN", + kind: "environment", + source: { environmentChannelKey: "ENV_PRESSURE_MEAN" }, + i18nPrefix: "environment.fields.pressureMean", + displayName: "Barometric pressure", + unit: "hPa", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, + ENV_PRESSURE_DELTA: { + key: "ENV_PRESSURE_DELTA", + kind: "environment", + source: { environmentChannelKey: "ENV_PRESSURE_DELTA" }, + i18nPrefix: "environment.fields.pressureDelta", + displayName: "Pressure swing", + unit: "hPa", + direction: "target-band", + archetype: "environmental-exposure", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: false, + mcp: false, + }, + }, }; /** Type guard: narrow an arbitrary string to a registered signal key. */