mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user