2baf29b3ae
Deliver encrypted health zone, observation API, baseline maturity pipeline, page-data bindings, and H5/WeChat channel integration for health P0. Co-authored-by: Cursor <cursoragent@cursor.com>
85 lines
2.7 KiB
JavaScript
85 lines
2.7 KiB
JavaScript
import {
|
|
isValidObservationForBaseline,
|
|
resolveBaselineMaturity,
|
|
} from './health-baseline-maturity.mjs';
|
|
|
|
const WINDOWS = Object.freeze([7, 30, 90]);
|
|
|
|
function withinWindow(observedAt, windowDays, now = Date.now()) {
|
|
const ts = Number(observedAt);
|
|
if (!Number.isFinite(ts)) return false;
|
|
const start = now - windowDays * 24 * 60 * 60 * 1000;
|
|
return ts >= start && ts <= now;
|
|
}
|
|
|
|
function mean(values) {
|
|
if (!values.length) return null;
|
|
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
}
|
|
|
|
function stddev(values, avg) {
|
|
if (values.length < 2) return 0;
|
|
const m = avg ?? mean(values);
|
|
const variance = values.reduce((sum, v) => sum + (v - m) ** 2, 0) / values.length;
|
|
return Math.sqrt(variance);
|
|
}
|
|
|
|
export function computeMetricBaseline(
|
|
observations = [],
|
|
{
|
|
metricType,
|
|
context = 'any',
|
|
windowDays = 30,
|
|
now = Date.now(),
|
|
} = {},
|
|
) {
|
|
const rows = observations.filter((row) => {
|
|
if (!isValidObservationForBaseline(row)) return false;
|
|
if (row.metricType !== metricType) return false;
|
|
if (row.valueNum == null) return false;
|
|
if (context !== 'any' && row.context !== context) return false;
|
|
return withinWindow(row.observedAt, windowDays, now);
|
|
});
|
|
const values = rows.map((row) => Number(row.valueNum)).filter(Number.isFinite);
|
|
const sampleCount = values.length;
|
|
const avg = mean(values);
|
|
return {
|
|
metricType,
|
|
context,
|
|
windowDays,
|
|
sampleCount,
|
|
maturity: resolveBaselineMaturity(sampleCount),
|
|
mean: avg == null ? null : Number(avg.toFixed(2)),
|
|
stddev: avg == null ? null : Number(stddev(values, avg).toFixed(2)),
|
|
min: values.length ? Math.min(...values) : null,
|
|
max: values.length ? Math.max(...values) : null,
|
|
};
|
|
}
|
|
|
|
export function computeHealthBaselines(observations = [], { now = Date.now() } = {}) {
|
|
const metricContexts = [
|
|
{ metricType: 'bp_systolic', context: 'morning' },
|
|
{ metricType: 'bp_diastolic', context: 'morning' },
|
|
{ metricType: 'bp_systolic', context: 'evening' },
|
|
{ metricType: 'bp_diastolic', context: 'evening' },
|
|
{ metricType: 'hr', context: 'any' },
|
|
{ metricType: 'weight', context: 'any' },
|
|
{ metricType: 'spo2', context: 'any' },
|
|
{ metricType: 'sleep_minutes', context: 'any' },
|
|
];
|
|
const baselines = [];
|
|
for (const windowDays of WINDOWS) {
|
|
for (const spec of metricContexts) {
|
|
baselines.push(computeMetricBaseline(observations, { ...spec, windowDays, now }));
|
|
}
|
|
}
|
|
return baselines;
|
|
}
|
|
|
|
export function deviationFromBaseline(value, baselineMean) {
|
|
const num = Number(value);
|
|
const mean = Number(baselineMean);
|
|
if (!Number.isFinite(num) || !Number.isFinite(mean) || mean === 0) return null;
|
|
return Number((((num - mean) / mean) * 100).toFixed(1));
|
|
}
|