diff --git a/docs/piezo-processor.md b/docs/piezo-processor.md index 233ade2ca..cbb6dca79 100644 --- a/docs/piezo-processor.md +++ b/docs/piezo-processor.md @@ -200,6 +200,30 @@ Piezo presence only sees vibration energy and rhythm, so strong bed vibration wi With one side away, readings use the home side. Presence on either side allows vitals, but an empty verdict requires usable, trusted evidence from both sides. Missing or unusable observations produce unknown immediately. A restart requires a new observation before publishing evidence. A missing, unreadable, legacy or stale checkpoint, or per-side evidence more than five minutes old, also means unknown, even if another side keeps rewriting the file. Unknown presence leaves piezo presence in control. Until an unloading has established a trustworthy baseline, empty-bed vibration can therefore still pass the piezo detector. +## 5a. Beat-to-Beat Detection (`beats.py`) + +Individual heartbeat times — what HRV and heart-rate-based sleep staging need — detected per side on a 20 s window advancing 5 s; only beats in the central 5 s are committed, so every beat is decided with context on both sides (~12.5 s latency). A side's tracker only runs while the presence detector (§5) says the side is occupied: an empty bed still carries regular vibration from the pod itself, which the detector would otherwise take for a pulse. + +| Step | What it does | +|---|---| +| Band-pass + decimate | Each piezo channel to the cardiac band (0.8–8.5 Hz, as the rate pipeline), 500 → 100 Hz | +| Motion blanking | Samples above 3× the typical heartbeat peak (median of per-1.5 s block maxima, so it tracks J-peak height at any heart rate) are zeroed with a 0.5 s margin; no beat is taken within 0.3 s, and a break is recorded, so no interval spans a turn-over | +| Other-side noise reference | Ridge least-squares FIR (±50 ms) from the other side's two channels, subtracted. Removes vibration common to both sides (pump beat frequencies, building, a partner's coupled heartbeat). The other side can also carry *this* sleeper's heartbeat; subtracting it collapses cardiac periodicity (measured ~0.83 → 0.1–0.26), so the cleaned channel is used unless periodicity drops by more than 0.10 | +| Two channels | `left1`/`left2` (and right): normalised, the second aligned to the first (lag ±100 ms, polarity), weighted by periodicity²; the combination is used only if it beats the better single channel | +| Period selection | Autocorrelation of the beat envelope over 40–150 bpm. Candidates are its peaks, minus any lag whose double correlates better (a second wave inside each beat complex, which on a bed can sit ~0.4 s after the main one) or whose half or third correlates at least as well (every other/third cycle of something faster). The winner has the best subharmonic sum — autocorrelation at 1×, 2×, 3× the lag, weighted 0.84ᵏ (Hermes 1988) | +| Rhythm tracking | As HRTracker (§6) does for the window rate: once a rhythm is tracked, a window's period must come from peaks within 20% of the median of the last 5 (quality is that peak's own correlation, ≥ 0.2 while following), else the window yields no beats — movement or machinery can put a stronger rhythm at another rate on the sensors. Three consistent windows at a new rate (15 s) re-seed it; without a rate hint, tracking starts from two agreeing windows and only at resting rates (≤ 120 bpm, excluding multiples of a faster rhythm), so a fast rhythm present when someone lies down isn't taken for the heart. A tracked rate can climb past 120 bpm; a rhythm unseen for 60 s is forgotten | +| Rate hint | In some sleeping positions every beat carries a second wave as strong as the first, half a period later: the envelope then repeats every half-beat and periodicity alone can't tell which is the heart (on a real night the tracker followed half-beats at ~2× the true rate for many minutes). The side's window heart rate (HRTracker, a different estimator that corrects half/double readings) is passed to the tracker each minute: a fresh hint (< 2 min) decides where tracking starts — the period nearest it, with the octave filters off — and a tracked rhythm more than 20% from it is dropped | +| Gated records | A pump-gated record is pushed masked rather than restarting the windows: its samples are blanked like movement (0.5 s margin, a break so no interval spans it), costing that second instead of a 20 s window refill | +| Template matching | The window's median beat complex is matched against the signal (normalised cross-correlation); peaks ≥ 0.4 at ≥ 0.6 period spacing are beats, gaps > 1.6 periods are searched again at 0.25, positions refined parabolically. With two good channels, a beat neither channel shows (same template) is dropped | +| Anchor continuity | A beat's time is a fixed point of the complex (J peak or K trough, ~80 ms apart, depending on polarity). Each window's template is aligned to the previous one (both cropped to the shorter, as template length follows the period) and beat times shifted accordingly, so a night keeps one anchor — a switch would put one bad interval per switch into RMSSD | +| Artifact correction | Intervals > 20% from the median of the last 11 are rejected (classic HRV editing criterion); an extra beat whose two short intervals sum to a normal one is removed; missed beats and ectopy become breaks, never interpolated beats (classification after Lipponen & Tarvainen 2019). Three mutually consistent outliers mean a real rate change and reset the history — unless the new rate is ~½× or ~2× (a detection artifact) | + +Output: one `heartbeats` row per side per minute — beat times as ms offsets, `null` for a break (~350 bytes per full minute, ~150 KB per side per night). Leaving the bed stores the minute in progress. In single-sleeper mode each minute keeps the side that saw the sleeper's beats best, stored under the home side: a minute is held until both trackers are past it (at most 3 min) and keeps the home side it was queued for if the mode changes meanwhile; a minute from another sensor than the last one stored under that side starts with a break, as the two trackers' anchors differ. A failed write is retried with the next minutes (up to 30 held). + +Validated on synthetic ballistocardiograms with known beat times (`test_beats.py`): ≥ 97% sensitivity, ≥ 99% PPV and < 5 ms RMS timing error on clean signals; 45–100 bpm; a noisy channel rescued by an inverted, delayed second channel; in-band common vibration removed via the other side; the sleeper's own heartbeat on the other side not cancelled; a movement burst leaving no false interval; RMSSD within 12% of ground truth; a second wave at half the period not doubling the rate; a stronger beat-like pulse train (at lie-down or mid-night) not taken for the heart, alone giving no beats; real rate steps and climbs followed. Replaying a short in-bed recording from a Pod 4 against a wrist-worn reference: per-minute heart rate within ~3 bpm once settled, and no beats from an empty bed. + +Known limit: a rhythmic disturbance that is stronger than the heartbeat and repeats within ~20% of the heart period (or at a multiple of it) can't be told apart by periodicity; beats in such stretches get the right rate but can land on the disturbance, inflating HRV there. Movement during them is usually blanked. Cost: ~0.1% of one Apple-silicon core per side (estimate ~1–1.5% on the pod's Cortex-A53). + ## 6. Heart Rate Extraction ### Bandpass: 0.8-8.5 Hz diff --git a/docs/research/sleep-stage-calibration/README.md b/docs/research/sleep-stage-calibration/README.md index 81b5bfafc..a556920e5 100644 --- a/docs/research/sleep-stage-calibration/README.md +++ b/docs/research/sleep-stage-calibration/README.md @@ -149,6 +149,110 @@ docs/research/sleep-stage-calibration/feat2.py dataset.csv It needs numpy and scikit-learn; its shebang pulls them through `uv`. +## 6. Personal models, container side + +`personal-models.py` trains the two ojwalch/sleep_classifiers models (logistic +regression, random forest with 200 trees of depth 8) on the same features the +iPhone computes for its on-device personal models +(sleepypod-ios `docs/design/on-device-stage-training.md`), so the container +run and the phone fit can be cross-checked: + +```sh +docs/research/sleep-stage-calibration/personal-models.py dataset.csv --contract A # the challenger's four inputs +docs/research/sleep-stage-calibration/personal-models.py dataset.csv --contract B # pod native: adds HRV, breathing, 15-min windows +``` + +Rows are 5-minute epochs from each night's first Watch-labeled sample, labeled +by the Watch stage at the epoch midpoint, rare classes oversampled instead of +class-weighted (Create ML has no class weights), scored leave-one-night-out +with Cohen's κ against the Watch, pooled and per night. On the ten left-side +nights to 2026-10-18 (724 labeled epochs): + +| contract | logistic κ | forest κ | +|---|---|---| +| A | 0.08 | 0.05 | +| B | 0.11 | 0.20 | + +The bundled challenger scores κ 0.12 on these nights and the pod's rules about +0.02, so contract B's forest is the first model to clear both. + +A sweep on the same nights (leave-one-night-out, contract B) chose the forest's +depth: 200 trees at depth 3/4/6/8/unlimited gave κ 0.18/0.21/0.22/0.26/0.23, +and 100, 500 or 1000 trees changed nothing at any depth, so the forest is +pinned at 200 trees, depth 8. Logistic regression was flat (κ 0.10–0.11) across +C from 0.01 to 100. Histogram gradient boosting at depth 3 reached κ 0.27, the +best single number but within noise of the depth-8 forest on eleven nights; +it would need its own export format and is a candidate once more nights exist. + +Two parity checks keep the container and the phone honest: + +- Features: `--fixture night-fixture.json` writes a synthetic night's raw rows + (deterministic, nobody's sleep) and the feature rows under both contracts. + Copy it to `sleepypod-ios/SleepypodTests/Fixtures/stage-features-night.json`; + the Swift `StageFeatureParityTests` suite recomputes every column from the raw + rows and fails on any difference above 1e-6. `--fixture-real` uses the first + real labeled night instead, for a local check only: it carries timestamps and + per-minute biometrics and must never be committed. +- Conversion: `--export out/` fits each model on all nights and writes + `personal-.mlmodel` with a card in the challenger's schema (test vectors + are the model's own predictions on 12 training rows). The phone's + `StageChallenger.load(modelURL:cardURL:)` refuses a model that fails its + vectors, so a converter mismatch shows up at load. Needs `coremltools` + (`uv run --with coremltools …`). + +### One command on the Mac + +`scripts/train-personal-models.py` chains the whole thing: snapshot the pod's +database over SSH, build the dataset, train both models on a contract with +leave-one-night-out scoring, export the pod form, and upload the model with +the higher pooled κ. About five seconds on Apple silicon; nothing runs on the +phone. + +```sh +scripts/train-personal-models.py --pod --side left # contract B, best model, uploaded +scripts/train-personal-models.py --pod --side left --no-upload --contract A +scripts/train-personal-models.py --pod --snapshot poddb/biometrics.db # reuse a snapshot +``` + +The pod needs the model endpoints below, and the Watch stages must already be +on it (section 2b). The run leaves the dataset and the exported models (about +half a megabyte) in the work directory and deletes the ~75 MB database +snapshot unless `--keep-snapshot`; the pod keeps one model row per side, +replaced on every upload. + +### Running a model on the pod + +`--export out/` also writes `personal-.json`, the pod's portable form +(forest trees as parallel arrays with four-class leaf votes, logistic as +scaler plus weights, the feature contract, hyperparameters and the test +vectors), checked against scikit-learn on every training row before it is +written. The pod evaluates it with `src/lib/personal-stage-model.ts`, which +computes the same feature contracts from a night's vitals and movement (the +same synthetic fixture is replayed by `src/lib/tests/personal-stage-model.test.ts`) +and refuses a model that fails its own test vectors. + +```sh +# store the container's forest on the pod for the left side +curl -X PUT http://:3000/api/sleep-stages/model -H 'content-type: application/json' -d "$(jq -c '{side:"left", model:.}' out/personal-forest.json)" +# score it beside the rules against the Watch on the last 14 nights +curl -X POST http://:3000/api/sleep-stages/model/preview -H 'content-type: application/json' -d '{"side":"left","nights":14}' +``` + +The stored model can also stage the side's nights: `PUT /api/sleep-stages/model/active` +with `{"side":"left","active":true}` (or the "Stage nights with this model" +switch on the Settings → Sleep stages page) makes `getSleepStages` and the +Watch comparison classify with the model, with the profile's rules as the +fallback for any night it cannot stage. Every stage read carries `stagedBy` +(`rules` or `model` plus the version), and the web Night view marks +model-staged nights. The profile system and its manifest are untouched: the +rules remain the configured classifier and the model is a per-side switch +beside it, which is why no manifest-hash migration was needed. On the ten left-side nights to 2026-10-19 +the container's contract-B forest, evaluated by the pod's TypeScript, +reached in-sample κ 0.33 (leave-one-night-out 0.20 in training). + +The Mac analysis container is not checked in here; add its Dockerfile location +when it is. + ## Tests ```sh @@ -159,3 +263,33 @@ The Python Modules CI workflow runs them on every PR that touches `scripts/**/*.py`. The tests use small synthetic fixtures, not a real export. When `src/lib/sleep-stages.ts` changes, update the port in the script and the classifier cases in the test, which mirror `src/lib/tests/sleep-stages.test.ts`. + + +### Saved model library + +Settings → Sleep stages now offers **Upload model JSON** even when no model is +stored. Upload `candidate.json` from a training run, choose an entry in **Saved +models**, then press **Use this model** to activate it for the current side. +Choosing a dropdown entry alone does not switch staging. Uploads save candidates +without changing the active model, and identical uploads reuse the same entry. +The model version and held-out κ are displayed as inline code; κ includes an +explanation. The existing toggle returns the selected model to rules-only staging. + +The library is separate for each side. Existing models are copied into it during +migration without changing their active state. It is stored in the configuration +database and included with its normal backups. + +| Method / path | Request | Behavior | +| --- | --- | --- | +| `GET /api/sleep-stages/models` | `?side=left` | List summaries, IDs, selected/active state, and recorded metrics | +| `POST /api/sleep-stages/models` | `{side, model}` | Validate and save a candidate; return its ID without activation | +| `PUT /api/sleep-stages/models/selected` | `{side, id, active}` | Select a saved model and explicitly set activation | + +The original singular `/api/sleep-stages/model` routes remain compatible with +existing clients. Singular PUT still selects/replaces the per-side slot and +preserves its active state, but also archives the old and new model in the +library. Singular PUT with `model: null` clears the selection without deleting +saved candidates. Use the plural POST route when an upload must not affect +staging. Models are validated against known feature contracts and their test +vectors before upload or selection. These retrospective models do not establish +prospective or clinical validity. diff --git a/docs/research/sleep-stage-calibration/personal-models.py b/docs/research/sleep-stage-calibration/personal-models.py new file mode 100755 index 000000000..023a74585 --- /dev/null +++ b/docs/research/sleep-stage-calibration/personal-models.py @@ -0,0 +1,396 @@ +#!/usr/bin/env -S uv run --with numpy --with scikit-learn --quiet --no-project +"""The two personal stage models, off the pod, on the same features the iPhone computes. + +Mirrors sleepypod-ios (docs/design/on-device-stage-training.md): 5-minute epochs +from each night's first labeled row, feature contract A (the challenger's +z-scores) or B (pod native), rare-class oversampling instead of class weights, +leave-one-night-out Cohen's kappa against the Watch, then a final fit. + +Input is dataset.csv from scripts/sleep-stage-dataset.py. Nights are the +Watch-labeled rows of each (night, side); the bed window is the first to the +last labeled sample, which is what the pod record gives the phone. + +Usage: + personal-models.py dataset.csv [--contract A|B] [--model logistic|forest|both] + personal-models.py dataset.csv --fixture night-fixture.json # synthetic feature-parity fixture for the Swift test + personal-models.py dataset.csv --export out/ # pod JSON + card per model (+ .mlmodel with coremltools) +""" +import argparse +import csv +import json +import math +import sys +from collections import Counter, defaultdict +from datetime import datetime, timezone + +import numpy as np + +STAGES = ('wake', 'light', 'deep', 'rem') # class index order, as StageScore.order +EPOCH = 300 +WINDOW_HALF = 450 +CONTRACT_A = ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'] +CONTRACT_B = CONTRACT_A + ['hrvRel', 'hrv15Rel', 'hr15StdRel', 'br15Std', 'motion15', 'minutesSinceBed'] +# Depth 8 after a leave-one-night-out sweep on eleven nights (Oct 2026): depth 4 κ 0.21, depth 8 κ 0.26; +# 100 to 1000 trees made no difference, so 200 stays. Logistic was flat in C. See README section 6. +HYPER = {'logistic': {'maxIterations': 200, 'l2Penalty': 0.01}, 'forest': {'trees': 200, 'maxDepth': 8}} + + +def num(v): + return float(v) if v not in ('', None) else None + + +def load(path): + nights = defaultdict(list) + with open(path, newline='') as f: + for r in csv.DictReader(f): + nights[(r['night'], r['side'])].append(r) + for rows in nights.values(): + rows.sort(key=lambda r: int(r['ts'])) + return dict(sorted(nights.items())) + + +def pstd(values): + return float(np.std(values)) if len(values) else 0.0 + + +def zscores(values): + values = np.asarray(values, dtype=float) + s = pstd(values) + return [0.0 if s == 0 else (v - values.mean()) / s for v in values] + + +def median(values): + return float(np.median(values)) if len(values) else 0.0 + + +def features(rows, entered, left, contract): + """StageChallenger.features (A) plus StageFeatures.podNative (B), by column name, per kept epoch.""" + night = left - entered + if night < EPOCH: + return [] + count = int(night // EPOCH) + # The phone only sees vitals inside the record window; the dataset's padding rows must not feed the + # night's medians, stds or windows. + rows = [r for r in rows if entered <= int(r['ts']) < left] + hr = [(int(r['ts']), num(r['hr'])) for r in rows if num(r['hr']) is not None] + hrv = [(int(r['ts']), num(r['hrv'])) for r in rows if num(r['hrv']) is not None] + br = [(int(r['ts']), num(r['br'])) for r in rows if num(r['br']) is not None] + mv = [(int(r['ts']), num(r['movement'])) for r in rows if num(r['movement']) is not None] + kept = [] + for i in range(count): + start = entered + i * EPOCH + samples = [v for t, v in hr if start <= t < start + EPOCH] + if not samples: + continue + motion = sum(v for t, v in mv if start <= t < start + EPOCH) + kept.append((start, float(np.mean(samples)), pstd(samples) if len(samples) > 1 else 0.0, motion)) + hrz = zscores([k[1] for k in kept]) + hrstdz = zscores([k[2] for k in kept]) + motionz = zscores([math.log(1 + k[3]) for k in kept]) + out = [] + for i, (start, _, _, _) in enumerate(kept): + out.append({'start': start, 'hrZ': hrz[i], 'hrStdZ': hrstdz[i], 'motionZ': motionz[i], + 'nightFraction': (start - entered) / night}) + if contract == 'A': + return out + night_hrv_median = median([v for _, v in hrv]) + night_hr_std = pstd([v for _, v in hr]) + + def ratio(value, denominator): + return value / denominator if denominator > 0 and math.isfinite(denominator) else 0.0 + + def window(samples, mid, half): + return [v for t, v in samples if abs(t - mid) <= half] + + for row in out: + mid = row['start'] + EPOCH / 2 + e_hrv = window(hrv, mid, EPOCH / 2) + hrv15 = window(hrv, mid, WINDOW_HALF) + hr15 = window(hr, mid, WINDOW_HALF) + br15 = window(br, mid, WINDOW_HALF) + motion15 = sum(v for t, v in mv if abs(t - mid) <= WINDOW_HALF) + row.update({ + 'hrvRel': ratio(float(np.mean(e_hrv)), night_hrv_median) if e_hrv else 0.0, + 'hrv15Rel': ratio(float(np.mean(hrv15)), night_hrv_median) if hrv15 else 0.0, + 'hr15StdRel': ratio(pstd(hr15), night_hr_std) if len(hr15) > 1 else 0.0, + 'br15Std': pstd(br15) if len(br15) > 1 else 0.0, + 'motion15': math.log(1 + motion15), + 'minutesSinceBed': (row['start'] - entered) / 60, + }) + return out + + +def label(epochs, rows): + """The Watch stage of the labeled row containing the epoch midpoint (first match), as StageDataset.label.""" + segments = [] + for r in rows: + t = int(r['ts']) + if not r['watch_stage']: + # An explicit unlabeled row ends the running segment; a missing sample (gap) is bridged below. + if segments: + segments[-1][1] = min(segments[-1][1], t) + continue + if segments and segments[-1][2] == r['watch_stage'] and 0 <= t - segments[-1][1] <= 120: + segments[-1][1] = t + 60 + else: + segments.append([t, t + 60, r['watch_stage']]) + out = [] + for e in epochs: + mid = e['start'] + EPOCH / 2 + for s, t, stage in segments: + if s <= mid < t: + out.append((e, STAGES.index(stage))) + break + return out + + +def oversample(rows): + counts = Counter(y for _, y in rows) + largest = max(counts.values()) + result = list(rows) + for stage, count in counts.items(): + copies = largest // count - 1 + if copies > 0: + members = [r for r in rows if r[1] == stage] + for _ in range(copies): + result.extend(members) + return result + + +def kappa(y_true, y_pred): + n = len(y_true) + if n == 0: + return None, None + acc = sum(a == b for a, b in zip(y_true, y_pred)) / n + ct, cp = Counter(y_true), Counter(y_pred) + chance = sum((ct[k] / n) * (cp[k] / n) for k in range(len(STAGES))) + return acc, (None if chance == 1 else (acc - chance) / (1 - chance)) + + +def make(kind, seed=42): + from sklearn.ensemble import RandomForestClassifier + from sklearn.linear_model import LogisticRegression + from sklearn.pipeline import make_pipeline + from sklearn.preprocessing import StandardScaler + if kind == 'logistic': + return make_pipeline(StandardScaler(), LogisticRegression(max_iter=HYPER['logistic']['maxIterations'], + C=1 / HYPER['logistic']['l2Penalty'])) + return RandomForestClassifier(HYPER['forest']['trees'], max_depth=HYPER['forest']['maxDepth'], random_state=seed) + + +def build(nights, contract): + columns = CONTRACT_A if contract == 'A' else CONTRACT_B + data = [] + for key, rows in nights.items(): + labeled = [r for r in rows if r['watch_stage']] + if not labeled: + continue + entered, left = int(labeled[0]['ts']), int(labeled[-1]['ts']) + 60 + if sum(60 for r in labeled if r['watch_stage'] != 'wake') < 2 * 3600: + print(f' skip {key}: under 2 h of Watch sleep', file=sys.stderr) + continue + pairs = label(features(rows, entered, left, contract), rows) + if pairs: + data.append((key, [([e[c] for c in columns], y) for e, y in pairs])) + return columns, data + + +def lono(data, kind, columns): + y_all, p_all, per_night = [], [], [] + for i, (key, held) in enumerate(data): + train = oversample([r for j, (_, rows) in enumerate(data) if j != i for r in rows]) + if len({y for _, y in train}) < 2: + print(f' skip fold {key}: training rows hold a single class', file=sys.stderr) + per_night.append((key, len(held), None, None)) + continue + clf = make(kind).fit(np.array([x for x, _ in train]), np.array([y for _, y in train])) + pred = clf.predict(np.array([x for x, _ in held])).tolist() + truth = [y for _, y in held] + acc, k = kappa(truth, pred) + per_night.append((key, len(held), acc, k)) + y_all += truth + p_all += pred + return kappa(y_all, p_all), per_night + + +def portable(clf, kind, columns): + """The model as plain JSON the pod evaluates (sleepypod-core src/lib/personal-stage-model.ts): + forest trees as parallel arrays with four-class leaf votes, logistic as scaler plus weights.""" + if kind == 'forest': + trees = [] + for est in clf.estimators_: + t = est.tree_ + classes = [int(c) for c in clf.classes_] + votes = [] + for node in range(t.node_count): + row = [0.0] * 4 + for k, c in enumerate(classes): + row[c] = float(t.value[node][0][k]) + votes.append(row) + trees.append({ + 'f': [int(f) if f >= 0 else -1 for f in t.feature], + 't': [float(x) if f >= 0 else 0.0 for x, f in zip(t.threshold, t.feature)], + 'l': [int(x) for x in t.children_left], + 'r': [int(x) for x in t.children_right], + 'v': votes, + }) + return {'kind': 'forest', 'trees': trees} + scaler = clf.named_steps['standardscaler'] + lr = clf.named_steps['logisticregression'] + return {'kind': 'logistic', 'logistic': { + 'mean': [float(x) for x in scaler.mean_], 'scale': [float(x) for x in scaler.scale_], + 'coef': [[float(w) for w in row] for row in lr.coef_], 'intercept': [float(b) for b in lr.intercept_], + 'classIndices': [int(c) for c in lr.classes_], + }} + + +def evaluate_portable(doc, x): + """Pure-Python evaluation of the portable form, mirroring the pod's TypeScript, to verify the export.""" + if doc['kind'] == 'forest': + total = [0.0] * 4 + for tree in doc['trees']: + node = 0 + while tree['f'][node] >= 0: + node = tree['l'][node] if x[tree['f'][node]] <= tree['t'][node] else tree['r'][node] + votes = tree['v'][node] + s = sum(votes) + for c in range(4): + total[c] += votes[c] / s if s > 0 else 0.25 + return max(range(4), key=lambda c: total[c]) + lr = doc['logistic'] + z = [(v - m) / s if s else 0.0 for v, m, s in zip(x, lr['mean'], lr['scale'])] + scores = [sum(w * zi for w, zi in zip(row, z)) + b for row, b in zip(lr['coef'], lr['intercept'])] + if len(lr['classIndices']) == 2: + return lr['classIndices'][1] if 1 / (1 + math.exp(-scores[0])) > 0.5 else lr['classIndices'][0] + return lr['classIndices'][max(range(len(scores)), key=lambda i: scores[i])] + + +def export(data, kind, columns, out_dir, pooled, per_night): + from pathlib import Path + rows = oversample([r for _, night in data for r in night]) + clf = make(kind).fit(np.array([x for x, _ in rows]), np.array([y for _, y in rows])) + Path(out_dir).mkdir(parents=True, exist_ok=True) + originals = [r for _, night in data for r in night] + stride = max(1, len(originals) // 12) + vectors = [] + for x, _ in originals[::stride][:12]: + vectors.append({'inputs': dict(zip(columns, map(float, x))), 'expected': int(clf.predict(np.array([x]))[0])}) + acc, k = pooled + nights = len(data) + version = f'container-{kind}-{datetime.now(timezone.utc):%Y%m%dT%H%M}' + # The pod's portable form, checked against sklearn on every training row before it is written. + doc = { + 'version': version, 'classes': list(STAGES), 'featureContract': columns, 'epochSeconds': EPOCH, + 'hyperparameters': HYPER[kind], 'trainedAt': datetime.now(timezone.utc).isoformat(timespec='seconds'), + 'source': 'sleepypod-core personal-models.py', + 'metrics': {'lono': {'epochs': len(originals), 'kappa': k, 'accuracy': acc, 'nights': nights}}, + 'testVectors': vectors, **portable(clf, kind, columns), + } + xs = np.array([x for x, _ in originals]) + disagreements = sum(int(evaluate_portable(doc, list(map(float, x))) != int(p)) for x, p in zip(xs, clf.predict(xs))) + if disagreements: + sys.exit(f'portable {kind} disagrees with sklearn on {disagreements} of {len(originals)} rows; not written') + with open(f'{out_dir}/personal-{kind}.json', 'w') as f: + json.dump(doc, f, separators=(',', ':')) + card = { + 'name': f'personal-{kind}', 'version': version, 'model': kind, + 'classes': list(STAGES), 'features': columns, 'featureContract': columns, 'hyperparameters': HYPER[kind], + 'metrics': {kind: {'lono': {'epochs': len(originals), 'kappa': k, 'accuracy': acc, 'nights': nights}}}, + 'training': {'dataset': 'Pod nights with Apple Watch labels, dataset.csv', 'subjects': nights}, + 'limitations': 'One person, one pod, the Watch as reference. Experimental, not a medical device.', + 'testVectors': vectors, + } + with open(f'{out_dir}/personal-{kind}.card.json', 'w') as f: + json.dump(card, f, indent=2, sort_keys=True) + print(f' wrote {out_dir}/personal-{kind}.json (pod form, {len(doc.get("trees", []))} trees) and card ({len(vectors)} test vectors)') + try: + import coremltools as ct + ct.converters.sklearn.convert(clf, columns, 'classLabel').save(f'{out_dir}/personal-{kind}.mlmodel') + print(f' wrote {out_dir}/personal-{kind}.mlmodel') + except ImportError: + print(' coremltools not installed; skipped the .mlmodel (uv run --with coremltools … to include it)') + + +def synthetic_night(seed=7, minutes=330): + """A plausible night nobody slept: smooth HR/HRV/breathing with noise, bursts of movement, Watch stages + in 20–40 minute runs. Deterministic, so the fixture and the Swift test agree run to run.""" + rng = np.random.default_rng(seed) + t0 = 1_700_000_000 + rows, stage, remaining = [], 'light', 0 + for m in range(minutes): + if remaining == 0: + stage = rng.choice(STAGES, p=[0.08, 0.5, 0.22, 0.2]) + remaining = int(rng.integers(20, 41)) + remaining -= 1 + base = {'wake': 66, 'light': 58, 'deep': 53, 'rem': 61}[stage] + hr = base + 3 * math.sin(m / 23) + rng.normal(0, 1.5) + hrv = {'wake': 25, 'light': 38, 'deep': 55, 'rem': 30}[stage] + rng.normal(0, 6) + br = 14.5 + (1.5 if stage == 'rem' else 0) + rng.normal(0, 0.6) + movement = int(rng.integers(150, 600)) if stage == 'wake' or rng.random() < 0.04 else int(rng.integers(0, 12)) + missing_hr = rng.random() < 0.03 + rows.append({'night': 'synthetic', 'side': 'left', 'ts': str(t0 + m * 60), 'watch_stage': stage if m % 47 else '', + 'hr': '' if missing_hr else f'{hr:.2f}', 'hrv': f'{max(hrv, 5):.2f}', 'br': f'{br:.2f}', 'movement': str(movement)}) + return ('synthetic', 'left'), rows + + +def fixture(nights, path, real=False): + """One night's raw rows and the feature rows under both contracts, for SleepypodTests to replay. + Synthetic unless --fixture-real: a real night carries timestamps and biometrics and must not be committed.""" + if real: + candidates = ((k, r) for k, r in nights.items() if any(x['watch_stage'] for x in r)) + else: + candidates = iter([synthetic_night()]) + key = rows = None + for k, r in candidates: + labeled = [x for x in r if x['watch_stage']] + if labeled and features(r, int(labeled[0]['ts']), int(labeled[-1]['ts']) + 60, 'A'): + key, rows = k, r + break + if rows is None: + sys.exit('no labeled night with usable feature rows for a fixture') + labeled = [r for r in rows if r['watch_stage']] + entered, left = int(labeled[0]['ts']), int(labeled[-1]['ts']) + 60 + doc = { + 'night': key[0], 'side': key[1], 'enteredBed': entered, 'leftBed': left, 'synthetic': not real, + 'vitals': [{'ts': int(r['ts']), 'hr': num(r['hr']), 'hrv': num(r['hrv']), 'br': num(r['br'])} for r in rows], + 'movement': [{'ts': int(r['ts']), 'total': num(r['movement'])} for r in rows if num(r['movement']) is not None], + 'contractA': features(rows, entered, left, 'A'), + 'contractB': features(rows, entered, left, 'B'), + } + if not doc['contractA']: + sys.exit('fixture night produced no feature rows') + with open(path, 'w') as f: + json.dump(doc, f, indent=1) + print(f'wrote {path}: {len(doc["contractA"])} epochs for night {key}' + ('' if real else ' (synthetic)')) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('csv') + ap.add_argument('--contract', choices=['A', 'B'], default='A') + ap.add_argument('--model', choices=['logistic', 'forest', 'both'], default='both') + ap.add_argument('--export', metavar='DIR') + ap.add_argument('--fixture', metavar='JSON', help='write a synthetic parity fixture (no dataset rows are used)') + ap.add_argument('--fixture-real', action='store_true', help='fixture from the first real labeled night; never commit it') + args = ap.parse_args() + nights = load(args.csv) + if args.fixture: + fixture(nights, args.fixture, real=args.fixture_real) + return + columns, data = build(nights, args.contract) + print(f'contract {args.contract}: {len(data)} nights, {sum(len(r) for _, r in data)} labeled epochs') + if len(data) < 2: + sys.exit('leave-one-night-out needs >= 2 Watch-labeled nights') + for kind in (['logistic', 'forest'] if args.model == 'both' else [args.model]): + pooled, per_night = lono(data, kind, columns) + acc, k = pooled + print(f'\n{kind}: pooled kappa {k:.3f} accuracy {acc:.1%}' if k is not None else f'\n{kind}: kappa undefined') + for key, n, a, kk in per_night: + acc_text = 'n/a' if a is None else f'{a:.1%}' + print(f' {key[0]} {key[1]}: n={n:3} acc={acc_text} kappa={kk if kk is None else round(kk, 3)}') + if args.export: + export(data, kind, columns, args.export, pooled, per_night) + + +if __name__ == '__main__': + main() diff --git a/modules/common/biometrics/manifest.v1.json b/modules/common/biometrics/manifest.v1.json index 66dc2e887..a477d1cab 100644 --- a/modules/common/biometrics/manifest.v1.json +++ b/modules/common/biometrics/manifest.v1.json @@ -635,13 +635,13 @@ }, "sourceHashes": { "modules/common/biometrics/signal.py": "0b1447c1162245232c053aeeb5cb3e75e7da3392c38a43c465f28596f084b32f", - "modules/piezo-processor/main.py": "85a3a24cf525950b49a826ffc0d983fe75fb2809e9d2a87ecb548b68c36e3d79", + "modules/piezo-processor/main.py": "58e13cbf5fdde9e80c6d62f78713ecead4960b1cb2411caf41b6b60ebb5472c7", "src/lib/sleep-stages.ts": "b1800e49ce8525d314333da6a29a63131a002d160bb294fa97b24df6e655e228", "modules/piezo-processor/runtime_adapter.py": "0a22a2fa05aae6569bfc4b53fba80d75e89f702f021ccb61f82ce64e284c5bb8", "modules/common/biometrics/runtime.py": "dbe327ce6e6ae315cad83439903e4c5d6a5c594468086739504caa8ed028a944", "modules/common/biometrics/gates.py": "c46285ff1cdf95bd4184823fabd6ceb66b005f7c410ee87880de4e563edd6e7c", "modules/common/biometrics/routing.py": "04872bab0f797e0bedd6e7114e686bbf7a5b4110ed2052c9b160e5aa1f7d0839", - "modules/common/biometrics/processing.py": "2d04538499498f3b4fa9fa7383d1d3e6c0f66c1db3455bfd67ede53be7d080ef", + "modules/common/biometrics/processing.py": "e668f2039047153dd2c3af708cbd648454de917229e49f746cfbd534f95e1139", "modules/common/biometrics/frames.py": "99427d69cf63613c96e45f2f280b53a6e7db18997a28e579cd5e412684db259f", "modules/common/biometrics/replay.py": "50c70408dca033dcf17d5bf6a1aa7afc9ed531d09aa3151eb61941f7c504639f" }, diff --git a/modules/common/biometrics/processing.py b/modules/common/biometrics/processing.py index c4ecf8895..38b86c106 100644 --- a/modules/common/biometrics/processing.py +++ b/modules/common/biometrics/processing.py @@ -60,6 +60,10 @@ def __init__(self, side: str, *, clock: Callable[[], float], # merges both zones into the home side, so only the home side's # processor may treat `bed_occupied() is True` as its own evidence. self.home_side: Optional[Callable[[], Optional[str]]] = None + # Receives this side's tracked window heart rate as a period (s); + # main() points it at the beat tracker so it starts from the right + # rhythm. It doesn't change any vitals value. + self.rate_hint: Optional[Callable[[float], None]] = None def install_runtime(self, parameters, run_context, reset_side=False): if parameters != self.parameters or reset_side: @@ -195,6 +199,8 @@ def _maybe_write(self) -> None: np.array(self._hrv_buf)) hr, hrv, br = estimate.heart_rate, estimate.hrv_index, estimate.breathing_rate hr_raw, hr_score = estimate.hr_raw, estimate.hr_score + if hr is not None and hr > 0 and self.rate_hint is not None: + self.rate_hint(60.0 / hr) if hr is not None or hrv is not None or br is not None: ts = datetime.fromtimestamp(now, timezone.utc) diff --git a/modules/piezo-processor/beats.py b/modules/piezo-processor/beats.py new file mode 100644 index 000000000..507c99371 --- /dev/null +++ b/modules/piezo-processor/beats.py @@ -0,0 +1,1013 @@ +""" +Beat-to-beat heartbeat detection from the bed's piezo (ballistocardiogram). + +Produces individual heartbeat times — the input HRV analysis and heart-rate- +based sleep staging need — instead of per-minute rate estimates. + +Pipeline, per side, on a sliding window (WINDOW_S long, advancing STEP_S; only +beats in the central STEP_S are committed, so every beat is decided with +context on both sides): + +1. Band-pass each piezo channel to the cardiac band (HR_BAND, as the rate + pipeline) and decimate 500 -> 100 Hz. Blank body movement (samples far + above the heartbeat's own amplitude, plus a margin) so a turn-over can't + corrupt the rhythm estimate or template of the surrounding windows; no + beat is taken near, and no interval spans, a blanked stretch. +2. Other-side noise reference: fit a short FIR mapping the other side's + channels onto this channel (ridge least squares) and subtract it. Vibration + common to both sides (pump beat frequencies, building, a partner's + movement) is removed. The other side can also carry THIS sleeper's own + heartbeat through the mattress, which a blind subtraction would cancel. + That case is unmistakable: removing your own heartbeat collapses the + channel's cardiac periodicity (measured ~0.83 -> 0.1-0.26), whereas + removing common vibration leaves it unchanged or better, and removing a + partner's coupled heartbeat improves it. So the cleaned channel is used + unless it loses more than ANC_MAX_QUALITY_DROP of periodicity. +3. Combine the side's two channels: normalise, align the second to the first + (lag and polarity), weight by periodicity, and keep the combination only + if it beats the best single channel. +4. Detect beats by template matching: a per-window average heartbeat complex + (the sleeper's own waveform in their current position) is matched against + the signal; correlation peaks are beats, refined to sub-sample precision. + Gaps longer than 1.6 beat periods are searched again at a lower + threshold. With two good channels, a beat neither channel confirms is + dropped. +5. Artifact correction (BeatCleaner): intervals outside 20% of the local + median are rejected (the classic HRV editing criterion, e.g. Kamath & + Fallen 1995); an extra beat whose two short intervals sum to a normal one + is removed; a missed beat becomes a break rather than an interpolated + beat (beat classification after Lipponen & Tarvainen 2019). No interval is + ever computed across a break. + +HRV: RMSSD over the last 5 minutes of clean intervals (Task Force of the ESC +and NASPE, 1996, short-term standard), only when enough of the window is +covered by valid intervals. +""" + +from __future__ import annotations + +import math +from collections import deque +from dataclasses import dataclass, field +from typing import Deque, Dict, List, Optional, Tuple + +import numpy as np +from scipy.signal import butter, correlate, find_peaks, sosfiltfilt + +FS_RAW = 500 +DECIMATE = 5 +FS = FS_RAW // DECIMATE # 100 Hz after decimation + +# Cardiac band — same as the rate pipeline (HR_BAND in main.py, PMC7582983). +BAND = (0.8, 8.5) + +WINDOW_S = 20.0 +STEP_S = 5.0 +# Committed region of each window, as offsets from the window start: the +# central STEP_S, leaving (WINDOW_S - STEP_S) / 2 of context on each side. +COMMIT_START_S = (WINDOW_S - STEP_S) / 2 +COMMIT_END_S = COMMIT_START_S + STEP_S + +# Heart-rate range considered: 40-150 bpm. +MIN_PERIOD_S = 0.4 +MAX_PERIOD_S = 1.5 + +# Envelope periodicity below this: no reliable heartbeat in the window. +MIN_QUALITY = 0.35 + +# Period selection by subharmonic summation: autocorrelation at 1x..3x the +# lag, weights decaying by 0.84 per harmonic (Hermes 1988), each multiple +# searched within +-5% for beat-to-beat variability. +SHS_HARMONICS = 3 +SHS_DECAY = 0.84 +SHS_TOL = 0.05 +# A lag whose double correlates better by more than this is a within-beat +# wave, not the period. +SUB_PERIOD_MARGIN = 0.05 +# Likewise a lag whose half or third correlates at least as well is every +# second or third cycle of a faster rhythm (searched down to 200 bpm). +FASTEST_S = 0.3 + +# Rhythm tracking across windows (as HRTracker does for the vitals, after +# Bruser et al. 2011): a resting heart can't change rate by more than +# TRACK_TOL between 5 s steps, so once a rhythm is established each window's +# period must lie within TRACK_TOL of the recent median. Movement or +# machinery can put a stronger rhythm at another rate on the sensors; those +# windows yield no beats rather than wrong ones. RESEED_WINDOWS consistent +# windows at a new rate (15 s) mean the rate really changed; SEED_WINDOWS +# agreeing windows start tracking. Tracking only starts (or re-seeds) at a +# resting rate, SEED_MIN_PERIOD_S (120 bpm) or slower, so a fast mechanical +# rhythm present when someone lies down can't be taken for the heart; a +# tracked rhythm may still climb past it gradually. A rhythm unseen for +# TRACK_MAX_AGE_S is forgotten. +TRACK_TOL = 0.20 +TRACK_HISTORY = 5 +SEED_WINDOWS = 2 +RESEED_WINDOWS = 3 +SEED_MIN_PERIOD_S = 0.5 +TRACK_MAX_AGE_S = 60.0 +# While following an established rhythm the prior already rules out other +# rates, so a weaker window is still trustworthy: measured against a +# wrist-worn reference this kept heart-rate error unchanged (~1 bpm) while +# adding ~12% more beats. +FOLLOW_MIN_QUALITY = 0.2 + +# Rate hint: the side's window heart rate (HRTracker, from subharmonic +# summation over 30 s — a different estimator from this one). Envelope +# periodicity can't tell a heartbeat from half of one when each beat has +# two similar waves, which some sleeping positions produce; the window rate +# can (it corrects half/double readings). A fresh hint decides where +# tracking starts, and a tracked rhythm more than TRACK_TOL from it is +# dropped. Hints older than HINT_MAX_AGE_S are ignored. +HINT_MAX_AGE_S = 120.0 + +# Beat shapes of consecutive windows correlating at least this well are the +# same complex, so the anchor is kept on the same point of it. +ANCHOR_MIN_SIMILARITY = 0.5 +# The other-side-cleaned channel is used unless its periodicity drops by more +# than this (self-cancellation of the sleeper's own heartbeat drops it ~0.6). +ANC_MAX_QUALITY_DROP = 0.10 +# Below this fraction of variance removed, cancellation isn't worth using. +ANC_MIN_REMOVED = 0.02 +# Template-match (normalised cross-correlation) thresholds. +NCC_MIN = 0.4 +NCC_SEARCHBACK = 0.25 +# Other-side reference FIR: +-50 ms of lags, ridge relative to mean energy. +ANC_LAGS = 5 +ANC_RIDGE = 1e-3 +# Two channels confirm a beat when both have a beat within this tolerance. +CONFIRM_TOL_S = 0.08 +CONFIRM_MIN_QUALITY = 0.4 + +# Motion blanking: |x| above MOTION_K x the typical heartbeat peak (median of +# per-MAX_PERIOD_S-block maxima: every block holds >= 1 beat at >= 40 bpm, so +# this tracks J-peak height at any heart rate), widened by MOTION_MARGIN_S. +MOTION_K = 3.0 +MOTION_MARGIN_S = 0.5 +# Beats within this of a blanked stretch are dropped. +MOTION_GUARD_S = 0.3 + +# Artifact correction. +MEDIAN_WINDOW = 11 +MIN_HISTORY = 5 +REL_TOL = 0.20 +MIN_RRI_S = 0.3 +MAX_RRI_S = 2.0 +# More than this apart in the incoming stream is a signal gap. +GAP_TOL_S = 2.0 + +_SOS = butter(4, BAND, btype="band", fs=FS_RAW, output="sos") + + +# --------------------------------------------------------------------------- +# Signal helpers +# --------------------------------------------------------------------------- + +def bandpass_decimate(x_raw: np.ndarray) -> np.ndarray: + """Zero-phase cardiac band-pass at 500 Hz, then take every 5th sample. + The band tops out at 8.5 Hz, far below the new 50 Hz Nyquist.""" + x = np.asarray(x_raw, dtype=np.float64) + if x.size < 64: + return np.zeros(x.size // DECIMATE) + return sosfiltfilt(_SOS, x - x.mean())[::DECIMATE] + + +def motion_mask(x: np.ndarray, fs: int = FS) -> np.ndarray: + """True where body movement swamps the heartbeat.""" + a = np.abs(x) + block = int(MAX_PERIOD_S * fs) + nblk = a.size // block + if nblk < 3: + return np.zeros(a.size, dtype=bool) + ref = float(np.median(a[:nblk * block].reshape(nblk, block).max(axis=1))) + if ref <= 0: + return np.zeros(a.size, dtype=bool) + hit = a > MOTION_K * ref + if not hit.any(): + return hit + m = int(MOTION_MARGIN_S * fs) + return np.convolve(hit.astype(float), np.ones(2 * m + 1), mode="same") > 0 + + +def _motion_starts(mask: np.ndarray) -> np.ndarray: + """Sample indices where blanked stretches begin.""" + if mask.size == 0: + return np.zeros(0, dtype=int) + m = mask.astype(np.int8) + starts = np.flatnonzero(np.diff(np.concatenate(([0], m))) == 1) + return starts + + +def _envelope(x: np.ndarray, fs: int = FS) -> np.ndarray: + """Beat-rate envelope: rectified signal smoothed over ~100 ms.""" + width = max(1, int(0.1 * fs)) + kernel = np.ones(width) / width + return np.convolve(np.abs(x), kernel, mode="same") + + +def _subharmonic_score(ac: np.ndarray, lag: int) -> float: + """Weighted mean autocorrelation at 1x, 2x and 3x `lag` (each the best + value within SHS_TOL of the multiple, since beat-to-beat variability + smears later peaks).""" + total = weight = 0.0 + for k in range(1, SHS_HARMONICS + 1): + centre = k * lag + tol = max(2, int(round(SHS_TOL * centre))) + lo, hi = centre - tol, min(ac.size, centre + tol + 1) + if lo >= ac.size: + break + w = SHS_DECAY ** (k - 1) + total += w * float(ac[lo:hi].max()) + weight += w + return total / weight if weight else 0.0 + + +def periodicity(x: np.ndarray, fs: int = FS, prior: Optional[float] = None, + min_period: float = MIN_PERIOD_S) -> Tuple[float, float]: + """(quality, period_s) from the normalised autocorrelation of the beat + envelope over the 40-150 bpm lag range. Quality 0 = no rhythm, 1 = a + perfectly repeating envelope. + + The period is the autocorrelation peak with the best subharmonic + score (Hermes, 1988, J Acoust Soc Am 83:257-264): a true period repeats + at 2x and 3x its lag too, while the decay of the autocorrelation with + lag keeps 2x the period (every other beat) from winning over the true + one. Excluded first: a lag whose double correlates better (a secondary + wave inside each beat complex, which would double the count) and a lag + whose half or third correlates at least as well (every other or third + cycle of a faster rhythm). + + The quality is the chosen peak's own autocorrelation, and (0, 0) means + no period qualifies. With a `prior` period (the rhythm being tracked, or + an independent rate estimate), only peaks within TRACK_TOL of it are + considered, in place of the octave filters above — so a signal can be + strongly rhythmic at some other rate and still score 0. `min_period` + narrows the lag range (to rates at which tracking may start).""" + if x.size < int(2 * MAX_PERIOD_S * fs) or not np.any(x): + return 0.0, 0.0 + env = _envelope(x, fs) + env = env - env.mean() + denom = float(np.dot(env, env)) + if denom <= 0: + return 0.0, 0.0 + ac = correlate(env, env, mode="full", method="fft")[env.size - 1:] / denom + lo, hi = int(min_period * fs), int(MAX_PERIOD_S * fs) + seg = ac[lo:hi + 1] + peaks, _ = find_peaks(seg) + if peaks.size == 0: + return 0.0, 0.0 + best = float(seg[peaks].max()) + if best <= 0: + return 0.0, 0.0 + lags = lo + peaks + + def sub_period(lag: int) -> bool: + # A secondary wave inside each beat: the "period" at `lag` correlates + # worse than twice that lag, which a true rhythm never does (the + # autocorrelation fades with lag as beat intervals vary). + tol = max(2, int(round(SHS_TOL * 2 * lag))) + double = lags[np.abs(lags - 2 * lag) <= tol] + return double.size > 0 and float(ac[double].max()) > float(ac[lag]) + SUB_PERIOD_MARGIN + + # Every peak of the autocorrelation down to FASTEST_S, for spotting a + # candidate that is only every other cycle of something faster. + floor = int(FASTEST_S * fs) + all_peaks = floor + find_peaks(ac[floor:hi + 1])[0] + + def multiple(lag: int) -> bool: + tol = max(2, int(round(SHS_TOL * lag))) + for k in (2, 3): + part = all_peaks[np.abs(all_peaks - lag / k) <= tol] + if part.size and float(ac[part].max()) >= float(ac[lag]): + return True + return False + + if prior is not None: + # The prior settles which octave is the heart; the octave filters + # would reject the true period when each beat has two similar waves + # (then its half repeats at least as strongly). + candidates = [p for p in peaks + if seg[p] > 0 and abs((lo + p) / fs / prior - 1) <= TRACK_TOL] + else: + # A genuine rhythm in range always leaves its own period standing; + # when nothing does, whatever repeats is outside the range. + candidates = [p for p, lag in zip(peaks, lags) + if seg[p] > 0 and not sub_period(int(lag)) and not multiple(int(lag))] + if not candidates: + return 0.0, 0.0 + scores = [_subharmonic_score(ac, lo + int(p)) for p in candidates] + pick = int(candidates[int(np.argmax(scores))]) + lag = lo + pick + _parabolic(seg, pick) + return max(0.0, min(1.0, float(seg[pick]))), lag / fs + + +def _parabolic(y: np.ndarray, i: int) -> float: + """Sub-sample offset of a local extremum at index i (parabolic fit).""" + if i <= 0 or i >= y.size - 1: + return 0.0 + a, b, c = y[i - 1], y[i], y[i + 1] + d = a - 2 * b + c + if d == 0: + return 0.0 + return float(max(-0.5, min(0.5, 0.5 * (a - c) / d))) + + +def _shift(x: np.ndarray, lag: int) -> np.ndarray: + """x delayed by `lag` samples (negative = advanced), zero-filled.""" + out = np.zeros_like(x) + if lag == 0: + out[:] = x + elif lag > 0: + out[lag:] = x[:-lag] + else: + out[:lag] = x[-lag:] + return out + + +def cancel_common_noise(primary: np.ndarray, references: List[np.ndarray], + lags: int = ANC_LAGS, ridge: float = ANC_RIDGE) -> np.ndarray: + """Subtract the part of `primary` predictable from the other side's + channels: ridge least-squares FIR over +-`lags` samples of each + reference. Returns the residual. Callers must verify it helped — see + the module docstring on self-cancellation.""" + refs = [r for r in references if r is not None and r.size == primary.size and np.any(r)] + if not refs: + return primary + cols = [_shift(r, lag) for r in refs for lag in range(-lags, lags + 1)] + R = np.stack(cols, axis=1) + RtR = R.T @ R + lam = ridge * float(np.trace(RtR)) / RtR.shape[0] + try: + w = np.linalg.solve(RtR + lam * np.eye(RtR.shape[0]), R.T @ primary) + except np.linalg.LinAlgError: + return primary + return primary - R @ w + + +def _best_alignment(a: np.ndarray, b: np.ndarray, max_lag: int) -> Tuple[int, float]: + """(lag, signed correlation) aligning b to a, |lag| <= max_lag.""" + best_lag, best_c = 0, 0.0 + na = np.linalg.norm(a) + for lag in range(-max_lag, max_lag + 1): + bs = _shift(b, lag) + nb = np.linalg.norm(bs) + if na == 0 or nb == 0: + continue + c = float(np.dot(a, bs) / (na * nb)) + if abs(c) > abs(best_c): + best_lag, best_c = lag, c + return best_lag, best_c + + +@dataclass +class Combined: + signal: np.ndarray + period: float + quality: float + # Each usable channel, lag- and polarity-aligned to `signal`, with its + # own periodicity — for per-channel confirmation of beats. + aligned: List[Tuple[np.ndarray, float]] + + +def combine_channels(c1: np.ndarray, c2: Optional[np.ndarray], fs: int = FS, + prior: Optional[float] = None, + min_period: float = MIN_PERIOD_S) -> Combined: + """Combine a side's two channels: the periodicity-weighted, lag- and + polarity-aligned sum when that is more periodic than either channel + alone, else the better channel. Periodicity is judged at `prior` when + given, over lags from `min_period` (see `periodicity`).""" + q1, p1 = periodicity(c1, fs, prior, min_period) + if c2 is None or c2.size != c1.size or not np.any(c2): + return Combined(c1, p1, q1, [(c1, q1)]) + q2, p2 = periodicity(c2, fs, prior, min_period) + s1, s2 = np.std(c1), np.std(c2) + if s1 == 0 or s2 == 0: + return Combined(c1, p1, q1, [(c1, q1)]) if s1 else Combined(c2, p2, q2, [(c2, q2)]) + z1, z2 = c1 / s1, c2 / s2 + lag, corr = _best_alignment(z1, z2, int(0.1 * fs)) + if abs(corr) < 0.3: + # The channels don't see the same thing: use the better one alone. + return Combined(c1, p1, q1, [(c1, q1)]) if q1 >= q2 else Combined(c2, p2, q2, [(c2, q2)]) + z2a = math.copysign(1.0, corr) * _shift(z2, lag) + aligned = [(z1, q1), (z2a, q2)] + w1, w2 = q1 ** 2, q2 ** 2 + if w1 + w2 > 0: + comb = (w1 * z1 + w2 * z2a) / (w1 + w2) + qc, pc = periodicity(comb, fs, prior, min_period) + if qc >= max(q1, q2): + return Combined(comb, pc, qc, aligned) + return Combined(z1, p1, q1, aligned) if q1 >= q2 else Combined(z2a, p2, q2, aligned) + + +def _ncc(y: np.ndarray, template: np.ndarray) -> np.ndarray: + """Normalised cross-correlation of `template` (odd length) centred at + every sample of `y`; 0 where the template does not fit.""" + L = template.size + h = L // 2 + t0 = template - template.mean() + tn = np.linalg.norm(t0) + out = np.zeros(y.size) + if tn == 0 or y.size < L: + return out + num = correlate(y, t0, mode="same", method="fft") + c1 = np.concatenate(([0.0], np.cumsum(y))) + c2 = np.concatenate(([0.0], np.cumsum(y * y))) + idx = np.arange(h, y.size - h) + s = c1[idx + h + 1] - c1[idx - h] + ss = c2[idx + h + 1] - c2[idx - h] + var = np.maximum(ss - s * s / L, 1e-12) + out[idx] = num[idx] / (np.sqrt(var) * tn) + return out + + +@dataclass +class Template: + waveform: np.ndarray + polarity: int + + +def _pick_polarity(x: np.ndarray, period: float, fs: int, + prefer: Optional[int]) -> Tuple[int, np.ndarray]: + """Choose the sign whose peaks (at >= 0.6 period spacing) are most + regular and closest to the period; ties keep the previous window's sign + so the beat anchor doesn't jump between windows.""" + dist = max(1, int(0.6 * period * fs)) + scores = {} + peaks_by = {} + for pol in (1, -1): + pk, props = find_peaks(pol * x, distance=dist, height=0) + if pk.size >= 3: + heights = props["peak_heights"] + pk = pk[heights >= 0.3 * np.median(heights)] + if pk.size < 3: + continue + ibi = np.diff(pk) / fs + scores[pol] = abs(ibi.mean() - period) / period + ibi.std() / ibi.mean() + peaks_by[pol] = pk + if not scores: + return (prefer or 1), np.array([], dtype=int) + pol = min(scores, key=scores.get) + if prefer in scores and scores[prefer] <= scores[pol] + 0.1: + pol = prefer + return pol, peaks_by[pol] + + +def detect_beats(x: np.ndarray, period: float, fs: int = FS, + previous: Optional[Template] = None + ) -> Tuple[np.ndarray, np.ndarray, Optional[Template]]: + """Beat positions (fractional samples) and confidences in `x`. + + Builds the window's average beat complex around regular candidate peaks, + matches it against the signal (normalised cross-correlation), takes + correlation peaks at >= 0.6 period spacing, searches long gaps again at + a lower threshold, and refines each beat by parabolic interpolation. + + Anchor continuity: a beat's time is the template centre, which depends + on the polarity picked (J peak vs K trough ~80 ms apart). Consecutive + windows may pick differently, and one inconsistent interval per switch + would inflate RMSSD while passing the 20% artifact rule. So the new + template is aligned (in the signal's own polarity) to the previous one; + beat times are shifted by that lag, and the template is stored + re-centred on the previous anchor. + """ + empty = (np.array([]), np.array([]), previous) + if period <= 0: + return empty + pol, cand = _pick_polarity(x, period, fs, previous.polarity if previous else None) + h = int(round(min(max(0.35 * period, 0.2), 0.5) * fs)) + segs = [pol * x[c - h:c + h + 1] for c in cand if c - h >= 0 and c + h < x.size] + if len(segs) < 3: + return empty + template = np.median(np.stack(segs), axis=0) + y = pol * x + ncc = _ncc(y, template) + dist = max(1, int(0.6 * period * fs)) + peaks, _ = find_peaks(ncc, height=NCC_MIN, distance=dist) + peaks = list(peaks) + # Search-back: a gap of > 1.6 periods probably hides a weaker beat. + extra = [] + bounds = [0] + peaks + [x.size - 1] + for a, b in zip(bounds[:-1], bounds[1:]): + if (b - a) / fs > 1.6 * period: + lo, hi = a + dist, b - dist + if hi > lo: + i = lo + int(np.argmax(ncc[lo:hi])) + if ncc[i] >= NCC_SEARCHBACK: + extra.append(i) + peaks = np.array(sorted(set(peaks) | set(extra)), dtype=int) + pos = np.array([p + _parabolic(ncc, p) for p in peaks], dtype=float) + conf = ncc[peaks] if peaks.size else np.array([]) + lag = _anchor_lag(previous, Template(template, pol), fs) + if lag: + pos = pos - lag + template = _shift(template, lag) + return pos, conf, Template(template, pol) + + +def _shape_match(previous: Template, new: Template, fs: int) -> Tuple[int, float]: + """(lag, correlation) of the best alignment of `new`'s beat shape to + `previous`'s, each in its signal's own polarity. Templates are sized + from the period, so both are cropped to the shorter, centred.""" + n = min(previous.waveform.size, new.waveform.size) + + def centre(w: np.ndarray) -> np.ndarray: + k = (w.size - n) // 2 + return w[k:k + n] + + a = previous.polarity * centre(previous.waveform) + b = new.polarity * centre(new.waveform) + a = a - a.mean() + b = b - b.mean() + best_lag, best_c = 0, 0.0 + for lag in range(-int(0.15 * fs), int(0.15 * fs) + 1): + bs = _shift(b, lag) + d = np.linalg.norm(a) * np.linalg.norm(bs) + if d == 0: + continue + c = float(np.dot(a, bs) / d) + if c > best_c: + best_lag, best_c = lag, c + return best_lag, best_c + + +def _anchor_lag(previous: Optional[Template], new: Template, fs: int) -> int: + """Samples by which `new`'s centre sits later on the beat complex than + `previous`'s (0 when unrelated or equal).""" + if previous is None: + return 0 + lag, similarity = _shape_match(previous, new, fs) + return lag if similarity >= ANCHOR_MIN_SIMILARITY else 0 + + +def confirmed_by_channels(pos: np.ndarray, template: Template, + aligned: List[Tuple[np.ndarray, float]], + fs: int = FS) -> np.ndarray: + """Boolean mask over beat positions: True unless two good channels both + fail to show the beat. Each aligned channel is matched against the SAME + template (so the same point of the beat complex is compared), and a + channel confirms a beat when its correlation reaches NCC_SEARCHBACK + within CONFIRM_TOL_S. With fewer than two good channels there is nothing + independent to cross-check, so every beat stands.""" + good = [ch for ch, q in aligned if q >= CONFIRM_MIN_QUALITY] + if len(good) < 2 or pos.size == 0: + return np.ones(pos.size, dtype=bool) + tol = int(round(CONFIRM_TOL_S * fs)) + ok = np.zeros(pos.size, dtype=bool) + for ch in good: + ncc = _ncc(template.polarity * ch, template.waveform) + for i, p in enumerate(np.round(pos).astype(int)): + lo, hi = max(0, p - tol), min(ncc.size, p + tol + 1) + if hi > lo and ncc[lo:hi].max() >= NCC_SEARCHBACK: + ok[i] = True + return ok + + +# --------------------------------------------------------------------------- +# Artifact correction +# --------------------------------------------------------------------------- + +Beat = Optional[Tuple[float, float]] # (time_s, confidence); None = break + + +class BeatCleaner: + """Streaming RR artifact correction. Feed raw beats (and breaks); + returns cleaned beats with at most one beat of delay (an apparent extra + beat is held until the next one decides it). + + - accept an interval within REL_TOL (20%) of the median of the last + MEDIAN_WINDOW accepted intervals (or within MIN_RRI_S..MAX_RRI_S while + there is no history yet); + - a short interval whose sum with the next is a normal interval marks + an extra beat: the middle beat is dropped; + - anything else (missed beats, ectopy, noise) is a break: no interval is + reported across it, and the beat after it starts a new run; + - three consecutive intervals that agree with each other but not with + the history mean the rate really changed: the history is reset. + """ + + def __init__(self) -> None: + self._last: Optional[float] = None + self._hist: Deque[float] = deque(maxlen=MEDIAN_WINDOW) + self._held: Optional[Tuple[float, float]] = None + self._outliers: List[float] = [] + + def _median(self) -> Optional[float]: + return float(np.median(self._hist)) if len(self._hist) >= MIN_HISTORY else None + + def _ok(self, d: float, med: Optional[float]) -> bool: + if not (MIN_RRI_S <= d <= MAX_RRI_S): + return False + return med is None or abs(d / med - 1) <= REL_TOL + + def _start_run(self, beat: Tuple[float, float], out: List[Beat]) -> None: + if out and out[-1] is not None or (not out and self._last is not None): + out.append(None) + self._last = beat[0] + out.append(beat) + + def push(self, beat: Beat) -> List[Beat]: + out: List[Beat] = [] + if beat is None: + if self._held is not None: + self._held = None + if self._last is not None: + out.append(None) + self._last = None + self._outliers = [] + return out + t, _ = beat + if self._last is None: + self._last = t + out.append(beat) + return out + med = self._median() + if self._held is not None: + # Was the held beat an extra one? Then last -> this beat is normal. + held, self._held = self._held, None + if self._ok(t - self._last, med): + self._accept(t - self._last, beat, out) + return out + # Not extra: the held beat broke the rhythm. Restart at it. + self._reject(held[0] - self._last) + self._start_run(held, out) + return out + self.push(beat) + d = t - self._last + if self._ok(d, med): + self._accept(d, beat, out) + return out + if med is not None and 0 <= d < med * (1 - REL_TOL): + self._held = beat # maybe an extra beat; decided by the next one + return out + self._reject(d) + self._start_run(beat, out) + return out + + def _reject(self, d: float) -> None: + """Record a rejected interval. Three that agree with each other (but + not the history) mean the rate really changed: adopt them.""" + self._outliers.append(d) + if len(self._outliers) < 3: + return + recent = self._outliers[-3:] + m = float(np.median(recent)) + if not (m > 0 and all(MIN_RRI_S <= r <= MAX_RRI_S and abs(r / m - 1) <= REL_TOL + for r in recent)): + return + old = self._median() + # Halving or doubling is a detection artifact (every other beat + # missed, or extra half-beats), not a physiological rate change. + if old is not None and (0.4 <= m / old <= 0.6 or 1.7 <= m / old <= 2.3): + return + self._hist.clear() + self._hist.extend(recent) + self._outliers = [] + + def _accept(self, d: float, beat: Tuple[float, float], out: List[Beat]) -> None: + self._hist.append(d) + self._outliers = [] + self._last = beat[0] + out.append(beat) + + +# --------------------------------------------------------------------------- +# Rolling statistics for vitals +# --------------------------------------------------------------------------- + +class BeatHistory: + """Recent cleaned beats of one side, for heart rate and RMSSD.""" + + def __init__(self, keep_s: float = 330.0) -> None: + self._keep_s = keep_s + self._items: Deque[Beat] = deque() + + def extend(self, items: List[Beat]) -> None: + for it in items: + if it is None and self._items and self._items[-1] is None: + continue + self._items.append(it) + latest = self.latest_time() + if latest is None: + return + while self._items and (self._items[0] is None or self._items[0][0] < latest - self._keep_s): + self._items.popleft() + + def latest_time(self) -> Optional[float]: + for it in reversed(self._items): + if it is not None: + return it[0] + return None + + def _intervals(self, since: float) -> List[List[float]]: + """Runs of consecutive valid intervals whose end beat is >= since.""" + runs: List[List[float]] = [[]] + prev: Optional[float] = None + for it in self._items: + if it is None: + prev = None + runs.append([]) + continue + if prev is not None and it[0] >= since: + runs[-1].append(it[0] - prev) + prev = it[0] + return [r for r in runs if r] + + def heart_rate(self, window_s: float = 60.0, min_coverage: float = 0.5) -> Optional[float]: + latest = self.latest_time() + if latest is None: + return None + ivs = [d for run in self._intervals(latest - window_s) for d in run] + if not ivs or sum(ivs) < min_coverage * window_s: + return None + return 60.0 / float(np.mean(ivs)) + + def rmssd_ms(self, window_s: float = 300.0, min_coverage: float = 0.5, + min_diffs: int = 20) -> Optional[float]: + """RMSSD over successive differences of adjacent intervals within a + run (never across a break).""" + latest = self.latest_time() + if latest is None: + return None + runs = self._intervals(latest - window_s) + diffs = [b - a for run in runs for a, b in zip(run[:-1], run[1:])] + covered = sum(sum(run) for run in runs) + if len(diffs) < min_diffs or covered < min_coverage * window_s: + return None + return 1000.0 * math.sqrt(float(np.mean(np.square(diffs)))) + + +# --------------------------------------------------------------------------- +# Streaming tracker +# --------------------------------------------------------------------------- + +@dataclass +class Chunk: + """One minute of cleaned beats for storage.""" + start: int # epoch seconds, minute-aligned + beats: List[Optional[int]] # ms offsets from start; None = break + quality: float # mean beat confidence + + @property + def coverage(self) -> int: + return sum(1 for b in self.beats if b is not None) + + +@dataclass +class BeatTracker: + """Per-side streaming beat detector. Feed every piezo record (own two + channels plus the other side's two as the noise reference); collect + finished minute chunks with `take_chunks()`.""" + side: str + history: BeatHistory = field(default_factory=BeatHistory) + _raw: Dict[str, np.ndarray] = field(default_factory=dict) + _start: Optional[float] = None # time of _raw sample 0 + _next_end: Optional[float] = None # end of the next window to process + _template: Optional[Template] = None + _cleaner: BeatCleaner = field(default_factory=BeatCleaner) + _chunk_items: List[Tuple[Optional[float], float]] = field(default_factory=list) + _chunk_start: Optional[int] = None + _ready: List[Chunk] = field(default_factory=list) + _last_committed_end: Optional[float] = None + _rhythm: Deque[float] = field(default_factory=lambda: deque(maxlen=TRACK_HISTORY)) + _rhythm_at: Optional[float] = None + _candidates: List[float] = field(default_factory=list) + _hint: Optional[float] = None # period (s) from the window rate + _hint_at: Optional[float] = None + _mask: np.ndarray = field(default_factory=lambda: np.zeros(0, dtype=bool)) + + KEYS = ("own1", "own2", "ref1", "ref2") + + def set_rate_hint(self, period: float, at: float) -> None: + """Independent heart period (s) measured at `at`; see HINT_MAX_AGE_S.""" + if period > 0: + self._hint, self._hint_at = float(period), float(at) + + def gap(self) -> None: + """Signal discontinuity (dropped records): restart windows.""" + self._emit([None]) + self._raw = {} + self._mask = np.zeros(0, dtype=bool) + self._start = None + self._next_end = None + + def push(self, ts: float, own1: np.ndarray, own2: Optional[np.ndarray], + ref1: Optional[np.ndarray], ref2: Optional[np.ndarray], + masked: bool = False) -> None: + """Add one record. A `masked` record (e.g. pump-gated) keeps the + windows running but is blanked like movement: no beat in it or + within MOTION_MARGIN_S of it, and no interval across it — where a + gap() would cost a whole window of refill.""" + n = own1.size + if n == 0: + return + if self._start is not None: + expected = self._start + self._raw["own1"].size / FS_RAW + if abs(ts - expected) > GAP_TOL_S: + self.gap() + if self._start is None: + self._start = float(ts) + self._raw = {k: np.zeros(0) for k in self.KEYS} + self._mask = np.zeros(0, dtype=bool) + self._next_end = self._start + WINDOW_S + for key, arr in zip(self.KEYS, (own1, own2, ref1, ref2)): + if arr is None or arr.size != n: + arr = np.zeros(n) + self._raw[key] = np.concatenate((self._raw[key], np.asarray(arr, dtype=np.float64))) + self._mask = np.concatenate((self._mask, np.full(n, masked))) + end = self._start + self._raw["own1"].size / FS_RAW + while self._next_end is not None and end >= self._next_end: + self._process(self._next_end) + self._next_end += STEP_S + keep_from = int(round((self._next_end - WINDOW_S - self._start) * FS_RAW)) + if keep_from > 0: + for k in self.KEYS: + self._raw[k] = self._raw[k][keep_from:] + self._mask = self._mask[keep_from:] + self._start += keep_from / FS_RAW + + def _process(self, window_end: float) -> None: + w0 = window_end - WINDOW_S + i0 = int(round((w0 - self._start) * FS_RAW)) + i1 = i0 + int(WINDOW_S * FS_RAW) + sig = {k: bandpass_decimate(self._raw[k][i0:i1]) for k in self.KEYS} + motion = np.zeros(sig["own1"].size, dtype=bool) + blank = self._mask[i0:i1][::DECIMATE][:motion.size] + if blank.any(): + # Masked records, widened by the filter's ringing margin. + pad = int(MOTION_MARGIN_S * FS) + blank = np.convolve(blank.astype(float), np.ones(2 * pad + 1), mode="same") > 0 + motion |= blank + for k in self.KEYS: + sig[k] = np.where(blank, 0.0, sig[k]) + for k in self.KEYS: + m = motion_mask(sig[k]) + sig[k] = np.where(m, 0.0, sig[k]) + if k.startswith("own"): + motion |= m + refs = [sig["ref1"], sig["ref2"]] + channels = [] + for key in ("own1", "own2"): + c = sig[key] + if not np.any(c): + channels.append(None) + continue + channels.append(self._use_reference(c, refs)) + c1, c2 = channels + if c1 is None: + c1, c2 = c2, None + if c1 is None or motion.mean() > 0.5: + self._commit(w0, [], [], ok=False) + return + comb = self._track(window_end, c1, c2) + if comb is None: + self._commit(w0, [], [], ok=False) + return + pos, conf, template = detect_beats(comb.signal, comb.period, previous=self._template) + if template is None or pos.size == 0: + self._commit(w0, [], [], ok=False) + return + self._template = template + keep = confirmed_by_channels(pos, template, comb.aligned) + if motion.any(): + guard = int(MOTION_GUARD_S * FS) + near = np.convolve(motion.astype(float), np.ones(2 * guard + 1), mode="same") > 0 + idx = np.clip(np.round(pos).astype(int), 0, motion.size - 1) + keep &= ~near[idx] + breaks = _motion_starts(motion) / FS + self._commit(w0, list(pos[keep] / FS), list(conf[keep]), ok=True, breaks=list(breaks)) + + def _track(self, now: float, c1: np.ndarray, c2: Optional[np.ndarray]) -> Optional[Combined]: + """This window's combined signal if its rhythm continues the tracked + one (or starts / re-seeds it); None if the window yields no beats.""" + if self._rhythm and self._rhythm_at is not None and now - self._rhythm_at > TRACK_MAX_AGE_S: + self._rhythm.clear() + self._candidates = [] + hint = self._hint + if hint is None or self._hint_at is None or now - self._hint_at > HINT_MAX_AGE_S: + hint = None + if self._rhythm and hint is not None and abs(float(np.median(self._rhythm)) / hint - 1) > TRACK_TOL: + # Tracking some other rhythm than the heart rate says (typically + # half-beats): drop it and restart at the hinted rate. + self._rhythm.clear() + self._candidates = [] + if self._rhythm: + comb = combine_channels(c1, c2, prior=float(np.median(self._rhythm))) + if comb.quality >= FOLLOW_MIN_QUALITY: + self._candidates = [] + return self._follow(now, comb) + if hint is not None: + if self._rhythm: + return None + comb = combine_channels(c1, c2, prior=hint) + if comb.quality < MIN_QUALITY: + return None + # This window and the independent rate agree: start here. + self._candidates = [] + return self._follow(now, comb) + comb = combine_channels(c1, c2, min_period=SEED_MIN_PERIOD_S) + if comb.quality < MIN_QUALITY: + if not self._rhythm: + self._candidates = [] + return None + # A rhythm other than the tracked one (or none tracked yet): adopt it + # once enough consecutive windows agree on it. + self._candidates.append(comb.period) + need = RESEED_WINDOWS if self._rhythm else SEED_WINDOWS + recent = self._candidates[-need:] + if len(recent) < need: + return None + m = float(np.median(recent)) + if not all(abs(p / m - 1) <= TRACK_TOL for p in recent): + return None + self._rhythm.clear() + self._rhythm.extend(recent[:-1]) + self._candidates = [] + return self._follow(now, comb) + + def _follow(self, now: float, comb: Combined) -> Combined: + self._rhythm.append(comb.period) + self._rhythm_at = now + return comb + + @staticmethod + def _use_reference(c: np.ndarray, refs: List[np.ndarray]) -> np.ndarray: + """`c` with vibration predictable from the other side removed, unless + that removal was (mostly) this sleeper's own heartbeat.""" + cleaned = cancel_common_noise(c, refs) + var = float(np.var(c)) + if var <= 0 or float(np.var(c - cleaned)) / var < ANC_MIN_REMOVED: + return c + q_raw, _ = periodicity(c) + q_clean, _ = periodicity(cleaned) + if q_clean >= MIN_QUALITY and q_clean >= q_raw - ANC_MAX_QUALITY_DROP: + return cleaned + return c + + def _commit(self, w0: float, times: List[float], conf: List[float], ok: bool, + breaks: Optional[List[float]] = None) -> None: + lo, hi = w0 + COMMIT_START_S, w0 + COMMIT_END_S + if not ok: + self._emit([None]) + self._last_committed_end = hi + return + events = [(w0 + t, 1, (w0 + t, float(c))) for t, c in zip(times, conf) + if lo <= w0 + t < hi] + # A movement starting in (or just before) the committed region + # breaks the run there. + events += [(w0 + b, 0, None) for b in (breaks or []) + if lo - MOTION_MARGIN_S <= w0 + b < hi] + events.sort(key=lambda e: (e[0], e[1])) + self._emit([e[2] for e in events]) + self._last_committed_end = hi + + def _emit(self, raw_items: List[Beat]) -> None: + cleaned: List[Beat] = [] + for it in raw_items: + cleaned.extend(self._cleaner.push(it)) + self.history.extend(cleaned) + for it in cleaned: + if it is None: + if self._chunk_items and self._chunk_items[-1][0] is not None: + self._chunk_items.append((None, 0.0)) + continue + t, c = it + minute = int(t // 60) * 60 + if self._chunk_start is None: + self._chunk_start = minute + if minute != self._chunk_start: + self._flush() + self._chunk_start = minute + self._chunk_items.append((t, c)) + + def _flush(self) -> None: + if self._chunk_start is None: + return + items = self._chunk_items + # A break at the end of a minute belongs to the next one's start. + carry_break = bool(items) and items[-1][0] is None + beats: List[Optional[int]] = [] + confs = [] + for t, c in items[:-1] if carry_break else items: + if t is None: + # A leading break says "not contiguous with the previous chunk". + if not beats or beats[-1] is not None: + beats.append(None) + else: + beats.append(int(round((t - self._chunk_start) * 1000))) + confs.append(c) + if confs: + self._ready.append(Chunk(self._chunk_start, beats, float(np.mean(confs)))) + self._chunk_items = [(None, 0.0)] if carry_break else [] + + def take_chunks(self, flush_before: Optional[float] = None) -> List[Chunk]: + """Finished chunks. The open minute is also flushed once committed + processing has moved past its end (`flush_before`, default the last + committed time).""" + edge = flush_before if flush_before is not None else self._last_committed_end + # One extra step of margin: the cleaner may still hold the minute's + # last beat until the next beat decides it. + if (self._chunk_start is not None and edge is not None + and edge >= self._chunk_start + 60 + STEP_S): + self._flush() + self._chunk_start = None + out, self._ready = self._ready, [] + return out diff --git a/modules/piezo-processor/main.py b/modules/piezo-processor/main.py index 41c8f90c0..3480d4afd 100644 --- a/modules/piezo-processor/main.py +++ b/modules/piezo-processor/main.py @@ -37,7 +37,7 @@ from pathlib import Path from datetime import datetime, timezone from dataclasses import dataclass -from typing import Callable, Optional +from typing import Callable, Dict, List, Optional, Tuple sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -46,6 +46,7 @@ from common.dialect import KNOWN_RECORD_TYPES, warn_unknown_type_once from common.side_mode import SingleSleeperMode from common.bed_presence import BedPresence +from beats import STEP_S, BeatHistory, BeatTracker, Chunk import numpy as np from runtime_adapter import RuntimeCoordinator, RunContext from common.biometrics.frames import decode_int32_samples as _int32_samples, LOST_SAMPLE @@ -249,6 +250,167 @@ def __init__(self, holder: "DBHolder", mode: SingleSleeperMode, clock=clock) +def write_heartbeats(holder: "DBHolder", side: str, chunk: Chunk) -> bool: + """Insert one minute of detected beats. Replays after a restart produce + the same rows again, so conflicts are ignored. Failures count towards + the connection reset like write_vitals.""" + try: + with holder.conn: + holder.conn.execute( + "INSERT OR IGNORE INTO heartbeats (side, timestamp, beats, quality) " + "VALUES (?, ?, ?, ?)", + (side, int(chunk.start), json.dumps(chunk.beats, separators=(",", ":")), + round(chunk.quality, 3)), + ) + holder.write_failures = 0 + return True + except sqlite3.Error as e: + holder.write_failures += 1 + log.warning("write_heartbeats failed (%d consecutive): %s", + holder.write_failures, e) + if holder.write_failures >= _DB_RECONNECT_THRESHOLD: + _reconnect_db(holder) + holder.write_failures = 0 + return False + + +# Minutes held for a retry after a failed write; older ones are dropped. +HEARTBEAT_RETRY_MAX = 30 +# A single-sleeper minute waits for the other side's tracker to finish it, but +# no longer than this past the minute's end (s) — a stuck tracker can't hold +# the stream back. +HEARTBEAT_HOLD_MAX_S = 180.0 + + +class BeatFront: + """Beat detection for both sides. Each side's tracker gets its own two + piezo channels plus the other side's as the noise reference. Finished + minutes go to the heartbeats table; with one side in away mode (single + sleeper), each minute keeps whichever side saw the sleeper's beats best, + stored under the home side. The two trackers finish a minute at + different times, so it is held until both are done with it; when the + stored minutes switch sensor, the new one starts with a break, since + the two trackers' beat anchors differ. + + A side's tracker only runs while `present(side)` holds. An empty bed + still carries regular vibration from the pod itself, which the detector + can mistake for a heartbeat; the vitals presence detector already tells + the two apart, so beats follow it. Leaving the bed is a gap, so no + interval spans an absence, and stores the minute in progress.""" + + def __init__(self, holder: "DBHolder", mode: SingleSleeperMode, + present: Callable[[str], bool] = lambda side: True): + self._holder = holder + self._mode = mode + self._present = present + self._was_present = {"left": False, "right": False} + self.trackers = {"left": BeatTracker("left"), "right": BeatTracker("right")} + self._latest_ts: Optional[float] = None + # Single sleeper: minute start -> (home side when it was queued, + # {sensor side: chunk}) awaiting the other side. The home side is + # kept so a mode change while it waits doesn't reroute it. + self._pending: Dict[int, Tuple[str, Dict[str, Chunk]]] = {} + # Storage side -> sensor side of the last minute stored there. Never + # reset: a mode change can switch sensors too. + self._last_source: Dict[str, str] = {} + # (storage side, chunk) whose write failed, oldest first. + self._retry: List[Tuple[str, Chunk]] = [] + + def history(self, side: str) -> BeatHistory: + return self.trackers[side].history + + def set_rate_hint(self, side: str, period: float) -> None: + # Record time, as the tracker ages the hint against its windows. + at = self._latest_ts if self._latest_ts is not None else time.time() + self.trackers[side].set_rate_hint(period, at) + + def push(self, ts: float, l1: np.ndarray, l2: Optional[np.ndarray], + r1: np.ndarray, r2: Optional[np.ndarray], masked: bool = False) -> None: + self._latest_ts = float(ts) + signals = {"left": (l1, l2, r1, r2), "right": (r1, r2, l1, l2)} + chunks = {} + for side, tracker in self.trackers.items(): + present = self._present(side) + if present: + tracker.push(ts, *signals[side], masked=masked) + chunks[side] = tracker.take_chunks() + elif self._was_present[side]: + tracker.gap() + # Nothing more is coming for the open minute until they're back. + chunks[side] = tracker.take_chunks(flush_before=float("inf")) + else: + chunks[side] = tracker.take_chunks() + self._was_present[side] = present + self._write(chunks) + + def gap(self) -> None: + for t in self.trackers.values(): + t.gap() + + def flush(self) -> None: + self._write({s: t.take_chunks(flush_before=float("inf")) + for s, t in self.trackers.items()}, force=True) + + def _store_from(self, side: str, source: str, chunk: Chunk) -> None: + """Store `source` sensor's chunk under `side`. A minute from another + sensor than the last one stored there starts with a break: the two + trackers' beat anchors differ, so no interval may span them.""" + last = self._last_source.get(side) + if last is not None and last != source and chunk.beats and chunk.beats[0] is not None: + chunk = Chunk(chunk.start, [None] + chunk.beats, chunk.quality) + self._last_source[side] = source + self._store(side, chunk) + + def _store(self, side: str, chunk: Chunk) -> None: + if not write_heartbeats(self._holder, side, chunk): + self._retry.append((side, chunk)) + if len(self._retry) > HEARTBEAT_RETRY_MAX: + dropped = self._retry.pop(0) + log.warning("Dropping unwritten heartbeat minute %s/%d", dropped[0], dropped[1].start) + + def _side_done(self, side: str, start: int) -> bool: + """True once `side`'s tracker can no longer produce minute `start`.""" + if not self._was_present[side]: + return True + tracker = self.trackers[side] + if tracker._chunk_start is not None: + return tracker._chunk_start > start + end = tracker._last_committed_end + return end is not None and end >= start + 60 + STEP_S + + def _write(self, chunks: dict, force: bool = False) -> None: + retry, self._retry = self._retry, [] + for side, c in retry: + self._store(side, c) + home = self._mode.home_side() + if home is None: + # Leaving single-sleeper mode: held minutes go out as they were + # queued, before any per-side minute. + self._release(force=True) + for side, cs in chunks.items(): + for c in cs: + self._store_from(side, side, c) + return + for side, cs in chunks.items(): + for c in cs: + self._pending.setdefault(c.start, (home, {}))[1][side] = c + self._release(force) + + def _release(self, force: bool) -> None: + """Store held minutes, oldest first, once neither tracker can still + add to them: the better side's chunk, under the home side the minute + was queued for.""" + for start in sorted(self._pending): + home, got = self._pending[start] + stale = self._latest_ts is not None and self._latest_ts > start + 60 + HEARTBEAT_HOLD_MAX_S + if not (force or stale or all(self._side_done(s, start) + for s in self.trackers if s not in got)): + break + del self._pending[start] + source, best = max(got.items(), key=lambda kv: kv[1].coverage * kv[1].quality) + self._store_from(home, source, best) + + def report_health(status: str, message: str) -> None: """Write module health to sleepypod.db system_health table.""" try: @@ -338,6 +500,17 @@ def main() -> None: left.home_side = right.home_side = bed_mode.home_side runtime = RuntimeCoordinator(db_holder, {"left": left, "right": right}) runtime_problem = None + sides = {"left": left, "right": right} + # Beats only while the side is occupied: the piezo says someone is there + # and the bed's capacitance presence doesn't say it's empty. + beat_front = BeatFront( + db_holder, bed_mode, + present=lambda s: (sides[s]._presence.state == PresenceDetector.PRESENT + and bed.occupied(bed_mode.home_side() or s) is not False)) + # Each side's window heart rate guides its beat tracker (see beats.py + # HINT_MAX_AGE_S): envelope periodicity alone can take half-beats for beats. + left.rate_hint = lambda period: beat_front.set_rate_hint("left", period) + right.rate_hint = lambda period: beat_front.set_rate_hint("right", period) # Source selected once at startup: NatsFollower on new-firmware pods (NATS # reachable), else the unchanged .RAW tailer. Same decoded-record contract. follower = create_follower(RAW_DATA_DIR, _shutdown, poll_interval=0.01) @@ -372,10 +545,20 @@ def main() -> None: if l_samples.size == 0 or r_samples.size == 0: continue - # Pump gating — drop entire record if pump detected or guard active + ts = record.get("ts") + ts = float(ts) if isinstance(ts, (int, float)) else time.time() + l2 = _int32_samples(record.get("left2", b"")) + r2 = _int32_samples(record.get("right2", b"")) + + # Pump gating — drop entire record if pump detected or guard active. + # Beat tracking blanks it instead: a restart would cost a window. if pump_gate.check(l_samples, r_samples): + beat_front.push(ts, l_samples, l2, r_samples, r2, masked=True) continue + # Beat detection uses both piezo channels of each side, and the + # other side as a noise reference. + beat_front.push(ts, l_samples, l2, r_samples, r2) frame_time_ms = int(time.time() * 1000) runtime.before_frame(bed_mode.home_side(), bed.occupied, frame_time_ms) left.ingest(l_samples) @@ -392,6 +575,7 @@ def main() -> None: report_health("down", str(e)) sys.exit(1) finally: + beat_front.flush() vitals_router.flush() db_holder.conn.close() log.info("Shutdown complete") diff --git a/modules/piezo-processor/test_beats.py b/modules/piezo-processor/test_beats.py new file mode 100644 index 000000000..04122835a --- /dev/null +++ b/modules/piezo-processor/test_beats.py @@ -0,0 +1,696 @@ +""" +Beat detection, artifact correction and HRV on synthetic ballistocardiograms +with known beat times. + +The synthetic BCG uses the classic H-I-J-K-L complex (J dominant), a +respiratory sinus arrhythmia rhythm plus beat-to-beat jitter (RMSSD ~40 ms), +respiration baseline wander and amplitude modulation, and white noise. Scores +are sensitivity / positive predictive value against the true J-peak times +(+-50 ms) and the RMS timing error of matched beats. +""" + +import math + +import numpy as np +import pytest + +import beats as B + +FS = B.FS_RAW + + +# --------------------------------------------------------------------------- +# Synthetic signal +# --------------------------------------------------------------------------- + +def _complex(fs=FS): + """BCG beat complex; J peak at 0.20 s.""" + t = np.arange(int(0.6 * fs)) / fs + waves = [(0.08, 0.3, 0.015), (0.14, -0.6, 0.02), (0.20, 1.0, 0.022), + (0.27, -0.7, 0.025), (0.35, 0.25, 0.03)] + return sum(a * np.exp(-0.5 * ((t - c) / w) ** 2) for c, a, w in waves) + + +J_OFFSET = 0.20 + + +def beat_times(duration, hr=60.0, rsa_ms=40.0, jitter_ms=20.0, seed=0, start=2.0): + rng = np.random.default_rng(seed) + times, t = [], start + base = 60.0 / hr + while t < duration - 1.0: + times.append(t) + rsa = rsa_ms / 1000 * math.sin(2 * math.pi * 0.25 * t) + t += base + rsa + rng.normal(0, jitter_ms / 1000) + return np.array(times) + + +def bcg(times, duration, amp=1000.0, noise=150.0, seed=1, fs=FS, + resp_amp=5000.0, delay=0.0, gain=1.0): + rng = np.random.default_rng(seed) + n = int(duration * fs) + tt = np.arange(n) / fs + x = resp_amp * np.sin(2 * math.pi * 0.25 * tt) + cplx = _complex(fs) + for bt in times: + i0 = int(round((bt + delay - J_OFFSET) * fs)) + if i0 < 0 or i0 + cplx.size > n: + continue + mod = 1 + 0.2 * math.sin(2 * math.pi * 0.25 * bt) + x[i0:i0 + cplx.size] += gain * amp * mod * cplx + return x + rng.normal(0, noise, n) + + +def interference(duration, freq=1.07, amp=900.0, fs=FS): + """In-band periodic vibration both sides see (e.g. a pump beat).""" + tt = np.arange(int(duration * fs)) / fs + return amp * np.sin(2 * math.pi * freq * tt) * (1 + 0.3 * np.sin(2 * math.pi * 0.05 * tt)) + + +def run_tracker(own1, own2=None, ref1=None, ref2=None, t0=1_700_000_000.0): + """Stream 1-second records through a tracker; return committed beat times + (absolute) and all chunks.""" + tr = B.BeatTracker("left") + n = own1.size + chunks = [] + for i in range(0, n - FS + 1, FS): + sl = slice(i, i + FS) + tr.push(t0 + i / FS, own1[sl], + None if own2 is None else own2[sl], + None if ref1 is None else ref1[sl], + None if ref2 is None else ref2[sl]) + chunks.extend(tr.take_chunks()) + chunks.extend(tr.take_chunks(flush_before=float("inf"))) + detected = [] + for c in chunks: + detected.extend(c.start + b / 1000 for b in c.beats if b is not None) + return tr, np.array(sorted(detected)) - t0, chunks + + +def anchor_offset(truth, detected): + """The detector reports a fixed point of the (filtered) beat complex, + not necessarily the J peak: a constant offset, irrelevant to intervals. + Estimated as the median nearest-beat difference.""" + if detected.size == 0: + return 0.0 + near = [detected[np.argmin(np.abs(detected - t))] - t for t in truth] + return float(np.median(near)) + + +def score(truth, detected, lo, hi, tol=0.05): + """Sensitivity, PPV and RMS timing error inside [lo, hi), after removing + the constant anchor offset.""" + off = anchor_offset(truth[(truth >= lo) & (truth < hi)], detected) + assert abs(off) < 0.15, f"anchor offset {off:.3f}s" + detected = detected - off + truth = truth[(truth >= lo) & (truth < hi)] + det = detected[(detected >= lo) & (detected < hi)] + used = np.zeros(det.size, dtype=bool) + errs = [] + for t in truth: + if det.size == 0: + break + j = int(np.argmin(np.abs(det - t))) + if not used[j] and abs(det[j] - t) <= tol: + used[j] = True + errs.append(det[j] - t) + tp = len(errs) + sens = tp / max(1, truth.size) + ppv = tp / max(1, det.size) + rms = float(np.sqrt(np.mean(np.square(errs)))) if errs else float("inf") + return sens, ppv, rms + + +DUR = 180.0 +EVAL = (15.0, DUR - 15.0) + + +def secondary_wave(times, duration, at=0.43, rel_amp=0.8, amp=1000.0, fs=FS): + """A second wave `at` s after each J peak, as some sensor positions show + (on a real bed it sat ~0.43 s after the main complex).""" + n = int(duration * fs) + tt = np.arange(n) / fs + x = np.zeros(n) + for bt in times: + c = bt + at + i0, i1 = int((c - 0.2) * fs), int((c + 0.25) * fs) + if i0 < 0 or i1 > n: + continue + seg = tt[i0:i1] + x[i0:i1] += amp * rel_amp * (np.exp(-0.5 * ((seg - c) / 0.05) ** 2) + - 0.6 * np.exp(-0.5 * ((seg - c - 0.07) / 0.05) ** 2)) + return x + + +def machine_pulses(duration, rate=2.3, amp=1500.0, start=0.0, end=None, seed=3, fs=FS): + """A beat-like pulse train at a fixed mechanical rate (e.g. a pump): + stronger than the heartbeat and faster than any resting heart rate.""" + rng = np.random.default_rng(seed) + n = int(duration * fs) + tt = np.arange(n) / fs + x = np.zeros(n) + t = start + end = duration if end is None else end + while t < end: + i0, i1 = max(0, int((t - 0.1) * fs)), min(n, int((t + 0.1) * fs)) + seg = tt[i0:i1] + x[i0:i1] += amp * (np.exp(-0.5 * ((seg - t) / 0.02) ** 2) + - 0.5 * np.exp(-0.5 * ((seg - t - 0.05) / 0.02) ** 2)) + t += 1 / rate + rng.normal(0, 0.005) + return x + + +def chunk_intervals(chunks, lo, hi, t0=1_700_000_000.0): + """Cleaned intervals (s) between consecutive beats of the same run whose + first beat falls in [lo, hi) s.""" + out = [] + for c in chunks: + b = c.beats + for i in range(len(b) - 1): + if b[i] is not None and b[i + 1] is not None: + t = c.start - t0 + b[i] / 1000 + if lo <= t < hi: + out.append((b[i + 1] - b[i]) / 1000) + return np.array(out) + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + +class TestDetection: + def test_clean_signal_finds_every_beat_precisely(self): + truth = beat_times(DUR) + _, det, _ = run_tracker(bcg(truth, DUR)) + sens, ppv, rms = score(truth, det, *EVAL) + # Cleaning may break a run at a noisy beat, so allow a few misses. + assert sens >= 0.97 and ppv >= 0.99 + assert rms < 0.005 + + @pytest.mark.parametrize("seeds,burst", [((31, 32), False), ((33, 34), True)]) + def test_anchor_is_stable_across_windows(self, seeds, burst): + # HRV depends on every beat being timed at the same point of the + # complex across the 5 s windows. Without anchor continuity, seed + # 33/34 flipped from the K trough to the J peak (78 ms) after a + # movement burst — one bad interval that passes the 20% rule. + truth = beat_times(DUR, seed=seeds[0]) + x = bcg(truth, DUR, seed=seeds[1]) + if burst: + x[int(90 * FS):int(94 * FS)] += np.random.default_rng(35).normal(0, 20000, 4 * FS) + _, det, _ = run_tracker(x) + off = anchor_offset(truth, det) + per_10s = [anchor_offset(truth[(truth >= m) & (truth < m + 10)], det) + for m in range(20, int(DUR) - 20, 10)] + assert all(abs(o - off) < 0.008 for o in per_10s) + + @pytest.mark.parametrize("hr", [45, 60, 80, 100]) + def test_heart_rate_range(self, hr): + truth = beat_times(DUR, hr=hr, rsa_ms=25, jitter_ms=10, seed=hr) + _, det, _ = run_tracker(bcg(truth, DUR, seed=hr + 1)) + sens, ppv, _ = score(truth, det, *EVAL) + assert sens >= 0.95 and ppv >= 0.98 + + def test_movement_burst_does_not_invent_intervals(self): + # Turning over: a few seconds of large broadband motion. Beats there + # may be lost, but no false interval may survive cleaning. + truth = beat_times(DUR, seed=33) + x = bcg(truth, DUR, seed=34) + rng = np.random.default_rng(35) + x[int(90 * FS):int(94 * FS)] += rng.normal(0, 20000, 4 * FS) + _, det, chunks = run_tracker(x) + sens, ppv, _ = score(truth, det, *EVAL) + assert ppv >= 0.98 and sens >= 0.85 + seq = [None if b is None else c.start + b / 1000 for c in chunks for b in c.beats] + runs, cur = [], [] + for v in seq: + if v is None: + runs.append(cur) + cur = [] + else: + cur.append(v) + runs.append(cur) + for run in runs: + for a, b in zip(run, run[1:]): + assert 0.75 <= b - a <= 1.3 # true IBIs are ~1.0 +- 0.06 s + + def test_large_dc_offset_like_real_channels(self): + truth = beat_times(DUR, seed=36) + x = bcg(truth, DUR, seed=37) - 2_350_000 # channel-2 style offset + _, det, _ = run_tracker(x) + sens, ppv, _ = score(truth, det, *EVAL) + assert sens >= 0.97 and ppv >= 0.99 + + def test_empty_bed_yields_no_beats(self): + rng = np.random.default_rng(3) + noise = rng.normal(0, 150, int(DUR * FS)) + 5000 * np.sin( + 2 * math.pi * 0.25 * np.arange(int(DUR * FS)) / FS) + _, det, _ = run_tracker(noise) + assert det.size <= 5 # vs ~150 real beats + + +class TestTwoChannels: + def test_noisy_first_channel_is_rescued_by_the_second(self): + truth = beat_times(DUR, seed=5) + ch1 = bcg(truth, DUR, noise=900, seed=6) # poor + ch2 = bcg(truth, DUR, noise=120, seed=7, gain=-0.6, delay=0.02) # inverted, clean + _, alone, _ = run_tracker(ch1) + _, both, _ = run_tracker(ch1, ch2) + s_alone = score(truth, alone, *EVAL) + s_both = score(truth, both, *EVAL) + assert s_both[0] >= 0.95 and s_both[1] >= 0.98 + assert s_both[0] > s_alone[0] + + def test_combination_never_worse_than_the_good_channel(self): + truth = beat_times(DUR, seed=8) + good = bcg(truth, DUR, noise=100, seed=9) + junk = np.random.default_rng(10).normal(0, 3000, good.size) # unrelated + _, one, _ = run_tracker(good) + _, both, _ = run_tracker(good, junk) + assert score(truth, both, *EVAL)[0] >= score(truth, one, *EVAL)[0] - 0.01 + + def test_combined_signal_uses_aligned_polarity(self): + truth = beat_times(60, seed=11) + a = B.bandpass_decimate(bcg(truth, 60, noise=100, seed=12)) + b = B.bandpass_decimate(bcg(truth, 60, noise=100, seed=13, gain=-1.0, delay=0.03)) + comb = B.combine_channels(a, b) + assert len(comb.aligned) == 2 + z1, z2 = comb.aligned[0][0], comb.aligned[1][0] + assert np.corrcoef(z1, z2)[0, 1] > 0.8 # inverted + delayed, now aligned + assert comb.quality >= max(q for _, q in comb.aligned) + + +class TestOtherSideReference: + def test_common_interference_is_removed(self): + truth = beat_times(DUR, seed=14) + common = interference(DUR) + primary = bcg(truth, DUR, noise=150, seed=15) + common + rng = np.random.default_rng(16) + ref = 0.8 * np.roll(common, 3) + rng.normal(0, 100, common.size) + _, without, _ = run_tracker(primary) + _, with_ref, _ = run_tracker(primary, None, ref) + s0 = score(truth, without, *EVAL) + s1 = score(truth, with_ref, *EVAL) + assert s1[0] >= 0.95 and s1[1] >= 0.98 + assert s1[0] >= s0[0] + + @pytest.mark.parametrize("leak", [0.15, 0.5]) + def test_own_heartbeat_leaking_to_the_other_side_is_not_cancelled(self, leak): + # Sleeping alone: the empty side picks up this sleeper's heartbeat. + # Subtracting it would erase the beats; the verification must refuse. + truth = beat_times(DUR, seed=17) + primary = bcg(truth, DUR, noise=150, seed=18) + ref = bcg(truth, DUR, noise=60, seed=19, gain=leak, delay=0.01, resp_amp=0) + _, det, _ = run_tracker(primary, None, ref) + sens, ppv, _ = score(truth, det, *EVAL) + assert sens >= 0.95 and ppv >= 0.98 + + def test_cancel_common_noise_removes_predictable_part(self): + rng = np.random.default_rng(20) + ref = rng.normal(0, 1, 2000) + target = rng.normal(0, 0.1, 2000) + primary = target + 0.7 * B._shift(ref, 2) + cleaned = B.cancel_common_noise(primary, [ref]) + assert np.std(cleaned - target) < 0.03 + + +class TestPeriodicity: + def test_period_is_the_fundamental_not_a_multiple(self): + truth = beat_times(30, hr=72, rsa_ms=0, jitter_ms=0, seed=21) + x = B.bandpass_decimate(bcg(truth, 30, noise=50, seed=22)) + q, period = B.periodicity(x) + assert q > 0.5 + assert period == pytest.approx(60 / 72, rel=0.03) + + def test_flat_signal_has_no_rhythm(self): + assert B.periodicity(np.zeros(2000)) == (0.0, 0.0) + + @pytest.mark.parametrize("hr", [70, 80]) + def test_a_second_wave_inside_each_beat_is_not_the_period(self, hr): + # At 70 bpm the wave sits at exactly half the period. + truth = beat_times(40, hr=hr, seed=23) + x = bcg(truth, 40, seed=24) + secondary_wave(truth, 40) + q, period = B.periodicity(B.bandpass_decimate(x[10 * FS:30 * FS])) + assert q >= B.MIN_QUALITY + assert period == pytest.approx(60 / hr, rel=0.05) + + def test_prior_confines_the_period_to_the_tracked_rhythm(self): + # A stronger rhythm at 0.7 s next to a 60 bpm heart (neither's + # multiples within TRACK_TOL of the other). + truth = beat_times(40, hr=60, seed=25) + x = B.bandpass_decimate((bcg(truth, 40, seed=26) + + machine_pulses(40, rate=1 / 0.7))[10 * FS:30 * FS]) + _, free = B.periodicity(x) + assert free == pytest.approx(0.7, rel=0.05) + q, period = B.periodicity(x, prior=1.0) + assert q > 0 + assert period == pytest.approx(1.0, rel=0.05) + + def test_nothing_near_the_prior_is_no_rhythm(self): + truth = beat_times(40, hr=60, rsa_ms=0, jitter_ms=0, seed=27) + x = B.bandpass_decimate(bcg(truth, 40, noise=50, seed=28)[10 * FS:30 * FS]) + assert B.periodicity(x, prior=0.6) == (0.0, 0.0) + + def test_every_other_cycle_of_a_fast_rhythm_is_not_a_period(self): + x = B.bandpass_decimate(machine_pulses(40)[10 * FS:30 * FS]) + _, full = B.periodicity(x) + assert full == pytest.approx(1 / 2.3, rel=0.05) + # Above 0.5 s only its 2x and 3x multiples remain: neither counts. + assert B.periodicity(x, min_period=0.5) == (0.0, 0.0) + + +# --------------------------------------------------------------------------- +# Artifact correction +# --------------------------------------------------------------------------- + +def _clean(times): + c = B.BeatCleaner() + out = [] + for t in times: + out.extend(c.push(None if t is None else (t, 1.0))) + return [None if o is None else round(o[0], 3) for o in out] + + +class TestBeatCleaner: + def _regular(self, n, start=0.0, ibi=1.0): + return [start + i * ibi for i in range(n)] + + def test_regular_rhythm_passes_unchanged(self): + ts = self._regular(12) + assert _clean(ts) == ts + + def test_extra_beat_is_removed_without_a_break(self): + ts = self._regular(10) + [9.4] + self._regular(4, start=10.0) + ts = sorted(ts) + out = _clean(ts) + assert 9.4 not in out + assert None not in out + assert out[-1] == 13.0 + + def test_missed_beat_becomes_a_break_not_an_invented_beat(self): + ts = self._regular(10) + self._regular(5, start=11.0) # 10.0 missing + out = _clean(ts) + assert out == self._regular(10) + [None] + self._regular(5, start=11.0) + + def test_ectopic_beat_breaks_the_run(self): + # Premature beat followed by a compensatory pause: neither interval + # is trustworthy, and the pair doesn't sum to a normal interval. + ts = self._regular(10) + [9.6, 11.0, 12.0, 13.0] + out = _clean(ts) + assert None in out + # No reported interval deviates from 1 s by more than 20%. + runs, cur = [], [] + for o in out: + if o is None: + runs.append(cur) + cur = [] + else: + cur.append(o) + runs.append(cur) + for run in runs: + for a, b in zip(run, run[1:]): + assert abs((b - a) - 1.0) <= 0.2 + 1e-9 + + def test_sustained_rate_change_is_adopted(self): + ts = self._regular(10, ibi=1.0) + self._regular(10, start=9.7, ibi=0.7) + out = _clean(ts) + tail = [o for o in out[-5:] if o is not None] + assert len(tail) == 5 and all(abs((b - a) - 0.7) < 1e-6 for a, b in zip(tail, tail[1:])) + + def test_explicit_break_is_passed_through(self): + out = _clean(self._regular(6) + [None] + self._regular(6, start=100.0)) + assert out[6] is None and out[7] == 100.0 + + +# --------------------------------------------------------------------------- +# HRV and chunks +# --------------------------------------------------------------------------- + +class TestBeatHistory: + def test_rmssd_matches_ground_truth(self): + truth = beat_times(360, seed=23) + _, det, _ = run_tracker(bcg(truth, 360, seed=24)) + tr, _, _ = run_tracker(bcg(truth, 360, seed=24)) + rmssd = tr.history.rmssd_ms() + window = truth[truth >= truth[-1] - 300 - 15] + true_rmssd = 1000 * np.sqrt(np.mean(np.diff(np.diff(window)) ** 2)) + assert rmssd == pytest.approx(true_rmssd, rel=0.12) + + def test_heart_rate_matches_ground_truth(self): + truth = beat_times(200, hr=58, seed=25) + tr, _, _ = run_tracker(bcg(truth, 200, seed=26)) + assert tr.history.heart_rate() == pytest.approx(58, abs=2) + + def test_no_difference_across_a_break(self): + h = B.BeatHistory() + items = [(float(i), 1.0) for i in range(40)] + [None] + [(40.5 + i * 0.5, 1.0) for i in range(60)] + h.extend(items) + # Intervals are 1.0 then 0.5; a difference across the break would be 0.5 s. + assert h.rmssd_ms(window_s=300, min_coverage=0.1, min_diffs=5) == pytest.approx(0.0, abs=1e-6) + + def test_insufficient_coverage_gives_no_value(self): + h = B.BeatHistory() + h.extend([(float(i), 1.0) for i in range(30)]) + assert h.rmssd_ms() is None # 30 s of beats in a 300 s window + assert h.heart_rate(window_s=60) is None + + +class TestChunks: + def test_minute_chunks_hold_ms_offsets(self): + truth = beat_times(200, seed=27) + _, det, chunks = run_tracker(bcg(truth, 200, seed=28)) + assert chunks and all(c.start % 60 == 0 for c in chunks) + for c in chunks: + offs = [b for b in c.beats if b is not None] + assert offs == sorted(offs) and all(0 <= b < 60_000 for b in offs) + assert 0 < c.quality <= 1 + starts = [c.start for c in chunks] + assert len(starts) == len(set(starts)) + + def test_signal_gap_leaves_a_break_between_the_beats_around_it(self): + truth = beat_times(240, seed=29) + x = bcg(truth, 240, seed=30) + tr = B.BeatTracker("left") + t0 = 1_700_000_000.0 + chunks = [] + for i in range(0, 120 * FS, FS): + tr.push(t0 + i / FS, x[i:i + FS], None, None, None) + chunks.extend(tr.take_chunks()) + # 30 s of dropped records, then data resumes. + for i in range(150 * FS, 240 * FS - FS + 1, FS): + tr.push(t0 + i / FS, x[i:i + FS], None, None, None) + chunks.extend(tr.take_chunks()) + chunks.extend(tr.take_chunks(flush_before=float("inf"))) + seq = [None if b is None else c.start + b / 1000 for c in chunks for b in c.beats] + last_before = max(i for i, v in enumerate(seq) if v is not None and v < t0 + 120) + first_after = min(i for i, v in enumerate(seq) if v is not None and v > t0 + 150) + assert None in seq[last_before + 1:first_after] + + def test_break_at_a_minute_edge_opens_the_next_chunk(self): + tr = B.BeatTracker("left") + tr._emit([(60.0 + i, 1.0) for i in range(50)]) # minute 60 + tr._emit([None]) + tr._emit([(125.0 + i, 1.0) for i in range(10)]) # minute 120 + chunks = tr.take_chunks(flush_before=float("inf")) + assert [c.start for c in chunks] == [60, 120] + assert chunks[0].beats[-1] is not None + assert chunks[1].beats[0] is None + + +# --------------------------------------------------------------------------- +# Rhythm tracking +# --------------------------------------------------------------------------- + +class TestRhythmTracking: + def test_rate_stays_right_through_a_stronger_rhythm(self): + truth = beat_times(DUR, hr=75, seed=31) + x = bcg(truth, DUR, seed=32) + machine_pulses(DUR, start=80, end=110) + _, detected, chunks = run_tracker(x) + during = chunk_intervals(chunks, 80, 110) + assert during.size > 0 + assert np.all(np.abs(during / 0.8 - 1) <= B.REL_TOL + 0.05) + sens, ppv, _ = score(truth, detected, 120, DUR - 15) + assert sens >= 0.95 and ppv >= 0.95 + + def test_a_fast_rhythm_at_lie_down_is_not_taken_for_the_heart(self): + truth = beat_times(DUR, hr=75, seed=33) + x = bcg(truth, DUR, seed=34) + machine_pulses(DUR, start=0, end=45) + _, detected, chunks = run_tracker(x) + assert np.all(chunk_intervals(chunks, 0, DUR) >= 0.6) + sens, ppv, _ = score(truth, detected, 70, DUR - 15) + assert sens >= 0.95 and ppv >= 0.95 + + def test_a_machine_rhythm_alone_gives_no_beats(self): + x = machine_pulses(120) + np.random.default_rng(35).normal(0, 150, 120 * FS) + _, detected, _ = run_tracker(x) + assert detected.size == 0 + + def test_a_real_rate_change_is_followed(self): + before = beat_times(90, hr=60, seed=36) + after = 90 + beat_times(90, hr=85, start=0.7, seed=37) + truth = np.concatenate([before, after]) + _, detected, _ = run_tracker(bcg(truth, DUR, seed=38)) + for lo, hi in ((15, 88), (120, DUR - 15)): + sens, ppv, _ = score(truth, detected, lo, hi) + assert sens >= 0.95 and ppv >= 0.95 + + def test_a_tracked_rate_can_climb_past_the_start_limit(self): + rng = np.random.default_rng(39) + times, t = [], 2.0 + while t < DUR - 1: + hr = 95 + 35 * min(1.0, max(0.0, (t - 30) / 90)) # 95 -> 130 bpm + times.append(t) + t += 60 / hr + rng.normal(0, 0.01) + truth = np.array(times) + _, detected, _ = run_tracker(bcg(truth, DUR, seed=40)) + sens, ppv, _ = score(truth, detected, 130, DUR - 15) + assert sens >= 0.9 and ppv >= 0.95 + + def test_tracking_only_starts_at_a_resting_rate(self): + truth = beat_times(120, hr=125, seed=41) + _, detected, _ = run_tracker(bcg(truth, 120, seed=42)) + assert detected.size == 0 + + +class TestAnchor: + def test_templates_of_different_lengths_still_align(self): + # Template length follows the period, so consecutive windows often + # differ by a sample or two; the anchor must still carry over. + w = np.asarray(_complex(), dtype=float)[::5] + previous = B.Template(w[5:-5], 1) + # The complex sits 3 samples later in the new window, so the new + # centre is 3 samples earlier on it. + new = B.Template(B._shift(w, 3)[4:-4], 1) + assert B._anchor_lag(previous, new, B.FS) == -3 + + +# --------------------------------------------------------------------------- +# Rate hint (window heart rate guiding the tracker) +# --------------------------------------------------------------------------- + +def _double_bump(duration, hr=64, seed=51): + """A heart whose every beat has a second wave as strong as the first, + half a period later: its envelope repeats every half-beat.""" + per = 60 / hr + truth = beat_times(duration, hr=hr, seed=seed) + return truth, bcg(truth, duration, seed=seed + 1) + secondary_wave(truth, duration, at=per / 2, rel_amp=1.0) + + +def _run_with_hint(x, hint=None, hint_age=0.0, rhythm=None, t0=1_700_000_000.0): + tr = B.BeatTracker("left") + if rhythm is not None: + tr._rhythm.extend(rhythm) + tr._rhythm_at = t0 + chunks = [] + for i in range(0, x.size - FS + 1, FS): + now = t0 + i / FS + if hint is not None and i % (60 * FS) == 0: + tr.set_rate_hint(hint, now - hint_age) + tr.push(now, x[i:i + FS], None, None, None) + chunks.extend(tr.take_chunks()) + chunks.extend(tr.take_chunks(flush_before=float("inf"))) + detected = [c.start + b / 1000 for c in chunks for b in c.beats if b is not None] + return np.array(sorted(detected)) - t0, chunks + + +class TestRateHint: + def test_half_beats_are_the_period_without_a_prior_and_not_with_one(self): + truth, x = _double_bump(40) + seg = B.bandpass_decimate(x[10 * FS:30 * FS]) + assert B.periodicity(seg)[1] == pytest.approx(60 / 64 / 2, rel=0.05) + q, period = B.periodicity(seg, prior=60 / 64) + assert q >= B.MIN_QUALITY + assert period == pytest.approx(60 / 64, rel=0.05) + + def test_hint_tracks_whole_beats_through_double_waves(self): + truth, x = _double_bump(DUR) + detected, _ = _run_with_hint(x, hint=60 / 64) + sens, ppv, _ = score(truth, detected, 30, DUR - 15) + assert sens >= 0.9 and ppv >= 0.95 + + def test_a_tracked_rhythm_the_hint_disagrees_with_is_dropped(self): + # Already following half-beats (as happened on a real night): the + # half-beat rhythm sustains itself unless the hint breaks it. + truth, x = _double_bump(DUR) + half = [60 / 64 / 2] * 5 + stuck, stuck_chunks = _run_with_hint(x, rhythm=half) + assert score(truth, stuck, 30, DUR - 15)[1] <= 0.6 # every other beat false + assert np.median(chunk_intervals(stuck_chunks, 30, DUR)) == pytest.approx(60 / 64 / 2, rel=0.05) + detected, chunks = _run_with_hint(x, hint=60 / 64, rhythm=half) + sens, ppv, _ = score(truth, detected, 30, DUR - 15) + assert sens >= 0.9 and ppv >= 0.95 + assert np.median(chunk_intervals(chunks, 30, DUR)) == pytest.approx(60 / 64, rel=0.05) + + def test_no_tracking_against_a_fresh_hint(self): + # One of the two estimates is wrong; guessing which would risk + # wrong beats, so there are none. + truth = beat_times(120, hr=80, seed=53) + detected, _ = _run_with_hint(bcg(truth, 120, seed=54), hint=1.0) + assert detected.size == 0 + + def test_a_stale_hint_is_ignored(self): + truth = beat_times(DUR, hr=80, seed=53) + detected, _ = _run_with_hint(bcg(truth, DUR, seed=54), hint=1.0, hint_age=B.HINT_MAX_AGE_S + 60) + sens, ppv, _ = score(truth, detected, 30, DUR - 15) + assert sens >= 0.95 and ppv >= 0.95 + + +class TestFollowing: + def _tracker(self, monkeypatch, quality): + tr = B.BeatTracker("left") + tr._rhythm.extend([0.8] * 3) + tr._rhythm_at = 100.0 + monkeypatch.setattr(B, "combine_channels", + lambda c1, c2, fs=B.FS, prior=None, min_period=B.MIN_PERIOD_S: + B.Combined(c1, 0.8, quality, [])) + return tr + + def test_a_weak_window_on_the_tracked_rhythm_still_counts(self, monkeypatch): + assert B.FOLLOW_MIN_QUALITY < B.MIN_QUALITY + tr = self._tracker(monkeypatch, (B.FOLLOW_MIN_QUALITY + B.MIN_QUALITY) / 2) + assert tr._track(105.0, np.ones(10), None) is not None + + def test_below_the_following_bar_yields_nothing(self, monkeypatch): + tr = self._tracker(monkeypatch, B.FOLLOW_MIN_QUALITY - 0.05) + assert tr._track(105.0, np.ones(10), None) is None + + +class TestMaskedRecords: + def _run(self, x, every, masked): + tr = B.BeatTracker("left") + t0 = 1_700_000_000.0 + chunks, blanked = [], [] + for n, i in enumerate(range(0, x.size - FS + 1, FS)): + hit = n % every == every - 1 + if hit: + blanked.append(i / FS) + if hit and not masked: + tr.gap() + else: + tr.push(t0 + i / FS, x[i:i + FS], None, None, None, masked=hit) + chunks.extend(tr.take_chunks()) + chunks.extend(tr.take_chunks(flush_before=float("inf"))) + detected = np.array(sorted(c.start + b / 1000 for c in chunks for b in c.beats if b is not None)) - t0 + return detected, chunks, blanked + + def test_masked_records_are_blanked_without_restarting_windows(self): + truth = beat_times(DUR, hr=70, seed=55) + x = bcg(truth, DUR, seed=56) + detected, chunks, blanked = self._run(x, every=20, masked=True) + gapped, _, _ = self._run(x, every=20, masked=False) + sens_masked = score(truth, detected, 30, DUR - 15)[0] + assert sens_masked >= 0.75 + assert sens_masked > score(truth, gapped, 30, DUR - 15)[0] + 0.3 + for s in blanked: + assert not np.any((detected >= s) & (detected < s + 1)) + + def test_no_interval_spans_a_masked_record(self): + truth = beat_times(DUR, hr=70, seed=55) + _, chunks, blanked = self._run(bcg(truth, DUR, seed=56), every=20, masked=True) + t0 = 1_700_000_000.0 + for c in chunks: + b = c.beats + for i in range(len(b) - 1): + if b[i] is None or b[i + 1] is None: + continue + lo, hi = c.start - t0 + b[i] / 1000, c.start - t0 + b[i + 1] / 1000 + assert not any(lo < s + 1 and hi > s for s in blanked) diff --git a/modules/piezo-processor/test_main.py b/modules/piezo-processor/test_main.py index 4b4f63430..d53947cef 100644 --- a/modules/piezo-processor/test_main.py +++ b/modules/piezo-processor/test_main.py @@ -2007,3 +2007,302 @@ def test_invalid_side_payload_cannot_refresh_empty_evidence(self, monkeypatch, t path = tmp_path / "state.json" detector.save_state(path, [tracker]) self.vitals(path, now, None) + +# =================================================================== +# Beat detection wiring +# =================================================================== + + +def _hb_db(): + import sqlite3 + import main + conn = TestWriteVitalsResilience()._make_db() + conn.execute( + """CREATE TABLE heartbeats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, side TEXT, timestamp INTEGER, + beats TEXT, quality REAL, UNIQUE(side, timestamp))""" + ) + return conn, main.DBHolder(conn) + + +def _hb_rows(conn): + import json + return [(side, ts, json.loads(beats), q) for side, ts, beats, q in conn.execute( + "SELECT side, timestamp, beats, quality FROM heartbeats ORDER BY side, timestamp")] + + +class TestHeartbeatStorage: + def test_write_and_ignore_replayed_minute(self): + import main + from beats import Chunk + conn, holder = _hb_db() + chunk = Chunk(1_790_000_040, [120, 1110, None, 3050], 0.8766) + assert main.write_heartbeats(holder, "left", chunk) is True + assert main.write_heartbeats(holder, "left", chunk) is True # restart replay + assert _hb_rows(conn) == [("left", 1_790_000_040, [120, 1110, None, 3050], 0.877)] + + def test_missing_table_is_reported_not_raised(self): + import main + from beats import Chunk + conn = TestWriteVitalsResilience()._make_db() + assert main.write_heartbeats(main.DBHolder(conn), "left", Chunk(60, [1], 0.5)) is False + + +class TestBeatFrontRouting: + def _front(self, home): + import main + conn, holder = _hb_db() + return main.BeatFront(holder, _Mode(home)), conn + + def test_per_side_writes_each_side(self): + from beats import Chunk + front, conn = self._front(None) + front._write({"left": [Chunk(60, [0, 1000], 0.9)], "right": [Chunk(60, [5, 1005], 0.7)]}) + assert [(r[0], r[1]) for r in _hb_rows(conn)] == [("left", 60), ("right", 60)] + + def test_single_sleeper_keeps_the_better_side_under_home(self): + from beats import Chunk + front, conn = self._front("left") + # Rolled onto the right: its chunk has more beats this minute. + front._write({"left": [Chunk(60, [0, None, 3000], 0.6)], + "right": [Chunk(60, [0, 1000, 2000, 3000], 0.9)]}) + assert _hb_rows(conn) == [("left", 60, [0, 1000, 2000, 3000], 0.9)] + + def _occupied(self, front, left_open, right_open): + """Both sides present, each tracker still open at the given minute.""" + front._was_present = {"left": True, "right": True} + front.trackers["left"]._chunk_start = left_open + front.trackers["right"]._chunk_start = right_open + + def test_single_sleeper_waits_for_the_other_side_to_finish_the_minute(self): + from beats import Chunk + front, conn = self._front("left") + # Left finishes minute 60 first; right is still inside it. + self._occupied(front, 120, 60) + front._write({"left": [Chunk(60, [0, None, 3000], 0.6)], "right": []}) + assert _hb_rows(conn) == [] + # Right finishes it on a later record with the better chunk. + self._occupied(front, 120, 120) + front._write({"left": [], "right": [Chunk(60, [0, 1000, 2000, 3000], 0.9)]}) + assert _hb_rows(conn) == [("left", 60, [0, 1000, 2000, 3000], 0.9)] + + def test_a_minute_only_one_side_saw_is_stored_once_the_other_moves_on(self): + from beats import Chunk + front, conn = self._front("left") + self._occupied(front, 120, None) + front.trackers["right"]._last_committed_end = 100.0 + front._write({"left": [Chunk(60, [0, 1000], 0.8)], "right": []}) + assert _hb_rows(conn) == [] + front.trackers["right"]._last_committed_end = 130.0 + front._write({"left": [], "right": []}) + assert _hb_rows(conn) == [("left", 60, [0, 1000], 0.8)] + + def test_a_held_minute_is_not_kept_past_the_hold_limit(self): + import main + from beats import Chunk + front, conn = self._front("left") + self._occupied(front, 120, 60) # right never moves on + front._write({"left": [Chunk(60, [0, 1000], 0.8)], "right": []}) + front._latest_ts = 120.0 + main.HEARTBEAT_HOLD_MAX_S + 1 + front._write({"left": [], "right": []}) + assert _hb_rows(conn) == [("left", 60, [0, 1000], 0.8)] + + def test_switching_sensor_starts_the_minute_with_a_break(self): + """The two trackers' beat anchors differ, so no interval may span + a minute from one sensor and the next from the other.""" + from beats import Chunk + front, conn = self._front("left") + front._write({"left": [Chunk(60, [0, 1000, 2000], 0.9)], "right": [Chunk(60, [80], 0.5)]}) + front._write({"left": [Chunk(120, [0], 0.5)], "right": [Chunk(120, [80, 1080, 2080], 0.9)]}) + front._write({"left": [], "right": [Chunk(180, [None, 40, 1040], 0.9)]}) + assert [r[2] for r in _hb_rows(conn)] == [[0, 1000, 2000], [None, 80, 1080, 2080], [None, 40, 1040]] + + def test_a_held_minute_keeps_the_home_side_it_was_queued_for(self): + from beats import Chunk + front, conn = self._front("left") + self._occupied(front, 120, 60) # right still inside minute 60 + front._write({"left": [Chunk(60, [0, 1000], 0.8)], "right": []}) + front._mode.home = "right" + self._occupied(front, 120, 120) + front._write({"left": [], "right": []}) + assert _hb_rows(conn) == [("left", 60, [0, 1000], 0.8)] + + def test_leaving_single_sleeper_mode_stores_held_minutes_first(self): + from beats import Chunk + front, conn = self._front("left") + self._occupied(front, 120, 60) + front._write({"left": [Chunk(60, [0, 1000], 0.8)], "right": []}) + front._mode.home = None + front._write({"left": [Chunk(120, [0, 1000], 0.8)], "right": [Chunk(120, [5, 1005], 0.7)]}) + assert [(r[0], r[1]) for r in _hb_rows(conn)] == [("left", 60), ("left", 120), ("right", 120)] + + def test_entering_single_sleeper_mode_breaks_at_a_sensor_switch(self): + """Per-side minutes from the left sensor, then the right sensor wins + under the left side: no interval may span the two.""" + from beats import Chunk + front, conn = self._front(None) + front._write({"left": [Chunk(60, [0, 1000], 0.9)], "right": []}) + front._mode.home = "left" + front._write({"left": [Chunk(120, [0], 0.2)], "right": [Chunk(120, [80, 1080], 0.9)]}) + assert [r[2] for r in _hb_rows(conn)] == [[0, 1000], [None, 80, 1080]] + + def test_leaving_the_bed_stores_the_minute_in_progress(self): + import main + import numpy as np + conn, holder = _hb_db() + here = {"left": True} + front = main.BeatFront(holder, _Mode(None), present=lambda s: here.get(s, False)) + flushes = [] + tr = front.trackers["left"] + tr.push = lambda *a, **k: None + tr.take_chunks = lambda flush_before=None: flushes.append(flush_before) or [] + x = np.zeros(5) + front.push(0.0, x, None, x, None) + here["left"] = False + front.push(1.0, x, None, x, None) + assert flushes == [None, float("inf")] + + def test_a_failed_write_is_retried(self): + import main + from beats import Chunk + conn = TestWriteVitalsResilience()._make_db() + holder = main.DBHolder(conn) + front = main.BeatFront(holder, _Mode(None)) + front._write({"left": [Chunk(60, [0, 1000], 0.8)], "right": []}) + assert holder.write_failures == 1 + conn.execute("CREATE TABLE heartbeats (id INTEGER PRIMARY KEY AUTOINCREMENT, side TEXT, " + "timestamp INTEGER, beats TEXT, quality REAL, UNIQUE(side, timestamp))") + front._write({"left": [], "right": []}) + assert _hb_rows(conn) == [("left", 60, [0, 1000], 0.8)] + assert holder.write_failures == 0 + + def test_beats_are_stored_compactly(self): + import main + from beats import Chunk + conn, holder = _hb_db() + main.write_heartbeats(holder, "left", Chunk(60, [0, None, 1000], 0.8)) + assert conn.execute("SELECT beats FROM heartbeats").fetchone()[0] == "[0,null,1000]" + + def test_both_trackers_get_the_other_side_as_reference(self): + import numpy as np + front, _ = self._front(None) + calls = {} + for side, tr in front.trackers.items(): + tr.push = (lambda s: lambda ts, o1, o2, r1, r2, masked=False: calls.setdefault(s, (o1, r1)))(side) + tr.take_chunks = lambda *a, **k: [] + l1, r1 = np.full(5, 1), np.full(5, 2) + front.push(0.0, l1, None, r1, None) + assert calls["left"][0] is l1 and calls["left"][1] is r1 + assert calls["right"][0] is r1 and calls["right"][1] is l1 + + def _spy(self, front): + calls = {"left": [], "right": []} + for side, tr in front.trackers.items(): + tr.push = (lambda s: lambda *a, masked=False: calls[s].append("masked" if masked else "push"))(side) + tr.gap = (lambda s: lambda: calls[s].append("gap"))(side) + tr.take_chunks = lambda *a, **k: [] + return calls + + def test_only_present_sides_are_tracked(self): + import main + import numpy as np + conn, holder = _hb_db() + front = main.BeatFront(holder, _Mode(None), present=lambda s: s == "right") + calls = self._spy(front) + front.push(0.0, np.zeros(5), None, np.zeros(5), None) + assert calls == {"left": [], "right": ["push"]} + + def test_leaving_the_bed_is_one_gap(self): + import main + import numpy as np + conn, holder = _hb_db() + here = {"left": True} + front = main.BeatFront(holder, _Mode(None), present=lambda s: here.get(s, False)) + calls = self._spy(front) + x = np.zeros(5) + front.push(0.0, x, None, x, None) + here["left"] = False + front.push(1.0, x, None, x, None) + front.push(2.0, x, None, x, None) + here["left"] = True + front.push(3.0, x, None, x, None) + assert calls["left"] == ["push", "gap", "push"] + assert calls["right"] == [] + + def test_a_gated_record_is_pushed_masked_not_as_a_gap(self): + import main + import numpy as np + conn, holder = _hb_db() + front = main.BeatFront(holder, _Mode(None), present=lambda s: True) + calls = self._spy(front) + x = np.zeros(5) + front.push(0.0, x, None, x, None, masked=True) + assert calls == {"left": ["masked"], "right": ["masked"]} + + def test_rate_hint_reaches_that_sides_tracker(self, monkeypatch): + import main + conn, holder = _hb_db() + front = main.BeatFront(holder, _Mode(None)) + monkeypatch.setattr(main.time, "time", lambda: 1234.0) + front.set_rate_hint("right", 0.9) + assert (front.trackers["right"]._hint, front.trackers["right"]._hint_at) == (0.9, 1234.0) + assert front.trackers["left"]._hint is None + + def test_rate_hint_is_timed_by_the_record_not_the_wall_clock(self, monkeypatch): + """Replayed records are hours old; the hint must age with them.""" + import main + import numpy as np + conn, holder = _hb_db() + front = main.BeatFront(holder, _Mode(None), present=lambda s: False) + monkeypatch.setattr(main.time, "time", lambda: 9_999_999.0) + front.push(5000.0, np.zeros(5), None, np.zeros(5), None) + front.set_rate_hint("left", 0.9) + assert front.trackers["left"]._hint_at == 5000.0 + + def test_empty_bed_vibration_writes_no_beats(self): + """A steady mechanical rhythm looks like a pulse to the detector; + with nobody present it must never reach the table.""" + import main + import numpy as np + conn, holder = _hb_db() + front = main.BeatFront(holder, _Mode(None), present=lambda s: False) + t = np.arange(500) / 500.0 + for sec in range(180): + hum = (4e5 * np.sin(2 * np.pi * 1.9 * (t + sec))).astype(np.int32) + front.push(1000.0 + sec, hum, hum, hum, hum) + front.flush() + assert _hb_rows(conn) == [] + + +class TestRateHint: + def test_window_rate_is_passed_to_the_beat_tracker(self, monkeypatch): + """Each window's tracked heart rate guides the beat tracker (as a + period); the vitals themselves are unchanged.""" + import main + from common.biometrics.signal import WindowEstimate + proc = main.SideProcessor("left", main.DBHolder(TestWriteVitalsResilience()._make_db())) + written = [] + proc.sink = lambda cand: written.append(cand) or True + hints = [] + proc.rate_hint = hints.append + monkeypatch.setattr(proc._estimator, "estimate", + lambda now, *a: WindowEstimate(now, 72.0, 40.0, 15.0, 72.0, 0.9, ())) + monkeypatch.setattr(main.PresenceDetector, "update", lambda self, *a, **k: True) + proc.ingest(np.random.default_rng(3).normal(0, 5e5, 15 * main.SAMPLE_RATE)) + assert hints == [pytest.approx(60 / 72)] + assert written[0].heart_rate == 72.0 and written[0].hrv == 40.0 + + def test_no_hint_without_a_heart_rate(self, monkeypatch): + import main + from common.biometrics.signal import WindowEstimate + proc = main.SideProcessor("left", main.DBHolder(TestWriteVitalsResilience()._make_db())) + proc.sink = lambda cand: True + hints = [] + proc.rate_hint = hints.append + monkeypatch.setattr(proc._estimator, "estimate", + lambda now, *a: WindowEstimate(now, None, None, 15.0, None, 0.0, ())) + monkeypatch.setattr(main.PresenceDetector, "update", lambda self, *a, **k: True) + proc.ingest(np.random.default_rng(3).normal(0, 5e5, 15 * main.SAMPLE_RATE)) + assert hints == [] + + diff --git a/scripts/biometrics-archive/merge.ts b/scripts/biometrics-archive/merge.ts index 7f747d307..8170d6416 100644 --- a/scripts/biometrics-archive/merge.ts +++ b/scripts/biometrics-archive/merge.ts @@ -34,6 +34,7 @@ export const TABLES: TableSpec[] = [ vitals_id: 'COALESCE((SELECT m.id FROM main.vitals m JOIN snap.vitals v ON m.side IS v.side AND m.timestamp IS v.timestamp WHERE v.id = s.vitals_id), s.vitals_id)', } }, { table: 'movement', key: ['side', 'timestamp'], mode: 'append' }, + { table: 'heartbeats', key: ['side', 'timestamp'], mode: 'append' }, { table: 'sleep_records', key: ['side', 'entered_bed_at', 'left_bed_at'], mode: 'state' }, { table: 'cap_sense_frames', key: ['side', 'timestamp'], mode: 'append' }, { table: 'bed_temp', key: ['timestamp'], mode: 'append' }, diff --git a/scripts/train-personal-models.py b/scripts/train-personal-models.py new file mode 100755 index 000000000..0b98fa971 --- /dev/null +++ b/scripts/train-personal-models.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Train the personal sleep stage models on this Mac and put the winner on the pod, in one command. + +Chains what already exists: a consistent snapshot of the pod's biometrics +database over SSH (README section 1), the per-minute dataset of pod vitals +with Apple Watch labels (scripts/sleep-stage-dataset.py --from-db), the two +models on a feature contract with leave-one-night-out scoring and the pod's +portable JSON export (docs/research/sleep-stage-calibration/personal-models.py +--export), then PUT /api/sleep-stages/model on the pod with the model whose +pooled kappa is higher. Seconds on Apple silicon; nothing runs on the phone. + +Usage: + scripts/train-personal-models.py --pod 192.168.1.88 --side left + scripts/train-personal-models.py --pod eight-pod.local --side left --contract A --kind logistic --no-upload + scripts/train-personal-models.py --pod ... --snapshot poddb/biometrics.db # reuse a snapshot + scripts/train-personal-models.py --pod ... --keep-snapshot # keep the ~75 MB snapshot for reuse + +The pod needs the sleep-stages model endpoints (sleepypod-core PR #855). The +Watch stages must already be on the pod (scripts/import-watch-health.py). +""" +import argparse +import json +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DATASET = ROOT / 'scripts' / 'sleep-stage-dataset.py' +MODELS = ROOT / 'docs' / 'research' / 'sleep-stage-calibration' / 'personal-models.py' +SNAPSHOT_SCRIPT = """ +import sqlite3 +src = sqlite3.connect('file:/persistent/sleepypod-data/biometrics.db?mode=ro', uri=True) +dst = sqlite3.connect('/persistent/sleepypod-data/biometrics.snapshot.db') +src.backup(dst) +dst.close() +print('snapshot ok') +""" + + +def run(cmd, **kw): + print('+', ' '.join(str(c) for c in cmd), file=sys.stderr) + return subprocess.run([str(c) for c in cmd], check=True, text=True, **kw) + + +def snapshot(pod, work): + """Copy a backup-API snapshot of the pod's biometrics.db to work/biometrics.db.""" + ssh = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '-p', '8822', f'root@{pod}'] + run(ssh + ['python3', '-'], input=SNAPSHOT_SCRIPT) + target = work / 'biometrics.db' + run(['scp', '-o', 'BatchMode=yes', '-P', '8822', f'root@{pod}:/persistent/sleepypod-data/biometrics.snapshot.db', target]) + run(ssh + ['rm', '-f', '/persistent/sleepypod-data/biometrics.snapshot.db']) + return target + + +def kappa_of(output, kind): + """The pooled kappa personal-models.py printed for `kind`, or None.""" + match = re.search(rf'^{kind}: pooled kappa (-?[0-9.]+)', output, re.M) + return float(match.group(1)) if match else None + + +def upload(pod, side, path): + model = json.loads(path.read_text()) + body = json.dumps({'side': side, 'model': model}).encode() + request = urllib.request.Request(f'http://{pod}:3000/api/sleep-stages/model', data=body, method='PUT', + headers={'content-type': 'application/json'}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + detail = error.read().decode(errors='replace') + sys.exit(f'pod refused the model ({error.code}): {detail[:400]}') + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--pod', required=True, help='pod host or IP (ssh on 8822, API on 3000)') + ap.add_argument('--side', choices=('left', 'right'), default='left') + ap.add_argument('--contract', choices=('A', 'B'), default='B', help='feature contract (default B, pod native)') + ap.add_argument('--kind', choices=('logistic', 'forest', 'auto'), default='auto', help='which model to upload (default: higher kappa)') + ap.add_argument('--work', default='personal-models', help='working directory (default ./personal-models)') + ap.add_argument('--snapshot', help='an existing biometrics.db snapshot instead of taking one') + ap.add_argument('--no-upload', action='store_true', help='train and export only') + ap.add_argument('--keep-snapshot', action='store_true', help='leave the ~75 MB database snapshot in the work dir (deleted after a run by default)') + args = ap.parse_args() + + work = Path(args.work) + work.mkdir(parents=True, exist_ok=True) + db = Path(args.snapshot) if args.snapshot else snapshot(args.pod, work) + dataset = work / 'dataset.csv' + try: + run([sys.executable, DATASET, '--from-db', db, '--side', args.side, '--out', dataset]) + finally: + # The snapshot is the only large file this leaves behind; a supplied one is the caller's to keep. + if not args.snapshot and not args.keep_snapshot: + db.unlink(missing_ok=True) + print(f'removed snapshot {db} (pass --keep-snapshot to keep it)', file=sys.stderr) + + out = work / f'contract-{args.contract}' + result = run([MODELS, dataset, '--contract', args.contract, '--export', out], capture_output=True) + print(result.stdout) + scores = {kind: kappa_of(result.stdout, kind) for kind in ('logistic', 'forest')} + kind = args.kind + if kind == 'auto': + ranked = sorted((k for k in scores if scores[k] is not None), key=lambda k: scores[k], reverse=True) + if not ranked: + sys.exit('no model scored; see the output above') + kind = ranked[0] + path = out / f'personal-{kind}.json' + print(f'chosen: {kind} (pooled kappa {scores[kind]}) -> {path}') + if args.no_upload: + return + stored = upload(args.pod, args.side, path) + print(f'pod stored {stored}') + print(f'score it there: curl -X POST http://{args.pod}:3000/api/sleep-stages/model/preview ' + f"-H 'content-type: application/json' -d '{{\"side\":\"{args.side}\",\"nights\":14}}'") + + +if __name__ == '__main__': + main() diff --git a/src/components/Settings/ModelScorecard.tsx b/src/components/Settings/ModelScorecard.tsx new file mode 100644 index 000000000..7b8b0174f --- /dev/null +++ b/src/components/Settings/ModelScorecard.tsx @@ -0,0 +1,92 @@ +'use client' + +import { STAGE_COLORS } from '@/src/components/ds' + +function object(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {} +} +function number(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} +export function modelScore(metrics: unknown) { + const root = object(metrics) + const evaluation = object(root.nestedSelection ?? root.lono) + const rawKappa = number(evaluation.kappa) + const kappa = rawKappa !== null && rawKappa >= -1 && rawKappa <= 1 ? rawKappa : null + const rawBaseline = number(object(root.rules).kappa) + const baseline = rawBaseline !== null && rawBaseline >= -1 && rawBaseline <= 1 ? rawBaseline : null + const recall = object(evaluation.recall) + const nights = number(evaluation.nights) + return { kappa, baseline, recall, nights } +} +function agreement(kappa: number) { + if (kappa < 0) return 'Below chance agreement' + if (kappa <= 0.2) return 'Slight agreement' + if (kappa <= 0.4) return 'Fair agreement' + if (kappa <= 0.6) return 'Moderate agreement' + if (kappa <= 0.8) return 'Substantial agreement' + return 'Near-perfect agreement' +} + +/** Descriptive held-out scores only: never substitute training fit or invented baseline values. */ +export function ModelScorecard({ metrics }: { metrics: unknown }) { + const { kappa, baseline, recall } = modelScore(metrics) + const position = (value: number) => `${Math.max(0, Math.min(1, value)) * 100}%` + return ( + <> +
+
+

κ vs Watch

+ {kappa?.toFixed(2) ?? '—'} +

{kappa === null ? 'Not evaluated' : agreement(kappa)}

+ {baseline !== null && kappa !== null + ?

{`${kappa - baseline >= 0 ? '+' : ''}${(kappa - baseline).toFixed(2)} over rules (${baseline.toFixed(2)})`}

+ :

Rules comparison not included in this model.

} +
+
+

Scale

+
+ {baseline !== null && rules} +
+ {['slight', 'fair', 'moderate', 'substantial', 'near-perfect'].map(label => )} +
+ {baseline !== null && } + {kappa !== null && ( + + 0.7 ? 'right-2' : 'left-2'}`}>This model + + )} +
+
+ slight + fair + moderate + substantial + near-perfect +
+

Per night

+

Per-night scores are not included in this model export.

+
+
+

Matches Watch, by stage

+ {(['wake', 'light', 'deep', 'rem'] as const).map((stage) => { + const raw = number(recall[stage]) + const value = raw !== null && raw >= 0 && raw <= 1 ? raw : null + return ( +
+ {stage === 'rem' ? 'REM' : stage[0].toUpperCase() + stage.slice(1)} + + {value === null ? '—' : `${Math.round(value * 100)}%`} +
+ ) + })} +

Share of Watch epochs in each stage the model labels the same. A dash means the export has no score.

+
+
+

+ κ (kappa) measures agreement with Watch stages beyond chance: 0 is chance-level and 1 is perfect agreement. These descriptive bands are not a measure of clinical accuracy. + {kappa !== null && ' Held-out nights were excluded from fitting the evaluated model.'} +

+ + ) +} diff --git a/src/components/Settings/PersonalModelCard.tsx b/src/components/Settings/PersonalModelCard.tsx new file mode 100644 index 000000000..2e7cc9cf4 --- /dev/null +++ b/src/components/Settings/PersonalModelCard.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useRef, useState } from 'react' +import { trpc } from '@/src/utils/trpc' +import { stageModelSchema } from '@/src/lib/personal-stage-model' +import { Button, Card, CardHeader, InlineError, Toggle } from '@/src/components/ds' + +import { ModelScorecard, modelScore } from './ModelScorecard' + +/** Uploads only save candidates. Activation is a separate, explicit user action. */ +export function PersonalModelCard({ side }: { side: 'left' | 'right' }) { + const fileInput = useRef(null) + const utils = trpc.useUtils() + const model = trpc.sleepStages.getModel.useQuery({ side }) + const library = trpc.sleepStages.listModels.useQuery({ side }) + const [choice, setChoice] = useState(null) + const [fileError, setFileError] = useState(null) + const [reading, setReading] = useState(false) + const [message, setMessage] = useState(null) + const refresh = () => { + void utils.sleepStages.getModel.invalidate({ side }) + void utils.sleepStages.listModels.invalidate({ side }) + void utils.biometrics.getSleepStages.invalidate() + } + const activate = trpc.sleepStages.setModelActive.useMutation({ onSuccess: refresh }) + const select = trpc.sleepStages.selectModel.useMutation({ onSuccess: () => { + setMessage('Model selected and active.') + refresh() + } }) + const upload = trpc.sleepStages.uploadModel.useMutation({ onSuccess: (result) => { + setChoice(String(result.id)) + setMessage('Model saved. Choose “Use this model” to activate it.') + refresh() + } }) + const models = library.data ?? [] + const selectedId = choice ?? String(models.find(m => m.selected)?.id ?? models[0]?.id ?? '') + const selected = models.find(m => String(m.id) === selectedId) + const stored = model.data?.model + const busy = reading || upload.isPending || select.isPending || activate.isPending + const metrics = choice === 'rules' ? undefined : selected ? selected.metrics : stored?.metrics + const score = modelScore(metrics) + const version = choice === 'rules' ? 'Rules only' : selected?.version ?? stored?.version + const kind = selected?.kind ?? stored?.kind + const error = fileError ?? upload.error?.message ?? select.error?.message ?? activate.error?.message ?? library.error?.message ?? model.error?.message + + async function readFile(file: File) { + setFileError(null) + setMessage(null) + setReading(true) + try { + if (file.size > 10 * 1024 * 1024) throw new Error('Choose a model JSON file smaller than 10 MB.') + let value: unknown + try { + value = JSON.parse(await file.text()) + } + catch { throw new Error('This file is not valid JSON. Choose candidate.json from a training run.') } + const parsed = stageModelSchema.safeParse(value) + if (!parsed.success) throw new Error('This JSON is not a supported sleep-stage model. Choose candidate.json from a training run.') + await upload.mutateAsync({ side, model: parsed.data }) + } + catch (error) { + setFileError(error instanceof Error ? error.message : 'Could not upload this model.') + } + finally { setReading(false) } + } + + return ( + + fileInput.current?.click()}>Upload model} /> +

+ {version + ? ( + <> + {version} + {choice !== 'rules' && ` · ${kind === 'forest' ? 'Random forest' : 'Logistic regression'}${score.nights ? ` · ${score.nights} held-out nights` : ''}`} + + ) + : 'None stored. Upload a model from a training run to get started.'} +

+ {version && choice !== 'rules' && } +
+

Switch model

+
+ {models.map(m => ( + + ))} + +
+
+

Choose a model to review its scores, then apply it.

+ +
+
+ { + const file = event.target.files?.[0] + event.target.value = '' + if (file) void readFile(file) + }} + /> + {stored && (!selected || selected.selected) && choice !== 'rules' && ( +
+ Stage nights with this model + { + setMessage(null) + activate.mutate({ side, active }) + }} + /> +
+ )} +

Uploads are saved for this side without replacing or activating another model.

+ {stored &&

{model.data?.active ? 'Nights are staged by the active model; the rules below are the fallback when it cannot stage a night.' : 'Nights are staged by the rules below. Activate a model to use it instead.'}

} + {message &&

{message}

} + {error && {error}} +
+ ) +} diff --git a/src/components/Settings/SleepStageRules.tsx b/src/components/Settings/SleepStageRules.tsx index 9998401f7..03b42f960 100644 --- a/src/components/Settings/SleepStageRules.tsx +++ b/src/components/Settings/SleepStageRules.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import { PersonalModelCard } from './PersonalModelCard' import { RotateCcw } from 'lucide-react' import { trpc } from '@/src/utils/trpc' import { Button, Card, CardHeader, InlineError, SegmentedControl, SelectValue, Skeleton, Slider, Toggle } from '@/src/components/ds' @@ -100,14 +101,19 @@ export function SleepStageRules({ side, onSideChange }: { side: Side, onSideChan {(query.data.pending?.warning ?? query.data.provenance?.warning) && ( {query.data.pending?.warning ?? query.data.provenance?.warning} )} - {/* Toolbar: profile, state, preset, reset */} -
+
+ {`Models and rules below apply to ${side === 'left' ? leftName : rightName}’s side`} +
+ + {/* Toolbar: profile, state, preset, reset */} +
+ {`Profile: ${isDefault ? 'System defaults' : 'Custom rules'}`} {lastChange && ` · changed ${shortDate(lastChange.changedAt)}`} @@ -270,6 +276,11 @@ export function SleepStageRules({ side, onSideChange }: { side: Side, onSideChan ) } +/** + * The personal model stored on the pod for this side (trained on a Mac from the pod's nights with the + * Apple Watch as the label) and the switch that makes it stage the side's nights. The rules below stay + * the fallback for any night the model cannot stage. + */ /** * Debounced preview of the draft. Re-runs when the draft, the saved rules * (after a save) or the night span change; an invalid draft shows no diff --git a/src/components/Settings/tests/ModelScorecard.test.tsx b/src/components/Settings/tests/ModelScorecard.test.tsx new file mode 100644 index 000000000..50cca94d8 --- /dev/null +++ b/src/components/Settings/tests/ModelScorecard.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { ModelScorecard, modelScore } from '../ModelScorecard' + +afterEach(cleanup) +it('uses held-out nested evaluation when available and supports legacy LONO exports', () => { + expect(modelScore({ nestedSelection: { kappa: 0.4 }, lono: { kappa: 0.8 } }).kappa).toBe(0.4) + expect(modelScore({ lono: { kappa: 0.25, nights: 12 } })).toMatchObject({ kappa: 0.25, nights: 12 }) + expect(modelScore({ lono: { kappa: NaN } }).kappa).toBeNull() + expect(modelScore({ lono: { kappa: 2 } }).kappa).toBeNull() +}) +it('does not invent a baseline, per-night bars or stage scores for a summary-only export', () => { + render() + expect(screen.getByLabelText('κ 0.25')).toBeTruthy() + expect(screen.getByText('Fair agreement')).toBeTruthy() + expect(screen.getByText('Rules comparison not included in this model.')).toBeTruthy() + expect(screen.getAllByText('—')).toHaveLength(4) + expect(screen.queryByText('rules')).toBeNull() +}) +it('separates labels vertically and shows real stage recall when supplied', () => { + render() + expect(screen.getByText('rules').className).toContain('top-0') + expect(screen.getByText('This model').className).toContain('left-2') + expect(screen.getByText('+0.11 over rules (0.14)')).toBeTruthy() + expect(screen.getByText('61%')).toBeTruthy() +}) + +it.each([-2, 2, NaN, Infinity])('omits invalid rules kappa %s from the comparison and scale', (kappa) => { + render() + expect(screen.queryByText('rules')).toBeNull() + expect(screen.getByText('Rules comparison not included in this model.')).toBeTruthy() +}) +it.each([-1, 0, 1])('accepts valid rules kappa %s', (kappa) => { + expect(modelScore({ rules: { kappa } }).baseline).toBe(kappa) +}) +it('claims held-out evaluation only when a valid score is available', () => { + const { rerender } = render() + expect(screen.getByText('Not evaluated')).toBeTruthy() + expect(screen.queryByText(/Held-out nights/)).toBeNull() + expect(screen.getByText(/not a measure of clinical accuracy/)).toBeTruthy() + rerender() + expect(screen.getByText(/Held-out nights/)).toBeTruthy() +}) + +it.each([ + [-0.1, 'Below chance agreement'], [0, 'Slight agreement'], [0.5, 'Moderate agreement'], + [0.7, 'Substantial agreement'], [0.9, 'Near-perfect agreement'], +])('describes kappa %s as %s', (kappa, label) => { + render() + expect(screen.getByText(label)).toBeTruthy() +}) diff --git a/src/components/Settings/tests/PersonalModelCard.test.tsx b/src/components/Settings/tests/PersonalModelCard.test.tsx new file mode 100644 index 000000000..24936d0c2 --- /dev/null +++ b/src/components/Settings/tests/PersonalModelCard.test.tsx @@ -0,0 +1,139 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { stageModelSchema, type StageModel } from '@/src/lib/personal-stage-model' +import { PersonalModelCard } from '../PersonalModelCard' + +const mock = vi.hoisted(() => ({ + upload: vi.fn(), select: vi.fn(), active: vi.fn(), invalidate: vi.fn(), + stored: null as StageModel | null, isActive: false, + models: [] as { id: number, version: string, kind: string, selected: boolean, active: boolean, metrics?: Record }[], +})) +vi.mock('@/src/utils/trpc', () => ({ trpc: { + useUtils: () => ({ sleepStages: { getModel: { invalidate: mock.invalidate }, listModels: { invalidate: mock.invalidate } }, biometrics: { getSleepStages: { invalidate: mock.invalidate } } }), + sleepStages: { + getModel: { useQuery: () => ({ data: { model: mock.stored, active: mock.isActive } }) }, + listModels: { useQuery: () => ({ data: mock.models }) }, + uploadModel: { useMutation: (options: { onSuccess: (result: { id: number }) => void }) => ({ mutateAsync: async (input: unknown) => { + const result = await mock.upload(input) + options.onSuccess(result) + return result + } }) }, + selectModel: { useMutation: (options: { onSuccess: () => void }) => ({ mutate: (input: unknown) => { + mock.select(input) + options.onSuccess() + } }) }, + setModelActive: { useMutation: (options: { onSuccess: () => void }) => ({ mutate: (input: unknown) => { + mock.active(input) + options.onSuccess() + } }) }, + }, +} })) +const model = { + version: 'example', kind: 'forest', classes: ['wake', 'light', 'deep', 'rem'], + featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'], + trees: [{ f: [-1], t: [0], l: [-1], r: [-1], v: [[0, 1, 0, 0]] }], + testVectors: [{ inputs: { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0 }, expected: 1 }], +} +function choose(contents: string) { + const file = new File([contents], 'candidate.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { value: () => Promise.resolve(contents) }) + fireEvent.change(screen.getByLabelText('Upload model'), { target: { files: [file] } }) +} +beforeEach(() => { + vi.clearAllMocks() + mock.models = [] + mock.stored = null + mock.isActive = false + mock.upload.mockResolvedValue({ id: 1, version: 'example' }) +}) +afterEach(cleanup) + +it('offers upload with no existing model, saves the JSON for the correct side without activating it', async () => { + render() + choose(JSON.stringify(model)) + await waitFor(() => expect(mock.upload).toHaveBeenCalledWith({ side: 'right', model: { ...model, epochSeconds: 300 } })) + expect(mock.select).not.toHaveBeenCalled() + expect(mock.active).not.toHaveBeenCalled() + expect(await screen.findByRole('status')).toHaveProperty('textContent', 'Model saved. Choose “Use this model” to activate it.') + expect(mock.invalidate).toHaveBeenCalledWith({ side: 'right' }) +}) + +it('rejects malformed JSON and unrelated JSON before upload', async () => { + render() + choose('{bad') + await screen.findByText(/not valid JSON/) + choose('{}') + await screen.findByText(/not a supported sleep-stage model/) + expect(mock.upload).not.toHaveBeenCalled() +}) + +it('requires an explicit action after choosing a saved model', () => { + mock.models = [ + { id: 1, version: 'one', kind: 'forest', selected: true, active: true }, + { id: 2, version: 'two', kind: 'forest', selected: false, active: false }, + ] + render() + fireEvent.click(screen.getByRole('radio', { name: 'two' })) + expect(mock.select).not.toHaveBeenCalled() + expect(screen.getAllByText('two')[0].tagName).toBe('CODE') + fireEvent.click(screen.getByRole('button', { name: 'Use this model' })) + expect(mock.select).toHaveBeenCalledWith({ side: 'left', id: 2, active: true }) + expect(screen.getByRole('status').textContent).toBe('Model selected and active.') +}) + +it('previews rules without disabling the model until applied', () => { + mock.stored = stageModelSchema.parse(model) + mock.isActive = true + render() + fireEvent.click(screen.getByRole('radio', { name: /Rules only/ })) + expect(mock.active).not.toHaveBeenCalled() + expect(screen.queryByText('Not evaluated')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Use rules only' })) + expect(mock.active).toHaveBeenCalledWith({ side: 'right', active: false }) + expect(mock.invalidate).toHaveBeenCalledWith({ side: 'right' }) +}) + +it('toggles the stored model and shows its evaluation when the library is empty', () => { + mock.stored = stageModelSchema.parse({ ...model, metrics: { lono: { kappa: 0.6, nights: 12 } } }) + render() + expect(screen.getByText(/12 held-out nights/)).toBeTruthy() + expect(screen.getByLabelText('κ 0.60')).toBeTruthy() + fireEvent.click(screen.getByRole('switch', { name: 'Stage nights with this model' })) + expect(mock.active).toHaveBeenCalledWith({ side: 'left', active: true }) +}) + +it('does not borrow stored scores for a candidate with no evaluation', () => { + mock.stored = stageModelSchema.parse({ ...model, metrics: { lono: { kappa: 0.6 } } }) + mock.models = [{ id: 2, version: 'unevaluated', kind: 'forest', selected: false, active: false }] + render() + expect(screen.getByText('Not evaluated')).toBeTruthy() + expect(screen.queryByLabelText('κ 0.60')).toBeNull() + expect(screen.queryByRole('switch')).toBeNull() +}) + +it('opens the file picker from the upload button and handles a canceled selection', () => { + render() + const input = screen.getByLabelText('Upload model') + const click = vi.spyOn(input, 'click') + fireEvent.click(screen.getByRole('button', { name: 'Upload model' })) + expect(click).toHaveBeenCalledOnce() + fireEvent.change(input, { target: { files: [] } }) + expect(mock.upload).not.toHaveBeenCalled() +}) + +it('rejects oversized files before reading or uploading them', async () => { + render() + const file = new File([''], 'large.json') + Object.defineProperty(file, 'size', { value: 10 * 1024 * 1024 + 1 }) + fireEvent.change(screen.getByLabelText('Upload model'), { target: { files: [file] } }) + expect(await screen.findByText(/smaller than 10 MB/)).toBeTruthy() + expect(mock.upload).not.toHaveBeenCalled() +}) + +it.each([new Error('Upload unavailable'), 'unknown failure'])('reports upload failure and re-enables upload', async (error) => { + mock.upload.mockRejectedValueOnce(error) + render() + choose(JSON.stringify(model)) + expect(await screen.findByText(error instanceof Error ? error.message : 'Could not upload this model.')).toBeTruthy() + expect((screen.getByRole('button', { name: 'Upload model' }) as HTMLButtonElement).disabled).toBe(false) +}) diff --git a/src/components/Settings/tests/SleepStageRules.test.tsx b/src/components/Settings/tests/SleepStageRules.test.tsx index ab8d37dd9..be9980fe5 100644 --- a/src/components/Settings/tests/SleepStageRules.test.tsx +++ b/src/components/Settings/tests/SleepStageRules.test.tsx @@ -3,7 +3,7 @@ * adjust sliders, saving / resetting / reverting payloads, presets, * validation, and the debounced preview with its per-rule statistics. */ -import { act, fireEvent, render, screen, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_STAGE_CLASSIFIER, @@ -16,8 +16,11 @@ import { const trpcMock = vi.hoisted(() => { const setMutate = vi.fn() const previewMutate = vi.fn() + const activeMutate = vi.fn() const invalidate = vi.fn() const state = { + model: { model: null, uploadedAt: null, active: false } as { model: { version: string, kind: string, metrics?: Record } | null, uploadedAt: number | null, active: boolean }, + activeError: null as { message: string } | null, classifier: null as unknown as StageClassifier, pending: null as { classifier: StageClassifier, generation: number, warning: string | null } | null, movementOnly: null as unknown as StageClassifier['ruleset'], @@ -35,11 +38,17 @@ const trpcMock = vi.hoisted(() => { return { setMutate, previewMutate, + activeMutate, invalidate, state, trpc: { - useUtils: () => ({ sleepStages: { getClassifier: { invalidate } } }), + useUtils: () => ({ sleepStages: { getClassifier: { invalidate }, getModel: { invalidate: vi.fn() }, listModels: { invalidate: vi.fn() } }, biometrics: { getSleepStages: { invalidate: vi.fn() } } }), sleepStages: { + getModel: { useQuery: () => ({ data: state.model }) }, + listModels: { useQuery: () => ({ data: [] }) }, + uploadModel: { useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }) }, + selectModel: { useMutation: () => ({ mutate: vi.fn(), isPending: false }) }, + setModelActive: { useMutation: () => ({ mutate: activeMutate, isPending: false, error: state.activeError }) }, getClassifier: { useQuery: () => ({ data: state.loading || state.queryError @@ -680,4 +689,43 @@ describe('SleepStageRules', () => { expect(screen.getByLabelText('Rule 2 stage')).toBeTruthy() expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true) }) + describe('personal model card', () => { + it('says when no model is stored on the pod', () => { + trpcMock.state.model = { model: null, uploadedAt: null, active: false } + render( {}} />) + expect(screen.getByText(/None stored/)).toBeTruthy() + expect(screen.queryByRole('switch', { name: 'Stage nights with this model' })).toBeNull() + }) + + it('describes an active logistic model without metrics and shows a switch error', () => { + trpcMock.state.model = { model: { version: 'container-logistic-test', kind: 'logistic' }, uploadedAt: 1, active: true } + render( {}} />) + expect(screen.getAllByText('container-logistic-test')[0]).toBeTruthy() + expect(screen.getByText(/Nights are staged by/)).toBeTruthy() + const toggle = screen.getByRole('switch', { name: 'Stage nights with this model' }) + expect(toggle.getAttribute('aria-checked')).toBe('true') + fireEvent.click(toggle) + expect(trpcMock.activeMutate).toHaveBeenCalledWith({ side: 'left', active: false }) + cleanup() + trpcMock.state.activeError = { message: 'No valid personal stage model stored for left' } + trpcMock.state.model = { model: { version: 'v', kind: 'forest', metrics: { lono: { kappa: null, nights: null } } }, uploadedAt: 1, active: false } + render( {}} />) + expect(screen.getByText('No valid personal stage model stored for left')).toBeTruthy() + expect(screen.getByText('v')).toBeTruthy() + trpcMock.state.activeError = null + }) + + it('shows the stored model and switches staging to it', () => { + trpcMock.state.model = { + model: { version: 'container-forest-test', kind: 'forest', metrics: { lono: { kappa: 0.25, nights: 11 } } }, + uploadedAt: 1, + active: false, + } + render( {}} />) + expect(screen.getByText('container-forest-test')).toBeTruthy() + expect(screen.getByLabelText('κ 0.25')).toBeTruthy() + fireEvent.click(screen.getByRole('switch', { name: 'Stage nights with this model' })) + expect(trpcMock.activeMutate).toHaveBeenCalledWith({ side: 'left', active: true }) + }) + }) }) diff --git a/src/components/Sleep/NightView.tsx b/src/components/Sleep/NightView.tsx index 6aa155415..b1742eb09 100644 --- a/src/components/Sleep/NightView.tsx +++ b/src/components/Sleep/NightView.tsx @@ -3,11 +3,10 @@ import { useTimeFormatter } from '@/src/hooks/useTimeFormatter' import { useMemo } from 'react' -import { Activity, Heart, Wind } from 'lucide-react' +import { Activity, BrainCircuit, Heart, Wind } from 'lucide-react' import { Card, InlineError, - LineChart, QualityRing, SectionLabel, Skeleton, @@ -21,7 +20,6 @@ import { cn } from '@/lib/utils' import { SleepRecordActions } from '@/src/components/biometrics/SleepRecordActions' import { average, - downsample, formatDuration, formatNightLong, formatNightShort, @@ -38,6 +36,7 @@ import { type SleepRecordRow, } from './sleepData' import { EmptyNote, NightHypnogram, qualityColor, VitalTile } from './parts' +import { NightVitals } from './NightVitals' import { useWeekNights, useWeekRecords } from './useSleepData' interface NightViewProps { @@ -92,19 +91,22 @@ export function NightView({ side, weekStart, isCurrentWeek, nightKey, onSelectNi exits: record?.timesExitedBed ?? records.find(r => r.id === id)?.timesExitedBed ?? null, quality: epochs.length > 0 ? stages?.qualityScore ?? null : null, distribution: stages?.distribution, + stagedBy: stages?.stagedBy, date: key && record ? keyToDate(key) : start != null ? keyToDate(toNightKey(new Date(start))) : key ? keyToDate(key) : null, - hr: downsample(epochs.map(e => e.heartRate)), - // Same chunking as `hr`, over the epochs that have a reading, so each point knows its time. - hrTimes: downsample(epochs.filter(e => e.heartRate != null).map(e => e.start)), avgHr: average(epochs.map(e => e.heartRate)), avgHrv: average(epochs.map(e => e.hrv)), avgBr: average(epochs.map(e => e.breathingRate)), } }, [stages, record, records, key, timeFormat]) + const referenceRange = { side, startDate: new Date(night.start ?? 0), endDate: new Date(night.end ?? 0) } + const referenceQuery = trpc.biometrics.getReferenceStages.useQuery(referenceRange, { enabled: night.start != null && night.end != null }) + const reference = night.start != null && night.end != null + ? toHypnoBlocks((referenceQuery.data ?? []).map(r => ({ start: new Date(r.start).getTime(), end: new Date(r.end).getTime(), stage: r.stage })), night.start, night.end) + : [] + const selectedKey = key ?? (night.start != null ? toNightKey(new Date(night.start)) : null) const round = (v: number | null) => (v == null ? '—' : Math.round(v)) - const hrValues = epochsHr(night.epochs) return (
@@ -114,34 +116,14 @@ export function NightView({ side, weekStart, isCurrentWeek, nightKey, onSelectNi : ( )} - - 0 && ( - - {`min ${Math.round(Math.min(...hrValues))} · avg ${round(night.avgHr)} · max ${Math.round(Math.max(...hrValues))} bpm`} - - )} - > - Heart rate - Heart rate through the night - - {loading - ?
- : hrValues.length > 1 - ? ( - (night.hrTimes[i] != null ? formatClock(new Date(night.hrTimes[i]), { hour: 'numeric', minute: '2-digit' }) : '')} - format={v => `${Math.round(v)} bpm`} - /> - ) - : No heart-rate data for this night} - + {referenceQuery.error && Could not load Apple Watch sleep stages.} + {night.start != null && night.end != null && }
@@ -184,10 +166,6 @@ export function NightView({ side, weekStart, isCurrentWeek, nightKey, onSelectNi ) } -function epochsHr(epochs: Array<{ heartRate: number | null }>): number[] { - return epochs.map(e => e.heartRate).filter((v): v is number => v != null) -} - interface Night { id: number | null start: number | null @@ -200,9 +178,11 @@ interface Night { quality: number | null distribution?: { wake: number, rem: number, light: number, deep: number } date: Date | null + /** Which algorithm staged the night: the profile's rules, or the side's active personal model. */ + stagedBy?: { engine: 'rules' | 'model', version: string | null } } -function NightCard({ night, error, empty }: { night: Night, error: string | null, empty: boolean }) { +function NightCard({ night, reference, error, empty }: { night: Night, reference: ReturnType, error: string | null, empty: boolean }) { const { formatClock } = useTimeFormatter() if (error) { return ( @@ -253,6 +233,12 @@ function NightCard({ night, error, empty }: { night: Night, error: string | null {night.date ? formatNightLong(night.date) : '—'} {formatDuration(night.asleepSeconds)} {times} + {night.hasStages && night.stagedBy?.engine === 'model' && ( + + + )}
{edit}
@@ -269,19 +255,25 @@ function NightCard({ night, error, empty }: { night: Night, error: string | null
- {night.hasStages + {(night.hasStages || reference.length > 0) ? ( <>
- +
- +
) : Not enough vitals to classify stages for this night} + {reference.length > 0 && ( +
+ Colored bars · sleepypod + Solid line · Apple Watch +
+ )}
{(['awake', 'rem', 'light', 'deep'] as const).map(k => (
diff --git a/src/components/Sleep/NightVitals.tsx b/src/components/Sleep/NightVitals.tsx new file mode 100644 index 000000000..c6a4a2562 --- /dev/null +++ b/src/components/Sleep/NightVitals.tsx @@ -0,0 +1,36 @@ +'use client' + +import { trpc } from '@/src/utils/trpc' +import { Card, InlineError, SectionLabel, Skeleton } from '@/src/components/ds' +import { VitalsExplorer } from '@/src/components/biometrics/VitalsExplorer' +import { VitalsLegend } from '@/src/components/biometrics/VitalsChart' +import { baselineBand, splitSessions } from '@/src/components/biometrics/biometricsLogic' + +/** Nights and Biometrics use the same recorded data, chart, colors and navigation. */ +export function NightVitals({ side, start, end }: { side: 'left' | 'right', start: number, end: number }) { + const range = { side, startDate: new Date(start), endDate: new Date(end) } + const vitals = trpc.biometrics.getVitals.useQuery({ ...range, limit: 100000 }) + const hr = trpc.biometrics.getReferenceVitals.useQuery({ ...range, limit: 20000 }) + const hrv = trpc.biometrics.getReferenceSamples.useQuery({ ...range, metric: 'hrv_sdnn', limit: 20000 }) + const br = trpc.biometrics.getReferenceSamples.useQuery({ ...range, metric: 'breathing_rate', limit: 20000 }) + const movement = trpc.biometrics.getMovementBuckets.useQuery({ ...range, bucketSeconds: 300, limit: 10000 }) + const points = (vitals.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), hr: r.heartRate, hrv: r.hrv, br: r.breathingRate, + provenanceKey: r.provenance?.processingRunId ?? r.processingRunId ?? 'legacy_unknown', hrvMetricId: r.provenance?.hrvMetricId ?? null })) + const reference = (hr.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), hr: r.heartRate })).sort((a, b) => a.t - b.t) + const referenceHrv = (hrv.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) + const referenceBr = (br.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) + return ( + + Vitals through the night + {vitals.error && Could not load this night’s recorded vitals.} + {(hr.error || hrv.error || br.error) && Some Apple Watch readings could not be loaded.} + {vitals.isLoading + ? + : points.length > 0 + ? + :

No recorded vitals for this night.

} + 0} referenceHrv={referenceHrv.length > 0} referenceBr={referenceBr.length > 0} /> + {referenceHrv.length > 0 &&

Watch SDNN and sleepypod’s window-based IBI variability index use different calculations. They cannot be directly converted; compare their trends, not their absolute values.

} +
+ ) +} diff --git a/src/components/Sleep/parts.tsx b/src/components/Sleep/parts.tsx index d383defef..eb5fb93fb 100644 --- a/src/components/Sleep/parts.tsx +++ b/src/components/Sleep/parts.tsx @@ -30,7 +30,8 @@ export function VitalTile({ icon: Icon, iconClassName, value, label, className } } /** Four-lane hypnogram with proportionally placed time ticks. */ -export function NightHypnogram({ blocks, ticks, lane = 26, bar = 18, labels = true }: { +export function NightHypnogram({ blocks, ticks, reference, lane = 26, bar = 18, labels = true }: { + reference?: HypnoBlock[] blocks: HypnoBlock[] ticks?: Tick[] lane?: number @@ -39,7 +40,7 @@ export function NightHypnogram({ blocks, ticks, lane = 26, bar = 18, labels = tr }) { return (
- + {ticks && ticks.length > 0 && (
{labels &&
} diff --git a/src/components/Sleep/tests/NightVitals.test.tsx b/src/components/Sleep/tests/NightVitals.test.tsx new file mode 100644 index 000000000..504ac1680 --- /dev/null +++ b/src/components/Sleep/tests/NightVitals.test.tsx @@ -0,0 +1,46 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { NightVitals } from '../NightVitals' +import { Hypnogram } from '@/src/components/ds' + +const mock = vi.hoisted(() => ({ query: vi.fn(), chart: vi.fn() })) +vi.mock('@/src/utils/trpc', () => ({ trpc: { biometrics: { + getVitals: { useQuery: mock.query }, getReferenceVitals: { useQuery: mock.query }, + getReferenceSamples: { useQuery: mock.query }, getMovementBuckets: { useQuery: mock.query }, +} } })) +vi.mock('@/src/components/biometrics/VitalsExplorer', () => ({ VitalsExplorer: (props: unknown) => { + mock.chart(props) + return
Shared Biometrics chart
+} })) +afterEach(cleanup) +it('uses the same chart and scopes all three Watch metrics to the selected side and night', () => { + mock.query.mockImplementation(input => ({ data: input.metric + ? [{ timestamp: new Date(2000), value: input.metric === 'hrv_sdnn' ? 35 : 14 }] + : input.limit === 100000 + ? [{ timestamp: new Date(1000), heartRate: 60, hrv: 40, breathingRate: 15 }] + : input.bucketSeconds ? [] : [{ timestamp: new Date(2000), heartRate: 62 }] })) + render() + expect(screen.getByText('Shared Biometrics chart')).toBeTruthy() + expect(mock.query).toHaveBeenCalledWith(expect.objectContaining({ side: 'right', metric: 'breathing_rate', startDate: new Date(0), endDate: new Date(10000) })) + expect(mock.chart).toHaveBeenCalledWith(expect.objectContaining({ reference: [{ t: 2000, hr: 62 }], referenceHrv: [{ t: 2000, value: 35 }], referenceBr: [{ t: 2000, value: 14 }] })) +}) +it('reports a failed recorded-vitals request', () => { + mock.query.mockReturnValue({ error: new Error('offline') }) + render() + expect(screen.getByText('Could not load this night’s recorded vitals.')).toBeTruthy() +}) +it('connects adjacent stage transitions but never bridges missing Watch intervals', () => { + render( + + ) + const paths = screen.getByRole('img').querySelectorAll('path') + expect(paths[1].getAttribute('d')).toBe('M 10 65 V 91 H 20') + expect(paths[2].getAttribute('d')).toBe('M 40 39 V 39 H 50') +}) diff --git a/src/components/Sleep/tests/SleepScreen.test.tsx b/src/components/Sleep/tests/SleepScreen.test.tsx index 88ccc3223..46206fb02 100644 --- a/src/components/Sleep/tests/SleepScreen.test.tsx +++ b/src/components/Sleep/tests/SleepScreen.test.tsx @@ -4,6 +4,7 @@ import { SleepScreen } from '../SleepScreen' import { DEFAULT_STAGE_RULESET } from '@/src/lib/sleep-stage-rules' const state = vi.hoisted(() => ({ + reference: vi.fn(), sleep: vi.fn(), stages: vi.fn(), vitals: vi.fn(), @@ -32,6 +33,9 @@ vi.mock('@/src/hooks/useWeekNavigator', () => ({ vi.mock('@/src/utils/trpc', () => ({ trpc: { biometrics: { + getReferenceStages: { useQuery: state.reference }, + getReferenceVitals: { useQuery: () => ({ data: [] }) }, + getReferenceSamples: { useQuery: () => ({ data: [] }) }, getSleepRecords: { useQuery: state.sleep }, getSleepStages: { useQuery: state.stages }, getVitals: { useQuery: state.vitals }, @@ -42,6 +46,7 @@ vi.mock('@/src/utils/trpc', () => ({ settings: { getAll: { useQuery: () => ({ data: undefined }) } }, }, })) +vi.mock('../NightVitals', () => ({ NightVitals: ({ side, start, end }: { side: string, start: number, end: number }) =>
{`${side}:${start}:${end}`}
})) vi.mock('@/src/components/MovementChart/MovementChart', () => ({ MovementChart: () =>
})) vi.mock('@/src/components/biometrics/RawDataButton', () => ({ RawDataButton: () =>
})) vi.mock('@/src/components/biometrics/SleepRecordActions', () => ({ @@ -82,6 +87,7 @@ beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date(2026, 8, 28, 12)) vi.clearAllMocks() + state.reference.mockReturnValue({ data: [] }) state.week.isCurrentWeek = true state.sleep.mockImplementation(({ limit }: { limit: number }) => ({ data: limit === 100 ? MONTH : WEEK, isLoading: false })) state.stages.mockImplementation(({ sleepRecordId }: { sleepRecordId?: number }) => ({ @@ -109,22 +115,28 @@ describe('SleepScreen night view', () => { // Asleep excludes the awake epoch (3 × 5 min). expect(screen.getByText('Asleep').nextSibling?.textContent).toBe('15m') expect(screen.getByText('Bed exits').nextSibling?.textContent).toBe('1') - expect(screen.getByText('min 54 · avg 63 · max 70 bpm')).toBeTruthy() + expect(screen.getByTestId('night-vitals').textContent).toContain('left:') expect(screen.getByTestId('record-actions').textContent).toBe('12') expect(screen.getByTestId('raw-data')).toBeTruthy() }) - it('reads out the heart rate under the pointer with the epoch time', () => { - const { container } = render() - // The heart-rate chart is the filled line (two paths) inside a hoverable box. - const box = [...container.querySelectorAll('svg')].map(s => s.parentElement).find(p => p?.className.includes('relative') && p.querySelectorAll('path').length === 2) as HTMLElement - vi.spyOn(box, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, width: 300, height: 96, right: 300, bottom: 96, toJSON: () => ({}) }) - fireEvent.pointerMove(box, { clientX: 0, clientY: 10 }) - expect(screen.getByTestId('chart-hover').textContent).toBe('11:20 PM · 70 bpm') - fireEvent.pointerMove(box, { clientX: 300, clientY: 10 }) - expect(screen.getByTestId('chart-hover').textContent).toBe('11:35 PM · 66 bpm') - fireEvent.pointerLeave(box) - expect(screen.queryByTestId('chart-hover')).toBeNull() + it('marks a night the personal model staged, and says nothing for the rules', () => { + render() + expect(screen.queryByText('Personal model')).toBeNull() + cleanup() + state.stages.mockImplementation(() => ({ + data: { ...stagesFor(at(22, 23, 20), 12), stagedBy: { engine: 'model', version: 'container-forest-test' } }, + isLoading: false, + })) + render() + expect(screen.getByText('Personal model').getAttribute('title')).toBe('container-forest-test') + cleanup() + state.stages.mockImplementation(() => ({ + data: { ...stagesFor(at(22, 23, 20), 12), stagedBy: { engine: 'model', version: null } }, + isLoading: false, + })) + render() + expect(screen.getByText('Personal model').getAttribute('title')).toBeNull() }) it('opens another night from the This week bars', () => { @@ -216,3 +228,11 @@ describe('SleepScreen week and month views', () => { expect(state.sleep).toHaveBeenLastCalledWith(expect.objectContaining({ startDate: new Date(2026, 7, 1, 6) })) }) }) + +it('overlays imported Watch stages for the selected night and side', () => { + state.reference.mockReturnValue({ data: [{ start: at(22, 23, 20), end: at(23, 0), stage: 'rem' }] }) + render() + expect(screen.getAllByRole('img', { name: 'Apple Watch sleep stages' })).toHaveLength(2) + expect(screen.getByText('Solid line · Apple Watch')).toBeTruthy() + expect(state.reference).toHaveBeenCalledWith(expect.objectContaining({ side: 'left', startDate: at(22, 23, 20) }), { enabled: true }) +}) diff --git a/src/components/biometrics/BiometricsPage.tsx b/src/components/biometrics/BiometricsPage.tsx index aa8b455df..6d49633e7 100644 --- a/src/components/biometrics/BiometricsPage.tsx +++ b/src/components/biometrics/BiometricsPage.tsx @@ -81,6 +81,11 @@ function BiometricsBody({ sectionSwitch, now }: { sectionSwitch?: ReactNode, now // must not sit over this side's pod line while the new query loads. const referenceQ = trpc.biometrics.getReferenceVitals.useQuery({ side, startDate, endDate, limit: 20000 }, { refetchInterval: 5 * 60_000 }) + const watchHrvQ = trpc.biometrics.getReferenceSamples.useQuery({ side, startDate, endDate, metric: 'hrv_sdnn', limit: 20000 }, { refetchInterval: 5 * 60_000 }) + const watchBrQ = trpc.biometrics.getReferenceSamples.useQuery({ side, startDate, endDate, metric: 'breathing_rate', limit: 20000 }, { refetchInterval: 5 * 60_000 }) + const referenceBr = (watchBrQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) + const referenceHrv = (watchHrvQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) + const rows = (vitalsQ.data ?? []) as VitalRow[] const points: VitalPoint[] = rows.map(r => ({ t: new Date(r.timestamp).getTime(), hr: r.heartRate, hrv: r.hrv, br: r.breathingRate, provenanceKey: r.provenance?.processingRunId ?? r.processingRunId ?? 'legacy_unknown', hrvMetricId: r.provenance?.hrvMetricId ?? null })) @@ -270,9 +275,9 @@ function BiometricsBody({ sectionSwitch, now }: { sectionSwitch?: ReactNode, now : sessions.length === 0 && !stall ?
No vitals in this range
: ( - + )} - 0} /> + 0} referenceHrv={referenceHrv.length > 0} bandLabel={bandLabel} stalled={!!stall} reference={reference.length > 0} />
diff --git a/src/components/biometrics/VitalsChart.tsx b/src/components/biometrics/VitalsChart.tsx index 7029394cd..169270ffc 100644 --- a/src/components/biometrics/VitalsChart.tsx +++ b/src/components/biometrics/VitalsChart.tsx @@ -73,8 +73,8 @@ export function referenceInSession(reference: ReferencePoint[], s: { start: numb } /** The reference sample nearest `t`, or null when none is within `tolMs`. */ -export function nearestReference(points: ReferencePoint[], t: number, tolMs = REFERENCE_TOL_MS): ReferencePoint | null { - let best: ReferencePoint | null = null +export function nearestReference(points: T[], t: number, tolMs = REFERENCE_TOL_MS): T | null { + let best: T | null = null for (const p of points) { if (best == null || Math.abs(p.t - t) < Math.abs(best.t - t)) best = p } @@ -85,8 +85,8 @@ export function nearestReference(points: ReferencePoint[], t: number, tolMs = RE export const REFERENCE_GAP_MS = 15 * 60_000 /** Time-sorted samples split into runs with no gap over `gapMs`. */ -export function referenceRuns(points: ReferencePoint[], gapMs = REFERENCE_GAP_MS): ReferencePoint[][] { - const runs: ReferencePoint[][] = [] +export function referenceRuns(points: T[], gapMs = REFERENCE_GAP_MS): T[][] { + const runs: T[][] = [] for (const p of points) { const run = runs[runs.length - 1] if (run && p.t - run[run.length - 1].t <= gapMs) run.push(p) @@ -96,15 +96,17 @@ export function referenceRuns(points: ReferencePoint[], gapMs = REFERENCE_GAP_MS } const REFERENCE_TOL_MS = 5 * 60_000 -const REFERENCE_STROKE = 'var(--text-2)' +const REFERENCE_STROKE = 'var(--text-1)' -export function VitalsChart({ sessions, stall, bands, reference = [], movement = [], onSelectWindow }: { +export function VitalsChart({ sessions, stall, bands, reference = [], referenceHrv = [], referenceBr = [], movement = [], onSelectWindow }: { sessions: Session[] /** In bed with no vitals: from the last reading to now. */ stall: { start: number, end: number } | null bands: Record /** Imported Apple Watch heart rate, time-sorted. */ reference?: ReferencePoint[] + referenceHrv?: Array<{ t: number, value: number }> + referenceBr?: Array<{ t: number, value: number }> movement?: Array<{ bucketStart: Date, eventCount: number }> onSelectWindow?: (window: RangeWindow) => void }) { @@ -164,6 +166,25 @@ export function VitalsChart({ sessions, stall, bands, reference = [], movement = ) })} + {[ + { key: 'hrv', points: referenceHrv, row: 1, title: (value: number) => `Apple Watch SDNN · ${Math.round(value)} ms` }, + { key: 'br', points: referenceBr, row: 2, title: (value: number) => `Apple Watch breathing · ${value.toFixed(1)} br/min` }, + ].map(({ key, points, row, title }) => ( + + {referenceRuns(points.filter(p => p.t >= session.start && p.t <= session.end)).map((run, r) => ( + + {run.length > 1 && ( + axis.xOf(seg, p.t)), run.map(p => yOf(row, p.value)))} fill="none" stroke={REFERENCE_STROKE} strokeWidth={1} strokeDasharray="3 3" strokeOpacity={0.8} /> + )} + {run.map((p, i) => ( + + {title(p.value)} + + ))} + + ))} + + ))} {referenceBySession[seg.index]?.length > 0 && (() => { // Lift the pen across sampling gaps (Watch taken off) and // mark samples with no neighbour, which a path cannot show. @@ -199,7 +220,7 @@ export function VitalsChart({ sessions, stall, bands, reference = [], movement = })()} ) - }), [axis, sessions, bands, smooth, referenceBySession]) + }), [axis, sessions, bands, smooth, referenceBySession, referenceHrv, referenceBr]) return (
@@ -324,6 +345,20 @@ export function VitalsChart({ sessions, stall, bands, reference = [], movement = )} + {hoverSeg && [ + { key: 'hrv', points: referenceHrv, row: 1, label: (v: number) => `Watch SDNN ${Math.round(v)} ms` }, + { key: 'br', points: referenceBr, row: 2, label: (v: number) => `Watch breathing ${v.toFixed(1)} br/min` }, + ].map(({ key, points, row, label }) => { + const sample = nearestReference(points.filter(p => p.t >= hoverSeg.start && p.t <= hoverSeg.end), hovered.t) + if (!sample) return null + const x = axis.xOf(hoverSeg, sample.t) + const flip = x + 180 > plotW + return ( + + {label(sample.value)} + + ) + })} {`${new Date(hovered.t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} · ${clock(hovered.t, timeFormat)}`} @@ -370,16 +405,28 @@ export function VitalsChart({ sessions, stall, bands, reference = [], movement = ) } -export function VitalsLegend({ bandLabel, stalled, reference = false }: { bandLabel: string, stalled: boolean, reference?: boolean }) { +export function VitalsLegend({ bandLabel, stalled, reference = false, referenceHrv = false, referenceBr = false }: { bandLabel: string, stalled: boolean, reference?: boolean, referenceHrv?: boolean, referenceBr?: boolean }) { return (
9-min average + {referenceHrv && ( + + + Apple Watch HRV · SDNN + + )} + {referenceBr && ( + + + Apple Watch breathing + + )} {reference && ( - + Apple Watch heart rate )} diff --git a/src/components/biometrics/tests/BiometricsPage.test.tsx b/src/components/biometrics/tests/BiometricsPage.test.tsx index e15cb5d51..add59c5cd 100644 --- a/src/components/biometrics/tests/BiometricsPage.test.tsx +++ b/src/components/biometrics/tests/BiometricsPage.test.tsx @@ -55,6 +55,7 @@ vi.mock('@/src/utils/trpc', () => ({ }, getFileCount: { useQuery: () => ({ data: { rawFiles: { left: 4, right: 4 }, totalSizeMB: 8.45 } }) }, getMovementBuckets: { useQuery: () => ({ data: [{ bucketStart: new Date(2026, 8, 27, 5), eventCount: 3 }] }) }, + getReferenceSamples: { useQuery: () => ({ data: [] }) }, getReferenceVitals: { useQuery: (_input: unknown, opts: Record) => { state.referenceOpts = opts diff --git a/src/components/biometrics/tests/DataPage.test.tsx b/src/components/biometrics/tests/DataPage.test.tsx index c3077de08..9fcd12692 100644 --- a/src/components/biometrics/tests/DataPage.test.tsx +++ b/src/components/biometrics/tests/DataPage.test.tsx @@ -27,6 +27,10 @@ vi.mock('@/src/hooks/useWeekNavigator', () => ({ vi.mock('@/src/utils/trpc', () => ({ trpc: { biometrics: { + getReferenceStages: { useQuery: () => ({ data: [] }) }, + getReferenceVitals: { useQuery: () => ({ data: [] }) }, + getMovementBuckets: { useQuery: () => ({ data: [] }) }, + getReferenceSamples: { useQuery: () => ({ data: [] }) }, getSleepRecords: { useQuery: state.sleep }, getSleepStages: { useQuery: state.stages }, getVitals: { useQuery: state.vitals }, diff --git a/src/components/biometrics/tests/VitalsChart.test.tsx b/src/components/biometrics/tests/VitalsChart.test.tsx index a30bcea17..ef59d62fb 100644 --- a/src/components/biometrics/tests/VitalsChart.test.tsx +++ b/src/components/biometrics/tests/VitalsChart.test.tsx @@ -217,3 +217,75 @@ it('cancels selection and leaves touch, secondary-click, and Shift gestures unse fireEvent.pointerLeave(overlay, { pointerType: 'mouse' }) expect(screen.queryByTestId('vitals-reference-hover')).toBeNull() }) + +it('draws Watch HRV as distinct samples only within the visible sessions', () => { + withWidth() + render( + + ) + const dot = screen.getByTestId('vitals-watch-hrv') + expect(dot.getAttribute('stroke')).toBe('var(--text-1)') + expect(dot.textContent).toBe('Apple Watch SDNN · 35 ms') +}) + +it('uses small thin neutral circles for Watch breathing samples', () => { + withWidth() + render() + const dot = screen.getByTestId('vitals-watch-br') + expect(dot.tagName).toBe('circle') + expect(dot.getAttribute('r')).toBe('2') + expect(dot.getAttribute('stroke-width')).toBe('1') + expect(dot.getAttribute('stroke')).toBe('var(--text-1)') + expect(dot.textContent).toBe('Apple Watch breathing · 14.5 br/min') +}) + +it.each(['referenceHrv', 'referenceBr'] as const)('joins nearby %s samples but leaves long gaps disconnected', (prop) => { + withWidth() + const points = [10, 15, 45, 50].map(m => ({ t: START + m * MIN, value: 30 })) + render() + const key = prop === 'referenceHrv' ? 'hrv' : 'br' + const lines = screen.getAllByTestId(`vitals-watch-${key}-line`) + expect(lines).toHaveLength(2) + for (const line of lines) { + expect(line.getAttribute('stroke-dasharray')).toBe('3 3') + expect(line.getAttribute('d')?.match(/L/g)).toHaveLength(1) + } + expect(screen.getAllByTestId(`vitals-watch-${key}`)).toHaveLength(4) +}) + +it('exposes Watch SDNN and breathing through the interaction overlay, without bridging gaps', () => { + withWidth() + const { container, rerender } = render( + + ) + const overlay = container.querySelector('rect[style*="touch-action"]') as SVGRectElement + vi.spyOn(overlay, 'getBoundingClientRect').mockReturnValue({ left: 0 } as DOMRect) + fireEvent.pointerMove(overlay, { clientX: 2, clientY: 50 }) + expect(screen.getByTestId('vitals-watch-hrv-hover').textContent).toBe('Watch SDNN 35 ms') + expect(screen.getByTestId('vitals-watch-br-hover').textContent).toBe('Watch breathing 14.5 br/min') + rerender( + + ) + expect(screen.queryByTestId('vitals-watch-hrv-hover')).toBeNull() + expect(screen.queryByTestId('vitals-watch-br-hover')).toBeNull() +}) diff --git a/src/components/ds/charts.tsx b/src/components/ds/charts.tsx index 1d6154a44..db471dd5a 100644 --- a/src/components/ds/charts.tsx +++ b/src/components/ds/charts.tsx @@ -212,8 +212,9 @@ export interface HypnoBlock { } /** Four-lane hypnogram (AWAKE / REM / LIGHT / DEEP). */ -export function Hypnogram({ blocks, lane = 26, bar = 16, labels = true, ticks }: { +export function Hypnogram({ blocks, lane = 26, bar = 16, labels = true, ticks, reference = [] }: { blocks: HypnoBlock[] + reference?: HypnoBlock[] lane?: number bar?: number labels?: boolean @@ -245,6 +246,17 @@ export function Hypnogram({ blocks, lane = 26, bar = 16, labels = true, ticks }: }} /> ))} + {reference.length > 0 && ( + + {reference.map((b, i) => { + const y = (LANE_ORDER.indexOf(b.stage) + 0.5) * lane + const previous = reference[i - 1] + const connected = previous && Math.abs(previous.startPct + previous.widthPct - b.startPct) < 0.001 + const fromY = connected ? (LANE_ORDER.indexOf(previous.stage) + 0.5) * lane : y + return + })} + + )}
{ticks && labels &&
} {ticks && ( diff --git a/src/db/biometrics-migrations/0033_heartbeats.sql b/src/db/biometrics-migrations/0033_heartbeats.sql new file mode 100644 index 000000000..da893529a --- /dev/null +++ b/src/db/biometrics-migrations/0033_heartbeats.sql @@ -0,0 +1,10 @@ +CREATE TABLE `heartbeats` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `side` text NOT NULL, + `timestamp` integer NOT NULL, + `beats` text NOT NULL, + `quality` real +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uq_heartbeats_side_timestamp` ON `heartbeats` (`side`,`timestamp`);--> statement-breakpoint +CREATE INDEX `idx_heartbeats_timestamp` ON `heartbeats` (`timestamp`); \ No newline at end of file diff --git a/src/db/biometrics-migrations/meta/0033_snapshot.json b/src/db/biometrics-migrations/meta/0033_snapshot.json new file mode 100644 index 000000000..53e2bea8e --- /dev/null +++ b/src/db/biometrics-migrations/meta/0033_snapshot.json @@ -0,0 +1,3144 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4b77feb-8340-42a9-a712-2f84b7960aaa", + "prevId": "8d572e89-425e-4ebe-a930-4a353fde8ded", + "tables": { + "ambient_light": { + "name": "ambient_light", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lux": { + "name": "lux", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_ambient_light_timestamp": { + "name": "idx_ambient_light_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bed_temp": { + "name": "bed_temp", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ambient_temp": { + "name": "ambient_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcu_temp": { + "name": "mcu_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "humidity": { + "name": "humidity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_outer_temp": { + "name": "left_outer_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_center_temp": { + "name": "left_center_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_inner_temp": { + "name": "left_inner_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_outer_temp": { + "name": "right_outer_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_center_temp": { + "name": "right_center_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_inner_temp": { + "name": "right_inner_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_bed_temp_timestamp": { + "name": "idx_bed_temp_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_backtest_jobs": { + "name": "biometric_backtest_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "experiment_id": { + "name": "experiment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_expires_at_ms": { + "name": "lease_expires_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fencing_token": { + "name": "fencing_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heartbeat_at_ms": { + "name": "heartbeat_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cancellation_requested_at_ms": { + "name": "cancellation_requested_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "biometric_backtest_jobs_idempotency_key_unique": { + "name": "biometric_backtest_jobs_idempotency_key_unique", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_biometric_job_state_created": { + "name": "idx_biometric_job_state_created", + "columns": [ + "state", + "created_at_ms" + ], + "isUnique": false + }, + "idx_biometric_job_experiment": { + "name": "idx_biometric_job_experiment", + "columns": [ + "experiment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "biometric_backtest_jobs_experiment_id_biometric_experiments_id_fk": { + "name": "biometric_backtest_jobs_experiment_id_biometric_experiments_id_fk", + "tableFrom": "biometric_backtest_jobs", + "tableTo": "biometric_experiments", + "columnsFrom": [ + "experiment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_backtest_results": { + "name": "biometric_backtest_results", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fencing_token": { + "name": "fencing_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at_ms": { + "name": "released_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "biometric_backtest_results_job_id_biometric_backtest_jobs_id_fk": { + "name": "biometric_backtest_results_job_id_biometric_backtest_jobs_id_fk", + "tableFrom": "biometric_backtest_results", + "tableTo": "biometric_backtest_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_dataset_raw_artifacts": { + "name": "biometric_dataset_raw_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dataset_id": { + "name": "dataset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_hash": { + "name": "artifact_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at_ms": { + "name": "released_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_biometric_dataset_raw_identity": { + "name": "idx_biometric_dataset_raw_identity", + "columns": [ + "dataset_id", + "artifact_hash" + ], + "isUnique": true + }, + "idx_biometric_dataset_raw_position": { + "name": "idx_biometric_dataset_raw_position", + "columns": [ + "dataset_id", + "position" + ], + "isUnique": true + }, + "idx_biometric_raw_artifact_references": { + "name": "idx_biometric_raw_artifact_references", + "columns": [ + "artifact_hash", + "released_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": { + "biometric_dataset_raw_artifacts_dataset_id_biometric_datasets_id_fk": { + "name": "biometric_dataset_raw_artifacts_dataset_id_biometric_datasets_id_fk", + "tableFrom": "biometric_dataset_raw_artifacts", + "tableTo": "biometric_datasets", + "columnsFrom": [ + "dataset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "biometric_dataset_raw_artifacts_artifact_hash_biometric_raw_artifacts_content_hash_fk": { + "name": "biometric_dataset_raw_artifacts_artifact_hash_biometric_raw_artifacts_content_hash_fk", + "tableFrom": "biometric_dataset_raw_artifacts", + "tableTo": "biometric_raw_artifacts", + "columnsFrom": [ + "artifact_hash" + ], + "columnsTo": [ + "content_hash" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_datasets": { + "name": "biometric_datasets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byte_length": { + "name": "byte_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at_ms": { + "name": "released_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "biometric_datasets_idempotency_key_unique": { + "name": "biometric_datasets_idempotency_key_unique", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_biometric_dataset_side_created": { + "name": "idx_biometric_dataset_side_created", + "columns": [ + "side", + "created_at_ms", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_experiments": { + "name": "biometric_experiments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dataset_id": { + "name": "dataset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_hash": { + "name": "protocol_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "holdout_accesses": { + "name": "holdout_accesses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "biometric_experiments_idempotency_key_unique": { + "name": "biometric_experiments_idempotency_key_unique", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_biometric_experiment_dataset": { + "name": "idx_biometric_experiment_dataset", + "columns": [ + "dataset_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "biometric_experiments_dataset_id_biometric_datasets_id_fk": { + "name": "biometric_experiments_dataset_id_biometric_datasets_id_fk", + "tableFrom": "biometric_experiments", + "tableTo": "biometric_datasets", + "columnsFrom": [ + "dataset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_holdout_accesses": { + "name": "biometric_holdout_accesses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_ms": { + "name": "start_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_ms": { + "name": "end_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessed_at_ms": { + "name": "accessed_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_biometric_holdout_side_start": { + "name": "idx_biometric_holdout_side_start", + "columns": [ + "side", + "start_ms" + ], + "isUnique": false + } + }, + "foreignKeys": { + "biometric_holdout_accesses_job_id_biometric_backtest_jobs_id_fk": { + "name": "biometric_holdout_accesses_job_id_biometric_backtest_jobs_id_fk", + "tableFrom": "biometric_holdout_accesses", + "tableTo": "biometric_backtest_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_processing_runs": { + "name": "biometric_processing_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bundle": { + "name": "bundle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_hash": { + "name": "config_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calibration_hash": { + "name": "calibration_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_identity": { + "name": "input_identity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at_ms": { + "name": "ended_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_biometric_processing_side_start": { + "name": "idx_biometric_processing_side_start", + "columns": [ + "side", + "started_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_raw_artifacts": { + "name": "biometric_raw_artifacts", + "columns": { + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compressed_bytes": { + "name": "compressed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expanded_bytes": { + "name": "expanded_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at_ms": { + "name": "released_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "biometric_raw_artifacts_name_unique": { + "name": "biometric_raw_artifacts_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_raw_sources": { + "name": "biometric_raw_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_name": { + "name": "source_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_hash": { + "name": "artifact_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "descriptor": { + "name": "descriptor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inventory_version": { + "name": "inventory_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inventory": { + "name": "inventory", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_event_ms": { + "name": "first_event_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_event_ms": { + "name": "last_event_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspected_at_ms": { + "name": "inspected_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unavailable_at_ms": { + "name": "unavailable_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_biometric_raw_source_identity": { + "name": "idx_biometric_raw_source_identity", + "columns": [ + "source_name", + "artifact_hash", + "inventory_version" + ], + "isUnique": true + }, + "idx_biometric_raw_source_window": { + "name": "idx_biometric_raw_source_window", + "columns": [ + "first_event_ms", + "last_event_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_runtime_configs": { + "name": "biometric_runtime_configs", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "boundary": { + "name": "boundary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_runtime_config": { + "name": "uq_biometric_runtime_config", + "columns": [ + "side", + "generation" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_runtime_receipts": { + "name": "biometric_runtime_receipts", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_hash": { + "name": "applied_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_sample_time_ms": { + "name": "effective_sample_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heartbeat_at_ms": { + "name": "heartbeat_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_runtime_receipt": { + "name": "uq_biometric_runtime_receipt", + "columns": [ + "side", + "component", + "generation" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_shadow_runs": { + "name": "biometric_shadow_runs", + "columns": { + "worker_receipt": { + "name": "worker_receipt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worker_receipt_hash": { + "name": "worker_receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_hash": { + "name": "plan_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heartbeat_at_ms": { + "name": "heartbeat_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at_ms": { + "name": "ended_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "point_count": { + "name": "point_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "byte_count": { + "name": "byte_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "biometric_shadow_runs_idempotency_key_unique": { + "name": "biometric_shadow_runs_idempotency_key_unique", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_biometric_shadow_side_state": { + "name": "idx_biometric_shadow_side_state", + "columns": [ + "side", + "state" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_shadow_samples": { + "name": "biometric_shadow_samples", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp_ms": { + "name": "timestamp_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byte_length": { + "name": "byte_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_shadow_sample": { + "name": "uq_biometric_shadow_sample", + "columns": [ + "run_id", + "sequence" + ], + "isUnique": true + }, + "idx_biometric_shadow_sample_time": { + "name": "idx_biometric_shadow_sample_time", + "columns": [ + "run_id", + "timestamp_ms" + ], + "isUnique": false + } + }, + "foreignKeys": { + "biometric_shadow_samples_run_id_biometric_shadow_runs_id_fk": { + "name": "biometric_shadow_samples_run_id_biometric_shadow_runs_id_fk", + "tableFrom": "biometric_shadow_samples", + "tableTo": "biometric_shadow_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calibration_profiles": { + "name": "calibration_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_window_start": { + "name": "source_window_start", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_window_end": { + "name": "source_window_end", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "samples_used": { + "name": "samples_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_cal_type_status": { + "name": "idx_cal_type_status", + "columns": [ + "sensor_type", + "status" + ], + "isUnique": false + }, + "uq_cal_side_type_active": { + "name": "uq_cal_side_type_active", + "columns": [ + "side", + "sensor_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calibration_runs": { + "name": "calibration_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_window_start": { + "name": "source_window_start", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_window_end": { + "name": "source_window_end", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "samples_used": { + "name": "samples_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_cal_runs_side_type": { + "name": "idx_cal_runs_side_type", + "columns": [ + "side", + "sensor_type", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cap_sense_frames": { + "name": "cap_sense_frames", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zones": { + "name": "zones", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max": { + "name": "max", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mean": { + "name": "mean", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spread": { + "name": "spread", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peak_zone": { + "name": "peak_zone", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "frame_count": { + "name": "frame_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_counts": { + "name": "status_counts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_cap_sense_frames_side_ts": { + "name": "uq_cap_sense_frames_side_ts", + "columns": [ + "side", + "timestamp" + ], + "isUnique": true + }, + "idx_cap_sense_frames_timestamp": { + "name": "idx_cap_sense_frames_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "flash_write_samples": { + "name": "flash_write_samples", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "boot_id": { + "name": "boot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sectors_written": { + "name": "sectors_written", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_flash_write_samples_timestamp": { + "name": "idx_flash_write_samples_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "flow_readings": { + "name": "flow_readings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "left_flowrate_cd": { + "name": "left_flowrate_cd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_flowrate_cd": { + "name": "right_flowrate_cd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_pump_rpm": { + "name": "left_pump_rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_pump_rpm": { + "name": "right_pump_rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_flow_readings_timestamp": { + "name": "idx_flow_readings_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "freezer_temp": { + "name": "freezer_temp", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ambient_temp": { + "name": "ambient_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heatsink_temp": { + "name": "heatsink_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_water_temp": { + "name": "left_water_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_water_temp": { + "name": "right_water_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_freezer_temp_timestamp": { + "name": "idx_freezer_temp_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "health_runs": { + "name": "health_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_health_runs_check_started": { + "name": "idx_health_runs_check_started", + "columns": [ + "check_id", + "started_at" + ], + "isUnique": false + }, + "idx_health_runs_last_seen": { + "name": "idx_health_runs_last_seen", + "columns": [ + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "heartbeats": { + "name": "heartbeats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "beats": { + "name": "beats", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quality": { + "name": "quality", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_heartbeats_side_timestamp": { + "name": "uq_heartbeats_side_timestamp", + "columns": [ + "side", + "timestamp" + ], + "isUnique": true + }, + "idx_heartbeats_timestamp": { + "name": "idx_heartbeats_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "movement": { + "name": "movement", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_movement": { + "name": "total_movement", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_movement_side_timestamp": { + "name": "idx_movement_side_timestamp", + "columns": [ + "side", + "timestamp" + ], + "isUnique": false + }, + "idx_movement_timestamp": { + "name": "idx_movement_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prime_events": { + "name": "prime_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_prime_events_timestamp": { + "name": "idx_prime_events_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pump_alerts": { + "name": "pump_alerts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flowrate_cd": { + "name": "flowrate_cd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "restore_target_temperature": { + "name": "restore_target_temperature", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "restore_duration_seconds": { + "name": "restore_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_pump_alerts_timestamp": { + "name": "idx_pump_alerts_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_pump_alerts_acknowledged": { + "name": "idx_pump_alerts_acknowledged", + "columns": [ + "acknowledged_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reference_imports": { + "name": "reference_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supersedes_import_id": { + "name": "supersedes_import_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "adapter_version": { + "name": "adapter_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_policy": { + "name": "time_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overlap_policy": { + "name": "overlap_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'exclude_conflicts'" + }, + "accepted_count": { + "name": "accepted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rejected_count": { + "name": "rejected_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at_ms": { + "name": "deleted_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_reference_import_active_root": { + "name": "uq_reference_import_active_root", + "columns": [ + "content_hash" + ], + "isUnique": true, + "where": "\"reference_imports\".\"deleted_at_ms\" IS NULL AND \"reference_imports\".\"supersedes_import_id\" IS NULL" + }, + "uq_reference_import_active_correction": { + "name": "uq_reference_import_active_correction", + "columns": [ + "content_hash", + "supersedes_import_id" + ], + "isUnique": true, + "where": "\"reference_imports\".\"deleted_at_ms\" IS NULL" + }, + "uq_reference_import_revision": { + "name": "uq_reference_import_revision", + "columns": [ + "family_id", + "revision" + ], + "isUnique": true + }, + "idx_reference_import_side_created": { + "name": "idx_reference_import_side_created", + "columns": [ + "side", + "created_at_ms", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reference_measurements": { + "name": "reference_measurements", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_key": { + "name": "record_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_ms": { + "name": "start_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_ms": { + "name": "end_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temporal_kind": { + "name": "temporal_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregation": { + "name": "aggregation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "window_ms": { + "name": "window_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality": { + "name": "quality", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scorable": { + "name": "scorable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exclusions": { + "name": "exclusions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original": { + "name": "original", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_reference_measurement_key": { + "name": "uq_reference_measurement_key", + "columns": [ + "import_id", + "record_key" + ], + "isUnique": true + }, + "idx_reference_measurement_side_metric_start": { + "name": "idx_reference_measurement_side_metric_start", + "columns": [ + "side", + "metric", + "start_ms" + ], + "isUnique": false + }, + "idx_reference_measurement_import_id": { + "name": "idx_reference_measurement_import_id", + "columns": [ + "import_id", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reference_measurements_import_id_reference_imports_id_fk": { + "name": "reference_measurements_import_id_reference_imports_id_fk", + "tableFrom": "reference_measurements", + "tableTo": "reference_imports", + "columnsFrom": [ + "import_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reference_samples": { + "name": "reference_samples", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_reference_samples_side_metric_timestamp_source": { + "name": "uq_reference_samples_side_metric_timestamp_source", + "columns": [ + "side", + "metric", + "timestamp", + "source" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reference_stages": { + "name": "reference_stages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end": { + "name": "end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_reference_stages_side_start_source": { + "name": "uq_reference_stages_side_start_source", + "columns": [ + "side", + "start", + "source" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reference_vitals": { + "name": "reference_vitals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heart_rate": { + "name": "heart_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_reference_vitals_side_timestamp_source": { + "name": "uq_reference_vitals_side_timestamp_source", + "columns": [ + "side", + "timestamp", + "source" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sleep_records": { + "name": "sleep_records", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entered_bed_at": { + "name": "entered_bed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "left_bed_at": { + "name": "left_bed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sleep_duration_seconds": { + "name": "sleep_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "times_exited_bed": { + "name": "times_exited_bed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "present_intervals": { + "name": "present_intervals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "not_present_intervals": { + "name": "not_present_intervals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sleep_records_side_entered": { + "name": "idx_sleep_records_side_entered", + "columns": [ + "side", + "entered_bed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thermal_state": { + "name": "thermal_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_powered": { + "name": "is_powered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_temp_f": { + "name": "target_temp_f", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_temp_f": { + "name": "current_temp_f", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_thermal_state_timestamp": { + "name": "idx_thermal_state_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vitals": { + "name": "vitals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heart_rate": { + "name": "heart_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hrv": { + "name": "hrv", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "breathing_rate": { + "name": "breathing_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_run_id": { + "name": "processing_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_vitals_side_timestamp": { + "name": "uq_vitals_side_timestamp", + "columns": [ + "side", + "timestamp" + ], + "isUnique": true + }, + "idx_vitals_timestamp": { + "name": "idx_vitals_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vitals_quality": { + "name": "vitals_quality", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "vitals_id": { + "name": "vitals_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "flags": { + "name": "flags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hr_raw": { + "name": "hr_raw", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_vq_vitals_id": { + "name": "idx_vq_vitals_id", + "columns": [ + "vitals_id" + ], + "isUnique": false + }, + "idx_vq_side_ts": { + "name": "idx_vq_side_ts", + "columns": [ + "side", + "timestamp" + ], + "isUnique": false + }, + "idx_vq_timestamp": { + "name": "idx_vq_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "water_level_alerts": { + "name": "water_level_alerts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_water_level_alerts_dismissed": { + "name": "idx_water_level_alerts_dismissed", + "columns": [ + "dismissed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "water_level_readings": { + "name": "water_level_readings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "calibrated_empty": { + "name": "calibrated_empty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "calibrated_full": { + "name": "calibrated_full", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_water_level_timestamp": { + "name": "idx_water_level_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/biometrics-migrations/meta/_journal.json b/src/db/biometrics-migrations/meta/_journal.json index 4010c655c..84e43f0c1 100644 --- a/src/db/biometrics-migrations/meta/_journal.json +++ b/src/db/biometrics-migrations/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1792344051715, "tag": "0032_reference_samples", "breakpoints": true + }, + { + "idx": 33, + "version": "6", + "when": 1792364530317, + "tag": "0033_heartbeats", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/biometrics-schema.ts b/src/db/biometrics-schema.ts index f3c3d3fa9..53f69f4c1 100644 --- a/src/db/biometrics-schema.ts +++ b/src/db/biometrics-schema.ts @@ -29,6 +29,26 @@ export const vitals = sqliteTable('vitals', { index('idx_vitals_timestamp').on(t.timestamp), ]) +// Detected heartbeat times, one row per side per ~60 s chunk, written by the +// piezo-processor's beat detector. Beats are stored rather than intervals so +// discontinuities are explicit: `beats` is a JSON array of millisecond offsets +// from `timestamp`, where `null` marks a break (rejected beat, missed beat or +// signal gap) — no interval may be computed across it. Sleep staging turns +// these into RR intervals for its HRV features, so a better model later can +// re-score past nights without re-reading raw sensor data. +export const heartbeats = sqliteTable('heartbeats', { + id: integer('id').primaryKey({ autoIncrement: true }), + side: text('side', { enum: ['left', 'right'] }).notNull(), + timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(), // chunk start + beats: text('beats', { mode: 'json' }).notNull(), // (number | null)[] ms offsets + quality: real('quality'), // 0–1 mean beat confidence over the chunk +}, t => [ + // Restart replays the current RAW file; the writer ignores conflicts. + uniqueIndex('uq_heartbeats_side_timestamp').on(t.side, t.timestamp), + // See idx_vitals_timestamp — retention pruning needs a timestamp seek. + index('idx_heartbeats_timestamp').on(t.timestamp), +]) + export const sleepRecords = sqliteTable('sleep_records', { id: integer('id').primaryKey({ autoIncrement: true }), side: text('side', { enum: ['left', 'right'] }).notNull(), diff --git a/src/db/migrations/0030_side_stage_models.sql b/src/db/migrations/0030_side_stage_models.sql new file mode 100644 index 000000000..c5941dc38 --- /dev/null +++ b/src/db/migrations/0030_side_stage_models.sql @@ -0,0 +1,6 @@ +CREATE TABLE `side_stage_models` ( + `side` text PRIMARY KEY NOT NULL, + `model` text NOT NULL, + `version` text NOT NULL, + `uploaded_at` integer NOT NULL +); diff --git a/src/db/migrations/0031_side_stage_models_active.sql b/src/db/migrations/0031_side_stage_models_active.sql new file mode 100644 index 000000000..8a0d3f397 --- /dev/null +++ b/src/db/migrations/0031_side_stage_models_active.sql @@ -0,0 +1 @@ +ALTER TABLE `side_stage_models` ADD `active` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/0032_curious_robin_chapel.sql b/src/db/migrations/0032_curious_robin_chapel.sql new file mode 100644 index 000000000..d325827a8 --- /dev/null +++ b/src/db/migrations/0032_curious_robin_chapel.sql @@ -0,0 +1,12 @@ +CREATE TABLE `stage_model_library` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `side` text NOT NULL, + `model` text NOT NULL, + `version` text NOT NULL, + `uploaded_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `stage_model_library_side_idx` ON `stage_model_library` (`side`); +--> statement-breakpoint +INSERT INTO `stage_model_library` (`side`, `model`, `version`, `uploaded_at`) +SELECT `side`, `model`, `version`, `uploaded_at` FROM `side_stage_models`; diff --git a/src/db/migrations/meta/0030_snapshot.json b/src/db/migrations/meta/0030_snapshot.json new file mode 100644 index 000000000..70bf85ee5 --- /dev/null +++ b/src/db/migrations/meta/0030_snapshot.json @@ -0,0 +1,2031 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "27e3a7e7-b7d5-4c53-83c9-e75dc76f3343", + "prevId": "c880c477-91bf-47e7-b901-e39bc7651a83", + "tables": { + "alarm_schedules": { + "name": "alarm_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_intensity": { + "name": "vibration_intensity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_pattern": { + "name": "vibration_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'rise'" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alarm_temperature": { + "name": "alarm_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_window": { + "name": "wake_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_alarm_schedules_side_day": { + "name": "idx_alarm_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation_fired": { + "name": "idx_automation_runs_automation_fired", + "columns": [ + "automation_id", + "fired_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cooldown_min": { + "name": "cooldown_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_automations_enabled": { + "name": "idx_automations_enabled", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "base_schedules": { + "name": "base_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "preset_name": { + "name": "preset_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Custom'" + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head": { + "name": "head", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feet": { + "name": "feet", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feed_rate": { + "name": "feed_rate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 50 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "base_schedules_day_time_side": { + "name": "base_schedules_day_time_side", + "columns": [ + "day_of_week", + "time", + "side" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_activation_events": { + "name": "biometric_activation_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_revision_id": { + "name": "previous_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_revision_id": { + "name": "next_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "previous_hash": { + "name": "previous_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_hash": { + "name": "next_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evaluation_id": { + "name": "evaluation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_activation_generation": { + "name": "uq_biometric_activation_generation", + "columns": [ + "side", + "generation" + ], + "isUnique": true + }, + "uq_biometric_activation_idempotency": { + "name": "uq_biometric_activation_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_config_outbox": { + "name": "biometric_config_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at_ms": { + "name": "next_attempt_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at_ms": { + "name": "delivered_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_outbox_generation": { + "name": "uq_biometric_outbox_generation", + "columns": [ + "side", + "generation" + ], + "isUnique": true + }, + "idx_biometric_outbox_pending": { + "name": "idx_biometric_outbox_pending", + "columns": [ + "state", + "next_attempt_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_profile_bindings": { + "name": "biometric_profile_bindings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "desired_revision_id": { + "name": "desired_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_revision_id": { + "name": "previous_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_profile_revisions": { + "name": "biometric_profile_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_manifest_hash": { + "name": "base_manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_manifest": { + "name": "base_manifest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overrides": { + "name": "overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dependency_hashes": { + "name": "dependency_hashes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_profile_family_revision": { + "name": "uq_biometric_profile_family_revision", + "columns": [ + "family_id", + "revision" + ], + "isUnique": true + }, + "uq_biometric_profile_idempotency": { + "name": "uq_biometric_profile_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_biometric_profile_side_created": { + "name": "idx_biometric_profile_side_created", + "columns": [ + "side", + "created_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "curve_templates": { + "name": "curve_templates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bedtime": { + "name": "bedtime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake": { + "name": "wake", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "points": { + "name": "points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "end_action": { + "name": "end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "left_days": { + "name": "left_days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "right_days": { + "name": "right_days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_curve_templates_name": { + "name": "idx_curve_templates_name", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_settings": { + "name": "device_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bed_mode": { + "name": "bed_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'two'" + }, + "unused_zone_mode": { + "name": "unused_zone_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'off'" + }, + "default_schedule_end_action": { + "name": "default_schedule_end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'America/Los_Angeles'" + }, + "temperature_unit": { + "name": "temperature_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'F'" + }, + "time_format": { + "name": "time_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'12h'" + }, + "reboot_daily": { + "name": "reboot_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reboot_time": { + "name": "reboot_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'03:00'" + }, + "prime_pod_daily": { + "name": "prime_pod_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "prime_pod_time": { + "name": "prime_pod_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'14:00'" + }, + "led_night_mode_enabled": { + "name": "led_night_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "led_day_brightness": { + "name": "led_day_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "led_night_brightness": { + "name": "led_night_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "led_night_start_time": { + "name": "led_night_start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'22:00'" + }, + "led_night_end_time": { + "name": "led_night_end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'07:00'" + }, + "global_max_on_hours": { + "name": "global_max_on_hours", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_enabled": { + "name": "mqtt_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_url": { + "name": "mqtt_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_username": { + "name": "mqtt_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_password": { + "name": "mqtt_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_topic_prefix": { + "name": "mqtt_topic_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_ha_discovery": { + "name": "mqtt_ha_discovery", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_enabled": { + "name": "mqtt_tls_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_insecure": { + "name": "mqtt_tls_insecure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homekit_enabled": { + "name": "homekit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_protection_enabled": { + "name": "pump_stall_protection_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_rpm_threshold": { + "name": "pump_stall_rpm_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "pump_stall_dwell_samples": { + "name": "pump_stall_dwell_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "pump_stall_auto_recovery_enabled": { + "name": "pump_stall_auto_recovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_recovery_rpm": { + "name": "pump_stall_recovery_rpm", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1500 + }, + "pump_stall_recovery_samples": { + "name": "pump_stall_recovery_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "autopilot_enabled": { + "name": "autopilot_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_state": { + "name": "device_state", + "columns": { + "hardware_deadline": { + "name": "hardware_deadline", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_temperature": { + "name": "current_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_temperature": { + "name": "target_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_powered": { + "name": "is_powered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_alarm_vibrating": { + "name": "is_alarm_vibrating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "water_level": { + "name": "water_level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "powered_on_at": { + "name": "powered_on_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "power_schedules": { + "name": "power_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_time": { + "name": "on_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "off_time": { + "name": "off_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_action": { + "name": "end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "on_temperature": { + "name": "on_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_power_schedules_side_day": { + "name": "idx_power_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "run_once_sessions": { + "name": "run_once_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_points": { + "name": "set_points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_time": { + "name": "wake_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_run_once_side_status": { + "name": "idx_run_once_side_status", + "columns": [ + "side", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_notes": { + "name": "schedule_notes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_schedule_notes_side_day": { + "name": "idx_schedule_notes_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_settings": { + "name": "side_settings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "away_mode": { + "name": "away_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "always_on": { + "name": "always_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_enabled": { + "name": "auto_off_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_minutes": { + "name": "auto_off_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "auto_on_enabled": { + "name": "auto_on_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_on_minutes": { + "name": "auto_on_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "auto_on_start": { + "name": "auto_on_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'20:00'" + }, + "auto_on_end": { + "name": "auto_on_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'08:00'" + }, + "away_start": { + "name": "away_start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "away_return": { + "name": "away_return", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_classifier": { + "name": "stage_classifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_stage_models": { + "name": "side_stage_models", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stage_classifier_changes": { + "name": "stage_classifier_changes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_before": { + "name": "agreement_before", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_after": { + "name": "agreement_after", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_stage_classifier_changes_side_changed_at": { + "name": "idx_stage_classifier_changes_side_changed_at", + "columns": [ + "side", + "changed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_health": { + "name": "system_health", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "system_health_component_unique": { + "name": "system_health_component_unique", + "columns": [ + "component" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tap_gestures": { + "name": "tap_gestures", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tap_type": { + "name": "tap_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature_change": { + "name": "temperature_change", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature_amount": { + "name": "temperature_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_behavior": { + "name": "alarm_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_snooze_duration": { + "name": "alarm_snooze_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_inactive_behavior": { + "name": "alarm_inactive_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "uq_tap_side_type": { + "name": "uq_tap_side_type", + "columns": [ + "side", + "tap_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_holds": { + "name": "temperature_holds", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_schedules": { + "name": "temperature_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_temp_schedules_side_day_time": { + "name": "idx_temp_schedules_side_day_time", + "columns": [ + "side", + "day_of_week", + "time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/0031_snapshot.json b/src/db/migrations/meta/0031_snapshot.json new file mode 100644 index 000000000..1e685a7c3 --- /dev/null +++ b/src/db/migrations/meta/0031_snapshot.json @@ -0,0 +1,2039 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c027b393-c447-43ae-9c52-63a55d2243ea", + "prevId": "27e3a7e7-b7d5-4c53-83c9-e75dc76f3343", + "tables": { + "alarm_schedules": { + "name": "alarm_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_intensity": { + "name": "vibration_intensity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_pattern": { + "name": "vibration_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'rise'" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alarm_temperature": { + "name": "alarm_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_window": { + "name": "wake_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_alarm_schedules_side_day": { + "name": "idx_alarm_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation_fired": { + "name": "idx_automation_runs_automation_fired", + "columns": [ + "automation_id", + "fired_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cooldown_min": { + "name": "cooldown_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_automations_enabled": { + "name": "idx_automations_enabled", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "base_schedules": { + "name": "base_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "preset_name": { + "name": "preset_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Custom'" + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head": { + "name": "head", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feet": { + "name": "feet", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feed_rate": { + "name": "feed_rate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 50 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "base_schedules_day_time_side": { + "name": "base_schedules_day_time_side", + "columns": [ + "day_of_week", + "time", + "side" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_activation_events": { + "name": "biometric_activation_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_revision_id": { + "name": "previous_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_revision_id": { + "name": "next_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "previous_hash": { + "name": "previous_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_hash": { + "name": "next_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evaluation_id": { + "name": "evaluation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_activation_generation": { + "name": "uq_biometric_activation_generation", + "columns": [ + "side", + "generation" + ], + "isUnique": true + }, + "uq_biometric_activation_idempotency": { + "name": "uq_biometric_activation_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_config_outbox": { + "name": "biometric_config_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at_ms": { + "name": "next_attempt_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at_ms": { + "name": "delivered_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_outbox_generation": { + "name": "uq_biometric_outbox_generation", + "columns": [ + "side", + "generation" + ], + "isUnique": true + }, + "idx_biometric_outbox_pending": { + "name": "idx_biometric_outbox_pending", + "columns": [ + "state", + "next_attempt_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_profile_bindings": { + "name": "biometric_profile_bindings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "desired_revision_id": { + "name": "desired_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_revision_id": { + "name": "previous_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_profile_revisions": { + "name": "biometric_profile_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_manifest_hash": { + "name": "base_manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_manifest": { + "name": "base_manifest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overrides": { + "name": "overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dependency_hashes": { + "name": "dependency_hashes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_profile_family_revision": { + "name": "uq_biometric_profile_family_revision", + "columns": [ + "family_id", + "revision" + ], + "isUnique": true + }, + "uq_biometric_profile_idempotency": { + "name": "uq_biometric_profile_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_biometric_profile_side_created": { + "name": "idx_biometric_profile_side_created", + "columns": [ + "side", + "created_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "curve_templates": { + "name": "curve_templates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bedtime": { + "name": "bedtime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake": { + "name": "wake", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "points": { + "name": "points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "end_action": { + "name": "end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "left_days": { + "name": "left_days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "right_days": { + "name": "right_days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_curve_templates_name": { + "name": "idx_curve_templates_name", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_settings": { + "name": "device_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bed_mode": { + "name": "bed_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'two'" + }, + "unused_zone_mode": { + "name": "unused_zone_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'off'" + }, + "default_schedule_end_action": { + "name": "default_schedule_end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'America/Los_Angeles'" + }, + "temperature_unit": { + "name": "temperature_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'F'" + }, + "time_format": { + "name": "time_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'12h'" + }, + "reboot_daily": { + "name": "reboot_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reboot_time": { + "name": "reboot_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'03:00'" + }, + "prime_pod_daily": { + "name": "prime_pod_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "prime_pod_time": { + "name": "prime_pod_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'14:00'" + }, + "led_night_mode_enabled": { + "name": "led_night_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "led_day_brightness": { + "name": "led_day_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "led_night_brightness": { + "name": "led_night_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "led_night_start_time": { + "name": "led_night_start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'22:00'" + }, + "led_night_end_time": { + "name": "led_night_end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'07:00'" + }, + "global_max_on_hours": { + "name": "global_max_on_hours", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_enabled": { + "name": "mqtt_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_url": { + "name": "mqtt_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_username": { + "name": "mqtt_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_password": { + "name": "mqtt_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_topic_prefix": { + "name": "mqtt_topic_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_ha_discovery": { + "name": "mqtt_ha_discovery", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_enabled": { + "name": "mqtt_tls_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_insecure": { + "name": "mqtt_tls_insecure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homekit_enabled": { + "name": "homekit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_protection_enabled": { + "name": "pump_stall_protection_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_rpm_threshold": { + "name": "pump_stall_rpm_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "pump_stall_dwell_samples": { + "name": "pump_stall_dwell_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "pump_stall_auto_recovery_enabled": { + "name": "pump_stall_auto_recovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_recovery_rpm": { + "name": "pump_stall_recovery_rpm", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1500 + }, + "pump_stall_recovery_samples": { + "name": "pump_stall_recovery_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "autopilot_enabled": { + "name": "autopilot_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_state": { + "name": "device_state", + "columns": { + "hardware_deadline": { + "name": "hardware_deadline", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_temperature": { + "name": "current_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_temperature": { + "name": "target_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_powered": { + "name": "is_powered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_alarm_vibrating": { + "name": "is_alarm_vibrating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "water_level": { + "name": "water_level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "powered_on_at": { + "name": "powered_on_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "power_schedules": { + "name": "power_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_time": { + "name": "on_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "off_time": { + "name": "off_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_action": { + "name": "end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "on_temperature": { + "name": "on_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_power_schedules_side_day": { + "name": "idx_power_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "run_once_sessions": { + "name": "run_once_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_points": { + "name": "set_points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_time": { + "name": "wake_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_run_once_side_status": { + "name": "idx_run_once_side_status", + "columns": [ + "side", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_notes": { + "name": "schedule_notes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_schedule_notes_side_day": { + "name": "idx_schedule_notes_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_settings": { + "name": "side_settings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "away_mode": { + "name": "away_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "always_on": { + "name": "always_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_enabled": { + "name": "auto_off_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_minutes": { + "name": "auto_off_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "auto_on_enabled": { + "name": "auto_on_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_on_minutes": { + "name": "auto_on_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "auto_on_start": { + "name": "auto_on_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'20:00'" + }, + "auto_on_end": { + "name": "auto_on_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'08:00'" + }, + "away_start": { + "name": "away_start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "away_return": { + "name": "away_return", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_classifier": { + "name": "stage_classifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_stage_models": { + "name": "side_stage_models", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stage_classifier_changes": { + "name": "stage_classifier_changes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_before": { + "name": "agreement_before", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_after": { + "name": "agreement_after", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_stage_classifier_changes_side_changed_at": { + "name": "idx_stage_classifier_changes_side_changed_at", + "columns": [ + "side", + "changed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_health": { + "name": "system_health", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "system_health_component_unique": { + "name": "system_health_component_unique", + "columns": [ + "component" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tap_gestures": { + "name": "tap_gestures", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tap_type": { + "name": "tap_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature_change": { + "name": "temperature_change", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature_amount": { + "name": "temperature_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_behavior": { + "name": "alarm_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_snooze_duration": { + "name": "alarm_snooze_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_inactive_behavior": { + "name": "alarm_inactive_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "uq_tap_side_type": { + "name": "uq_tap_side_type", + "columns": [ + "side", + "tap_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_holds": { + "name": "temperature_holds", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_schedules": { + "name": "temperature_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_temp_schedules_side_day_time": { + "name": "idx_temp_schedules_side_day_time", + "columns": [ + "side", + "day_of_week", + "time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/0032_snapshot.json b/src/db/migrations/meta/0032_snapshot.json new file mode 100644 index 000000000..32404a782 --- /dev/null +++ b/src/db/migrations/meta/0032_snapshot.json @@ -0,0 +1,2092 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b0786f57-48da-4ac9-94ff-cd1c3eaa372e", + "prevId": "c027b393-c447-43ae-9c52-63a55d2243ea", + "tables": { + "alarm_schedules": { + "name": "alarm_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_intensity": { + "name": "vibration_intensity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_pattern": { + "name": "vibration_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'rise'" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alarm_temperature": { + "name": "alarm_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_window": { + "name": "wake_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_alarm_schedules_side_day": { + "name": "idx_alarm_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation_fired": { + "name": "idx_automation_runs_automation_fired", + "columns": [ + "automation_id", + "fired_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cooldown_min": { + "name": "cooldown_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_automations_enabled": { + "name": "idx_automations_enabled", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "base_schedules": { + "name": "base_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "preset_name": { + "name": "preset_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Custom'" + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head": { + "name": "head", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feet": { + "name": "feet", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feed_rate": { + "name": "feed_rate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 50 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "base_schedules_day_time_side": { + "name": "base_schedules_day_time_side", + "columns": [ + "day_of_week", + "time", + "side" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_activation_events": { + "name": "biometric_activation_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_revision_id": { + "name": "previous_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_revision_id": { + "name": "next_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "previous_hash": { + "name": "previous_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_hash": { + "name": "next_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evaluation_id": { + "name": "evaluation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_activation_generation": { + "name": "uq_biometric_activation_generation", + "columns": [ + "side", + "generation" + ], + "isUnique": true + }, + "uq_biometric_activation_idempotency": { + "name": "uq_biometric_activation_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_config_outbox": { + "name": "biometric_config_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at_ms": { + "name": "next_attempt_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at_ms": { + "name": "delivered_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_outbox_generation": { + "name": "uq_biometric_outbox_generation", + "columns": [ + "side", + "generation" + ], + "isUnique": true + }, + "idx_biometric_outbox_pending": { + "name": "idx_biometric_outbox_pending", + "columns": [ + "state", + "next_attempt_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_profile_bindings": { + "name": "biometric_profile_bindings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "desired_revision_id": { + "name": "desired_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_revision_id": { + "name": "previous_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_profile_revisions": { + "name": "biometric_profile_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_manifest_hash": { + "name": "base_manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_manifest": { + "name": "base_manifest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overrides": { + "name": "overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dependency_hashes": { + "name": "dependency_hashes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_profile_family_revision": { + "name": "uq_biometric_profile_family_revision", + "columns": [ + "family_id", + "revision" + ], + "isUnique": true + }, + "uq_biometric_profile_idempotency": { + "name": "uq_biometric_profile_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_biometric_profile_side_created": { + "name": "idx_biometric_profile_side_created", + "columns": [ + "side", + "created_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "curve_templates": { + "name": "curve_templates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bedtime": { + "name": "bedtime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake": { + "name": "wake", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "points": { + "name": "points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "end_action": { + "name": "end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "left_days": { + "name": "left_days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "right_days": { + "name": "right_days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_curve_templates_name": { + "name": "idx_curve_templates_name", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_settings": { + "name": "device_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bed_mode": { + "name": "bed_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'two'" + }, + "unused_zone_mode": { + "name": "unused_zone_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'off'" + }, + "default_schedule_end_action": { + "name": "default_schedule_end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'America/Los_Angeles'" + }, + "temperature_unit": { + "name": "temperature_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'F'" + }, + "time_format": { + "name": "time_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'12h'" + }, + "reboot_daily": { + "name": "reboot_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reboot_time": { + "name": "reboot_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'03:00'" + }, + "prime_pod_daily": { + "name": "prime_pod_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "prime_pod_time": { + "name": "prime_pod_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'14:00'" + }, + "led_night_mode_enabled": { + "name": "led_night_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "led_day_brightness": { + "name": "led_day_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "led_night_brightness": { + "name": "led_night_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "led_night_start_time": { + "name": "led_night_start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'22:00'" + }, + "led_night_end_time": { + "name": "led_night_end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'07:00'" + }, + "global_max_on_hours": { + "name": "global_max_on_hours", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_enabled": { + "name": "mqtt_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_url": { + "name": "mqtt_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_username": { + "name": "mqtt_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_password": { + "name": "mqtt_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_topic_prefix": { + "name": "mqtt_topic_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_ha_discovery": { + "name": "mqtt_ha_discovery", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_enabled": { + "name": "mqtt_tls_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_insecure": { + "name": "mqtt_tls_insecure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homekit_enabled": { + "name": "homekit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_protection_enabled": { + "name": "pump_stall_protection_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_rpm_threshold": { + "name": "pump_stall_rpm_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "pump_stall_dwell_samples": { + "name": "pump_stall_dwell_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "pump_stall_auto_recovery_enabled": { + "name": "pump_stall_auto_recovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_recovery_rpm": { + "name": "pump_stall_recovery_rpm", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1500 + }, + "pump_stall_recovery_samples": { + "name": "pump_stall_recovery_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "autopilot_enabled": { + "name": "autopilot_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_state": { + "name": "device_state", + "columns": { + "hardware_deadline": { + "name": "hardware_deadline", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_temperature": { + "name": "current_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_temperature": { + "name": "target_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_powered": { + "name": "is_powered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_alarm_vibrating": { + "name": "is_alarm_vibrating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "water_level": { + "name": "water_level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "powered_on_at": { + "name": "powered_on_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "power_schedules": { + "name": "power_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_time": { + "name": "on_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "off_time": { + "name": "off_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_action": { + "name": "end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "on_temperature": { + "name": "on_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_power_schedules_side_day": { + "name": "idx_power_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "run_once_sessions": { + "name": "run_once_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_points": { + "name": "set_points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_time": { + "name": "wake_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_run_once_side_status": { + "name": "idx_run_once_side_status", + "columns": [ + "side", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_notes": { + "name": "schedule_notes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_schedule_notes_side_day": { + "name": "idx_schedule_notes_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_settings": { + "name": "side_settings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "away_mode": { + "name": "away_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "always_on": { + "name": "always_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_enabled": { + "name": "auto_off_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_minutes": { + "name": "auto_off_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "auto_on_enabled": { + "name": "auto_on_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_on_minutes": { + "name": "auto_on_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "auto_on_start": { + "name": "auto_on_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'20:00'" + }, + "auto_on_end": { + "name": "auto_on_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'08:00'" + }, + "away_start": { + "name": "away_start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "away_return": { + "name": "away_return", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_classifier": { + "name": "stage_classifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_stage_models": { + "name": "side_stage_models", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stage_classifier_changes": { + "name": "stage_classifier_changes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_before": { + "name": "agreement_before", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_after": { + "name": "agreement_after", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_stage_classifier_changes_side_changed_at": { + "name": "idx_stage_classifier_changes_side_changed_at", + "columns": [ + "side", + "changed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stage_model_library": { + "name": "stage_model_library", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "stage_model_library_side_idx": { + "name": "stage_model_library_side_idx", + "columns": [ + "side" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_health": { + "name": "system_health", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "system_health_component_unique": { + "name": "system_health_component_unique", + "columns": [ + "component" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tap_gestures": { + "name": "tap_gestures", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tap_type": { + "name": "tap_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature_change": { + "name": "temperature_change", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature_amount": { + "name": "temperature_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_behavior": { + "name": "alarm_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_snooze_duration": { + "name": "alarm_snooze_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_inactive_behavior": { + "name": "alarm_inactive_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "uq_tap_side_type": { + "name": "uq_tap_side_type", + "columns": [ + "side", + "tap_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_holds": { + "name": "temperature_holds", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_schedules": { + "name": "temperature_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_temp_schedules_side_day_time": { + "name": "idx_temp_schedules_side_day_time", + "columns": [ + "side", + "day_of_week", + "time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index a53910403..fec0c2d8b 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -211,6 +211,27 @@ "when": 1792415320894, "tag": "0029_clear_stale_gesture_alarm_fields", "breakpoints": true + }, + { + "idx": 30, + "version": "6", + "when": 1792475644291, + "tag": "0030_side_stage_models", + "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1792538319908, + "tag": "0031_side_stage_models_active", + "breakpoints": true + }, + { + "idx": 32, + "version": "6", + "when": 1792617694402, + "tag": "0032_curious_robin_chapel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/retention.ts b/src/db/retention.ts index 8070df158..a37fdb01d 100644 --- a/src/db/retention.ts +++ b/src/db/retention.ts @@ -8,6 +8,7 @@ import { flowReadings, freezerTemp, healthRuns, + heartbeats, movement, pumpAlerts, thermalState, @@ -27,7 +28,8 @@ import { * Tables covered (all write at ≥1/minute and have no referential joins): * vitals, movement, bed_temp, freezer_temp, flow_readings, * ambient_light, water_level_readings, pump_alerts, vitals_quality, - * thermal_state, health_runs, flash_write_samples + * thermal_state, health_runs, flash_write_samples, + * heartbeats (~150 KB per side per night of beat times) * * vitals_quality shares vitals' timestamp cutoff so each quality row dies * with its paired vitals row (vitals_id is a logical, unenforced reference — @@ -46,6 +48,7 @@ const RETENTION_TABLES = [ { table: vitals, column: vitals.timestamp, name: 'vitals' }, { table: vitalsQuality, column: vitalsQuality.timestamp, name: 'vitals_quality' }, { table: movement, column: movement.timestamp, name: 'movement' }, + { table: heartbeats, column: heartbeats.timestamp, name: 'heartbeats' }, { table: bedTemp, column: bedTemp.timestamp, name: 'bed_temp' }, { table: freezerTemp, column: freezerTemp.timestamp, name: 'freezer_temp' }, { table: flowReadings, column: flowReadings.timestamp, name: 'flow_readings' }, diff --git a/src/db/schema.ts b/src/db/schema.ts index f17f6ebba..4253e21a1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -119,6 +119,30 @@ export const sideSettings = sqliteTable('side_settings', { * recent changes with their effect and revert one. `before`/`after` are * classifiers ({ engine, ruleset }) or null for the defaults. */ +/** + * A personal sleep-stage model uploaded for a side (sleepypod-ios training or + * the analysis container's export), validated by stageModelSchema. It is + * scored beside the rules on read; it does not replace the side's classifier. + */ +export const sideStageModels = sqliteTable('side_stage_models', { + side: text('side', { enum: ['left', 'right'] }).primaryKey(), + model: text('model', { mode: 'json' }).notNull(), + version: text('version').notNull(), + uploadedAt: integer('uploaded_at', { mode: 'timestamp' }).notNull(), + // When set, the side's stages are classified by this model instead of the profile's rules; the rules + // remain the fallback whenever the model cannot stage a night. + active: integer('active', { mode: 'boolean' }).notNull().default(false), +}) + +/** Saved candidates; the legacy per-side row remains the selected model for existing clients. */ +export const stageModelLibrary = sqliteTable('stage_model_library', { + id: integer('id').primaryKey({ autoIncrement: true }), + side: text('side', { enum: ['left', 'right'] }).notNull(), + model: text('model', { mode: 'json' }).notNull(), + version: text('version').notNull(), + uploadedAt: integer('uploaded_at', { mode: 'timestamp' }).notNull(), +}, table => [index('stage_model_library_side_idx').on(table.side)]) + export const stageClassifierChanges = sqliteTable('stage_classifier_changes', { id: integer('id').primaryKey({ autoIncrement: true }), side: text('side', { enum: ['left', 'right'] }).notNull(), diff --git a/src/db/tests/__snapshots__/schema.test.ts.snap b/src/db/tests/__snapshots__/schema.test.ts.snap index 8f093dfee..825a4c411 100644 --- a/src/db/tests/__snapshots__/schema.test.ts.snap +++ b/src/db/tests/__snapshots__/schema.test.ts.snap @@ -490,6 +490,25 @@ exports[`complete Drizzle schema contracts > pins every biometrics table, column "primaryKeys": [], "uniqueConstraints": [], }, + { + "checks": 0, + "columns": [ + "id | sql:integer | logical:number/SQLiteInteger | not-null | default:auto | primary-key | not-unique | autoincrement | enum:none", + "side | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:left,right", + "timestamp | sql:integer | logical:date/SQLiteTimestamp | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "beats | sql:text | logical:json/SQLiteTextJson | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "quality | sql:real | logical:number/SQLiteReal | nullable | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + ], + "exportName": "heartbeats", + "foreignKeys": [], + "indexes": [ + "idx_heartbeats_timestamp | non-unique | columns:timestamp | where:none", + "uq_heartbeats_side_timestamp | unique | columns:side,timestamp | where:none", + ], + "name": "heartbeats", + "primaryKeys": [], + "uniqueConstraints": [], + }, { "checks": 0, "columns": [ @@ -1179,6 +1198,22 @@ exports[`complete Drizzle schema contracts > pins every main-database table, col "primaryKeys": [], "uniqueConstraints": [], }, + { + "checks": 0, + "columns": [ + "side | sql:text | logical:string/SQLiteText | not-null | default:none | primary-key | not-unique | not-autoincrement | enum:left,right", + "model | sql:text | logical:json/SQLiteTextJson | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "version | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "uploaded_at | sql:integer | logical:date/SQLiteTimestamp | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "active | sql:integer | logical:boolean/SQLiteBoolean | not-null | default:false | not-primary-key | not-unique | not-autoincrement | enum:none", + ], + "exportName": "sideStageModels", + "foreignKeys": [], + "indexes": [], + "name": "side_stage_models", + "primaryKeys": [], + "uniqueConstraints": [], + }, { "checks": 0, "columns": [ @@ -1200,6 +1235,24 @@ exports[`complete Drizzle schema contracts > pins every main-database table, col "primaryKeys": [], "uniqueConstraints": [], }, + { + "checks": 0, + "columns": [ + "id | sql:integer | logical:number/SQLiteInteger | not-null | default:auto | primary-key | not-unique | autoincrement | enum:none", + "side | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:left,right", + "model | sql:text | logical:json/SQLiteTextJson | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "version | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "uploaded_at | sql:integer | logical:date/SQLiteTimestamp | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + ], + "exportName": "stageModelLibrary", + "foreignKeys": [], + "indexes": [ + "stage_model_library_side_idx | non-unique | columns:side | where:none", + ], + "name": "stage_model_library", + "primaryKeys": [], + "uniqueConstraints": [], + }, { "checks": 0, "columns": [ diff --git a/src/db/tests/retention.test.ts b/src/db/tests/retention.test.ts index c81be533b..16a11cbae 100644 --- a/src/db/tests/retention.test.ts +++ b/src/db/tests/retention.test.ts @@ -135,6 +135,10 @@ describe('pruneOldBiometrics', () => { { vitalsId: 1, side: 'left', timestamp: old, qualityScore: 0.5 }, { vitalsId: 2, side: 'left', timestamp: fresh, qualityScore: 0.9 }, ]).run() + db.insert(schema.heartbeats).values([ + { side: 'left', timestamp: old, beats: [0, 1000, null, 3000] }, + { side: 'left', timestamp: fresh, beats: [0, 1000] }, + ]).run() // Pruned by when the run last held, not when it started. db.insert(schema.healthRuns).values([ @@ -149,11 +153,12 @@ describe('pruneOldBiometrics', () => { const result = pruneOldBiometrics(cutoff, db) - expect(result.rowsDeleted).toBe(12) + expect(result.rowsDeleted).toBe(13) expect(result.perTable).toEqual({ vitals: 1, vitals_quality: 1, movement: 1, + heartbeats: 1, bed_temp: 1, freezer_temp: 1, flow_readings: 1, @@ -263,6 +268,7 @@ describe('pruneOldBiometrics', () => { vitals: 0, vitals_quality: 0, movement: 0, + heartbeats: 0, bed_temp: 0, freezer_temp: 0, flow_readings: 0, diff --git a/src/db/tests/schema.test.ts b/src/db/tests/schema.test.ts index 772b1fca1..ce71d6c8f 100644 --- a/src/db/tests/schema.test.ts +++ b/src/db/tests/schema.test.ts @@ -247,6 +247,17 @@ const expectedTables: Record = { ], indexes: [], }, + sideStageModels: { + name: 'side_stage_models', + columns: [ + { name: 'side', notNull: true, default: undefined }, + { name: 'model', notNull: true, default: undefined }, + { name: 'version', notNull: true, default: undefined }, + { name: 'uploaded_at', notNull: true, default: undefined }, + { name: 'active', notNull: true, default: false }, + ], + indexes: [], + }, stageClassifierChanges: { name: 'stage_classifier_changes', columns: [ @@ -472,7 +483,7 @@ describe('db schema definitions', () => { // exercised against real migrations by the profile repository tests. expect(actualTableExports).toEqual([...Object.keys(expectedTables), 'biometricProfileRevisions', 'biometricProfileBindings', - 'biometricActivationEvents', 'biometricConfigOutbox', + 'biometricActivationEvents', 'biometricConfigOutbox', 'stageModelLibrary', ].sort()) }) }) diff --git a/src/db/tests/stage-model-library.test.ts b/src/db/tests/stage-model-library.test.ts new file mode 100644 index 000000000..003e51e9a --- /dev/null +++ b/src/db/tests/stage-model-library.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs' +import Database from 'better-sqlite3' +import { expect, it } from 'vitest' + +it('migrates existing personal models into the library without changing activation', () => { + const raw = new Database(':memory:') + try { + raw.exec(readFileSync('src/db/migrations/0030_side_stage_models.sql', 'utf8')) + raw.exec(readFileSync('src/db/migrations/0031_side_stage_models_active.sql', 'utf8')) + raw.prepare('INSERT INTO side_stage_models VALUES (?, ?, ?, ?, ?)').run('left', '{"version":"existing"}', 'existing', 1234, 1) + raw.exec(readFileSync('src/db/migrations/0032_curious_robin_chapel.sql', 'utf8')) + expect(raw.prepare('SELECT side, model, version, uploaded_at FROM stage_model_library').get()).toEqual({ side: 'left', model: '{"version":"existing"}', version: 'existing', uploaded_at: 1234 }) + expect(raw.prepare('SELECT active FROM side_stage_models').get()).toEqual({ active: 1 }) + } + finally { raw.close() } +}) diff --git a/src/demo/biometrics/worker-build.json b/src/demo/biometrics/worker-build.json index 886ed4dcd..5adb210a7 100644 --- a/src/demo/biometrics/worker-build.json +++ b/src/demo/biometrics/worker-build.json @@ -1,5 +1,5 @@ { - "buildHash": "1b724cde2e734e3887c468c401b068a553fab89f00c7a55b787241658be1ef63", + "buildHash": "19252f1accd2d6f16a64364c31549ffb89e25e0fcee43a4e32195e46d9a28590", "identity": "esbuild-browser-evaluator-bundle", "simulated": true } diff --git a/src/demo/handlers/biometrics.ts b/src/demo/handlers/biometrics.ts index 7de0079c9..ce26a3e89 100644 --- a/src/demo/handlers/biometrics.ts +++ b/src/demo/handlers/biometrics.ts @@ -197,6 +197,29 @@ export const biometrics: DemoHandlers<'biometrics'> = { .slice(-(input.limit ?? 5000)) }, + getReferenceSamples: (input) => { + const start = input.startDate.getTime() + const end = input.endDate.getTime() + if (start > end) throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) + return demoVitals(input.side, start, end) + .filter((_, i) => i % 5 === 0) + .map(v => ({ timestamp: new Date(v.t), value: input.metric === 'hrv_sdnn' ? v.hrv : v.breathingRate })) + .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + .slice(-(input.limit ?? 5000)) + }, + + getReferenceStages: (input) => { + const start = input.startDate.getTime() + const end = input.endDate.getTime() + if (start > end) throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) + return demoNights(input.side).flatMap(n => mergeIntoBlocks(demoStageEpochs(n, n.enteredBedAt.getTime(), n.leftBedAt?.getTime() ?? Date.now()))) + .filter(b => b.start <= end && b.end > start) + .sort((a, b) => a.start - b.start) + .slice(0, input.limit ?? 2000) + .map(b => ({ id: Math.floor(b.start / 1000) * 2 + sideBit(input.side), side: input.side, source: 'apple_watch' as const, + start: new Date(b.start), end: new Date(b.end), stage: b.stage, createdAt: new Date(b.start) })) + }, + compareToWatch: async (input) => { const endDate = input.endDate ?? new Date() const startDate = input.startDate ?? new Date(endDate.getTime() - 14 * DAY) diff --git a/src/demo/handlers/sleepStages.ts b/src/demo/handlers/sleepStages.ts index 5634fff29..e274c9117 100644 --- a/src/demo/handlers/sleepStages.ts +++ b/src/demo/handlers/sleepStages.ts @@ -77,6 +77,20 @@ export const sleepStages: DemoHandlers<'sleepStages'> = { } }), + // The demo has no Mac-trained model: the Settings card shows "none stored", and the switch cannot be turned on. + getModel: async () => ({ model: null, uploadedAt: null, active: false }), + listModels: async () => [], + uploadModel: async () => { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Upload personal models on your pod. Model uploads are unavailable in the demo.' }) + }, + selectModel: async () => { + throw new TRPCError({ code: 'NOT_FOUND', message: 'No personal stage models are stored in the demo.' }) + }, + setModelActive: async (input) => { + if (input.active) throw new TRPCError({ code: 'NOT_FOUND', message: `No valid personal stage model stored for ${input.side}` }) + return { active: false, version: null } + }, + previewClassifier: async (input) => { const nights = previewNights(input.side, input.sleepRecordId, input.nights ?? 1) const [latest] = nights diff --git a/src/demo/tests/biometrics.referenceVitals.test.ts b/src/demo/tests/biometrics.referenceVitals.test.ts index bfbe30139..ce17bfa9d 100644 --- a/src/demo/tests/biometrics.referenceVitals.test.ts +++ b/src/demo/tests/biometrics.referenceVitals.test.ts @@ -50,3 +50,24 @@ describe('demo biometrics.getReferenceVitals', () => { .rejects.toThrow(/startDate must be before or equal to endDate/) }) }) + +describe('demo reference samples and stages', () => { + it.each(['hrv_sdnn', 'breathing_rate'] as const)('returns chronological %s samples and keeps the newest when limited', async (metric) => { + const night = demoNights('left')[0] + const input = { side: 'left' as const, startDate: night.enteredBedAt, endDate: (night.leftBedAt ?? new Date()), metric } + const all = await required(biometrics.getReferenceSamples)(input) + expect(all.length).toBeGreaterThan(3) + expect(all.map(r => r.timestamp.getTime())).toEqual(all.map(r => r.timestamp.getTime()).sort((a, b) => a - b)) + expect(await required(biometrics.getReferenceSamples)({ ...input, limit: 3 })).toEqual(all.slice(-3)) + await expect(async () => required(biometrics.getReferenceSamples)({ ...input, startDate: input.endDate, endDate: new Date(0) })).rejects.toThrow(/startDate/) + }) + it('returns overlapping Watch stage blocks for the chosen side and honors the limit', async () => { + const night = demoNights('right')[0] + const input = { side: 'right' as const, startDate: night.enteredBedAt, endDate: (night.leftBedAt ?? new Date()) } + const all = await required(biometrics.getReferenceStages)(input) + expect(all.length).toBeGreaterThan(2) + expect(all.every(b => b.side === 'right' && b.end > input.startDate && b.start <= input.endDate)).toBe(true) + expect(await required(biometrics.getReferenceStages)({ ...input, limit: 2 })).toEqual(all.slice(0, 2)) + await expect(async () => required(biometrics.getReferenceStages)({ ...input, startDate: input.endDate, endDate: new Date(0) })).rejects.toThrow(/startDate/) + }) +}) diff --git a/src/demo/tests/sleepStages.test.ts b/src/demo/tests/sleepStages.test.ts index 576afbe74..f2a6896df 100644 --- a/src/demo/tests/sleepStages.test.ts +++ b/src/demo/tests/sleepStages.test.ts @@ -33,6 +33,20 @@ describe('demo sleepStages', () => { vi.useRealTimers() }) + it('has no personal model and refuses to switch one on', async () => { + await expect(required(sleepStages.uploadModel)({ side: 'left', model: { + version: 'example', kind: 'forest', classes: ['wake', 'light', 'deep', 'rem'], + featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'], + trees: [{ f: [-1], t: [0], l: [-1], r: [-1], v: [[0, 1, 0, 0]] }], + testVectors: [{ inputs: { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0 }, expected: 1 }], + } })).rejects.toMatchObject({ code: 'FORBIDDEN', message: expect.stringContaining('demo') }) + expect(await required(sleepStages.listModels)({ side: 'left' })).toEqual([]) + await expect(required(sleepStages.selectModel)({ side: 'left', id: 1, active: true })).rejects.toMatchObject({ code: 'NOT_FOUND' }) + expect(await required(sleepStages.getModel)({ side: 'left' })).toEqual({ model: null, uploadedAt: null, active: false }) + expect(await required(sleepStages.setModelActive)({ side: 'left', active: false })).toEqual({ active: false, version: null }) + await expect(required(sleepStages.setModelActive)({ side: 'left', active: true })).rejects.toMatchObject({ code: 'NOT_FOUND' }) + }) + it('keeps a classifier per side for the session', async () => { expect((await required(sleepStages.getClassifier)({ side: 'left' })).isDefault).toBe(true) await required(sleepStages.setClassifier)({ side: 'left', classifier: movementOnly }) diff --git a/src/lib/personal-stage-model.ts b/src/lib/personal-stage-model.ts new file mode 100644 index 000000000..a60e6a15f --- /dev/null +++ b/src/lib/personal-stage-model.ts @@ -0,0 +1,360 @@ +/** + * Personal sleep stage models on the pod. + * + * A model trained off the pod (on the iPhone with Create ML, or in the + * analysis container with scikit-learn, see + * docs/research/sleep-stage-calibration/personal-models.py --export) arrives + * as a portable JSON document: a random forest as plain trees or a logistic + * regression as weights, plus the feature contract it was trained on and + * test vectors it must reproduce. This module validates such a document, + * computes the contract's features from a night's vitals and movement + * exactly as sleepypod-ios `StageFeatures` and the Python port do, evaluates + * the model per 5-minute epoch, and post-processes like the rules engine. + * + * Evaluating 200 trees of depth 4 is about 800 comparisons per epoch; a night + * costs well under a millisecond. Training never runs here. + */ +import { z } from 'zod' +import { DEFAULT_STAGE_RULESET, SLEEP_STAGES, type StageRuleset } from './sleep-stage-rules' +import type { SleepEpoch, SleepStage, SleepStageBlock } from './sleep-stages' + +export const STAGE_MODEL_EPOCH_SECONDS = 300 +const WINDOW_HALF_SECONDS = 450 + +/** Column names of the two feature contracts, in the order models expect them. */ +export const CHALLENGER_CONTRACT = ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'] as const +export const POD_NATIVE_CONTRACT = [...CHALLENGER_CONTRACT, 'hrvRel', 'hrv15Rel', 'hr15StdRel', 'br15Std', 'motion15', 'minutesSinceBed'] as const +export const STAGE_MODEL_CONTRACTS = { challenger: CHALLENGER_CONTRACT, podNative: POD_NATIVE_CONTRACT } as const +export type StageModelContract = keyof typeof STAGE_MODEL_CONTRACTS + +const number = z.number().finite() + +/** One tree as parallel arrays: node i splits on feature f[i] at t[i] (f[i] = -1 is a leaf with class votes v[i]). */ +const treeSchema = z.object({ + f: z.array(z.number().int().min(-1)), + t: z.array(number), + l: z.array(z.number().int().min(-1)), + r: z.array(z.number().int().min(-1)), + v: z.array(z.array(number.min(0))), +}).strict().superRefine((tree, ctx) => { + const n = tree.f.length + if ([tree.t, tree.l, tree.r, tree.v].some(a => a.length !== n)) ctx.addIssue({ code: 'custom', message: 'tree arrays differ in length' }) + if (n === 0) ctx.addIssue({ code: 'custom', message: 'tree has no nodes' }) + for (let i = 0; i < n; i++) { + if (tree.f[i] >= 0 && (tree.l[i] < 0 || tree.l[i] >= n || tree.r[i] < 0 || tree.r[i] >= n)) { + ctx.addIssue({ code: 'custom', message: `node ${i} points outside the tree` }) + } + if (tree.f[i] >= 0 && (tree.l[i] <= i || tree.r[i] <= i)) ctx.addIssue({ code: 'custom', message: `node ${i} does not point forward` }) + } +}) + +const testVectorSchema = z.object({ inputs: z.record(z.string(), number), expected: z.number().int().min(0).max(3) }).strict() + +export const stageModelSchema = z.object({ + version: z.string().min(1).max(120), + kind: z.enum(['forest', 'logistic']), + /** Class order of every vote, score and `expected`: always the four stages in StageScore order. */ + classes: z.tuple([z.literal('wake'), z.literal('light'), z.literal('deep'), z.literal('rem')]), + featureContract: z.array(z.string().min(1)).min(1).max(32), + epochSeconds: z.literal(STAGE_MODEL_EPOCH_SECONDS).default(STAGE_MODEL_EPOCH_SECONDS), + /** Forest: trees over feature indices into `featureContract`. */ + trees: z.array(treeSchema).max(1000).optional(), + /** Logistic: StandardScaler followed by a linear model; `coef` rows follow `classIndices`. */ + logistic: z.object({ + mean: z.array(number), + scale: z.array(number), + coef: z.array(z.array(number)), + intercept: z.array(number), + /** Class index (into `classes`) of each coef row; a two-class fit has one row for classIndices[1]. */ + classIndices: z.array(z.number().int().min(0).max(3)).min(2).max(4), + }).strict().optional(), + hyperparameters: z.record(z.string(), number).optional(), + metrics: z.record(z.string(), z.unknown()).optional(), + trainedAt: z.string().optional(), + source: z.string().max(200).optional(), + testVectors: z.array(testVectorSchema).min(1).max(64), +}).strict().superRefine((model, ctx) => { + const n = model.featureContract.length + if (model.kind === 'forest') { + if (!model.trees?.length) ctx.addIssue({ code: 'custom', path: ['trees'], message: 'a forest needs trees' }) + for (const [i, tree] of (model.trees ?? []).entries()) { + if (tree.f.some(f => f >= n)) ctx.addIssue({ code: 'custom', path: ['trees', i], message: 'split feature outside the contract' }) + if (tree.v.some(v => v.length !== 4)) ctx.addIssue({ code: 'custom', path: ['trees', i], message: 'leaf votes must have four classes' }) + } + } + else { + const lr = model.logistic + if (!lr) { + ctx.addIssue({ code: 'custom', path: ['logistic'], message: 'a logistic model needs weights' }) + return + } + const rows = lr.classIndices.length === 2 ? 1 : lr.classIndices.length + if (lr.mean.length !== n || lr.scale.length !== n) ctx.addIssue({ code: 'custom', path: ['logistic'], message: 'scaler does not match the contract' }) + if (lr.coef.length !== rows || lr.coef.some(c => c.length !== n) || lr.intercept.length !== rows) { + ctx.addIssue({ code: 'custom', path: ['logistic'], message: 'coef and intercept do not match the classes and contract' }) + } + } + for (const [i, vector] of model.testVectors.entries()) { + if (model.featureContract.some(c => !(c in vector.inputs))) ctx.addIssue({ code: 'custom', path: ['testVectors', i], message: 'test vector misses a contract column' }) + } +}) +export type StageModel = z.infer + +export interface StageModelPrediction { + classIndex: number + stage: SleepStage + /** Per class in `classes` order, summing to 1. */ + probabilities: number[] +} + +/** One epoch's inputs by column name in, a stage out. */ +export function evaluateStageModel(model: StageModel, values: Record): StageModelPrediction { + const x = model.featureContract.map((column) => { + const v = values[column] + if (v === undefined || !Number.isFinite(v)) throw new Error(`feature ${column} is missing`) + return v + }) + let probabilities: number[] + if (model.kind === 'forest') { + const trees = model.trees ?? [] + const sum = [0, 0, 0, 0] + for (const tree of trees) { + let node = 0 + while (tree.f[node] >= 0) node = x[tree.f[node]] <= tree.t[node] ? tree.l[node] : tree.r[node] + const votes = tree.v[node] + const total = votes.reduce((a, b) => a + b, 0) + for (let c = 0; c < 4; c++) sum[c] += total > 0 ? votes[c] / total : 0.25 + } + probabilities = sum.map(s => s / Math.max(trees.length, 1)) + } + else { + const lr = model.logistic + if (!lr) throw new Error('logistic model without weights') + const zed = x.map((v, i) => (lr.scale[i] === 0 ? 0 : (v - lr.mean[i]) / lr.scale[i])) + const scores = lr.coef.map((row, k) => row.reduce((acc, w, i) => acc + w * zed[i], 0) + lr.intercept[k]) + probabilities = [0, 0, 0, 0] + if (lr.classIndices.length === 2) { + const p = 1 / (1 + Math.exp(-scores[0])) + probabilities[lr.classIndices[1]] = p + probabilities[lr.classIndices[0]] = 1 - p + } + else { + const max = Math.max(...scores) + const exps = scores.map(s => Math.exp(s - max)) + const total = exps.reduce((a, b) => a + b, 0) + lr.classIndices.forEach((c, k) => { + probabilities[c] = exps[k] / total + }) + } + } + let classIndex = 0 + for (let c = 1; c < 4; c++) { + if (probabilities[c] > probabilities[classIndex]) classIndex = c + } + return { classIndex, stage: SLEEP_STAGES[classIndex], probabilities } +} + +/** Indices of the model's own test vectors it gets wrong; a model that fails any is refused. */ +export function failingTestVectors(model: StageModel): number[] { + const failing: number[] = [] + model.testVectors.forEach((vector, i) => { + try { + if (evaluateStageModel(model, vector.inputs).classIndex !== vector.expected) failing.push(i) + } + catch { + failing.push(i) + } + }) + return failing +} + +/** The contract whose columns match the model's exactly, or null for an unknown contract. */ +export function stageModelContract(model: StageModel): StageModelContract | null { + for (const [name, columns] of Object.entries(STAGE_MODEL_CONTRACTS)) { + if (columns.length === model.featureContract.length && columns.every((c, i) => c === model.featureContract[i])) return name as StageModelContract + } + return null +} + +// MARK: Features (must stay identical to sleepypod-ios StageFeatures and personal-models.py) + +interface VitalsLike { timestamp: Date, heartRate: number | null, hrv: number | null, breathingRate: number | null } +interface MovementLike { timestamp: Date, totalMovement: number } + +export interface FeatureEpoch { start: number, values: Record } + +function mean(values: number[]): number { + return values.length === 0 ? 0 : values.reduce((a, b) => a + b, 0) / values.length +} +function populationStd(values: number[]): number { + if (values.length === 0) return 0 + const m = mean(values) + return Math.sqrt(values.reduce((acc, v) => acc + (v - m) * (v - m), 0) / values.length) +} +function zScores(values: number[]): number[] { + const m = mean(values) + const s = populationStd(values) + return values.map(v => (s === 0 ? 0 : (v - m) / s)) +} +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b) + if (sorted.length === 0) return 0 + const mid = sorted.length >> 1 + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} + +/** + * The contract's rows for a night: 5-minute epochs from bed entry (a trailing + * partial epoch dropped), epochs without a heart-rate sample skipped, z-scores + * per night with the population std. Timestamps are unix seconds. Only rows + * inside [enteredBed, leftBed) take part, as on the phone. + */ +export function stageModelFeatures(contract: StageModelContract, enteredBed: Date, leftBed: Date, vitals: VitalsLike[], movement: MovementLike[]): FeatureEpoch[] { + const entered = enteredBed.getTime() / 1000 + const left = leftBed.getTime() / 1000 + const night = left - entered + if (night < STAGE_MODEL_EPOCH_SECONDS) return [] + const count = Math.floor(night / STAGE_MODEL_EPOCH_SECONDS) + const inWindow = (rows: T[]) => rows.filter((r) => { + const t = r.timestamp.getTime() / 1000 + return t >= entered && t < left + }) + const rows = inWindow(vitals) + const moves = inWindow(movement) + const finite = (v: number | null): v is number => v !== null && Number.isFinite(v) + const hr = rows.filter(r => finite(r.heartRate)).map(r => ({ t: r.timestamp.getTime() / 1000, v: r.heartRate as number })) + const hrv = rows.filter(r => finite(r.hrv)).map(r => ({ t: r.timestamp.getTime() / 1000, v: r.hrv as number })) + const br = rows.filter(r => finite(r.breathingRate)).map(r => ({ t: r.timestamp.getTime() / 1000, v: r.breathingRate as number })) + const mv = moves.map(r => ({ t: r.timestamp.getTime() / 1000, v: r.totalMovement })) + + const kept: { start: number, hr: number, hrStd: number, motion: number }[] = [] + for (let i = 0; i < count; i++) { + const start = entered + i * STAGE_MODEL_EPOCH_SECONDS + const end = start + STAGE_MODEL_EPOCH_SECONDS + const samples = hr.filter(s => s.t >= start && s.t < end).map(s => s.v) + if (samples.length === 0) continue + const motion = mv.filter(s => s.t >= start && s.t < end).reduce((a, s) => a + s.v, 0) + kept.push({ start, hr: mean(samples), hrStd: samples.length < 2 ? 0 : populationStd(samples), motion }) + } + const hrZ = zScores(kept.map(k => k.hr)) + const hrStdZ = zScores(kept.map(k => k.hrStd)) + const motionZ = zScores(kept.map(k => Math.log(1 + k.motion))) + const out: FeatureEpoch[] = kept.map((k, i) => ({ + start: k.start, + values: { hrZ: hrZ[i], hrStdZ: hrStdZ[i], motionZ: motionZ[i], nightFraction: (k.start - entered) / night }, + })) + if (contract === 'challenger') return out + + const nightHRVMedian = median(hrv.map(s => s.v)) + const nightHRStd = populationStd(hr.map(s => s.v)) + const ratio = (value: number, denominator: number) => (denominator > 0 && Number.isFinite(denominator) ? value / denominator : 0) + const window = (samples: { t: number, v: number }[], mid: number, half: number) => samples.filter(s => Math.abs(s.t - mid) <= half).map(s => s.v) + for (const row of out) { + const mid = row.start + STAGE_MODEL_EPOCH_SECONDS / 2 + const epochHRV = window(hrv, mid, STAGE_MODEL_EPOCH_SECONDS / 2) + const hrv15 = window(hrv, mid, WINDOW_HALF_SECONDS) + const hr15 = window(hr, mid, WINDOW_HALF_SECONDS) + const br15 = window(br, mid, WINDOW_HALF_SECONDS) + const motion15 = mv.filter(s => Math.abs(s.t - mid) <= WINDOW_HALF_SECONDS).reduce((a, s) => a + s.v, 0) + row.values.hrvRel = epochHRV.length === 0 ? 0 : ratio(mean(epochHRV), nightHRVMedian) + row.values.hrv15Rel = hrv15.length === 0 ? 0 : ratio(mean(hrv15), nightHRVMedian) + row.values.hr15StdRel = hr15.length < 2 ? 0 : ratio(populationStd(hr15), nightHRStd) + row.values.br15Std = br15.length < 2 ? 0 : populationStd(br15) + row.values.motion15 = Math.log(1 + motion15) + row.values.minutesSinceBed = (row.start - entered) / 60 + } + return out +} + +// MARK: Outliers + +/** + * The rules engine's outlier filter (sleep-stages.ts `filterOutliers`), ported rather than imported: that file + * is a pinned numerical source of the biometrics manifest, and exporting from it would change the manifest hash. + * Hard limits null a field; the HR spike filter nulls a reading more than `heartRateSpikeStdDevs` from its + * ±2-sample window median. + */ +export function filterModelOutliers(vitals: T[], limits: StageRuleset['outliers']): T[] { + const inRange = (v: number | null, r: { min: number, max: number }) => v !== null && v >= r.min && v <= r.max + return vitals.map((v, i) => { + let heartRate = inRange(v.heartRate, limits.heartRate) ? v.heartRate : null + const hrv = inRange(v.hrv, limits.hrv) ? v.hrv : null + const breathingRate = inRange(v.breathingRate, limits.breathingRate) ? v.breathingRate : null + if (heartRate !== null && limits.heartRateSpikeStdDevs > 0) { + const windowHRs = vitals.slice(Math.max(0, i - 2), Math.min(vitals.length - 1, i + 2) + 1) + .map(w => w.heartRate) + .filter((hr): hr is number => inRange(hr, limits.heartRate)) + if (windowHRs.length > 0) { + const sorted = [...windowHRs].sort((a, b) => a - b) + const windowMedian = sorted[Math.floor(sorted.length / 2)] + const windowMean = windowHRs.reduce((a, h) => a + h, 0) / windowHRs.length + const stdDev = Math.sqrt(windowHRs.reduce((a, h) => a + (h - windowMean) ** 2, 0) / windowHRs.length) + if (stdDev > 0 && Math.abs(heartRate - windowMedian) > limits.heartRateSpikeStdDevs * stdDev) heartRate = null + } + } + return { ...v, heartRate, hrv, breathingRate } + }) +} + +// MARK: Classification + +/** + * A night staged by the model: features from the outlier-filtered vitals (the + * ruleset's limits, as the phone filters before training), one 5-minute epoch + * per prediction, then the rules engine's smoothing (an isolated epoch between + * two equal neighbours takes their stage) and transition constraints. + */ +export function classifyWithStageModel( + model: StageModel, + enteredBed: Date, + leftBed: Date, + vitals: VitalsLike[], + movement: MovementLike[], + ruleset: StageRuleset = DEFAULT_STAGE_RULESET, +): SleepEpoch[] { + const contract = stageModelContract(model) + if (!contract) throw new Error('unknown feature contract') + const sorted = [...vitals].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + const filtered = filterModelOutliers(sorted, ruleset.outliers) + const rows = stageModelFeatures(contract, enteredBed, leftBed, filtered, movement) + const epochs: SleepEpoch[] = rows.map((row) => { + const startMs = row.start * 1000 + const endMs = startMs + STAGE_MODEL_EPOCH_SECONDS * 1000 + const inEpoch = filtered.filter(v => v.timestamp.getTime() >= startMs && v.timestamp.getTime() < endMs) + const avg = (pick: (v: VitalsLike) => number | null) => { + const values = inEpoch.map(pick).filter((v): v is number => v !== null && Number.isFinite(v)) + return values.length === 0 ? null : mean(values) + } + const motion = movement.filter(m => m.timestamp.getTime() >= startMs && m.timestamp.getTime() < endMs).reduce((a, m) => a + m.totalMovement, 0) + return { + start: startMs, + duration: STAGE_MODEL_EPOCH_SECONDS * 1000, + stage: evaluateStageModel(model, row.values).stage, + heartRate: avg(v => v.heartRate), + hrv: avg(v => v.hrv), + breathingRate: avg(v => v.breathingRate), + movement: motion, + } + }) + if (ruleset.smoothIsolatedEpochs) { + for (let i = 1; i < epochs.length - 1; i++) { + if (epochs[i - 1].stage === epochs[i + 1].stage && epochs[i].stage !== epochs[i - 1].stage) epochs[i].stage = epochs[i - 1].stage + } + } + for (let i = 1; i < epochs.length; i++) { + const t = ruleset.transitions.find(t => t.from === epochs[i - 1].stage && t.to === epochs[i].stage) + if (t && t.via !== epochs[i].stage) epochs[i].stage = t.via + } + return epochs +} + +/** Blocks for drawing, from classified epochs. */ +export function stageModelBlocks(epochs: SleepEpoch[]): SleepStageBlock[] { + const blocks: SleepStageBlock[] = [] + for (const e of epochs) { + const last = blocks[blocks.length - 1] + if (last && last.stage === e.stage && last.end === e.start) last.end = e.start + e.duration + else blocks.push({ start: e.start, end: e.start + e.duration, stage: e.stage }) + } + return blocks +} diff --git a/src/lib/tests/fixtures/stage-features-night.json b/src/lib/tests/fixtures/stage-features-night.json new file mode 100644 index 000000000..d6d2ed3d0 --- /dev/null +++ b/src/lib/tests/fixtures/stage-features-night.json @@ -0,0 +1,4615 @@ +{ + "night": "synthetic", + "side": "left", + "enteredBed": 1700000060, + "leftBed": 1700019740, + "synthetic": true, + "vitals": [ + { + "ts": 1700000000, + "hr": null, + "hrv": 49.66, + "br": 14.23 + }, + { + "ts": 1700000060, + "hr": 55.14, + "hrv": 52.05, + "br": 14.13 + }, + { + "ts": 1700000120, + "hr": 51.86, + "hrv": 54.82, + "br": 14.92 + }, + { + "ts": 1700000180, + "hr": 50.54, + "hrv": 47.26, + "br": 13.39 + }, + { + "ts": 1700000240, + "hr": 53.75, + "hrv": 53.88, + "br": 12.99 + }, + { + "ts": 1700000300, + "hr": 53.82, + "hrv": 45.82, + "br": 14.21 + }, + { + "ts": 1700000360, + "hr": 52.56, + "hrv": 54.8, + "br": 15.03 + }, + { + "ts": 1700000420, + "hr": 54.06, + "hrv": 55.38, + "br": 13.76 + }, + { + "ts": 1700000480, + "hr": 55.31, + "hrv": 55.72, + "br": 14.12 + }, + { + "ts": 1700000540, + "hr": 52.35, + "hrv": 55.45, + "br": 14.85 + }, + { + "ts": 1700000600, + "hr": 55.26, + "hrv": 63.63, + "br": 14.09 + }, + { + "ts": 1700000660, + "hr": 54.57, + "hrv": 47.88, + "br": 14.15 + }, + { + "ts": 1700000720, + "hr": 52.51, + "hrv": 50.23, + "br": 14.89 + }, + { + "ts": 1700000780, + "hr": 54.46, + "hrv": 62.54, + "br": 14.91 + }, + { + "ts": 1700000840, + "hr": 57.0, + "hrv": 52.43, + "br": 14.32 + }, + { + "ts": 1700000900, + "hr": 54.52, + "hrv": 48.32, + "br": 14.49 + }, + { + "ts": 1700000960, + "hr": 54.89, + "hrv": 59.01, + "br": 14.3 + }, + { + "ts": 1700001020, + "hr": 55.9, + "hrv": 47.25, + "br": 14.71 + }, + { + "ts": 1700001080, + "hr": 53.77, + "hrv": 55.98, + "br": 15.85 + }, + { + "ts": 1700001140, + "hr": 55.51, + "hrv": 57.96, + "br": 14.39 + }, + { + "ts": 1700001200, + "hr": 53.74, + "hrv": 54.52, + "br": 14.52 + }, + { + "ts": 1700001260, + "hr": 54.09, + "hrv": 60.83, + "br": 14.62 + }, + { + "ts": 1700001320, + "hr": 52.45, + "hrv": 48.21, + "br": 14.72 + }, + { + "ts": 1700001380, + "hr": 52.91, + "hrv": 59.54, + "br": 13.99 + }, + { + "ts": 1700001440, + "hr": 57.47, + "hrv": 63.65, + "br": 14.46 + }, + { + "ts": 1700001500, + "hr": 54.19, + "hrv": 61.59, + "br": 14.17 + }, + { + "ts": 1700001560, + "hr": 53.8, + "hrv": 62.54, + "br": 14.41 + }, + { + "ts": 1700001620, + "hr": 54.73, + "hrv": 53.04, + "br": 14.16 + }, + { + "ts": 1700001680, + "hr": 55.36, + "hrv": 46.73, + "br": 14.02 + }, + { + "ts": 1700001740, + "hr": 54.28, + "hrv": 57.02, + "br": 15.34 + }, + { + "ts": 1700001800, + "hr": 53.25, + "hrv": 59.41, + "br": 14.49 + }, + { + "ts": 1700001860, + "hr": 56.61, + "hrv": 51.76, + "br": 14.41 + }, + { + "ts": 1700001920, + "hr": 55.19, + "hrv": 56.75, + "br": 14.48 + }, + { + "ts": 1700001980, + "hr": 56.92, + "hrv": 53.19, + "br": 14.41 + }, + { + "ts": 1700002040, + "hr": 60.14, + "hrv": 29.71, + "br": 15.07 + }, + { + "ts": 1700002100, + "hr": 62.17, + "hrv": 42.99, + "br": 15.05 + }, + { + "ts": 1700002160, + "hr": 59.13, + "hrv": 43.17, + "br": 14.8 + }, + { + "ts": 1700002220, + "hr": 59.28, + "hrv": 27.87, + "br": 14.99 + }, + { + "ts": 1700002280, + "hr": 62.25, + "hrv": 28.14, + "br": 13.23 + }, + { + "ts": 1700002340, + "hr": 61.03, + "hrv": 32.84, + "br": 13.59 + }, + { + "ts": 1700002400, + "hr": 58.49, + "hrv": 41.03, + "br": 14.46 + }, + { + "ts": 1700002460, + "hr": null, + "hrv": 32.68, + "br": 14.62 + }, + { + "ts": 1700002520, + "hr": 61.41, + "hrv": 50.15, + "br": 13.66 + }, + { + "ts": 1700002580, + "hr": 58.69, + "hrv": 35.24, + "br": 14.95 + }, + { + "ts": 1700002640, + "hr": 60.39, + "hrv": 44.93, + "br": 14.49 + }, + { + "ts": 1700002700, + "hr": 55.9, + "hrv": 34.82, + "br": 15.3 + }, + { + "ts": 1700002760, + "hr": 59.32, + "hrv": 44.78, + "br": 14.59 + }, + { + "ts": 1700002820, + "hr": 61.88, + "hrv": 41.32, + "br": 14.63 + }, + { + "ts": 1700002880, + "hr": 59.58, + "hrv": 44.56, + "br": 13.74 + }, + { + "ts": 1700002940, + "hr": 63.13, + "hrv": 46.76, + "br": 14.22 + }, + { + "ts": 1700003000, + "hr": 56.55, + "hrv": 39.5, + "br": 14.46 + }, + { + "ts": 1700003060, + "hr": 60.13, + "hrv": 45.13, + "br": 14.7 + }, + { + "ts": 1700003120, + "hr": 59.48, + "hrv": 35.66, + "br": 13.41 + }, + { + "ts": 1700003180, + "hr": 61.23, + "hrv": 38.66, + "br": 14.63 + }, + { + "ts": 1700003240, + "hr": 60.22, + "hrv": 47.07, + "br": 14.83 + }, + { + "ts": 1700003300, + "hr": 62.45, + "hrv": 41.04, + "br": 14.54 + }, + { + "ts": 1700003360, + "hr": 59.85, + "hrv": 43.24, + "br": 14.26 + }, + { + "ts": 1700003420, + "hr": 57.46, + "hrv": 36.59, + "br": 13.99 + }, + { + "ts": 1700003480, + "hr": 60.61, + "hrv": 47.15, + "br": 14.31 + }, + { + "ts": 1700003540, + "hr": 60.33, + "hrv": 50.09, + "br": 14.35 + }, + { + "ts": 1700003600, + "hr": 57.65, + "hrv": 31.36, + "br": 15.27 + }, + { + "ts": 1700003660, + "hr": 61.7, + "hrv": 39.56, + "br": 14.83 + }, + { + "ts": 1700003720, + "hr": 57.26, + "hrv": 38.25, + "br": 15.39 + }, + { + "ts": 1700003780, + "hr": 57.89, + "hrv": 34.97, + "br": 14.68 + }, + { + "ts": 1700003840, + "hr": 58.61, + "hrv": 37.76, + "br": 14.62 + }, + { + "ts": 1700003900, + "hr": 61.74, + "hrv": 41.55, + "br": 14.53 + }, + { + "ts": 1700003960, + "hr": 56.69, + "hrv": 43.13, + "br": 14.92 + }, + { + "ts": 1700004020, + "hr": 58.12, + "hrv": 33.93, + "br": 14.88 + }, + { + "ts": 1700004080, + "hr": 56.8, + "hrv": 37.66, + "br": 14.39 + }, + { + "ts": 1700004140, + "hr": 56.7, + "hrv": 44.67, + "br": 15.14 + }, + { + "ts": 1700004200, + "hr": 58.1, + "hrv": 35.67, + "br": 14.3 + }, + { + "ts": 1700004260, + "hr": 59.36, + "hrv": 36.85, + "br": 14.63 + }, + { + "ts": 1700004320, + "hr": 58.3, + "hrv": 40.35, + "br": 14.27 + }, + { + "ts": 1700004380, + "hr": 56.08, + "hrv": 32.42, + "br": 14.98 + }, + { + "ts": 1700004440, + "hr": 58.67, + "hrv": 46.06, + "br": 14.27 + }, + { + "ts": 1700004500, + "hr": 61.45, + "hrv": 36.95, + "br": 15.45 + }, + { + "ts": 1700004560, + "hr": 56.94, + "hrv": 43.9, + "br": 13.75 + }, + { + "ts": 1700004620, + "hr": 55.82, + "hrv": 34.99, + "br": 14.22 + }, + { + "ts": 1700004680, + "hr": 56.8, + "hrv": 31.84, + "br": 13.73 + }, + { + "ts": 1700004740, + "hr": 54.84, + "hrv": 38.02, + "br": 14.11 + }, + { + "ts": 1700004800, + "hr": 59.26, + "hrv": 33.32, + "br": 14.73 + }, + { + "ts": 1700004860, + "hr": 57.77, + "hrv": 37.07, + "br": 14.86 + }, + { + "ts": 1700004920, + "hr": 56.84, + "hrv": 43.75, + "br": 13.96 + }, + { + "ts": 1700004980, + "hr": 57.63, + "hrv": 31.51, + "br": 13.42 + }, + { + "ts": 1700005040, + "hr": 54.25, + "hrv": 31.47, + "br": 14.05 + }, + { + "ts": 1700005100, + "hr": 55.21, + "hrv": 33.67, + "br": 14.85 + }, + { + "ts": 1700005160, + "hr": 53.89, + "hrv": 30.73, + "br": 13.4 + }, + { + "ts": 1700005220, + "hr": 56.57, + "hrv": 37.81, + "br": 14.6 + }, + { + "ts": 1700005280, + "hr": 62.59, + "hrv": 16.99, + "br": 14.95 + }, + { + "ts": 1700005340, + "hr": 61.92, + "hrv": 22.87, + "br": 15.33 + }, + { + "ts": 1700005400, + "hr": 64.7, + "hrv": 18.55, + "br": 15.12 + }, + { + "ts": 1700005460, + "hr": 61.56, + "hrv": 19.14, + "br": 15.33 + }, + { + "ts": 1700005520, + "hr": 63.13, + "hrv": 19.78, + "br": 13.36 + }, + { + "ts": 1700005580, + "hr": 63.52, + "hrv": 24.44, + "br": 13.83 + }, + { + "ts": 1700005640, + "hr": 63.51, + "hrv": 32.74, + "br": 15.62 + }, + { + "ts": 1700005700, + "hr": 63.4, + "hrv": 21.35, + "br": 14.05 + }, + { + "ts": 1700005760, + "hr": 61.86, + "hrv": 28.64, + "br": 14.44 + }, + { + "ts": 1700005820, + "hr": 62.27, + "hrv": 19.31, + "br": 14.36 + }, + { + "ts": 1700005880, + "hr": 63.65, + "hrv": 24.97, + "br": 13.68 + }, + { + "ts": 1700005940, + "hr": 62.32, + "hrv": 23.23, + "br": 13.25 + }, + { + "ts": 1700006000, + "hr": 63.42, + "hrv": 24.05, + "br": 14.25 + }, + { + "ts": 1700006060, + "hr": 62.75, + "hrv": 21.68, + "br": 14.56 + }, + { + "ts": 1700006120, + "hr": 63.47, + "hrv": 25.86, + "br": 14.42 + }, + { + "ts": 1700006180, + "hr": 60.58, + "hrv": 27.76, + "br": 14.65 + }, + { + "ts": 1700006240, + "hr": 63.63, + "hrv": 21.08, + "br": 14.34 + }, + { + "ts": 1700006300, + "hr": 63.34, + "hrv": 15.91, + "br": 14.82 + }, + { + "ts": 1700006360, + "hr": 64.53, + "hrv": 26.4, + "br": 13.57 + }, + { + "ts": 1700006420, + "hr": 59.21, + "hrv": 27.26, + "br": 13.6 + }, + { + "ts": 1700006480, + "hr": 62.05, + "hrv": 32.64, + "br": 14.28 + }, + { + "ts": 1700006540, + "hr": 65.39, + "hrv": 24.38, + "br": 14.36 + }, + { + "ts": 1700006600, + "hr": 61.97, + "hrv": 27.55, + "br": 14.74 + }, + { + "ts": 1700006660, + "hr": 61.86, + "hrv": 24.66, + "br": 14.94 + }, + { + "ts": 1700006720, + "hr": 64.64, + "hrv": 27.38, + "br": 14.31 + }, + { + "ts": 1700006780, + "hr": 60.6, + "hrv": 28.48, + "br": 14.47 + }, + { + "ts": 1700006840, + "hr": 60.54, + "hrv": 22.81, + "br": 14.14 + }, + { + "ts": 1700006900, + "hr": 62.62, + "hrv": 30.38, + "br": 14.73 + }, + { + "ts": 1700006960, + "hr": 63.14, + "hrv": 29.54, + "br": 12.84 + }, + { + "ts": 1700007020, + "hr": 64.23, + "hrv": 35.2, + "br": 15.18 + }, + { + "ts": 1700007080, + "hr": 49.45, + "hrv": 54.76, + "br": 15.04 + }, + { + "ts": 1700007140, + "hr": 50.24, + "hrv": 56.19, + "br": 15.31 + }, + { + "ts": 1700007200, + "hr": 50.1, + "hrv": 53.81, + "br": 14.97 + }, + { + "ts": 1700007260, + "hr": 49.07, + "hrv": 57.02, + "br": 14.1 + }, + { + "ts": 1700007320, + "hr": 51.25, + "hrv": 52.15, + "br": 15.12 + }, + { + "ts": 1700007380, + "hr": 49.22, + "hrv": 49.88, + "br": 14.62 + }, + { + "ts": 1700007440, + "hr": 49.28, + "hrv": 55.44, + "br": 14.29 + }, + { + "ts": 1700007500, + "hr": 52.12, + "hrv": 51.2, + "br": 14.24 + }, + { + "ts": 1700007560, + "hr": 50.93, + "hrv": 56.31, + "br": 15.42 + }, + { + "ts": 1700007620, + "hr": 51.11, + "hrv": 57.71, + "br": 14.0 + }, + { + "ts": 1700007680, + "hr": 49.89, + "hrv": 54.15, + "br": 14.63 + }, + { + "ts": 1700007740, + "hr": 51.87, + "hrv": 49.66, + "br": 14.28 + }, + { + "ts": 1700007800, + "hr": 50.12, + "hrv": 52.89, + "br": 14.37 + }, + { + "ts": 1700007860, + "hr": 49.78, + "hrv": 52.73, + "br": 16.02 + }, + { + "ts": 1700007920, + "hr": 54.53, + "hrv": 53.6, + "br": 14.28 + }, + { + "ts": 1700007980, + "hr": 52.57, + "hrv": 51.95, + "br": 15.65 + }, + { + "ts": 1700008040, + "hr": 48.64, + "hrv": 58.83, + "br": 14.38 + }, + { + "ts": 1700008100, + "hr": 51.28, + "hrv": 44.86, + "br": 14.72 + }, + { + "ts": 1700008160, + "hr": 51.4, + "hrv": 41.14, + "br": 15.23 + }, + { + "ts": 1700008220, + "hr": 55.01, + "hrv": 55.14, + "br": 13.42 + }, + { + "ts": 1700008280, + "hr": 52.28, + "hrv": 42.99, + "br": 14.71 + }, + { + "ts": 1700008340, + "hr": 52.12, + "hrv": 53.12, + "br": 14.46 + }, + { + "ts": 1700008400, + "hr": 52.37, + "hrv": 66.07, + "br": 15.69 + }, + { + "ts": 1700008460, + "hr": 51.53, + "hrv": 63.66, + "br": 14.47 + }, + { + "ts": 1700008520, + "hr": 52.02, + "hrv": 54.5, + "br": 13.85 + }, + { + "ts": 1700008580, + "hr": 52.7, + "hrv": 53.57, + "br": 14.82 + }, + { + "ts": 1700008640, + "hr": 54.31, + "hrv": 56.62, + "br": 14.57 + }, + { + "ts": 1700008700, + "hr": 53.19, + "hrv": 51.88, + "br": 15.39 + }, + { + "ts": 1700008760, + "hr": 54.19, + "hrv": 58.64, + "br": 15.34 + }, + { + "ts": 1700008820, + "hr": 52.88, + "hrv": 51.01, + "br": 14.82 + }, + { + "ts": 1700008880, + "hr": 51.21, + "hrv": 51.11, + "br": 14.72 + }, + { + "ts": 1700008940, + "hr": 53.28, + "hrv": 45.8, + "br": 14.05 + }, + { + "ts": 1700009000, + "hr": 52.74, + "hrv": 43.14, + "br": 14.92 + }, + { + "ts": 1700009060, + "hr": 53.99, + "hrv": 58.79, + "br": 14.52 + }, + { + "ts": 1700009120, + "hr": 54.57, + "hrv": 46.26, + "br": 14.4 + }, + { + "ts": 1700009180, + "hr": 52.04, + "hrv": 64.8, + "br": 14.56 + }, + { + "ts": 1700009240, + "hr": 54.07, + "hrv": 50.69, + "br": 14.56 + }, + { + "ts": 1700009300, + "hr": 60.79, + "hrv": 53.43, + "br": 13.9 + }, + { + "ts": 1700009360, + "hr": 60.61, + "hrv": 31.11, + "br": 14.21 + }, + { + "ts": 1700009420, + "hr": 58.84, + "hrv": 25.4, + "br": 13.63 + }, + { + "ts": 1700009480, + "hr": null, + "hrv": 27.36, + "br": 14.22 + }, + { + "ts": 1700009540, + "hr": 58.44, + "hrv": 41.79, + "br": 14.15 + }, + { + "ts": 1700009600, + "hr": 62.04, + "hrv": 39.32, + "br": 15.2 + }, + { + "ts": 1700009660, + "hr": 59.73, + "hrv": 52.92, + "br": 14.96 + }, + { + "ts": 1700009720, + "hr": 60.56, + "hrv": 45.26, + "br": 14.21 + }, + { + "ts": 1700009780, + "hr": 60.32, + "hrv": 45.9, + "br": 14.69 + }, + { + "ts": 1700009840, + "hr": 61.55, + "hrv": 50.58, + "br": 14.96 + }, + { + "ts": 1700009900, + "hr": 58.94, + "hrv": 37.33, + "br": 14.78 + }, + { + "ts": 1700009960, + "hr": 60.87, + "hrv": 36.33, + "br": 14.57 + }, + { + "ts": 1700010020, + "hr": 61.8, + "hrv": 32.2, + "br": 14.36 + }, + { + "ts": 1700010080, + "hr": 60.83, + "hrv": 31.64, + "br": 15.18 + }, + { + "ts": 1700010140, + "hr": 61.73, + "hrv": 38.73, + "br": 14.56 + }, + { + "ts": 1700010200, + "hr": 62.27, + "hrv": 37.71, + "br": 15.35 + }, + { + "ts": 1700010260, + "hr": 61.84, + "hrv": 38.21, + "br": 14.79 + }, + { + "ts": 1700010320, + "hr": 62.14, + "hrv": 42.19, + "br": 14.59 + }, + { + "ts": 1700010380, + "hr": 59.78, + "hrv": 36.87, + "br": 15.21 + }, + { + "ts": 1700010440, + "hr": 59.92, + "hrv": 31.4, + "br": 15.26 + }, + { + "ts": 1700010500, + "hr": 62.31, + "hrv": 45.15, + "br": 14.24 + }, + { + "ts": 1700010560, + "hr": 59.97, + "hrv": 40.13, + "br": 14.48 + }, + { + "ts": 1700010620, + "hr": 61.01, + "hrv": 34.61, + "br": 14.24 + }, + { + "ts": 1700010680, + "hr": 62.31, + "hrv": 45.21, + "br": 14.85 + }, + { + "ts": 1700010740, + "hr": 60.52, + "hrv": 49.13, + "br": 15.52 + }, + { + "ts": 1700010800, + "hr": 62.0, + "hrv": 42.74, + "br": 14.94 + }, + { + "ts": 1700010860, + "hr": 60.88, + "hrv": 44.16, + "br": 13.14 + }, + { + "ts": 1700010920, + "hr": 62.43, + "hrv": 36.63, + "br": 13.97 + }, + { + "ts": 1700010980, + "hr": 58.63, + "hrv": 37.84, + "br": 14.8 + }, + { + "ts": 1700011040, + "hr": 62.54, + "hrv": 38.11, + "br": 14.44 + }, + { + "ts": 1700011100, + "hr": 60.58, + "hrv": 37.04, + "br": 14.55 + }, + { + "ts": 1700011160, + "hr": 60.32, + "hrv": 40.77, + "br": 14.0 + }, + { + "ts": 1700011220, + "hr": 63.92, + "hrv": 40.23, + "br": 15.57 + }, + { + "ts": 1700011280, + "hr": 60.27, + "hrv": 40.62, + "br": 14.54 + }, + { + "ts": 1700011340, + "hr": 60.47, + "hrv": 24.69, + "br": 14.72 + }, + { + "ts": 1700011400, + "hr": 60.43, + "hrv": 39.64, + "br": 13.64 + }, + { + "ts": 1700011460, + "hr": 59.25, + "hrv": 47.55, + "br": 13.87 + }, + { + "ts": 1700011520, + "hr": 61.09, + "hrv": 36.08, + "br": 14.46 + }, + { + "ts": 1700011580, + "hr": 60.76, + "hrv": 32.16, + "br": 14.85 + }, + { + "ts": 1700011640, + "hr": 57.53, + "hrv": 36.22, + "br": 15.03 + }, + { + "ts": 1700011700, + "hr": 60.04, + "hrv": 29.72, + "br": 14.57 + }, + { + "ts": 1700011760, + "hr": 59.05, + "hrv": 35.57, + "br": 15.1 + }, + { + "ts": 1700011820, + "hr": 61.6, + "hrv": 43.19, + "br": 14.28 + }, + { + "ts": 1700011880, + "hr": 56.84, + "hrv": 42.5, + "br": 14.12 + }, + { + "ts": 1700011940, + "hr": 61.85, + "hrv": 31.09, + "br": 15.02 + }, + { + "ts": 1700012000, + "hr": 59.83, + "hrv": 28.39, + "br": 15.38 + }, + { + "ts": 1700012060, + "hr": 59.5, + "hrv": 36.64, + "br": 14.6 + }, + { + "ts": 1700012120, + "hr": 56.59, + "hrv": 38.0, + "br": 14.07 + }, + { + "ts": 1700012180, + "hr": 58.32, + "hrv": 34.15, + "br": 14.98 + }, + { + "ts": 1700012240, + "hr": 63.05, + "hrv": 29.23, + "br": 14.68 + }, + { + "ts": 1700012300, + "hr": 57.69, + "hrv": 39.33, + "br": 14.38 + }, + { + "ts": 1700012360, + "hr": 60.47, + "hrv": 35.61, + "br": 14.24 + }, + { + "ts": 1700012420, + "hr": 57.9, + "hrv": 36.92, + "br": 15.13 + }, + { + "ts": 1700012480, + "hr": 59.21, + "hrv": 37.24, + "br": 14.32 + }, + { + "ts": 1700012540, + "hr": 61.01, + "hrv": 38.2, + "br": 14.05 + }, + { + "ts": 1700012600, + "hr": 57.79, + "hrv": 44.85, + "br": 14.03 + }, + { + "ts": 1700012660, + "hr": 55.73, + "hrv": 37.76, + "br": 15.14 + }, + { + "ts": 1700012720, + "hr": 61.29, + "hrv": 39.91, + "br": 14.5 + }, + { + "ts": 1700012780, + "hr": 60.43, + "hrv": 38.84, + "br": 14.71 + }, + { + "ts": 1700012840, + "hr": 58.44, + "hrv": 32.32, + "br": 14.46 + }, + { + "ts": 1700012900, + "hr": 56.95, + "hrv": 37.27, + "br": 14.4 + }, + { + "ts": 1700012960, + "hr": 56.87, + "hrv": 28.16, + "br": 13.94 + }, + { + "ts": 1700013020, + "hr": 56.45, + "hrv": 35.76, + "br": 14.52 + }, + { + "ts": 1700013080, + "hr": 55.11, + "hrv": 31.09, + "br": 15.47 + }, + { + "ts": 1700013140, + "hr": 58.04, + "hrv": 35.75, + "br": 14.02 + }, + { + "ts": 1700013200, + "hr": 54.76, + "hrv": 37.12, + "br": 13.99 + }, + { + "ts": 1700013260, + "hr": 58.41, + "hrv": 43.38, + "br": 14.2 + }, + { + "ts": 1700013320, + "hr": 59.41, + "hrv": 37.13, + "br": 14.4 + }, + { + "ts": 1700013380, + "hr": 57.51, + "hrv": 34.82, + "br": 14.28 + }, + { + "ts": 1700013440, + "hr": null, + "hrv": 37.88, + "br": 15.03 + }, + { + "ts": 1700013500, + "hr": 56.67, + "hrv": 33.02, + "br": 14.74 + }, + { + "ts": 1700013560, + "hr": 59.46, + "hrv": 37.81, + "br": 13.6 + }, + { + "ts": 1700013620, + "hr": 54.91, + "hrv": 45.83, + "br": 14.64 + }, + { + "ts": 1700013680, + "hr": 56.05, + "hrv": 33.94, + "br": 14.3 + }, + { + "ts": 1700013740, + "hr": 58.25, + "hrv": 45.27, + "br": 15.21 + }, + { + "ts": 1700013800, + "hr": 56.17, + "hrv": 39.88, + "br": 14.27 + }, + { + "ts": 1700013860, + "hr": 52.26, + "hrv": 56.32, + "br": 15.06 + }, + { + "ts": 1700013920, + "hr": 47.47, + "hrv": 50.95, + "br": 14.23 + }, + { + "ts": 1700013980, + "hr": 50.33, + "hrv": 48.19, + "br": 15.72 + }, + { + "ts": 1700014040, + "hr": 51.31, + "hrv": 57.55, + "br": 14.08 + }, + { + "ts": 1700014100, + "hr": 51.13, + "hrv": 59.62, + "br": 14.12 + }, + { + "ts": 1700014160, + "hr": 55.01, + "hrv": 50.75, + "br": 15.33 + }, + { + "ts": 1700014220, + "hr": 52.03, + "hrv": 62.6, + "br": 14.7 + }, + { + "ts": 1700014280, + "hr": 51.37, + "hrv": 58.49, + "br": 15.34 + }, + { + "ts": 1700014340, + "hr": 50.78, + "hrv": 46.09, + "br": 13.79 + }, + { + "ts": 1700014400, + "hr": 49.19, + "hrv": 62.39, + "br": 14.85 + }, + { + "ts": 1700014460, + "hr": 50.09, + "hrv": 55.51, + "br": 14.61 + }, + { + "ts": 1700014520, + "hr": 52.74, + "hrv": 44.79, + "br": 14.66 + }, + { + "ts": 1700014580, + "hr": 50.19, + "hrv": 59.64, + "br": 13.55 + }, + { + "ts": 1700014640, + "hr": 51.16, + "hrv": 56.09, + "br": 12.95 + }, + { + "ts": 1700014700, + "hr": 50.61, + "hrv": 56.55, + "br": 13.65 + }, + { + "ts": 1700014760, + "hr": 50.18, + "hrv": 53.68, + "br": 13.53 + }, + { + "ts": 1700014820, + "hr": 51.99, + "hrv": 58.25, + "br": 13.91 + }, + { + "ts": 1700014880, + "hr": 49.75, + "hrv": 56.67, + "br": 15.03 + }, + { + "ts": 1700014940, + "hr": 48.11, + "hrv": 55.37, + "br": 14.07 + }, + { + "ts": 1700015000, + "hr": 49.29, + "hrv": 43.88, + "br": 13.69 + }, + { + "ts": 1700015060, + "hr": 53.02, + "hrv": 46.01, + "br": 14.09 + }, + { + "ts": 1700015120, + "hr": 49.51, + "hrv": 65.26, + "br": 14.3 + }, + { + "ts": 1700015180, + "hr": 50.46, + "hrv": 46.77, + "br": 13.91 + }, + { + "ts": 1700015240, + "hr": 49.04, + "hrv": 58.73, + "br": 14.84 + }, + { + "ts": 1700015300, + "hr": 49.15, + "hrv": 42.17, + "br": 14.33 + }, + { + "ts": 1700015360, + "hr": 46.74, + "hrv": 70.68, + "br": 13.82 + }, + { + "ts": 1700015420, + "hr": 50.27, + "hrv": 48.03, + "br": 14.88 + }, + { + "ts": 1700015480, + "hr": 54.32, + "hrv": 59.25, + "br": 15.59 + }, + { + "ts": 1700015540, + "hr": 55.07, + "hrv": 30.79, + "br": 14.39 + }, + { + "ts": 1700015600, + "hr": 53.65, + "hrv": 37.01, + "br": 14.43 + }, + { + "ts": 1700015660, + "hr": 55.12, + "hrv": 45.25, + "br": 14.03 + }, + { + "ts": 1700015720, + "hr": 54.81, + "hrv": 30.07, + "br": 14.57 + }, + { + "ts": 1700015780, + "hr": 55.62, + "hrv": 32.62, + "br": 13.81 + }, + { + "ts": 1700015840, + "hr": 56.07, + "hrv": 33.7, + "br": 14.92 + }, + { + "ts": 1700015900, + "hr": 55.84, + "hrv": 39.68, + "br": 14.86 + }, + { + "ts": 1700015960, + "hr": 54.81, + "hrv": 29.47, + "br": 14.41 + }, + { + "ts": 1700016020, + "hr": 56.23, + "hrv": 29.48, + "br": 14.01 + }, + { + "ts": 1700016080, + "hr": 56.9, + "hrv": 35.9, + "br": 15.38 + }, + { + "ts": 1700016140, + "hr": 54.72, + "hrv": 21.32, + "br": 16.09 + }, + { + "ts": 1700016200, + "hr": 55.47, + "hrv": 38.61, + "br": 13.61 + }, + { + "ts": 1700016260, + "hr": 56.22, + "hrv": 45.2, + "br": 14.55 + }, + { + "ts": 1700016320, + "hr": 53.1, + "hrv": 30.48, + "br": 15.3 + }, + { + "ts": 1700016380, + "hr": 55.98, + "hrv": 31.73, + "br": 14.29 + }, + { + "ts": 1700016440, + "hr": 57.16, + "hrv": 34.54, + "br": 13.96 + }, + { + "ts": 1700016500, + "hr": 57.82, + "hrv": 53.93, + "br": 15.01 + }, + { + "ts": 1700016560, + "hr": 55.03, + "hrv": 31.98, + "br": 14.26 + }, + { + "ts": 1700016620, + "hr": 55.9, + "hrv": 36.82, + "br": 13.77 + }, + { + "ts": 1700016680, + "hr": 57.81, + "hrv": 46.94, + "br": 14.35 + }, + { + "ts": 1700016740, + "hr": 54.9, + "hrv": 45.06, + "br": 15.52 + }, + { + "ts": 1700016800, + "hr": 55.85, + "hrv": 40.5, + "br": 13.97 + }, + { + "ts": 1700016860, + "hr": 56.75, + "hrv": 38.9, + "br": 14.78 + }, + { + "ts": 1700016920, + "hr": 58.21, + "hrv": 35.8, + "br": 14.45 + }, + { + "ts": 1700016980, + "hr": 54.64, + "hrv": 42.56, + "br": 16.29 + }, + { + "ts": 1700017040, + "hr": 57.18, + "hrv": 46.44, + "br": 13.77 + }, + { + "ts": 1700017100, + "hr": 57.17, + "hrv": 35.42, + "br": 14.57 + }, + { + "ts": 1700017160, + "hr": 58.95, + "hrv": 30.28, + "br": 14.91 + }, + { + "ts": 1700017220, + "hr": 57.73, + "hrv": 31.42, + "br": 14.23 + }, + { + "ts": 1700017280, + "hr": 60.09, + "hrv": 38.72, + "br": 15.45 + }, + { + "ts": 1700017340, + "hr": 57.89, + "hrv": 45.58, + "br": 15.08 + }, + { + "ts": 1700017400, + "hr": 57.23, + "hrv": 49.92, + "br": 14.5 + }, + { + "ts": 1700017460, + "hr": 59.38, + "hrv": 41.96, + "br": 14.42 + }, + { + "ts": 1700017520, + "hr": 56.38, + "hrv": 42.93, + "br": 14.2 + }, + { + "ts": 1700017580, + "hr": 56.8, + "hrv": 59.12, + "br": 14.28 + }, + { + "ts": 1700017640, + "hr": null, + "hrv": 55.45, + "br": 14.4 + }, + { + "ts": 1700017700, + "hr": 53.09, + "hrv": 44.85, + "br": 14.28 + }, + { + "ts": 1700017760, + "hr": 52.14, + "hrv": 58.9, + "br": 13.75 + }, + { + "ts": 1700017820, + "hr": 53.23, + "hrv": 61.62, + "br": 14.26 + }, + { + "ts": 1700017880, + "hr": 55.76, + "hrv": 41.41, + "br": 13.99 + }, + { + "ts": 1700017940, + "hr": 52.97, + "hrv": 56.86, + "br": 13.95 + }, + { + "ts": 1700018000, + "hr": 53.26, + "hrv": 55.8, + "br": 15.51 + }, + { + "ts": 1700018060, + "hr": 53.12, + "hrv": 49.37, + "br": 14.67 + }, + { + "ts": 1700018120, + "hr": 54.28, + "hrv": 61.82, + "br": 14.19 + }, + { + "ts": 1700018180, + "hr": 53.93, + "hrv": 68.06, + "br": 14.25 + }, + { + "ts": 1700018240, + "hr": 54.57, + "hrv": 58.94, + "br": 14.61 + }, + { + "ts": 1700018300, + "hr": 55.34, + "hrv": 58.63, + "br": 13.63 + }, + { + "ts": 1700018360, + "hr": 54.65, + "hrv": 42.08, + "br": 15.03 + }, + { + "ts": 1700018420, + "hr": 54.77, + "hrv": 57.44, + "br": 13.74 + }, + { + "ts": 1700018480, + "hr": 55.42, + "hrv": 52.74, + "br": 14.16 + }, + { + "ts": 1700018540, + "hr": 53.7, + "hrv": 64.14, + "br": 15.01 + }, + { + "ts": 1700018600, + "hr": 53.17, + "hrv": 44.61, + "br": 13.73 + }, + { + "ts": 1700018660, + "hr": 55.6, + "hrv": 42.19, + "br": 14.8 + }, + { + "ts": 1700018720, + "hr": 54.9, + "hrv": 48.06, + "br": 13.68 + }, + { + "ts": 1700018780, + "hr": 56.44, + "hrv": 49.03, + "br": 14.03 + }, + { + "ts": 1700018840, + "hr": 52.6, + "hrv": 62.43, + "br": 14.21 + }, + { + "ts": 1700018900, + "hr": 56.23, + "hrv": 61.32, + "br": 14.58 + }, + { + "ts": 1700018960, + "hr": 55.62, + "hrv": 63.64, + "br": 14.6 + }, + { + "ts": 1700019020, + "hr": 56.98, + "hrv": 47.12, + "br": 14.89 + }, + { + "ts": 1700019080, + "hr": 52.55, + "hrv": 54.38, + "br": 14.89 + }, + { + "ts": 1700019140, + "hr": 56.9, + "hrv": 51.03, + "br": 15.02 + }, + { + "ts": 1700019200, + "hr": 55.57, + "hrv": 59.85, + "br": 13.94 + }, + { + "ts": 1700019260, + "hr": 63.54, + "hrv": 23.86, + "br": 16.71 + }, + { + "ts": 1700019320, + "hr": 65.73, + "hrv": 36.65, + "br": 16.49 + }, + { + "ts": 1700019380, + "hr": 63.73, + "hrv": 21.7, + "br": 15.15 + }, + { + "ts": 1700019440, + "hr": 64.11, + "hrv": 26.16, + "br": 16.48 + }, + { + "ts": 1700019500, + "hr": 62.17, + "hrv": 32.23, + "br": 16.83 + }, + { + "ts": 1700019560, + "hr": 64.21, + "hrv": 23.38, + "br": 15.77 + }, + { + "ts": 1700019620, + "hr": 64.15, + "hrv": 36.9, + "br": 16.43 + }, + { + "ts": 1700019680, + "hr": 64.66, + "hrv": 36.18, + "br": 15.67 + }, + { + "ts": 1700019740, + "hr": 62.36, + "hrv": 17.04, + "br": 16.62 + } + ], + "movement": [ + { + "ts": 1700000000, + "total": 10.0 + }, + { + "ts": 1700000060, + "total": 4.0 + }, + { + "ts": 1700000120, + "total": 3.0 + }, + { + "ts": 1700000180, + "total": 10.0 + }, + { + "ts": 1700000240, + "total": 7.0 + }, + { + "ts": 1700000300, + "total": 11.0 + }, + { + "ts": 1700000360, + "total": 8.0 + }, + { + "ts": 1700000420, + "total": 0.0 + }, + { + "ts": 1700000480, + "total": 8.0 + }, + { + "ts": 1700000540, + "total": 4.0 + }, + { + "ts": 1700000600, + "total": 9.0 + }, + { + "ts": 1700000660, + "total": 9.0 + }, + { + "ts": 1700000720, + "total": 1.0 + }, + { + "ts": 1700000780, + "total": 11.0 + }, + { + "ts": 1700000840, + "total": 6.0 + }, + { + "ts": 1700000900, + "total": 2.0 + }, + { + "ts": 1700000960, + "total": 2.0 + }, + { + "ts": 1700001020, + "total": 4.0 + }, + { + "ts": 1700001080, + "total": 11.0 + }, + { + "ts": 1700001140, + "total": 9.0 + }, + { + "ts": 1700001200, + "total": 10.0 + }, + { + "ts": 1700001260, + "total": 5.0 + }, + { + "ts": 1700001320, + "total": 9.0 + }, + { + "ts": 1700001380, + "total": 1.0 + }, + { + "ts": 1700001440, + "total": 3.0 + }, + { + "ts": 1700001500, + "total": 4.0 + }, + { + "ts": 1700001560, + "total": 4.0 + }, + { + "ts": 1700001620, + "total": 11.0 + }, + { + "ts": 1700001680, + "total": 11.0 + }, + { + "ts": 1700001740, + "total": 5.0 + }, + { + "ts": 1700001800, + "total": 7.0 + }, + { + "ts": 1700001860, + "total": 10.0 + }, + { + "ts": 1700001920, + "total": 8.0 + }, + { + "ts": 1700001980, + "total": 8.0 + }, + { + "ts": 1700002040, + "total": 1.0 + }, + { + "ts": 1700002100, + "total": 0.0 + }, + { + "ts": 1700002160, + "total": 5.0 + }, + { + "ts": 1700002220, + "total": 11.0 + }, + { + "ts": 1700002280, + "total": 3.0 + }, + { + "ts": 1700002340, + "total": 1.0 + }, + { + "ts": 1700002400, + "total": 8.0 + }, + { + "ts": 1700002460, + "total": 9.0 + }, + { + "ts": 1700002520, + "total": 10.0 + }, + { + "ts": 1700002580, + "total": 4.0 + }, + { + "ts": 1700002640, + "total": 7.0 + }, + { + "ts": 1700002700, + "total": 6.0 + }, + { + "ts": 1700002760, + "total": 6.0 + }, + { + "ts": 1700002820, + "total": 1.0 + }, + { + "ts": 1700002880, + "total": 10.0 + }, + { + "ts": 1700002940, + "total": 4.0 + }, + { + "ts": 1700003000, + "total": 9.0 + }, + { + "ts": 1700003060, + "total": 2.0 + }, + { + "ts": 1700003120, + "total": 7.0 + }, + { + "ts": 1700003180, + "total": 11.0 + }, + { + "ts": 1700003240, + "total": 4.0 + }, + { + "ts": 1700003300, + "total": 5.0 + }, + { + "ts": 1700003360, + "total": 9.0 + }, + { + "ts": 1700003420, + "total": 1.0 + }, + { + "ts": 1700003480, + "total": 11.0 + }, + { + "ts": 1700003540, + "total": 3.0 + }, + { + "ts": 1700003600, + "total": 5.0 + }, + { + "ts": 1700003660, + "total": 0.0 + }, + { + "ts": 1700003720, + "total": 4.0 + }, + { + "ts": 1700003780, + "total": 3.0 + }, + { + "ts": 1700003840, + "total": 6.0 + }, + { + "ts": 1700003900, + "total": 6.0 + }, + { + "ts": 1700003960, + "total": 11.0 + }, + { + "ts": 1700004020, + "total": 11.0 + }, + { + "ts": 1700004080, + "total": 0.0 + }, + { + "ts": 1700004140, + "total": 4.0 + }, + { + "ts": 1700004200, + "total": 3.0 + }, + { + "ts": 1700004260, + "total": 2.0 + }, + { + "ts": 1700004320, + "total": 11.0 + }, + { + "ts": 1700004380, + "total": 2.0 + }, + { + "ts": 1700004440, + "total": 10.0 + }, + { + "ts": 1700004500, + "total": 5.0 + }, + { + "ts": 1700004560, + "total": 4.0 + }, + { + "ts": 1700004620, + "total": 0.0 + }, + { + "ts": 1700004680, + "total": 6.0 + }, + { + "ts": 1700004740, + "total": 5.0 + }, + { + "ts": 1700004800, + "total": 9.0 + }, + { + "ts": 1700004860, + "total": 5.0 + }, + { + "ts": 1700004920, + "total": 5.0 + }, + { + "ts": 1700004980, + "total": 467.0 + }, + { + "ts": 1700005040, + "total": 4.0 + }, + { + "ts": 1700005100, + "total": 2.0 + }, + { + "ts": 1700005160, + "total": 5.0 + }, + { + "ts": 1700005220, + "total": 3.0 + }, + { + "ts": 1700005280, + "total": 275.0 + }, + { + "ts": 1700005340, + "total": 522.0 + }, + { + "ts": 1700005400, + "total": 382.0 + }, + { + "ts": 1700005460, + "total": 467.0 + }, + { + "ts": 1700005520, + "total": 313.0 + }, + { + "ts": 1700005580, + "total": 439.0 + }, + { + "ts": 1700005640, + "total": 305.0 + }, + { + "ts": 1700005700, + "total": 280.0 + }, + { + "ts": 1700005760, + "total": 170.0 + }, + { + "ts": 1700005820, + "total": 294.0 + }, + { + "ts": 1700005880, + "total": 247.0 + }, + { + "ts": 1700005940, + "total": 265.0 + }, + { + "ts": 1700006000, + "total": 574.0 + }, + { + "ts": 1700006060, + "total": 277.0 + }, + { + "ts": 1700006120, + "total": 493.0 + }, + { + "ts": 1700006180, + "total": 506.0 + }, + { + "ts": 1700006240, + "total": 413.0 + }, + { + "ts": 1700006300, + "total": 450.0 + }, + { + "ts": 1700006360, + "total": 274.0 + }, + { + "ts": 1700006420, + "total": 291.0 + }, + { + "ts": 1700006480, + "total": 239.0 + }, + { + "ts": 1700006540, + "total": 578.0 + }, + { + "ts": 1700006600, + "total": 578.0 + }, + { + "ts": 1700006660, + "total": 489.0 + }, + { + "ts": 1700006720, + "total": 341.0 + }, + { + "ts": 1700006780, + "total": 554.0 + }, + { + "ts": 1700006840, + "total": 171.0 + }, + { + "ts": 1700006900, + "total": 458.0 + }, + { + "ts": 1700006960, + "total": 561.0 + }, + { + "ts": 1700007020, + "total": 382.0 + }, + { + "ts": 1700007080, + "total": 7.0 + }, + { + "ts": 1700007140, + "total": 5.0 + }, + { + "ts": 1700007200, + "total": 8.0 + }, + { + "ts": 1700007260, + "total": 5.0 + }, + { + "ts": 1700007320, + "total": 5.0 + }, + { + "ts": 1700007380, + "total": 10.0 + }, + { + "ts": 1700007440, + "total": 11.0 + }, + { + "ts": 1700007500, + "total": 5.0 + }, + { + "ts": 1700007560, + "total": 10.0 + }, + { + "ts": 1700007620, + "total": 9.0 + }, + { + "ts": 1700007680, + "total": 11.0 + }, + { + "ts": 1700007740, + "total": 7.0 + }, + { + "ts": 1700007800, + "total": 6.0 + }, + { + "ts": 1700007860, + "total": 6.0 + }, + { + "ts": 1700007920, + "total": 1.0 + }, + { + "ts": 1700007980, + "total": 8.0 + }, + { + "ts": 1700008040, + "total": 11.0 + }, + { + "ts": 1700008100, + "total": 9.0 + }, + { + "ts": 1700008160, + "total": 2.0 + }, + { + "ts": 1700008220, + "total": 3.0 + }, + { + "ts": 1700008280, + "total": 3.0 + }, + { + "ts": 1700008340, + "total": 11.0 + }, + { + "ts": 1700008400, + "total": 0.0 + }, + { + "ts": 1700008460, + "total": 2.0 + }, + { + "ts": 1700008520, + "total": 4.0 + }, + { + "ts": 1700008580, + "total": 9.0 + }, + { + "ts": 1700008640, + "total": 11.0 + }, + { + "ts": 1700008700, + "total": 9.0 + }, + { + "ts": 1700008760, + "total": 3.0 + }, + { + "ts": 1700008820, + "total": 7.0 + }, + { + "ts": 1700008880, + "total": 11.0 + }, + { + "ts": 1700008940, + "total": 8.0 + }, + { + "ts": 1700009000, + "total": 4.0 + }, + { + "ts": 1700009060, + "total": 5.0 + }, + { + "ts": 1700009120, + "total": 8.0 + }, + { + "ts": 1700009180, + "total": 3.0 + }, + { + "ts": 1700009240, + "total": 197.0 + }, + { + "ts": 1700009300, + "total": 3.0 + }, + { + "ts": 1700009360, + "total": 9.0 + }, + { + "ts": 1700009420, + "total": 2.0 + }, + { + "ts": 1700009480, + "total": 7.0 + }, + { + "ts": 1700009540, + "total": 3.0 + }, + { + "ts": 1700009600, + "total": 10.0 + }, + { + "ts": 1700009660, + "total": 2.0 + }, + { + "ts": 1700009720, + "total": 11.0 + }, + { + "ts": 1700009780, + "total": 0.0 + }, + { + "ts": 1700009840, + "total": 1.0 + }, + { + "ts": 1700009900, + "total": 6.0 + }, + { + "ts": 1700009960, + "total": 3.0 + }, + { + "ts": 1700010020, + "total": 0.0 + }, + { + "ts": 1700010080, + "total": 11.0 + }, + { + "ts": 1700010140, + "total": 10.0 + }, + { + "ts": 1700010200, + "total": 11.0 + }, + { + "ts": 1700010260, + "total": 3.0 + }, + { + "ts": 1700010320, + "total": 7.0 + }, + { + "ts": 1700010380, + "total": 5.0 + }, + { + "ts": 1700010440, + "total": 344.0 + }, + { + "ts": 1700010500, + "total": 8.0 + }, + { + "ts": 1700010560, + "total": 495.0 + }, + { + "ts": 1700010620, + "total": 6.0 + }, + { + "ts": 1700010680, + "total": 8.0 + }, + { + "ts": 1700010740, + "total": 3.0 + }, + { + "ts": 1700010800, + "total": 11.0 + }, + { + "ts": 1700010860, + "total": 4.0 + }, + { + "ts": 1700010920, + "total": 2.0 + }, + { + "ts": 1700010980, + "total": 11.0 + }, + { + "ts": 1700011040, + "total": 10.0 + }, + { + "ts": 1700011100, + "total": 11.0 + }, + { + "ts": 1700011160, + "total": 4.0 + }, + { + "ts": 1700011220, + "total": 3.0 + }, + { + "ts": 1700011280, + "total": 7.0 + }, + { + "ts": 1700011340, + "total": 8.0 + }, + { + "ts": 1700011400, + "total": 6.0 + }, + { + "ts": 1700011460, + "total": 5.0 + }, + { + "ts": 1700011520, + "total": 10.0 + }, + { + "ts": 1700011580, + "total": 10.0 + }, + { + "ts": 1700011640, + "total": 11.0 + }, + { + "ts": 1700011700, + "total": 7.0 + }, + { + "ts": 1700011760, + "total": 5.0 + }, + { + "ts": 1700011820, + "total": 4.0 + }, + { + "ts": 1700011880, + "total": 7.0 + }, + { + "ts": 1700011940, + "total": 11.0 + }, + { + "ts": 1700012000, + "total": 3.0 + }, + { + "ts": 1700012060, + "total": 3.0 + }, + { + "ts": 1700012120, + "total": 10.0 + }, + { + "ts": 1700012180, + "total": 7.0 + }, + { + "ts": 1700012240, + "total": 0.0 + }, + { + "ts": 1700012300, + "total": 5.0 + }, + { + "ts": 1700012360, + "total": 0.0 + }, + { + "ts": 1700012420, + "total": 10.0 + }, + { + "ts": 1700012480, + "total": 9.0 + }, + { + "ts": 1700012540, + "total": 2.0 + }, + { + "ts": 1700012600, + "total": 5.0 + }, + { + "ts": 1700012660, + "total": 8.0 + }, + { + "ts": 1700012720, + "total": 9.0 + }, + { + "ts": 1700012780, + "total": 3.0 + }, + { + "ts": 1700012840, + "total": 0.0 + }, + { + "ts": 1700012900, + "total": 6.0 + }, + { + "ts": 1700012960, + "total": 9.0 + }, + { + "ts": 1700013020, + "total": 6.0 + }, + { + "ts": 1700013080, + "total": 3.0 + }, + { + "ts": 1700013140, + "total": 7.0 + }, + { + "ts": 1700013200, + "total": 0.0 + }, + { + "ts": 1700013260, + "total": 11.0 + }, + { + "ts": 1700013320, + "total": 4.0 + }, + { + "ts": 1700013380, + "total": 9.0 + }, + { + "ts": 1700013440, + "total": 10.0 + }, + { + "ts": 1700013500, + "total": 0.0 + }, + { + "ts": 1700013560, + "total": 0.0 + }, + { + "ts": 1700013620, + "total": 11.0 + }, + { + "ts": 1700013680, + "total": 6.0 + }, + { + "ts": 1700013740, + "total": 3.0 + }, + { + "ts": 1700013800, + "total": 8.0 + }, + { + "ts": 1700013860, + "total": 11.0 + }, + { + "ts": 1700013920, + "total": 11.0 + }, + { + "ts": 1700013980, + "total": 1.0 + }, + { + "ts": 1700014040, + "total": 8.0 + }, + { + "ts": 1700014100, + "total": 7.0 + }, + { + "ts": 1700014160, + "total": 4.0 + }, + { + "ts": 1700014220, + "total": 6.0 + }, + { + "ts": 1700014280, + "total": 9.0 + }, + { + "ts": 1700014340, + "total": 6.0 + }, + { + "ts": 1700014400, + "total": 2.0 + }, + { + "ts": 1700014460, + "total": 11.0 + }, + { + "ts": 1700014520, + "total": 0.0 + }, + { + "ts": 1700014580, + "total": 7.0 + }, + { + "ts": 1700014640, + "total": 1.0 + }, + { + "ts": 1700014700, + "total": 4.0 + }, + { + "ts": 1700014760, + "total": 5.0 + }, + { + "ts": 1700014820, + "total": 5.0 + }, + { + "ts": 1700014880, + "total": 3.0 + }, + { + "ts": 1700014940, + "total": 3.0 + }, + { + "ts": 1700015000, + "total": 11.0 + }, + { + "ts": 1700015060, + "total": 7.0 + }, + { + "ts": 1700015120, + "total": 1.0 + }, + { + "ts": 1700015180, + "total": 2.0 + }, + { + "ts": 1700015240, + "total": 2.0 + }, + { + "ts": 1700015300, + "total": 9.0 + }, + { + "ts": 1700015360, + "total": 6.0 + }, + { + "ts": 1700015420, + "total": 3.0 + }, + { + "ts": 1700015480, + "total": 4.0 + }, + { + "ts": 1700015540, + "total": 9.0 + }, + { + "ts": 1700015600, + "total": 1.0 + }, + { + "ts": 1700015660, + "total": 3.0 + }, + { + "ts": 1700015720, + "total": 11.0 + }, + { + "ts": 1700015780, + "total": 9.0 + }, + { + "ts": 1700015840, + "total": 1.0 + }, + { + "ts": 1700015900, + "total": 8.0 + }, + { + "ts": 1700015960, + "total": 2.0 + }, + { + "ts": 1700016020, + "total": 2.0 + }, + { + "ts": 1700016080, + "total": 8.0 + }, + { + "ts": 1700016140, + "total": 8.0 + }, + { + "ts": 1700016200, + "total": 10.0 + }, + { + "ts": 1700016260, + "total": 6.0 + }, + { + "ts": 1700016320, + "total": 8.0 + }, + { + "ts": 1700016380, + "total": 224.0 + }, + { + "ts": 1700016440, + "total": 3.0 + }, + { + "ts": 1700016500, + "total": 154.0 + }, + { + "ts": 1700016560, + "total": 1.0 + }, + { + "ts": 1700016620, + "total": 1.0 + }, + { + "ts": 1700016680, + "total": 2.0 + }, + { + "ts": 1700016740, + "total": 6.0 + }, + { + "ts": 1700016800, + "total": 3.0 + }, + { + "ts": 1700016860, + "total": 11.0 + }, + { + "ts": 1700016920, + "total": 10.0 + }, + { + "ts": 1700016980, + "total": 9.0 + }, + { + "ts": 1700017040, + "total": 10.0 + }, + { + "ts": 1700017100, + "total": 6.0 + }, + { + "ts": 1700017160, + "total": 11.0 + }, + { + "ts": 1700017220, + "total": 4.0 + }, + { + "ts": 1700017280, + "total": 3.0 + }, + { + "ts": 1700017340, + "total": 8.0 + }, + { + "ts": 1700017400, + "total": 9.0 + }, + { + "ts": 1700017460, + "total": 8.0 + }, + { + "ts": 1700017520, + "total": 4.0 + }, + { + "ts": 1700017580, + "total": 9.0 + }, + { + "ts": 1700017640, + "total": 4.0 + }, + { + "ts": 1700017700, + "total": 4.0 + }, + { + "ts": 1700017760, + "total": 6.0 + }, + { + "ts": 1700017820, + "total": 5.0 + }, + { + "ts": 1700017880, + "total": 5.0 + }, + { + "ts": 1700017940, + "total": 0.0 + }, + { + "ts": 1700018000, + "total": 8.0 + }, + { + "ts": 1700018060, + "total": 5.0 + }, + { + "ts": 1700018120, + "total": 3.0 + }, + { + "ts": 1700018180, + "total": 11.0 + }, + { + "ts": 1700018240, + "total": 0.0 + }, + { + "ts": 1700018300, + "total": 381.0 + }, + { + "ts": 1700018360, + "total": 3.0 + }, + { + "ts": 1700018420, + "total": 4.0 + }, + { + "ts": 1700018480, + "total": 7.0 + }, + { + "ts": 1700018540, + "total": 3.0 + }, + { + "ts": 1700018600, + "total": 8.0 + }, + { + "ts": 1700018660, + "total": 1.0 + }, + { + "ts": 1700018720, + "total": 10.0 + }, + { + "ts": 1700018780, + "total": 6.0 + }, + { + "ts": 1700018840, + "total": 8.0 + }, + { + "ts": 1700018900, + "total": 7.0 + }, + { + "ts": 1700018960, + "total": 6.0 + }, + { + "ts": 1700019020, + "total": 11.0 + }, + { + "ts": 1700019080, + "total": 6.0 + }, + { + "ts": 1700019140, + "total": 0.0 + }, + { + "ts": 1700019200, + "total": 1.0 + }, + { + "ts": 1700019260, + "total": 0.0 + }, + { + "ts": 1700019320, + "total": 7.0 + }, + { + "ts": 1700019380, + "total": 9.0 + }, + { + "ts": 1700019440, + "total": 5.0 + }, + { + "ts": 1700019500, + "total": 2.0 + }, + { + "ts": 1700019560, + "total": 8.0 + }, + { + "ts": 1700019620, + "total": 0.0 + }, + { + "ts": 1700019680, + "total": 3.0 + }, + { + "ts": 1700019740, + "total": 3.0 + } + ], + "contractA": [ + { + "start": 1700000060, + "hrZ": -1.028286841065768, + "hrStdZ": 0.11348822171620755, + "motionZ": -0.34151438545852214, + "nightFraction": 0.0 + }, + { + "start": 1700000360, + "hrZ": -0.7902342283457965, + "hrStdZ": -0.27279094915877133, + "motionZ": -0.47278264144387433, + "nightFraction": 0.01524390243902439 + }, + { + "start": 1700000660, + "hrZ": -0.6010818137240573, + "hrStdZ": -0.10270596765953444, + "motionZ": -0.47278264144387433, + "nightFraction": 0.03048780487804878 + }, + { + "start": 1700000960, + "hrZ": -0.560779452654085, + "hrStdZ": -0.6960737868615342, + "motionZ": -0.3217876146876578, + "nightFraction": 0.04573170731707317 + }, + { + "start": 1700001260, + "hrZ": -0.7058679525059868, + "hrStdZ": 0.260322965249376, + "motionZ": -0.6640841627169484, + "nightFraction": 0.06097560975609756 + }, + { + "start": 1700001560, + "hrZ": -0.6892096432637339, + "hrStdZ": -0.8643709728904386, + "motionZ": -0.2838850692909076, + "nightFraction": 0.07621951219512195 + }, + { + "start": 1700001860, + "hrZ": 0.3645627575124909, + "hrStdZ": 1.1391267677135581, + "motionZ": -0.52245627343106, + "nightFraction": 0.09146341463414634 + }, + { + "start": 1700002160, + "hrZ": 0.8562515625661584, + "hrStdZ": -0.14018364206524356, + "motionZ": -0.4971911515614862, + "nightFraction": 0.10670731707317073 + }, + { + "start": 1700002460, + "hrZ": 0.604093123471696, + "hrStdZ": 0.6210761058695307, + "motionZ": -0.3217876146876578, + "nightFraction": 0.12195121951219512 + }, + { + "start": 1700002760, + "hrZ": 0.8712977773656142, + "hrStdZ": 0.8255221488088875, + "motionZ": -0.4491745561920188, + "nightFraction": 0.13719512195121952 + }, + { + "start": 1700003060, + "hrZ": 1.0351940457168367, + "hrStdZ": -0.5265154971868624, + "motionZ": -0.47278264144387433, + "nightFraction": 0.1524390243902439 + }, + { + "start": 1700003360, + "hrZ": 0.626259422060178, + "hrStdZ": -0.1846568880686506, + "motionZ": -0.47278264144387433, + "nightFraction": 0.1676829268292683 + }, + { + "start": 1700003660, + "hrZ": 0.6961168479148027, + "hrStdZ": 0.42825797431813223, + "motionZ": -0.7647102584530985, + "nightFraction": 0.18292682926829268 + }, + { + "start": 1700003960, + "hrZ": 0.11630021332145865, + "hrStdZ": -0.9215410195687398, + "motionZ": -0.47278264144387433, + "nightFraction": 0.19817073170731708 + }, + { + "start": 1700004260, + "hrZ": 0.5166369999498556, + "hrStdZ": 0.2332198387472241, + "motionZ": -0.4491745561920188, + "nightFraction": 0.21341463414634146 + }, + { + "start": 1700004560, + "hrZ": -0.031475110601774124, + "hrStdZ": -0.050135363371515904, + "motionZ": -0.6040508974292268, + "nightFraction": 0.22865853658536586 + }, + { + "start": 1700004860, + "hrZ": -0.1367986141979724, + "hrStdZ": -0.1455711776353011, + "motionZ": 1.529406578111034, + "nightFraction": 0.24390243902439024 + }, + { + "start": 1700005160, + "hrZ": 0.8288459570385778, + "hrStdZ": 2.758923203218567, + "motionZ": 2.175908445413887, + "nightFraction": 0.25914634146341464 + }, + { + "start": 1700005460, + "hrZ": 1.659074595080016, + "hrStdZ": -0.8468510552499723, + "motionZ": 2.4770693019439034, + "nightFraction": 0.27439024390243905 + }, + { + "start": 1700005760, + "hrZ": 1.5730962247974056, + "hrStdZ": -0.8956650970994249, + "motionZ": 2.3678763083287793, + "nightFraction": 0.2896341463414634 + }, + { + "start": 1700006060, + "hrZ": 1.5865303451540642, + "hrStdZ": -0.4291491574219605, + "motionZ": 2.5996428241123954, + "nightFraction": 0.3048780487804878 + }, + { + "start": 1700006360, + "hrZ": 1.553213726669553, + "hrStdZ": 0.7204982587725353, + "motionZ": 2.536751465437261, + "nightFraction": 0.3201219512195122 + }, + { + "start": 1700006660, + "hrZ": 1.397915295346592, + "hrStdZ": -0.006025933225379629, + "motionZ": 2.5559521257772664, + "nightFraction": 0.3353658536585366 + }, + { + "start": 1700006960, + "hrZ": -0.38076223987487273, + "hrStdZ": 5.7301388681329195, + "motionZ": 2.0254788390769414, + "nightFraction": 0.35060975609756095 + }, + { + "start": 1700007260, + "hrZ": -1.7897327828811214, + "hrStdZ": -0.2892019183081337, + "motionZ": -0.3217876146876578, + "nightFraction": 0.36585365853658536 + }, + { + "start": 1700007560, + "hrZ": -1.6295980682297617, + "hrStdZ": -0.8815166203465411, + "motionZ": -0.19703507821755983, + "nightFraction": 0.38109756097560976 + }, + { + "start": 1700007860, + "hrZ": -1.47483700172107, + "hrStdZ": 0.6028371580866748, + "motionZ": -0.34151438545852214, + "nightFraction": 0.39634146341463417 + }, + { + "start": 1700008160, + "hrZ": -1.1319982502191652, + "hrStdZ": -0.3107098167081021, + "motionZ": -0.7647102584530985, + "nightFraction": 0.4115853658536585 + }, + { + "start": 1700008460, + "hrZ": -1.1013684558059866, + "hrStdZ": -0.607006226476151, + "motionZ": -0.34151438545852214, + "nightFraction": 0.4268292682926829 + }, + { + "start": 1700008760, + "hrZ": -1.07181339102134, + "hrStdZ": -0.6034251236857966, + "motionZ": -0.3826674198643134, + "nightFraction": 0.4420731707317073 + }, + { + "start": 1700009060, + "hrZ": -0.4721142583001453, + "hrStdZ": 1.596432138032722, + "motionZ": 0.9518459049230744, + "nightFraction": 0.4573170731707317 + }, + { + "start": 1700009360, + "hrZ": 0.8418770537845329, + "hrStdZ": -0.08473104901623967, + "motionZ": -0.42631606194237864, + "nightFraction": 0.4725609756097561 + }, + { + "start": 1700009660, + "hrZ": 0.9056891254786558, + "hrStdZ": -0.7118834422316325, + "motionZ": -0.7295822139564274, + "nightFraction": 0.4878048780487805 + }, + { + "start": 1700009960, + "hrZ": 1.2496026066090924, + "hrStdZ": -1.0471847127984364, + "motionZ": -0.34151438545852214, + "nightFraction": 0.5030487804878049 + }, + { + "start": 1700010260, + "hrZ": 1.1684605196548807, + "hrStdZ": -0.4457419628469774, + "motionZ": 1.3321300674214178, + "nightFraction": 0.5182926829268293 + }, + { + "start": 1700010560, + "hrZ": 1.158787952998087, + "hrStdZ": -0.6989543056938425, + "motionZ": 1.5865780360167359, + "nightFraction": 0.5335365853658537 + }, + { + "start": 1700010860, + "hrZ": 1.1184855919281145, + "hrStdZ": -0.0973985633405905, + "motionZ": -0.2838850692909076, + "nightFraction": 0.5487804878048781 + }, + { + "start": 1700011160, + "hrZ": 1.1372933604274371, + "hrStdZ": -0.10735324203741113, + "motionZ": -0.4971911515614862, + "nightFraction": 0.5640243902439024 + }, + { + "start": 1700011460, + "hrZ": 0.7751094756119467, + "hrStdZ": -0.2714257876065082, + "motionZ": -0.19703507821755983, + "nightFraction": 0.5792682926829268 + }, + { + "start": 1700011760, + "hrZ": 0.8019777163252622, + "hrStdZ": 0.34164854541862494, + "motionZ": -0.4491745561920188, + "nightFraction": 0.5945121951219512 + }, + { + "start": 1700012060, + "hrZ": 0.5859570609902056, + "hrStdZ": 0.7672441676698958, + "motionZ": -0.5758126863001315, + "nightFraction": 0.6097560975609756 + }, + { + "start": 1700012360, + "hrZ": 0.6520529331449616, + "hrStdZ": -0.23223176570475923, + "motionZ": -0.5486403259838895, + "nightFraction": 0.625 + }, + { + "start": 1700012660, + "hrZ": 0.46182578889469045, + "hrStdZ": 0.610794745094982, + "motionZ": -0.5486403259838895, + "nightFraction": 0.6402439024390244 + }, + { + "start": 1700012960, + "hrZ": -0.16205476046848707, + "hrStdZ": -0.3538773662956457, + "motionZ": -0.5758126863001315, + "nightFraction": 0.6554878048780488 + }, + { + "start": 1700013260, + "hrZ": 0.30921418164306075, + "hrStdZ": -0.5457250707444471, + "motionZ": -0.3617969124071884, + "nightFraction": 0.6707317073170732 + }, + { + "start": 1700013560, + "hrZ": 0.031933937481648945, + "hrStdZ": 0.14062272080845345, + "motionZ": -0.4971911515614862, + "nightFraction": 0.6859756097560976 + }, + { + "start": 1700013860, + "hrZ": -1.7059038718555786, + "hrStdZ": 0.12673988748503537, + "motionZ": -0.2838850692909076, + "nightFraction": 0.7012195121951219 + }, + { + "start": 1700014160, + "hrZ": -1.3899333610669913, + "hrStdZ": 0.4326166646050993, + "motionZ": -0.52245627343106, + "nightFraction": 0.7164634146341463 + }, + { + "start": 1700014460, + "hrZ": -1.5828473293885934, + "hrStdZ": -0.6036423642390462, + "motionZ": -0.633442002467746, + "nightFraction": 0.7317073170731707 + }, + { + "start": 1700014760, + "hrZ": -1.8767858827922619, + "hrStdZ": -0.27472031834399857, + "motionZ": -0.52245627343106, + "nightFraction": 0.7469512195121951 + }, + { + "start": 1700015060, + "hrZ": -1.7768360273387296, + "hrStdZ": -0.043634811445312546, + "motionZ": -0.6960886357521511, + "nightFraction": 0.7621951219512195 + }, + { + "start": 1700015360, + "hrZ": -1.3001934370845178, + "hrStdZ": 1.7360088642374587, + "motionZ": -0.633442002467746, + "nightFraction": 0.7774390243902439 + }, + { + "start": 1700015660, + "hrZ": -0.364641295446883, + "hrStdZ": -1.1552737077400919, + "motionZ": -0.4041610187429272, + "nightFraction": 0.7926829268292683 + }, + { + "start": 1700015960, + "hrZ": -0.32863785289104247, + "hrStdZ": -0.7470786580095934, + "motionZ": -0.4491745561920188, + "nightFraction": 0.8079268292682927 + }, + { + "start": 1700016260, + "hrZ": -0.21310441782378725, + "hrStdZ": 0.10928887974693101, + "motionZ": 1.3849272708700713, + "nightFraction": 0.823170731707317 + }, + { + "start": 1700016560, + "hrZ": -0.2555562381508237, + "hrStdZ": -0.5243657788372772, + "motionZ": -1.0215098309656516, + "nightFraction": 0.8384146341463414 + }, + { + "start": 1700016860, + "hrZ": -0.01589153098805136, + "hrStdZ": -0.37335404829582197, + "motionZ": -0.14954652254362996, + "nightFraction": 0.8536585365853658 + }, + { + "start": 1700017160, + "hrZ": 0.41077613153939413, + "hrStdZ": -0.5427698767027761, + "motionZ": -0.34151438545852214, + "nightFraction": 0.8689024390243902 + }, + { + "start": 1700017460, + "hrZ": -0.1173191396808175, + "hrStdZ": 0.7849731361058033, + "motionZ": -0.47278264144387433, + "nightFraction": 0.8841463414634146 + }, + { + "start": 1700017760, + "hrZ": -0.9073797578558507, + "hrStdZ": -0.3341954037518103, + "motionZ": -0.6040508974292268, + "nightFraction": 0.899390243902439 + }, + { + "start": 1700018060, + "hrZ": -0.698882209920524, + "hrStdZ": -0.8627874599701694, + "motionZ": 1.3939610417394726, + "nightFraction": 0.9146341463414634 + }, + { + "start": 1700018360, + "hrZ": -0.6736260636500073, + "hrStdZ": -0.7835397617748301, + "motionZ": -0.5758126863001315, + "nightFraction": 0.9298780487804879 + }, + { + "start": 1700018660, + "hrZ": -0.45545594905789055, + "hrStdZ": -0.14605155765017167, + "motionZ": -0.4041610187429272, + "nightFraction": 0.9451219512195121 + }, + { + "start": 1700018960, + "hrZ": -0.3560434584186231, + "hrStdZ": 0.09354064872170186, + "motionZ": -0.6040508974292268, + "nightFraction": 0.9603658536585366 + }, + { + "start": 1700019260, + "hrZ": 1.8826183578148006, + "hrStdZ": -0.41130468049486485, + "motionZ": -0.633442002467746, + "nightFraction": 0.975609756097561 + } + ], + "contractB": [ + { + "start": 1700000060, + "hrZ": -1.028286841065768, + "hrStdZ": 0.11348822171620755, + "motionZ": -0.34151438545852214, + "nightFraction": 0.0, + "hrvRel": 1.2094599890273532, + "hrv15Rel": 1.254066648141419, + "hr15StdRel": 0.3555309304546723, + "br15Std": 0.5985657789546083, + "motion15": 4.30406509320417, + "minutesSinceBed": 0.0 + }, + { + "start": 1700000360, + "hrZ": -0.7902342283457965, + "hrStdZ": -0.27279094915877133, + "motionZ": -0.47278264144387433, + "nightFraction": 0.01524390243902439, + "hrvRel": 1.304412571518144, + "hrv15Rel": 1.2626675288031977, + "hr15StdRel": 0.37226337211728117, + "br15Std": 0.55589981954935, + "motion15": 4.564348191467836, + "minutesSinceBed": 5.0 + }, + { + "start": 1700000660, + "hrZ": -0.6010818137240573, + "hrStdZ": -0.10270596765953444, + "motionZ": -0.47278264144387433, + "nightFraction": 0.03048780487804878, + "hrvRel": 1.2556234814640646, + "hrv15Rel": 1.2960410298612746, + "hr15StdRel": 0.2948131331529299, + "br15Std": 0.47498026274783245, + "motion15": 4.605170185988092, + "minutesSinceBed": 10.0 + }, + { + "start": 1700000960, + "hrZ": -0.560779452654085, + "hrStdZ": -0.6960737868615342, + "motionZ": -0.3217876146876578, + "nightFraction": 0.04573170731707317, + "hrvRel": 1.314954150011756, + "hrv15Rel": 1.311544791911592, + "hr15StdRel": 0.33170229954686214, + "br15Std": 0.41722108947175707, + "motion15": 4.5217885770490405, + "minutesSinceBed": 15.0 + }, + { + "start": 1700001260, + "hrZ": -0.7058679525059868, + "hrStdZ": 0.260322965249376, + "motionZ": -0.6640841627169484, + "nightFraction": 0.06097560975609756, + "hrvRel": 1.3965044282467278, + "hrv15Rel": 1.321185045849988, + "hr15StdRel": 0.31274999914561546, + "br15Std": 0.4610314522893204, + "motion15": 4.672828834461906, + "minutesSinceBed": 20.0 + }, + { + "start": 1700001560, + "hrZ": -0.6892096432637339, + "hrStdZ": -0.8643709728904386, + "motionZ": -0.2838850692909076, + "nightFraction": 0.07621951219512195, + "hrvRel": 1.295164197821146, + "hrv15Rel": 1.2493093110745357, + "hr15StdRel": 0.6408208375169906, + "br15Std": 0.3712058054502921, + "motion15": 4.532599493153256, + "minutesSinceBed": 25.0 + }, + { + "start": 1700001860, + "hrZ": 0.3645627575124909, + "hrStdZ": 1.1391267677135581, + "motionZ": -0.52245627343106, + "nightFraction": 0.09146341463414634, + "hrvRel": 1.0877419860490634, + "hrv15Rel": 1.0564160592522924, + "hr15StdRel": 0.7081472660727463, + "br15Std": 0.5289786001106284, + "motion15": 4.634728988229636, + "minutesSinceBed": 30.0 + }, + { + "start": 1700002160, + "hrZ": 0.8562515625661584, + "hrStdZ": -0.14018364206524356, + "motionZ": -0.4971911515614862, + "nightFraction": 0.10670731707317073, + "hrvRel": 0.8062152206285759, + "hrv15Rel": 0.9552815659534445, + "hr15StdRel": 0.5102556508785014, + "br15Std": 0.5582772048901872, + "motion15": 4.584967478670572, + "minutesSinceBed": 35.0 + }, + { + "start": 1700002460, + "hrZ": 0.604093123471696, + "hrStdZ": 0.6210761058695307, + "motionZ": -0.3217876146876578, + "nightFraction": 0.12195121951219512, + "hrvRel": 0.9507014656321027, + "hrv15Rel": 0.930108158946626, + "hr15StdRel": 0.460965474766044, + "br15Std": 0.5536156016361894, + "motion15": 4.574710978503383, + "minutesSinceBed": 40.0 + }, + { + "start": 1700002760, + "hrZ": 0.8712977773656142, + "hrStdZ": 0.8255221488088875, + "motionZ": -0.4491745561920188, + "nightFraction": 0.13719512195121952, + "hrvRel": 1.0269221725840585, + "hrv15Rel": 0.9780449094756641, + "hr15StdRel": 0.4556071385030037, + "br15Std": 0.47430829570544086, + "motion15": 4.653960350157523, + "minutesSinceBed": 45.0 + }, + { + "start": 1700003060, + "hrZ": 1.0351940457168367, + "hrStdZ": -0.5265154971868624, + "motionZ": -0.47278264144387433, + "nightFraction": 0.1524390243902439, + "hrvRel": 0.9828356454267575, + "hrv15Rel": 0.9882288972490008, + "hr15StdRel": 0.41836785069383514, + "br15Std": 0.43446087280674645, + "motion15": 4.48863636973214, + "minutesSinceBed": 50.0 + }, + { + "start": 1700003360, + "hrZ": 0.626259422060178, + "hrStdZ": -0.1846568880686506, + "motionZ": -0.47278264144387433, + "nightFraction": 0.1676829268292683, + "hrvRel": 0.9718238106434675, + "hrv15Rel": 0.9569862450035269, + "hr15StdRel": 0.4155858441868052, + "br15Std": 0.457614398265614, + "motion15": 4.48863636973214, + "minutesSinceBed": 55.0 + }, + { + "start": 1700003660, + "hrZ": 0.6961168479148027, + "hrStdZ": 0.42825797431813223, + "motionZ": -0.7647102584530985, + "nightFraction": 0.18292682926829268, + "hrvRel": 0.9217807038169133, + "hrv15Rel": 0.9293880790030568, + "hr15StdRel": 0.3968029434088038, + "br15Std": 0.38159154782961324, + "motion15": 4.382026634673881, + "minutesSinceBed": 60.0 + }, + { + "start": 1700003960, + "hrZ": 0.11630021332145865, + "hrStdZ": -0.9215410195687398, + "motionZ": -0.47278264144387433, + "nightFraction": 0.19817073170731708, + "hrvRel": 0.9088094678266321, + "hrv15Rel": 0.9165295085821774, + "hr15StdRel": 0.42294076169766814, + "br15Std": 0.43124048902555523, + "motion15": 4.418840607796598, + "minutesSinceBed": 65.0 + }, + { + "start": 1700004260, + "hrZ": 0.5166369999498556, + "hrStdZ": 0.2332198387472241, + "motionZ": -0.4491745561920188, + "nightFraction": 0.21341463414634146, + "hrvRel": 0.9269143349792304, + "hrv15Rel": 0.8917675758288266, + "hr15StdRel": 0.3749614506433398, + "br15Std": 0.4731340818150812, + "motion15": 4.48863636973214, + "minutesSinceBed": 70.0 + }, + { + "start": 1700004560, + "hrZ": -0.031475110601774124, + "hrStdZ": -0.050135363371515904, + "motionZ": -0.6040508974292268, + "nightFraction": 0.22865853658536586, + "hrvRel": 0.8587663610000783, + "hrv15Rel": 0.8566012226663533, + "hr15StdRel": 0.4757562051728745, + "br15Std": 0.5661327141227575, + "motion15": 6.297109319933935, + "minutesSinceBed": 75.0 + }, + { + "start": 1700004860, + "hrZ": -0.1367986141979724, + "hrStdZ": -0.1455711776353011, + "motionZ": 1.529406578111034, + "nightFraction": 0.24390243902439024, + "hrvRel": 0.8158946625911122, + "hrv15Rel": 0.7430490242181989, + "hr15StdRel": 0.7447650861699002, + "br15Std": 0.6313722035178613, + "motion15": 7.6787889981991535, + "minutesSinceBed": 80.0 + }, + { + "start": 1700005160, + "hrZ": 0.8288459570385778, + "hrStdZ": 2.758923203218567, + "motionZ": 2.175908445413887, + "nightFraction": 0.25914634146341464, + "hrvRel": 0.5724978446586723, + "hrv15Rel": 0.662047378321185, + "hr15StdRel": 0.8665638537131847, + "br15Std": 0.7186464599335334, + "motion15": 8.201111644442758, + "minutesSinceBed": 85.0 + }, + { + "start": 1700005460, + "hrZ": 1.659074595080016, + "hrStdZ": -0.8468510552499723, + "motionZ": 2.4770693019439034, + "nightFraction": 0.27439024390243905, + "hrvRel": 0.5724978446586722, + "hrv15Rel": 0.5676581236774041, + "hr15StdRel": 0.6451297638116347, + "br15Std": 0.7286864787924913, + "motion15": 8.480321716640333, + "minutesSinceBed": 90.0 + }, + { + "start": 1700005760, + "hrZ": 1.5730962247974056, + "hrStdZ": -0.8956650970994249, + "motionZ": 2.3678763083287793, + "nightFraction": 0.2896341463414634, + "hrvRel": 0.5559996864958068, + "hrv15Rel": 0.5530507876792853, + "hr15StdRel": 0.22849772688536743, + "br15Std": 0.6367762435699058, + "motion15": 8.660080678964786, + "minutesSinceBed": 95.0 + }, + { + "start": 1700006060, + "hrZ": 1.5865303451540642, + "hrStdZ": -0.4291491574219605, + "motionZ": 2.5996428241123954, + "nightFraction": 0.3048780487804878, + "hrvRel": 0.5434987067952033, + "hrv15Rel": 0.5810310369151187, + "hr15StdRel": 0.3445709411896248, + "br15Std": 0.47440587844165677, + "motion15": 8.722417141427496, + "minutesSinceBed": 100.0 + }, + { + "start": 1700006360, + "hrZ": 1.553213726669553, + "hrStdZ": 0.7204982587725353, + "motionZ": 2.536751465437261, + "nightFraction": 0.3201219512195122, + "hrvRel": 0.6383337252135747, + "hrv15Rel": 0.608056078062544, + "hr15StdRel": 0.39358510969937904, + "br15Std": 0.5290110909754162, + "motion15": 8.80597465931132, + "minutesSinceBed": 105.0 + }, + { + "start": 1700006660, + "hrZ": 1.397915295346592, + "hrStdZ": -0.006025933225379629, + "motionZ": 2.5559521257772664, + "nightFraction": 0.3353658536585366, + "hrvRel": 0.6397444940826084, + "hrv15Rel": 0.8206853985422056, + "hr15StdRel": 1.3844149734233409, + "br15Std": 0.6439223070371145, + "motion15": 8.505525386548555, + "minutesSinceBed": 110.0 + }, + { + "start": 1700006960, + "hrZ": -0.38076223987487273, + "hrStdZ": 5.7301388681329195, + "motionZ": 2.0254788390769414, + "nightFraction": 0.35060975609756095, + "hrvRel": 1.1228152676542047, + "hrv15Rel": 1.0069509757818011, + "hr15StdRel": 1.4937492458541741, + "br15Std": 0.6190567421488922, + "motion15": 8.014004994779459, + "minutesSinceBed": 115.0 + }, + { + "start": 1700007260, + "hrZ": -1.7897327828811214, + "hrStdZ": -0.2892019183081337, + "motionZ": -0.3217876146876578, + "nightFraction": 0.36585365853658536, + "hrvRel": 1.2618543773022963, + "hrv15Rel": 1.2030331530684222, + "hr15StdRel": 1.0798721193436505, + "br15Std": 0.714888267056468, + "motion15": 6.955592608396297, + "minutesSinceBed": 120.0 + }, + { + "start": 1700007560, + "hrZ": -1.6295980682297617, + "hrStdZ": -0.8815166203465411, + "motionZ": -0.19703507821755983, + "nightFraction": 0.38109756097560976, + "hrvRel": 1.2675366408025708, + "hrv15Rel": 1.2337173759699036, + "hr15StdRel": 0.35698700276322815, + "br15Std": 0.5822742990850619, + "motion15": 4.762173934797756, + "minutesSinceBed": 125.0 + }, + { + "start": 1700007860, + "hrZ": -1.47483700172107, + "hrStdZ": 0.6028371580866748, + "motionZ": -0.34151438545852214, + "nightFraction": 0.39634146341463417, + "hrvRel": 1.1878281997021711, + "hrv15Rel": 1.2561868093110744, + "hr15StdRel": 0.3809521754345958, + "br15Std": 0.671525490487889, + "motion15": 4.605170185988092, + "minutesSinceBed": 130.0 + }, + { + "start": 1700008160, + "hrZ": -1.1319982502191652, + "hrStdZ": -0.3107098167081021, + "motionZ": -0.7647102584530985, + "nightFraction": 0.4115853658536585, + "hrvRel": 1.2623246335919742, + "hrv15Rel": 1.262785092875617, + "hr15StdRel": 0.3929939864316252, + "br15Std": 0.6787442449111446, + "motion15": 4.532599493153256, + "minutesSinceBed": 135.0 + }, + { + "start": 1700008460, + "hrZ": -1.1013684558059866, + "hrStdZ": -0.607006226476151, + "motionZ": -0.34151438545852214, + "nightFraction": 0.4268292682926829, + "hrvRel": 1.3279645740261776, + "hrv15Rel": 1.2449741359040676, + "hr15StdRel": 0.25744691165663985, + "br15Std": 0.5681425327327642, + "motion15": 4.532599493153256, + "minutesSinceBed": 140.0 + }, + { + "start": 1700008760, + "hrZ": -1.07181339102134, + "hrStdZ": -0.6034251236857966, + "motionZ": -0.3826674198643134, + "nightFraction": 0.4420731707317073, + "hrvRel": 1.2089113566893956, + "hrv15Rel": 1.227089701387256, + "hr15StdRel": 0.6485781864104722, + "br15Std": 0.4289649606902643, + "motion15": 5.683579767338681, + "minutesSinceBed": 145.0 + }, + { + "start": 1700009060, + "hrZ": -0.4721142583001453, + "hrStdZ": 1.596432138032722, + "motionZ": 0.9518459049230744, + "nightFraction": 0.4573170731707317, + "hrvRel": 1.195548240457716, + "hrv15Rel": 1.0897748648013166, + "hr15StdRel": 0.8470335963533735, + "br15Std": 0.4579028281196784, + "motion15": 5.645446897643238, + "minutesSinceBed": 150.0 + }, + { + "start": 1700009360, + "hrZ": 0.8418770537845329, + "hrStdZ": -0.08473104901623967, + "motionZ": -0.42631606194237864, + "nightFraction": 0.4725609756097561, + "hrvRel": 0.8539070460067403, + "hrv15Rel": 1.0393692687514695, + "hr15StdRel": 0.7403457516732721, + "br15Std": 0.39700440803598114, + "motion15": 5.602118820879701, + "minutesSinceBed": 155.0 + }, + { + "start": 1700009660, + "hrZ": 0.9056891254786558, + "hrStdZ": -0.7118834422316325, + "motionZ": -0.7295822139564274, + "nightFraction": 0.4878048780487805, + "hrvRel": 1.0514930637197273, + "hrv15Rel": 0.8990565483188336, + "hr15StdRel": 0.28557215747265774, + "br15Std": 0.45026207646214184, + "motion15": 4.499809670330265, + "minutesSinceBed": 160.0 + }, + { + "start": 1700009960, + "hrZ": 1.2496026066090924, + "hrStdZ": -1.0471847127984364, + "motionZ": -0.34151438545852214, + "nightFraction": 0.5030487804878049, + "hrvRel": 0.8418371345716749, + "hrv15Rel": 0.9442599341641194, + "hr15StdRel": 0.2462490936589012, + "br15Std": 0.35173176480807067, + "motion15": 6.822197390620491, + "minutesSinceBed": 165.0 + }, + { + "start": 1700010260, + "hrZ": 1.1684605196548807, + "hrStdZ": -0.4457419628469774, + "motionZ": 1.3321300674214178, + "nightFraction": 0.5182926829268293, + "hrvRel": 0.9168038247511561, + "hrv15Rel": 0.9205413825534917, + "hr15StdRel": 0.2083552819287144, + "br15Std": 0.562149891043305, + "motion15": 6.835184586147301, + "minutesSinceBed": 170.0 + }, + { + "start": 1700010560, + "hrZ": 1.158787952998087, + "hrStdZ": -0.6989543056938425, + "motionZ": 1.5865780360167359, + "nightFraction": 0.5335365853658537, + "hrvRel": 1.0031350419311857, + "hrv15Rel": 0.9407917940277452, + "hr15StdRel": 0.2739517964210181, + "br15Std": 0.5633368774543345, + "motion15": 6.838405200847344, + "minutesSinceBed": 175.0 + }, + { + "start": 1700010860, + "hrZ": 1.1184855919281145, + "hrStdZ": -0.0973985633405905, + "motionZ": -0.2838850692909076, + "nightFraction": 0.5487804878048781, + "hrvRel": 0.9191551061995454, + "hrv15Rel": 0.9391899835410299, + "hr15StdRel": 0.3130951803420793, + "br15Std": 0.622454503859519, + "motion15": 6.38856140554563, + "minutesSinceBed": 180.0 + }, + { + "start": 1700011160, + "hrZ": 1.1372933604274371, + "hrStdZ": -0.10735324203741113, + "motionZ": -0.4971911515614862, + "nightFraction": 0.5640243902439024, + "hrvRel": 0.915040363664864, + "hrv15Rel": 0.8773659769574418, + "hr15StdRel": 0.3609806269429017, + "br15Std": 0.5885758102190404, + "motion15": 4.74493212836325, + "minutesSinceBed": 185.0 + }, + { + "start": 1700011460, + "hrZ": 0.7751094756119467, + "hrStdZ": -0.2714257876065082, + "motionZ": -0.19703507821755983, + "nightFraction": 0.5792682926829268, + "hrvRel": 0.8515557645583508, + "hrv15Rel": 0.8597754526216788, + "hr15StdRel": 0.383196744608943, + "br15Std": 0.5248507972509903, + "motion15": 4.653960350157523, + "minutesSinceBed": 190.0 + }, + { + "start": 1700011760, + "hrZ": 0.8019777163252622, + "hrStdZ": 0.34164854541862494, + "motionZ": -0.4491745561920188, + "nightFraction": 0.5945121951219512, + "hrvRel": 0.8518692687514694, + "hrv15Rel": 0.8456236774041852, + "hr15StdRel": 0.43215983972619, + "br15Std": 0.41668661410585306, + "motion15": 4.59511985013459, + "minutesSinceBed": 195.0 + }, + { + "start": 1700012060, + "hrZ": 0.5859570609902056, + "hrStdZ": 0.7672441676698958, + "motionZ": -0.5758126863001315, + "nightFraction": 0.6097560975609756, + "hrvRel": 0.8345481620816678, + "hrv15Rel": 0.8650805313896074, + "hr15StdRel": 0.4788956307761241, + "br15Std": 0.4510543204537566, + "motion15": 4.499809670330265, + "minutesSinceBed": 200.0 + }, + { + "start": 1700012360, + "hrZ": 0.6520529331449616, + "hrStdZ": -0.23223176570475923, + "motionZ": -0.5486403259838895, + "nightFraction": 0.625, + "hrvRel": 0.9035974606160357, + "hrv15Rel": 0.8588496355513756, + "hr15StdRel": 0.4637004982054712, + "br15Std": 0.36765674803408704, + "motion15": 4.465908118654584, + "minutesSinceBed": 205.0 + }, + { + "start": 1700012660, + "hrZ": 0.46182578889469045, + "hrStdZ": 0.610794745094982, + "motionZ": -0.5486403259838895, + "nightFraction": 0.6402439024390244, + "hrvRel": 0.8396426052198448, + "hrv15Rel": 0.8672995532565249, + "hr15StdRel": 0.47283816053953015, + "br15Std": 0.4452948461412958, + "motion15": 4.48863636973214, + "minutesSinceBed": 210.0 + }, + { + "start": 1700012960, + "hrZ": -0.16205476046848707, + "hrStdZ": -0.3538773662956457, + "motionZ": -0.5758126863001315, + "nightFraction": 0.6554878048780488, + "hrvRel": 0.827886197977898, + "hrv15Rel": 0.8494298142487655, + "hr15StdRel": 0.43968647525068144, + "br15Std": 0.46671056341162903, + "motion15": 4.454347296253507, + "minutesSinceBed": 215.0 + }, + { + "start": 1700013260, + "hrZ": 0.30921418164306075, + "hrStdZ": -0.5457250707444471, + "motionZ": -0.3617969124071884, + "nightFraction": 0.6707317073170732, + "hrvRel": 0.8779684928285917, + "hrv15Rel": 0.9010698330590172, + "hr15StdRel": 0.4485760073207479, + "br15Std": 0.4978512422149816, + "motion15": 4.59511985013459, + "minutesSinceBed": 220.0 + }, + { + "start": 1700013560, + "hrZ": 0.031933937481648945, + "hrStdZ": 0.14062272080845345, + "motionZ": -0.4971911515614862, + "nightFraction": 0.6859756097560976, + "hrvRel": 1.0151657653421116, + "hrv15Rel": 1.0468198918410534, + "hr15StdRel": 0.8461809069218648, + "br15Std": 0.542367135227606, + "motion15": 4.653960350157523, + "minutesSinceBed": 225.0 + }, + { + "start": 1700013860, + "hrZ": -1.7059038718555786, + "hrStdZ": 0.12673988748503537, + "motionZ": -0.2838850692909076, + "nightFraction": 0.7012195121951219, + "hrvRel": 1.267262324633592, + "hrv15Rel": 1.1920849988243594, + "hr15StdRel": 0.7846898742208944, + "br15Std": 0.5853200913816305, + "motion15": 4.653960350157523, + "minutesSinceBed": 230.0 + }, + { + "start": 1700014160, + "hrZ": -1.3899333610669913, + "hrStdZ": 0.4326166646050993, + "motionZ": -0.52245627343106, + "nightFraction": 0.7164634146341463, + "hrvRel": 1.3160514146876714, + "hrv15Rel": 1.2920438513990122, + "hr15StdRel": 0.3809418476964816, + "br15Std": 0.7522297583684125, + "motion15": 4.543294782270004, + "minutesSinceBed": 235.0 + }, + { + "start": 1700014460, + "hrZ": -1.5828473293885934, + "hrStdZ": -0.6036423642390462, + "motionZ": -0.633442002467746, + "nightFraction": 0.7317073170731707, + "hrvRel": 1.278548475585861, + "hrv15Rel": 1.2737479426287326, + "hr15StdRel": 0.3960188798333841, + "br15Std": 0.6881312442950109, + "motion15": 4.442651256490317, + "minutesSinceBed": 240.0 + }, + { + "start": 1700014760, + "hrZ": -1.8767858827922619, + "hrStdZ": -0.27472031834399857, + "motionZ": -0.52245627343106, + "nightFraction": 0.7469512195121951, + "hrvRel": 1.2299553256524807, + "hrv15Rel": 1.2785827651069832, + "hr15StdRel": 0.3734204921908539, + "br15Std": 0.5317409496173865, + "motion15": 4.356708826689592, + "minutesSinceBed": 245.0 + }, + { + "start": 1700015060, + "hrZ": -1.7768360273387296, + "hrStdZ": -0.043634811445312546, + "motionZ": -0.6960886357521511, + "nightFraction": 0.7621951219512195, + "hrvRel": 1.2917156516968413, + "hrv15Rel": 1.2017987303080178, + "hr15StdRel": 0.5938662805121218, + "br15Std": 0.53152492886035, + "motion15": 4.31748811353631, + "minutesSinceBed": 250.0 + }, + { + "start": 1700015360, + "hrZ": -1.3001934370845178, + "hrStdZ": 1.7360088642374587, + "motionZ": -0.633442002467746, + "nightFraction": 0.7774390243902439, + "hrvRel": 1.1404106904929854, + "hrv15Rel": 1.05144897719257, + "hr15StdRel": 0.6983704094038726, + "br15Std": 0.46680931599530007, + "motion15": 4.3694478524670215, + "minutesSinceBed": 255.0 + }, + { + "start": 1700015660, + "hrZ": -0.364641295446883, + "hrStdZ": -1.1552737077400919, + "motionZ": -0.4041610187429272, + "nightFraction": 0.7926829268292683, + "hrvRel": 0.8260443608433263, + "hrv15Rel": 0.9214965906418999, + "hr15StdRel": 0.5919838692180234, + "br15Std": 0.6638520990213107, + "motion15": 4.5217885770490405, + "minutesSinceBed": 260.0 + }, + { + "start": 1700015960, + "hrZ": -0.32863785289104247, + "hrStdZ": -0.7470786580095934, + "motionZ": -0.4491745561920188, + "nightFraction": 0.8079268292682927, + "hrvRel": 0.7836821067481775, + "hrv15Rel": 0.828767928521044, + "hr15StdRel": 0.26065559991014514, + "br15Std": 0.6411110960668207, + "motion15": 6.129050210060545, + "minutesSinceBed": 265.0 + }, + { + "start": 1700016260, + "hrZ": -0.21310441782378725, + "hrStdZ": 0.10928887974693101, + "motionZ": 1.3849272708700713, + "nightFraction": 0.823170731707317, + "hrvRel": 0.8929383180500039, + "hrv15Rel": 0.8682988478720903, + "hr15StdRel": 0.2882036814190838, + "br15Std": 0.6844055358484471, + "motion15": 6.1092475827643655, + "minutesSinceBed": 270.0 + }, + { + "start": 1700016560, + "hrZ": -0.2555562381508237, + "hrStdZ": -0.5243657788372772, + "motionZ": -1.0215098309656516, + "nightFraction": 0.8384146341463414, + "hrvRel": 0.9412963398385454, + "hrv15Rel": 0.9207912062073831, + "hr15StdRel": 0.35155936689997197, + "br15Std": 0.6601180647240309, + "motion15": 6.144185634125646, + "minutesSinceBed": 275.0 + }, + { + "start": 1700016860, + "hrZ": -0.01589153098805136, + "hrStdZ": -0.37335404829582197, + "motionZ": -0.14954652254362996, + "nightFraction": 0.8536585365853658, + "hrvRel": 0.8989732737675367, + "hrv15Rel": 0.938014342816835, + "hr15StdRel": 0.3714226655674138, + "br15Std": 0.6570292991944878, + "motion15": 4.634728988229636, + "minutesSinceBed": 280.0 + }, + { + "start": 1700017160, + "hrZ": 0.41077613153939413, + "hrStdZ": -0.5427698767027761, + "motionZ": -0.34151438545852214, + "nightFraction": 0.8689024390243902, + "hrvRel": 0.9322047182381065, + "hrv15Rel": 1.0261139195861744, + "hr15StdRel": 0.5046642413303243, + "br15Std": 0.6099590150165828, + "motion15": 4.762173934797756, + "minutesSinceBed": 285.0 + }, + { + "start": 1700017460, + "hrZ": -0.1173191396808175, + "hrStdZ": 0.7849731361058033, + "motionZ": -0.47278264144387433, + "nightFraction": 0.8841463414634146, + "hrvRel": 1.1882200799435692, + "hrv15Rel": 1.1230161062779214, + "hr15StdRel": 0.6223936634365925, + "br15Std": 0.49458947623256194, + "motion15": 4.543294782270004, + "minutesSinceBed": 290.0 + }, + { + "start": 1700017760, + "hrZ": -0.9073797578558507, + "hrStdZ": -0.3341954037518103, + "motionZ": -0.6040508974292268, + "nightFraction": 0.899390243902439, + "hrvRel": 1.2695352300337017, + "hrv15Rel": 1.260580766517752, + "hr15StdRel": 0.43743380213287064, + "br15Std": 0.44912240814726656, + "motion15": 6.124683390894205, + "minutesSinceBed": 295.0 + }, + { + "start": 1700018060, + "hrZ": -0.698882209920524, + "hrStdZ": -0.8627874599701694, + "motionZ": 1.3939610417394726, + "nightFraction": 0.9146341463414634, + "hrvRel": 1.328082138098597, + "hrv15Rel": 1.285283917234893, + "hr15StdRel": 0.252880976691468, + "br15Std": 0.5370055865631194, + "motion15": 6.111467339502679, + "minutesSinceBed": 300.0 + }, + { + "start": 1700018360, + "hrZ": -0.6736260636500073, + "hrStdZ": -0.7835397617748301, + "motionZ": -0.5758126863001315, + "nightFraction": 0.9298780487804879, + "hrvRel": 1.1881808919194294, + "hrv15Rel": 1.2998177756877498, + "hr15StdRel": 0.26059933459241097, + "br15Std": 0.4523894892678209, + "motion15": 6.139884552226255, + "minutesSinceBed": 305.0 + }, + { + "start": 1700018660, + "hrZ": -0.45545594905789055, + "hrStdZ": -0.14605155765017167, + "motionZ": -0.4041610187429272, + "nightFraction": 0.9451219512195121, + "hrvRel": 1.2801551845755936, + "hrv15Rel": 1.2107923818481072, + "hr15StdRel": 0.5908778640930227, + "br15Std": 0.7339182089987959, + "motion15": 4.406719247264253, + "minutesSinceBed": 310.0 + }, + { + "start": 1700018960, + "hrZ": -0.3560434584186231, + "hrStdZ": 0.09354064872170186, + "motionZ": -0.6040508974292268, + "nightFraction": 0.9603658536585366, + "hrvRel": 1.1751704679050081, + "hrv15Rel": 1.0331383729132377, + "hr15StdRel": 1.0448847228568012, + "br15Std": 0.9957124883092506, + "motion15": 4.477336814478207, + "minutesSinceBed": 315.0 + }, + { + "start": 1700019260, + "hrZ": 1.8826183578148006, + "hrStdZ": -0.41130468049486485, + "motionZ": -0.633442002467746, + "nightFraction": 0.975609756097561, + "hrvRel": 0.6426052198448153, + "hrv15Rel": 0.9279965273381686, + "hr15StdRel": 1.0342884826993448, + "br15Std": 0.8916934225188123, + "motion15": 4.07753744390572, + "minutesSinceBed": 320.0 + } + ] +} \ No newline at end of file diff --git a/src/lib/tests/personal-stage-model.test.ts b/src/lib/tests/personal-stage-model.test.ts new file mode 100644 index 000000000..ee31063fc --- /dev/null +++ b/src/lib/tests/personal-stage-model.test.ts @@ -0,0 +1,230 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { DEFAULT_STAGE_RULESET } from '../sleep-stage-rules' +import { + classifyWithStageModel, + evaluateStageModel, + failingTestVectors, + filterModelOutliers, + POD_NATIVE_CONTRACT, + stageModelBlocks, + stageModelContract, + stageModelFeatures, + stageModelSchema, + type StageModel, +} from '../personal-stage-model' + +/** A one-tree forest: deep when hrZ <= -0.5, wake when motionZ > 1, else light. */ +function forest(overrides: Partial = {}): StageModel { + return stageModelSchema.parse({ + version: 'test-forest', + kind: 'forest', + classes: ['wake', 'light', 'deep', 'rem'], + featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'], + trees: [{ + // node 0: hrZ <= -0.5 → 1 (deep leaf) else 2; node 2: motionZ <= 1 → 3 (light) else 4 (wake) + f: [0, -1, 2, -1, -1], + t: [-0.5, 0, 1, 0, 0], + l: [1, -1, 3, -1, -1], + r: [2, -1, 4, -1, -1], + v: [[0, 0, 0, 0], [0, 1, 9, 0], [0, 0, 0, 0], [1, 8, 1, 0], [7, 2, 1, 0]], + }], + testVectors: [ + { inputs: { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 2 }, + { inputs: { hrZ: 0, hrStdZ: 0, motionZ: 2, nightFraction: 0.5 }, expected: 0 }, + { inputs: { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 1 }, + ], + ...overrides, + }) +} + +describe('personal stage model evaluation', () => { + it('walks a forest and averages leaf votes', () => { + const model = forest() + const deep = evaluateStageModel(model, { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }) + expect(deep.stage).toBe('deep') + expect(deep.probabilities).toEqual([0, 0.1, 0.9, 0]) + expect(evaluateStageModel(model, { hrZ: 0, hrStdZ: 0, motionZ: 2, nightFraction: 0.5 }).stage).toBe('wake') + expect(failingTestVectors(model)).toEqual([]) + expect(stageModelContract(model)).toBe('challenger') + }) + + it('refuses a model that fails its own vectors, and malformed trees', () => { + const wrong = forest({ testVectors: [{ inputs: { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 1 }] }) + expect(failingTestVectors(wrong)).toEqual([0]) + expect(stageModelSchema.safeParse({ ...forest(), trees: [{ f: [0], t: [0], l: [5], r: [0], v: [[1, 0, 0, 0]] }] }).success).toBe(false) + expect(stageModelSchema.safeParse({ ...forest(), featureContract: ['hrZ'] }).success).toBe(false) + expect(stageModelSchema.safeParse({ ...forest(), kind: 'logistic' }).success).toBe(false) + }) + + it('evaluates a multinomial logistic model through its scaler', () => { + const model = stageModelSchema.parse({ + version: 'test-logistic', + kind: 'logistic', + classes: ['wake', 'light', 'deep', 'rem'], + featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'], + logistic: { + mean: [0, 0, 0, 0.5], + scale: [1, 1, 1, 0.25], + // wake likes motion, light is the default, deep likes low HR, rem likes late night. + coef: [[0, 0, 2, 0], [0, 0, 0, 0], [-2, 0, 0, 0], [0, 0, 0, 2]], + intercept: [0, 0.5, 0, 0], + classIndices: [0, 1, 2, 3], + }, + testVectors: [{ inputs: { hrZ: -2, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 2 }], + }) + expect(evaluateStageModel(model, { hrZ: -2, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }).stage).toBe('deep') + expect(evaluateStageModel(model, { hrZ: 0, hrStdZ: 0, motionZ: 3, nightFraction: 0.5 }).stage).toBe('wake') + expect(evaluateStageModel(model, { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 1 }).stage).toBe('rem') + const light = evaluateStageModel(model, { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }) + expect(light.stage).toBe('light') + expect(light.probabilities.reduce((a, b) => a + b, 0)).toBeCloseTo(1, 12) + expect(failingTestVectors(model)).toEqual([]) + }) + + it('evaluates a two-class logistic model with one coefficient row', () => { + const model = stageModelSchema.parse({ + version: 'two-class', + kind: 'logistic', + classes: ['wake', 'light', 'deep', 'rem'], + featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'], + logistic: { mean: [0, 0, 0, 0], scale: [1, 1, 1, 1], coef: [[-3, 0, 0, 0]], intercept: [0], classIndices: [1, 2] }, + testVectors: [{ inputs: { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0 }, expected: 2 }], + }) + expect(evaluateStageModel(model, { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0 }).stage).toBe('deep') + expect(evaluateStageModel(model, { hrZ: 1, hrStdZ: 0, motionZ: 0, nightFraction: 0 }).stage).toBe('light') + }) +}) + +describe('feature contract parity', () => { + interface Fixture { + enteredBed: number + leftBed: number + vitals: { ts: number, hr: number | null, hrv: number | null, br: number | null }[] + movement: { ts: number, total: number }[] + contractA: Record[] + contractB: Record[] + } + + it('reproduces the Python port on the synthetic night, both contracts', () => { + const fixture = JSON.parse(readFileSync(path.join(__dirname, 'fixtures', 'stage-features-night.json'), 'utf8')) as Fixture + const vitals = fixture.vitals.map(v => ({ timestamp: new Date(v.ts * 1000), heartRate: v.hr, hrv: v.hrv, breathingRate: v.br })) + const movement = fixture.movement.map(m => ({ timestamp: new Date(m.ts * 1000), totalMovement: m.total })) + const entered = new Date(fixture.enteredBed * 1000) + const left = new Date(fixture.leftBed * 1000) + for (const [contract, expected] of [['challenger', fixture.contractA], ['podNative', fixture.contractB]] as const) { + const rows = stageModelFeatures(contract, entered, left, vitals, movement) + expect(rows.length).toBe(expected.length) + rows.forEach((row, i) => { + expect(row.start).toBe(expected[i].start) + for (const column of contract === 'challenger' ? Object.keys(row.values) : POD_NATIVE_CONTRACT) { + expect(Math.abs(row.values[column] - expected[i][column])).toBeLessThan(1e-6) + } + }) + } + }) + + it('drops a partial trailing epoch and epochs without heart rate', () => { + const t0 = new Date(Date.UTC(2026, 9, 1)) + const vitals = [0, 1, 6, 7].map(m => ({ timestamp: new Date(t0.getTime() + m * 60_000), heartRate: 60 + m, hrv: 40, breathingRate: 14 })) + const rows = stageModelFeatures('challenger', t0, new Date(t0.getTime() + 16 * 60_000), vitals, []) + // Three whole epochs; the middle one (5–10 min) has samples at 6 and 7, the last (10–15) has none. + expect(rows.map(r => (r.start - t0.getTime() / 1000) / 60)).toEqual([0, 5]) + }) +}) + +describe('classifyWithStageModel', () => { + it('stages a night in 5-minute epochs and smooths an isolated epoch', () => { + const t0 = new Date(Date.UTC(2026, 9, 1, 23)) + // 30 minutes, one vital a minute. Heart rate dips hard in minutes 10–14 only, so the forest calls that + // epoch deep between two light ones and smoothing takes it back to light. + const vitals = Array.from({ length: 30 }, (_, m) => ({ + timestamp: new Date(t0.getTime() + m * 60_000), + heartRate: m >= 10 && m < 15 ? 48 : 60 + (m % 2), + hrv: 40, + breathingRate: 14, + })) + const epochs = classifyWithStageModel(forest(), t0, new Date(t0.getTime() + 30 * 60_000), vitals, []) + expect(epochs.length).toBe(6) + expect(epochs.every(e => e.duration === 300_000)).toBe(true) + expect(epochs.map(e => e.stage)).toEqual(['light', 'light', 'light', 'light', 'light', 'light']) + expect(epochs[2].heartRate).toBe(48) + expect(stageModelBlocks(epochs)).toEqual([{ start: t0.getTime(), end: t0.getTime() + 30 * 60_000, stage: 'light' }]) + }) +}) + +describe('schema edge cases', () => { + const base = () => JSON.parse(JSON.stringify(forest())) as Record + const tree = () => ({ f: [0, -1, -1], t: [0, 0, 0], l: [1, -1, -1], r: [2, -1, -1], v: [[0, 0, 0, 0], [0, 1, 0, 0], [1, 0, 0, 0]] }) + + it('rejects trees whose arrays disagree, have no nodes, point backwards, or vote with the wrong width', () => { + expect(stageModelSchema.safeParse({ ...base(), trees: [{ ...tree(), t: [0] }] }).success).toBe(false) + expect(stageModelSchema.safeParse({ ...base(), trees: [{ f: [], t: [], l: [], r: [], v: [] }] }).success).toBe(false) + expect(stageModelSchema.safeParse({ ...base(), trees: [{ ...tree(), l: [0, -1, -1] }] }).success).toBe(false) + expect(stageModelSchema.safeParse({ ...base(), trees: [{ ...tree(), v: [[0, 0, 0], [0, 1, 0], [1, 0, 0]] }] }).success).toBe(false) + expect(stageModelSchema.safeParse({ ...base(), trees: [] }).success).toBe(false) + expect(stageModelSchema.safeParse({ ...base(), trees: undefined }).success).toBe(false) + }) + + it('rejects logistic weights that do not match the contract or the classes, and vectors missing a column', () => { + const logistic = (overrides: Record) => ({ + ...base(), kind: 'logistic', trees: undefined, + logistic: { mean: [0, 0, 0, 0], scale: [1, 1, 1, 1], coef: [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], intercept: [0, 0, 0, 0], classIndices: [0, 1, 2, 3], ...overrides }, + testVectors: [{ inputs: { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0 }, expected: 0 }], + }) + expect(stageModelSchema.safeParse(logistic({})).success).toBe(true) + expect(stageModelSchema.safeParse(logistic({ mean: [0, 0] })).success).toBe(false) + expect(stageModelSchema.safeParse(logistic({ coef: [[0, 0, 0, 0]] })).success).toBe(false) + expect(stageModelSchema.safeParse(logistic({ intercept: [0] })).success).toBe(false) + expect(stageModelSchema.safeParse({ ...logistic({}), logistic: undefined }).success).toBe(false) + expect(stageModelSchema.safeParse({ ...base(), testVectors: [{ inputs: { hrZ: 0 }, expected: 0 }] }).success).toBe(false) + }) + + it('reports missing features, empty leaves and an unknown contract', () => { + expect(() => evaluateStageModel(forest(), { hrZ: 0, hrStdZ: 0, motionZ: 0 })).toThrow(/nightFraction/) + expect(() => evaluateStageModel(forest(), { hrZ: Number.NaN, hrStdZ: 0, motionZ: 0, nightFraction: 0 })).toThrow() + // A leaf with no votes counts a quarter for every class. + const hollow = forest({ trees: [{ f: [-1], t: [0], l: [-1], r: [-1], v: [[0, 0, 0, 0]] }], testVectors: [{ inputs: { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0 }, expected: 0 }] }) + expect(evaluateStageModel(hollow, { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0 }).probabilities).toEqual([0.25, 0.25, 0.25, 0.25]) + const unknown = forest({ featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction', 'extra'], + testVectors: [{ inputs: { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0, extra: 0 }, expected: 2 }] }) + expect(stageModelContract(unknown)).toBeNull() + expect(() => classifyWithStageModel(unknown, new Date(0), new Date(3_600_000), [], [])).toThrow(/contract/) + // A vector the model cannot evaluate counts as failing (built past the schema, which would refuse it). + const unevaluable = { ...forest(), testVectors: [{ inputs: { hrZ: 0 }, expected: 1 }] } as StageModel + expect(failingTestVectors(unevaluable)).toEqual([0]) + }) + + it('zeroes a scaled feature whose scale is zero and ignores rows outside the bed window', () => { + const model = stageModelSchema.parse({ + version: 'zero-scale', kind: 'logistic', classes: ['wake', 'light', 'deep', 'rem'], + featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'], + logistic: { mean: [0, 0, 0, 0], scale: [0, 1, 1, 1], coef: [[5, 0, 0, 0]], intercept: [0], classIndices: [1, 2] }, + testVectors: [{ inputs: { hrZ: 9, hrStdZ: 0, motionZ: 0, nightFraction: 0 }, expected: 1 }], + }) + expect(evaluateStageModel(model, { hrZ: 9, hrStdZ: 0, motionZ: 0, nightFraction: 0 }).stage).toBe('light') + const t0 = new Date(Date.UTC(2026, 9, 2)) + const vitals = [-5, 0, 1, 2, 3, 4, 20].map(m => ({ timestamp: new Date(t0.getTime() + m * 60_000), heartRate: 60, hrv: null, breathingRate: Number.NaN })) + const rows = stageModelFeatures('podNative', t0, new Date(t0.getTime() + 10 * 60_000), vitals, [{ timestamp: new Date(t0.getTime() - 60_000), totalMovement: 500 }]) + expect(rows).toHaveLength(1) + expect(rows[0].values.hrvRel).toBe(0) + expect(rows[0].values.br15Std).toBe(0) + expect(rows[0].values.motion15).toBe(0) + expect(stageModelFeatures('challenger', t0, new Date(t0.getTime() + 60_000), vitals, [])).toEqual([]) + }) + + it('leaves spikes alone when smoothing is off and applies no transition without a rule', () => { + const t0 = new Date(Date.UTC(2026, 9, 3)) + const vitals = Array.from({ length: 15 }, (_, m) => ({ timestamp: new Date(t0.getTime() + m * 60_000), heartRate: m >= 5 && m < 10 ? 48 : 60 + (m % 2), hrv: 40, breathingRate: 14 })) + const ruleset = { ...DEFAULT_STAGE_RULESET, smoothIsolatedEpochs: false, transitions: [] } + const epochs = classifyWithStageModel(forest(), t0, new Date(t0.getTime() + 15 * 60_000), vitals, [], ruleset) + expect(epochs.map(e => e.stage)).toEqual(['light', 'deep', 'light']) + expect(stageModelBlocks(epochs)).toHaveLength(3) + // The filter nulls a reading outside the ruleset's limits before features are computed. + const spiky = vitals.map((v, i) => (i === 0 ? { ...v, heartRate: 400, hrv: 5000, breathingRate: 99 } : v)) + const filtered = filterModelOutliers(spiky, DEFAULT_STAGE_RULESET.outliers) + expect(filtered[0]).toMatchObject({ heartRate: null, hrv: null, breathingRate: null }) + expect(filterModelOutliers(spiky, { ...DEFAULT_STAGE_RULESET.outliers, heartRateSpikeStdDevs: 0 })[1].heartRate).toBe(61) + }) +}) diff --git a/src/mcp/README.md b/src/mcp/README.md index fcb9201fb..a3d223de1 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -52,6 +52,9 @@ claude mcp add --transport http sleepypod http://192.168.1.88:3000/api/mcp | "Would the movement-only preset match my watch better?" / "Try deep below 0.90" (dry run) | [`preview_sleep_stage_rules`](#sleep-stage-tuning) | `sleepStages.getClassifier`, `previewClassifier` | | "Apply that ruleset" / "Go back to the default rules" | [`set_sleep_stage_rules`](#sleep-stage-tuning) | `sleepStages.getClassifier`, `setClassifier` | | "How far is the pod from my watch?" / "Did that change bring HR closer?" | [`compare_vitals_to_watch`](#watch-deviation-report) | `biometrics.compareToWatch` | +| "Which personal stage models do I have?" | [`get_stage_models`](#personal-stage-models) | `sleepStages.listModels` | +| "How would this model have staged last week?" (dry run) | [`preview_stage_model`](#personal-stage-models) | `sleepStages.previewModel` | +| "Add this model" / "Use model 3" / "Go back to the rules" | [`add_stage_model`, `select_stage_model`, `set_stage_model_active`](#personal-stage-models) | `sleepStages.uploadModel`, `selectModel`, `setModelActive` | ### Control tonight @@ -270,6 +273,26 @@ means `reset: true`). Pass the preview's `candidateRuleset` as `ruleset` to save previewed; overrides are re-resolved against the rules saved at save time. A `CONFLICT` means another save landed first: re-read with `get_sleep_stage_rules` and retry. +### Personal stage models + +`get_stage_models` lists a side's model library (newest first) with each entry's `id`, +`selected` and `active` flags. `preview_stage_model` is read-only: it stages the most recent +`nights` (default 7) with an inline `model` or, when omitted, the side's selected model, and +returns pooled agreement with the Watch next to the rules' agreement plus per-night epochs, +accuracy and kappa. Nights the model trained on are in-sample. `add_stage_model` stores a model +in the library without selecting it; the router refuses unknown feature contracts and models +that fail their own test vectors, and identical uploads return the existing id. +`select_stage_model` makes a library entry the side's model (`active` decides whether it stages +nights) and returns the previously selected entry so it can be restored; +`set_stage_model_active` with `active: false` returns the side to the rules. Both change what +the pod shows and are meant for after the user approves a preview. + +Models travel inline in the tool call, capped at 256 KB of JSON. Logistic models fit; the +forests sleepypod/training exports are about 1-2 MB, more than a host can write into a call, so +upload those with `POST /api/sleep-stages/models` and use these tools to preview, select and +activate them. `PUT /api/sleep-stages/model` replaces the side's model in place (and keeps it +active) and is deliberately not exposed. + ### Watch deviation report `compare_vitals_to_watch` (`biometrics.compareToWatch`, `GET /api/biometrics/watch-deviation`) is diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b3759e605..6372e87b0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -18,6 +18,7 @@ import { registerMaintenanceTools } from '@/src/mcp/tools/maintenance' import { registerScheduleTools } from '@/src/mcp/tools/schedules' import { registerSettingsTools } from '@/src/mcp/tools/settings' import { registerSleepTools } from '@/src/mcp/tools/sleep' +import { registerStageModelTools } from '@/src/mcp/tools/stageModels' import { buildPodStatus, registerStatusTools } from '@/src/mcp/tools/status' export const MCP_SERVER_NAME = 'sleepypod' @@ -30,7 +31,8 @@ Confirm with the user before prime_pod, pod_maintenance and manage_schedule dele Run apply_sleep_curve with mode "preview" and show the result before calling it with mode "apply". Run preview_sleep_stage_rules before set_sleep_stage_rules and apply a tuned ruleset only when it improves kappa against the reference stages; a reset or rollback the user asks for does not need to. Nights without reference stages cannot be scored (see get_sleep_stage_rules coverage); import Apple Watch stages with import_apple_watch_data first. -Every classifier change is recorded in the change log with the ruleset before it, and can be reverted by setting that previous ruleset again.` +Every classifier change is recorded in the change log with the ruleset before it, and can be reverted by setting that previous ruleset again. +Personal stage models: run preview_stage_model before select_stage_model, report in-sample scores as in-sample, and select or activate a model only after the user approves; set_stage_model_active with active false returns a side to the rules.` function json(uri: string, value: unknown) { return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(value, null, 2) }] } @@ -44,6 +46,7 @@ export function createSleepypodMcpServer(): McpServer { registerStatusTools(server) registerSleepTools(server) + registerStageModelTools(server) registerControlTools(server) registerScheduleTools(server) registerSettingsTools(server) diff --git a/src/mcp/tests/server.test.ts b/src/mcp/tests/server.test.ts index 626ee87bd..2038a239f 100644 --- a/src/mcp/tests/server.test.ts +++ b/src/mcp/tests/server.test.ts @@ -27,7 +27,10 @@ const callerMock = vi.hoisted(() => { getMovementSummary: fn(), getVitalsBaseline: fn(), getOccupancy: fn(), importReferenceVitals: fn(), importReferenceSamples: fn(), importReferenceStages: fn(), getReferenceStages: fn(), getReferenceVitals: fn(), getVitals: fn(), compareToWatch: fn(), }, - sleepStages: { getClassifier: fn(), previewClassifier: fn(), setClassifier: fn() }, + sleepStages: { + getClassifier: fn(), previewClassifier: fn(), setClassifier: fn(), + listModels: fn(), previewModel: fn(), uploadModel: fn(), selectModel: fn(), setModelActive: fn(), + }, environment: { getSummary: fn(), getAmbientLightSummary: fn(), getLatestBedTemp: fn(), getLatestFreezerTemp: fn(), getLatestAmbientLight: fn(), @@ -114,6 +117,7 @@ describe('sleepypod MCP server', () => { const client = await connect() const { tools } = await client.listTools() expect(tools.map(t => t.name).sort()).toEqual([ + 'add_stage_model', 'apply_biometric_profile', 'apply_sleep_curve', 'backtest_biometrics', @@ -132,6 +136,7 @@ describe('sleepypod MCP server', () => { 'get_sleep_history', 'get_sleep_stage_rules', 'get_sleep_summary', + 'get_stage_models', 'get_vitals_trend', 'import_apple_watch_data', 'import_biometric_reference', @@ -143,16 +148,19 @@ describe('sleepypod MCP server', () => { 'manage_schedule', 'pod_maintenance', 'preview_sleep_stage_rules', + 'preview_stage_model', 'prime_pod', 'propose_biometric_profile', 'release_biometric_artifacts', 'resume_schedule', 'run_once_curve', 'schedule_biometric_shadow', + 'select_stage_model', 'set_device_settings', 'set_power', 'set_side_settings', 'set_sleep_stage_rules', + 'set_stage_model_active', 'set_temperature', 'stop_biometric_shadow', ]) @@ -1709,3 +1717,143 @@ describe('sleep stage tuning tools', () => { }) }) }) + +describe('personal stage model tools', () => { + const CONTRACT = ['hrZ', 'motionZ'] + const MODEL = { + version: 'research-test-1', + kind: 'forest' as const, + classes: ['wake', 'light', 'deep', 'rem'], + featureContract: CONTRACT, + epochSeconds: 300, + trees: [{ f: [-1], t: [0], l: [-1], r: [-1], v: [[0, 1, 0, 0]] }], + testVectors: [{ inputs: { hrZ: 0, motionZ: 0 }, expected: 1 }], + } + const AGREEMENT = { epochs: 10, accuracy: 0.7, kappa: 0.4, confusion: {}, recall: { wake: null, light: 0.8, deep: 0.5, rem: 0.6 } } + const LIBRARY = [ + { id: 3, version: 'new', kind: 'forest', uploadedAt: 1_800_000_000_000, selected: false, active: false }, + { id: 1, version: 'old', kind: 'forest', uploadedAt: 1_790_000_000_000, metrics: { kappa: 0.25 }, selected: true, active: true }, + ] + + beforeEach(() => vi.clearAllMocks()) + + it('lists the library with ISO times and names the active entry', async () => { + callerMock.sleepStages.listModels.mockResolvedValue(LIBRARY) + const result = await (await connect()).callTool({ name: 'get_stage_models', arguments: { side: 'left' } }) as CallToolResult + expect(callerMock.sleepStages.listModels).toHaveBeenCalledWith({ side: 'left' }) + expect(parse(result)).toMatchObject({ side: 'left', active: 1, models: [{ id: 3, uploadedAt: new Date(1_800_000_000_000).toISOString() }, { id: 1, metrics: { kappa: 0.25 } }] }) + }) + + it('reports no active model when none is active', async () => { + callerMock.sleepStages.listModels.mockResolvedValue([{ ...LIBRARY[0] }]) + const result = await (await connect()).callTool({ name: 'get_stage_models', arguments: { side: 'right' } }) as CallToolResult + expect(parse(result)).toMatchObject({ active: null }) + }) + + it('previews the selected model over seven nights by default and drops the per-epoch blocks', async () => { + callerMock.sleepStages.previewModel.mockResolvedValue({ + version: 'old', pooled: AGREEMENT, rulesPooled: { ...AGREEMENT, kappa: 0 }, + nights: [{ sleepRecordId: 9, enteredBedAt: 1_800_000_000_000, leftBedAt: 1_800_030_000_000, blocks: [{ start: 1, end: 2, stage: 'deep' }], agreement: AGREEMENT, rulesAgreement: null }], + }) + const result = await (await connect()).callTool({ name: 'preview_stage_model', arguments: { side: 'left' } }) as CallToolResult + expect(callerMock.sleepStages.previewModel).toHaveBeenCalledWith({ side: 'left', model: undefined, sleepRecordId: undefined, nights: 7 }) + const body = parse(result) as { note: string, nights: Array> } + expect(body.note).toMatch(/in-sample/) + expect(body.nights[0]).toEqual({ + sleepRecordId: 9, enteredBedAt: new Date(1_800_000_000_000).toISOString(), leftBedAt: new Date(1_800_030_000_000).toISOString(), + model: { epochs: 10, accuracy: 0.7, kappa: 0.4 }, rules: null, + }) + }) + + it('previews an inline candidate for one record without a night count', async () => { + callerMock.sleepStages.previewModel.mockResolvedValue({ version: MODEL.version, pooled: null, rulesPooled: null, nights: [] }) + await (await connect()).callTool({ name: 'preview_stage_model', arguments: { side: 'right', model: MODEL, sleepRecordId: 4, nights: 3 } }) + expect(callerMock.sleepStages.previewModel).toHaveBeenCalledWith({ side: 'right', model: expect.objectContaining({ version: 'research-test-1' }), sleepRecordId: 4, nights: undefined }) + }) + + it('surfaces a preview without a stored model as an error', async () => { + callerMock.sleepStages.previewModel.mockRejectedValue(new TRPCError({ code: 'NOT_FOUND', message: 'No personal stage model stored for left' })) + const result = await (await connect()).callTool({ name: 'preview_stage_model', arguments: { side: 'left' } }) as CallToolResult + expect(result.isError).toBe(true) + expect(text(result)).toBe('NOT_FOUND: No personal stage model stored for left') + }) + + it('adds a model to the library without selecting it', async () => { + callerMock.sleepStages.uploadModel.mockResolvedValue({ id: 5, version: MODEL.version }) + const result = await (await connect()).callTool({ name: 'add_stage_model', arguments: { side: 'left', model: MODEL } }) as CallToolResult + expect(callerMock.sleepStages.uploadModel).toHaveBeenCalledWith({ side: 'left', model: expect.objectContaining({ trees: MODEL.trees }) }) + expect(parse(result)).toMatchObject({ side: 'left', id: 5, selected: false }) + expect(callerMock.sleepStages.selectModel).not.toHaveBeenCalled() + }) + + it('reports a model the pod refuses for its contract or test vectors', async () => { + callerMock.sleepStages.uploadModel.mockRejectedValue(new TRPCError({ code: 'BAD_REQUEST', message: 'Model fails its test vectors' })) + const result = await (await connect()).callTool({ name: 'add_stage_model', arguments: { side: 'left', model: MODEL } }) as CallToolResult + expect(result.isError).toBe(true) + expect(text(result)).toBe('BAD_REQUEST: Model fails its test vectors') + }) + + it('refuses inline models over the size cap and points to the REST upload', async () => { + const leaves = 20_000 + const big = { ...MODEL, trees: [{ f: Array(leaves).fill(-1), t: Array(leaves).fill(0.123456789), l: Array(leaves).fill(-1), r: Array(leaves).fill(-1), v: Array(leaves).fill([0, 1, 0, 0]) }] } + const client = await connect() + for (const name of ['add_stage_model', 'preview_stage_model']) { + const result = await client.callTool({ name, arguments: { side: 'left', model: big } }) as CallToolResult + expect(result.isError).toBe(true) + expect(text(result)).toMatch(/at most 256 KB.*POST \/api\/sleep-stages\/models/) + } + expect(callerMock.sleepStages.uploadModel).not.toHaveBeenCalled() + expect(callerMock.sleepStages.previewModel).not.toHaveBeenCalled() + }) + + it('rejects a malformed model and an unknown side before calling the pod', async () => { + const client = await connect() + const bad = await client.callTool({ name: 'add_stage_model', arguments: { side: 'left', model: { ...MODEL, kind: 'svm' } } }) as CallToolResult + expect(bad.isError).toBe(true) + const side = await client.callTool({ name: 'get_stage_models', arguments: { side: 'middle' } }) as CallToolResult + expect(side.isError).toBe(true) + expect(callerMock.sleepStages.uploadModel).not.toHaveBeenCalled() + expect(callerMock.sleepStages.listModels).not.toHaveBeenCalled() + }) + + it('selects a library model and returns the previous selection to switch back to', async () => { + callerMock.sleepStages.listModels.mockResolvedValue(LIBRARY) + callerMock.sleepStages.selectModel.mockResolvedValue({ version: 'new', active: true }) + const result = await (await connect()).callTool({ name: 'select_stage_model', arguments: { side: 'left', id: 3, active: true } }) as CallToolResult + expect(callerMock.sleepStages.selectModel).toHaveBeenCalledWith({ side: 'left', id: 3, active: true }) + expect(parse(result)).toEqual({ side: 'left', id: 3, version: 'new', active: true, previous: { id: 1, version: 'old', active: true } }) + }) + + it('selects with no previous model and reports a missing library entry', async () => { + callerMock.sleepStages.listModels.mockResolvedValue([]) + callerMock.sleepStages.selectModel.mockResolvedValueOnce({ version: 'new', active: false }) + const client = await connect() + const ok = await client.callTool({ name: 'select_stage_model', arguments: { side: 'right', id: 3, active: false } }) as CallToolResult + expect(parse(ok)).toMatchObject({ previous: null, active: false }) + callerMock.sleepStages.selectModel.mockRejectedValueOnce(new TRPCError({ code: 'NOT_FOUND', message: 'Model not found for this side' })) + const missing = await client.callTool({ name: 'select_stage_model', arguments: { side: 'right', id: 99, active: true } }) as CallToolResult + expect(text(missing)).toBe('NOT_FOUND: Model not found for this side') + }) + + it('turns the model off, and reports activation without a stored model', async () => { + callerMock.sleepStages.setModelActive.mockResolvedValueOnce({ active: false, version: 'old' }) + const client = await connect() + const off = await client.callTool({ name: 'set_stage_model_active', arguments: { side: 'left', active: false } }) as CallToolResult + expect(parse(off)).toEqual({ side: 'left', active: false, version: 'old' }) + callerMock.sleepStages.setModelActive.mockRejectedValueOnce(new TRPCError({ code: 'NOT_FOUND', message: 'No valid personal stage model stored for right' })) + const on = await client.callTool({ name: 'set_stage_model_active', arguments: { side: 'right', active: true } }) as CallToolResult + expect(on.isError).toBe(true) + expect(text(on)).toMatch(/No valid personal stage model stored for right/) + }) + + it('marks reads read-only and selection and activation destructive', async () => { + const { tools } = await (await connect()).listTools() + const hints = Object.fromEntries(tools.filter(t => /stage_model/.test(t.name)).map(t => [t.name, t.annotations])) + expect(hints.get_stage_models).toMatchObject({ readOnlyHint: true }) + expect(hints.preview_stage_model).toMatchObject({ readOnlyHint: true }) + expect(hints.add_stage_model).toMatchObject({ readOnlyHint: false, destructiveHint: false }) + expect(hints.select_stage_model).toMatchObject({ destructiveHint: true }) + expect(hints.set_stage_model_active).toMatchObject({ destructiveHint: true }) + expect(tools.map(t => t.name)).not.toContain('set_stage_model') + }) +}) diff --git a/src/mcp/tools/stageModels.ts b/src/mcp/tools/stageModels.ts new file mode 100644 index 000000000..1d28bf60d --- /dev/null +++ b/src/mcp/tools/stageModels.ts @@ -0,0 +1,144 @@ +/** + * Personal sleep-stage model tools: list a side's model library, preview a + * model against the Watch reference, add one to the library, select it and + * switch it on or off. Each wraps one sleepStages procedure, so the router's + * contract and test-vector checks apply unchanged. + */ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { getCaller, jsonResult, runTool } from '@/src/mcp/caller' +import { side } from '@/src/mcp/schemas' +import { stageModelSchema } from '@/src/lib/personal-stage-model' + +const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } + +/** + * A model must be written out in full inside the tool call, token by token. + * Logistic models are a few kilobytes; forests from sleepypod/training are + * about 1-2 MB, far beyond what a host can emit, so they go through REST. + */ +export const MAX_INLINE_MODEL_BYTES = 256 * 1024 + +const REST_UPLOAD_HINT = 'Upload larger models over REST instead: POST /api/sleep-stages/models with {"side", "model"} ' + + '(for example `jq -n --slurpfile m candidate.json \'{side:"left", model:$m[0]}\' | curl -X POST -H "Content-Type: application/json" ' + + '-d @- http://:3000/api/sleep-stages/models`), then use get_stage_models and select_stage_model here.' + +const model = stageModelSchema.describe( + 'A portable stage model document (sleepypod/training model/candidate.json or the core personal-model export): ' + + `version, kind, classes, featureContract, trees or logistic weights, and testVectors. At most ${MAX_INLINE_MODEL_BYTES / 1024} KB as JSON.`, +) + +function checkSize(value: unknown) { + const bytes = Buffer.byteLength(JSON.stringify(value)) + if (bytes > MAX_INLINE_MODEL_BYTES) { + throw new Error(`Model is ${Math.round(bytes / 1024)} KB; tool calls accept at most ${MAX_INLINE_MODEL_BYTES / 1024} KB. ${REST_UPLOAD_HINT}`) + } +} + +type Agreement = { epochs: number, accuracy: number, kappa: number | null } | null + +const brief = (a: Agreement) => (a ? { epochs: a.epochs, accuracy: a.accuracy, kappa: a.kappa } : null) + +export function registerStageModelTools(server: McpServer) { + server.registerTool('get_stage_models', { + title: 'List personal sleep-stage models', + description: + 'List the side\'s personal stage model library, newest first: id, version, kind, upload time, training metrics, ' + + 'whether it is selected, and whether the selected model is active (staging that side\'s nights instead of the rules). ' + + 'Use the id with select_stage_model. Note the currently selected id before changing anything so the user can switch back.', + inputSchema: { side }, + annotations: READ_ONLY, + }, async ({ side: s }) => runTool(async () => { + const models = await getCaller().sleepStages.listModels({ side: s }) + return jsonResult({ + side: s, + models: models.map(m => ({ ...m, uploadedAt: new Date(m.uploadedAt).toISOString() })), + active: models.find(m => m.active)?.id ?? null, + }) + })) + + server.registerTool('preview_stage_model', { + title: 'Preview a personal sleep-stage model', + description: + 'Stage recent nights with a model and compare its agreement with the Apple Watch reference against the current rules. ' + + 'Read-only: nothing is stored or changed. Pass model to preview a candidate inline, or omit it to preview the side\'s ' + + 'selected model. Always preview before select_stage_model. Nights the model was trained on score in-sample and read ' + + 'higher than held-out results; say so when reporting. Nights without imported Watch stages have no agreement ' + + '(import them with import_apple_watch_data).', + inputSchema: { + side, + nights: z.number().int().min(1).max(30).optional().describe('Most recent nights to stage, 1-30. Default 7.'), + sleepRecordId: z.number().int().optional().describe('Preview a single sleep record instead of the most recent nights.'), + model: model.optional(), + }, + annotations: READ_ONLY, + }, async ({ side: s, nights, sleepRecordId, model: candidate }) => runTool(async () => { + if (candidate) checkSize(candidate) + const result = await getCaller().sleepStages.previewModel({ + side: s, model: candidate, sleepRecordId, nights: sleepRecordId === undefined ? (nights ?? 7) : undefined, + }) + return jsonResult({ + version: result.version, + note: 'Agreement is with Apple Watch stages, which are not polysomnography. Nights the model trained on are in-sample.', + pooled: result.pooled, + rulesPooled: result.rulesPooled, + nights: result.nights.map(n => ({ + sleepRecordId: n.sleepRecordId, + enteredBedAt: new Date(n.enteredBedAt).toISOString(), + leftBedAt: new Date(n.leftBedAt).toISOString(), + model: brief(n.agreement), + rules: brief(n.rulesAgreement), + })), + }) + })) + + server.registerTool('add_stage_model', { + title: 'Add a personal sleep-stage model to the library', + description: + 'Store a model in the side\'s library. It is not selected and does not stage any night until select_stage_model. ' + + 'The pod refuses models whose feature contract it does not know or that do not reproduce their own test vectors. ' + + 'Adding the same model twice returns the existing id. Run preview_stage_model on it first. ' + + `Tool calls carry at most ${MAX_INLINE_MODEL_BYTES / 1024} KB; forests from sleepypod/training are larger. ${REST_UPLOAD_HINT}`, + inputSchema: { side, model }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ side: s, model: candidate }) => runTool(async () => { + checkSize(candidate) + const stored = await getCaller().sleepStages.uploadModel({ side: s, model: candidate }) + return jsonResult({ side: s, ...stored, selected: false, next: 'Preview it, then select_stage_model with this id once the user approves.' }) + })) + + server.registerTool('select_stage_model', { + title: 'Select a personal sleep-stage model', + description: + 'Make a library model the side\'s model. With active: true it stages that side\'s nights from now on, changing what the ' + + 'pod and app show for every stored night; with active: false it is selected but the rules keep staging. Change one side ' + + 'at a time and only after the user explicitly approves the preview. To switch back, select the previously selected id ' + + 'again (from get_stage_models), or call set_stage_model_active with active: false to return to the rules.', + inputSchema: { + side, + id: z.number().int().positive().describe('Library id from get_stage_models.'), + active: z.boolean().describe('true to stage the side\'s nights with this model; false to select it without using it.'), + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, async ({ side: s, id, active }) => runTool(async () => { + const before = (await getCaller().sleepStages.listModels({ side: s })).find(m => m.selected) + const selected = await getCaller().sleepStages.selectModel({ side: s, id, active }) + return jsonResult({ + side: s, id, ...selected, + previous: before ? { id: before.id, version: before.version, active: before.active } : null, + }) + })) + + server.registerTool('set_stage_model_active', { + title: 'Turn the personal sleep-stage model on or off', + description: + 'Switch whether the side\'s selected model stages its nights (true) or the rules do (false). Turning it off is the quick ' + + 'way back to the rules and keeps the model selected. Turning it on changes what the pod shows; do it only after the ' + + 'user explicitly approves. Fails if no valid model is selected for the side.', + inputSchema: { side, active: z.boolean().describe('true to stage nights with the selected model; false to use the rules.') }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, async ({ side: s, active }) => runTool(async () => { + const result = await getCaller().sleepStages.setModelActive({ side: s, active }) + return jsonResult({ side: s, ...result }) + })) +} diff --git a/src/server/routers/biometrics.ts b/src/server/routers/biometrics.ts index 9842a2fed..602677b6f 100644 --- a/src/server/routers/biometrics.ts +++ b/src/server/routers/biometrics.ts @@ -9,8 +9,10 @@ import { deviceSettings } from '@/src/db/schema' import { eq, and, gt, gte, lt, lte, desc, asc, avg, min, max, count, sql, getTableColumns, inArray } from 'drizzle-orm' import { sideSchema, idSchema, validateDateRange } from '@/src/server/validation-schemas' import { listRawFiles } from './raw' -import { loadReference, loadStageView, loadStageInputs } from './sleepStages' +import { loadActiveStageModel, loadReference, loadStageView, loadStageInputs } from './sleepStages' import { stageViewSchema, type StageView } from '@/src/biometrics/profiles/stage-view' +import { classifyWithStageModel, type StageModel } from '@/src/lib/personal-stage-model' +import type { SleepEpoch } from '@/src/lib/sleep-stages' import { classifySleepStages, mergeIntoBlocks, @@ -93,6 +95,31 @@ const DAY_MS = 24 * 60 * 60 * 1000 const WATCH_DEVIATION_DEFAULT_DAYS = 14 const WATCH_DEVIATION_MAX_DAYS = 31 +/** Which algorithm staged a night: the profile's rules, or the side's active personal model. */ +const stagedBySchema = z.object({ engine: z.enum(['rules', 'model']), version: z.string().nullable() }) +type StagedBy = z.infer + +/** + * Stage one night: with the side's active personal model when there is one and it can stage the + * night, otherwise with the rules. The model is applied over the record window the way the phone + * and the Mac evaluator do; a model failure never leaves a night blank. + */ +function stageNight( + vitalsData: Parameters[0], movementData: Parameters[1], + calibrationQuality: number, ruleset: StageRuleset, model: StageModel | null, window: { start: Date, end: Date }, +): { epochs: SleepEpoch[], stagedBy: StagedBy } { + if (model) { + try { + const epochs = classifyWithStageModel(model, window.start, window.end, vitalsData, movementData, ruleset) + if (epochs.length > 0) return { epochs, stagedBy: { engine: 'model', version: model.version } } + } + catch (error) { + console.warn(`[sleep-stages] personal model ${model.version} failed, using rules: ${error instanceof Error ? error.message : error}`) + } + } + return { epochs: classifySleepStages(vitalsData, movementData, calibrationQuality, ruleset), stagedBy: { engine: 'rules', version: null } } +} + /** * One sleep record's pod series, Watch samples and re-classified stages for * compareToWatch. Pod vitals are read past the record's edges by the widest @@ -129,7 +156,7 @@ async function loadDeviationNight(side: 'left' | 'right', record: typeof sleepRe breathingRate: watch(watchSamples.filter(s => s.metric === 'breathing_rate')), hrvSdnn: watch(watchSamples.filter(s => s.metric === 'hrv_sdnn')), }, - epochs: classifySleepStages(inRecord(vitalsData), inRecord(movementData), calibrationQuality, ruleset), + epochs: stageNight(inRecord(vitalsData), inRecord(movementData), calibrationQuality, ruleset, loadActiveStageModel(side), { start, end }).epochs, reference, } } @@ -1295,6 +1322,25 @@ export const biometricsRouter = router({ * samples the newest survive (like getVitals), so the latest night is * never the one that goes missing. */ + getReferenceSamples: publicProcedure + .meta({ openapi: { method: 'GET', path: '/biometrics/reference-samples', protect: false, tags: ['Biometrics'] } }) + .input(z.object({ side: sideSchema, startDate: z.date(), endDate: z.date(), + metric: z.enum(['breathing_rate', 'hrv_sdnn']), + limit: z.number().int().min(1).max(20000).default(5000), + }).strict()) + .output(z.array(z.object({ timestamp: z.date(), value: z.number() }))) + .query(async ({ input }) => { + if (!validateDateRange(input.startDate, input.endDate)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) + } + const rows = await biometricsDb.select({ timestamp: referenceSamples.timestamp, value: referenceSamples.value }) + .from(referenceSamples) + .where(and(eq(referenceSamples.side, input.side), eq(referenceSamples.metric, input.metric), + gte(referenceSamples.timestamp, input.startDate), lte(referenceSamples.timestamp, input.endDate))) + .orderBy(desc(referenceSamples.timestamp)).limit(input.limit) + return rows.reverse() + }), + getReferenceVitals: publicProcedure .meta({ openapi: { method: 'GET', path: '/biometrics/reference-vitals', protect: false, tags: ['Biometrics'] } }) .output(z.array(z.object({ @@ -1620,11 +1666,13 @@ export const biometricsRouter = router({ qualityScore: z.number(), totalSleepMs: z.number(), stageView: stageViewSchema.optional(), + /** Which algorithm staged this result: the profile's rules or the side's active personal model. */ + stagedBy: stagedBySchema.optional(), sleepRecordId: z.number().nullable(), enteredBedAt: z.number().nullable(), leftBedAt: z.number().nullable(), })) - .query(async ({ input }): Promise => { + .query(async ({ input }): Promise => { try { // Reject ambiguous requests: if sleepRecordId is provided alongside date range, error out if (input.sleepRecordId && (input.startDate || input.endDate)) { @@ -1763,12 +1811,14 @@ export const biometricsRouter = router({ const stageView = loadStageView(input.side) const classifier = stageView.classifier - // Classify stages - const epochs = classifySleepStages(vitalsData, movementData, calibrationQuality, classifier.ruleset) + // Classify stages: the side's active personal model over the record window, else the rules. + const { epochs, stagedBy } = stageNight(vitalsData, movementData, calibrationQuality, classifier.ruleset, + loadActiveStageModel(input.side), { start: windowStart, end: windowEnd }) if (epochs.length === 0) { return { stageView, + stagedBy, epochs: [], blocks: [], distribution: { wake: 0, light: 0, deep: 0, rem: 0 }, @@ -1787,6 +1837,7 @@ export const biometricsRouter = router({ return { stageView, + stagedBy, epochs, blocks, distribution, diff --git a/src/server/routers/sleepStages.ts b/src/server/routers/sleepStages.ts index 955598b12..5a96f043a 100644 --- a/src/server/routers/sleepStages.ts +++ b/src/server/routers/sleepStages.ts @@ -8,13 +8,23 @@ import { writeLegacyStageClassifier } from '@/src/biometrics/profiles/legacy-sta import { ReferenceInputError } from '@/src/biometrics/references/time' import { ProfileStore } from '@/src/biometrics/profiles/store' import type { Override } from '@/src/biometrics/algorithms/registry' -import { sideSettings, stageClassifierChanges } from '@/src/db/schema' +import { sideSettings, sideStageModels, stageClassifierChanges, stageModelLibrary } from '@/src/db/schema' +import { + classifyWithStageModel, + failingTestVectors, + stageModelBlocks, + stageModelContract, + stageModelSchema, + type StageModel, +} from '@/src/lib/personal-stage-model' import { and, asc, desc, eq, gt, gte, lte } from 'drizzle-orm' import { sideSchema } from '@/src/server/validation-schemas' import { changedSegments, classifyNights, + classifySleepStages, piezoCalibrationQuality, + scoreAgainstReference, type NightInputs, type SleepStageBlock, } from '@/src/lib/sleep-stages' @@ -32,6 +42,27 @@ import { type Side = 'left' | 'right' +function validateModel(model: StageModel) { + if (!stageModelContract(model)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown model feature contract' }) + if (failingTestVectors(model).length) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Model fails its test vectors' }) +} + +/** Called inside the same transaction as any selection change. Identical uploads are idempotent. */ +function saveLibraryModel(tx: Pick, side: Side, model: unknown, version: string, uploadedAt: Date) { + const existing = tx.select({ id: stageModelLibrary.id }).from(stageModelLibrary) + .where(and(eq(stageModelLibrary.side, side), eq(stageModelLibrary.model, model))).get() + if (existing) return existing.id + return tx.insert(stageModelLibrary).values({ side, model, version, uploadedAt }).returning({ id: stageModelLibrary.id }).get().id +} + +/** The side's stored model when it is marked active and still validates; null means the rules stage the side. */ +export function loadActiveStageModel(side: Side): StageModel | null { + const row = db.select().from(sideStageModels).where(and(eq(sideStageModels.side, side), eq(sideStageModels.active, true))).get() + if (!row) return null + const parsed = stageModelSchema.safeParse(row.model) + return parsed.success ? parsed.data : null +} + /** Rules delivered to the stage consumer, with the actual bundle identity. */ export function loadStageView(side: Side) { return consumeStageView(db, biometricsDb, side) @@ -372,4 +403,178 @@ export const sleepStagesRouter = router({ }) } }), + + listModels: publicProcedure + .meta({ openapi: { method: 'GET', path: '/sleep-stages/models', protect: false, tags: ['Sleep stages'] } }) + .input(z.object({ side: sideSchema }).strict()) + .output(z.array(z.object({ id: z.number(), version: z.string(), kind: z.string(), uploadedAt: z.number(), + metrics: z.record(z.string(), z.unknown()).optional(), selected: z.boolean(), active: z.boolean() }))) + .query(({ input }) => { + const selected = db.select().from(sideStageModels).where(eq(sideStageModels.side, input.side)).get() + return db.select().from(stageModelLibrary).where(eq(stageModelLibrary.side, input.side)) + .orderBy(desc(stageModelLibrary.id)).all().flatMap((row) => { + const parsed = stageModelSchema.safeParse(row.model) + if (!parsed.success) return [] + const isSelected = JSON.stringify(row.model) === JSON.stringify(selected?.model) + return [{ id: row.id, version: row.version, kind: parsed.data.kind, uploadedAt: row.uploadedAt.getTime(), + metrics: parsed.data.metrics, selected: isSelected, active: isSelected && !!selected?.active }] + }) + }), + + uploadModel: publicProcedure + .meta({ openapi: { method: 'POST', path: '/sleep-stages/models', protect: false, tags: ['Sleep stages'] } }) + .input(z.object({ side: sideSchema, model: stageModelSchema }).strict()) + .output(z.object({ id: z.number(), version: z.string() })) + .mutation(({ input }) => { + validateModel(input.model) + const id = db.transaction(tx => saveLibraryModel(tx, input.side, input.model, input.model.version, new Date())) + return { id, version: input.model.version } + }), + + selectModel: publicProcedure + .meta({ openapi: { method: 'PUT', path: '/sleep-stages/models/selected', protect: false, tags: ['Sleep stages'] } }) + .input(z.object({ side: sideSchema, id: z.number().int().positive(), active: z.boolean() }).strict()) + .output(z.object({ version: z.string(), active: z.boolean() })) + .mutation(({ input }) => db.transaction((tx) => { + const row = tx.select().from(stageModelLibrary).where(and(eq(stageModelLibrary.id, input.id), eq(stageModelLibrary.side, input.side))).get() + if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Model not found for this side' }) + const model = stageModelSchema.parse(row.model) + validateModel(model) + tx.insert(sideStageModels).values({ side: input.side, model, version: model.version, uploadedAt: row.uploadedAt, active: input.active }) + .onConflictDoUpdate({ target: sideStageModels.side, set: { model, version: model.version, uploadedAt: row.uploadedAt, active: input.active } }).run() + return { version: model.version, active: input.active } + })), + + /** The side's uploaded personal stage model, if any, and whether it stages the side's nights. */ + getModel: publicProcedure + .meta({ openapi: { method: 'GET', path: '/sleep-stages/model', protect: false, tags: ['Sleep stages'] } }) + .input(z.object({ side: sideSchema }).strict()) + .output(z.object({ model: stageModelSchema.nullable(), uploadedAt: z.number().nullable(), active: z.boolean() })) + .query(async ({ input }) => { + const row = db.select().from(sideStageModels).where(eq(sideStageModels.side, input.side)).get() + if (!row) return { model: null, uploadedAt: null, active: false } + const parsed = stageModelSchema.safeParse(row.model) + return { model: parsed.success ? parsed.data : null, uploadedAt: row.uploadedAt.getTime(), active: parsed.success && row.active } + }), + + /** + * Make the stored model stage the side's nights (or go back to the rules). The profile's rules stay + * the fallback for any night the model cannot stage, and the choice is recorded on every stage read + * as `stagedBy`. + */ + setModelActive: publicProcedure + .meta({ openapi: { method: 'PUT', path: '/sleep-stages/model/active', protect: false, tags: ['Sleep stages'] } }) + .input(z.object({ side: sideSchema, active: z.boolean() }).strict()) + .output(z.object({ active: z.boolean(), version: z.string().nullable() })) + .mutation(async ({ input }) => { + const row = db.select().from(sideStageModels).where(eq(sideStageModels.side, input.side)).get() + if (!row || !stageModelSchema.safeParse(row.model).success) { + if (input.active) throw new TRPCError({ code: 'NOT_FOUND', message: `No valid personal stage model stored for ${input.side}` }) + return { active: false, version: null } + } + db.update(sideStageModels).set({ active: input.active }).where(eq(sideStageModels.side, input.side)).run() + return { active: input.active, version: row.version } + }), + + /** + * Upload a personal stage model (or null to remove it). The document must + * validate, name a known feature contract, and reproduce every one of its + * own test vectors, so a converter mismatch is refused at the door. + */ + setModel: publicProcedure + .meta({ openapi: { method: 'PUT', path: '/sleep-stages/model', protect: false, tags: ['Sleep stages'] } }) + .input(z.object({ side: sideSchema, model: stageModelSchema.nullable() }).strict()) + .output(z.object({ stored: z.boolean(), version: z.string().nullable() })) + .mutation(async ({ input }) => { + if (input.model === null) { + db.delete(sideStageModels).where(eq(sideStageModels.side, input.side)).run() + return { stored: false, version: null } + } + if (!stageModelContract(input.model)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown feature contract: ${input.model.featureContract.join(', ')}` }) + } + const failing = failingTestVectors(input.model) + if (failing.length > 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Model fails ${failing.length} of its ${input.model.testVectors.length} test vectors (first: ${failing[0]})` }) + } + const model = input.model + db.transaction((tx) => { + const previous = tx.select().from(sideStageModels).where(eq(sideStageModels.side, input.side)).get() + if (previous) saveLibraryModel(tx, input.side, previous.model, previous.version, previous.uploadedAt) + saveLibraryModel(tx, input.side, model, model.version, new Date()) + tx.insert(sideStageModels) + .values({ side: input.side, model, version: model.version, uploadedAt: new Date() }) + .onConflictDoUpdate({ target: sideStageModels.side, set: { model, version: model.version, uploadedAt: new Date() } }) + .run() + }) + return { stored: true, version: input.model.version } + }), + + /** + * Stage recent nights with a personal model (the uploaded one, or one sent + * inline) and score it against the Watch beside the side's rules on the + * same nights: the pod-side half of the model pipeline. + */ + previewModel: publicProcedure + .meta({ openapi: { method: 'POST', path: '/sleep-stages/model/preview', protect: false, tags: ['Sleep stages'] } }) + .input(z.object({ + side: sideSchema, + sleepRecordId: z.number().int().optional(), + nights: z.number().int().min(1).max(30).optional(), + model: stageModelSchema.optional(), + }).strict()) + .output(z.object({ + version: z.string().nullable(), + nights: z.array(z.object({ + sleepRecordId: z.number(), + enteredBedAt: z.number(), + leftBedAt: z.number(), + blocks: z.array(z.object({ start: z.number(), end: z.number(), stage: stageEnum })), + agreement: agreementSchema, + rulesAgreement: agreementSchema, + })), + pooled: agreementSchema, + rulesPooled: agreementSchema, + })) + .mutation(async ({ input }) => { + let model: StageModel | null = input.model ?? null + if (!model) { + const row = db.select().from(sideStageModels).where(eq(sideStageModels.side, input.side)).get() + const parsed = row ? stageModelSchema.safeParse(row.model) : null + if (!parsed?.success) throw new TRPCError({ code: 'NOT_FOUND', message: `No personal stage model stored for ${input.side}` }) + model = parsed.data + } + if (failingTestVectors(model).length > 0) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Model fails its own test vectors' }) + try { + const loaded = await loadNights(input.side, input.sleepRecordId, input.nights ?? 1) + const ruleset = (await loadStageClassifier(input.side)).ruleset + const staged = loaded.map(night => ({ + night, + epochs: classifyWithStageModel(model, night.record.enteredBedAt, night.record.leftBedAt, night.vitalsData, night.movementData, ruleset), + rules: classifySleepStages(night.vitalsData, night.movementData, night.calibrationQuality, ruleset), + })) + const allReference = staged.flatMap(s => s.night.reference) + return { + version: model.version, + nights: staged.map(s => ({ + sleepRecordId: s.night.record.id, + enteredBedAt: s.night.record.enteredBedAt.getTime(), + leftBedAt: s.night.record.leftBedAt.getTime(), + blocks: stageModelBlocks(s.epochs), + agreement: scoreAgainstReference(s.epochs, s.night.reference), + rulesAgreement: scoreAgainstReference(s.rules, s.night.reference), + })), + pooled: scoreAgainstReference(staged.flatMap(s => s.epochs), allReference), + rulesPooled: scoreAgainstReference(staged.flatMap(s => s.rules), allReference), + } + } + catch (error) { + if (error instanceof TRPCError) throw error + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to preview stage model: ${error instanceof Error ? error.message : 'Unknown error'}`, + cause: error, + }) + } + }), }) diff --git a/src/server/routers/tests/biometrics.referenceVitals.test.ts b/src/server/routers/tests/biometrics.referenceVitals.test.ts index 43c4bc246..046f81acf 100644 --- a/src/server/routers/tests/biometrics.referenceVitals.test.ts +++ b/src/server/routers/tests/biometrics.referenceVitals.test.ts @@ -175,6 +175,29 @@ describe('biometrics reference vitals', () => { }) }) + describe('getReferenceSamples', () => { + it('keeps the newest samples within the limit in chronological order', async () => { + await caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'hrv_sdnn', samples: [ + { timestamp: T0 + 2 * MIN, value: 52 }, { timestamp: T0, value: 48 }, { timestamp: T0 + MIN, value: 50 }, + ] }) + const rows = await caller.getReferenceSamples({ side: 'left', metric: 'hrv_sdnn', startDate: new Date(T0 * 1000), endDate: new Date((T0 + 3 * MIN) * 1000), limit: 2 }) + expect(rows.map(r => r.value)).toEqual([50, 52]) + }) + it('isolates side, metric and time window, returning chronological samples', async () => { + for (const side of ['left', 'right'] as const) { + await caller.importReferenceSamples({ side, source: 'apple_watch', metric: 'hrv_sdnn', samples: [ + { timestamp: T0 + 2 * MIN, value: 52 }, { timestamp: T0, value: 48 }, + ] }) + } + await caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'breathing_rate', samples: [{ timestamp: T0, value: 15 }] }) + expect(await caller.getReferenceSamples({ side: 'left', metric: 'hrv_sdnn', startDate: new Date(T0 * 1000), endDate: new Date((T0 + MIN) * 1000) })).toEqual([{ timestamp: new Date(T0 * 1000), value: 48 }]) + expect((await caller.getReferenceSamples({ side: 'right', metric: 'hrv_sdnn', startDate: new Date(T0 * 1000), endDate: new Date((T0 + 3 * MIN) * 1000) })).map(r => r.value)).toEqual([48, 52]) + }) + it('rejects reversed ranges', async () => { + await expect(caller.getReferenceSamples({ side: 'left', metric: 'hrv_sdnn', startDate: new Date(2000), endDate: new Date(1000) })).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + }) + }) + describe('getReferenceVitals', () => { beforeEach(async () => { await caller.importReferenceVitals({ side: 'left', source: 'apple_watch', samples }) diff --git a/src/server/routers/tests/biometrics.test.ts b/src/server/routers/tests/biometrics.test.ts index ff132bada..0749a5505 100644 --- a/src/server/routers/tests/biometrics.test.ts +++ b/src/server/routers/tests/biometrics.test.ts @@ -92,6 +92,7 @@ const dbMock = vi.hoisted(() => { }) // Top-level db mock for device settings (timezone fetch in getSleepStages default) +// and the side's stored personal model (`.get()`, none stored here, so the rules stage every night). const topDbMock = vi.hoisted(() => { const makeChain = () => { const chain: Record = {} @@ -99,6 +100,7 @@ const topDbMock = vi.hoisted(() => { chain.from = vi.fn(() => chain) chain.where = vi.fn(() => chain) chain.limit = vi.fn(() => chain) + chain.get = vi.fn(() => undefined) return chain } return { select: vi.fn(() => makeChain()) } @@ -1638,6 +1640,7 @@ describe('biometrics.getSleepStages query window', () => { const out = await caller.getSleepStages({ side: 'left', sleepRecordId: 3 }) expect(out).toEqual({ stageView: expect.objectContaining({ provenance: expect.objectContaining({ interpretation: 'current_classifier_view' }) }), + stagedBy: { engine: 'rules', version: null }, epochs: [], blocks: [], distribution: { wake: 0, light: 0, deep: 0, rem: 0 }, diff --git a/src/server/routers/tests/sleepStages.test.ts b/src/server/routers/tests/sleepStages.test.ts index 46d58428f..107a8dde8 100644 --- a/src/server/routers/tests/sleepStages.test.ts +++ b/src/server/routers/tests/sleepStages.test.ts @@ -409,4 +409,150 @@ describe('sleepStages router', () => { expect(out.saved?.agreement?.accuracy).toBeLessThan(1) }) }) + describe('personal stage model', () => { + /** One tree: deep when hrZ <= -0.5, else light. */ + const model = { + version: 'test-forest', + kind: 'forest' as const, + classes: ['wake', 'light', 'deep', 'rem'] as ['wake', 'light', 'deep', 'rem'], + featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction'], + trees: [{ f: [0, -1, -1], t: [-0.5, 0, 0], l: [1, -1, -1], r: [2, -1, -1], v: [[0, 0, 0, 0], [0, 1, 9, 0], [1, 8, 1, 0]] }], + testVectors: [ + { inputs: { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 2 }, + { inputs: { hrZ: 1, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 1 }, + ], + } + + it('keeps multiple uploads without changing staging, deduplicates, and switches per side', async () => { + const first = await caller.uploadModel({ side: 'left', model }) + expect(await caller.uploadModel({ side: 'left', model })).toEqual(first) + expect((await caller.getModel({ side: 'left' })).model).toBeNull() + await caller.selectModel({ side: 'left', id: first.id, active: true }) + const second = await caller.uploadModel({ side: 'left', model: { ...model, version: 'second' } }) + expect((await caller.getModel({ side: 'left' })).model?.version).toBe('test-forest') + expect((await caller.getModel({ side: 'left' })).active).toBe(true) + expect(await caller.listModels({ side: 'right' })).toEqual([]) + expect((await rejectionOf(caller.selectModel({ side: 'right', id: first.id, active: true }))).code).toBe('NOT_FOUND') + await caller.selectModel({ side: 'left', id: second.id, active: true }) + const list = await caller.listModels({ side: 'left' }) + expect(list).toHaveLength(2) + expect(list.filter(m => m.active).map(m => m.version)).toEqual(['second']) + await caller.selectModel({ side: 'left', id: first.id, active: false }) + expect((await caller.getModel({ side: 'left' })).active).toBe(false) + expect((await caller.getModel({ side: 'left' })).model?.version).toBe('test-forest') + expect(await caller.listModels({ side: 'left' })).toHaveLength(2) + }) + + it('rejects invalid library uploads without storing a candidate or changing the active model', async () => { + await caller.setModel({ side: 'left', model }) + await caller.setModelActive({ side: 'left', active: true }) + const wrong = { ...model, testVectors: [{ inputs: { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 1 }] } + expect((await rejectionOf(caller.uploadModel({ side: 'left', model: wrong }))).code).toBe('BAD_REQUEST') + expect(await caller.listModels({ side: 'left' })).toHaveLength(1) + expect((await caller.getModel({ side: 'left' })).active).toBe(true) + }) + + it('archives legacy uploads and preserves the existing active-state contract', async () => { + await caller.setModel({ side: 'left', model }) + await caller.setModelActive({ side: 'left', active: true }) + await caller.setModel({ side: 'left', model: { ...model, version: 'legacy replacement' } }) + expect(await caller.listModels({ side: 'left' })).toHaveLength(2) + expect((await caller.getModel({ side: 'left' })).active).toBe(true) + await caller.setModel({ side: 'left', model: null }) + expect(await caller.listModels({ side: 'left' })).toHaveLength(2) + }) + + it('stores a validated model per side and removes it with null', async () => { + expect(await caller.getModel({ side: 'left' })).toEqual({ model: null, uploadedAt: null, active: false }) + const stored = await caller.setModel({ side: 'left', model }) + expect(stored).toEqual({ stored: true, version: 'test-forest' }) + const out = await caller.getModel({ side: 'left' }) + expect(out.model?.version).toBe('test-forest') + expect(out.uploadedAt).not.toBeNull() + expect(out.active).toBe(false) + expect((await caller.getModel({ side: 'right' })).model).toBeNull() + expect(await caller.setModel({ side: 'left', model: null })).toEqual({ stored: false, version: null }) + expect((await caller.getModel({ side: 'left' })).model).toBeNull() + }) + + it('refuses a model that fails its own vectors or names an unknown contract', async () => { + const wrong = { ...model, testVectors: [{ inputs: { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 1 }] } + expect((await rejectionOf(caller.setModel({ side: 'left', model: wrong }))).code).toBe('BAD_REQUEST') + const unknown = { ...model, featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction', 'extra'], + trees: [{ f: [4, -1, -1], t: [0, 0, 0], l: [1, -1, -1], r: [2, -1, -1], v: [[0, 0, 0, 0], [0, 1, 9, 0], [1, 8, 1, 0]] }], + testVectors: [{ inputs: { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0, extra: -1 }, expected: 2 }] } + expect((await rejectionOf(caller.setModel({ side: 'left', model: unknown }))).code).toBe('BAD_REQUEST') + }) + + it('scores the model and the rules against the Watch on the same nights', async () => { + seedNight() + bio.insert(bioSchema.referenceStages).values([ + { side: 'left', source: 'apple_watch', start: BED, end: at(20), stage: 'light' }, + { side: 'left', source: 'apple_watch', start: at(20), end: at(40), stage: 'deep' }, + { side: 'left', source: 'apple_watch', start: at(40), end: at(60), stage: 'light' }, + ]).run() + expect((await rejectionOf(caller.previewModel({ side: 'left' }))).code).toBe('NOT_FOUND') + await caller.setModel({ side: 'left', model }) + const out = await caller.previewModel({ side: 'left' }) + expect(out.version).toBe('test-forest') + expect(out.nights).toHaveLength(1) + // The HR dip in minutes 20–40 is the only low-z stretch: the tree calls it deep, matching the Watch exactly. + expect(out.nights[0].blocks.map(b => b.stage)).toEqual(['light', 'deep', 'light']) + expect(out.nights[0].agreement?.accuracy).toBe(1) + expect(out.pooled?.epochs).toBe(12) + expect(out.rulesPooled?.epochs).toBe(12) + const inline = await caller.previewModel({ side: 'right', model, nights: 3 }) + expect(inline.nights).toHaveLength(0) + }) + + it('stages the side with the model once it is made active, and falls back to the rules without one', async () => { + const { biometricsRouter } = await import('@/src/server/routers/biometrics') + const bio = biometricsRouter.createCaller({}) + const record = seedNight() + expect((await rejectionOf(caller.setModelActive({ side: 'left', active: true }))).code).toBe('NOT_FOUND') + expect(await caller.setModelActive({ side: 'left', active: false })).toEqual({ active: false, version: null }) + await caller.setModel({ side: 'left', model }) + const rules = await bio.getSleepStages({ side: 'left', sleepRecordId: record.id }) + expect(rules.stagedBy).toEqual({ engine: 'rules', version: null }) + expect(await caller.setModelActive({ side: 'left', active: true })).toEqual({ active: true, version: 'test-forest' }) + expect((await caller.getModel({ side: 'left' })).active).toBe(true) + const staged = await bio.getSleepStages({ side: 'left', sleepRecordId: record.id }) + expect(staged.stagedBy).toEqual({ engine: 'model', version: 'test-forest' }) + // The model works in 5-minute epochs from bed entry over the 60-minute record. + expect(staged.epochs).toHaveLength(12) + expect(staged.epochs.every(e => e.duration === 300_000)).toBe(true) + expect(staged.blocks.map(b => b.stage)).toEqual(['light', 'deep', 'light']) + // The "last night" lookup finds no overnight record in this fixture and answers empty, before any staging. + const latest = await bio.getSleepStages({ side: 'left' }) + expect(latest.sleepRecordId).toBeNull() + expect(latest.stagedBy).toBeUndefined() + // Removing the model returns the side to the rules. + await caller.setModel({ side: 'left', model: null }) + expect((await bio.getSleepStages({ side: 'left', sleepRecordId: record.id })).stagedBy?.engine).toBe('rules') + // A stored model the evaluator cannot run (an unknown contract written straight to the table) falls back to the rules. + const main = dbHolder.main as ReturnType> + const odd = { ...model, version: 'odd', featureContract: ['hrZ', 'hrStdZ', 'motionZ', 'nightFraction', 'extra'], + trees: [{ f: [4, -1, -1], t: [0, 0, 0], l: [1, -1, -1], r: [2, -1, -1], v: [[0, 0, 0, 0], [0, 1, 9, 0], [1, 8, 1, 0]] }], + testVectors: [{ inputs: { hrZ: 0, hrStdZ: 0, motionZ: 0, nightFraction: 0, extra: -1 }, expected: 2 }] } + main.insert(mainSchema.sideStageModels).values({ side: 'left', model: odd, version: 'odd', uploadedAt: new Date(), active: true }).run() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect((await bio.getSleepStages({ side: 'left', sleepRecordId: record.id })).stagedBy).toEqual({ engine: 'rules', version: null }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('personal model odd failed')) + warn.mockRestore() + }) + + it('refuses an inline model that fails its vectors and wraps unexpected failures', async () => { + const main = dbHolder.main as ReturnType> + const wrong = { ...model, testVectors: [{ inputs: { hrZ: -1, hrStdZ: 0, motionZ: 0, nightFraction: 0.5 }, expected: 1 }] } + expect((await rejectionOf(caller.previewModel({ side: 'left', model: wrong }))).code).toBe('BAD_REQUEST') + // A stored document that no longer validates reads back as nothing stored, and cannot be active. + main.insert(mainSchema.sideStageModels).values({ side: 'right', model: { nonsense: true }, version: 'x', uploadedAt: new Date(), active: true }).run() + expect(await caller.getModel({ side: 'right' })).toEqual({ model: null, uploadedAt: expect.any(Number), active: false }) + expect((await rejectionOf(caller.previewModel({ side: 'right' }))).code).toBe('NOT_FOUND') + dbHolder.bio = throwing(new Error('disk gone')) + const error = await rejectionOf(caller.previewModel({ side: 'left', model })) + expect(error.code).toBe('INTERNAL_SERVER_ERROR') + expect(error.message).toBe('Failed to preview stage model: disk gone') + }) + }) })