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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:58:30 +02:00
1 parent 76dc03ae7f
commit cdee3e3efc
10 files changed
+265 -12

No files matched your search

@@ -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<typeof vi.fn>).mockResolvedValue([]);
(
prisma.environmentContext.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
+24 -5
View File
@@ -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,
),
},
});
@@ -52,6 +52,15 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
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. */
@@ -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<NamedSeries[]> {
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);
+15
View File
@@ -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();
}
@@ -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,
);
+4 -1
View File
@@ -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);
}
+3 -1
View File
@@ -13,7 +13,9 @@ import { allSignals } from "@/lib/signals/registry";
export function deriveMeasurementLoinc(): Record<string, LoincMapping> {
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,
+1 -1
View File
@@ -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
+144 -3
View File
@@ -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<string, SignalDefinition> = {
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. */