diff --git a/.github/workflows/python-modules.yml b/.github/workflows/python-modules.yml index d8997abfe..67d24a058 100644 --- a/.github/workflows/python-modules.yml +++ b/.github/workflows/python-modules.yml @@ -83,8 +83,8 @@ jobs: target: . cov_source: . deps: "" - # Stdlib-only repo scripts; tests load them by path from - # scripts/tests/. + # Repo scripts, including the NumPy-backed personal-model label helper. + # Reuse the locked numerical environment for reproducible tests. - name: scripts workdir: scripts target: tests @@ -113,6 +113,8 @@ jobs: if [ "${{ matrix.module.name }}" = "common" ]; then # Keep common's cwd/import layout while using the numerical lock. UV_PROJECT_ENVIRONMENT="$PWD/.venv" uv sync --project piezo-processor --locked --no-install-project + elif [ "${{ matrix.module.name }}" = "scripts" ]; then + UV_PROJECT_ENVIRONMENT="$PWD/.venv" uv sync --project ../modules/piezo-processor --locked --no-install-project elif [ -f uv.lock ]; then uv sync --locked --no-install-project else diff --git a/.node-version b/.node-version index a45fd52cc..ce62013c3 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24 +24.22.1 diff --git a/AGENTS.md b/AGENTS.md index 8e5870fad..ebe45bc31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ pump-stall diagnosis. The `/debug` console exposes live diagnostics. -# This is NOT the Next.js you know +## This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. diff --git a/app/[lang]/schedule/[curve]/page.tsx b/app/[lang]/schedule/[curve]/page.tsx index 6027b3864..9a740f51d 100644 --- a/app/[lang]/schedule/[curve]/page.tsx +++ b/app/[lang]/schedule/[curve]/page.tsx @@ -8,16 +8,15 @@ const SIDES: SideSelection[] = ['left', 'right', 'both'] export default async function ScheduleCurve({ params, searchParams }: { params: Promise<{ curve: string }> - searchParams: Promise<{ side?: string | string[], from?: string | string[], saved?: string | string[] }> + searchParams: Promise<{ side?: string | string[], from?: string | string[] }> }) { const { curve } = await params - const { side, from, saved } = await searchParams + const { side, from } = await searchParams return ( s === side)} fromBoth={from === 'both'} - savedCurve={typeof saved === 'string' ? saved : undefined} /> ) } diff --git a/app/api/biometric-tuning/datasets/[id]/export/route.ts b/app/api/biometric-tuning/datasets/[id]/export/route.ts new file mode 100644 index 000000000..868161fd9 --- /dev/null +++ b/app/api/biometric-tuning/datasets/[id]/export/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { biometricsDb } from '@/src/db' +import { coordinatorRequest } from '@/src/biometrics/backtest/coordinator-service' +import type { CoordinatorResults } from '@/src/biometrics/backtest/coordinator-tasks' +import { datasetBundleChunks, datasetBundleTarBytes, retainedRawDirectory } from '@/src/biometrics/backtest/dataset-export' + +// One export at a time: each one reads every retained archive of the dataset +// twice (verification, then streaming). A stalled client must not latch it. +let inflight = false +const EXPORT_WATCHDOG_MS = (() => { + const raw = Number(process.env.DATASET_EXPORT_WATCHDOG_MS ?? '') + return Number.isFinite(raw) && raw > 0 ? raw : 30 * 60 * 1000 +})() + +function errorResponse(error: unknown) { + // Match on the code, not the class: the coordinator's reply is rebuilt as a + // ReferenceInputError that need not be the same class instance this route + // imports once Next has bundled both. + const code = (error as { code?: unknown } | null)?.code + if (code === 'NOT_FOUND' || code === 'DATASET_RELEASED') return NextResponse.json({ error: 'Dataset not found or released' }, { status: 404 }) + if (code === 'DATASET_CORRUPT') return NextResponse.json({ error: `DATASET_CORRUPT: ${(error as Error).message}` }, { status: 409 }) + if (code === 'WORKER_UNAVAILABLE') return NextResponse.json({ error: (error as Error).message }, { status: 503 }) + return NextResponse.json({ error: 'Dataset export failed' }, { status: 500 }) +} + +/** + * Stream one frozen calibration dataset as an uncompressed tar: bundle.json + * (index with every file's SHA-256), manifest.json, snapshot.json and the + * retained raw/.RAW.gz archives it references. Nothing else is read. + */ +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + if (!z.string().uuid().safeParse(id).success) return NextResponse.json({ error: 'Invalid dataset id' }, { status: 400 }) + if (inflight) return NextResponse.json({ error: 'Another dataset export is in progress, retry later' }, { status: 429, headers: { 'Retry-After': '60' } }) + inflight = true + let prepared: CoordinatorResults['exportDataset'] + let directory: string + try { + prepared = await coordinatorRequest(biometricsDb, { type: 'exportDataset', input: { id } }) + directory = retainedRawDirectory(biometricsDb) + } + catch (error) { + inflight = false + return errorResponse(error) + } + + const chunks = datasetBundleChunks(prepared.index, { manifest: prepared.manifestText, snapshot: prepared.snapshotText }, directory) + let watchdog: ReturnType | undefined + const release = () => { + if (watchdog) clearTimeout(watchdog) + inflight = false + } + const body = new ReadableStream({ + start(controller) { + watchdog = setTimeout(() => { + controller.error(new Error('Dataset export exceeded its time limit')) + void chunks.return(undefined).finally(release) + }, EXPORT_WATCHDOG_MS) + }, + async pull(controller) { + try { + const next = await chunks.next() + if (next.done) { + release() + controller.close() + } + else controller.enqueue(next.value) + } + catch (error) { + release() + controller.error(error) + } + }, + async cancel() { + await chunks.return(undefined).finally(release) + }, + }) + return new Response(body, { + headers: { + 'Content-Type': 'application/x-tar', + 'Content-Disposition': `attachment; filename="calibration-dataset-${id}.tar"`, + 'Content-Length': String(datasetBundleTarBytes(prepared.index)), + 'Cache-Control': 'no-store', + 'X-Dataset-Content-Hash': prepared.index.dataset.contentHash, + }, + }) +} diff --git a/app/api/biometrics/analysis-export/route.test.ts b/app/api/biometrics/analysis-export/route.test.ts new file mode 100644 index 000000000..b7a25d63f --- /dev/null +++ b/app/api/biometrics/analysis-export/route.test.ts @@ -0,0 +1,248 @@ +// @vitest-environment node +import { createHash, hkdfSync } from 'node:crypto' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, truncateSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { hostname, tmpdir } from 'node:os' +import { join } from 'node:path' +import Database from 'better-sqlite3' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parseTarHeader } from '@/src/biometrics/backtest/bundle-tar' + +const state = vi.hoisted(() => ({ archive: '', source: '', seed: { source: 'mmc-cid', value: 'cid-0123' } as { source: string, value: string } })) +vi.mock('@/src/lib/podStorage', () => ({ + get ARCHIVE_DIR() { + return state.archive + }, +})) +vi.mock('@/src/db', () => ({ biometricsDb: { $client: { get name() { + return state.source +} } } })) +vi.mock('@/src/homekit/storage', () => ({ readSeed: () => state.seed })) +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ + getDacMonitorIfRunning: () => ({ getLastStatus: () => ({ podVersion: 'J00', sensorLabel: '20600-0003-J55-B0708DE3' }) }), +})) + +const START = Date.UTC(2026, 9, 10, 6) +const END = Date.UTC(2026, 9, 10, 14) +const MINUTE = 60_000 +const journal = JSON.parse(readFileSync(join(process.cwd(), 'src/db/biometrics-migrations/meta/_journal.json'), 'utf-8')) as { entries: Array<{ when: number, tag: string }> } +const latest = journal.entries[journal.entries.length - 1] + +let root: string +let snapshots: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'analysis-export-test-')) + state.archive = join(root, 'biometrics-archive') + snapshots = join(root, 'tmpfs') + mkdirSync(state.archive) + mkdirSync(snapshots) + vi.stubEnv('TMPDIR', snapshots) + state.source = join(root, 'biometrics.db') + const database = new Database(state.source) + database.pragma('journal_mode = WAL') + database.exec('CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash TEXT NOT NULL, created_at NUMERIC); CREATE TABLE vitals (id INTEGER PRIMARY KEY, heart_rate REAL)') + database.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?), (?, ?)').run('old-hash', 1, 'latest-hash', latest.when) + // Left in the WAL (no checkpoint) so the snapshot must include uncheckpointed pages. + database.prepare('INSERT INTO vitals (heart_rate) VALUES (?), (?)').run(58, 61) + sources.push(database) +}) +const sources: Database.Database[] = [] +afterEach(() => { + for (const database of sources.splice(0)) database.close() + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +function archive(name: string, mtimeMs: number, body: string | Buffer = `frame ${name}`) { + const path = join(state.archive, name) + writeFileSync(path, body) + utimesSync(path, mtimeMs / 1000, mtimeMs / 1000) + return path +} + +async function loadRoute() { + vi.resetModules() + return await import('./route') +} + +const get = (GET: (request: Request) => Promise, query: string, init?: RequestInit) => + GET(new Request(`http://pod/api/biometrics/analysis-export?${query}`, init)) + +function untar(buffer: Buffer) { + const entries: Array<{ name: string, body: Buffer }> = [] + let position = 0 + for (;;) { + const header = parseTarHeader(buffer.subarray(position, position + 512)) + position += 512 + if (!header) break + entries.push({ name: header.name, body: buffer.subarray(position, position + header.bytes) }) + position += Math.ceil(header.bytes / 512) * 512 + } + return entries +} +const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex') + +describe('GET /api/biometrics/analysis-export', () => { + it('selects archives by the widened mtime window and streams a hashed snapshot', async () => { + archive('A0000001.RAW.gz', START - 21 * MINUTE) + archive('A0000002.RAW.gz', START - 19 * MINUTE) + archive('A0000003.RAW.gz', START + 4 * 60 * MINUTE, Buffer.alloc(1300, 7)) + archive('A0000004.RAW.gz', END + 44 * MINUTE) + archive('A0000005.RAW.gz', END + 46 * MINUTE) + archive('A0000006.RAW.gz.tmp.4242', START + MINUTE) + archive('notes.txt', START + MINUTE) + symlinkSync(archive('linked-target', START), join(state.archive, 'A0000007.RAW.gz')) + const before = readdirSync(state.archive).sort() + + const { GET } = await loadRoute() + const response = await get(GET, `start=${new Date(START).toISOString()}&end=${END}`) + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/x-tar') + expect(response.headers.get('Cache-Control')).toBe('no-store, no-transform') + const buffer = Buffer.from(await response.arrayBuffer()) + expect(buffer.length).toBe(Number(response.headers.get('Content-Length'))) + + const entries = untar(buffer) + expect(entries.map(entry => entry.name)).toEqual(['biometrics.db', 'raw/A0000002.RAW.gz', 'raw/A0000003.RAW.gz', 'raw/A0000004.RAW.gz', 'manifest.json']) + const manifest = JSON.parse(entries[4].body.toString()) + expect(manifest.formatVersion).toBe('analysis-export-v1') + expect(manifest.files).toEqual(entries.slice(0, 4).map(entry => expect.objectContaining({ path: entry.name, bytes: entry.body.length, sha256: sha256(entry.body) }))) + expect(manifest.files[2]).toEqual({ role: 'raw', path: 'raw/A0000003.RAW.gz', name: 'A0000003.RAW.gz', mtimeMs: START + 4 * 60 * MINUTE, bytes: 1300, sha256: sha256(Buffer.alloc(1300, 7)) }) + expect(manifest.window).toEqual({ + requested: { startMs: START, endMs: END, start: new Date(START).toISOString(), end: new Date(END).toISOString() }, + rawSelection: { includeRaw: true, mtimeFromMs: START - 20 * MINUTE, mtimeToMs: END + 45 * MINUTE }, + served: { archiveCount: 3, rawMtimeMinMs: START - 19 * MINUTE, rawMtimeMaxMs: END + 44 * MINUTE }, + }) + expect(manifest.pod).toEqual({ id: Buffer.from(hkdfSync('sha256', 'cid-0123', 'sleepypod-pod-id-v1', 'pod-id', 16)).toString('hex'), + idSource: 'mmc-cid', hostname: hostname(), model: { podVersion: 'J00', name: 'Pod 5', hardwareRevision: 'J55' } }) + expect(manifest.schema.biometrics).toEqual({ migrationHash: 'latest-hash', migrationCreatedAt: latest.when, migrationTag: latest.tag, appliedMigrations: 2 }) + expect(manifest.build).toEqual(expect.objectContaining({ branch: expect.any(String), commitHash: expect.any(String) })) + + const extracted = join(root, 'extracted.db') + writeFileSync(extracted, entries[0].body) + const copy = new Database(extracted, { readonly: true }) + expect(copy.prepare('SELECT heart_rate FROM vitals ORDER BY id').pluck().all()).toEqual([58, 61]) + copy.close() + + expect(readdirSync(snapshots)).toEqual([]) + expect(readdirSync(state.archive).sort()).toEqual(before) + }) + + it('omits RAW archives with raw=0', async () => { + archive('A0000001.RAW.gz', START) + const { GET } = await loadRoute() + const response = await get(GET, `start=${START}&end=${END}&raw=0`) + const entries = untar(Buffer.from(await response.arrayBuffer())) + expect(entries.map(entry => entry.name)).toEqual(['biometrics.db', 'manifest.json']) + const manifest = JSON.parse(entries[1].body.toString()) + expect(manifest.window.served).toEqual({ archiveCount: 0, rawMtimeMinMs: null, rawMtimeMaxMs: null }) + expect(manifest.window.rawSelection.includeRaw).toBe(false) + }) + + it('reports a null pod id when only a random dev seed exists', async () => { + state.seed = { source: 'random-dev', value: 'abc' } + try { + const { GET } = await loadRoute() + const entries = untar(Buffer.from(await (await get(GET, `start=${START}&end=${END}`)).arrayBuffer())) + expect(JSON.parse(String(entries.at(-1)?.body)).pod).toMatchObject({ id: null, idSource: 'random-dev' }) + } + finally { + state.seed = { source: 'mmc-cid', value: 'cid-0123' } + } + }) + + it.each([ + ['', 'missing start and end'], + [`start=${START}`, 'missing end'], + [`start=${END}&end=${START}`, 'start after end'], + [`start=${START}&end=${START}`, 'empty window'], + [`start=${START}&end=${START + 14 * 24 * 60 * MINUTE + 1}`, 'over 14 days'], + [`start=yesterday&end=${END}`, 'unparseable start'], + [`start=${START}&end=${END}&raw=yes`, 'bad raw flag'], + ])('rejects %s (%s) with 400', async (query) => { + const { GET } = await loadRoute() + const response = await get(GET, query) + expect(response.status).toBe(400) + expect((await response.json()).error).toMatch(/start and end/) + expect(readdirSync(snapshots)).toEqual([]) + }) + + it('accepts exactly 14 days', async () => { + const { GET } = await loadRoute() + const response = await get(GET, `start=${START}&end=${START + 14 * 24 * 60 * MINUTE}&raw=0`) + expect(response.status).toBe(200) + await response.arrayBuffer() + }) + + it('rejects windows over 2 GiB of RAW with 413 and no snapshot', async () => { + const sparse = archive('A0000001.RAW.gz', START, '') + truncateSync(sparse, 2 * 1024 ** 3 - 10) + utimesSync(sparse, START / 1000, START / 1000) + archive('A0000002.RAW.gz', START + MINUTE, Buffer.alloc(11)) + const { GET } = await loadRoute() + const response = await get(GET, `start=${START}&end=${END}`) + expect(response.status).toBe(413) + expect((await response.json()).error).toMatch(/2 RAW archives totalling 2147483649 bytes.*raw=0/) + expect(readdirSync(snapshots)).toEqual([]) + const without = await get(GET, `start=${START}&end=${END}&raw=0`) + expect(without.status).toBe(200) + await without.arrayBuffer() + }) + + it('allows one export at a time and removes the snapshot when the client cancels', async () => { + archive('A0000001.RAW.gz', START, Buffer.alloc(600_000, 1)) + const { GET } = await loadRoute() + const first = await get(GET, `start=${START}&end=${END}`) + expect(readdirSync(snapshots)).toHaveLength(1) + expect((await get(GET, `start=${START}&end=${END}`)).status).toBe(429) + const reader = (first.body as ReadableStream).getReader() + await reader.read() + await reader.cancel() + await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([])) + const next = await get(GET, `start=${START}&end=${END}&raw=0`) + expect(next.status).toBe(200) + await next.arrayBuffer() + }) + + it('removes the snapshot when the request is aborted mid-stream', async () => { + archive('A0000001.RAW.gz', START, Buffer.alloc(600_000, 1)) + const { GET } = await loadRoute() + const controller = new AbortController() + const response = await get(GET, `start=${START}&end=${END}`, { signal: controller.signal }) + await (response.body as ReadableStream).getReader().read() + controller.abort() + await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([])) + expect((await get(GET, `start=${START}&end=${END}&raw=0`)).status).toBe(200) + }) + + it('cleans up and streams nothing when the client left while the snapshot was taken', async () => { + archive('A0000001.RAW.gz', START) + const { GET } = await loadRoute() + const controller = new AbortController() + controller.abort() + const response = await get(GET, `start=${START}&end=${END}`, { signal: controller.signal }) + expect(response.status).toBe(499) + expect(readdirSync(snapshots)).toEqual([]) + expect((await get(GET, `start=${START}&end=${END}&raw=0`)).status).toBe(200) + }) + + it('aborts the stream and removes the snapshot when an archive vanishes', async () => { + const path = archive('A0000001.RAW.gz', START) + const { GET } = await loadRoute() + const response = await get(GET, `start=${START}&end=${END}`) + unlinkSync(path) + await expect(response.arrayBuffer()).rejects.toThrow() + await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([])) + }) + + it('returns 500 and cleans up when the database cannot be read', async () => { + state.source = join(root, 'missing.db') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { GET } = await loadRoute() + const response = await get(GET, `start=${START}&end=${END}`) + expect(response.status).toBe(500) + expect(error).toHaveBeenCalled() + expect(readdirSync(snapshots)).toEqual([]) + error.mockRestore() + }) +}) diff --git a/app/api/biometrics/analysis-export/route.ts b/app/api/biometrics/analysis-export/route.ts new file mode 100644 index 000000000..48a0ef83f --- /dev/null +++ b/app/api/biometrics/analysis-export/route.ts @@ -0,0 +1,128 @@ +import { mkdtemp, rm, statfs } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { biometricsDb } from '@/src/db' +import { AnalysisExportTooLargeError, analysisWindowSchema, liveDatabaseBytes, planAnalysisExport } from '@/src/biometrics/analysis-export' +import { analysisBundleTarBytes, analysisExportChunks, podIdentity, readBuildInfo, snapshotBiometricsDatabase, type AnalysisBundle } from '@/src/biometrics/analysis-export-bundle' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +// One export at a time: each holds a database snapshot on tmpfs (RAM) until it finishes. +let inflight = false +const EXPORT_WATCHDOG_MS = (() => { + const raw = Number(process.env.ANALYSIS_EXPORT_WATCHDOG_MS ?? '') + return Number.isFinite(raw) && raw > 0 ? raw : 60 * 60 * 1000 +})() + +/** + * Stream an analysis snapshot as an uncompressed tar: a consistent backup of + * biometrics.db, the RAW archives for the window (raw=1, the default) and + * manifest.json with every file's SHA-256. Read-only: the backup goes through + * a read-only connection into os.tmpdir() (tmpfs on the pod) and is deleted + * when the stream ends, fails or the client disconnects. + */ +export async function GET(request: Request) { + const query = new URL(request.url).searchParams + const raw = query.get('raw') ?? '1' + const parsed = raw === '0' || raw === '1' + ? analysisWindowSchema.safeParse({ start: query.get('start') ?? '', end: query.get('end') ?? '', includeRaw: raw === '1' }) + : null + if (!parsed?.success) { + const detail = parsed ? parsed.error.issues.map(issue => issue.message).join('; ') : 'raw must be 0 or 1' + return Response.json({ error: `Specify start and end as ISO 8601 or epoch ms, at most 14 days apart, and raw=0|1: ${detail}` }, { status: 400 }) + } + if (inflight) return Response.json({ error: 'Another analysis export is in progress, retry later' }, { status: 429, headers: { 'Retry-After': '60' } }) + inflight = true + + let directory: string | undefined + const release = async () => { + const target = directory + directory = undefined + try { + if (target) await rm(target, { recursive: true, force: true }) + } + finally { inflight = false } + } + + let bundle: AnalysisBundle + try { + const plan = await planAnalysisExport(parsed.data) + const source = biometricsDb.$client.name + const neededBytes = await liveDatabaseBytes(source) + const space = await statfs(tmpdir()) + if (neededBytes > space.bavail * space.bsize) { + await release() + return Response.json({ error: `Not enough free space in ${tmpdir()} for a ${neededBytes}-byte database snapshot` }, { status: 507 }) + } + directory = await mkdtemp(path.join(tmpdir(), 'sp-analysis-export-')) + const [snapshot, build] = await Promise.all([snapshotBiometricsDatabase(source, directory), readBuildInfo()]) + bundle = { generatedAtMs: Date.now(), window: plan.window, database: { path: snapshot.path, bytes: snapshot.bytes }, + archives: plan.archives, build, pod: podIdentity(), schema: snapshot.schema } + } + catch (error) { + await release() + if (error instanceof AnalysisExportTooLargeError) return Response.json({ error: error.message }, { status: 413 }) + console.error('analysis export:', error) + return Response.json({ error: 'Analysis export failed' }, { status: 500 }) + } + + const chunks = analysisExportChunks(bundle) + let watchdog: ReturnType | undefined + let finished = false + // Runs once on completion, failure, cancel, client abort or the watchdog. + // chunks.return() closes any open file handle before the snapshot is removed. + const finish = async () => { + if (finished) return + finished = true + if (watchdog) clearTimeout(watchdog) + request.signal.removeEventListener('abort', onAbort) + try { + await chunks.return(undefined) + } + finally { await release() } + } + const onAbort = () => void finish() + request.signal.addEventListener('abort', onAbort, { once: true }) + // The client may have gone while the snapshot was being taken; the abort + // event has then already fired, so clean up now instead of streaming. + if (request.signal.aborted) { + await finish() + return new Response(null, { status: 499 }) + } + const body = new ReadableStream({ + start(controller) { + watchdog = setTimeout(() => { + controller.error(new Error('Analysis export exceeded its time limit')) + void finish() + }, EXPORT_WATCHDOG_MS) + }, + async pull(controller) { + try { + const next = await chunks.next() + if (next.done) { + await finish() + controller.close() + } + else controller.enqueue(next.value) + } + catch (error) { + await finish() + controller.error(error) + } + }, + async cancel() { + await finish() + }, + }) + const { startMs, endMs } = bundle.window + return new Response(body, { + headers: { + 'Content-Type': 'application/x-tar', + 'Content-Disposition': `attachment; filename="sleepypod-analysis-${startMs}-${endMs}.tar"`, + 'Content-Length': String(analysisBundleTarBytes(bundle)), + // no-transform: a proxy that recompresses the tar would break Content-Length and the manifest hashes. + 'Cache-Control': 'no-store, no-transform', + }, + }) +} diff --git a/app/api/biometrics/watch-upload/route.test.ts b/app/api/biometrics/watch-upload/route.test.ts index 888b8b889..2be35c9fe 100644 --- a/app/api/biometrics/watch-upload/route.test.ts +++ b/app/api/biometrics/watch-upload/route.test.ts @@ -8,8 +8,8 @@ import { POST } from './route' let directory: string let server: Server -const seen = new Set() -const requests: Array<{ side: string, source: string }> = [] +const seen = new Set() +const requests: Array<{ side: string, source: string, metric?: string, samples?: Array<{ value?: number, heartRate?: number }> }> = [] beforeAll(async () => { directory = await mkdtemp(join(tmpdir(), 'watch-upload-test-')) vi.stubEnv('WATCH_UPLOAD_DIR', directory) @@ -20,8 +20,9 @@ beforeAll(async () => { requests.push(data) let written = 0 for (const sample of data.samples ?? []) { - if (!seen.has(sample.timestamp)) { - seen.add(sample.timestamp) + const key = `${data.metric ?? 'heart_rate'}:${sample.timestamp}` + if (!seen.has(key)) { + seen.add(key) written++ } } @@ -51,6 +52,26 @@ it('streams XML through the real importer, routes to the selected side, dedupes, expect(await readdir(directory)).toEqual([]) }) +it('imports Watch breathing rate and HRV, skips records sleepypod wrote to Health, and dedupes per metric', async () => { + requests.length = 0 + const date = new Date(Date.now() - 60_000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' +0000') + const record = (type: string, source: string, value: number, unit: string) => + `` + const xml = `${[ + record('RespiratoryRate', 'Apple Watch', 14.5, 'count/min'), + record('HeartRateVariabilitySDNN', 'Apple Watch', 48, 'ms'), + // The pod's own readings written back by the iOS app, even from a source named like a Watch. + record('RespiratoryRate', 'sleepypod', 99, 'count/min'), + record('HeartRateVariabilitySDNN', 'sleepypod design', 99, 'ms'), + record('HeartRate', 'sleepypod Watch', 99, 'count/min'), + ].join('')}` + const request = () => new Request('http://pod/api/biometrics/watch-upload?side=left&days=7', { method: 'POST', body: xml }) + expect(await (await POST(request())).json()).toEqual({ stages: 0, vitals: 0, breathing: 1, hrv: 1, rejectedStageBatches: 0, + matchedStages: 0, matchedVitals: 0, matchedBreathing: 1, matchedHrv: 1 }) + expect(requests.map(r => [r.metric, r.side, r.samples?.map(s => s.value)])).toEqual([['breathing_rate', 'left', [14.5]], ['hrv_sdnn', 'left', [48]]]) + expect(await (await POST(request())).json()).toMatchObject({ breathing: 0, hrv: 0, matchedBreathing: 1, matchedHrv: 1 }) +}) + it('rejects malformed files and cleans up after parser failure', async () => { const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body: ' { + const response = await POST(new Request('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { + method: 'POST', headers: { origin: 'http://attacker.example.com:3000', host: 'attacker.example.com:3000' }, body: '', + })) + expect(response.status).toBe(403) + expect(await readdir(directory)).toEqual([]) +}) + +it('reports a body cut short of its Content-Length instead of a generic import failure', async () => { + const before = requests.length + const zip = new Uint8Array(4096).fill(0x41) + zip.set([0x50, 0x4B, 0x03, 0x04]) + const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { + method: 'POST', headers: { 'content-length': String(zip.byteLength * 2) }, body: zip, + })) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Upload incomplete: the pod received 4096 of 8192 bytes. Nothing was imported; retry the upload.') + expect(requests.length).toBe(before) + expect(await readdir(directory)).toEqual([]) +}) + it('rejects cross-origin requests, missing side, and oversized bodies before import', async () => { for (const origin of ['http://evil', 'http://192.168.1.88:8080', 'null']) { const response = await POST(new Request('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { origin, host: '192.168.1.88:3000' }, body: 'x' })) diff --git a/app/api/biometrics/watch-upload/route.ts b/app/api/biometrics/watch-upload/route.ts index c5658c0d2..14017d593 100644 --- a/app/api/biometrics/watch-upload/route.ts +++ b/app/api/biometrics/watch-upload/route.ts @@ -2,13 +2,15 @@ import { spawn } from 'node:child_process' import { mkdir, mkdtemp, open, rm } from 'node:fs/promises' import path from 'node:path' import { z } from 'zod' +import { rejectUnknownHost } from '@/src/lib/hostAllowlist' import { WATCH_IMPORT_TIMEOUT_MS, WATCH_UPLOAD_TIMEOUT_MS } from '@/src/utils/watchUploadLimits' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const MAX_BYTES = 512 * 1024 * 1024 const options = z.object({ side: z.enum(['left', 'right']), days: z.coerce.number().int().min(1).max(90).default(30) }) -const resultSchema = z.object({ stages: z.number(), vitals: z.number(), rejectedStageBatches: z.number(), matchedStages: z.number(), matchedVitals: z.number() }) +const resultSchema = z.object({ stages: z.number(), vitals: z.number(), breathing: z.number(), hrv: z.number(), rejectedStageBatches: z.number(), + matchedStages: z.number(), matchedVitals: z.number(), matchedBreathing: z.number(), matchedHrv: z.number() }) const shared = globalThis as typeof globalThis & { watchUploadBusy?: boolean } /** @@ -28,11 +30,15 @@ function isSameOrigin(request: Request): boolean { /** Raw export.zip/export.xml body, streamed to flash. Uses the same importer as the CLI. */ export async function POST(request: Request): Promise { + // proxy.ts skips this route so Next streams the body instead of truncating it at 10 MB. + const rejectedHost = rejectUnknownHost(request) + if (rejectedHost) return rejectedHost if (!isSameOrigin(request)) return Response.json({ error: 'Cross-origin upload is not allowed' }, { status: 403 }) const parsed = options.safeParse(Object.fromEntries(new URL(request.url).searchParams)) if (!parsed.success) return Response.json({ error: 'Specify side=left|right and days=1..90' }, { status: 400 }) if (!request.body) return Response.json({ error: 'Send export.zip or export.xml as the request body' }, { status: 400 }) - if (Number(request.headers.get('content-length')) > MAX_BYTES) return Response.json({ error: 'Upload exceeds 512 MiB' }, { status: 413 }) + const declaredBytes = Number(request.headers.get('content-length')) + if (declaredBytes > MAX_BYTES) return Response.json({ error: 'Upload exceeds 512 MiB' }, { status: 413 }) if (shared.watchUploadBusy) return Response.json({ error: 'Another Watch import is running; retry when it finishes' }, { status: 409 }) shared.watchUploadBusy = true let signal = AbortSignal.any([request.signal, AbortSignal.timeout(WATCH_UPLOAD_TIMEOUT_MS)]) @@ -62,6 +68,9 @@ export async function POST(request: Request): Promise { await file.close() } if (bytes === 0) return Response.json({ error: 'The uploaded file is empty' }, { status: 400 }) + if (declaredBytes > 0 && bytes !== declaredBytes) { + return Response.json({ error: `Upload incomplete: the pod received ${bytes} of ${declaredBytes} bytes. Nothing was imported; retry the upload.` }, { status: 400 }) + } signal = AbortSignal.any([request.signal, AbortSignal.timeout(WATCH_IMPORT_TIMEOUT_MS)]) const { side, days } = parsed.data const stdout = await new Promise((resolve, reject) => { diff --git a/app/globals.css b/app/globals.css index 3510997a4..751acaffc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -48,6 +48,16 @@ --color-vital-hr: var(--chart-vital-hr); --color-vital-hrv: var(--chart-vital-hrv); --color-vital-br: var(--chart-vital-br); + --color-calib-prev: var(--calib-prev); + --color-calib-saved: var(--calib-saved); + --color-calib-none: var(--calib-none); + --color-calib-better: var(--calib-better); + --color-actor-agent: var(--actor-agent); + --color-actor-agent-bg: var(--actor-agent-bg); + --color-actor-you: var(--actor-you); + --color-actor-you-bg: var(--actor-you-bg); + --color-actor-pod: var(--actor-pod); + --color-actor-pod-bg: var(--actor-pod-bg); --radius-tag: 3px; --radius-thumb: 6px; --radius-ctl: 8px; @@ -162,6 +172,18 @@ --chart-vital-hrv: #b294e6; --chart-vital-br: #6db3e3; + /* Biometric calibration: previous vs saved profile, and who acted. */ + --calib-prev: #3a3a40; + --calib-saved: #7fc0ea; + --calib-none: #2a2a2f; + --calib-better: #5fc27a; + --actor-agent: #b89cf0; + --actor-agent-bg: #231c33; + --actor-you: #7fc0ea; + --actor-you-bg: #12202c; + --actor-pod: #5fc27a; + --actor-pod-bg: #12261a; + --toggle-off-track: var(--sp-line-2); --toggle-off-knob: #6a6a70; --toggle-on-knob: var(--sp-ink-0); @@ -204,6 +226,16 @@ --chart-vital-hr: #c94f40; --chart-vital-hrv: #7a5cc4; --chart-vital-br: #2f7fb8; + --calib-prev: #c4c4c0; + --calib-saved: #2f7fb8; + --calib-none: #e2e2df; + --calib-better: #2e9d5b; + --actor-agent: #6a4fb8; + --actor-agent-bg: #ece6f8; + --actor-you: #2f6fb5; + --actor-you-bg: #e3eef9; + --actor-pod: #2e8a52; + --actor-pod-bg: #e2f3e8; --toggle-off-track: #dcdcd8; --toggle-off-knob: #ffffff; --toggle-on-knob: #ffffff; diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index eb156a87c..a1b014de3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -320,3 +320,41 @@ The biometrics worker location probes are also excluded from automatic tracing: `coordinator.cjs` and `shadow-host.cjs` in the standalone bundle after Next builds. Keep that packaging step and its dependency-graph checks when changing tracing. Do not apply these annotations to shipped assets that need inclusion in a release. + +## Optional recording controls + +System → Storage → Data recording controls app diagnostic logs, the Health +status timeline and compressed RAW waveform archives independently. It does +not disable hardware safety checks, live sensing, biometric database records, +module lifecycle status, firmware/OS logs or hourly flash-write measurements. +Existing saved data remains subject to its normal retention policy. + +- **Quiet** (default) emits core console and bundled Python warnings/errors. + **Off** suppresses those application logs. Direct third-party stdout/stderr + and firmware logs are outside this control. The installer configures the + system journal in RAM, so suppressing these logs primarily saves processing + and memory rather than further flash writes. +- **Debug for 15 minutes** temporarily permits all application log levels, + then returns to the selected Quiet/Off setting. Expiry is an absolute + timestamp, evaluated at each log call; it survives process restarts and + needs no timer-driven persistent write. Existing RAM logs remain until + journal rotation or reboot. +- Disabling **Health timeline** skips new `health_runs` samples (within one + minute). Live diagnostic queries and the module `system_health` lifecycle + records continue. Re-enabling starts fresh timeline runs. +- Disabling **Raw waveform archive** stops future gzip writes. A file already + being compressed may finish. The linker releases pending RAM pins within + one minute; the archiver still removes completed temporary files using its + normal age threshold so firmware that retains RAW files cannot fill tmpfs. + Firmware's current files and existing compressed archives are preserved. + This applies to the legacy RAW/tmpfs archiver, not NATS-managed telemetry. + +Preferences live in `recording-policy.json` beside `DATABASE_URL` (normally +`/persistent/sleepypod-data/`). The core writes the complete JSON atomically +only when a setting changes; Python readers cache it for at most one second. +The archiver resolves the same directory via `DATABASE_URL` or +`/etc/sleepypod/data-dir`. `SP_RECORDING_POLICY_FILE` overrides the path for +isolated tests. The file is shared with shell/Python services intentionally, +so no SQLite schema change or privileged service restart is needed to apply +preferences. Deploy the core, Python common module and archiver scripts +together using the normal updater. diff --git a/docs/biometrics-datasets.md b/docs/biometrics-datasets.md index b52e093ae..598edf371 100644 --- a/docs/biometrics-datasets.md +++ b/docs/biometrics-datasets.md @@ -249,3 +249,78 @@ reader verification → Python numerical replay → release → collection, whil checking that canonical observations and the parent dataset remain unchanged. Public `signalReplay` remains false until coordinator/API source selection and fenced waveform-job/report integration are wired to this publisher. + +## Off-pod fitting bundle + +`GET /api/biometric-tuning/datasets/{id}/export` streams one live dataset as an +uncompressed tar: `bundle.json` (every other file with its size and SHA-256), +`manifest.json`, `snapshot.json` and the retained `raw/.RAW.gz` archives +the snapshot references. The coordinator verifies the dataset hash, checks that +the live RAW references match the snapshot exactly, and hashes each archive before +anything is sent. The route hashes the archives again while streaming and aborts +on any change. Released or unknown datasets return 404; an integrity failure +returns 409 without streaming. The MCP tool `export_calibration_dataset` returns +the same index and the download path, never waveform bytes. +`scripts/biometrics-local-fit` replays these bundles with the shipped Python +worker and scores them with the shipped evaluator. Its reports are labelled +`exploratory-local` and are never activation evidence. + +## Analysis snapshot export + +`GET /api/biometrics/analysis-export?start=&end=&raw=1|0` +streams an uncompressed tar for off-pod analysis, such as comparing pod +biometrics with the Apple Watch. It replaces the ssh `backup()` + scp step and +`fetch_raw.sh`. The route is read-only and does no replay or backtest work: + +- `biometrics.db`: a consistent online backup taken through a separate + read-only connection. The backup is written to `os.tmpdir()` (tmpfs on the + pod, so the eMMC is never written) and deleted when the stream ends, fails or + the client disconnects. If the snapshot would not fit in tmpfs the route + returns 507. +- `raw/.RAW.gz` (when `raw=1`, the default): finished archives from + `/persistent/biometrics-archive` whose file mtime falls in + [start − 20 min, end + 45 min], oldest first. A frame is gzipped about 15 min + after it closes, so the widening catches the frames that overlap the window. + `*.tmp.*` partials and symlinks are skipped. +- `manifest.json`: the last entry, written once every other file has been + hashed during streaming. Fields: + - `formatVersion`: `analysis-export-v1`. + - `generatedAt` / `generatedAtMs` and `build` (branch, commit, title and build + date from `.git-info`). + - `pod`: `id` is a stable pod identifier, HKDF-SHA256 over the same + hardware-rooted seed chain the HomeKit identity uses (eMMC CID, then eMMC + serial, then `/etc/machine-id`; ADR 0020). It is the same on every export + and survives reinstalls; the seed itself never leaves the pod. `idSource` + names the seed used (`id` is null on a dev machine with no hardware seed). + `hostname`, and `model` (`podVersion`, model name and sensor hardware + revision from the last DAC status poll, or null before the first poll). + - `schema.biometrics`: the latest applied drizzle migration in the snapshot + (`migrationHash`, `migrationCreatedAt`, `migrationTag` from the migration + journal, `appliedMigrations`). + - `window`: `requested` start/end, `rawSelection` (the widened mtime range and + `includeRaw`) and `served` (archive count and the min/max mtime of the + archives actually included). + - `files`: every other entry with `bytes` and `sha256`; raw entries also carry + the original `name` and `mtimeMs`. + +The window must be at most 14 days with start before end (400 otherwise). If +the window's RAW archives total more than 2 GiB the route returns 413 without +taking a snapshot; narrow the window or pass `raw=0`. One export runs at a time +(429 while another is streaming). `Content-Length` is exact, so a download cut +short by a pruned archive or a dropped connection is detectable. + +The MCP tool `export_analysis_snapshot` (tRPC `biometricTuning.describeAnalysisExport`) +returns the download path, archive count, RAW bytes and an estimated tar size +for a window, never the bytes themselves. + +**Privacy:** the snapshot is the full biometrics database for both sides of the +bed plus raw sensor waveforms. Download it only to trusted machines and don't +share it. + +```sh +mkdir -p snapshot && curl -fsS "http://POD:3000/api/biometrics/analysis-export?start=2026-10-15T09:16:00Z&end=2026-10-15T15:24:00Z" | tar -xf - -C snapshot/ +``` + +To keep history beyond the pod's retention, run `pnpm biometrics:archive pull` +whenever you want to sync: it pulls this export into a long-term local store +with a merged database (`scripts/biometrics-archive/README.md`). diff --git a/docs/biometrics-reference-imports.md b/docs/biometrics-reference-imports.md index 5413a8c94..4a96e7c15 100644 --- a/docs/biometrics-reference-imports.md +++ b/docs/biometrics-reference-imports.md @@ -138,6 +138,14 @@ This alignment primitive does not by itself establish validated improvement. The existing `biometrics.importReferenceStages` and `importReferenceVitals` contracts still accept Unix seconds and return `{ written }`. They retain first-write-wins heart-rate behavior and whole-request stage-overlap rejection. +`biometrics.importReferenceSamples` (`POST /api/biometrics/reference-samples`) +follows the same contract for one `metric` per request, `breathing_rate` +(breaths/min, 4–60) or `hrv_sdnn` (ms, 1–500), as `{ timestamp, value }` +samples stored in `reference_samples` and first-write-wins on side, metric, +time and source. The **Upload Apple Watch data** dialog +(`POST /api/biometrics/watch-upload`) feeds all three from a Health export, +using only Watch-sourced records: anything the sleepypod iOS app wrote back +into Health is the pod's own data and is never imported as a reference. Newly accepted rows and normalized provenance commit atomically; if provenance cannot be saved, the legacy insert rolls back too. @@ -173,3 +181,27 @@ macro F1, coverage, and stage-duration/transition differences on common support. Absent classes have null scores. Transitions across unobserved gaps are not inferred. Conflicting intervals from separate sources remain unscored. This scorer is a primitive for replay reports; it is not a new live stage classifier. + +### Pod-vs-Watch deviation report + +`biometrics.compareToWatch` (`GET /api/biometrics/watch-deviation`, MCP +`compare_vitals_to_watch`) scores what the pod stored against the legacy Watch +tables (`reference_vitals`, `reference_samples`, `reference_stages`) per sleep +record of at least `minHours` (default 3) in a window of at most 31 days +(default the last 14). Deviation is pod − Watch. Heart rate pairs each Watch +sample with the nearest pod reading within ±60 s, breathing rate within ±150 s, +and HRV compares Watch SDNN with the mean pod HRV within ±5 minutes; Watch +samples are taken inside the record and pod readings up to 5 minutes past its +edges. Each metric reports `n`, `watchSamples`, `coverage`, `mae`, `bias`, +nearest-rank `p90` absolute error and Pearson `r`, null without data. Stages +re-run the side's effective classifier on the stored vitals and movement and +score each 5-minute epoch against the Watch stage at its midpoint (accuracy, +kappa, recall, minutes per stage over the scored epochs). The summary pools +every pair and scored epoch of all nights rather than averaging nightly figures. +`configChanges` lists the side's profile generations whose numeric components +the processor acknowledged inside the window, at the latest acknowledged sample +time (`effective_sample_time_ms` in `biometric_runtime_receipts`). Settings → +Biometric calibration draws the nights as a trend with these as markers once +any night in the last 31 days pairs with Watch data. +HRV is not like-for-like: the pod stores a window IBI variability index, not +SDNN, which the response's `hrvNote` repeats. diff --git a/docs/biometrics-replay-benchmark.md b/docs/biometrics-replay-benchmark.md index d6684c9c8..de41d9c06 100644 --- a/docs/biometrics-replay-benchmark.md +++ b/docs/biometrics-replay-benchmark.md @@ -630,6 +630,17 @@ explicitly. Existing worker serialization, process limits, quotas and health checks remain enforced. This policy authorizes bounded replay on this measured pod only; it provides no accuracy designation or shadow qualification. +**Raised 2026-10-16.** Calibration datasets frozen from real Pod 5 nights include +sessions of 10–14 hours. Replay ran about 31× slower on the pod than on a laptop +(a 2-hour cohort took 124 CPU-seconds), so a 10.5-hour night needed roughly 2,100 +CPU-seconds and was killed at the 1,200-second limit on every attempt. With the +owner's approval the budget on this pod is now the schema maximum: 3,600 CPU-seconds +and a one-hour wall deadline per waveform child, and a four-hour whole-job deadline +(`{"jobWallMs":14400000,"waveformWallMs":3600000,"waveformCpuSeconds":3600}`). The +admission cutoffs above are unchanged, so health refusals still pause or interrupt +replay; workers still run one at a time at nice 19. The previous file is kept as +`/etc/sleepypod/biometrics-replay.env.bak-20261016`. + Deployment stores the two environment values in `/etc/sleepypod/biometrics-replay.env`, loaded by `/etc/systemd/system/sleepypod.service.d/biometrics-replay.conf`. A service restart diff --git a/docs/hardware/alarms.md b/docs/hardware/alarms.md index edd24970a..b49c4a852 100644 --- a/docs/hardware/alarms.md +++ b/docs/hardware/alarms.md @@ -55,6 +55,24 @@ inside a window opens it immediately; the record of an early fire lives in memory, so a restart between an early fire and the set time lets the alarm fire again at the set time. See `src/scheduler/wakeWindow.ts`. +## Snooze length and Snooze taper + +Each alarm sets how long a snooze lasts (`snoozeDuration`, 1–30 minutes, +default 5) and an optional **Snooze taper** (`snoozeTaper`: off, 30, 40, 50 +or 60%). With a taper, each later snooze of the same alarm is that much +shorter than the one before — 9 min at 30% gives 9, 6.3, 4.4, 3.1 min… — so +you ease out of bed instead of drifting back to sleep. A snooze never gets +shorter than 5 seconds (`MIN_SNOOZE_SECONDS`). + +The scheduled alarm carries both settings in its `AlarmConfig`, and +`snoozeManager` brings a snoozed alarm back with `snoozeCount` one higher; +`nextSnoozeSeconds` (`src/hardware/snoozeLength.ts`) turns those into the +next snooze's length. Tap gestures and the HomeKit snooze switch use it (an +alarm without these settings — one started from the API — snoozes 5 minutes +from a tap, 9 from HomeKit). `device.snoozeAlarm` still takes an explicit +duration. Tap gestures used to carry their own snooze length; migration +`0034_alarm_snooze_from_gestures` copied each side's onto its alarms. + ## Alarm temperature warm-up An alarm's temperature used to be a set point at the alarm time: the bed only @@ -255,7 +273,8 @@ re-armed by us, and a tap only snoozes if we know an alarm is vibrating. calls `markAlarmEnded`, and an alarm nobody stops ends when its duration runs out — so the flag can't linger for a later tap to "snooze". - A tap configured to snooze clears the alarm and snoozes it through - `snoozeManager` with the snoozed alarm's own settings (the gesture, API + `snoozeManager` with the snoozed alarm's own settings, for the alarm's + snooze length (see Snooze length and Snooze taper above; the gesture, API and HomeKit paths all share it). Any other tap during an alarm just records it ended, since the firmware already stopped it. - Taps come from two places (`src/hardware/gestureDispatch.ts`): the tap 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 7e199d92e..0306db60b 100644 --- a/docs/research/sleep-stage-calibration/README.md +++ b/docs/research/sleep-stage-calibration/README.md @@ -56,15 +56,22 @@ builds can use `--from-db` without an export: scripts/import-watch-health.py ~/Desktop/apple_health_export --pod --side left ``` -It posts `SleepAnalysis` segments to `POST /api/biometrics/reference-stages` -and `HeartRate` samples to `POST /api/biometrics/reference-vitals`, in batches -of 2000, for the last 30 days (`--days 0` for everything; `--dry-run` to count -only). Heart rate is first-write-wins on side + time + source, so re-running -on a newer export only adds what is new. Stages are stricter: the pod rejects +It posts `SleepAnalysis` segments to `POST /api/biometrics/reference-stages`, +`HeartRate` samples to `POST /api/biometrics/reference-vitals`, and +`RespiratoryRate` (`breathing_rate`, breaths/min) and +`HeartRateVariabilitySDNN` (`hrv_sdnn`, ms) samples to +`POST /api/biometrics/reference-samples`, in batches of 2000, for the last 30 +days (`--days 0` for everything; `--dry-run` to count only). Records the +sleepypod iOS app wrote back into Health (source `sleepypod`, `sleepypod +design`) are the pod's own readings and are always skipped, whatever +`--source` says. Heart rate, breathing rate and HRV are first-write-wins on +side + time + source (+ metric), so re-running on a newer export only adds +what is new. Stages are stricter: the pod rejects a batch whose segment overlaps a stored one with different boundaries (say a night the iOS app already posted); the script skips that batch with a warning, -still imports heart rate, and exits 1. Watch heart rate is kept -in its own `reference_vitals` table and never mixed into the pod's `vitals`, +still imports the vitals, and exits 1. Watch heart rate is kept in its own +`reference_vitals` table, breathing rate and HRV in `reference_samples`, never +mixed into the pod's `vitals`, which feed occupancy, sleep staging and the chart baselines. ## 3. Build the dataset @@ -142,6 +149,120 @@ 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 κ as an unselected candidate, then print its preview. About five seconds on Apple silicon; nothing runs on the +phone. + +```sh +scripts/train-personal-models.py --pod --side left # contract B, candidate stored and previewed +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`. Candidates accumulate in the model library; the +current selection and activation stay unchanged. `--select` explicitly selects the +candidate inactive (turning personal staging off); `--activate` selects it active, +after preview succeeds. The two flags are mutually exclusive; neither can be +combined with `--no-upload`. Preview +scores on training nights are in-sample, not held-out validation. + +### 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 a candidate without changing the selection +curl -X POST http://:3000/api/sleep-stages/models -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 "$(jq -c '{side:"left", nights:14, model:.}' out/personal-forest.json)" +``` + +The legacy singular `PUT /api/sleep-stages/model` replaces the selected model in +place and preserves its active state, so it immediately changes staging on an +active side. It archives both the previous selection and the uploaded model in +the library. To select a library candidate explicitly, use +`PUT /api/sleep-stages/models/selected` with `{side, id, active}`. + +The selected 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 @@ -152,3 +273,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..5af7c6a85 --- /dev/null +++ b/docs/research/sleep-stage-calibration/personal-models.py @@ -0,0 +1,399 @@ +#!/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 = [] + interrupted = False + 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) + interrupted = True + continue + if not interrupted and 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']]) + interrupted = False + 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/biometrics-archiver/sleepypod-biometrics-archiver b/modules/biometrics-archiver/sleepypod-biometrics-archiver index fcba0b94c..6e51978be 100755 --- a/modules/biometrics-archiver/sleepypod-biometrics-archiver +++ b/modules/biometrics-archiver/sleepypod-biometrics-archiver @@ -17,19 +17,30 @@ # file + rename. set -euo pipefail +# shellcheck source=modules/biometrics-archiver/sleepypod-recording-policy +source "$(dirname "$0")/sleepypod-recording-policy" + TMPFS_DIR="${TMPFS_DIR:-/persistent/biometrics}" ARCHIVE_DIR="${ARCHIVE_DIR:-/persistent/biometrics-archive}" PENDING_DIR="${PENDING_DIR:-$TMPFS_DIR/.pending}" KEEP_RECENT_MIN="${KEEP_RECENT_MIN:-15}" GZIP_LEVEL="${GZIP_LEVEL:-6}" -mkdir -p "$ARCHIVE_DIR" - if ! mountpoint -q "$TMPFS_DIR"; then echo "archiver: $TMPFS_DIR is not a tmpfs mount — skipping" >&2 exit 0 fi +if ! raw_archive_enabled; then + # Older firmware leaves completed frames behind. Continue the normal RAM + # cleanup without writing gzip files, or opting out could fill tmpfs and + # interrupt live sensing. Keep the same age threshold as the archive pass. + find "$TMPFS_DIR" -maxdepth 1 -type f -name '*.RAW' ! -name 'SEQNO.RAW' -mmin "+$KEEP_RECENT_MIN" -delete + exit 0 +fi + +mkdir -p "$ARCHIVE_DIR" + archived=0 removed=0 failed=0 @@ -38,6 +49,7 @@ failed=0 # the source is only that extra name — the firmware's own name, if it still # holds one, is untouched. archive_one() { + raw_archive_enabled || return 0 local src=$1 base dst tmp base=$(basename "$src") dst="$ARCHIVE_DIR/$base.gz" diff --git a/modules/biometrics-archiver/sleepypod-biometrics-linker b/modules/biometrics-archiver/sleepypod-biometrics-linker index 3dbed86e9..699f353f2 100755 --- a/modules/biometrics-archiver/sleepypod-biometrics-linker +++ b/modules/biometrics-archiver/sleepypod-biometrics-linker @@ -20,6 +20,9 @@ # Runs every minute: link + stat on tmpfs only, no eMMC writes. set -euo pipefail +# shellcheck source=modules/biometrics-archiver/sleepypod-recording-policy +source "$(dirname "$0")/sleepypod-recording-policy" + TMPFS_DIR="${TMPFS_DIR:-/persistent/biometrics}" PENDING_DIR="${PENDING_DIR:-$TMPFS_DIR/.pending}" # Backstop for a stuck archiver (eMMC full, gzip failing): pinned frames must @@ -38,6 +41,14 @@ if ! mountpoint -q "$TMPFS_DIR"; then exit 0 fi +if ! raw_archive_enabled; then + # Release only our RAM pins, never firmware filenames or saved archives. + if [ -d "$PENDING_DIR" ]; then + find "$PENDING_DIR" -maxdepth 1 -type f -name '*.RAW' -delete + fi + exit 0 +fi + mkdir -p "$PENDING_DIR" linked=0 diff --git a/modules/biometrics-archiver/sleepypod-recording-policy b/modules/biometrics-archiver/sleepypod-recording-policy new file mode 100644 index 000000000..fcc977701 --- /dev/null +++ b/modules/biometrics-archiver/sleepypod-recording-policy @@ -0,0 +1,27 @@ +#!/bin/bash +# Shared read-only policy check. Never source user-controlled settings as shell. +raw_archive_enabled() { + local file="${SP_RECORDING_POLICY_FILE:-}" data_dir="/persistent/sleepypod-data" + if [ -z "$file" ]; then + if [ -n "${DATABASE_URL:-}" ]; then + data_dir=$(dirname "${DATABASE_URL#file:}") + elif [ -r /etc/sleepypod/data-dir ]; then + read -r data_dir < /etc/sleepypod/data-dir + fi + file="$data_dir/recording-policy.json" + fi + [ -e "$file" ] || return 0 + # Python is installed with the bundled sensor modules. Parse JSON rather + # than sourcing shell or matching a field inside potentially corrupt text. + python3 - "$file" <<'PYTHON' +import json +import sys +try: + with open(sys.argv[1]) as source: + policy = json.load(source) + enabled = isinstance(policy, dict) and policy.get("rawArchive", True) is True +except (OSError, ValueError): + enabled = False +sys.exit(0 if enabled else 1) +PYTHON +} diff --git a/modules/calibrator/main.py b/modules/calibrator/main.py index 4a97f780f..aa9bc7533 100644 --- a/modules/calibrator/main.py +++ b/modules/calibrator/main.py @@ -71,6 +71,8 @@ format="%(asctime)s [calibrator] %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) +from common.recording_policy import install_logging_policy +install_logging_policy() log = logging.getLogger(__name__) # --------------------------------------------------------------------------- diff --git a/modules/common/biometrics/manifest.v1.json b/modules/common/biometrics/manifest.v1.json index fdef253cd..6eb0ed7c0 100644 --- a/modules/common/biometrics/manifest.v1.json +++ b/modules/common/biometrics/manifest.v1.json @@ -1,6 +1,6 @@ { "id": "sleepypod-biometric-bundle", - "version": "1.0.0", + "version": "1.1.0", "schemaVersion": 1, "canonicalization": "finite-binary64-json-v1", "sampleRateHz": 500, @@ -30,11 +30,20 @@ "default": 5, "unit": "windows", "description": "Number of accepted heart-rate estimates retained by the tracker." + }, + "minimumPeakScore": { + "type": "number", + "minimum": 0.1, + "maximum": 0.8, + "default": 0.4, + "unit": "score", + "description": "Minimum subharmonic-summation autocorrelation score for a heart-rate estimate. Weaker windows report no heart rate instead of a guess; raising it trades coverage for accuracy." } }, "required": [ "trackingToleranceBpm", - "trackingHistoryLength" + "trackingHistoryLength", + "minimumPeakScore" ], "additionalProperties": false }, @@ -375,7 +384,8 @@ "defaults": { "heart_rate": { "trackingToleranceBpm": 15, - "trackingHistoryLength": 5 + "trackingHistoryLength": 5, + "minimumPeakScore": 0.4 }, "breathing_rate": { "intervalLowerRatio": 0.5, @@ -624,20 +634,20 @@ } }, "sourceHashes": { - "modules/common/biometrics/signal.py": "fff73469815673c8daebba9e2c9e7bcf97708939ad7a5639a52f0a53c9d75c18", - "modules/piezo-processor/main.py": "75db2ea2f005fd537d994b306332c1abf97c0c28abb496a8d3af03816ccf8550", + "modules/common/biometrics/signal.py": "0b1447c1162245232c053aeeb5cb3e75e7da3392c38a43c465f28596f084b32f", + "modules/piezo-processor/main.py": "11841a735be9ac73642edd32af8e7b1981ed944e4e65778ed95e06460284b4ba", "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" }, "dependencyHashes": { "modules/piezo-processor/uv.lock": "2803397e553a32588071d6be84c2bce5326552da03076261f48afce0ba919869", - "pnpm-lock.yaml": "bf4c8635e3f34acd1637bdc33fab0c1a9ee1287dcad827d60c8b9b4027118a41" + "pnpm-lock.yaml": "c4a4ad5306768919942ee174cbf6bd221480d4aaaf7c5f48dba52b05cf62a578" }, "legacySourceHashes": { "modules/piezo-processor/main.py": "c98bdb3d6ee38b5de5d36760238978a8fefe2153a0cdf3e82ea1872d2d0da34f", 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/common/biometrics/raw_records.py b/modules/common/biometrics/raw_records.py index 706eaa2b9..e385ea0c3 100644 --- a/modules/common/biometrics/raw_records.py +++ b/modules/common/biometrics/raw_records.py @@ -6,6 +6,7 @@ tags and non-text map keys are outside this versioned firmware-record contract. """ from dataclasses import dataclass +import heapq import struct from common.cbor_raw import read_raw_record @@ -118,15 +119,70 @@ def value(self, depth=0): def decode_record(payload, limits=DecodeLimits()): + records = decode_records(payload, limits) + if len(records) != 1: + raise ValueError('Trailing bytes after CBOR record') + return records[0] + + +def decode_records(payload, limits=DecodeLimits()): + """Decode one RAW frame. Pod 5 firmware packs several `log` maps into a + single frame back to back, so a frame is a CBOR sequence; every item must + be a typed record and all items share one decode budget.""" if not isinstance(payload, bytes) or len(payload) > limits.record_bytes: raise ValueError('RAW inner record byte budget exceeded') decoder = _Decoder(payload, limits) - result = decoder.value() - if decoder.position != len(payload): - raise ValueError('Trailing bytes after CBOR record') - if not isinstance(result, dict) or not isinstance(result.get('type'), str): - raise ValueError('RAW record must have a textual type') - return result + records = [] + while True: + result = decoder.value() + if not isinstance(result, dict) or not isinstance(result.get('type'), str): + raise ValueError('RAW record must have a textual type') + records.append(result) + if decoder.position == len(payload): + return records + + +EVENT_TYPES = ('piezo-dual', 'frzHealth') +REORDER_WINDOW_MS = 5000 + + +class EventOrder: + """Release piezo and pump events in timestamp order. + + Each record type must be monotonic on its own, but Pod 5 stamps a + frzHealth record up to a second ahead of the piezo frame written after + it. Events are held until one at least `window_ms` newer arrives, which + merges the streams. A step back within a stream, or an event older than + one already released, is a real reversal: strict callers raise on it, + counting callers drop it and report `reversals`. + """ + def __init__(self, strict=True, window_ms=REORDER_WINDOW_MS): + self.strict, self.window_ms = strict, window_ms + self.released, self.reversals = None, 0 + self._held, self._sequence, self._latest = [], 0, {} + + def push(self, timestamp_ms, item, kind): + latest = self._latest.get(kind) + if (latest is not None and timestamp_ms < latest) or (self.released is not None and timestamp_ms < self.released): + if self.strict: + raise ValueError('Retained event timestamps reversed') + self.reversals += 1 + return [] + self._latest[kind] = timestamp_ms + heapq.heappush(self._held, (timestamp_ms, self._sequence, item)) + self._sequence += 1 + return self._release(timestamp_ms - self.window_ms) + + def flush(self): + return self._release(None) + + def _release(self, through): + ready = [] + while self._held and (through is None or self._held[0][0] <= through): + timestamp_ms, _, item = heapq.heappop(self._held) + self.released = timestamp_ms + ready.append((timestamp_ms, item)) + return ready class RawRecordBudget: @@ -164,7 +220,7 @@ def iter_retained_records(store, descriptor, limits=DecodeLimits(), max_records= if count > max_records: raise ValueError('RAW record count limit exceeded') if payload is not None: - yield decode_record(payload, limits) + yield from decode_records(payload, limits) def inventory_retained(store, descriptor, limits=DecodeLimits(), max_records=1_000_000): @@ -183,11 +239,18 @@ def inventory_retained(store, descriptor, limits=DecodeLimits(), max_records=1_0 'first_event_ms': None, 'last_event_ms': None, 'channels': {side: {'samples': 0, 'lost_markers': 0, 'truncated_tail_bytes': 0} for side in ('left1', 'right1')}} - previous = None + order, previous = EventOrder(strict=False), None + + def count_order(ready): + nonlocal previous + for timestamp_ms, _ in ready: + result['duplicate_timestamps'] += int(timestamp_ms == previous) + previous = timestamp_ms + for record in iter_retained_records(store, descriptor, limits, max_records): result['decoded_records'] += 1 kind = record['type'] - if kind not in ('piezo-dual', 'frzHealth'): + if kind not in EVENT_TYPES: continue timestamp = record.get('ts') if (type(timestamp) not in (int, float) or not math.isfinite(timestamp) @@ -195,10 +258,7 @@ def inventory_retained(store, descriptor, limits=DecodeLimits(), max_records=1_0 result['invalid_timestamps'] += 1 else: timestamp_ms = math.floor(timestamp * 1000 + 0.5) - if previous is not None: - result['timestamp_reversals'] += int(timestamp_ms < previous) - result['duplicate_timestamps'] += int(timestamp_ms == previous) - previous = timestamp_ms + count_order(order.push(timestamp_ms, None, kind)) result['first_event_ms'] = timestamp_ms if result['first_event_ms'] is None else min(result['first_event_ms'], timestamp_ms) result['last_event_ms'] = timestamp_ms if result['last_event_ms'] is None else max(result['last_event_ms'], timestamp_ms) if kind == 'frzHealth': @@ -225,6 +285,8 @@ def inventory_retained(store, descriptor, limits=DecodeLimits(), max_records=1_0 result['usable_dual_frames'] += int(usable) result['empty_channel_frames'] += int(not usable and not oversized) result['oversize_channel_frames'] += int(oversized) + count_order(order.flush()) + result['timestamp_reversals'] = order.reversals result['ordered_waveform_available'] = (result['usable_dual_frames'] > 0 and result['invalid_timestamps'] == 0 and result['timestamp_reversals'] == 0 and result['oversize_channel_frames'] == 0) diff --git a/modules/common/biometrics/retained_replay.py b/modules/common/biometrics/retained_replay.py index 1286d3fab..d7a265f1b 100644 --- a/modules/common/biometrics/retained_replay.py +++ b/modules/common/biometrics/retained_replay.py @@ -8,7 +8,7 @@ import math from .raw_artifacts import RawArtifact -from .raw_records import RawRecordBudget, iter_retained_records +from .raw_records import EVENT_TYPES, EventOrder, RawRecordBudget, iter_retained_records from .replay import RecordedContext, WaveformReplay MAX_SAFE_INTEGER = 9_007_199_254_740_991 @@ -74,7 +74,9 @@ def parse(cls, raw): first, last = _integer(item['first_event_ms']), _integer(item['last_event_ms']) if first > last or last < start or first >= end: raise ValueError('Artifact has no events in the retained window') - if artifact.sha256 in hashes or (artifacts and first <= artifacts[-1].last_event_ms): + # Bounds may touch: Pod 5 can end one archive with a pump record + # stamped at the next archive's first frame. + if artifact.sha256 in hashes or (artifacts and first < artifacts[-1].last_event_ms): raise ValueError('Retained artifacts must be distinct and have nonoverlapping ordered event bounds') hashes.add(artifact.sha256) compressed += artifact.compressed_bytes @@ -125,7 +127,19 @@ def write(row): score_start_ms=snapshot.score_start_ms, score_end_ms=snapshot.score_end_ms, sink=write) budget = RawRecordBudget(maximum_records) index, decoded = 0, 0 - previous = None + order = EventOrder() + + def feed(ready): + nonlocal index + for timestamp, record in ready: + if not snapshot.retained_start_ms <= timestamp < snapshot.score_end_ms: + continue + while index < len(snapshot.contexts) and timestamp >= snapshot.contexts[index].end_ms: + index += 1 + interval = snapshot.contexts[index] if index < len(snapshot.contexts) else None + context = interval.context if interval and timestamp >= interval.start_ms else RecordedContext() + replay.feed(timestamp, record, context) + for source in snapshot.artifacts: if cancelled(): raise ValueError('Retained replay cancelled') @@ -136,25 +150,18 @@ def write(row): decoded += 1 if decoded > maximum_records: raise ValueError('Retained replay decoded record budget exceeded') - if record['type'] not in ('piezo-dual', 'frzHealth'): + if record['type'] not in EVENT_TYPES: continue timestamp = _timestamp(record) - if previous is not None and timestamp < previous: - raise ValueError('Retained event timestamps reversed') - previous = timestamp - first = timestamp if first is None else first - last = timestamp + # Bounds are the inspected min/max; the streams interleave within a file. + first = timestamp if first is None else min(first, timestamp) + last = timestamp if last is None else max(last, timestamp) if not source.first_event_ms <= timestamp <= source.last_event_ms: raise ValueError('Retained event lies outside inspected bounds') - if not snapshot.retained_start_ms <= timestamp < snapshot.score_end_ms: - continue - while index < len(snapshot.contexts) and timestamp >= snapshot.contexts[index].end_ms: - index += 1 - interval = snapshot.contexts[index] if index < len(snapshot.contexts) else None - context = interval.context if interval and timestamp >= interval.start_ms else RecordedContext() - replay.feed(timestamp, record, context) + feed(order.push(timestamp, record, record['type'])) if first != source.first_event_ms or last != source.last_event_ms: raise ValueError('Retained event bounds differ from inspected source') + feed(order.flush()) if cancelled(): raise ValueError('Retained replay cancelled') replay.close() diff --git a/modules/common/biometrics/signal.py b/modules/common/biometrics/signal.py index dface3b08..58e103feb 100644 --- a/modules/common/biometrics/signal.py +++ b/modules/common/biometrics/signal.py @@ -27,6 +27,7 @@ class ProcessingParameters: breathing_interval_upper_ratio: float hrv_harmonic_tolerance: float hrv_minimum_windows: int + heart_rate_minimum_peak_score: float def __post_init__(self): resolve_config([ @@ -36,6 +37,7 @@ def __post_init__(self): {"path": "breathing_rate.intervalUpperRatio", "value": self.breathing_interval_upper_ratio}, {"path": "hrv_index.harmonicTolerance", "value": self.hrv_harmonic_tolerance}, {"path": "hrv_index.minimumWindows", "value": self.hrv_minimum_windows}, + {"path": "heart_rate.minimumPeakScore", "value": self.heart_rate_minimum_peak_score}, ]) @classmethod @@ -46,7 +48,8 @@ def from_config(cls, config): config["breathing_rate"]["intervalLowerRatio"], config["breathing_rate"]["intervalUpperRatio"], config["hrv_index"]["harmonicTolerance"], - config["hrv_index"]["minimumWindows"]) + config["hrv_index"]["minimumWindows"], + config["heart_rate"]["minimumPeakScore"]) DEFAULT_PARAMETERS = ProcessingParameters.from_config(manifest()["defaults"]) @@ -111,7 +114,8 @@ def _compute_autocorr(filtered: np.ndarray, fs: float) -> Optional[np.ndarray]: def subharmonic_summation_hr(samples: np.ndarray, fs: float = SAMPLE_RATE, bpm_range: tuple = (45, 120), - n_harmonics: int = 3): + n_harmonics: int = 3, + min_score: float = DEFAULT_PARAMETERS.heart_rate_minimum_peak_score): """Subharmonic summation for robust fundamental HR detection. Only scores autocorrelation PEAKS (not every lag) to avoid edge effects @@ -120,7 +124,10 @@ def subharmonic_summation_hr(samples: np.ndarray, fs: float = SAMPLE_RATE, Hermes 1988 (JASA), adapted for BCG by Bruser et al. 2011. - Returns (heart_rate_bpm, score) or (None, 0.0) on failure. + Returns (heart_rate_bpm, score) or (None, 0.0) on failure. A best score + below min_score also returns (None, score): on a weak heartbeat the tallest + autocorrelation peak is usually not the heart (the true rate is often not a + candidate at all), so reporting nothing beats reporting a confident guess. """ filtered = _bandpass(samples, HR_BAND[0], HR_BAND[1], fs) acr = _compute_autocorr(filtered, fs) @@ -161,7 +168,7 @@ def subharmonic_summation_hr(samples: np.ndarray, fs: float = SAMPLE_RATE, best_score = score best_lag = lag - if best_lag is None or best_score < 0.1: + if best_lag is None or best_score < min_score: return None, best_score hr = 60.0 * fs / best_lag @@ -483,7 +490,7 @@ def estimate(self, timestamp_s: float, hr_samples: np.ndarray, # Match live warmup exactly; never pad unavailable history with zeroes. if len(hr_samples) < int(10 * fs): return WindowEstimate(timestamp_s, None, None, None, None, 0.0, ("hr_warmup",)) - hr_raw, hr_score = subharmonic_summation_hr(hr_samples, fs) + hr_raw, hr_score = subharmonic_summation_hr(hr_samples, fs, min_score=self.parameters.heart_rate_minimum_peak_score) hr = self.hr_tracker.update(hr_raw, hr_score) br = compute_breathing_rate(breathing_samples, fs, self.parameters) hrv = (compute_hrv(hrv_samples, fs, self.parameters) diff --git a/modules/common/dialect.py b/modules/common/dialect.py index deb0ab85e..78e7c2695 100644 --- a/modules/common/dialect.py +++ b/modules/common/dialect.py @@ -47,6 +47,8 @@ "capSense", "capSense2", "piezo-dual", "bedTemp", "bedTemp2", "frzTemp", "frzHealth", "frzTherm", + "sensHealth", "frzWater", "lps", + "tap-gesture", "alarm-info", }) # Track unknown record types seen so we only log each once per process. diff --git a/modules/common/recording_policy.py b/modules/common/recording_policy.py new file mode 100644 index 000000000..2fe6d8bc3 --- /dev/null +++ b/modules/common/recording_policy.py @@ -0,0 +1,51 @@ +"""Shared logging preferences. Reload at most once a second; never write on reads.""" +import json +import logging +import os +import time +from pathlib import Path + +_cache = None +_cache_path = None +_read_at = -float("inf") + + +def logging_mode(): + global _cache, _cache_path, _read_at + now = time.monotonic() + database = os.environ.get("DATABASE_URL", "file:/persistent/sleepypod-data/sleepypod.db").removeprefix("file:") + path = os.environ.get("SP_RECORDING_POLICY_FILE", str(Path(database).parent / "recording-policy.json")) + if path != _cache_path or now - _read_at >= 1: + try: + policy = json.loads(Path(path).read_text()) + if not isinstance(policy, dict) or policy.get("loggingMode", "quiet") not in ("quiet", "off"): + raise ValueError("Invalid logging mode") + expiry = policy.get("debugUntil") + if expiry is not None and (type(expiry) not in (int, float) or expiry < 0): + raise ValueError("Invalid debug expiry") + _cache = policy + except FileNotFoundError: + _cache = {} + except (OSError, ValueError, TypeError): + # If preferences are damaged, keep warning/error output available. + _cache = {} + _cache_path, _read_at = path, now + until = _cache.get("debugUntil") + if until is not None and until > time.time() * 1000: + return "debug" + return _cache.get("loggingMode", "quiet") + + +class RecordingFilter(logging.Filter): + def filter(self, record): + mode = logging_mode() + return mode == "debug" or (mode == "quiet" and record.levelno >= logging.WARNING) + + +def install_logging_policy(): + """Install after basicConfig; discard records before handler formatting/I/O.""" + root = logging.getLogger() + root.setLevel(logging.DEBUG) + for handler in root.handlers: + if not any(isinstance(f, RecordingFilter) for f in handler.filters): + handler.addFilter(RecordingFilter()) diff --git a/modules/common/test_dialect.py b/modules/common/test_dialect.py index 3a170e910..3b96c1d74 100644 --- a/modules/common/test_dialect.py +++ b/modules/common/test_dialect.py @@ -130,7 +130,9 @@ def test_is_bed_temp_record(self): class TestKnownRecordTypes: def test_covers_all_parsed_dialects(self): for t in ("capSense", "capSense2", "piezo-dual", "bedTemp", "bedTemp2", - "frzTemp", "frzHealth", "frzTherm"): + "frzTemp", "frzHealth", "frzTherm", + "sensHealth", "frzWater", "lps", + "tap-gesture", "alarm-info"): assert t in KNOWN_RECORD_TYPES def test_excludes_new_unconsumed_types(self): diff --git a/modules/common/test_raw_records.py b/modules/common/test_raw_records.py index 48b7e1c39..0eb95f0bf 100644 --- a/modules/common/test_raw_records.py +++ b/modules/common/test_raw_records.py @@ -162,3 +162,51 @@ def test_shared_budget_counts_empty_placeholders_across_artifact_streams(tmp_pat assert budget.consumed == 4 with pytest.raises(ValueError, match='shared RAW'): list(iter_retained_records(store, descriptor, budget=3)) + + +def test_streams_every_map_of_a_pod5_packed_frame(tmp_path): + # Pod 5 firmware packs consecutive `log` maps into one frame. + first, second = cbor2.dumps({'type': 'log', 'msg': 'a'}), cbor2.dumps({'type': 'log', 'msg': 'b'}) + store, descriptor = retain(tmp_path, envelope(first + second) + envelope(b'\xa1\x64type\x61x')) + assert list(iter_retained_records(store, descriptor)) == [{'type': 'log', 'msg': 'a'}, {'type': 'log', 'msg': 'b'}, {'type': 'x'}] + with pytest.raises(ValueError, match='Trailing bytes'): + decode_record(first + second) + + +def test_rejects_untyped_or_truncated_items_after_the_first_map(): + first = cbor2.dumps({'type': 'log'}) + with pytest.raises(ValueError, match='textual type'): + raw_records.decode_records(first + cbor2.dumps([1])) + with pytest.raises(ValueError): + raw_records.decode_records(first + b'\xa1') + + +def pump_record(timestamp): + return b'\xa2\x64type\x69frzHealth\x62ts\x1a' + timestamp.to_bytes(4, 'big') + + +def test_inventory_merges_pod5_pump_records_stamped_ahead_of_piezo_frames(tmp_path): + # Pod 5 writes frzHealth stamped one second ahead of the next piezo frame. + frames = [piezo_record(100), pump_record(102), piezo_record(101), piezo_record(102), pump_record(104), piezo_record(103)] + store, descriptor = retain(tmp_path, b''.join(envelope(frame) for frame in frames)) + result = raw_records.inventory_retained(store, descriptor) + assert result['timestamp_reversals'] == 0 + assert result['duplicate_timestamps'] == 1 + assert result['ordered_waveform_available'] is True + assert (result['first_event_ms'], result['last_event_ms']) == (100000, 104000) + + +def test_event_order_releases_merged_streams_and_rejects_reversals(): + order = raw_records.EventOrder(window_ms=2000) + released = order.push(10_000, 'a', 'piezo') + order.push(11_000, 'p', 'pump') + order.push(10_500, 'b', 'piezo') + released += order.push(13_000, 'c', 'piezo') + order.flush() + assert released == [(10_000, 'a'), (10_500, 'b'), (11_000, 'p'), (13_000, 'c')] + with pytest.raises(ValueError, match='reversed'): + order.push(12_000, 'late', 'pump') + strict = raw_records.EventOrder() + strict.push(5, 'x', 'piezo') + with pytest.raises(ValueError, match='reversed'): + strict.push(4, 'y', 'piezo') + counting = raw_records.EventOrder(strict=False) + counting.push(5, 'x', 'piezo') + assert counting.push(4, 'y', 'piezo') == [] and counting.reversals == 1 diff --git a/modules/common/test_recording_policy.py b/modules/common/test_recording_policy.py new file mode 100644 index 000000000..c03b1e995 --- /dev/null +++ b/modules/common/test_recording_policy.py @@ -0,0 +1,58 @@ +import json +import logging +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from common import recording_policy as policy + + +class RecordingPolicyTest(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.path = Path(self.tmp.name) / 'recording-policy.json' + self.env = patch.dict('os.environ', {'SP_RECORDING_POLICY_FILE': str(self.path)}) + self.env.start() + self.addCleanup(self.env.stop) + policy._cache_path = None + + def write(self, **settings): + self.path.write_text(json.dumps(settings)) + policy._cache_path = None + + def test_quiet_by_default_without_creating_file(self): + self.assertEqual(policy.logging_mode(), 'quiet') + self.assertFalse(self.path.exists()) + + def test_expiry_restores_off_without_writing(self): + self.write(loggingMode='off', debugUntil=2000) + with patch('time.time', return_value=1): + self.assertEqual(policy.logging_mode(), 'debug') + with patch('time.time', return_value=2): + self.assertEqual(policy.logging_mode(), 'off') + self.assertEqual(json.loads(self.path.read_text())['debugUntil'], 2000) + + def test_filter_discards_off_and_quiet_info_but_keeps_quiet_errors(self): + filt = policy.RecordingFilter() + info = logging.LogRecord('test', logging.INFO, '', 0, 'info', (), None) + error = logging.LogRecord('test', logging.ERROR, '', 0, 'error', (), None) + self.write(loggingMode='quiet') + self.assertFalse(filt.filter(info)) + self.assertTrue(filt.filter(error)) + self.write(loggingMode='off') + self.assertFalse(filt.filter(error)) + + def test_corrupt_policy_falls_back_to_warnings(self): + self.path.write_text('{broken') + self.assertEqual(policy.logging_mode(), 'quiet') + + def test_reload_after_one_second(self): + with patch('time.monotonic', return_value=0): + self.assertEqual(policy.logging_mode(), 'quiet') + self.path.write_text(json.dumps({'loggingMode': 'off'})) + with patch('time.monotonic', return_value=0.5): + self.assertEqual(policy.logging_mode(), 'quiet') + with patch('time.monotonic', return_value=1): + self.assertEqual(policy.logging_mode(), 'off') diff --git a/modules/cover-buttons/main.py b/modules/cover-buttons/main.py index 11ac7f772..44a70df0d 100644 --- a/modules/cover-buttons/main.py +++ b/modules/cover-buttons/main.py @@ -47,6 +47,8 @@ format="%(asctime)s [cover-buttons] %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) +from common.recording_policy import install_logging_policy +install_logging_policy() log = logging.getLogger(__name__) # --------------------------------------------------------------------------- diff --git a/modules/cover-buttons/test_main.py b/modules/cover-buttons/test_main.py index a8e6f8f6e..c4ca20ed7 100644 --- a/modules/cover-buttons/test_main.py +++ b/modules/cover-buttons/test_main.py @@ -5,6 +5,7 @@ """ import sys +from pathlib import Path _stubs = { "cbor2": type(sys)("cbor2"), @@ -12,6 +13,7 @@ "common.raw_follower": type(sys)("common.raw_follower"), } _stubs["common.raw_follower"].RawFileFollower = None +_stubs["common"].__path__ = [str(Path(__file__).resolve().parent.parent / "common")] sys.modules.update(_stubs) from main import iter_presses # noqa: E402 diff --git a/modules/environment-monitor/main.py b/modules/environment-monitor/main.py index c61b9d198..f6c8f1854 100644 --- a/modules/environment-monitor/main.py +++ b/modules/environment-monitor/main.py @@ -123,6 +123,8 @@ def _safe_freezer_centidegrees(val) -> Optional[int]: format="%(asctime)s [environment-monitor] %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) +from common.recording_policy import install_logging_policy +install_logging_policy() log = logging.getLogger(__name__) # --------------------------------------------------------------------------- diff --git a/modules/environment-monitor/test_main.py b/modules/environment-monitor/test_main.py index 602c3a7ce..18168bca9 100644 --- a/modules/environment-monitor/test_main.py +++ b/modules/environment-monitor/test_main.py @@ -6,10 +6,12 @@ import sqlite3 import sys +from pathlib import Path # Stub pod-only imports so this test runs on a developer machine. _cbor2_stub = type(sys)("cbor2") _common_stub = type(sys)("common") +_common_stub.__path__ = [str(Path(__file__).resolve().parent.parent / "common")] _raw_follower_stub = type(sys)("common.raw_follower") _raw_follower_stub.RawFileFollower = None _nats_follower_stub = type(sys)("common.nats_follower") 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 7a55ada15..cacd80499 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 @@ -90,6 +91,8 @@ format="%(asctime)s [piezo-processor] %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) +from common.recording_policy import install_logging_policy +install_logging_policy() log = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -247,6 +250,188 @@ 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 +HEARTBEAT_RETRY_INTERVAL_S = 5.0 +# 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, + clock: Callable[[], float] = time.monotonic): + self._holder = holder + self._mode = mode + self._present = present + self._clock = clock + self._retry_after = 0.0 + 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 tick(self) -> None: + """Retry on the reader thread even when no piezo records arrive.""" + self._drain() + + def flush(self, *, shutdown: bool = False) -> None: + # Shutdown gets one final bounded attempt even inside the backoff. + # _drain still stops at the first failure; ingestion never bypasses it. + if shutdown: + self._retry_after = 0.0 + 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: + self._retry.append((side, chunk)) + + def _drain(self) -> None: + # One failed transaction per call, even when new minutes arrive during + # an outage. Back off from completion (busy_timeout may itself take 5s). + if self._clock() >= self._retry_after: + while self._retry: + side, chunk = self._retry[0] + if not write_heartbeats(self._holder, side, chunk): + self._retry_after = self._clock() + HEARTBEAT_RETRY_INTERVAL_S + break + self._retry.pop(0) + while 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: + 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, c in sorted(((side, c) for side, cs in chunks.items() for c in cs), + key=lambda item: item[1].start): + self._store_from(side, side, c) + self._drain() + return + for side, cs in chunks.items(): + for c in cs: + self._pending.setdefault(c.start, (home, {}))[1][side] = c + self._release(force) + self._drain() + + 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: @@ -336,14 +521,29 @@ 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) report_health("healthy", "piezo-processor v2 started") + def poll() -> None: + vitals_router.tick() + beat_front.tick() + try: - for record in follower.read_records(on_poll=vitals_router.tick): + for record in follower.read_records(on_poll=poll): rtype = record.get("type") # Surface genuinely-new firmware types once (blanketReadings, log, @@ -370,10 +570,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) @@ -390,6 +600,7 @@ def main() -> None: report_health("down", str(e)) sys.exit(1) finally: + beat_front.flush(shutdown=True) 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..70d1db44b 100644 --- a/modules/piezo-processor/test_main.py +++ b/modules/piezo-processor/test_main.py @@ -2007,3 +2007,423 @@ 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) + now = [0.0] + front = main.BeatFront(holder, _Mode(None), clock=lambda: now[0]) + 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))") + now[0] += main.HEARTBEAT_RETRY_INTERVAL_S + 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 == [] + + + +class TestHeartbeatRetryBackoff: + @pytest.mark.parametrize('message', ['database is locked', 'disk I/O error']) + def test_outage_backoff_preserves_order_and_limits_reconnects(self, monkeypatch, message): + import sqlite3 + import main + from beats import Chunk + from unittest.mock import MagicMock + now = [100.0] + conn = MagicMock() + conn.execute.side_effect = sqlite3.OperationalError(message) + holder = main.DBHolder(conn) + reconnect = MagicMock() + monkeypatch.setattr(main, '_reconnect_db', reconnect) + front = main.BeatFront(holder, _Mode(None), clock=lambda: now[0]) + chunks = [Chunk(t, [0, 1000], .8) for t in range(60, 601, 60)] + front._write({'left': chunks}) + assert conn.execute.call_count == 1 + assert holder.write_failures == 1 + for _ in range(100): + front._write({}) + assert conn.execute.call_count == 1 + # Even forced shutdown flush must respect backoff. + front.flush() + assert conn.execute.call_count == 1 + for attempt in range(2, main._DB_RECONNECT_THRESHOLD + 1): + now[0] += main.HEARTBEAT_RETRY_INTERVAL_S + front._write({}) + assert conn.execute.call_count == attempt + assert reconnect.call_count == 1 + assert holder.write_failures == 0 + assert [c for _, c in front._retry] == chunks + conn.execute.side_effect = None + now[0] += main.HEARTBEAT_RETRY_INTERVAL_S + front._write({'right': [Chunk(660, [0], .7)]}) + successful = conn.execute.call_args_list[main._DB_RECONNECT_THRESHOLD:] + assert [call.args[1][1] for call in successful] == list(range(60, 661, 60)) + assert front._retry == [] + assert holder.write_failures == 0 + + def test_failure_after_success_stops_and_capacity_stays_bounded(self, monkeypatch): + import main + from beats import Chunk + from unittest.mock import Mock + conn, holder = _hb_db() + writer = Mock(side_effect=[True, False]) + monkeypatch.setattr(main, 'write_heartbeats', writer) + front = main.BeatFront(holder, _Mode(None), clock=lambda: 0) + front._write({'left': [Chunk(t, [0], .8) for t in range(35)]}) + assert writer.call_count == 2 + assert len(front._retry) == main.HEARTBEAT_RETRY_MAX + assert [c.start for _, c in front._retry] == list(range(5, 35)) + front._write({}) + assert writer.call_count == 2 + conn.close() + +class TestHeartbeatLifecycleRetries: + @pytest.mark.parametrize('recovers', [True, False]) + def test_shutdown_gets_one_final_attempt_inside_backoff(self, monkeypatch, recovers): + import main + from beats import Chunk + from unittest.mock import Mock + _, holder = _hb_db() + writer = Mock(return_value=False) + monkeypatch.setattr(main, 'write_heartbeats', writer) + front = main.BeatFront(holder, _Mode(None), clock=lambda: 0) + chunks = [Chunk(60, [0], .8), Chunk(120, [0], .8)] + front._write({'left': chunks}) + writer.return_value = recovers + front.flush(shutdown=True) + assert writer.call_count == (3 if recovers else 2) + assert [c for _, c in front._retry] == ([] if recovers else chunks) + assert [call.args[2].start for call in writer.call_args_list] == ([60, 60, 120] if recovers else [60, 60]) + holder.conn.close() + + def test_idle_poll_retries_only_after_deadline(self, monkeypatch): + import main + from beats import Chunk + from unittest.mock import Mock + _, holder = _hb_db() + writer = Mock(return_value=False) + monkeypatch.setattr(main, 'write_heartbeats', writer) + now = [0.0] + front = main.BeatFront(holder, _Mode(None), clock=lambda: now[0]) + front._write({'left': [Chunk(60, [0], .8)]}) + writer.return_value = True + now[0] = main.HEARTBEAT_RETRY_INTERVAL_S - .01 + front.tick() + assert writer.call_count == 1 + now[0] += .01 + front.tick() + assert writer.call_count == 2 + assert front._retry == [] + holder.conn.close() + + def test_main_wires_idle_poll_and_shutdown_flush(self, monkeypatch, tmp_path): + import main + from unittest.mock import MagicMock + monkeypatch.setattr(main, 'BIOMETRICS_DB', tmp_path / 'biometrics.db') + conn = MagicMock() + monkeypatch.setattr(main, 'open_biometrics_db', lambda: conn) + for name in ['PumpGate', 'FrzHealthPumpState', 'SideProcessor', 'SingleSleeperMode', + 'BedPresence', 'RuntimeCoordinator', 'report_health']: + monkeypatch.setattr(main, name, MagicMock()) + front, vitals = MagicMock(), MagicMock() + monkeypatch.setattr(main, 'BeatFront', MagicMock(return_value=front)) + monkeypatch.setattr(main, 'SingleSleeperVitals', MagicMock(return_value=vitals)) + follower = MagicMock() + def read_records(*, on_poll): + on_poll() + return iter(()) + follower.read_records.side_effect = read_records + monkeypatch.setattr(main, 'create_follower', MagicMock(return_value=follower)) + main.main() + front.tick.assert_called_once_with() + vitals.tick.assert_called_once_with() + front.flush.assert_called_once_with(shutdown=True) + vitals.flush.assert_called_once_with() + conn.close.assert_called_once_with() diff --git a/modules/piezo-processor/test_retained_replay.py b/modules/piezo-processor/test_retained_replay.py index 09feddcd7..b6f2bc5d0 100644 --- a/modules/piezo-processor/test_retained_replay.py +++ b/modules/piezo-processor/test_retained_replay.py @@ -44,7 +44,7 @@ def fixture(tmp_path, records=None, split_at=76): name = f'{index}.RAW.gz' (archive / name).write_bytes(gzip.compress(data, mtime=0)) artifact = store.retain(archive, name) - selected.append({'artifact': asdict(artifact), 'first_event_ms': group[0][0], 'last_event_ms': group[-1][0]}) + selected.append({'artifact': asdict(artifact), 'first_event_ms': min(t for t, _ in group), 'last_event_ms': max(t for t, _ in group)}) (archive / name).unlink() snapshot = {'version': 'retained-waveform-input-v1', 'retained_start_ms': START, 'score_start_ms': START + 60000, 'score_end_ms': START + 150000, 'artifacts': selected, @@ -95,7 +95,7 @@ def test_rejects_overlaps_duplicates_unknown_versions_and_bad_context_before_out store, snapshot, _ = fixture(tmp_path) bad_inputs = [] bad = deepcopy(snapshot) - bad['artifacts'][1]['first_event_ms'] = bad['artifacts'][0]['last_event_ms'] + bad['artifacts'][1]['first_event_ms'] = bad['artifacts'][0]['last_event_ms'] - 1 bad_inputs.append(bad) bad = deepcopy(snapshot) bad['artifacts'].append(bad['artifacts'][0]) @@ -152,3 +152,25 @@ def test_missing_artifact_never_falls_back_to_a_rolling_archive(tmp_path): (store.directory / snapshot['artifacts'][0]['artifact']['name']).unlink() with pytest.raises(FileNotFoundError): run(store, snapshot, lambda _: None) + + +def test_pod5_pump_records_stamped_ahead_replay_like_time_ordered_input(tmp_path): + # Pod 5 writes each frzHealth stamped a second ahead of the piezo frame after it. + pumps = [(START + offset, {'type': 'frzHealth', 'left': {'pumpRpm': 0}, 'right': {'pumpRpm': 0}}) for offset in range(1000, 150000, 10000)] + ordered = sorted([*frames(), *pumps], key=lambda item: item[0]) + written = [] + for timestamp, record in ordered: + if record['type'] == 'frzHealth' and written and written[-1][1]['type'] != 'frzHealth': + written.insert(len(written) - 1, (timestamp, record)) + else: + written.append((timestamp, record)) + assert written != ordered + outputs = {} + for name, records in (('ordered', ordered), ('written', written)): + folder = tmp_path / name + folder.mkdir() + store, snapshot, _ = fixture(folder, records, split_at=len(records) // 2) + rows = [] + run(store, snapshot, rows.append) + outputs[name] = [(row.side, row.timestamp_ms, row.candidate.heart_rate) for row in rows] + assert outputs['written'] == outputs['ordered'] and outputs['ordered'] diff --git a/modules/piezo-processor/test_shared_signal.py b/modules/piezo-processor/test_shared_signal.py index c5d05d8c9..6b42ea8fc 100644 --- a/modules/piezo-processor/test_shared_signal.py +++ b/modules/piezo-processor/test_shared_signal.py @@ -77,3 +77,26 @@ def test_explicit_event_time_and_warmup_never_consult_wall_clock(): estimator.estimate(20, data, data, data, fs=250) with pytest.raises(ValueError, match="finite"): estimator.estimate(float("nan"), data, data, data) + + +def test_minimum_peak_score_abstains_on_weak_windows_only(): + # A heartbeat buried in noise still yields a tallest peak, just a weak one. + weak = samples()[-15000:] + np.random.default_rng(7).normal(0, 2.5, 15000) + hr, score = core.subharmonic_summation_hr(weak) + assert hr is not None and 0.1 < score < 0.8 + assert core.subharmonic_summation_hr(weak, min_score=score - 1e-6)[0] == pytest.approx(hr) + assert core.subharmonic_summation_hr(weak, min_score=score + 1e-6) == (None, pytest.approx(score)) + strict = core.WindowEstimator(parameters("heart_rate.minimumPeakScore", score + 1e-6)) + estimate = strict.estimate(1700000000, weak, samples()[-30000:], samples()) + assert estimate.heart_rate is None and "no_hr" in estimate.rejection_reasons + # 0.1 is the previous fixed floor; the shipped default is now 0.4. + legacy = core.WindowEstimator(parameters("heart_rate.minimumPeakScore", 0.1)) + assert legacy.estimate(1700000000, weak, samples()[-30000:], samples()).heart_rate == pytest.approx(hr) + assert core.DEFAULT_PARAMETERS.heart_rate_minimum_peak_score == 0.4 + assert core.subharmonic_summation_hr(weak)[0] == (hr if score >= 0.4 else None) + + +@pytest.mark.parametrize("value", [0.05, 0.85]) +def test_minimum_peak_score_range_is_validated(value): + with pytest.raises(ValueError): + parameters("heart_rate.minimumPeakScore", value) diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index 624ccf952..636f675e0 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -212,6 +212,8 @@ format="%(asctime)s [sleep-detector] %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) +from common.recording_policy import install_logging_policy +install_logging_policy() log = logging.getLogger(__name__) # --------------------------------------------------------------------------- diff --git a/modules/sleep-detector/test_main.py b/modules/sleep-detector/test_main.py index 99c664a91..67f7c505d 100644 --- a/modules/sleep-detector/test_main.py +++ b/modules/sleep-detector/test_main.py @@ -36,6 +36,7 @@ _stubs[f"common.{_name}"] = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_stubs[f"common.{_name}"]) _stubs["common.health"].report_health = lambda *a, **kw: None +_stubs["common"].__path__ = [str(Path(__file__).resolve().parent.parent / "common")] sys.modules.update(_stubs) import main # noqa: E402 diff --git a/package.json b/package.json index ee79c37d4..a32ded5fe 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,7 @@ { + "engines": { + "node": ">=24.22.1 <25" + }, "name": "@sleepypod/core", "type": "module", "private": true, @@ -30,6 +33,8 @@ "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "build:biometrics-worker": "node scripts/build/biometrics-worker.mjs", "biometrics:experiment": "tsx scripts/algorithm-experiments/cli.ts", + "biometrics:local-fit": "tsx scripts/biometrics-local-fit/cli.ts", + "biometrics:archive": "tsx scripts/biometrics-archive/cli.ts", "test:browser": "node tests/browser/run.mjs" }, "lint-staged": { @@ -67,7 +72,7 @@ "lucide-react": "^1.0.0", "mqtt": "^5.15.1", "negotiator": "^1.0.0", - "next": "^16.1.6", + "next": "^16.4.3", "node-schedule": "^2.1.1", "qrcode": "^1.5.4", "react": "^19.2.4", @@ -92,13 +97,13 @@ "@lingui/loader": "^6.0.0", "@lingui/swc-plugin": "^6.0.0", "@lingui/vite-plugin": "^6.0.0", - "@playwright/test": "1.58.2", + "@playwright/test": "1.64.1", "@rolldown/plugin-babel": "^0.2.4", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^11.0.0", - "@stryker-mutator/core": "^9.6.1", - "@stryker-mutator/typescript-checker": "^9.6.1", - "@stryker-mutator/vitest-runner": "^9.6.1", + "@stryker-mutator/core": "^10.0.0", + "@stryker-mutator/typescript-checker": "^10.0.0", + "@stryker-mutator/vitest-runner": "^10.0.0", "@stylistic/eslint-plugin": "^5.9.0", "@tailwindcss/postcss": "^4.2.1", "@testing-library/dom": "^10.4.1", @@ -116,12 +121,12 @@ "@types/three": "0.186.0", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.0.0", - "@vitest/coverage-v8": "4.1.11", + "@vitest/coverage-v8": "4.1.12", "conventional-changelog-conventionalcommits": "^9.1.0", "drizzle-kit": "^0.31.9", "esbuild": "0.28.2", "eslint": "^9.39.3", - "eslint-config-next": "^16.1.6", + "eslint-config-next": "^16.4.3", "eslint-plugin-react": "^7.37.5", "globals": "^17.0.0", "jiti": "^2.6.1", @@ -137,7 +142,7 @@ "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.18" }, - "packageManager": "pnpm@10.34.6", + "packageManager": "pnpm@10.34.7", "pnpm": { "onlyBuiltDependencies": [ "cbor-extract", @@ -152,7 +157,7 @@ }, "overrides": { "dbus-next>usocket": "-", - "@babel/runtime": "7.29.7" + "@babel/runtime": "7.29.10" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f70c531ac..0a8c9dfa0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: dbus-next>usocket: '-' - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 patchedDependencies: dbus-next@0.10.2: @@ -19,7 +19,7 @@ importers: dependencies: '@ajayche/trpc-panel': specifier: ^2.0.4 - version: 2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + version: 2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) '@base-ui/react': specifier: ^1.2.0 version: 1.9.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -28,13 +28,13 @@ importers: version: 0.9.0 '@lingui/core': specifier: ^6.0.0 - version: 6.9.1(@babel/core@7.29.7) + version: 6.9.1(@babel/core@8.0.9) '@lingui/macro': specifier: ^5.9.2 - version: 5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7))(react@19.3.0) + version: 5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9))(react@19.3.0) '@lingui/react': specifier: ^6.0.0 - version: 6.9.1(@babel/core@7.29.7)(react@19.3.0) + version: 6.9.1(@babel/core@8.0.9)(react@19.3.0) '@modelcontextprotocol/sdk': specifier: ^1.31.0 version: 1.32.1(zod@4.6.5) @@ -52,7 +52,7 @@ importers: version: 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/next': specifier: ^11.10.0 - version: 11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) + version: 11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) '@trpc/react-query': specifier: ^11.10.0 version: 11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) @@ -61,7 +61,7 @@ importers: version: 11.19.0(typescript@6.0.3) '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 2.0.1(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) better-sqlite3: specifier: ^12.6.2 version: 12.11.1 @@ -105,8 +105,8 @@ importers: specifier: ^1.0.0 version: 1.1.0 next: - specifier: ^16.1.6 - version: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: ^16.4.3 + version: 16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) node-schedule: specifier: ^2.1.1 version: 2.1.1 @@ -149,7 +149,7 @@ importers: devDependencies: '@codecov/nextjs-webpack-plugin': specifier: ^2.0.1 - version: 2.0.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.111.1(esbuild@0.28.2)) + version: 2.0.1(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.111.1(esbuild@0.28.2)) '@eslint/css': specifier: ^2.0.0 version: 2.0.0 @@ -161,7 +161,7 @@ importers: version: 2.1.1 '@lingui/babel-plugin-lingui-macro': specifier: ^6.0.0 - version: 6.9.1(@babel/core@7.29.7) + version: 6.9.1(@babel/core@8.0.9) '@lingui/cli': specifier: ^6.0.0 version: 6.9.1(esbuild@0.28.2)(rolldown@1.2.13) @@ -170,16 +170,16 @@ importers: version: 6.9.1(esbuild@0.28.2)(rolldown@1.2.13)(webpack@5.111.1(esbuild@0.28.2)) '@lingui/swc-plugin': specifier: ^6.0.0 - version: 6.7.1(@lingui/core@6.9.1(@babel/core@7.29.7)) + version: 6.7.1(@lingui/core@6.9.1(@babel/core@8.0.9)) '@lingui/vite-plugin': specifier: ^6.0.0 - version: 6.9.1(@babel/core@7.29.7)(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7))(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(esbuild@0.28.2)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 6.9.1(@babel/core@8.0.9)(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9))(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(esbuild@0.28.2)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) '@playwright/test': - specifier: 1.58.2 - version: 1.58.2 + specifier: 1.64.1 + version: 1.64.1 '@rolldown/plugin-babel': specifier: ^0.2.4 - version: 0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) '@semantic-release/commit-analyzer': specifier: ^13.0.1 version: 13.0.1(semantic-release@25.0.9(typescript@6.0.3)) @@ -187,14 +187,14 @@ importers: specifier: ^11.0.0 version: 11.0.1(semantic-release@25.0.9(typescript@6.0.3)) '@stryker-mutator/core': - specifier: ^9.6.1 - version: 9.6.1(@types/node@25.9.9) + specifier: ^10.0.0 + version: 10.1.1(@types/node@25.9.9) '@stryker-mutator/typescript-checker': - specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.9))(typescript@6.0.3) + specifier: ^10.0.0 + version: 10.1.1(@stryker-mutator/core@10.1.1(@types/node@25.9.9))(typescript@6.0.3) '@stryker-mutator/vitest-runner': - specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.9))(vitest@4.1.11) + specifier: ^10.0.0 + version: 10.1.1(@stryker-mutator/core@10.1.1(@types/node@25.9.9))(vitest@4.1.12) '@stylistic/eslint-plugin': specifier: ^5.9.0 version: 5.10.0(eslint@9.39.5(jiti@2.7.0)) @@ -245,10 +245,10 @@ importers: version: 8.18.2 '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) '@vitest/coverage-v8': - specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + specifier: 4.1.12 + version: 4.1.12(vitest@4.1.12) conventional-changelog-conventionalcommits: specifier: ^9.1.0 version: 9.3.1 @@ -262,8 +262,8 @@ importers: specifier: ^9.39.3 version: 9.39.5(jiti@2.7.0) eslint-config-next: - specifier: ^16.1.6 - version: 16.4.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + specifier: ^16.4.3 + version: 16.4.3(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.5(jiti@2.7.0)) @@ -305,7 +305,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) vitest: specifier: ^4.0.18 - version: 4.1.11(@types/node@25.9.9)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.12(@types/node@25.9.9)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages: @@ -356,100 +356,198 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.6': + resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/compat-data@7.29.7': resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} + '@babel/compat-data@8.0.5': + resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/core@7.29.7': resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} + '@babel/core@8.0.9': + resolution: {integrity: sha512-wXtyKlpR/pXf/u0vmib+OBakQaIzLglLcRoKpWxCHW/whqq7YfQYPD0IAAl9H/evi40MyLIjYYdvb6N+9DI2yA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@7.29.8': resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.6': + resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-annotate-as-pure@7.29.7': resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@8.0.0': + resolution: {integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@8.0.7': + resolution: {integrity: sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-create-class-features-plugin@7.29.7': resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@8.0.7': + resolution: {integrity: sha512-7ug3KrOhNVPPxFFLTel0/pMzimKzU/vTlNJKVL/dKguCpmNJZjjibyFbWg+Qs8QHmiIZPNuZ2UNnDGdHWGEpOg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.6': + resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-member-expression-to-functions@7.29.7': resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@8.0.5': + resolution: {integrity: sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@8.0.0': + resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@8.0.6': + resolution: {integrity: sha512-YUi+3X0cb0v97xKhuqkLdrwQtP6XXv1eVbhLRCFyMwG481UskbMQk4mjH7K/Jg3oJ1KLskvFwwq4kF/QnABkWQ==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-optimise-call-expression@7.29.7': resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} engines: {node: '>=6.9.0'} + '@babel/helper-optimise-call-expression@8.0.0': + resolution: {integrity: sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-plugin-utils@7.29.7': resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@8.0.1': + resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-replace-supers@7.29.7': resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-replace-supers@8.0.1': + resolution: {integrity: sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} engines: {node: '>=6.9.0'} + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + resolution: {integrity: sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-string-parser@7.29.11': + resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.8': + resolution: {integrity: sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helpers@7.29.10': resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} engines: {node: '>=6.9.0'} + '@babel/helpers@8.0.8': + resolution: {integrity: sha512-FBmhiDWs625wHg03OKiLQDeT6LOc2lWBbW97Yqs2iI1tZcucjpmYnO29NHsQ83XxKvredcvUymW/AAn2UtnakA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@7.29.11': + resolution: {integrity: sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@7.29.9': resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-proposal-decorators@7.29.7': - resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} - engines: {node: '>=6.9.0'} + '@babel/parser@8.0.8': + resolution: {integrity: sha512-Gq65JogAr0S2p0BzvfSJboyuiRnxDQApyOqHkKmkrvm+VlLK4gC3pKsiOKEdnhFBemrFbSnOu90mZZWDpiJnmQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/plugin-proposal-decorators@8.0.2': + resolution: {integrity: sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-syntax-decorators@7.29.7': - resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} - engines: {node: '>=6.9.0'} + '@babel/plugin-syntax-decorators@8.0.1': + resolution: {integrity: sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 '@babel/plugin-syntax-jsx@7.29.7': resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} @@ -457,23 +555,35 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@8.0.1': + resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-syntax-typescript@7.29.7': resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-destructuring@7.29.7': - resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} - engines: {node: '>=6.9.0'} + '@babel/plugin-syntax-typescript@8.0.3': + resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-transform-explicit-resource-management@7.29.7': - resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} - engines: {node: '>=6.9.0'} + '@babel/plugin-transform-destructuring@8.0.5': + resolution: {integrity: sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-explicit-resource-management@8.0.5': + resolution: {integrity: sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 '@babel/plugin-transform-modules-commonjs@7.29.7': resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} @@ -481,17 +591,53 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-commonjs@8.0.1': + resolution: {integrity: sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-display-name@8.0.1': + resolution: {integrity: sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx-development@8.0.1': + resolution: {integrity: sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx@8.0.1': + resolution: {integrity: sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-pure-annotations@8.0.1': + resolution: {integrity: sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-transform-typescript@7.29.9': resolution: {integrity: sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} - engines: {node: '>=6.9.0'} + '@babel/plugin-transform-typescript@8.0.6': + resolution: {integrity: sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 + + '@babel/preset-react@8.0.1': + resolution: {integrity: sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 '@babel/preset-typescript@7.29.7': resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} @@ -499,22 +645,44 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + '@babel/preset-typescript@8.0.7': + resolution: {integrity: sha512-sUFLFphAG6YNjCnJpSKpyoNxkfyeM2KNuUjJJN0R6dZusMcK2V3Fg11MVHPFvb37AZeDNocc+xM748vdh9z82Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@7.29.10': resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.7': + resolution: {integrity: sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/types@7.29.11': + resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.8': + resolution: {integrity: sha512-iXSSZAKQWmaeAwGtFw10v9fg5hUiUs0L+w+Z2K11kym3pDNK+SF0VcAQPsEFvWcTSZ7LMgClMXDBvTbH0IlICg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@base-ui/react@1.9.0': resolution: {integrity: sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==} engines: {node: '>=14.0.0'} @@ -1556,6 +1724,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.4.0-beta.0': + resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -1797,12 +1968,12 @@ packages: '@cfworker/json-schema': optional: true - '@napi-rs/wasm-runtime@1.2.5': - resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + '@napi-rs/wasm-runtime@1.2.6': + resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/core': ^1.7.1 || 2.0.0-beta.2 + '@emnapi/runtime': ^1.7.1 || 2.0.0-beta.2 '@nats-io/nats-core@3.4.0': resolution: {integrity: sha512-QMDM86EUNm+wudlKC67HLar/KHHQUvJGLfb4jbahje3pUk3K9afeck9fwsxBZF0eUFox6T/TA6m/t+lQqf+QsQ==} @@ -1819,60 +1990,60 @@ packages: resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} engines: {node: '>= 18.0.0'} - '@next/env@16.4.0': - resolution: {integrity: sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==} + '@next/env@16.4.3': + resolution: {integrity: sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==} - '@next/eslint-plugin-next@16.4.0': - resolution: {integrity: sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==} + '@next/eslint-plugin-next@16.4.3': + resolution: {integrity: sha512-oxuC06wt/71h6ACYCV9GbKOfnf5a7VlRe/p80YHTut15TNgDEmynDnaq8t/xhy5rlvk7dhItIPs6H3rvIM4kyw==} - '@next/swc-darwin-arm64@16.4.0': - resolution: {integrity: sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==} + '@next/swc-darwin-arm64@16.4.3': + resolution: {integrity: sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.4.0': - resolution: {integrity: sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==} + '@next/swc-darwin-x64@16.4.3': + resolution: {integrity: sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.4.0': - resolution: {integrity: sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==} + '@next/swc-linux-arm64-gnu@16.4.3': + resolution: {integrity: sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.4.0': - resolution: {integrity: sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==} + '@next/swc-linux-arm64-musl@16.4.3': + resolution: {integrity: sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.4.0': - resolution: {integrity: sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==} + '@next/swc-linux-x64-gnu@16.4.3': + resolution: {integrity: sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.4.0': - resolution: {integrity: sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==} + '@next/swc-linux-x64-musl@16.4.3': + resolution: {integrity: sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.4.0': - resolution: {integrity: sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==} + '@next/swc-win32-arm64-msvc@16.4.3': + resolution: {integrity: sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.4.0': - resolution: {integrity: sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==} + '@next/swc-win32-x64-msvc@16.4.3': + resolution: {integrity: sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1976,9 +2147,9 @@ packages: '@oxc-project/types@0.153.0': resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} - engines: {node: '>=18'} + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} hasBin: true '@pnpm/config.env-replace@1.1.0': @@ -2106,7 +2277,7 @@ packages: peerDependencies: '@babel/core': ^7.29.0 || ^8.0.0-rc.1 '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 rolldown: ^1.0.0-rc.5 vite: ^8.0.0 peerDependenciesMeta: @@ -2191,34 +2362,34 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@stryker-mutator/api@9.6.1': - resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/api@10.1.1': + resolution: {integrity: sha512-3s/ECsrD1Tf1WEB7amBk0HxjN7zgkabKvmqnI2C831rTNszJ6o2HoV9PVxtElDEWxb2+t4iavK5x25G+DTUOUA==} + engines: {node: '>=22.0.0'} - '@stryker-mutator/core@9.6.1': - resolution: {integrity: sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/core@10.1.1': + resolution: {integrity: sha512-4xJAOCw9utJMLyrTk9gINi434tBxnyuwt8lU/InyoKagKd2/zh/2ui7ncWoD0qZeJrxClYzblg8iUh5ewBAB3Q==} + engines: {node: '>=22.0.0'} hasBin: true - '@stryker-mutator/instrumenter@9.6.1': - resolution: {integrity: sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/instrumenter@10.1.1': + resolution: {integrity: sha512-Vyude2i8LhDciLV1q6J55KtdA7gZpRuMfZcBcmWV6hLHAvWocoPKnCKc5BlZt1mwGWkX3Olc0457I7Ue6F1WEg==} + engines: {node: '>=22.0.0'} - '@stryker-mutator/typescript-checker@9.6.1': - resolution: {integrity: sha512-dCFJDoFixFe7cbilsukb7a5jpn9JRSPef7/vgx+xfaf4gPf/neDVbci8E/YSvxmcFveuPHdeUxioocA1CKZqrg==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/typescript-checker@10.1.1': + resolution: {integrity: sha512-Tgsqu+bYPS10jzvRHKXdLTuGEyma1sjJ8lKqqtU+sylqbpYznz41YiBflQaE61UEeutXAIbENrb06GkImWOJ0A==} + engines: {node: '>=22.0.0'} peerDependencies: - '@stryker-mutator/core': 9.6.1 + '@stryker-mutator/core': 10.1.1 typescript: '>=3.6' - '@stryker-mutator/util@9.6.1': - resolution: {integrity: sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==} + '@stryker-mutator/util@10.1.1': + resolution: {integrity: sha512-Z0zDzsmhoSykE1gT3DvD1knFxySgNMfBlpSSs1j87kWvjEMfeMqfLOk0bqEPRg/rTFIUNSEXLZapxePUhZmfZA==} - '@stryker-mutator/vitest-runner@9.6.1': - resolution: {integrity: sha512-eyUHTCf3Ui+SUn/tpFJwzw6MV391kyBLZk/cDHFUfKFELqKMLbvd7e81axArlApKqO6cOnLfrxlwED+2SRN0ow==} - engines: {node: '>=14.18.0'} + '@stryker-mutator/vitest-runner@10.1.1': + resolution: {integrity: sha512-w/OmNt1vHD4Yeacy8Rxhl2Ue/9l662iyOW8XxM4rc9huixF6IySHW3cvhTs8JnMvG9tG7vS7ZZ52wUXx7xy1IQ==} + engines: {node: '>=22.0.0'} peerDependencies: - '@stryker-mutator/core': 9.6.1 + '@stryker-mutator/core': 10.1.1 vitest: '>=2.0.0' '@stylistic/eslint-plugin@5.10.0': @@ -2458,6 +2629,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -2473,6 +2647,9 @@ packages: '@types/jsdom@28.0.3': resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2784,20 +2961,20 @@ packages: oxc-transform-react: optional: true - '@vitest/coverage-v8@4.1.11': - resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + '@vitest/coverage-v8@4.1.12': + resolution: {integrity: sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==} peerDependencies: - '@vitest/browser': 4.1.11 - vitest: 4.1.11 + '@vitest/browser': 4.1.12 + vitest: 4.1.12 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/expect@4.1.12': + resolution: {integrity: sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==} - '@vitest/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + '@vitest/mocker@4.1.12': + resolution: {integrity: sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2807,20 +2984,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/pretty-format@4.1.12': + resolution: {integrity: sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==} - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/runner@4.1.12': + resolution: {integrity: sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==} - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/snapshot@4.1.12': + resolution: {integrity: sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==} - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/spy@4.1.12': + resolution: {integrity: sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==} - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/utils@4.1.12': + resolution: {integrity: sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2928,14 +3105,11 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - angular-html-parser@10.4.0: - resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} + angular-html-parser@10.11.0: + resolution: {integrity: sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==} engines: {node: '>= 14'} ansi-colors@4.1.3: @@ -3073,6 +3247,16 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.32: + resolution: {integrity: sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + baseline-browser-mapping@2.11.34: + resolution: {integrity: sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==} + engines: {node: '>=6.0.0'} + hasBin: true + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -3127,6 +3311,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.29.5: + resolution: {integrity: sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -3171,6 +3360,9 @@ packages: caniuse-lite@1.0.30001815: resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + caniuse-lite@1.0.30001819: + resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==} + cbor-extract@2.2.2: resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} hasBin: true @@ -3181,8 +3373,8 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} chalk@2.4.2: @@ -3780,6 +3972,9 @@ packages: electron-to-chromium@1.5.452: resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} + electron-to-chromium@1.5.467: + resolution: {integrity: sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3792,6 +3987,10 @@ packages: emojilib@2.4.0: resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -3902,8 +4101,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.4.0: - resolution: {integrity: sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==} + eslint-config-next@16.4.3: + resolution: {integrity: sha512-j9IqBma6abp800frdUURaizYMgXOK+jNGyq6CsYcM9AxpSJs5D/TgMNGeAmRUilZvA7g9V1Tr71gNg9/SVTuMQ==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -4206,6 +4405,10 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flru@1.0.2: + resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} + engines: {node: '>=6'} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -4228,11 +4431,6 @@ packages: resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} engines: {node: '>=14.14'} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5397,8 +5595,8 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minimizer-webpack-plugin@5.13.0: - resolution: {integrity: sha512-OO1NF2zHj6sf4CNv2AsUWYcS8WEwkVKQDXxxKzlOxx1Xl+WlqYtVC+oNlGNwGkLAOnpj277BSHypWwRtd9w4JQ==} + minimizer-webpack-plugin@5.13.1: + resolution: {integrity: sha512-1mhAfvachWYYOm8tpmany5NLuvP+F2wxZWBSX8DC0uFS2wJpM9q3rWgN9wBooAH9d5V12wX0BjyJe4fMjHMarQ==} engines: {node: '>= 10.13.0'} peerDependencies: '@minify-html/node': '*' @@ -5484,14 +5682,14 @@ packages: resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} engines: {node: '>=18'} - mutation-testing-elements@3.7.3: - resolution: {integrity: sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==} + mutation-testing-elements@3.8.4: + resolution: {integrity: sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==} - mutation-testing-metrics@3.7.3: - resolution: {integrity: sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==} + mutation-testing-metrics@3.8.4: + resolution: {integrity: sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==} - mutation-testing-report-schema@3.7.3: - resolution: {integrity: sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==} + mutation-testing-report-schema@3.8.4: + resolution: {integrity: sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==} mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} @@ -5513,8 +5711,8 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + napi-postinstall@0.3.7: + resolution: {integrity: sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} hasBin: true @@ -5531,8 +5729,8 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - next@16.4.0: - resolution: {integrity: sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==} + next@16.4.3: + resolution: {integrity: sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5578,6 +5776,10 @@ packages: resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} + node-releases@2.0.60: + resolution: {integrity: sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==} + engines: {node: '>=18'} + node-schedule@2.1.1: resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} engines: {node: '>=6'} @@ -5742,6 +5944,10 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5956,14 +6162,14 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} - engines: {node: '>=18'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} hasBin: true - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} - engines: {node: '>=18'} + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} hasBin: true pngjs@5.0.0: @@ -6335,9 +6541,15 @@ packages: scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} - schema-utils@4.5.0: - resolution: {integrity: sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==} + schema-utils@4.8.1: + resolution: {integrity: sha512-x39pYlJPyH7Uge5QO64B9XGP9zLDc9WNmiQZc+CE3IzZPvMu3lHOuAMGpYNoPixEL7MSWlGhYTg5UlLSL8G6iw==} engines: {node: '>= 10.13.0'} + hasBin: true + peerDependencies: + webpack: ^5.112.0 + peerDependenciesMeta: + webpack: + optional: true semantic-release@25.0.9: resolution: {integrity: sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==} @@ -6352,11 +6564,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -6785,6 +6992,10 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + tldts-core@7.4.16: resolution: {integrity: sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==} @@ -7098,6 +7309,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verkit@0.5.0: + resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} + engines: {node: '>=18.12.0'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -7155,20 +7370,20 @@ packages: yaml: optional: true - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + vitest@4.1.12: + resolution: {integrity: sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.11 - '@vitest/browser-preview': 4.1.11 - '@vitest/browser-webdriverio': 4.1.11 - '@vitest/coverage-istanbul': 4.1.11 - '@vitest/coverage-v8': 4.1.11 - '@vitest/ui': 4.1.11 + '@vitest/browser-playwright': 4.1.12 + '@vitest/browser-preview': 4.1.12 + '@vitest/browser-webdriverio': ^4.1.11 + '@vitest/coverage-istanbul': 4.1.12 + '@vitest/coverage-v8': 4.1.12 + '@vitest/ui': 4.1.12 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -7200,12 +7415,12 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - watchpack@2.5.2: - resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + watchpack@2.6.3: + resolution: {integrity: sha512-s3NZy0QpTY9qGqzc0wtz4qI28z3+5qKbbAec4MV4G9N3CfgZ9wgvQRr0cl2jh/tqkWG1NWHkmVMHZpCamev5Wg==} engines: {node: '>=10.13.0'} - weapon-regex@1.3.6: - resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==} + weapon-regex@2.0.6: + resolution: {integrity: sha512-iJC2vqYp+bh3r4X6zjuP0h65XqXjgUX3nJgmhj0PQMrdhAbUwCfN76CSTEEQqV3G7468kjOJ69PmytTG8l/8Gg==} web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} @@ -7461,13 +7676,13 @@ snapshots: '@actions/io@3.0.2': {} - '@ajayche/trpc-panel@2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': + '@ajayche/trpc-panel@2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': dependencies: '@microlink/react-json-view': 1.31.38(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@trpc/server': 11.19.0(typescript@6.0.3) clsx: 2.1.1 fuzzysort: 2.0.4 - nuqs: 2.10.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + nuqs: 2.10.1(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) path: 0.12.7 pretty-bytes: 6.1.1 pretty-ms: 8.0.0 @@ -7516,8 +7731,15 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@8.0.6': + dependencies: + '@babel/helper-validator-identifier': 8.0.6 + js-tokens: 10.0.0 + '@babel/compat-data@7.29.7': {} + '@babel/compat-data@8.0.5': {} + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -7538,6 +7760,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@8.0.9': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-compilation-targets': 8.0.7 + '@babel/helpers': 8.0.8 + '@babel/parser': 8.0.8 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.8 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.1.0 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 3.0.0 + verkit: 0.5.0 + '@babel/generator@7.29.8': dependencies: '@babel/parser': 7.29.9 @@ -7546,10 +7787,23 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.6': + dependencies: + '@babel/parser': 8.0.8 + '@babel/types': 8.0.8 + '@jridgewell/gen-mapping': 0.4.0-beta.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.29.7': dependencies: '@babel/types': 7.29.8 + '@babel/helper-annotate-as-pure@8.0.0': + dependencies: + '@babel/types': 8.0.8 + '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 @@ -7558,6 +7812,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@8.0.7': + dependencies: + '@babel/compat-data': 8.0.5 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.29.5 + flru: 1.0.2 + verkit: 0.5.0 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -7571,15 +7833,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@8.0.7(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-member-expression-to-functions': 8.0.5 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/helper-replace-supers': 8.0.1(@babel/core@8.0.9) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/traverse': 8.0.7 + verkit: 0.5.0 + '@babel/helper-globals@7.29.7': {} + '@babel/helper-globals@8.0.6': {} + '@babel/helper-member-expression-to-functions@7.29.7': dependencies: '@babel/traverse': 7.29.10 - '@babel/types': 7.29.8 + '@babel/types': 7.29.11 transitivePeerDependencies: - supports-color + '@babel/helper-member-expression-to-functions@8.0.5': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.8 + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.10 @@ -7587,6 +7867,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@8.0.0': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.8 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -7596,12 +7881,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@8.0.6(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-validator-identifier': 8.0.6 + '@babel/traverse': 8.0.7 + '@babel/helper-optimise-call-expression@7.29.7': dependencies: - '@babel/types': 7.29.8 + '@babel/types': 7.29.11 + + '@babel/helper-optimise-call-expression@8.0.0': + dependencies: + '@babel/types': 8.0.8 '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -7611,6 +7911,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-member-expression-to-functions': 8.0.5 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/traverse': 8.0.7 + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: '@babel/traverse': 7.29.10 @@ -7618,60 +7925,89 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.8 + + '@babel/helper-string-parser@7.29.11': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.8': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.6': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-validator-option@8.0.0': {} + '@babel/helpers@7.29.10': dependencies: '@babel/template': 7.29.7 '@babel/types': 7.29.8 + '@babel/helpers@8.0.8': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.8 + + '@babel/parser@7.29.11': + dependencies: + '@babel/types': 7.29.11 + '@babel/parser@7.29.9': dependencies: '@babel/types': 7.29.8 - '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': + '@babel/parser@8.0.8': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color + '@babel/types': 8.0.8 - '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.9)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 + '@babel/core': 8.0.9 + '@babel/helper-create-class-features-plugin': 8.0.7(@babel/core@8.0.9) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.9) + + '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.9)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.10 - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.9 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-destructuring@8.0.5(@babel/core@8.0.9)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.9 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + + '@babel/plugin-transform-explicit-resource-management@8.0.5(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-transform-destructuring': 8.0.5(@babel/core@8.0.9) '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': dependencies: @@ -7681,6 +8017,37 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-commonjs@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-module-transforms': 8.0.6(@babel/core@8.0.9) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + + '@babel/plugin-transform-react-display-name@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + + '@babel/plugin-transform-react-jsx-development@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.9) + + '@babel/plugin-transform-react-jsx@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-syntax-jsx': 8.0.1(@babel/core@8.0.9) + '@babel/types': 8.0.8 + + '@babel/plugin-transform-react-pure-annotations@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -7692,16 +8059,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@8.0.6(@babel/core@8.0.9)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.9 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-create-class-features-plugin': 8.0.7(@babel/core@8.0.9) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/plugin-syntax-typescript': 8.0.3(@babel/core@8.0.9) + + '@babel/preset-react@8.0.1(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-react-display-name': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-transform-react-jsx-development': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-transform-react-pure-annotations': 8.0.1(@babel/core@8.0.9) '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': dependencies: @@ -7714,7 +8089,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime@7.29.7': {} + '@babel/preset-typescript@8.0.7(@babel/core@8.0.9)': + dependencies: + '@babel/core': 8.0.9 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.9) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-modules-commonjs': 8.0.1(@babel/core@8.0.9) + '@babel/plugin-transform-typescript': 8.0.6(@babel/core@8.0.9) + + '@babel/runtime@7.29.10': {} '@babel/template@7.29.7': dependencies: @@ -7722,6 +8105,12 @@ snapshots: '@babel/parser': 7.29.9 '@babel/types': 7.29.8 + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/parser': 8.0.8 + '@babel/types': 8.0.8 + '@babel/traverse@7.29.10': dependencies: '@babel/code-frame': 7.29.7 @@ -7734,14 +8123,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-globals': 8.0.6 + '@babel/parser': 8.0.8 + '@babel/template': 8.0.0 + '@babel/types': 8.0.8 + obug: 3.0.0 + + '@babel/types@7.29.11': + dependencies: + '@babel/helper-string-parser': 7.29.11 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.8': + dependencies: + '@babel/helper-string-parser': 8.0.8 + '@babel/helper-validator-identifier': 8.0.6 + '@base-ui/react@1.9.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@base-ui/utils': 0.5.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@floating-ui/utils': 0.2.12 @@ -7753,7 +8162,7 @@ snapshots: '@base-ui/utils@0.5.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@floating-ui/utils': 0.2.12 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) @@ -7795,11 +8204,11 @@ snapshots: unplugin: 1.16.1 zod: 3.25.76 - '@codecov/nextjs-webpack-plugin@2.0.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.111.1(esbuild@0.28.2))': + '@codecov/nextjs-webpack-plugin@2.0.1(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.111.1(esbuild@0.28.2))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 '@codecov/webpack-plugin': 2.0.1(webpack@5.111.1(esbuild@0.28.2)) - next: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) unplugin: 1.16.1 webpack: 5.111.1(esbuild@0.28.2) @@ -8505,6 +8914,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.4.0-beta.0': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -8538,6 +8952,14 @@ snapshots: optionalDependencies: '@babel/core': 7.29.7 + '@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9)': + dependencies: + '@babel/types': 7.29.8 + '@lingui/conf': 6.9.1 + '@lingui/message-utils': 6.9.1 + optionalDependencies: + '@babel/core': 8.0.9 + '@lingui/cli@6.9.1(esbuild@0.28.2)(rolldown@1.2.13)': dependencies: '@babel/core': 7.29.7 @@ -8583,12 +9005,12 @@ snapshots: lilconfig: 3.1.3 normalize-path: 3.0.0 - '@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7))': + '@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9))': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@lingui/message-utils': 5.9.5 optionalDependencies: - '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@7.29.7) + '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@8.0.9) '@lingui/core@6.9.1(@babel/core@7.29.7)': dependencies: @@ -8597,6 +9019,13 @@ snapshots: transitivePeerDependencies: - '@babel/core' + '@lingui/core@6.9.1(@babel/core@8.0.9)': + dependencies: + '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@8.0.9) + '@lingui/message-utils': 6.9.1 + transitivePeerDependencies: + - '@babel/core' + '@lingui/format-po@6.9.1': dependencies: '@lingui/conf': 6.9.1 @@ -8615,12 +9044,12 @@ snapshots: - rolldown - supports-color - '@lingui/macro@5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7))(react@19.3.0)': + '@lingui/macro@5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9))(react@19.3.0)': dependencies: - '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7)) - '@lingui/react': 5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7))(react@19.3.0) + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9)) + '@lingui/react': 5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9))(react@19.3.0) optionalDependencies: - '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@7.29.7) + '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@8.0.9) transitivePeerDependencies: - react @@ -8677,38 +9106,38 @@ snapshots: '@lingui/native-tools-win32-arm64-msvc': 0.1.6 '@lingui/native-tools-win32-x64-msvc': 0.1.6 - '@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7))(react@19.3.0)': + '@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 - '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7)) + '@babel/runtime': 7.29.10 + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9)) react: 19.3.0 optionalDependencies: - '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@7.29.7) + '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@8.0.9) - '@lingui/react@6.9.1(@babel/core@7.29.7)(react@19.3.0)': + '@lingui/react@6.9.1(@babel/core@8.0.9)(react@19.3.0)': dependencies: - '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@7.29.7) - '@lingui/core': 6.9.1(@babel/core@7.29.7) + '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@8.0.9) + '@lingui/core': 6.9.1(@babel/core@8.0.9) react: 19.3.0 use-sync-external-store: 1.7.0(react@19.3.0) transitivePeerDependencies: - '@babel/core' - '@lingui/swc-plugin@6.7.1(@lingui/core@6.9.1(@babel/core@7.29.7))': + '@lingui/swc-plugin@6.7.1(@lingui/core@6.9.1(@babel/core@8.0.9))': dependencies: '@lingui/conf': 6.9.0 - '@lingui/core': 6.9.1(@babel/core@7.29.7) + '@lingui/core': 6.9.1(@babel/core@8.0.9) - '@lingui/vite-plugin@6.9.1(@babel/core@7.29.7)(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@7.29.7))(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(esbuild@0.28.2)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': + '@lingui/vite-plugin@6.9.1(@babel/core@8.0.9)(@lingui/babel-plugin-lingui-macro@6.9.1(@babel/core@8.0.9))(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(esbuild@0.28.2)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@lingui/cli': 6.9.1(esbuild@0.28.2)(rolldown@1.2.13) '@lingui/conf': 6.9.1 '@lingui/native-tools': 0.1.6(@lingui/conf@6.9.1) vite: 8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) optionalDependencies: - '@babel/core': 7.29.7 - '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@7.29.7) - '@rolldown/plugin-babel': 0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + '@babel/core': 8.0.9 + '@lingui/babel-plugin-lingui-macro': 6.9.1(@babel/core@8.0.9) + '@rolldown/plugin-babel': 0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) rolldown: 1.2.13 transitivePeerDependencies: - babel-plugin-macros @@ -8775,7 +9204,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 @@ -8799,37 +9228,37 @@ snapshots: '@nats-io/nkeys': 2.0.3 '@nats-io/nuid': 3.0.0 - '@next/env@16.4.0': {} + '@next/env@16.4.3': {} - '@next/eslint-plugin-next@16.4.0(eslint@9.39.5(jiti@2.7.0))': + '@next/eslint-plugin-next@16.4.3(eslint@9.39.5(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.4.0': + '@next/swc-darwin-arm64@16.4.3': optional: true - '@next/swc-darwin-x64@16.4.0': + '@next/swc-darwin-x64@16.4.3': optional: true - '@next/swc-linux-arm64-gnu@16.4.0': + '@next/swc-linux-arm64-gnu@16.4.3': optional: true - '@next/swc-linux-arm64-musl@16.4.0': + '@next/swc-linux-arm64-musl@16.4.3': optional: true - '@next/swc-linux-x64-gnu@16.4.0': + '@next/swc-linux-x64-gnu@16.4.3': optional: true - '@next/swc-linux-x64-musl@16.4.0': + '@next/swc-linux-x64-musl@16.4.3': optional: true - '@next/swc-win32-arm64-msvc@16.4.0': + '@next/swc-win32-arm64-msvc@16.4.3': optional: true - '@next/swc-win32-x64-msvc@16.4.0': + '@next/swc-win32-x64-msvc@16.4.3': optional: true '@noble/hashes@2.4.0': {} @@ -8934,9 +9363,9 @@ snapshots: '@oxc-project/types@0.153.0': {} - '@playwright/test@1.58.2': + '@playwright/test@1.64.1': dependencies: - playwright: 1.58.2 + playwright: 1.64.1 '@pnpm/config.env-replace@1.1.0': {} @@ -9007,13 +9436,13 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.13': optional: true - '@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': + '@rolldown/plugin-babel@0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 8.0.9 picomatch: 4.0.7 rolldown: 1.2.13 optionalDependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 vite: 8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) '@rolldown/pluginutils@1.0.1': {} @@ -9152,20 +9581,20 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@stryker-mutator/api@9.6.1': + '@stryker-mutator/api@10.1.1': dependencies: - mutation-testing-metrics: 3.7.3 - mutation-testing-report-schema: 3.7.3 + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 tslib: 2.8.1 typed-inject: 5.0.0 - '@stryker-mutator/core@9.6.1(@types/node@25.9.9)': + '@stryker-mutator/core@10.1.1(@types/node@25.9.9)': dependencies: '@inquirer/prompts': 8.7.2(@types/node@25.9.9) - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/instrumenter': 9.6.1 - '@stryker-mutator/util': 9.6.1 - ajv: 8.18.0 + '@stryker-mutator/api': 10.1.1 + '@stryker-mutator/instrumenter': 10.1.1 + '@stryker-mutator/util': 10.1.1 + ajv: 8.20.0 chalk: 5.6.2 commander: 14.0.3 diff-match-patch: 1.0.5 @@ -9175,9 +9604,9 @@ snapshots: lodash.groupby: 4.6.0 minimatch: 10.2.6 mutation-server-protocol: 0.4.1 - mutation-testing-elements: 3.7.3 - mutation-testing-metrics: 3.7.3 - mutation-testing-report-schema: 3.7.3 + mutation-testing-elements: 3.8.4 + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 npm-run-path: 6.0.0 progress: 2.0.3 rxjs: 7.8.2 @@ -9189,43 +9618,42 @@ snapshots: typed-rest-client: 2.3.1 transitivePeerDependencies: - '@types/node' - - supports-color - '@stryker-mutator/instrumenter@9.6.1': - dependencies: - '@babel/core': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/parser': 7.29.9 - '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/util': 9.6.1 - angular-html-parser: 10.4.0 - semver: 7.7.4 + '@stryker-mutator/instrumenter@10.1.1': + dependencies: + '@babel/core': 8.0.9 + '@babel/generator': 8.0.6 + '@babel/parser': 8.0.8 + '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@8.0.9) + '@babel/plugin-transform-explicit-resource-management': 8.0.5(@babel/core@8.0.9) + '@babel/preset-react': 8.0.1(@babel/core@8.0.9) + '@babel/preset-typescript': 8.0.7(@babel/core@8.0.9) + '@babel/traverse': 8.0.7 + '@stryker-mutator/api': 10.1.1 + '@stryker-mutator/util': 10.1.1 + angular-html-parser: 10.11.0 + semver: 7.8.5 tslib: 2.8.1 - weapon-regex: 1.3.6 - transitivePeerDependencies: - - supports-color + weapon-regex: 2.0.6 - '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.9))(typescript@6.0.3)': + '@stryker-mutator/typescript-checker@10.1.1(@stryker-mutator/core@10.1.1(@types/node@25.9.9))(typescript@6.0.3)': dependencies: - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.9) - '@stryker-mutator/util': 9.6.1 - semver: 7.7.4 + '@stryker-mutator/api': 10.1.1 + '@stryker-mutator/core': 10.1.1(@types/node@25.9.9) + '@stryker-mutator/util': 10.1.1 + semver: 7.8.5 typescript: 6.0.3 - '@stryker-mutator/util@9.6.1': {} + '@stryker-mutator/util@10.1.1': {} - '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.9))(vitest@4.1.11)': + '@stryker-mutator/vitest-runner@10.1.1(@stryker-mutator/core@10.1.1(@types/node@25.9.9))(vitest@4.1.12)': dependencies: - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@25.9.9) - '@stryker-mutator/util': 9.6.1 + '@stryker-mutator/api': 10.1.1 + '@stryker-mutator/core': 10.1.1(@types/node@25.9.9) + '@stryker-mutator/util': 10.1.1 semver: 7.8.5 tslib: 2.8.1 - vitest: 4.1.11(@types/node@25.9.9)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 4.1.12(@types/node@25.9.9)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0))': dependencies: @@ -9326,7 +9754,7 @@ snapshots: '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -9336,7 +9764,7 @@ snapshots: '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@testing-library/dom': 10.4.2 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) @@ -9349,11 +9777,11 @@ snapshots: '@trpc/server': 11.19.0(typescript@6.0.3) typescript: 6.0.3 - '@trpc/next@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': + '@trpc/next@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': dependencies: '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/server': 11.19.0(typescript@6.0.3) - next: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) typescript: 6.0.3 @@ -9439,6 +9867,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/gensync@1.0.5': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -9460,6 +9890,8 @@ snapshots: parse5: 8.0.1 undici-types: 7.30.0 + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -9690,7 +10122,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -9702,22 +10134,22 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/analytics@2.0.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': + '@vercel/analytics@2.0.1(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': optionalDependencies: - next: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 - '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': + '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) optionalDependencies: - '@rolldown/plugin-babel': 0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + '@rolldown/plugin-babel': 0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) - '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + '@vitest/coverage-v8@4.1.12(vitest@4.1.12)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.11 + '@vitest/utils': 4.1.12 ast-v8-to-istanbul: 1.0.7 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -9726,48 +10158,48 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@25.9.9)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 4.1.12(@types/node@25.9.9)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) - '@vitest/expect@4.1.11': + '@vitest/expect@4.1.12': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - chai: 6.2.2 - tinyrainbow: 3.1.1 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 + chai: 6.3.0 + tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.11(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': + '@vitest/mocker@4.1.12(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': dependencies: - '@vitest/spy': 4.1.11 + '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) - '@vitest/pretty-format@4.1.11': + '@vitest/pretty-format@4.1.12': dependencies: - tinyrainbow: 3.1.1 + tinyrainbow: 3.2.0 - '@vitest/runner@4.1.11': + '@vitest/runner@4.1.12': dependencies: - '@vitest/utils': 4.1.11 + '@vitest/utils': 4.1.12 pathe: 2.0.3 - '@vitest/snapshot@4.1.11': + '@vitest/snapshot@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/pretty-format': 4.1.12 + '@vitest/utils': 4.1.12 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.11': {} + '@vitest/spy@4.1.12': {} - '@vitest/utils@4.1.11': + '@vitest/utils@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.11 + '@vitest/pretty-format': 4.1.12 convert-source-map: 2.0.0 - tinyrainbow: 3.1.1 + tinyrainbow: 3.2.0 '@webassemblyjs/ast@1.14.1': dependencies: @@ -9893,13 +10325,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.8 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -9907,7 +10332,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - angular-html-parser@10.4.0: {} + angular-html-parser@10.11.0: {} ansi-colors@4.1.3: {} @@ -10048,6 +10473,10 @@ snapshots: baseline-browser-mapping@2.11.28: {} + baseline-browser-mapping@2.11.32: {} + + baseline-browser-mapping@2.11.34: {} + before-after-hook@4.0.0: {} better-sqlite3@12.11.1: @@ -10122,7 +10551,7 @@ snapshots: broker-factory@3.1.15: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 fast-unique-numbers: 9.0.27 tslib: 2.8.1 worker-factory: 7.0.50 @@ -10135,6 +10564,14 @@ snapshots: node-releases: 2.0.58 update-browserslist-db: 1.3.4(browserslist@4.29.3) + browserslist@4.29.5: + dependencies: + baseline-browser-mapping: 2.11.34 + caniuse-lite: 1.0.30001819 + electron-to-chromium: 1.5.467 + node-releases: 2.0.60 + update-browserslist-db: 1.3.4(browserslist@4.29.5) + buffer-from@1.1.2: {} buffer@5.7.1: @@ -10178,6 +10615,8 @@ snapshots: caniuse-lite@1.0.30001815: {} + caniuse-lite@1.0.30001819: {} + cbor-extract@2.2.2: dependencies: node-gyp-build-optional-packages: 5.1.1 @@ -10196,7 +10635,7 @@ snapshots: ccount@2.0.1: {} - chai@6.2.2: {} + chai@6.3.0: {} chalk@2.4.2: dependencies: @@ -10666,6 +11105,8 @@ snapshots: electron-to-chromium@1.5.452: {} + electron-to-chromium@1.5.467: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -10674,6 +11115,8 @@ snapshots: emojilib@2.4.0: {} + empathic@2.1.0: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -10913,9 +11356,9 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.4.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + eslint-config-next@16.4.3(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 16.4.0(eslint@9.39.5(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.4.3(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) @@ -11018,7 +11461,7 @@ snapshots: eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.9 + '@babel/parser': 7.29.11 eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.6.5 @@ -11293,7 +11736,7 @@ snapshots: fast-unique-numbers@9.0.27: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 tslib: 2.8.1 fast-uri@3.1.8: {} @@ -11373,6 +11816,8 @@ snapshots: flatted@3.4.4: {} + flru@1.0.2: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -11391,9 +11836,6 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -12275,9 +12717,9 @@ snapshots: magicast@0.5.5: dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - source-map-js: 1.2.1 + '@babel/parser': 7.29.11 + '@babel/types': 7.29.11 + source-map-js: 1.2.2 make-asynchronous@1.1.0: dependencies: @@ -12597,11 +13039,11 @@ snapshots: minimist@1.2.8: {} - minimizer-webpack-plugin@5.13.0(esbuild@0.28.2)(webpack@5.111.1(esbuild@0.28.2)): + minimizer-webpack-plugin@5.13.1(esbuild@0.28.2)(webpack@5.111.1(esbuild@0.28.2)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 - schema-utils: 4.5.0 + schema-utils: 4.8.1(webpack@5.111.1(esbuild@0.28.2)) terser: 5.51.2 webpack: 5.111.1(esbuild@0.28.2) optionalDependencies: @@ -12658,13 +13100,13 @@ snapshots: dependencies: zod: 4.6.5 - mutation-testing-elements@3.7.3: {} + mutation-testing-elements@3.8.4: {} - mutation-testing-metrics@3.7.3: + mutation-testing-metrics@3.8.4: dependencies: - mutation-testing-report-schema: 3.7.3 + mutation-testing-report-schema: 3.8.4 - mutation-testing-report-schema@3.7.3: {} + mutation-testing-report-schema@3.8.4: {} mute-stream@3.0.0: {} @@ -12680,7 +13122,7 @@ snapshots: napi-build-utils@2.0.0: {} - napi-postinstall@0.3.4: {} + napi-postinstall@0.3.7: {} natural-compare@1.4.0: {} @@ -12692,26 +13134,26 @@ snapshots: nerf-dart@1.0.0: {} - next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.4.0 + '@next/env': 16.4.3 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.28 - caniuse-lite: 1.0.30001815 + baseline-browser-mapping: 2.11.32 + caniuse-lite: 1.0.30001819 postcss: 8.5.23 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@8.0.9)(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.4.0 - '@next/swc-darwin-x64': 16.4.0 - '@next/swc-linux-arm64-gnu': 16.4.0 - '@next/swc-linux-arm64-musl': 16.4.0 - '@next/swc-linux-x64-gnu': 16.4.0 - '@next/swc-linux-x64-musl': 16.4.0 - '@next/swc-win32-arm64-msvc': 16.4.0 - '@next/swc-win32-x64-msvc': 16.4.0 - '@playwright/test': 1.58.2 + '@next/swc-darwin-arm64': 16.4.3 + '@next/swc-darwin-x64': 16.4.3 + '@next/swc-linux-arm64-gnu': 16.4.3 + '@next/swc-linux-arm64-musl': 16.4.3 + '@next/swc-linux-x64-gnu': 16.4.3 + '@next/swc-linux-x64-musl': 16.4.3 + '@next/swc-win32-arm64-msvc': 16.4.3 + '@next/swc-win32-x64-msvc': 16.4.3 + '@playwright/test': 1.64.1 sharp: 0.35.5(@types/node@25.9.9) transitivePeerDependencies: - '@babel/core' @@ -12750,6 +13192,8 @@ snapshots: node-releases@2.0.58: {} + node-releases@2.0.60: {} + node-schedule@2.1.1: dependencies: cron-parser: 4.9.0 @@ -12787,12 +13231,12 @@ snapshots: npm@11.20.0: {} - nuqs@2.10.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): + nuqs@2.10.1(next@16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): dependencies: '@standard-schema/spec': 1.1.0 react: 19.3.0 optionalDependencies: - next: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.4.3(@babel/core@8.0.9)(@playwright/test@1.64.1)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) object-assign@4.1.1: {} @@ -12840,6 +13284,8 @@ snapshots: obug@2.2.1: {} + obug@3.0.0: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -13062,13 +13508,11 @@ snapshots: dependencies: find-up: 3.0.0 - playwright-core@1.58.2: {} + playwright-core@1.64.1: {} - playwright@1.58.2: + playwright@1.64.1: dependencies: - playwright-core: 1.58.2 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.64.1 pngjs@5.0.0: {} @@ -13274,7 +13718,7 @@ snapshots: react-textarea-autosize@8.5.9(@types/react@19.3.0)(react@19.3.0): dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 react: 19.3.0 use-composed-ref: 1.4.0(@types/react@19.3.0)(react@19.3.0) use-latest: 1.3.0(@types/react@19.3.0)(react@19.3.0) @@ -13520,12 +13964,14 @@ snapshots: scheduler@0.28.0: {} - schema-utils@4.5.0: + schema-utils@4.8.1(webpack@5.111.1(esbuild@0.28.2)): dependencies: '@types/json-schema': 7.0.15 ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) + optionalDependencies: + webpack: 5.111.1(esbuild@0.28.2) semantic-release@25.0.9(typescript@6.0.3): dependencies: @@ -13566,8 +14012,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} - semver@7.8.5: {} send@1.2.1: @@ -13973,12 +14417,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.3.0): + styled-jsx@5.1.6(@babel/core@8.0.9)(react@19.3.0): dependencies: client-only: 0.0.1 react: 19.3.0 optionalDependencies: - '@babel/core': 7.29.7 + '@babel/core': 8.0.9 super-regex@1.1.0: dependencies: @@ -14092,6 +14536,8 @@ snapshots: tinyrainbow@3.1.1: {} + tinyrainbow@3.2.0: {} + tldts-core@7.4.16: {} tldts@7.4.16: @@ -14339,7 +14785,7 @@ snapshots: unrs-resolver@1.12.2: dependencies: - napi-postinstall: 0.3.4 + napi-postinstall: 0.3.7 optionalDependencies: '@unrs/resolver-binding-android-arm-eabi': 1.12.2 '@unrs/resolver-binding-android-arm64': 1.12.2 @@ -14370,6 +14816,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.3.4(browserslist@4.29.5): + dependencies: + browserslist: 4.29.5 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -14419,6 +14871,8 @@ snapshots: vary@1.1.2: {} + verkit@0.5.0: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -14472,15 +14926,15 @@ snapshots: tsx: 4.23.15 yaml: 2.9.1 - vitest@4.1.11(@types/node@25.9.9)(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)): + vitest@4.1.12(@types/node@25.9.9)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)): dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/expect': 4.1.12 + '@vitest/mocker': 4.1.12(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.12 + '@vitest/runner': 4.1.12 + '@vitest/snapshot': 4.1.12 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 @@ -14491,12 +14945,12 @@ snapshots: tinybench: 2.9.0 tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 + tinyrainbow: 3.2.0 vite: 8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.9 - '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.12(vitest@4.1.12) jsdom: 29.1.1(@noble/hashes@2.4.0) transitivePeerDependencies: - msw @@ -14505,11 +14959,11 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - watchpack@2.5.2: + watchpack@2.6.3: dependencies: graceful-fs: 4.2.11 - weapon-regex@1.3.6: {} + weapon-regex@2.0.6: {} web-worker@1.5.0: {} @@ -14526,17 +14980,17 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - browserslist: 4.29.3 + browserslist: 4.29.5 chrome-trace-event: 1.0.4 enhanced-resolve: 5.26.0 es-module-lexer: 2.3.2 events: 3.3.0 graceful-fs: 4.2.11 mime-db: 1.54.0 - minimizer-webpack-plugin: 5.13.0(esbuild@0.28.2)(webpack@5.111.1(esbuild@0.28.2)) - schema-utils: 4.5.0 + minimizer-webpack-plugin: 5.13.1(esbuild@0.28.2)(webpack@5.111.1(esbuild@0.28.2)) + schema-utils: 4.8.1(webpack@5.111.1(esbuild@0.28.2)) tapable: 2.3.3 - watchpack: 2.5.2 + watchpack: 2.6.3 webpack-sources: 3.6.0 transitivePeerDependencies: - '@minify-html/node' @@ -14630,13 +15084,13 @@ snapshots: worker-factory@7.0.50: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 fast-unique-numbers: 9.0.27 tslib: 2.8.1 worker-timers-broker@8.0.18: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 broker-factory: 3.1.15 fast-unique-numbers: 9.0.27 tslib: 2.8.1 @@ -14644,13 +15098,13 @@ snapshots: worker-timers-worker@9.0.15: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 tslib: 2.8.1 worker-factory: 7.0.50 worker-timers@8.0.34: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 tslib: 2.8.1 worker-timers-broker: 8.0.18 worker-timers-worker: 9.0.15 diff --git a/proxy.ts b/proxy.ts index 0ea24cfe2..69aa40cc5 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,7 +1,7 @@ import linguiConfig from 'lingui.config' import Negotiator from 'negotiator' import { type NextRequest, NextResponse } from 'next/server' -import { ALLOWED_HOSTS_ENV, isHostAllowed, parseAllowedHosts } from '@/src/lib/hostAllowlist' +import { rejectUnknownHost } from '@/src/lib/hostAllowlist' const { locales } = linguiConfig @@ -23,22 +23,6 @@ const getRequestLocale = (requestHeaders: Headers): string => { return activeLocale } -// DNS-rebinding guard (see src/lib/hostAllowlist.ts). A request without a -// Host header passes: Node rejects HTTP/1.1 requests that lack one, and the -// browsers that carry a rebinding attack always send it, so only HTTP/1.0 -// tools get here without one. The Vercel demo is served from a public domain -// with no pod behind it, so it skips the check. -const rejectUnknownHost = (request: NextRequest): NextResponse | undefined => { - if (process.env.NEXT_PUBLIC_DEMO === '1') return - const host = request.headers.get('host') - if (host === null) return - if (isHostAllowed(host, parseAllowedHosts(process.env[ALLOWED_HOSTS_ENV]))) return - return new NextResponse( - `Host not allowed. Use the pod's IP address, or add this host to ${ALLOWED_HOSTS_ENV}.\n`, - { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } }, - ) -} - export const proxy = (request: NextRequest) => { const rejected = rejectUnknownHost(request) if (rejected) { @@ -71,10 +55,12 @@ export const proxy = (request: NextRequest) => { } // Every API route and page runs the Host check. Build output and images are -// left out: they carry no pod data. +// left out: they carry no pod data. Next buffers a matched request's body and +// silently truncates it at 10 MB (proxyClientMaxBodySize), so the Health +// export upload is left out too and runs the Host check itself. export const config = { matcher: [ - '/api/:path*', + '/api/((?!biometrics/watch-upload(?:/|$)).*)', '/((?!api|_vercel|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } diff --git a/renovate.json b/renovate.json index 3083cc7fe..e01ab5a80 100644 --- a/renovate.json +++ b/renovate.json @@ -113,7 +113,6 @@ }, { "matchUpdateTypes": [ - "fix", "patch" ], "commitMessagePrefix": "fix(deps-patch):", diff --git a/scripts/bin/sp-update b/scripts/bin/sp-update index 995eb1a06..d57d95be9 100644 --- a/scripts/bin/sp-update +++ b/scripts/bin/sp-update @@ -589,6 +589,38 @@ if [ "$(cat "$NATS_ORDERING_DROPIN" 2>/dev/null || true)" != "$nats_ordering_con systemctl daemon-reload fi +# Replay and shadow workers run as User=sleepypod and serialize on a flock at +# /run/sleepypod-shadow.lock, which that user cannot create in root-owned /run. +# Fresh installs get this ExecStartPre in the unit; OTA upgrades get the same +# step as a drop-in. Append-open, never truncate: a surviving worker may hold it. +EXECUTION_LOCK_DROPIN=/etc/systemd/system/sleepypod.service.d/biometrics-execution-lock.conf +mkdir -p "$(dirname "$EXECUTION_LOCK_DROPIN")" +execution_lock_content=$(cat << 'EOF' +[Service] +ExecStartPre=+/bin/sh -c ': >> /run/sleepypod-shadow.lock && chown sleepypod:sleepypod /run/sleepypod-shadow.lock && chmod 600 /run/sleepypod-shadow.lock' +EOF +) +if [ "$(cat "$EXECUTION_LOCK_DROPIN" 2>/dev/null || true)" != "$execution_lock_content" ]; then + printf '%s\n' "$execution_lock_content" > "$EXECUTION_LOCK_DROPIN" + systemctl daemon-reload +fi + +# Load the iptables kernel modules as root before the service starts. The +# service user has CAP_NET_ADMIN but cannot load modules, and Pod 4 boots +# without them, so the internet access toggle failed after every reboot. +# Fresh installs get this ExecStartPre in the unit; OTA upgrades get it here. +IPTABLES_MODULES_DROPIN=/etc/systemd/system/sleepypod.service.d/iptables-modules.conf +mkdir -p "$(dirname "$IPTABLES_MODULES_DROPIN")" +iptables_modules_content=$(cat << 'EOF' +[Service] +ExecStartPre=+/bin/sh -c 'PATH=/usr/sbin:/sbin:/usr/bin:/bin modprobe -a ip_tables iptable_filter iptable_nat 2>/dev/null; true' +EOF +) +if [ "$(cat "$IPTABLES_MODULES_DROPIN" 2>/dev/null || true)" != "$iptables_modules_content" ]; then + printf '%s\n' "$iptables_modules_content" > "$IPTABLES_MODULES_DROPIN" + systemctl daemon-reload +fi + # Replace the pre-existing ExecStart fallback (`pnpm start`, which cannot run # as the sleepypod user and crash-loops with a misleading pnpm error) with a # clear failure when .next/standalone/server.js is missing. Drop-in so OTA @@ -607,6 +639,39 @@ if [ "$(cat "$EXECSTART_DROPIN" 2>/dev/null || true)" != "$execstart_content" ]; systemctl daemon-reload fi +# Ensure sleepypod service user has journal read access for the Web log viewer (/debug, /system/logs). +# On pods where /run/log/journal is restricted to group systemd-journal (02750), User=sleepypod +# needs SupplementaryGroups=systemd-journal (or adm on images without systemd-journal) to run journalctl. +_group_exists() { + getent group "$1" &>/dev/null || grep -q "^$1:" /etc/group 2>/dev/null +} +supp_group="" +if _group_exists systemd-journal; then + supp_group="systemd-journal" +elif _group_exists adm; then + supp_group="adm" +fi +if [ -n "$supp_group" ]; then + if id sleepypod &>/dev/null; then + usermod -aG "$supp_group" sleepypod 2>/dev/null || true + fi + if id dac &>/dev/null; then + usermod -aG "$supp_group" dac 2>/dev/null || true + fi + + JOURNAL_DROPIN=/etc/systemd/system/sleepypod.service.d/journal-access.conf + mkdir -p "$(dirname "$JOURNAL_DROPIN")" + journal_dropin_content=$(cat << EOF +[Service] +SupplementaryGroups=$supp_group +EOF +) + if [ "$(cat "$JOURNAL_DROPIN" 2>/dev/null || true)" != "$journal_dropin_content" ]; then + printf '%s\n' "$journal_dropin_content" > "$JOURNAL_DROPIN" + systemctl daemon-reload + fi +fi + # Install CLI tools from new source. # # Use `install` (atomic mkstemp + rename) instead of `cp` — sp-update is @@ -632,6 +697,11 @@ if [ -f "$INSTALL_DIR/scripts/lib/storage-helpers" ]; then install_storage_cleanup_sudoers fi +if [ -f "$INSTALL_DIR/scripts/lib/journald-helpers" ]; then + source "$INSTALL_DIR/scripts/lib/journald-helpers" + install_journald_config +fi + # Re-block WAN before starting service if [ "$WAN_WAS_BLOCKED" = true ]; then reload_iptables_helpers diff --git a/scripts/biometrics-archive/README.md b/scripts/biometrics-archive/README.md new file mode 100644 index 000000000..30c6e3c26 --- /dev/null +++ b/scripts/biometrics-archive/README.md @@ -0,0 +1,198 @@ +# Off-pod biometrics archive + +Keep a pod's biometrics history off the pod for good. A pod keeps about four +weeks of RAW sensor archives (the pruner deletes the oldest at 80% disk) and +90 days of vitals. Every night with Apple Watch data is a labelled night for +tuning pod biometrics, so this tool pulls the pod's read-only analysis export +into a long-term store on a laptop or NAS. Run it daily or weekly. + +## Pull + +```sh +pnpm biometrics:archive pull --pod 192.168.1.88 --dest /Volumes/nas/sleepypod +pnpm biometrics:archive pull --pod pod.local:3000 --dest ~/sleepypod-archive --since 2026-10-01T00:00:00Z --until 2026-10-08T00:00:00Z +pnpm biometrics:archive pull --pod 192.168.1.88 --dest ~/sleepypod-archive --no-raw # database only +``` + +Each pull calls `GET http://POD:3000/api/biometrics/analysis-export?start=&end=&raw=1` +([format](../../docs/biometrics-datasets.md#analysis-snapshot-export)). It's read-only on the pod. + +- **Window.** Without `--since`, a pull continues from the cursor: the largest + served window end among this pod's successful pulls. The first pull covers the + last 14 days. `--until` defaults to now. Ranges longer than 14 days are + split into 14-day windows, oldest first, and each window is committed on its own. +- **429** (another export is streaming): retried up to 6 times. The wait is the + pod's `Retry-After`, or else 30 s doubling each time, at most 5 min. +- **413** (the window's RAW is over 2 GiB): the window is halved, oldest half + first, down to 1 h. Below that the pull fails and tells you to use `--no-raw`. +- **Verification.** The tar streams into `/.staging/`, hashing each entry + as it arrives. Nothing is stored until the export checks out: the manifest + is valid `analysis-export-v1`, it lists exactly the files received with + matching sizes and SHA-256s, the window is the one requested, and + `Content-Length` matches. A corrupt, partial or refused pull leaves the store + unchanged and doesn't move the cursor. +- **Commit order.** RAW blobs first, then the database merge in one + transaction, then the `archive/` links, then the pull record, which is the + cursor. If a pull dies part way, the next one repeats the same window, and + every step is idempotent. +- **Lock.** `/.biometrics-archive.lock` allows one pull per destination. + A lock left by a dead process on the same host is taken over. Otherwise the + pull fails and names the holder. + +## Layout + +``` +/ + .biometrics-archive.lock while a pull runs + / hashed pod id from the manifest; the hostname if it is null (dev machine), with a warning + biometrics.db long-term merged database (pod schema + archive_* tables) + raw/.RAW.gz content-addressed, written once + archive/.RAW.gz hard link to raw/ under the pod's original name + pulls/.json one per successful window +``` + +`archive/` names never get overwritten. If the pod reuses a name for different +bytes (counter reset, reinstall), or a file the archiver didn't write is +already there, the new one becomes `-.RAW.gz`. That's +listed under `raw.renamed` in the pull record and in the `archive_raw_files` +table. On filesystems without hard links the file is copied. + +A pull record holds the pod's manifest verbatim and the requested window. It +also has `raw` counts (new, already stored, linked, copied, renamed), per-table +`tables` counts (snapshot rows, inserted, reassigned, ignored, updated, new +conflicts), the `rows` totals, schema tags, warnings and `cursor`. + +`/` works directly as the data folder for +`analysis/watch-deviation`: it expects `biometrics.db` and `archive/*.RAW.gz`. +Add `watch.json` yourself, then +`SP_BIOMETRICS_DIR=/ analysis/watch-deviation/run.sh …`. The +archive database uses a rollback journal, not WAL, so it can be opened from a +read-only mount. + +## Merging the database + +Each export carries the pod's whole `biometrics.db`, not just the window. The +archive database is created and migrated with this checkout's +`src/db/biometrics-migrations`, so it has the pod's schema plus +`archive_uq_` unique indexes where the pod has no natural key. Before a +merge it's migrated to the newest migration the checkout knows. A snapshot +from a newer pod build fails with "update the checkout" and nothing is stored. +An older snapshot still merges: columns it lacks take their defaults. + +Rules (`TABLES` in `merge.ts`): + +- **Nothing is deleted.** Rows the pod has pruned (90-day retention, deleted + sleep records, deleted reference imports) stay in the archive. +- **Integer `id` surrogates** keep the pod's id when it's free. Ids are + monotonic on one pod. A row whose id is already taken by a different key + gets a new archive id and counts as `reassigned`. That only happens after the + pod's database was reset, since the pod id survives reinstalls. + `vitals_quality.vitals_id` is remapped to the archive id of its vitals row. +- **append**: a row is written once. Insert-or-ignore on the natural key. If a + key matches but the content differs, the archive keeps its row and the + pod's copy goes to `archive_conflicts`, recorded once. +- **state**: the pod updates rows in place. The newest snapshot (by manifest + `generatedAtMs`) overwrites the non-key columns. An older snapshot only + inserts. +- A row the archive's constraints refuse is kept in `archive_conflicts` with + reason `rejected`. A table the checkout has no rule for is merged + append-only on its primary key, with a warning. + +| Tables | Natural key | Mode | +|---|---|---| +| `vitals`, `vitals_quality`, `cap_sense_frames` | side, timestamp | append | +| `movement` | side, timestamp (added) | append | +| `bed_temp`, `freezer_temp`, `ambient_light`, `water_level_readings`, `flow_readings` | timestamp | append | +| `prime_events` | timestamp (added) | append | +| `thermal_state` | side, timestamp, is_powered (added) | append | +| `flash_write_samples` | boot_id, timestamp (added) | append | +| `calibration_runs` | side, sensor_type, created_at, triggered_by, duration_ms (added) | append | +| `reference_measurements` | import_id, record_key | append | +| `biometric_runtime_configs` | side, generation | append; after a pod reset reuses a generation, the new config is a conflict | +| `biometric_holdout_accesses` | id (text) | append | +| `biometric_shadow_samples` | run_id, sequence | append | +| `sleep_records` | side, entered_bed_at, left_bed_at (added) | state; editing a record's times on the pod adds a second row | +| `water_level_alerts` | type, started_at (added) | state (dismissed_at) | +| `pump_alerts` | timestamp, type, side (added) | state (acknowledged/dismissed) | +| `health_runs` | check_id, started_at (added) | state (last_seen_at) | +| `calibration_profiles` | side, sensor_type | state: the current profile; history is in `calibration_runs` | +| `reference_stages` | side, start, source | state: a corrected import replaces the label | +| `reference_samples` | side, metric, timestamp, source | state (Watch breathing rate and HRV SDNN) | +| `reference_vitals` | side, timestamp, source | state | +| `reference_imports` | id (text) | state (deleted_at_ms) | +| `biometric_processing_runs`, `biometric_experiments`, `biometric_backtest_jobs`, `biometric_raw_sources`, `biometric_shadow_runs`, `biometric_dataset_raw_artifacts` | id (text) | state | +| `biometric_backtest_results` | job_id | state | +| `biometric_raw_artifacts` | content_hash | state | +| `biometric_runtime_receipts` | side, component, generation | state | +| `biometric_datasets` | id (text) | state, but `snapshot` and `byte_length` keep their first value: releasing a dataset on the pod replaces them with a tombstone | + +Bookkeeping tables: `archive_snapshots` (each merged snapshot's SHA-256, +`generatedAtMs` and migration), `archive_raw_files` (RAW hash → original name +→ `archive/` name) and `archive_conflicts`. A test fails when a migration adds +a table without a rule here. + +## Storage interface and S3 + +`store.ts` defines the seam. Only `LocalStore` is implemented. + +```ts +interface Store { + put(key, stream, { sha256, size, mtimeMs? }) // atomic replace; rejects bytes that don't match + get(key): Readable + exists(key): boolean + list(prefix): string[] // recursive, sorted + link(from, to): 'linked' | 'copied' // fails if `to` exists +} +``` + +Keys are `/raw/.RAW.gz`, `/archive/` and +`/pulls/.json`. The caller enforces write-once +(`exists` → skip). The SQLite merge needs a local file, so `biometrics.db` +stays outside the interface, at `//biometrics.db`. The +staging directory and the lock file are local too. + +An S3 backend would implement `put` as a multipart upload with +`x-amz-checksum-sha256`, `exists` as `HeadObject`, `list` as `ListObjectsV2` +and `link` as `CopyObject` (`'copied'`). `pull.ts` would take a local work +directory for the database, staging and lock, plus the bucket store for +everything else. After each merge the database would be uploaded as +`/biometrics.db` (versioned bucket), and on a fresh machine it would be +downloaded first. The cursor already comes from `pulls/` in the store. + +## Scheduling + +Nothing is installed for you. Pick a time after the night's last archive is +written (about 45 min after you get up). + +cron (Linux/NAS): + +```cron +30 12 * * * cd /path/to/sleepypod-core && pnpm -s biometrics:archive pull --pod 192.168.1.88 --dest /srv/sleepypod-archive >> /var/log/sleepypod-archive.log 2>&1 +``` + +launchd (macOS), `~/Library/LaunchAgents/com.sleepypod.biometrics-archive.plist`, then +`launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.sleepypod.biometrics-archive.plist`: + +```xml + + + + + Labelcom.sleepypod.biometrics-archive + ProgramArguments + + /bin/zsh-lc + cd ~/src/sleepypod-core && pnpm -s biometrics:archive pull --pod 192.168.1.88 --dest /Volumes/nas/sleepypod + + StartCalendarIntervalHour12Minute30 + StandardOutPath/tmp/sleepypod-archive.log + StandardErrorPath/tmp/sleepypod-archive.log + + +``` + +A missed run (laptop asleep, pod offline) is harmless: the next run picks up at the cursor. Only RAW older than the pod's +retention (about four weeks) is lost, so don't go longer than that between runs. + +**Privacy:** the archive has the full biometrics database for both sides of the +bed plus raw sensor waveforms. Keep it on trusted machines. diff --git a/scripts/biometrics-archive/archive.test.ts b/scripts/biometrics-archive/archive.test.ts new file mode 100644 index 000000000..383b3e54f --- /dev/null +++ b/scripts/biometrics-archive/archive.test.ts @@ -0,0 +1,280 @@ +// @vitest-environment node +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runArchiveCli } from './cli' +import { ExportIntegrityError } from './download' +import { ArchiveLockedError, findCursor, LOCK_FILE, pull, type PullOptions } from './pull' +import { LocalStore } from './store' + +const state = vi.hoisted(() => ({ archive: '', source: '' })) +vi.mock('@/src/lib/podStorage', () => ({ + get ARCHIVE_DIR() { + return state.archive + }, +})) +vi.mock('@/src/db', () => ({ biometricsDb: { $client: { get name() { + return state.source +} } } })) +vi.mock('@/src/homekit/storage', () => ({ readSeed: () => ({ source: 'mmc-cid', value: 'cid-0123' }) })) +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ getDacMonitorIfRunning: () => null })) + +const DAY = 24 * 60 * 60 * 1000 +const NOW = Date.UTC(2026, 9, 18, 12) +const POD = 'pod.local' + +let root: string +let dest: string +let pod: Database.Database +let route: { GET: (request: Request) => Promise } +const calls: string[] = [] + +const podFetch: typeof fetch = async (input) => { + calls.push(String(input)) + return route.GET(new Request(String(input))) +} + +beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'biometrics-archive-test-')) + state.archive = join(root, 'biometrics-archive') + state.source = join(root, 'biometrics.db') + dest = join(root, 'dest') + mkdirSync(state.archive) + mkdirSync(join(root, 'tmpfs')) + vi.stubEnv('TMPDIR', join(root, 'tmpfs')) + pod = new Database(state.source) + pod.pragma('journal_mode = WAL') + migrate(drizzle(pod), { migrationsFolder: 'src/db/biometrics-migrations' }) + calls.length = 0 + vi.resetModules() + route = await import('@/app/api/biometrics/analysis-export/route') +}) +afterEach(() => { + pod.close() + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +const seconds = (ms: number) => Math.floor(ms / 1000) +function vitals(fromMs: number, count: number, side = 'left') { + const insert = pod.prepare('INSERT INTO vitals (side, timestamp, heart_rate) VALUES (?, ?, ?)') + const quality = pod.prepare('INSERT INTO vitals_quality (vitals_id, side, timestamp, quality_score, created_at) VALUES (?, ?, ?, 0.9, ?)') + for (let index = 0; index < count; index++) { + const ts = seconds(fromMs) + index * 60 + const id = insert.run(side, ts, 55 + index).lastInsertRowid + quality.run(id, side, ts, ts) + } +} +function rawArchive(name: string, mtimeMs: number, body = `frame ${name} ${mtimeMs}`) { + const path = join(state.archive, name) + writeFileSync(path, body) + utimesSync(path, mtimeMs / 1000, mtimeMs / 1000) + return createHash('sha256').update(body).digest('hex') +} + +const options = (extra: Partial = {}): PullOptions => ({ pod: POD, dest, includeRaw: true, fetch: podFetch, now: () => NOW, sleep: async () => {}, ...extra }) +const podDir = () => readdirSync(dest).filter(name => !name.startsWith('.')) +function archiveDb(read: (db: Database.Database) => T): T { + const db = new Database(join(dest, podDir()[0], 'biometrics.db'), { readonly: true }) + try { + return read(db) + } + finally { + db.close() + } +} +const count = (table: string) => archiveDb(db => (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n) +function listing(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => entry.isDirectory() + ? listing(join(directory, entry.name)).map(name => `${entry.name}/${name}`) + : [`${entry.name}:${createHash('sha256').update(readFileSync(join(directory, entry.name))).digest('hex')}`]) +} + +describe('biometrics archive pull', () => { + it('stores a first pull: content-addressed RAW, archive links, merged database and a pull record', async () => { + vitals(NOW - 3 * DAY, 5) + const a = rawArchive('A0000001.RAW.gz', NOW - 3 * DAY) + const b = rawArchive('A0000002.RAW.gz', NOW - DAY) + + const [record] = await pull(options()) + expect(calls).toEqual([`http://pod.local:3000/api/biometrics/analysis-export?start=${NOW - 14 * DAY}&end=${NOW}&raw=1`]) + const [dir] = podDir() + expect(dir).toMatch(/^[0-9a-f]{32}$/) + expect(readdirSync(join(dest, dir, 'raw')).sort()).toEqual([`${a}.RAW.gz`, `${b}.RAW.gz`].sort()) + const linked = join(dest, dir, 'archive', 'A0000001.RAW.gz') + expect(statSync(linked).ino).toBe(statSync(join(dest, dir, 'raw', `${a}.RAW.gz`)).ino) + expect(statSync(linked).mtimeMs).toBe(NOW - 3 * DAY) + expect(count('vitals')).toBe(5) + expect(count('vitals_quality')).toBe(5) + expect(record).toMatchObject({ podHost: 'pod.local:3000', podDir: dir, cursor: { endMs: NOW }, raw: { new: 2, skipped: 0, linked: 2, renamed: [] } }) + expect(record.tables.vitals).toMatchObject({ snapshot: 5, inserted: 5, reassigned: 0, ignored: 0, conflicts: 0 }) + const pulls = readdirSync(join(dest, dir, 'pulls')) + expect(pulls).toHaveLength(1) + expect(JSON.parse(readFileSync(join(dest, dir, 'pulls', pulls[0]), 'utf8')).manifest.formatVersion).toBe('analysis-export-v1') + expect(existsSync(join(dest, LOCK_FILE))).toBe(false) + expect(existsSync(join(dest, '.staging'))).toBe(false) + }) + + it('is idempotent: the same window again adds nothing, and the cursor makes the default pull a no-op', async () => { + vitals(NOW - 2 * DAY, 4) + rawArchive('A0000001.RAW.gz', NOW - 2 * DAY) + await pull(options()) + const [again] = await pull(options({ sinceMs: NOW - 14 * DAY, untilMs: NOW })) + expect(again.raw).toMatchObject({ new: 0, skipped: 1, linked: 0, copied: 0 }) + expect(again.rows).toEqual({ inserted: 0, reassigned: 0, ignored: again.rows.ignored, updated: 0, conflicts: 0 }) + expect(again.rows.ignored).toBeGreaterThan(0) + expect(count('vitals')).toBe(4) + + calls.length = 0 + expect(await pull(options())).toEqual([]) + expect(calls).toEqual([]) + }) + + it('merges overlapping windows without duplicating RAW or rows', async () => { + const t0 = NOW - 10 * DAY + vitals(t0, 3) + rawArchive('A0000001.RAW.gz', t0 + 12 * 60 * 60 * 1000) + rawArchive('A0000002.RAW.gz', t0 + 36 * 60 * 60 * 1000) + await pull(options({ sinceMs: t0, untilMs: t0 + 2 * DAY })) + vitals(t0 + 2 * DAY, 2) + rawArchive('A0000003.RAW.gz', t0 + 60 * 60 * 60 * 1000) + const [second] = await pull(options({ sinceMs: t0 + DAY, untilMs: t0 + 3 * DAY })) + expect(second.raw).toMatchObject({ new: 1, skipped: 1 }) + expect(second.tables.vitals).toMatchObject({ inserted: 2, ignored: 3 }) + expect(count('vitals')).toBe(5) + expect(readdirSync(join(dest, podDir()[0], 'archive')).sort()).toEqual(['A0000001.RAW.gz', 'A0000002.RAW.gz', 'A0000003.RAW.gz']) + }) + + it('keeps history the pod has since pruned', async () => { + vitals(NOW - 13 * DAY, 3) + await pull(options({ now: () => NOW - 5 * DAY })) + pod.exec(`DELETE FROM vitals_quality; DELETE FROM vitals WHERE timestamp < ${seconds(NOW - 10 * DAY)}`) + vitals(NOW - 2 * DAY, 2) + const [second] = await pull(options()) + expect(second.window).toMatchObject({ startMs: NOW - 5 * DAY, endMs: NOW }) + expect(count('vitals')).toBe(5) + expect(count('vitals_quality')).toBe(5) + }) + + it.each([ + ['a flipped RAW byte', (bytes: Buffer) => { + const at = bytes.indexOf('frame A0000009') + bytes[at] ^= 0xff + return bytes + }, 'SHA-256 mismatch: raw/A0000009.RAW.gz'], + ['a cut-short download', (bytes: Buffer) => bytes.subarray(0, bytes.length - 2048), 'truncated'], + ])('rejects %s without changing the store or the cursor', async (_label, corrupt, message) => { + vitals(NOW - 3 * DAY, 2) + await pull(options({ now: () => NOW - DAY })) + const before = listing(dest) + vitals(NOW - DAY / 2, 2) + rawArchive('A0000009.RAW.gz', NOW - DAY / 2) + const corrupting: typeof fetch = async (input) => { + const response = await podFetch(input) + return new Response(new Uint8Array(corrupt(Buffer.from(await response.arrayBuffer()))), { status: 200, headers: response.headers }) + } + await expect(pull(options({ fetch: corrupting }))).rejects.toThrow(message) + await expect(pull(options({ fetch: corrupting }))).rejects.toBeInstanceOf(ExportIntegrityError) + expect(listing(dest)).toEqual(before) + expect(await findCursor(new LocalStore(dest), dest, 'pod.local:3000', () => {})).toBe(NOW - DAY) + }) + + it('does not let a database-only pull advance the cursor of RAW pulls', async () => { + vitals(NOW - 3 * DAY, 2) + await pull(options({ now: () => NOW - 2 * DAY })) + await pull(options({ now: () => NOW - DAY, includeRaw: false })) + const store = new LocalStore(dest) + expect(await findCursor(store, dest, 'pod.local:3000', () => {}, true)).toBe(NOW - 2 * DAY) + expect(await findCursor(store, dest, 'pod.local:3000', () => {}, false)).toBe(NOW - DAY) + }) + + it('retries 429 with backoff, honouring Retry-After', async () => { + vitals(NOW - DAY, 1) + let busy = 2 + const delays: number[] = [] + const fetchImpl: typeof fetch = async (input) => { + if (busy-- > 0) return Response.json({ error: 'Another analysis export is in progress, retry later' }, { status: 429, headers: busy ? { 'Retry-After': '60' } : {} }) + return podFetch(input) + } + const records = await pull(options({ fetch: fetchImpl, sleep: async (ms) => { + delays.push(ms) + }, retry: { attempts: 3, baseDelayMs: 1000, maxDelayMs: 45_000 } })) + expect(delays).toEqual([45_000, 2000]) + expect(records).toHaveLength(1) + + busy = 5 + await expect(pull(options({ fetch: fetchImpl, sinceMs: NOW - DAY, untilMs: NOW, retry: { attempts: 2, baseDelayMs: 1, maxDelayMs: 1 } }))).rejects.toThrow('HTTP 429') + }) + + it('halves the window on 413 down to the floor, oldest half first', async () => { + vitals(NOW - 12 * DAY, 2) + const fetchImpl: typeof fetch = async (input) => { + const query = new URL(String(input)).searchParams + if (Number(query.get('end')) - Number(query.get('start')) > 4 * DAY) return Response.json({ error: 'over the limit' }, { status: 413 }) + return podFetch(input) + } + const records = await pull(options({ fetch: fetchImpl })) + expect(records.map(record => [record.window.startMs, record.window.endMs])).toEqual([ + [NOW - 14 * DAY, NOW - 10.5 * DAY], [NOW - 10.5 * DAY, NOW - 7 * DAY], [NOW - 7 * DAY, NOW - 3.5 * DAY], [NOW - 3.5 * DAY, NOW], + ]) + expect(count('vitals')).toBe(2) + + const always413: typeof fetch = async () => Response.json({ error: 'over the limit' }, { status: 413 }) + await expect(pull(options({ fetch: always413, sinceMs: NOW - DAY, untilMs: NOW, minWindowMs: 6 * 60 * 60 * 1000 }))) + .rejects.toThrow(/over the limit.*cannot be halved below 6 h; pull it with --no-raw/) + }) + + it('splits ranges longer than 14 days into chunks, oldest first', async () => { + await pull(options({ sinceMs: NOW - 30 * DAY, includeRaw: false })) + expect(calls.map(call => new URL(call).searchParams.toString())).toEqual([ + `start=${NOW - 30 * DAY}&end=${NOW - 16 * DAY}&raw=0`, `start=${NOW - 16 * DAY}&end=${NOW - 2 * DAY}&raw=0`, `start=${NOW - 2 * DAY}&end=${NOW}&raw=0`, + ]) + }) + + it('lets only one pull into a destination run at a time', async () => { + mkdirSync(dest) + writeFileSync(join(dest, LOCK_FILE), JSON.stringify({ pid: process.pid, host: (await import('node:os')).hostname(), startedAt: 'earlier' })) + await expect(pull(options())).rejects.toBeInstanceOf(ArchiveLockedError) + expect(calls).toEqual([]) + + // A lock left by a process that no longer exists is taken over. + writeFileSync(join(dest, LOCK_FILE), JSON.stringify({ pid: 999_999, host: (await import('node:os')).hostname(), startedAt: 'earlier' })) + await expect(pull(options())).resolves.toHaveLength(1) + + const results = await Promise.allSettled([pull(options({ sinceMs: NOW - DAY })), pull(options({ sinceMs: NOW - DAY }))]) + expect(results.map(result => result.status).sort()).toEqual(['fulfilled', 'rejected']) + expect((results.find(result => result.status === 'rejected') as PromiseRejectedResult).reason).toBeInstanceOf(ArchiveLockedError) + }) + + it('never overwrites an archive name the pod reused for different bytes', async () => { + const first = rawArchive('A0000001.RAW.gz', NOW - 5 * DAY, 'before the counter reset') + await pull(options({ now: () => NOW - 4 * DAY })) + rmSync(join(state.archive, 'A0000001.RAW.gz')) + const second = rawArchive('A0000001.RAW.gz', NOW - DAY, 'after the counter reset') + const [record] = await pull(options()) + const dir = join(dest, podDir()[0]) + const renamed = `A0000001-${second.slice(0, 12)}.RAW.gz` + expect(record.raw.renamed).toEqual([{ name: 'A0000001.RAW.gz', archiveName: renamed, sha256: second }]) + expect(readFileSync(join(dir, 'archive', 'A0000001.RAW.gz'), 'utf8')).toBe('before the counter reset') + expect(readFileSync(join(dir, 'archive', renamed), 'utf8')).toBe('after the counter reset') + expect(archiveDb(db => db.prepare('SELECT sha256, name, archive_name AS archiveName FROM archive_raw_files ORDER BY mtime_ms').all())).toEqual([ + { sha256: first, name: 'A0000001.RAW.gz', archiveName: 'A0000001.RAW.gz' }, + { sha256: second, name: 'A0000001.RAW.gz', archiveName: renamed }, + ]) + }) + + it('validates CLI arguments before contacting the pod', async () => { + await expect(runArchiveCli(['pull', '--dest', dest], { fetch: podFetch })).rejects.toThrow('--pod is required') + await expect(runArchiveCli(['pull', '--pod', 'pod local', '--dest', dest], { fetch: podFetch })).rejects.toThrow('--pod is HOST or HOST:PORT') + await expect(runArchiveCli(['pull', '--pod', POD, '--dest', dest, '--since', 'yesterday'], { fetch: podFetch })).rejects.toThrow('--since must be an ISO 8601 timestamp') + await expect(runArchiveCli(['pull', '--pod', POD, '--dest', dest, '--since', '2026-10-10T00:00:00Z', '--until', '2026-10-01T00:00:00Z'], { fetch: podFetch })) + .rejects.toThrow('--since must be before --until') + await expect(runArchiveCli(['push'])).rejects.toThrow('Usage') + expect(calls).toEqual([]) + }) +}) diff --git a/scripts/biometrics-archive/cli.ts b/scripts/biometrics-archive/cli.ts new file mode 100644 index 000000000..2c3b71d9b --- /dev/null +++ b/scripts/biometrics-archive/cli.ts @@ -0,0 +1,53 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { z } from 'zod' +import { pull, type PullOptions } from './pull' + +const usage = `Usage: + pull --pod HOST[:PORT] --dest DIR [--since ISO] [--until ISO] [--no-raw] +Pulls the pod's analysis export (GET /api/biometrics/analysis-export) into DIR//. +Without --since, continues from the last successful pull (first run: the last 14 days).` + +const instant = (flag: string) => z.string().refine(value => Number.isFinite(Date.parse(value)), `${flag} must be an ISO 8601 timestamp`) + .transform(value => Date.parse(value)) + +const pullArgsSchema = z.object({ + pod: z.string({ error: '--pod is required' }).regex(/^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(:\d{1,5})?$/, '--pod is HOST or HOST:PORT'), + dest: z.string({ error: '--dest is required' }).min(1), + since: instant('--since').optional(), + until: instant('--until').optional(), + noRaw: z.boolean(), +}).refine(args => args.since === undefined || args.until === undefined || args.since < args.until, { message: '--since must be before --until' }) + +function options(args: string[]) { + const values: Record = {} + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === '--no-raw') values.noRaw = true + else if (['--pod', '--dest', '--since', '--until'].includes(arg) && index + 1 < args.length) values[arg.slice(2)] = args[++index] + else throw new Error(`Unknown or incomplete option: ${arg}\n${usage}`) + } + return { noRaw: false, ...values } +} + +export async function runArchiveCli(args: string[], overrides: Partial = {}) { + const [command, ...rest] = args + if (command !== 'pull') throw new Error(usage) + const parsed = pullArgsSchema.safeParse(options(rest)) + if (!parsed.success) throw new Error(`${parsed.error.issues.map(issue => issue.message).join('; ')}\n${usage}`) + const { pod, dest, since, until, noRaw } = parsed.data + const records = await pull({ pod, dest: resolve(dest), sinceMs: since, untilMs: until, includeRaw: !noRaw, + log: line => process.stderr.write(`${line}\n`), ...overrides }) + process.stdout.write(`${JSON.stringify({ pulls: records.length, cursor: records.at(-1)?.cursor.end ?? null, + newRaw: records.reduce((total, record) => total + record.raw.new, 0), + rowsAdded: records.reduce((total, record) => total + record.rows.inserted + record.rows.reassigned, 0), + conflicts: records.reduce((total, record) => total + record.rows.conflicts, 0) })}\n`) + return records +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runArchiveCli(process.argv.slice(2)).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/biometrics-archive/download.ts b/scripts/biometrics-archive/download.ts new file mode 100644 index 000000000..211758af3 --- /dev/null +++ b/scripts/biometrics-archive/download.ts @@ -0,0 +1,235 @@ +import { createHash } from 'node:crypto' +import { createWriteStream } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import type { ReadableStream as WebReadableStream } from 'node:stream/web' +import { z } from 'zod' +import { ANALYSIS_EXPORT_FORMAT, ANALYSIS_EXPORT_PATH } from '@/src/biometrics/analysis-export' +import { parseTarHeader } from '@/src/biometrics/backtest/bundle-tar' + +const DATABASE_ENTRY = 'biometrics.db' +const MANIFEST_ENTRY = 'manifest.json' +const RAW_ENTRY = /^raw\/([A-Za-z0-9_-]+\.RAW\.gz)$/ +const MAX_MANIFEST_BYTES = 16 * 1024 * 1024 + +const sha256 = z.string().regex(/^[0-9a-f]{64}$/) +const size = z.number().int().nonnegative() + +/** manifest.json of analysis-export-v1 (src/biometrics/analysis-export-bundle.ts). */ +export const exportManifestSchema = z.object({ + formatVersion: z.literal(ANALYSIS_EXPORT_FORMAT), + generatedAt: z.string(), + generatedAtMs: size, + build: z.object({ branch: z.string(), commitHash: z.string(), commitTitle: z.string(), buildDate: z.string() }), + pod: z.object({ + id: z.string().regex(/^[0-9a-f]{32}$/).nullable(), + idSource: z.string(), + hostname: z.string().min(1), + model: z.object({ podVersion: z.string(), name: z.string(), hardwareRevision: z.string().nullable() }).nullable(), + }), + schema: z.object({ + biometrics: z.object({ migrationHash: z.string(), migrationCreatedAt: z.number(), migrationTag: z.string().nullable(), appliedMigrations: size }).nullable(), + }), + window: z.object({ + requested: z.object({ startMs: size, endMs: size, start: z.string(), end: z.string() }), + rawSelection: z.object({ includeRaw: z.boolean(), mtimeFromMs: z.number(), mtimeToMs: z.number() }), + served: z.object({ archiveCount: size, rawMtimeMinMs: z.number().nullable(), rawMtimeMaxMs: z.number().nullable() }), + }), + files: z.array(z.discriminatedUnion('role', [ + z.object({ role: z.literal('database'), path: z.literal(DATABASE_ENTRY), bytes: size, sha256 }), + z.object({ role: z.literal('raw'), path: z.string().regex(RAW_ENTRY), name: z.string(), mtimeMs: z.number(), bytes: size, sha256 }), + ])), +}) +export type ExportManifest = z.infer +export type ManifestRawFile = Extract + +export interface ExportWindow { startMs: number, endMs: number, includeRaw: boolean } + +/** A verified export in a staging directory: every file matched the manifest. */ +export interface DownloadedExport { + manifest: ExportManifest + /** manifest.json as served, kept verbatim in the pull record. */ + manifestJson: unknown + databasePath: string + raw: Array +} + +/** A response other than 200; 413 and 429 drive window halving and retry. */ +export class ExportHttpError extends Error { + constructor(readonly status: number, message: string, readonly retryAfterMs: number | null) { + super(message) + } +} + +export class ExportIntegrityError extends Error {} + +export function exportUrl(pod: string, window: ExportWindow) { + return `http://${pod}${ANALYSIS_EXPORT_PATH}?start=${window.startMs}&end=${window.endMs}&raw=${window.includeRaw ? 1 : 0}` +} + +/** Byte-exact reads over a chunked stream. */ +class ByteReader { + private chunks: Buffer[] = [] + private buffered = 0 + private ended = false + total = 0 + constructor(private readonly source: AsyncIterator) {} + + private async fill(bytes: number) { + while (this.buffered < bytes && !this.ended) { + const next = await this.source.next() + if (next.done) this.ended = true + else { + const chunk = Buffer.from(next.value) + this.chunks.push(chunk) + this.buffered += chunk.length + this.total += chunk.length + } + } + } + + private take(bytes: number) { + const first = this.chunks[0] + if (first.length <= bytes) { + this.chunks.shift() + this.buffered -= first.length + return first + } + this.chunks[0] = first.subarray(bytes) + this.buffered -= bytes + return first.subarray(0, bytes) + } + + async read(bytes: number) { + await this.fill(bytes) + if (this.buffered < bytes) throw new ExportIntegrityError('Export stream ended early (truncated tar)') + const parts: Buffer[] = [] + let remaining = bytes + while (remaining > 0) { + const part = this.take(remaining) + parts.push(part) + remaining -= part.length + } + return Buffer.concat(parts, bytes) + } + + async* stream(bytes: number) { + let remaining = bytes + while (remaining > 0) { + if (this.buffered === 0) await this.fill(1) + if (this.buffered === 0) throw new ExportIntegrityError('Export stream ended early (truncated tar)') + const part = this.take(remaining) + remaining -= part.length + yield part + } + } + + async atEnd() { + await this.fill(1) + return this.buffered === 0 + } +} + +async function errorMessage(response: Response) { + const text = await response.text().catch(() => '') + try { + const parsed = JSON.parse(text) as { error?: unknown } + if (typeof parsed.error === 'string') return parsed.error + } + catch { /* not JSON */ } + return text.slice(0, 500) || response.statusText +} + +/** + * Download one export into `staging`, hashing every entry as it streams, then + * check the manifest: the exact file set, sizes, SHA-256s and the requested + * window. Throws before anything leaves `staging`. + */ +export async function downloadExport(fetchImpl: typeof fetch, pod: string, window: ExportWindow, staging: string): Promise { + const response = await fetchImpl(exportUrl(pod, window)).catch((error: unknown) => { + const cause = error instanceof Error && error.cause instanceof Error ? error.cause.message : String(error) + throw new Error(`Could not reach ${pod}: ${cause}`) + }) + if (response.status !== 200) { + const retryAfter = Number(response.headers.get('Retry-After') ?? Number.NaN) + throw new ExportHttpError(response.status, `HTTP ${response.status} from ${pod}: ${await errorMessage(response)}`, + Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter * 1000 : null) + } + if (!response.body) throw new ExportIntegrityError('Export response has no body') + await mkdir(join(staging, 'raw'), { recursive: true }) + const reader = new ByteReader(Readable.fromWeb(response.body as WebReadableStream)[Symbol.asyncIterator]()) + const received = new Map() + let manifestText: string | undefined + for (;;) { + const header = parseTarHeader(await reader.read(512)) + if (!header) break + if (manifestText !== undefined) throw new ExportIntegrityError(`Unexpected entry after manifest.json: ${header.name}`) + if (received.has(header.name)) throw new ExportIntegrityError(`Duplicate export entry: ${header.name}`) + const padding = (512 - (header.bytes % 512)) % 512 + if (header.name === MANIFEST_ENTRY) { + if (header.bytes > MAX_MANIFEST_BYTES) throw new ExportIntegrityError('manifest.json is too large') + manifestText = (await reader.read(header.bytes)).toString('utf8') + } + else { + const raw = RAW_ENTRY.exec(header.name) + if (header.name !== DATABASE_ENTRY && !raw) throw new ExportIntegrityError(`Unexpected export entry: ${header.name}`) + const localPath = raw ? join(staging, 'raw', raw[1]) : join(staging, DATABASE_ENTRY) + const digest = createHash('sha256') + await pipeline(Readable.from((async function* () { + for await (const chunk of reader.stream(header.bytes)) { + digest.update(chunk) + yield chunk + } + })()), createWriteStream(localPath, { flags: 'wx', mode: 0o600 })) + received.set(header.name, { bytes: header.bytes, sha256: digest.digest('hex'), localPath }) + } + await reader.read(padding) + } + while (!(await reader.atEnd())) { + if ((await reader.read(512)).some(byte => byte !== 0)) throw new ExportIntegrityError('Export tar has data after its end marker') + } + const declared = Number(response.headers.get('Content-Length')) + if (Number.isFinite(declared) && declared > 0 && reader.total !== declared) { + throw new ExportIntegrityError(`Export was ${reader.total} bytes, Content-Length said ${declared}`) + } + if (manifestText === undefined) throw new ExportIntegrityError('Export has no manifest.json (download cut short?)') + + let manifestJson: unknown + try { + manifestJson = JSON.parse(manifestText) + } + catch { + throw new ExportIntegrityError('manifest.json is not valid JSON') + } + const parsed = exportManifestSchema.safeParse(manifestJson) + if (!parsed.success) throw new ExportIntegrityError(`manifest.json does not match ${ANALYSIS_EXPORT_FORMAT}: ${parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ')}`) + const manifest = parsed.data + const { requested, rawSelection } = manifest.window + if (requested.startMs !== window.startMs || requested.endMs !== window.endMs || rawSelection.includeRaw !== window.includeRaw) { + throw new ExportIntegrityError(`Pod served window ${requested.startMs}–${requested.endMs} (raw=${rawSelection.includeRaw}), not the one requested`) + } + + const listed = new Set() + for (const file of manifest.files) { + if (listed.has(file.path)) throw new ExportIntegrityError(`manifest.json lists ${file.path} twice`) + listed.add(file.path) + if (file.role === 'raw' && file.path !== `raw/${file.name}`) throw new ExportIntegrityError(`manifest.json raw entry ${file.path} names ${file.name}`) + const got = received.get(file.path) + if (!got) throw new ExportIntegrityError(`Export is missing ${file.path}`) + if (got.bytes !== file.bytes) throw new ExportIntegrityError(`Size mismatch: ${file.path} is ${got.bytes} bytes, manifest says ${file.bytes}`) + if (got.sha256 !== file.sha256) throw new ExportIntegrityError(`SHA-256 mismatch: ${file.path}`) + } + for (const name of received.keys()) if (!listed.has(name)) throw new ExportIntegrityError(`Export entry ${name} is not in manifest.json`) + if (!listed.has(DATABASE_ENTRY)) throw new ExportIntegrityError('Export has no biometrics.db') + + // Every listed file was received (checked above), so the lookups below hit. + const localPath = (path: string) => received.get(path)?.localPath ?? '' + return { + manifest, + manifestJson, + databasePath: localPath(DATABASE_ENTRY), + raw: manifest.files.filter((file): file is ManifestRawFile => file.role === 'raw').map(file => ({ ...file, localPath: localPath(file.path) })), + } +} diff --git a/scripts/biometrics-archive/merge.test.ts b/scripts/biometrics-archive/merge.test.ts new file mode 100644 index 000000000..346538fa0 --- /dev/null +++ b/scripts/biometrics-archive/merge.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node +import { randomBytes } from 'node:crypto' +import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { ExportManifest } from './download' +import { checkSnapshotSchema, mergeSnapshot, migrationJournal, MIGRATIONS_FOLDER, openArchiveDatabase, TABLES } from './merge' + +let root: string +let archive: Database.Database +const open: Database.Database[] = [] +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'biometrics-archive-merge-')) + archive = openArchiveDatabase(join(root, 'archive.db')) +}) +afterEach(() => { + archive.close() + for (const db of open.splice(0)) db.close() + rmSync(root, { recursive: true, force: true }) +}) + +const journal = migrationJournal() +let snapshots = 0 +function snapshot(fill: (db: Database.Database) => void, migrationsFolder = MIGRATIONS_FOLDER) { + const path = join(root, `snapshot-${++snapshots}.db`) + const db = new Database(path) + migrate(drizzle(db), { migrationsFolder }) + fill(db) + db.close() + return path +} +function manifest(generatedAtMs: number, entry = journal[journal.length - 1]) { + return { + generatedAtMs, generatedAt: new Date(generatedAtMs).toISOString(), + files: [{ role: 'database', path: 'biometrics.db', bytes: 0, sha256: randomBytes(32).toString('hex') }], + schema: { biometrics: { migrationHash: 'h', migrationCreatedAt: entry.when, migrationTag: entry.tag, appliedMigrations: 1 } }, + } as unknown as ExportManifest +} +const merge = (path: string, generatedAtMs: number, entry?: (typeof journal)[number]) => + mergeSnapshot(archive, path, manifest(generatedAtMs, entry), { pullKey: `pod/pulls/${generatedAtMs}.json`, nowMs: generatedAtMs, occupiedArchiveNames: new Set() }) +const rows = (sql: string) => archive.prepare(sql).all() +function vitals(db: Database.Database, side: string, timestamps: number[], heartRate = 60) { + for (const ts of timestamps) { + const id = db.prepare('INSERT INTO vitals (side, timestamp, heart_rate) VALUES (?, ?, ?)').run(side, ts, heartRate).lastInsertRowid + db.prepare('INSERT INTO vitals_quality (vitals_id, side, timestamp, quality_score, created_at) VALUES (?, ?, ?, 0.9, ?)').run(id, side, ts, ts) + } +} + +it('has a merge rule, with a unique key in the archive, for every table the migrations create', () => { + const tables = (rows('SELECT name FROM sqlite_master WHERE type = \'table\' AND name NOT LIKE \'sqlite_%\' AND name NOT LIKE \'archive_%\' AND name != \'__drizzle_migrations\'') as Array<{ name: string }>) + .map(row => row.name).sort() + expect(TABLES.map(spec => spec.table).sort()).toEqual(tables) + const indexed = rows('SELECT name FROM sqlite_master WHERE type = \'index\' AND name LIKE \'archive_uq_%\'') as Array<{ name: string }> + expect(indexed.map(row => row.name).sort()).toEqual(['calibration_runs', 'flash_write_samples', 'health_runs', 'movement', 'prime_events', 'pump_alerts', + 'sleep_records', 'thermal_state', 'vitals_quality', 'water_level_alerts'].map(name => `archive_uq_${name}`)) +}) + +it('gives rows new ids after a pod database reset and keeps vitals_quality pointing at its vitals row', () => { + merge(snapshot(db => vitals(db, 'left', [100, 160, 220])), 1) + const result = merge(snapshot(db => vitals(db, 'left', [10_000, 10_060])), 2) + expect(result.tables.vitals).toMatchObject({ snapshot: 2, inserted: 0, reassigned: 2, conflicts: 0 }) + expect(rows('SELECT v.timestamp, q.timestamp AS qualityTimestamp FROM vitals_quality q JOIN vitals v ON v.id = q.vitals_id ORDER BY v.timestamp')) + .toEqual([100, 160, 220, 10_000, 10_060].map(ts => ({ timestamp: ts, qualityTimestamp: ts }))) +}) + +it('keeps the first copy of an append-only row and records a changed copy once', () => { + merge(snapshot(db => vitals(db, 'left', [100], 60)), 1) + const changed = snapshot(db => vitals(db, 'left', [100], 72)) + expect(merge(changed, 2).tables.vitals).toMatchObject({ inserted: 0, ignored: 1, conflicts: 1 }) + expect(merge(changed, 3).tables.vitals).toMatchObject({ conflicts: 0 }) + expect(rows('SELECT heart_rate AS hr FROM vitals')).toEqual([{ hr: 60 }]) + const [conflict] = rows('SELECT table_name, reason, key, archive_row, snapshot_row FROM archive_conflicts') as Array> + expect(conflict).toMatchObject({ table_name: 'vitals', reason: 'changed', key: '{"side":"left","timestamp":100}' }) + expect(JSON.parse(conflict.archive_row).heart_rate).toBe(60) + expect(JSON.parse(conflict.snapshot_row).heart_rate).toBe(72) +}) + +it('keeps calibration runs the pod logged in the same second as separate rows', () => { + const runs = (db: Database.Database) => { + const insert = db.prepare(`INSERT INTO calibration_runs (side, sensor_type, status, error_message, duration_ms, triggered_by, created_at) + VALUES ('left', 'piezo', 'failed', 'No piezo records available', ?, ?, 1777925137)`) + insert.run(4, 'startup') + insert.run(6, 'daily') + } + const path = snapshot(runs) + expect(merge(path, 1).tables.calibration_runs).toMatchObject({ inserted: 2, conflicts: 0 }) + expect(merge(path, 2).tables.calibration_runs).toMatchObject({ inserted: 0, ignored: 2, conflicts: 0 }) +}) + +it('takes the newest snapshot\'s values for state tables, but keeps a released dataset\'s content', () => { + const dataset = (db: Database.Database, snapshotText: string, releasedAtMs: number | null) => db.prepare(`INSERT INTO biometric_datasets + (id, side, subject_id, idempotency_key, request_hash, content_hash, manifest, snapshot, byte_length, created_at_ms, released_at_ms) + VALUES ('ds-1', 'left', 'me', 'k', 'r', 'c', '{}', ?, ?, 1, ?)`).run(snapshotText, snapshotText.length, releasedAtMs) + const health = (db: Database.Database, lastSeenAt: number) => db.prepare('INSERT INTO health_runs (check_id, status, started_at, last_seen_at) VALUES (\'disk\', \'ok\', 10, ?)').run(lastSeenAt) + merge(snapshot((db) => { + dataset(db, '{"nights":[1,2]}', null) + health(db, 20) + }), 1000) + const released = snapshot((db) => { + dataset(db, '{"released":true}', 5000) + health(db, 90) + }) + const older = snapshot(db => health(db, 50)) + expect(merge(released, 2000).tables.health_runs).toMatchObject({ updated: 1 }) + expect(merge(older, 1500).tables.health_runs).toMatchObject({ updated: 0, conflicts: 0 }) + expect(rows('SELECT last_seen_at AS lastSeenAt FROM health_runs')).toEqual([{ lastSeenAt: 90 }]) + expect(rows('SELECT snapshot, byte_length AS bytes, released_at_ms AS releasedAtMs FROM biometric_datasets')) + .toEqual([{ snapshot: '{"nights":[1,2]}', bytes: 16, releasedAtMs: 5000 }]) +}) + +it('merges a snapshot from an older schema and refuses one newer than the checkout', () => { + const folder = join(root, 'migrations') + cpSync(MIGRATIONS_FOLDER, folder, { recursive: true }) + const old = journal[19] + writeFileSync(join(folder, 'meta', '_journal.json'), JSON.stringify({ version: '7', dialect: 'sqlite', entries: journal.slice(0, 20) })) + const path = snapshot(db => vitals(db, 'right', [500, 560]), folder) + expect(checkSnapshotSchema(manifest(1, old).schema.biometrics)).toMatchObject({ known: true }) + const result = merge(path, 1, old) + expect(result.tables.vitals).toMatchObject({ inserted: 2 }) + expect(result.tables.biometric_datasets).toBeUndefined() + expect(rows('SELECT side, processing_run_id AS run FROM vitals')).toEqual([{ side: 'right', run: null }, { side: 'right', run: null }]) + + const latest = journal[journal.length - 1] + expect(() => checkSnapshotSchema({ migrationHash: 'h', migrationCreatedAt: latest.when + 1, migrationTag: '0099_future', appliedMigrations: 99 })) + .toThrow('The pod\'s biometrics schema (0099_future) is newer than this checkout\'s') +}) diff --git a/scripts/biometrics-archive/merge.ts b/scripts/biometrics-archive/merge.ts new file mode 100644 index 000000000..8170d6416 --- /dev/null +++ b/scripts/biometrics-archive/merge.ts @@ -0,0 +1,256 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import type { ExportManifest, ManifestRawFile } from './download' + +export const MIGRATIONS_FOLDER = fileURLToPath(new URL('../../src/db/biometrics-migrations', import.meta.url)) + +/** + * How a pod table merges into the archive. `key` is the natural key, backed by + * a unique index (the archive adds `archive_uq_
` where the pod has none). + * - append: rows are written once. A key match with other content is recorded + * in archive_conflicts and the archive keeps its row. + * - state: the pod updates rows in place. The newest snapshot's non-key values + * win, except `keep` columns, which keep what the archive saw first. + * Integer `id` surrogates keep the pod's id when it is free; a row whose id is + * taken by a different key (the pod's database was reset) gets a new id. + */ +export interface TableSpec { + table: string + key: string[] + mode: 'append' | 'state' + keep?: string[] + /** SQL for a column's value, over the snapshot row `s`. */ + remap?: Record +} + +export const TABLES: TableSpec[] = [ + { table: 'vitals', key: ['side', 'timestamp'], mode: 'append' }, + // vitals_id follows the vitals row to its archive id. + { table: 'vitals_quality', key: ['side', 'timestamp'], mode: 'append', remap: { + 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' }, + { table: 'freezer_temp', key: ['timestamp'], mode: 'append' }, + { table: 'ambient_light', key: ['timestamp'], mode: 'append' }, + { table: 'water_level_readings', key: ['timestamp'], mode: 'append' }, + { table: 'flow_readings', key: ['timestamp'], mode: 'append' }, + { table: 'thermal_state', key: ['side', 'timestamp', 'is_powered'], mode: 'append' }, + { table: 'prime_events', key: ['timestamp'], mode: 'append' }, + { table: 'flash_write_samples', key: ['boot_id', 'timestamp'], mode: 'append' }, + { table: 'water_level_alerts', key: ['type', 'started_at'], mode: 'state' }, + { table: 'pump_alerts', key: ['timestamp', 'type', 'side'], mode: 'state' }, + { table: 'health_runs', key: ['check_id', 'started_at'], mode: 'state' }, + { table: 'calibration_profiles', key: ['side', 'sensor_type'], mode: 'state' }, + // A retried calibration can log several runs in the same second; trigger and duration tell them apart. + { table: 'calibration_runs', key: ['side', 'sensor_type', 'created_at', 'triggered_by', 'duration_ms'], mode: 'append' }, + { table: 'reference_imports', key: ['id'], mode: 'state' }, + { table: 'reference_measurements', key: ['import_id', 'record_key'], mode: 'append' }, + { table: 'reference_stages', key: ['side', 'start', 'source'], mode: 'state' }, + { table: 'reference_samples', key: ['side', 'metric', 'timestamp', 'source'], mode: 'state' }, + { table: 'reference_vitals', key: ['side', 'timestamp', 'source'], mode: 'state' }, + { table: 'biometric_processing_runs', key: ['id'], mode: 'state' }, + { table: 'biometric_runtime_configs', key: ['side', 'generation'], mode: 'append' }, + { table: 'biometric_runtime_receipts', key: ['side', 'component', 'generation'], mode: 'state' }, + // Releasing a dataset on the pod replaces its snapshot with a tombstone; the archive keeps the content. + { table: 'biometric_datasets', key: ['id'], mode: 'state', keep: ['snapshot', 'byte_length'] }, + { table: 'biometric_experiments', key: ['id'], mode: 'state' }, + { table: 'biometric_backtest_jobs', key: ['id'], mode: 'state' }, + { table: 'biometric_backtest_results', key: ['job_id'], mode: 'state' }, + { table: 'biometric_holdout_accesses', key: ['id'], mode: 'append' }, + { table: 'biometric_raw_artifacts', key: ['content_hash'], mode: 'state' }, + { table: 'biometric_dataset_raw_artifacts', key: ['id'], mode: 'state' }, + { table: 'biometric_raw_sources', key: ['id'], mode: 'state' }, + { table: 'biometric_shadow_runs', key: ['id'], mode: 'state' }, + { table: 'biometric_shadow_samples', key: ['run_id', 'sequence'], mode: 'append' }, +] + +const INTERNAL_TABLE = /^(sqlite_|__drizzle_migrations$|archive_)/ + +const ARCHIVE_TABLES = ` +CREATE TABLE IF NOT EXISTS archive_snapshots ( + sha256 TEXT PRIMARY KEY NOT NULL, generated_at_ms INTEGER NOT NULL, migration_tag TEXT, + migration_created_at INTEGER, pull_key TEXT NOT NULL, merged_at_ms INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS archive_raw_files ( + sha256 TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, archive_name TEXT NOT NULL UNIQUE, + mtime_ms INTEGER NOT NULL, bytes INTEGER NOT NULL, pull_key TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS archive_conflicts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, table_name TEXT NOT NULL, reason TEXT NOT NULL, key TEXT NOT NULL, + archive_row TEXT, snapshot_row TEXT NOT NULL, pull_key TEXT NOT NULL, UNIQUE (table_name, key, snapshot_row));` + +export interface TableCounts { snapshot: number, inserted: number, reassigned: number, ignored: number, updated: number, conflicts: number } +export interface ArchiveName { sha256: string, name: string, archiveName: string, renamed: boolean } +export interface MergeResult { tables: Record, archiveNames: ArchiveName[], warnings: string[] } + +const quote = (name: string) => `"${name.replaceAll('"', '""')}"` + +interface Column { name: string, type: string, pk: number } + +function columns(db: Database.Database, schema: 'main' | 'snap', table: string) { + return db.prepare('SELECT name, type, pk FROM pragma_table_info(?, ?)').all(table, schema) as Column[] +} + +function tables(db: Database.Database, schema: 'main' | 'snap') { + return (db.prepare(`SELECT name FROM ${schema}.sqlite_master WHERE type = 'table'`).all() as Array<{ name: string }>) + .map(row => row.name).filter(name => !INTERNAL_TABLE.test(name)) +} + +function hasUniqueKey(db: Database.Database, table: string, key: string[]) { + const pk = columns(db, 'main', table).filter(column => column.pk > 0).map(column => column.name) + if (pk.length === key.length && pk.every(name => key.includes(name))) return true + const indexes = db.prepare('SELECT name, "unique" AS isUnique, partial FROM pragma_index_list(?)').all(table) as Array<{ name: string, isUnique: number, partial: number }> + return indexes.some((index) => { + if (!index.isUnique || index.partial) return false + const indexed = (db.prepare('SELECT name FROM pragma_index_info(?)').all(index.name) as Array<{ name: string }>).map(row => row.name) + return indexed.length === key.length && indexed.every(name => key.includes(name)) + }) +} + +interface JournalEntry { when: number, tag: string } +export function migrationJournal(folder = MIGRATIONS_FOLDER): JournalEntry[] { + return (JSON.parse(readFileSync(join(folder, 'meta', '_journal.json'), 'utf8')) as { entries: JournalEntry[] }).entries +} + +/** + * The archive database is migrated to this checkout's newest migration, which + * must be at least the snapshot's. An older snapshot still merges: columns it + * lacks take their defaults. + */ +export function checkSnapshotSchema(schema: ExportManifest['schema']['biometrics'], journal = migrationJournal()) { + if (!schema) throw new Error('The snapshot has no applied biometrics migrations; refusing to merge it') + const latest = journal[journal.length - 1] + if (schema.migrationCreatedAt > latest.when) { + throw new Error(`The pod's biometrics schema (${schema.migrationTag ?? schema.migrationCreatedAt}) is newer than this checkout's (${latest.tag}). Update the checkout, then pull again.`) + } + return { known: journal.some(entry => entry.when === schema.migrationCreatedAt), checkoutTag: latest.tag } +} + +/** Open (or create) the archive database: migrate it, then add the merge's unique keys and bookkeeping tables. */ +export function openArchiveDatabase(path: string, migrationsFolder = MIGRATIONS_FOLDER) { + const db = new Database(path) + // A single file, so the archive reads from read-only and network mounts without -shm. + db.pragma('journal_mode = DELETE') + db.pragma('busy_timeout = 5000') + migrate(drizzle(db), { migrationsFolder }) + // Snapshots are internally consistent and the archive never deletes, so + // table order can't break references; enforcement would only block merges. + db.pragma('foreign_keys = OFF') + db.exec(ARCHIVE_TABLES) + const existing = new Set(tables(db, 'main')) + for (const spec of TABLES) { + if (!existing.has(spec.table) || hasUniqueKey(db, spec.table, spec.key)) continue + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${quote(`archive_uq_${spec.table}`)} ON ${quote(spec.table)} (${spec.key.map(quote).join(', ')})`) + } + return db +} + +function mergeTable(db: Database.Database, spec: TableSpec, newest: boolean, pullKey: string): TableCounts { + const table = quote(spec.table) + const archiveColumns = columns(db, 'main', spec.table) + const snapshotNames = new Set(columns(db, 'snap', spec.table).map(column => column.name)) + const common = archiveColumns.map(column => column.name).filter(name => snapshotNames.has(name)) + const missingKey = spec.key.filter(name => !common.includes(name)) + if (missingKey.length) throw new Error(`${spec.table}: key column(s) ${missingKey.join(', ')} missing from the snapshot or archive`) + const surrogate = !spec.key.includes('id') && archiveColumns.some(column => column.name === 'id' && column.pk === 1 && column.type.toUpperCase() === 'INTEGER') + const value = (name: string) => spec.remap?.[name] ?? `s.${quote(name)}` + const keyMatch = spec.key.map(name => `m.${quote(name)} IS s.${quote(name)}`).join(' AND ') + const json = (alias: string, names: string[]) => `json_object(${names.map(name => `'${name}', ${alias}.${quote(name)}`).join(', ')})` + const counts: TableCounts = { snapshot: 0, inserted: 0, reassigned: 0, ignored: 0, updated: 0, conflicts: 0 } + counts.snapshot = (db.prepare(`SELECT COUNT(*) AS n FROM snap.${table}`).get() as { n: number }).n + + const insert = (names: string[], where = '') => + db.prepare(`INSERT OR IGNORE INTO main.${table} (${names.map(quote).join(', ')}) SELECT ${names.map(value).join(', ')} FROM snap.${table} s ${where}`).run().changes + counts.inserted = insert(common) + if (surrogate) counts.reassigned = insert(common.filter(name => name !== 'id'), `WHERE NOT EXISTS (SELECT 1 FROM main.${table} m WHERE ${keyMatch})`) + + const recordConflicts = (reason: string, archiveRow: string, from: string, where: string) => + db.prepare(`INSERT OR IGNORE INTO archive_conflicts (table_name, reason, key, archive_row, snapshot_row, pull_key) + SELECT ?, ?, ${json('s', spec.key)}, ${archiveRow}, ${json('s', common)}, ? FROM ${from} WHERE ${where}`).run(spec.table, reason, pullKey).changes + // A row the archive's constraints refused (for example a second active reference + // import with the same content after a pod reset) is kept aside, not lost. + counts.conflicts += recordConflicts('rejected', 'NULL', `snap.${table} s`, `NOT EXISTS (SELECT 1 FROM main.${table} m WHERE ${keyMatch})`) + + const compared = common.filter(name => !spec.key.includes(name) && !(surrogate && name === 'id') && !spec.remap?.[name] && !spec.keep?.includes(name)) + if (compared.length) { + const differs = `NOT (${compared.map(name => `m.${quote(name)} IS s.${quote(name)}`).join(' AND ')})` + if (spec.mode === 'append') { + counts.conflicts += recordConflicts('changed', json('m', common), `snap.${table} s JOIN main.${table} m ON ${keyMatch}`, differs) + } + else if (newest) { + counts.updated = db.prepare(`UPDATE main.${table} AS m SET ${compared.map(name => `${quote(name)} = s.${quote(name)}`).join(', ')} + FROM snap.${table} AS s WHERE ${keyMatch} AND ${differs}`).run().changes + } + } + counts.ignored = counts.snapshot - counts.inserted - counts.reassigned + return counts +} + +function assignArchiveNames(db: Database.Database, raw: readonly ManifestRawFile[], pullKey: string, occupied: ReadonlySet): ArchiveName[] { + const known = db.prepare('SELECT archive_name AS archiveName FROM archive_raw_files WHERE sha256 = ?') + const taken = db.prepare('SELECT 1 FROM archive_raw_files WHERE archive_name = ?') + const add = db.prepare('INSERT INTO archive_raw_files (sha256, name, archive_name, mtime_ms, bytes, pull_key) VALUES (?, ?, ?, ?, ?, ?)') + return raw.map((file) => { + const existing = known.get(file.sha256) as { archiveName: string } | undefined + if (existing) return { sha256: file.sha256, name: file.name, archiveName: existing.archiveName, renamed: existing.archiveName !== file.name } + // The pod reused a name for different bytes (counter reset, reinstall), or a + // file the archive didn't write sits there: never overwrite. + const renamed = Boolean(taken.get(file.name)) || occupied.has(file.name) + const archiveName = renamed ? file.name.replace(/\.RAW\.gz$/, `-${file.sha256.slice(0, 12)}.RAW.gz`) : file.name + add.run(file.sha256, file.name, archiveName, Math.floor(file.mtimeMs), file.bytes, pullKey) + return { sha256: file.sha256, name: file.name, archiveName, renamed } + }) +} + +/** + * Merge one verified snapshot into the archive database in a single + * transaction, together with the RAW name index and the snapshot record. + * Nothing is ever deleted: rows the pod has since pruned stay. + */ +export function mergeSnapshot(db: Database.Database, snapshotPath: string, manifest: ExportManifest, + options: { pullKey: string, nowMs: number, occupiedArchiveNames: ReadonlySet }): MergeResult { + const { pullKey, nowMs } = options + db.prepare('ATTACH DATABASE ? AS snap').run(snapshotPath) + try { + return db.transaction(() => { + const warnings: string[] = [] + const latest = (db.prepare('SELECT MAX(generated_at_ms) AS ms FROM archive_snapshots').get() as { ms: number | null }).ms + const newest = latest === null || manifest.generatedAtMs >= latest + if (!newest) warnings.push(`Snapshot from ${manifest.generatedAt} is older than one already merged; state tables keep their newer values`) + const archiveTables = new Set(tables(db, 'main')) + const snapshotTables = tables(db, 'snap') + const classified = new Set(TABLES.map(spec => spec.table)) + const specs = [...TABLES.filter(spec => snapshotTables.includes(spec.table))] + for (const name of snapshotTables.filter(table => !classified.has(table)).sort()) { + if (!archiveTables.has(name)) { + warnings.push(`Table ${name} is not in this checkout's schema; skipped`) + continue + } + const pk = columns(db, 'main', name).filter(column => column.pk > 0).sort((a, b) => a.pk - b.pk).map(column => column.name) + if (!pk.length) { + warnings.push(`Table ${name} has no merge rule or primary key; skipped`) + continue + } + warnings.push(`Table ${name} has no merge rule; merged on its primary key (${pk.join(', ')})`) + specs.push({ table: name, key: pk, mode: 'append' }) + } + const tableCounts: Record = {} + for (const spec of specs) tableCounts[spec.table] = mergeTable(db, spec, newest, pullKey) + const archiveNames = assignArchiveNames(db, manifest.files.filter((file): file is ManifestRawFile => file.role === 'raw'), pullKey, options.occupiedArchiveNames) + const database = manifest.files.find(file => file.role === 'database') + const schema = manifest.schema.biometrics + db.prepare('INSERT OR IGNORE INTO archive_snapshots (sha256, generated_at_ms, migration_tag, migration_created_at, pull_key, merged_at_ms) VALUES (?, ?, ?, ?, ?, ?)') + .run(database?.sha256 ?? '', manifest.generatedAtMs, schema?.migrationTag ?? null, schema?.migrationCreatedAt ?? null, pullKey, nowMs) + return { tables: tableCounts, archiveNames, warnings } + })() + } + finally { + db.prepare('DETACH DATABASE snap').run() + } +} diff --git a/scripts/biometrics-archive/pull.ts b/scripts/biometrics-archive/pull.ts new file mode 100644 index 000000000..ec8e9a365 --- /dev/null +++ b/scripts/biometrics-archive/pull.ts @@ -0,0 +1,284 @@ +import { randomBytes } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { hostname } from 'node:os' +import { join } from 'node:path' +import { z } from 'zod' +import { MAX_ANALYSIS_WINDOW_MS } from '@/src/biometrics/analysis-export' +import { downloadExport, ExportHttpError, type DownloadedExport, type ExportWindow } from './download' +import { checkSnapshotSchema, mergeSnapshot, openArchiveDatabase, type TableCounts } from './merge' +import { LocalStore, putBuffer, readText, type Store } from './store' + +export const PULL_RECORD_FORMAT = 'biometrics-archive-pull-v1' +export const LOCK_FILE = '.biometrics-archive.lock' +const STAGING_DIR = '.staging' +const HOUR_MS = 60 * 60 * 1000 + +export interface PullOptions { + /** host[:port]; port defaults to 3000. */ + pod: string + dest: string + sinceMs?: number + untilMs?: number + includeRaw: boolean + fetch?: typeof fetch + sleep?: (ms: number) => Promise + now?: () => number + log?: (line: string) => void + /** 429 handling: attempts per window and exponential backoff bounds. */ + retry?: { attempts: number, baseDelayMs: number, maxDelayMs: number } + /** 413 halves the window down to this length, then fails. */ + minWindowMs?: number +} + +export interface PullRecord { + formatVersion: typeof PULL_RECORD_FORMAT + pulledAt: string + podHost: string + podDir: string + podId: string | null + window: ExportWindow + /** The served (requested) window end; the next default pull starts here. */ + cursor: { endMs: number, end: string } + raw: { new: number, skipped: number, linked: number, copied: number, renamed: Array<{ name: string, archiveName: string, sha256: string }> } + tables: Record + rows: { inserted: number, reassigned: number, ignored: number, updated: number, conflicts: number } + schema: { snapshotTag: string | null, knownToCheckout: boolean, checkoutTag: string } + warnings: string[] + manifest: unknown +} + +export class ArchiveLockedError extends Error {} + +const pullRecordCursorSchema = z.object({ + formatVersion: z.literal(PULL_RECORD_FORMAT), + podHost: z.string(), + cursor: z.object({ endMs: z.number().int().nonnegative() }), + // Records written before the mode was stored always included RAW. + window: z.object({ includeRaw: z.boolean().default(true) }).default({ includeRaw: true }), +}) + +export function normalizePod(pod: string) { + return /:\d+$/.test(pod) ? pod : `${pod}:3000` +} + +/** Directory name for a pod: its stable id, else its hostname made path-safe. */ +export function podDirectory(pod: DownloadedExport['manifest']['pod']) { + return pod.id ?? (pod.hostname.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '') || 'unknown-pod') +} + +function isAlive(pid: number) { + try { + process.kill(pid, 0) + return true + } + catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +/** One pull per destination. A lock left by a dead process on this host is taken over. */ +export async function acquireLock(dest: string) { + const path = join(dest, LOCK_FILE) + const body = JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date().toISOString() }) + for (let attempt = 0; ; attempt++) { + try { + await writeFile(path, body, { flag: 'wx', mode: 0o600 }) + return async () => { + await rm(path, { force: true }) + } + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + let holder: { pid?: unknown, host?: unknown, startedAt?: unknown } = {} + try { + holder = JSON.parse(await readFile(path, 'utf8')) as typeof holder + } + catch { /* unreadable: treat as held */ } + const stale = holder.host === hostname() && typeof holder.pid === 'number' && !isAlive(holder.pid) + if (!stale || attempt > 0) { + throw new ArchiveLockedError(`Another pull into ${dest} is running (pid ${String(holder.pid)} on ${String(holder.host)} since ${String(holder.startedAt)}). If none is, delete ${path}.`) + } + await rm(path, { force: true }) + } + } +} + +/** + * The next default start: the largest served end among this pod's successful + * pulls, so an explicit backfill of an older range never moves it back. Pods + * are matched by --pod; if none match and the destination holds exactly one + * pod with pulls, that pod's cursor is used. A pull that includes RAW only + * counts earlier RAW pulls, so a --no-raw pull never skips archives it did not + * fetch; a database-only pull counts every record. + */ +export async function findCursor(store: Store, dest: string, podHost: string, log: (line: string) => void, includeRaw = true) { + const directories = (await readdir(dest, { withFileTypes: true }).catch(() => [])) + .filter(entry => entry.isDirectory() && !entry.name.startsWith('.')).map(entry => entry.name) + const perPod = new Map() + for (const directory of directories) { + const cursors = { matched: null as number | null, any: null as number | null } + for (const key of await store.list(`${directory}/pulls/`)) { + if (!key.endsWith('.json')) continue + const parsed = pullRecordCursorSchema.safeParse(await readText(store, key).then(text => JSON.parse(text) as unknown).catch(() => null)) + if (!parsed.success || (includeRaw && !parsed.data.window.includeRaw)) continue + const end = parsed.data.cursor.endMs + cursors.any = Math.max(cursors.any ?? end, end) + if (parsed.data.podHost === podHost) cursors.matched = Math.max(cursors.matched ?? end, end) + } + if (cursors.any !== null) perPod.set(directory, cursors) + } + const matched = [...perPod.values()].map(cursor => cursor.matched).filter(end => end !== null) + if (matched.length) return Math.max(...matched) + if (perPod.size === 1) { + const [[directory, cursor]] = [...perPod] + log(`No earlier pull from ${podHost}; continuing from the only pod in ${dest} (${directory})`) + return cursor.any + } + return null +} + +export function planWindows(sinceMs: number, untilMs: number) { + const windows: Array<{ startMs: number, endMs: number }> = [] + for (let start = sinceMs; start < untilMs; start += MAX_ANALYSIS_WINDOW_MS) windows.push({ startMs: start, endMs: Math.min(start + MAX_ANALYSIS_WINDOW_MS, untilMs) }) + return windows +} + +const sum = (counts: Record, field: keyof TableCounts) => Object.values(counts).reduce((total, row) => total + row[field], 0) + +/** Store one verified export: RAW blobs, the merged database, archive/ links, then the pull record (the cursor). */ +async function commit(store: Store, dest: string, podHost: string, download: DownloadedExport, window: ExportWindow, nowMs: number, log: (line: string) => void): Promise { + const { manifest } = download + const warnings: string[] = [] + const podDir = podDirectory(manifest.pod) + if (!manifest.pod.id) { + warnings.push(`Pod id is null (no hardware seed); storing under hostname ${podDir}`) + log(`warning: ${warnings[0]}`) + } + const schema = checkSnapshotSchema(manifest.schema.biometrics) + if (!schema.known) warnings.push(`Snapshot migration ${manifest.schema.biometrics?.migrationTag ?? manifest.schema.biometrics?.migrationCreatedAt} is not in this checkout's journal; merged on common columns`) + + const stamp = new Date(nowMs).toISOString().replace(/:/g, '-') + let pullKey = `${podDir}/pulls/${stamp}.json` + for (let suffix = 1; await store.exists(pullKey); suffix++) pullKey = `${podDir}/pulls/${stamp}-${suffix}.json` + + const raw: PullRecord['raw'] = { new: 0, skipped: 0, linked: 0, copied: 0, renamed: [] } + for (const file of download.raw) { + const key = `${podDir}/raw/${file.sha256}.RAW.gz` + if (await store.exists(key)) raw.skipped++ + else { + await store.put(key, createReadStream(file.localPath), { sha256: file.sha256, size: file.bytes, mtimeMs: file.mtimeMs }) + raw.new++ + } + } + + await mkdir(join(dest, podDir), { recursive: true }) + const occupied = new Set() + for (const file of download.raw) if (await store.exists(`${podDir}/archive/${file.name}`)) occupied.add(file.name) + const db = openArchiveDatabase(join(dest, podDir, 'biometrics.db')) + let merged + try { + merged = mergeSnapshot(db, download.databasePath, manifest, { pullKey, nowMs, occupiedArchiveNames: occupied }) + } + finally { + db.close() + } + warnings.push(...merged.warnings) + + for (const name of merged.archiveNames) { + const archiveKey = `${podDir}/archive/${name.archiveName}` + if (name.renamed) raw.renamed.push({ name: name.name, archiveName: name.archiveName, sha256: name.sha256 }) + if (await store.exists(archiveKey)) continue + raw[await store.link(`${podDir}/raw/${name.sha256}.RAW.gz`, archiveKey) === 'linked' ? 'linked' : 'copied']++ + } + + const record: PullRecord = { + formatVersion: PULL_RECORD_FORMAT, + pulledAt: new Date(nowMs).toISOString(), + podHost, + podDir, + podId: manifest.pod.id, + window, + cursor: { endMs: manifest.window.requested.endMs, end: manifest.window.requested.end }, + raw, + tables: merged.tables, + rows: { inserted: sum(merged.tables, 'inserted'), reassigned: sum(merged.tables, 'reassigned'), ignored: sum(merged.tables, 'ignored'), + updated: sum(merged.tables, 'updated'), conflicts: sum(merged.tables, 'conflicts') }, + schema: { snapshotTag: manifest.schema.biometrics?.migrationTag ?? null, knownToCheckout: schema.known, checkoutTag: schema.checkoutTag }, + warnings, + manifest: download.manifestJson, + } + await putBuffer(store, pullKey, Buffer.from(`${JSON.stringify(record, null, 2)}\n`)) + return record +} + +/** + * Pull [since, until] from the pod in ≤14-day windows, oldest first. Each + * window is downloaded and verified in staging before anything is stored; a + * failed window leaves the store and the cursor as they were. + */ +export async function pull(options: PullOptions): Promise { + const fetchImpl = options.fetch ?? fetch + const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) + const now = options.now ?? Date.now + const log = options.log ?? (() => {}) + const retry = options.retry ?? { attempts: 6, baseDelayMs: 30_000, maxDelayMs: 5 * 60_000 } + const minWindowMs = options.minWindowMs ?? HOUR_MS + const podHost = normalizePod(options.pod) + + await mkdir(options.dest, { recursive: true }) + const release = await acquireLock(options.dest) + const records: PullRecord[] = [] + try { + await rm(join(options.dest, STAGING_DIR), { recursive: true, force: true }) + const store = new LocalStore(options.dest) + const untilMs = options.untilMs ?? now() + const sinceMs = options.sinceMs ?? (await findCursor(store, options.dest, podHost, log, options.includeRaw)) ?? untilMs - MAX_ANALYSIS_WINDOW_MS + if (sinceMs >= untilMs) { + log(`Nothing to pull: ${new Date(sinceMs).toISOString()} is not before ${new Date(untilMs).toISOString()}`) + return records + } + const pending = planWindows(sinceMs, untilMs).reverse() + for (let span = pending.pop(); span; span = pending.pop()) { + const window: ExportWindow = { ...span, includeRaw: options.includeRaw } + const label = `${new Date(span.startMs).toISOString()} → ${new Date(span.endMs).toISOString()}` + const staging = join(options.dest, STAGING_DIR, `${process.pid}-${randomBytes(4).toString('hex')}`) + try { + let download: DownloadedExport | undefined + for (let attempt = 1; !download; attempt++) { + try { + download = await downloadExport(fetchImpl, podHost, window, staging) + } + catch (error) { + if (!(error instanceof ExportHttpError) || error.status !== 429 || attempt >= retry.attempts) throw error + const delay = Math.min(retry.maxDelayMs, error.retryAfterMs ?? retry.baseDelayMs * 2 ** (attempt - 1)) + log(`${label}: pod busy with another export (429), retrying in ${Math.round(delay / 1000)} s`) + await rm(staging, { recursive: true, force: true }) + await sleep(delay) + } + } + const record = await commit(store, options.dest, podHost, download, window, now(), log) + records.push(record) + log(`${label}: ${record.raw.new} new RAW (${record.raw.skipped} already stored), ${record.rows.inserted + record.rows.reassigned} rows added, ` + + `${record.rows.updated} updated, ${record.rows.conflicts} conflicts → ${record.podDir}/pulls/`) + } + catch (error) { + if (!(error instanceof ExportHttpError) || error.status !== 413) throw error + const half = Math.floor((span.endMs - span.startMs) / 2) + if (half < minWindowMs) { + throw new Error(`${error.message}. The window ${label} cannot be halved below ${minWindowMs / HOUR_MS} h; pull it with --no-raw, or fetch its RAW archives another way.`) + } + log(`${label}: RAW over the export limit (413), halving the window`) + pending.push({ startMs: span.startMs + half, endMs: span.endMs }, { startMs: span.startMs, endMs: span.startMs + half }) + } + finally { + await rm(staging, { recursive: true, force: true }) + } + } + return records + } + finally { + await rm(join(options.dest, STAGING_DIR), { recursive: true, force: true }) + await release() + } +} diff --git a/scripts/biometrics-archive/store.ts b/scripts/biometrics-archive/store.ts new file mode 100644 index 000000000..5e654efcd --- /dev/null +++ b/scripts/biometrics-archive/store.ts @@ -0,0 +1,119 @@ +import { createHash, randomBytes } from 'node:crypto' +import { constants, createReadStream, createWriteStream } from 'node:fs' +import { copyFile, link, mkdir, readdir, rename, rm, stat, utimes } from 'node:fs/promises' +import { dirname, join, resolve, sep } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' + +/** `mtimeMs` is the source file's modification time, kept where the backend can. */ +export interface ObjectMeta { sha256: string, size: number, mtimeMs?: number } + +/** + * Where archived objects live. Keys are relative POSIX paths such as + * `/raw/.RAW.gz`. `put` replaces atomically and rejects bytes + * that don't match `meta`; write-once is the caller's job (`exists` → skip). + * `link` makes `to` name the same bytes as `from` and fails if `to` exists; + * a backend without links copies. + */ +export interface Store { + put(key: string, body: Readable, meta: ObjectMeta): Promise + get(key: string): Promise + exists(key: string): Promise + /** Every key under `prefix`, recursively, sorted. */ + list(prefix: string): Promise + link(from: string, to: string): Promise<'linked' | 'copied'> +} + +export class StoreIntegrityError extends Error {} + +const KEY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/ + +export class LocalStore implements Store { + readonly root: string + constructor(root: string) { + this.root = resolve(root) + } + + private path(key: string) { + if (!KEY.test(key) || key.split('/').some(part => part === '.' || part === '..')) throw new Error(`Invalid store key: ${key}`) + return join(this.root, ...key.split('/')) + } + + async put(key: string, body: Readable, meta: ObjectMeta) { + const target = this.path(key) + await mkdir(dirname(target), { recursive: true }) + const temporary = `${target}.tmp-${process.pid}-${randomBytes(4).toString('hex')}` + const digest = createHash('sha256') + let size = 0 + try { + await pipeline(body, async function* (source: AsyncIterable) { + for await (const chunk of source) { + digest.update(chunk) + size += chunk.length + yield chunk + } + }, createWriteStream(temporary, { flags: 'wx', mode: 0o600 })) + const sha256 = digest.digest('hex') + if (size !== meta.size || sha256 !== meta.sha256) throw new StoreIntegrityError(`${key}: expected ${meta.size} bytes with SHA-256 ${meta.sha256}, got ${size} bytes with ${sha256}`) + if (meta.mtimeMs !== undefined) await utimes(temporary, new Date(), new Date(meta.mtimeMs)) + await rename(temporary, target) + } + finally { + await rm(temporary, { force: true }) + } + } + + async get(key: string) { + const path = this.path(key) + await stat(path) + return createReadStream(path) + } + + async exists(key: string) { + return stat(this.path(key)).then(info => info.isFile(), () => false) + } + + async list(prefix: string) { + const trimmed = prefix.replace(/\/+$/, '') + const start = trimmed ? this.path(trimmed) : this.root + const keys: string[] = [] + const walk = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return [] + throw error + }) + for (const entry of entries) { + const path = join(directory, entry.name) + if (entry.isDirectory()) await walk(path) + else if (entry.isFile() && !entry.name.includes('.tmp-')) keys.push(path.slice(this.root.length + 1).split(sep).join('/')) + } + } + await walk(start) + return keys.filter(key => key.startsWith(prefix)).sort() + } + + async link(from: string, to: string) { + const source = this.path(from), target = this.path(to) + await mkdir(dirname(target), { recursive: true }) + try { + await link(source, target) + return 'linked' as const + } + catch (error) { + // Filesystems without hard links (some NAS/SMB mounts) get a copy. + if (!['EPERM', 'ENOTSUP', 'EXDEV', 'ENOSYS'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error + await copyFile(source, target, constants.COPYFILE_EXCL) + return 'copied' as const + } + } +} + +export async function putBuffer(store: Store, key: string, bytes: Buffer) { + await store.put(key, Readable.from([bytes]), { sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length }) +} + +export async function readText(store: Store, key: string) { + const chunks: Buffer[] = [] + for await (const chunk of await store.get(key)) chunks.push(chunk as Buffer) + return Buffer.concat(chunks).toString('utf8') +} diff --git a/scripts/biometrics-local-fit/README.md b/scripts/biometrics-local-fit/README.md new file mode 100644 index 000000000..bb2029ccb --- /dev/null +++ b/scripts/biometrics-local-fit/README.md @@ -0,0 +1,96 @@ +# Local biometric profile fitting + +Fit biometric profile candidates on a laptop against a frozen calibration +dataset exported from the pod, then send only the best one or two back to the +pod for a real backtest. Waveform replay on Pod 5 runs about 30 times slower +than on a laptop, so parameter sweeps belong here. Activation decisions do not: +everything this tool writes is labelled `exploratory-local` and +`not_activation_evidence`. + +## 1. Export the dataset + +```sh +curl -fo dataset.tar http://POD:3000/api/biometric-tuning/datasets/DATASET_ID/export +``` + +The MCP tool `export_calibration_dataset` returns the same bundle index and this +download path. Waveform bytes never go through MCP. The tar holds: + +- `bundle.json`: the index. It lists every other file with its exact size and SHA-256. +- `manifest.json` and `snapshot.json`: the frozen dataset. Together they hash + to the dataset's `contentHash`. +- `raw/.RAW.gz`: each retained archive the snapshot references, and nothing else. + +A released dataset returns 404. If a retained archive fails its hash check on +the pod, the export returns 409 and streams nothing. Archives contain both +sides of the bed; keep the bundle on a machine you trust. + +## 2. Set up the replay environment + +```sh +(cd modules/piezo-processor && uv sync --frozen) +``` + +This installs the locked Python 3.10, NumPy and SciPy that the pod uses. The +runner calls the unchanged `common.biometrics.replay_worker.run()` through +`local_replay.py`. The only differences from the pod are that it skips the +pod's host-wide execution lock, CPU rlimit and nice level, so nights can replay +in parallel. `run.json` records the local Python build identity. Its +`identity.sources` hashes can be compared with the pod's +(`python -B -m common.biometrics.replay_worker --describe-build`). The +interpreter and wheel hashes differ by platform by design. + +## 3. Fit or sweep + +```sh +pnpm biometrics:local-fit verify dataset.tar +pnpm biometrics:local-fit fit dataset.tar candidates.json out/ +pnpm biometrics:local-fit sweep dataset.tar default out/ # or a GRID.json +``` + +`candidates.json` is `[{ "id": "tol12", "overrides": [{ "path": "heart_rate.trackingToleranceBpm", "value": 12 }] }]`. +The shipped defaults are always added as `defaults`. A grid maps override paths +to value lists. A comma-joined key varies several paths together, for example +`"breathing_rate.intervalLowerRatio,breathing_rate.intervalUpperRatio": [[0.5, 1.5], [0.4, 1.7]]`. +The built-in `default` grid has 18 points, sized to finish in under 15 minutes +on a 10-core laptop for seven frozen nights. + +The tool verifies every file hash, the dataset content hash and the exact set +of archives before any replay. It then replays each tuning and validation night +once per distinct configuration, longest nights first, across `--jobs` workers +(default: all cores). Holdout nights are skipped unless you pass +`--include-holdout`. Use it only to score a candidate you have already chosen, +once, on nights it was not fitted to. Each candidate and split is scored with the pod's own +`evaluateBacktest`, against defaults on common support. + +Outputs: + +- `out//.json`: per-night HR, breathing and HRV error, plus + the full evaluator report. A metric without paired reference data reads + `no_paired_reference`, never zero error. +- `out/ranking.json` and `out/ranking.md`: ranked by tuning HR error on each + candidate's own readings, among candidates that keep at least `--min-coverage` + (default 75%) of the defaults' Watch coverage; validation and holdout are shown + alongside. Each split also shows the common-support difference against + defaults. That difference reads 0 for a change that only withholds readings + (such as `heart_rate.minimumPeakScore`), which is why it is not the ranking key. +- `--save-readings` also writes every replay's derived readings to + `out/readings/-.json` for error analysis. +- `out/run.json`: timing, worker count and the Python build identity. + +## 4. Check parity, then confirm on the pod + +```sh +curl -fo pod-validation.json http://POD:3000/api/biometric-tuning/backtests/JOB_ID/result +pnpm biometrics:local-fit parity out/ pod-validation.json [pod-tuning.json] +``` + +`parity` compares local per-night HR error for defaults with the pod's +results. It exits non-zero if any night differs by more than 0.1 bpm. + +For the top one or two candidates, create a revision with +`propose_biometric_profile`, using the same overrides. Its `resolvedHash` +matches the local report. Then run `backtest_biometrics` on tuning and +validation. Run holdout once, for the final candidate only. Only the pod's +report can back activation, and applying a profile still needs the user's +explicit authorization. diff --git a/scripts/biometrics-local-fit/bundle.test.ts b/scripts/biometrics-local-fit/bundle.test.ts new file mode 100644 index 000000000..94c9a0571 --- /dev/null +++ b/scripts/biometrics-local-fit/bundle.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { createWriteStream, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { once } from 'node:events' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { TAR_END, tarHeader, tarPadding } from '@/src/biometrics/backtest/bundle-tar' +import { datasetBundleChunks, prepareDatasetExport } from '@/src/biometrics/backtest/dataset-export' +import { exportableWaveformDataset } from '@/src/biometrics/backtest/tests/fixtures/export' +import { extractBundle, verifyBundle } from './bundle' + +let directory: string +let fixture: ReturnType +let tarPath: string +beforeEach(async () => { + directory = mkdtempSync(join(tmpdir(), 'local-fit-bundle-')) + fixture = exportableWaveformDataset(directory) + const prepared = await prepareDatasetExport(fixture.db, fixture.id) + tarPath = join(directory, 'bundle.tar') + const out = createWriteStream(tarPath) + for await (const chunk of datasetBundleChunks(prepared.index, { manifest: prepared.manifestText, snapshot: prepared.snapshotText }, fixture.rawDirectory)) out.write(chunk) + out.end() + await once(out, 'finish') +}) +afterEach(() => { + fixture.sqlite.close() + rmSync(directory, { recursive: true, force: true }) +}) + +const extract = () => extractBundle(tarPath, join(directory, `out-${Math.random().toString(16).slice(2)}`)) +const flip = (path: string) => { + const bytes = readFileSync(path) + bytes[bytes.length - 1] ^= 0xff + writeFileSync(path, bytes) +} + +it('extracts and verifies an exported bundle end to end', async () => { + const bundle = await verifyBundle(await extract()) + expect(bundle.index.dataset.contentHash).toBe(fixture.contentHash) + expect(bundle.snapshot.version).toBe('waveform-dataset-v1') + expect(bundle.manifest.splitProtocol.assignments.map(row => row.split)).toEqual(['tuning', 'validation', 'holdout']) +}) + +it('rejects a RAW archive whose bytes do not match the index', async () => { + const root = await extract() + flip(join(root, 'raw', fixture.artifacts[1].name)) + await expect(verifyBundle(root)).rejects.toThrow(`SHA-256 mismatch: raw/${fixture.artifacts[1].name}`) +}) + +it('rejects edited snapshots even when the index is rewritten to match', async () => { + const root = await extract() + const snapshot = JSON.parse(readFileSync(join(root, 'snapshot.json'), 'utf8')) + snapshot.subjectId = 'someone else' + const text = JSON.stringify(snapshot) + writeFileSync(join(root, 'snapshot.json'), text) + const index = JSON.parse(readFileSync(join(root, 'bundle.json'), 'utf8')) + const { createHash } = await import('node:crypto') + Object.assign(index.files[1], { sha256: createHash('sha256').update(text).digest('hex'), bytes: Buffer.byteLength(text) }) + writeFileSync(join(root, 'bundle.json'), JSON.stringify(index)) + await expect(verifyBundle(root)).rejects.toThrow('content hash') +}) + +it('rejects files the index does not list, and missing or linked files', async () => { + const root = await extract() + writeFileSync(join(root, 'raw', fixture.unrelated.name), 'extra') + await expect(verifyBundle(root)).rejects.toThrow('unlisted') + rmSync(join(root, 'raw', fixture.unrelated.name)) + const raw = join(root, 'raw', fixture.artifacts[0].name) + rmSync(raw) + await expect(verifyBundle(root)).rejects.toThrow('missing') + symlinkSync(join(fixture.rawDirectory, fixture.artifacts[0].name), raw) + await expect(verifyBundle(root)).rejects.toThrow('not a regular file') +}) + +it('rejects a RAW set that differs from what the frozen snapshot references', async () => { + const root = await extract() + const index = JSON.parse(readFileSync(join(root, 'bundle.json'), 'utf8')) + index.files = index.files.filter((file: { path: string }) => file.path !== `raw/${fixture.artifacts[2].name}`) + writeFileSync(join(root, 'bundle.json'), JSON.stringify(index)) + rmSync(join(root, 'raw', fixture.artifacts[2].name)) + await expect(verifyBundle(root)).rejects.toThrow('not exactly the frozen dataset') +}) + +it('refuses tar entries that are unlisted, resized, links or outside the bundle', async () => { + const index = readFileSync(tarPath).subarray(512, 512 + Number.parseInt(readFileSync(tarPath).subarray(124, 135).toString(), 8)) + const write = (entries: Buffer[]) => { + tarPath = join(directory, `crafted-${Math.random().toString(16).slice(2)}.tar`) + writeFileSync(tarPath, Buffer.concat([tarHeader('bundle.json', index.length), index, tarPadding(index.length), ...entries, TAR_END])) + } + write([tarHeader('notes.txt', 2), Buffer.from('hi'), tarPadding(2)]) + await expect(extract()).rejects.toThrow('Unlisted or resized bundle entry: notes.txt') + write([tarHeader('manifest.json', 1), Buffer.from('x'), tarPadding(1)]) + await expect(extract()).rejects.toThrow('Unlisted or resized') + const link = tarHeader('manifest.json', 0) + link[156] = '2'.charCodeAt(0) + let sum = 0 + link.fill(32, 148, 156) + for (const byte of link) sum += byte + link.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii') + write([link]) + await expect(extract()).rejects.toThrow('only regular files') + expect(() => tarHeader('../escape', 1)).toThrow('Invalid bundle entry name') + write([]) + await expect(extract()).rejects.toThrow('missing') +}) + +it('rejects an oversized listed entry before writing it', async () => { + const index = JSON.parse(readFileSync(tarPath).subarray(512, 512 + Number.parseInt(readFileSync(tarPath).subarray(124, 135).toString(), 8)).toString('utf8')) + index.files[1].bytes = 16 * 1024 * 1024 + 1 + const text = Buffer.from(JSON.stringify(index)) + tarPath = join(directory, 'oversized.tar') + // The header claims a 16 MiB + 1 snapshot; extraction must refuse before streaming it. + writeFileSync(tarPath, Buffer.concat([tarHeader('bundle.json', text.length), text, tarPadding(text.length), tarHeader('snapshot.json', index.files[1].bytes), TAR_END])) + await expect(extract()).rejects.toThrow('Bundle file is too large: snapshot.json') +}) diff --git a/scripts/biometrics-local-fit/bundle.ts b/scripts/biometrics-local-fit/bundle.ts new file mode 100644 index 000000000..1524711dc --- /dev/null +++ b/scripts/biometrics-local-fit/bundle.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto' +import { constants, createReadStream, createWriteStream } from 'node:fs' +import { lstat, mkdir, open, readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { dataHash } from '@/src/biometrics/algorithms/canonical' +import { parseTarHeader } from '@/src/biometrics/backtest/bundle-tar' +import { datasetManifestSchema, datasetSnapshotSchema } from '@/src/biometrics/backtest/contracts' +import { DATASET_BUNDLE_INDEX_PATH, datasetBundleIndexSchema, type DatasetBundleIndex } from '@/src/biometrics/backtest/dataset-export' + +const MAX_INDEX_BYTES = 1024 * 1024 +const MAX_TEXT_BYTES = 16 * 1024 * 1024 +const MAX_RAW_BYTES = 256 * 1024 * 1024 + +export class BundleError extends Error {} + +async function sha256File(path: string) { + const digest = createHash('sha256') + for await (const chunk of createReadStream(path, { highWaterMark: 1024 * 1024 })) digest.update(chunk as Buffer) + return digest.digest('hex') +} + +async function readExactly(handle: Awaited>, position: number, length: number) { + const buffer = Buffer.alloc(length) + const { bytesRead } = await handle.read(buffer, 0, length, position) + if (bytesRead !== length) throw new BundleError('Truncated bundle archive') + return buffer +} + +/** Unpack an export tar into a new directory. Only the index and the files it + * lists are accepted, each once and at its listed size; verifyBundle then + * checks every hash. */ +export async function extractBundle(tarPath: string, destination: string) { + await mkdir(destination, { recursive: false, mode: 0o700 }) + await mkdir(join(destination, 'raw'), { mode: 0o700 }) + const handle = await open(tarPath, constants.O_RDONLY) + try { + const total = (await handle.stat()).size + let position = 0, index: DatasetBundleIndex | undefined + const expected = new Map(), seen = new Set() + for (;;) { + const header = parseTarHeader(await readExactly(handle, position, 512)) + position += 512 + if (!header) break + if (seen.has(header.name)) throw new BundleError(`Duplicate bundle entry: ${header.name}`) + seen.add(header.name) + if (!index) { + if (header.name !== DATASET_BUNDLE_INDEX_PATH || header.bytes > MAX_INDEX_BYTES) throw new BundleError('Bundle must start with its bundle.json index') + const text = await readExactly(handle, position, header.bytes) + index = datasetBundleIndexSchema.parse(JSON.parse(text.toString('utf8'))) + for (const file of index.files) expected.set(file.path, file) + await writeFile(join(destination, DATASET_BUNDLE_INDEX_PATH), text, { flag: 'wx', mode: 0o600 }) + } + else { + const file = expected.get(header.name) + if (!file || file.bytes !== header.bytes) throw new BundleError(`Unlisted or resized bundle entry: ${header.name}`) + // Enforce size limits before writing, not only during verification. + if (header.bytes > (file.role === 'raw' ? MAX_RAW_BYTES : MAX_TEXT_BYTES)) throw new BundleError(`Bundle file is too large: ${header.name}`) + if (position + header.bytes > total) throw new BundleError('Truncated bundle archive') + await pipeline(createReadStream('', { fd: handle.fd, autoClose: false, start: position, end: position + header.bytes - 1 }), + createWriteStream(join(destination, header.name), { flags: 'wx', mode: 0o600 })) + } + position += Math.ceil(header.bytes / 512) * 512 + } + if (!index) throw new BundleError('Bundle has no index') + const missing = index.files.filter(file => !seen.has(file.path)) + if (missing.length) throw new BundleError(`Bundle is missing ${missing.length} listed file(s), e.g. ${missing[0].path}`) + } + finally { + await handle.close() + } + return destination +} + +/** Check a bundle directory against its index: exact file set, sizes and + * SHA-256s, the dataset's own content hash, and that the RAW archives are + * exactly the ones the frozen snapshot references. */ +export async function verifyBundle(directory: string) { + const indexPath = join(directory, DATASET_BUNDLE_INDEX_PATH) + if ((await stat(indexPath)).size > MAX_INDEX_BYTES) throw new BundleError('Bundle index is too large') + const index = datasetBundleIndexSchema.parse(JSON.parse(await readFile(indexPath, 'utf8'))) + const listed = new Set(index.files.map(file => file.path)) + if (listed.size !== index.files.length) throw new BundleError('Bundle index lists a file twice') + const present = [ + ...(await readdir(directory)).filter(name => name !== 'raw'), + ...(await readdir(join(directory, 'raw')).catch(() => [] as string[])).map(name => `raw/${name}`), + ] + const extra = present.filter(name => name !== DATASET_BUNDLE_INDEX_PATH && !listed.has(name)) + if (extra.length) throw new BundleError(`Bundle contains unlisted file(s), e.g. ${extra[0]}`) + for (const file of index.files) { + const path = join(directory, file.path) + const info = await lstat(path).catch(() => null) + if (!info?.isFile()) throw new BundleError(`Bundle file is missing or not a regular file: ${file.path}`) + const limit = file.role === 'raw' ? MAX_RAW_BYTES : MAX_TEXT_BYTES + if (info.size !== file.bytes || info.size > limit) throw new BundleError(`Bundle file size mismatch: ${file.path}`) + if (await sha256File(path) !== file.sha256) throw new BundleError(`Bundle file SHA-256 mismatch: ${file.path}`) + } + const text = (role: 'manifest' | 'snapshot') => readFile(join(directory, index.files.find(file => file.role === role)?.path ?? ''), 'utf8') + const manifest = datasetManifestSchema.parse(JSON.parse(await text('manifest'))) + const snapshot = datasetSnapshotSchema.parse(JSON.parse(await text('snapshot'))) + if (dataHash({ manifest, snapshot }) !== index.dataset.contentHash) throw new BundleError('Dataset content hash does not match its manifest and snapshot') + if (snapshot.side !== index.dataset.side || snapshot.version !== index.dataset.snapshotVersion) throw new BundleError('Bundle index does not describe this snapshot') + const frozen = new Map(snapshot.version === 'waveform-dataset-v1' + ? snapshot.waveformInputs.flatMap(cohort => cohort.input.artifacts.map(source => [source.artifact.sha256, source.artifact] as const)) + : []) + const raw = index.files.filter(file => file.role === 'raw') + if (raw.length !== frozen.size || raw.some((file) => { + const artifact = frozen.get(file.sha256) + return !artifact || artifact.compressed_bytes !== file.bytes || artifact.expanded_bytes !== file.expandedBytes + })) throw new BundleError('Bundle RAW archives are not exactly the frozen dataset\'s archives') + return { index, manifest, snapshot, rawDirectory: join(directory, 'raw') } +} +export type VerifiedBundle = Awaited> diff --git a/scripts/biometrics-local-fit/cli.ts b/scripts/biometrics-local-fit/cli.ts new file mode 100644 index 000000000..36027a8d5 --- /dev/null +++ b/scripts/biometrics-local-fit/cli.ts @@ -0,0 +1,119 @@ +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { extractBundle, verifyBundle } from './bundle' +import { candidateFileSchema, defaultPythonLocation, fitCandidates, LOCAL_SPLITS, type Candidate, type LocalSplit } from './fit' +import { DEFAULT_GRID, expandGrid } from './grid' +import { compareWithPod, parseLocalReport, rankCandidates, rankingMarkdown } from './report' + +const usage = `Usage: + verify BUNDLE + fit BUNDLE CANDIDATES.json OUTPUT_DIR [--jobs N] [--splits tuning,validation] [--include-holdout] [--save-readings] [--min-coverage 0.75] [--protocol FILE] [--python PATH] + sweep BUNDLE GRID.json|default OUTPUT_DIR [same options as fit] + parity OUTPUT_DIR POD_RESULT.json [POD_RESULT.json ...] +BUNDLE is the tar from GET /api/biometric-tuning/datasets/{id}/export, or a directory extracted from it.` + +function options(args: string[]) { + const values: Record = {} + const positional: string[] = [] + for (let index = 0; index < args.length; index++) { + if (args[index] === '--include-holdout' || args[index] === '--save-readings') values[args[index].slice(2)] = 'true' + else if (args[index].startsWith('--')) values[args[index].slice(2)] = args[++index] ?? '' + else positional.push(args[index]) + } + return { values, positional } +} + +async function readJson(path: string) { + return JSON.parse(await readFile(path, 'utf8')) as unknown +} + +async function openBundle(path: string, outputDirectory: string) { + if ((await stat(path)).isDirectory()) return verifyBundle(path) + const destination = join(outputDirectory, 'bundle') + if (resolve(path) === resolve(destination) || resolve(path).startsWith(`${resolve(destination)}/`)) { + throw new Error(`The bundle cannot live inside ${destination}: that directory is replaced by the extracted copy`) + } + await rm(destination, { recursive: true, force: true }) + await extractBundle(path, destination) + return verifyBundle(destination) +} + +async function fit(bundlePath: string, candidates: Candidate[], outputDirectory: string, flags: Record) { + // Check flags before extracting a bundle or replaying anything. + const jobs = flags.jobs === undefined ? undefined : Number(flags.jobs) + if (jobs !== undefined && !(Number.isSafeInteger(jobs) && jobs >= 1)) throw new Error('--jobs is a positive whole number of parallel replays') + const minimumCoverage = flags['min-coverage'] === undefined ? undefined : Number(flags['min-coverage']) + if (minimumCoverage !== undefined && !(Number.isFinite(minimumCoverage) && minimumCoverage >= 0 && minimumCoverage <= 1)) { + throw new Error('--min-coverage is a fraction from 0 to 1, for example 0.75') + } + await mkdir(outputDirectory, { recursive: true }) + const bundle = await openBundle(bundlePath, outputDirectory) + const includeHoldout = flags['include-holdout'] === 'true' + const splits = (flags.splits?.split(',') ?? [...LOCAL_SPLITS, ...(includeHoldout ? ['holdout'] : [])]) as LocalSplit[] + const started = performance.now() + const result = await fitCandidates({ bundle, candidates, outputDirectory, splits, includeHoldout, + readingsDirectory: flags['save-readings'] === 'true' ? join(outputDirectory, 'readings') : undefined, + jobs, + protocol: flags.protocol ? await readJson(flags.protocol) as never : undefined, + python: flags.python ? defaultPythonLocation(flags.python) : undefined, + onProgress: (done, total, item) => process.stderr.write(`[${done}/${total}] ${item.cohortId} ${item.candidateIds.join(',') || '(baseline)'} ${(item.wallMs / 1000).toFixed(1)}s\n`) }) + for (const report of result.reports) { + await mkdir(join(outputDirectory, report.candidate.id), { recursive: true }) + await writeFile(join(outputDirectory, report.candidate.id, `${report.split}.json`), `${JSON.stringify(report)}\n`) + } + const ranking = rankCandidates(result.reports, minimumCoverage) + await writeFile(join(outputDirectory, 'ranking.json'), `${JSON.stringify(ranking, null, 2)}\n`) + await writeFile(join(outputDirectory, 'ranking.md'), rankingMarkdown(ranking)) + const totalWallMs = performance.now() - started + await writeFile(join(outputDirectory, 'run.json'), `${JSON.stringify({ version: 'biometrics-local-fit-run-v1', label: 'exploratory-local', + dataset: bundle.index.dataset, splits, candidates: ranking.rows.length, workItems: result.workItems, jobs: result.jobs, + replayWallMs: result.replayWallMs, totalWallMs, pythonBuild: { hash: result.build.hash, sourcesHash: result.build.sourcesHash, identity: result.build.identity } }, null, 2)}\n`) + await rm(join(outputDirectory, 'work'), { recursive: true, force: true }) + process.stdout.write(rankingMarkdown(ranking)) + process.stdout.write(`\n${ranking.rows.length} candidates, ${result.workItems} replays on ${result.jobs} workers in ${(totalWallMs / 60000).toFixed(1)} min\n`) +} + +export async function runLocalFitCli(args: string[]) { + const [command, ...rest] = args + const { values, positional } = options(rest) + if (command === 'verify' && positional.length === 1) { + const path = positional[0] + let bundle + if ((await stat(path)).isDirectory()) bundle = await verifyBundle(path) + else { + const directory = `${path}.verify-${process.pid}` + try { + bundle = await verifyBundle(await extractBundle(path, directory)) + } + finally { + await rm(directory, { recursive: true, force: true }) + } + } + process.stdout.write(`${JSON.stringify({ verified: true, dataset: bundle.index.dataset, files: bundle.index.files.length })}\n`) + return + } + if (command === 'fit' && positional.length === 3) { + return fit(positional[0], candidateFileSchema.parse(await readJson(positional[1])), positional[2], values) + } + if (command === 'sweep' && positional.length === 3) { + const grid = positional[1] === 'default' ? DEFAULT_GRID : await readJson(positional[1]) + return fit(positional[0], expandGrid(grid), positional[2], values) + } + if (command === 'parity' && positional.length >= 2) { + const defaults = await Promise.all(LOCAL_SPLITS.map(split => readJson(join(positional[0], 'defaults', `${split}.json`)).then(parseLocalReport).catch(() => null))) + const parity = compareWithPod(defaults.filter(row => row !== null), await Promise.all(positional.slice(1).map(readJson))) + await writeFile(join(positional[0], 'parity.json'), `${JSON.stringify(parity, null, 2)}\n`) + process.stdout.write(`${JSON.stringify(parity, null, 2)}\n`) + if (!parity.allWithinTolerance) process.exitCode = 2 + return + } + throw new Error(usage) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runLocalFitCli(process.argv.slice(2)).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/biometrics-local-fit/fit.ts b/scripts/biometrics-local-fit/fit.ts new file mode 100644 index 000000000..e7b57ede6 --- /dev/null +++ b/scripts/biometrics-local-fit/fit.ts @@ -0,0 +1,188 @@ +import { execFile } from 'node:child_process' +import { copyFile, link, mkdir, readdir, writeFile } from 'node:fs/promises' +import { availableParallelism, tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { z } from 'zod' +import { configHash, dataHash } from '@/src/biometrics/algorithms/canonical' +import { defaultConfig, manifestHash, resolveConfig, type BiometricConfig, type Override } from '@/src/biometrics/algorithms/registry' +import { experimentProtocolSchema } from '@/src/biometrics/backtest/contracts' +import { evaluateBacktest } from '@/src/biometrics/backtest/evaluator' +import { waveformPythonBuildSchema } from '@/src/biometrics/backtest/waveform-build' +import { runWaveformChild } from '@/src/biometrics/backtest/waveform-child' +import type { waveformEvaluationSchema } from '@/src/biometrics/backtest/waveform-output' +import type { VerifiedBundle } from './bundle' +import { summarizeLocalReport, type LocalFitReport } from './report' + +export const LOCAL_SPLITS = ['tuning', 'validation'] as const +export type LocalSplit = typeof LOCAL_SPLITS[number] | 'holdout' +/** Matches the protocol of the pod experiment the frozen nights were scored with. */ +export const DEFAULT_PROTOCOL = { version: 'descriptive-evaluation-v1', mode: 'signal_replay', assumedStageCalibrationQuality: 0, pointToleranceMs: 30000, clockOffsetMs: 0 } as const +const LOCAL_EXPERIMENT_ID = '00000000-0000-4000-8000-000000000000' +const REPO = resolve(import.meta.dirname, '../..') + +/** Names the runner itself writes inside OUTPUT_DIR; a candidate directory must not shadow them. */ +export const RESERVED_CANDIDATE_IDS = new Set(['bundle', 'work', 'readings', 'ranking.json', 'ranking.md', 'run.json', 'parity.json']) +export const candidateIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/, 'Candidate ids start with a letter or digit and use only letters, digits, _ . -') + .refine(id => !RESERVED_CANDIDATE_IDS.has(id), 'Candidate id collides with a runner output name') +export const candidateSchema = z.object({ id: candidateIdSchema, + overrides: z.array(z.object({ path: z.string().min(1).max(200), value: z.unknown() }).strict()).max(64) }).strict() +export const candidateFileSchema = z.array(candidateSchema).min(1).max(500) + .refine(rows => new Set(rows.map(row => row.id)).size === rows.length, 'Candidate ids must be unique') +export type Candidate = z.infer + +export interface PythonLocation { pythonPath: string, modulesPath: string, script: string } +export function defaultPythonLocation(pythonPath?: string): PythonLocation { + return { pythonPath: pythonPath ?? join(REPO, 'modules/piezo-processor/.venv/bin/python'), modulesPath: join(REPO, 'modules'), + script: join(REPO, 'scripts/biometrics-local-fit/local_replay.py') } +} + +export async function describeLocalBuild(location: PythonLocation) { + const { stdout } = await promisify(execFile)(location.pythonPath, ['-B', location.script, '--describe-build', '-'], { + cwd: tmpdir(), env: { NODE_ENV: 'production', PYTHONPATH: location.modulesPath, LANG: 'C.UTF-8', TZ: 'UTC' }, encoding: 'utf8', maxBuffer: 65536, timeout: 30000, + }).catch(() => { + throw new Error(`Cannot run ${location.pythonPath}; create it with: (cd modules/piezo-processor && uv sync --frozen)`) + }) + const build = waveformPythonBuildSchema.parse(JSON.parse(stdout)) + if (configHash(build.identity) !== build.hash) throw new Error('Local Python build identity integrity mismatch') + return { ...build, sourcesHash: configHash(build.identity.sources) } +} + +/** The read-only artifact store serializes readers on its directory lock, so + * each worker slot gets a private directory of hard links (copies across devices). */ +export async function prepareWorkerSlots(rawDirectory: string, workDirectory: string, count: number) { + const names = (await readdir(rawDirectory)).filter(name => /^[a-f0-9]{64}\.RAW\.gz$/.test(name)) + const slots: string[] = [] + for (let index = 0; index < count; index++) { + const slot = join(workDirectory, `slot-${index}`) + await mkdir(slot, { recursive: true, mode: 0o700 }) + for (const name of names) { + await link(join(rawDirectory, name), join(slot, name)).catch(async (error: NodeJS.ErrnoException) => { + if (error.code === 'EEXIST') return + if (error.code !== 'EXDEV') throw error + await copyFile(join(rawDirectory, name), join(slot, name)) + }) + } + await writeFile(join(slot, '.lock'), '', { mode: 0o600 }) + slots.push(slot) + } + return slots +} + +/** Run items across slots; the first failure aborts every replay still running. */ +async function runPool(items: T[], slots: string[], work: (item: T, slot: string, signal: AbortSignal) => Promise) { + let next = 0 + const controller = new AbortController() + await Promise.all(slots.map(async (slot) => { + while (!controller.signal.aborted && next < items.length) { + try { + await work(items[next++], slot, controller.signal) + } + catch (error) { + controller.abort() + throw error + } + } + })) +} + +export interface ResolvedCandidate extends Candidate { resolved: BiometricConfig, resolvedHash: string } +export function resolveCandidates(candidates: Candidate[]): ResolvedCandidate[] { + const rows = candidates.some(candidate => candidate.id === 'defaults') ? candidates : [{ id: 'defaults', overrides: [] }, ...candidates] + return rows.map((candidate) => { + const { resolved, hash } = resolveConfig(candidate.overrides as Override[]) + return { ...candidate, resolved: resolved as BiometricConfig, resolvedHash: hash } + }) +} + +export interface FitOptions { + bundle: VerifiedBundle + candidates: Candidate[] + outputDirectory: string + splits?: readonly LocalSplit[] + /** Score holdout nights too, once, for a candidate that is already chosen. */ + includeHoldout?: boolean + /** Also write each replay's derived readings here, one JSON file per night and configuration. */ + readingsDirectory?: string + jobs?: number + protocol?: z.input + python?: PythonLocation + onProgress?: (done: number, total: number, item: { cohortId: string, candidateIds: string[], wallMs: number }) => void +} + +/** Replay each selected night once per distinct resolved configuration, then + * score each candidate and split with the pod's own evaluator. Holdout nights + * are replayed only with includeHoldout. */ +export async function fitCandidates(options: FitOptions) { + const { bundle } = options + if (bundle.snapshot.version !== 'waveform-dataset-v1') throw new Error('Local fitting needs a retained waveform dataset') + const snapshot = bundle.snapshot + const splits = options.splits ?? LOCAL_SPLITS + if (splits.some(split => split === 'holdout') && !options.includeHoldout) throw new Error('Holdout nights are held back: pass --include-holdout only to score an already-chosen candidate, once') + if (splits.some(split => split !== 'holdout' && !(LOCAL_SPLITS as readonly string[]).includes(split))) throw new Error('Unknown split') + const protocol = experimentProtocolSchema.parse(options.protocol ?? DEFAULT_PROTOCOL) + if (protocol.mode !== 'signal_replay') throw new Error('Local fitting scores waveform replay; use a signal_replay protocol') + const python = options.python ?? defaultPythonLocation() + const build = await describeLocalBuild(python) + const candidates = resolveCandidates(options.candidates) + const defaults = { manifestHash, resolvedHash: configHash(defaultConfig), resolved: defaultConfig as BiometricConfig } + const configs = new Map([[defaults.resolvedHash, defaults.resolved], ...candidates.map(row => [row.resolvedHash, row.resolved] as const)]) + const assignments = bundle.manifest.splitProtocol.assignments.filter(row => (splits as readonly string[]).includes(row.split)) + const inputs = new Map(snapshot.waveformInputs.map(row => [row.cohortId, row.input])) + const items = assignments.flatMap(assignment => [...configs.keys()].map(hash => ({ cohortId: assignment.cohortId, hash }))) + const length = (cohortId: string) => { + const input = inputs.get(cohortId) + if (!input) throw new Error(`Bundle has no retained input for ${cohortId}`) + return input.score_end_ms - input.retained_start_ms + } + // Longest nights first keeps every slot busy until the end. + items.sort((a, b) => length(b.cohortId) - length(a.cohortId)) + const jobs = Math.max(1, Math.min(options.jobs ?? availableParallelism(), items.length)) + const work = join(options.outputDirectory, 'work') + const slots = await prepareWorkerSlots(bundle.rawDirectory, work, jobs) + const outputs = new Map['outputs'][number]>() + const otherSideSide = snapshot.side === 'left' ? 'right' : 'left' + const started = performance.now() + let done = 0 + await runPool(items, slots, async (item, slot, signal) => { + const input = inputs.get(item.cohortId) + const resolved = configs.get(item.hash) + if (!input || !resolved) throw new Error('Missing replay input') + const itemStarted = performance.now() + const output = await runWaveformChild({ ...python, retainedDirectory: slot }, + { version: 'retained-waveform-work-v1', input, configs: { [snapshot.side]: resolved, [otherSideSide]: defaults.resolved } }, + { signal, timeoutMs: 3600000, expectedBuildHash: build.hash }) + outputs.set(`${item.cohortId}:${item.hash}`, { cohortId: item.cohortId, configHash: item.hash, ...output }) + if (options.readingsDirectory) { + await mkdir(options.readingsDirectory, { recursive: true }) + await writeFile(join(options.readingsDirectory, `${item.cohortId}-${item.hash.slice(0, 12)}.json`), + `${JSON.stringify({ cohortId: item.cohortId, configHash: item.hash, side: snapshot.side, readings: output.readings })}\n`) + } + options.onProgress?.(++done, items.length, { cohortId: item.cohortId, + candidateIds: candidates.filter(row => row.resolvedHash === item.hash).map(row => row.id), wallMs: performance.now() - itemStarted }) + }) + const replayWallMs = performance.now() - started + const evaluatorBuildHash = configHash({ version: 'biometrics-local-fit-evaluator-v1', pythonBuildHash: build.hash, node: process.version }) + const reports: LocalFitReport[] = [] + for (const candidate of candidates) { + for (const split of splits) { + const cohorts = assignments.filter(row => row.split === split).map(row => row.cohortId) + if (!cohorts.length) continue + const jobConfigs = [{ label: 'default' as const, revisionId: null, ...defaults }, + { label: 'candidate' as const, revisionId: null, manifestHash, resolvedHash: candidate.resolvedHash, resolved: candidate.resolved }] + const runOutputs = cohorts.flatMap(cohortId => [...new Set(jobConfigs.map(config => config.resolvedHash))].map((hash) => { + const output = outputs.get(`${cohortId}:${hash}`) + if (!output) throw new Error(`Missing replay output for ${cohortId}`) + return output + })) + const job = { version: 'backtest-job-v1' as const, experimentId: LOCAL_EXPERIMENT_ID, idempotencyKey: `local:${candidate.id}:${split}`, + evaluatorBuildHash, split, configs: jobConfigs, waveform: { pythonBuildHash: build.hash, otherSide: defaults }, + datasetId: bundle.index.dataset.id, datasetHash: bundle.index.dataset.contentHash, protocol, protocolHash: configHash(protocol), + holdoutUse: 0, evidence: 'descriptive_only' as const } + const report = evaluateBacktest({ job, dataset: { manifest: bundle.manifest, snapshot }, waveform: { hash: dataHash(runOutputs), outputs: runOutputs } }) + reports.push(summarizeLocalReport({ candidate, split, report, dataset: bundle.index.dataset, + build: { pythonBuildHash: build.hash, pythonSourcesHash: build.sourcesHash, evaluatorBuildHash }, protocol })) + } + } + return { reports, build, replayWallMs, workItems: items.length, jobs } +} diff --git a/scripts/biometrics-local-fit/grid.ts b/scripts/biometrics-local-fit/grid.ts new file mode 100644 index 000000000..ffef23677 --- /dev/null +++ b/scripts/biometrics-local-fit/grid.ts @@ -0,0 +1,51 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' +import { candidateFileSchema, type Candidate } from './fit' + +/** Axis keys are override paths; a comma-joined key varies several paths + * together (for example both breathing ratios), each value then an array. */ +export const gridSchema = z.record(z.string().min(1).max(400), z.array(z.unknown()).min(1).max(20)) + .refine(grid => Object.keys(grid).length > 0 && Object.keys(grid).length <= 8, 'Use 1-8 grid axes') + +/** Sized to finish under 15 minutes on a 10-core laptop for the seven frozen + * nights (tuning + validation is about 28 night-hours per candidate). HR error + * depends only on the two heart-rate parameters; the breathing ratios and + * minimumWindows axis is varied jointly so the grid still covers them. */ +export const DEFAULT_GRID = { + 'heart_rate.trackingToleranceBpm': [10, 15, 20], + 'heart_rate.trackingHistoryLength': [3, 5, 8], + 'breathing_rate.intervalLowerRatio,breathing_rate.intervalUpperRatio,hrv_index.minimumWindows': [[0.5, 1.5, 10], [0.4, 1.7, 20]], +} satisfies z.input + +// Keep sign and decimal point so -1 and 1 stay distinct ids. +const label = (value: unknown) => String(value).replace(/^-/, 'm').replace(/[^A-Za-z0-9.]/g, '_') +const short = (path: string) => ({ + 'heart_rate.trackingToleranceBpm': 'tol', 'heart_rate.trackingHistoryLength': 'hist', + 'breathing_rate.intervalLowerRatio': 'brlo', 'breathing_rate.intervalUpperRatio': 'brhi', + 'hrv_index.harmonicTolerance': 'harm', 'hrv_index.minimumWindows': 'mw', +} as Record)[path] ?? (path.split('.').at(-1) ?? path).replace(/[^A-Za-z0-9_.-]/g, '_') + +export function expandGrid(raw: unknown): Candidate[] { + const grid = gridSchema.parse(raw) + const axes = Object.entries(grid).map(([key, values]) => { + const paths = key.split(',') + return values.map((value) => { + const parts = paths.length === 1 ? [value] : value + if (!Array.isArray(parts) || parts.length !== paths.length) throw new Error(`Grid axis ${key} needs ${paths.length} values per entry`) + return paths.map((path, index) => ({ path, value: parts[index] })) + }) + }) + let combos: Array> = [[]] + for (const axis of axes) combos = combos.flatMap(combo => axis.map(level => [...combo, ...level])) + if (combos.length > 500) throw new Error('Grid expands to more than 500 candidates') + const seen = new Map() + const candidates = combos.map((overrides) => { + let id = overrides.map(row => `${short(row.path)}${label(row.value)}`).join('-').replace(/^[^A-Za-z0-9]+/, '') + // Long combinations keep a readable prefix plus a hash of the exact overrides. + if (id.length > 80) id = `${id.slice(0, 67)}-${createHash('sha256').update(JSON.stringify(overrides)).digest('hex').slice(0, 12)}` + const repeat = seen.get(id) ?? 0 + seen.set(id, repeat + 1) + return { id: repeat ? `${id.slice(0, 76)}-${repeat + 1}` : id, overrides } + }) + return candidateFileSchema.parse(candidates) +} diff --git a/scripts/biometrics-local-fit/local_replay.py b/scripts/biometrics-local-fit/local_replay.py new file mode 100644 index 000000000..433084b79 --- /dev/null +++ b/scripts/biometrics-local-fit/local_replay.py @@ -0,0 +1,34 @@ +"""Laptop entry point for the shipped retained waveform worker. + +Calls the unchanged common.biometrics.replay_worker.run(), so readings come +from the same sources the pod pins in its Python build hash. Only the pod's +host-wide execution lock, CPU rlimit and nice level are left out, which lets +several nights replay in parallel on a development machine. +""" +import os +import sys + +for key in ('OPENBLAS_NUM_THREADS', 'OMP_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMEXPR_NUM_THREADS'): + os.environ[key] = '1' + + +def main(): + if len(sys.argv) != 3 or sys.argv[1] not in ('--retained-directory', '--describe-build'): + sys.stderr.write('usage: local_replay.py --retained-directory DIR | --describe-build -\n') + return 2 + try: + if sys.argv[1] == '--describe-build': + import json + from common.biometrics.build_identity import replay_build_identity + sys.stdout.write(json.dumps(replay_build_identity(), allow_nan=False, separators=(',', ':')) + '\n') + else: + from common.biometrics.replay_worker import run + run(sys.stdin.buffer, sys.stdout.buffer, sys.argv[2]) + return 0 + except Exception: + sys.stderr.write('Retained waveform worker failed\n') + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/biometrics-local-fit/report.test.ts b/scripts/biometrics-local-fit/report.test.ts new file mode 100644 index 000000000..39b27add7 --- /dev/null +++ b/scripts/biometrics-local-fit/report.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { configHash, dataHash } from '@/src/biometrics/algorithms/canonical' +import { defaultConfig, manifestHash, resolveConfig } from '@/src/biometrics/algorithms/registry' +import { evaluateBacktest } from '@/src/biometrics/backtest/evaluator' +import { exportableWaveformDataset, exportNights } from '@/src/biometrics/backtest/tests/fixtures/export' +import { candidateFileSchema, DEFAULT_PROTOCOL, fitCandidates } from './fit' +import { runLocalFitCli } from './cli' +import { DEFAULT_GRID, expandGrid } from './grid' +import { compareWithPod, parseLocalReport, rankCandidates, rankingMarkdown, summarizeLocalReport } from './report' + +const pythonBuildHash = 'b'.repeat(64) +let directory: string +let fixture: ReturnType +beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), 'local-fit-report-')) + fixture = exportableWaveformDataset(directory) +}) +afterAll(() => { + fixture.sqlite.close() + rmSync(directory, { recursive: true, force: true }) +}) + +/** Score a candidate with fixed synthetic readings (references are 62 bpm). */ +function localReport(id: string, overrides: Array<{ path: string, value: unknown }>, split: 'tuning' | 'validation' | 'holdout', heartRates: { default: number | Array, candidate: number | Array }) { + const defaults = { manifestHash, resolvedHash: configHash(defaultConfig), resolved: defaultConfig } + const candidate = resolveConfig(overrides as never) + const configs = [{ label: 'default' as const, revisionId: null, ...defaults }, + { label: 'candidate' as const, revisionId: null, manifestHash, resolvedHash: candidate.hash, resolved: candidate.resolved }] + const night = exportNights.find(row => row.split === split) ?? exportNights[0] + const artifact = fixture.snapshot.version === 'waveform-dataset-v1' ? fixture.snapshot.waveformInputs.find(row => row.cohortId === night.cohortId)?.input.artifacts[0].artifact.sha256 ?? '' : '' + const outputs = [...new Map(configs.map(config => [config.resolvedHash, config])).values()].map((config) => { + const chosen = config.label === 'default' ? heartRates.default : heartRates.candidate + const rate = (index: number) => Array.isArray(chosen) ? chosen[index] : chosen + const readings = Array.from({ length: 10 }, (_, index) => ({ side: 'left' as const, timestampMs: night.startMs + index * 60000, heartRate: rate(index), hrvIndex: 30, + hrvMetric: 'window_ibi_variability_v1' as const, breathingRate: 14, qualityScore: 1, flags: null, hrRaw: rate(index), sourceSide: 'left' as const, + configHash: config.resolvedHash, limitations: [] })) + return { cohortId: night.cohortId, configHash: config.resolvedHash, readings, run: { version: 'retained-waveform-run-v1' as const, build_hash: pythonBuildHash, + decoded_records: 10, raw_records: 10, counts: { records: 10, accepted_frames: 10, empty_channel_frames: 0, pump_gated_frames: 0, preroll_outputs: 0, scored_outputs: readings.length }, + limitations: [], config_hashes: { left: config.resolvedHash, right: defaults.resolvedHash }, artifact_hashes: [artifact] } } + }) + const protocol = { ...DEFAULT_PROTOCOL } + const job = { version: 'backtest-job-v1' as const, experimentId: '00000000-0000-4000-8000-000000000000', idempotencyKey: `local:${id}:${split}`, evaluatorBuildHash: 'e'.repeat(64), + split, configs, waveform: { pythonBuildHash, otherSide: defaults }, datasetId: fixture.id, datasetHash: fixture.contentHash, + protocol, protocolHash: configHash(protocol), holdoutUse: 0, evidence: 'descriptive_only' as const } + const report = evaluateBacktest({ job, dataset: { manifest: fixture.manifest, snapshot: fixture.snapshot }, waveform: { hash: dataHash(outputs), outputs } }) + return summarizeLocalReport({ candidate: { id, overrides, resolvedHash: candidate.hash }, split, report, dataset: { id: fixture.id, contentHash: fixture.contentHash }, + build: { pythonBuildHash, pythonSourcesHash: 'c'.repeat(64), evaluatorBuildHash: 'e'.repeat(64) }, protocol }) +} + +describe('local fit reports', () => { + it('labels every report exploratory-local and scores HR, breathing and HRV per night', () => { + const report = localReport('tighter', [{ path: 'heart_rate.trackingToleranceBpm', value: 10 }], 'tuning', { default: 60, candidate: 61 }) + expect(report).toMatchObject({ version: 'biometrics-local-fit-report-v1', label: 'exploratory-local', evidence: 'not_activation_evidence', split: 'tuning', + candidate: { id: 'tighter', resolvedHash: resolveConfig([{ path: 'heart_rate.trackingToleranceBpm', value: 10 }]).hash } }) + expect(report.notice).toContain('Only a backtest run on the pod') + expect(report.summary.heart_rate).toMatchObject({ status: 'scored', totalNights: 1, scoredNights: 1, coverageReducedNights: 0, + equalNightMeanMae: { default: 2, candidate: 1 }, equalNightMeanMaeDifference: -1 }) + expect(report.summary.heart_rate.nights).toEqual([{ cohortId: 'night-a', pairedCount: 5, defaultMae: 2, candidateMae: 1, maeDifference: -1, coverageReduced: false, lostObservationTimestamps: 0 }]) + // The fixture has no breathing or HRV reference, which must read as unscored, never zero error. + expect(report.summary.breathing_rate).toMatchObject({ status: 'no_paired_reference', scoredNights: 0, equalNightMeanMaeDifference: null }) + expect(report.summary.window_ibi_variability_v1.status).toBe('no_paired_reference') + expect(report.report.status).toBe('insufficient_evidence') + expect(parseLocalReport(JSON.parse(JSON.stringify(report))).label).toBe('exploratory-local') + expect(() => parseLocalReport({ ...report, label: 'pod' })).toThrow() + }) + + it('ranks by tuning HR error on own readings and keeps validation alongside', () => { + const better = [{ path: 'heart_rate.trackingToleranceBpm', value: 10 }], worse = [{ path: 'heart_rate.trackingToleranceBpm', value: 25 }] + const reports = [ + localReport('worse', worse, 'tuning', { default: 60, candidate: 58 }), localReport('worse', worse, 'validation', { default: 60, candidate: 59 }), + localReport('better', better, 'tuning', { default: 60, candidate: 61.5 }), localReport('better', better, 'validation', { default: 60, candidate: 60 }), + localReport('defaults', [], 'tuning', { default: 60, candidate: 60 }), localReport('defaults', [], 'validation', { default: 60, candidate: 60 }), + ] + const ranking = rankCandidates(reports) + expect(ranking).toMatchObject({ label: 'exploratory-local', evidence: 'not_activation_evidence', minimumCoverage: 0.75 }) + expect(ranking.rows.map(row => [row.rank, row.id])).toEqual([[1, 'better'], [2, 'defaults'], [3, 'worse']]) + expect(ranking.rows[0].heartRate).toEqual({ + tuning: { maeDifference: -1.5, mae: 0.5, ownMae: 0.5, coverage: 1, scoredNights: 1, coverageReducedNights: 0 }, + validation: { maeDifference: 0, mae: 2, ownMae: 2, coverage: 1, scoredNights: 1, coverageReducedNights: 0 } }) + expect(rankingMarkdown(ranking)).toContain('| 1 | better | 0.500 | -1.500 | 100% | 2.000 | 0.000 | 100% | n/a | n/a |') + const tied = rankCandidates([localReport('b-two', [...better, { path: 'heart_rate.trackingHistoryLength', value: 8 }], 'tuning', { default: 60, candidate: 61 }), + localReport('a-one', [...better, { path: 'hrv_index.minimumWindows', value: 10 }], 'tuning', { default: 60, candidate: 61 })]) + expect(tied.rows.map(row => [row.id, row.changedDefaults])).toEqual([['a-one', 1], ['b-two', 2]]) + }) + + it('credits abstaining on bad windows but not below the coverage floor', () => { + const gate = (value: number) => [{ path: 'heart_rate.minimumPeakScore', value }] + // References sit on readings 0, 2, 4, 6 and 8 at 62 bpm. Defaults are right + // except one wild reading; each gate only removes readings, never changes them. + const defaults = [62, 60, 62, 60, 62, 60, 62, 60, 90, 60] + const dropsWild = defaults.map((value, index) => index === 8 ? null : value) + const dropsMost = defaults.map((value, index) => index >= 2 && index % 2 === 0 ? null : value) + const ranking = rankCandidates([ + localReport('defaults', [], 'tuning', { default: defaults, candidate: defaults }), + localReport('gate-0.45', gate(0.45), 'tuning', { default: defaults, candidate: dropsWild }), + localReport('gate-0.6', gate(0.6), 'tuning', { default: defaults, candidate: dropsMost }), + ]) + const rows = Object.fromEntries(ranking.rows.map(row => [row.id, row])) + expect(rows.defaults.heartRate.tuning).toMatchObject({ ownMae: 5.6, coverage: 1 }) + expect(rows['gate-0.45'].heartRate.tuning).toMatchObject({ ownMae: 0, coverage: 0.8, maeDifference: 0 }) + expect(rows['gate-0.6'].heartRate.tuning).toMatchObject({ ownMae: 0, coverage: 0.2, maeDifference: 0 }) + // Common support scores both gates as no change; own error with the coverage floor does not. + expect(ranking.rows.map(row => row.id)).toEqual(['gate-0.45', 'defaults', 'gate-0.6']) + expect(rankCandidates([localReport('gate-0.6', gate(0.6), 'tuning', { default: defaults, candidate: dropsMost }), + localReport('defaults', [], 'tuning', { default: defaults, candidate: defaults })], 0.1).rows[0].id).toBe('gate-0.6') + }) + + it('rejects a coverage floor outside 0 to 1', async () => { + for (const value of [Number.NaN, 75, -0.1]) expect(() => rankCandidates([], value), String(value)).toThrow('fraction from 0 to 1') + writeFileSync(join(directory, 'one.json'), JSON.stringify([{ id: 'x', overrides: [] }])) + for (const flag of ['75%', '75']) { + await expect(runLocalFitCli(['fit', directory, join(directory, 'one.json'), join(directory, 'out'), '--min-coverage', flag])).rejects.toThrow('--min-coverage is a fraction from 0 to 1') + } + }) + + it('shows holdout error alongside only when holdout nights were scored', () => { + const overrides = [{ path: 'heart_rate.trackingToleranceBpm', value: 10 }] + const without = rankingMarkdown(rankCandidates([localReport('tol10', overrides, 'tuning', { default: 60, candidate: 61 })])) + expect(without).not.toContain('holdout') + const ranking = rankCandidates([localReport('tol10', overrides, 'tuning', { default: 60, candidate: 61 }), localReport('tol10', overrides, 'holdout', { default: 60, candidate: 62 })]) + expect(ranking.rows[0].heartRate.holdout).toEqual({ maeDifference: -2, mae: 0, ownMae: 0, coverage: 1, scoredNights: 1, coverageReducedNights: 0 }) + expect(rankingMarkdown(ranking)).toContain('| 1 | tol10 | 1.000 | -1.000 | 100% | n/a | n/a | n/a | 0.000 | -2.000 | 100% |') + }) + + it('compares local default HR error with pod results within 0.1 bpm', () => { + const defaults = [localReport('defaults', [], 'tuning', { default: 60, candidate: 60 }), localReport('defaults', [], 'validation', { default: 60, candidate: 60 })] + const pod = (split: string, mae: number) => ({ report: { split, datasetHash: fixture.contentHash, numericNightSummary: [{ metric: 'heart_rate', baseline: 'default', candidate: 'current', + baselineHash: configHash(defaultConfig), candidateHash: configHash(defaultConfig), + nights: [{ cohortId: split === 'tuning' ? 'night-a' : 'night-b', commonSupport: { baseline: { pairedCount: 5, mae } } }] }] } }) + const close = compareWithPod(defaults, [pod('tuning', 2.05), pod('validation', 2)]) + expect(close.allWithinTolerance).toBe(true) + expect(close.rows.map(row => [row.cohortId, row.localMae, row.podMae])).toEqual([['night-a', 2, 2.05], ['night-b', 2, 2]]) + expect(compareWithPod(defaults, [pod('tuning', 2.2)]).allWithinTolerance).toBe(false) + expect(() => compareWithPod(defaults, [pod('holdout', 2)])).toThrow('holdout results are not compared') + expect(() => compareWithPod(defaults, [{ report: { ...pod('tuning', 2).report, datasetHash: 'f'.repeat(64) } }])).toThrow('different dataset') + }) +}) + +describe('local fit inputs', () => { + it('replays holdout nights only when explicitly included', async () => { + const bundle = { index: { dataset: { id: fixture.id, contentHash: fixture.contentHash } }, manifest: fixture.manifest, snapshot: fixture.snapshot, rawDirectory: fixture.rawDirectory } + await expect(fitCandidates({ bundle: bundle as never, candidates: [{ id: 'x', overrides: [] }], outputDirectory: directory, splits: ['holdout' as never] })) + .rejects.toThrow('Holdout nights are held back') + }) + + it('expands grids, including jointly varied axes', () => { + const candidates = expandGrid(DEFAULT_GRID) + expect(candidates).toHaveLength(18) + expect(new Set(candidates.map(row => row.id)).size).toBe(18) + expect(candidates[0]).toEqual({ id: 'tol10-hist3-brlo0.5-brhi1.5-mw10', overrides: [ + { path: 'heart_rate.trackingToleranceBpm', value: 10 }, { path: 'heart_rate.trackingHistoryLength', value: 3 }, + { path: 'breathing_rate.intervalLowerRatio', value: 0.5 }, { path: 'breathing_rate.intervalUpperRatio', value: 1.5 }, { path: 'hrv_index.minimumWindows', value: 10 }] }) + for (const candidate of candidates) expect(() => resolveConfig(candidate.overrides as never)).not.toThrow() + expect(() => expandGrid({ 'a,b': [[1]] })).toThrow('needs 2 values') + }) + + it('keeps grid ids distinct and valid for signed, repeated and long values', () => { + const ids = expandGrid({ 'heart_rate.trackingToleranceBpm': [-1, 1], 'a/b.c d': ['x y'] }).map(row => row.id) + expect(ids).toEqual(['tolm1-c_dx_y', 'tol1-c_dx_y']) + const long = expandGrid({ 'heart_rate.trackingToleranceBpm': ['a'.repeat(90), `${'a'.repeat(90)}b`] }).map(row => row.id) + expect(new Set(long).size).toBe(2) + expect(long.every(id => id.length <= 80)).toBe(true) + }) + + it('rejects candidate ids that would collide with runner output paths', () => { + for (const id of ['.', '..', 'work', 'bundle', 'readings', 'ranking.json', 'run.json', '-x']) { + expect(() => candidateFileSchema.parse([{ id, overrides: [] }]), id).toThrow() + } + expect(candidateFileSchema.parse([{ id: 'tol10.v2', overrides: [] }])).toHaveLength(1) + }) + + it('rejects a --jobs value that is not a positive whole number', async () => { + writeFileSync(join(directory, 'one.json'), JSON.stringify([{ id: 'x', overrides: [] }])) + for (const flag of ['four', '4x', '0', '1.5']) { + await expect(runLocalFitCli(['fit', directory, join(directory, 'one.json'), join(directory, 'jobs-out'), '--jobs', flag]), flag).rejects.toThrow('--jobs is a positive whole number') + } + }) + + it('refuses a bundle stored where the runner extracts it', async () => { + const out = join(directory, 'self') + mkdirSync(out) + writeFileSync(join(out, 'bundle'), 'not a tar') + writeFileSync(join(out, 'candidates.json'), JSON.stringify([{ id: 'x', overrides: [] }])) + await expect(runLocalFitCli(['fit', join(out, 'bundle'), join(out, 'candidates.json'), out])).rejects.toThrow('cannot live inside') + }) +}) diff --git a/scripts/biometrics-local-fit/report.ts b/scripts/biometrics-local-fit/report.ts new file mode 100644 index 000000000..cb6a243a4 --- /dev/null +++ b/scripts/biometrics-local-fit/report.ts @@ -0,0 +1,163 @@ +import { z } from 'zod' +import type { evaluateBacktest } from '@/src/biometrics/backtest/evaluator' +import { defaultConfig, type Override } from '@/src/biometrics/algorithms/registry' +import type { createNumericNightSummary } from '@/src/biometrics/backtest/numeric-night-summary' + +export const METRICS = ['heart_rate', 'breathing_rate', 'window_ibi_variability_v1'] as const +type Metric = typeof METRICS[number] +type EvaluatorReport = ReturnType +type NightSummary = ReturnType>[number] + +const mean = (values: number[]) => values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null + +interface OwnScore { pairedCount: number, metrics: { mae: number } | null } +interface NumericWindow { metrics: Array<{ metric: string, baseline: string, candidate: string, ownSupport: { baseline: OwnScore, candidate: OwnScore } }> } + +/** Pool each configuration's own-support error across a night's windows. Unlike + * common support, this credits a candidate that abstains on hard windows, so it + * is always reported with the Watch coverage it kept. */ +function ownSupportNights(report: EvaluatorReport, metric: Metric) { + return (report.nights as Array<{ cohortId: string, numericComparisons?: NumericWindow[] }>).map((night) => { + const pooled = { default: { paired: 0, error: 0 }, candidate: { paired: 0, error: 0 } } + for (const window of night.numericComparisons ?? []) { + const row = window.metrics.find(item => item.metric === metric && item.baseline === 'default' && item.candidate === 'candidate') + if (!row) continue + for (const [key, score] of [['default', row.ownSupport.baseline], ['candidate', row.ownSupport.candidate]] as const) { + if (score.metrics && score.pairedCount) { + pooled[key].paired += score.pairedCount + pooled[key].error += score.metrics.mae * score.pairedCount + } + } + } + const mae = (key: 'default' | 'candidate') => pooled[key].paired ? pooled[key].error / pooled[key].paired : null + return { cohortId: night.cohortId, defaultPairedCount: pooled.default.paired, candidatePairedCount: pooled.candidate.paired, + defaultMae: mae('default'), candidateMae: mae('candidate') } + }) +} + +function metricSummary(report: EvaluatorReport, metric: Metric) { + const summary = (report.numericNightSummary as NightSummary[] | undefined) + ?.find(row => row.metric === metric && row.baseline === 'default' && row.candidate === 'candidate') + const nights = (summary?.nights ?? []).map(night => ({ cohortId: night.cohortId, pairedCount: night.commonSupport.candidate.pairedCount, + defaultMae: night.commonSupport.baseline.mae, candidateMae: night.commonSupport.candidate.mae, maeDifference: night.commonMaeDifference, + coverageReduced: night.coverageReduced, lostObservationTimestamps: night.lostObservationTimestamps })) + const scored = nights.filter(night => night.maeDifference !== null) + const own = ownSupportNights(report, metric) + const ownScored = own.filter(night => night.defaultMae !== null && night.candidateMae !== null) + const defaultPairs = own.reduce((sum, night) => sum + night.defaultPairedCount, 0) + return { status: scored.length ? 'scored' as const : 'no_paired_reference' as const, + totalNights: nights.length, scoredNights: scored.length, coverageReducedNights: nights.filter(night => night.coverageReduced).length, + equalNightMeanMae: { default: mean(scored.map(night => night.defaultMae as number)), candidate: mean(scored.map(night => night.candidateMae as number)) }, + equalNightMeanMaeDifference: summary?.equalNightMeanMaeDifference ?? null, nights, + ownSupport: { equalNightMeanMae: { default: mean(ownScored.map(night => night.defaultMae as number)), candidate: mean(ownScored.map(night => night.candidateMae as number)) }, + referenceCoverage: defaultPairs ? own.reduce((sum, night) => sum + night.candidatePairedCount, 0) / defaultPairs : null, nights: own } } +} +export type MetricSummary = ReturnType + +/** One report per candidate per split. Everything here was computed off-pod + * and is labelled so it cannot be mistaken for the pod's backtest evidence. */ +export function summarizeLocalReport(input: { + candidate: { id: string, overrides: Array<{ path: string, value: unknown }>, resolvedHash: string } + split: 'tuning' | 'validation' | 'holdout' + report: EvaluatorReport + dataset: { id: string, contentHash: string } + build: { pythonBuildHash: string, pythonSourcesHash: string, evaluatorBuildHash: string } + protocol: unknown +}) { + return { version: 'biometrics-local-fit-report-v1' as const, label: 'exploratory-local' as const, + evidence: 'not_activation_evidence' as const, + notice: 'Computed off-pod from an exported dataset bundle. Only a backtest run on the pod can support activation; create the candidate with propose_biometric_profile and backtest it there.', + candidate: { id: input.candidate.id, overrides: input.candidate.overrides as Override[], resolvedHash: input.candidate.resolvedHash }, + split: input.split, dataset: { id: input.dataset.id, contentHash: input.dataset.contentHash }, build: input.build, protocol: input.protocol, + summary: Object.fromEntries(METRICS.map(metric => [metric, metricSummary(input.report, metric)])) as Record, + report: input.report } +} +export type LocalFitReport = ReturnType + +const reportShape = z.object({ version: z.literal('biometrics-local-fit-report-v1'), label: z.literal('exploratory-local'), + evidence: z.literal('not_activation_evidence'), candidate: z.object({ id: z.string(), resolvedHash: z.string() }).passthrough(), + split: z.enum(['tuning', 'validation', 'holdout']), summary: z.record(z.string(), z.object({ status: z.string(), nights: z.array(z.unknown()) }).passthrough()) }).passthrough() +export function parseLocalReport(value: unknown) { + return reportShape.parse(value) as unknown as LocalFitReport +} + +/** Rank by the tuning split's equal-night mean HR error difference against + * defaults on common support (lower is better), validation shown alongside. */ +export function rankCandidates(reports: LocalFitReport[], minimumCoverage = 0.75) { + if (!Number.isFinite(minimumCoverage) || minimumCoverage < 0 || minimumCoverage > 1) throw new Error('Minimum coverage must be a fraction from 0 to 1') + const ids = [...new Set(reports.map(report => report.candidate.id))] + const pick = (id: string, split: 'tuning' | 'validation' | 'holdout') => reports.find(report => report.candidate.id === id && report.split === split) + const view = (report: LocalFitReport | undefined, metric: Metric) => report + ? { maeDifference: report.summary[metric].equalNightMeanMaeDifference, mae: report.summary[metric].equalNightMeanMae.candidate, + ownMae: report.summary[metric].ownSupport?.equalNightMeanMae.candidate ?? null, coverage: report.summary[metric].ownSupport?.referenceCoverage ?? null, + scoredNights: report.summary[metric].scoredNights, coverageReducedNights: report.summary[metric].coverageReducedNights } + : null + const key = (value: number | null | undefined) => value ?? Number.POSITIVE_INFINITY + // Ties go to the candidate that changes the fewest defaults. + const changes = (overrides: Override[]) => overrides.filter(row => row.path.split('.') + .reduce((parent, part) => (parent as Record | undefined)?.[part], defaultConfig) !== row.value).length + const rows = ids.map((id) => { + const tuning = pick(id, 'tuning'), validation = pick(id, 'validation'), holdout = pick(id, 'holdout') + const candidate = (tuning ?? validation ?? holdout)?.candidate + return { id, overrides: candidate?.overrides ?? [], resolvedHash: candidate?.resolvedHash ?? '', changedDefaults: changes(candidate?.overrides ?? []), + heartRate: { tuning: view(tuning, 'heart_rate'), validation: view(validation, 'heart_rate'), ...(holdout ? { holdout: view(holdout, 'heart_rate') } : {}) }, + breathingRate: { tuning: view(tuning, 'breathing_rate'), validation: view(validation, 'breathing_rate') }, + hrv: { tuning: view(tuning, 'window_ibi_variability_v1'), validation: view(validation, 'window_ibi_variability_v1') } } + }) + // A candidate below the coverage floor ranks after every one above it, so + // abstaining on most of the night cannot win on error alone. + const kept = (row: typeof rows[number]) => (row.heartRate.tuning?.coverage ?? 1) >= minimumCoverage ? 0 : 1 + rows.sort((a, b) => kept(a) - kept(b) + || key(a.heartRate.tuning?.ownMae) - key(b.heartRate.tuning?.ownMae) + || key(a.heartRate.validation?.ownMae) - key(b.heartRate.validation?.ownMae) + || (a.heartRate.tuning?.coverageReducedNights ?? 0) - (b.heartRate.tuning?.coverageReducedNights ?? 0) + || a.changedDefaults - b.changedDefaults + || a.id.localeCompare(b.id)) + return { version: 'biometrics-local-fit-ranking-v1' as const, label: 'exploratory-local' as const, evidence: 'not_activation_evidence' as const, + rankedBy: 'tuning.heart_rate.ownSupport_equalNightMeanMae_with_reference_coverage_floor' as const, minimumCoverage, + rows: rows.map((row, index) => ({ rank: index + 1, ...row })) } +} + +const format = (value: number | null | undefined, digits = 3) => value === null || value === undefined ? 'n/a' : value.toFixed(digits) +const percent = (value: number | null | undefined) => value === null || value === undefined ? 'n/a' : `${Math.round(value * 100)}%` +export function rankingMarkdown(ranking: ReturnType) { + const holdout = ranking.rows.some(row => row.heartRate.holdout) + const splits = ['tuning', 'validation', ...(holdout ? ['holdout'] : [])] as const + const lines = [`_Exploratory, computed off-pod. Not activation evidence. Ranked by tuning HR error on each candidate's own readings, among candidates keeping ≥${percent(ranking.minimumCoverage)} of the defaults' Watch coverage._`, '', + `| # | candidate | ${splits.map(split => `${split} HR MAE | ${split} Δ common | ${split} coverage`).join(' | ')} | BR Δ (t) | HRV Δ (t) |`, + `| ---: | --- | ${splits.map(() => '---: | ---: | ---:').join(' | ')} | ---: | ---: |`] + for (const row of ranking.rows) { + const cells = splits.map((split) => { + const view = row.heartRate[split as 'tuning'] + return `${format(view?.ownMae)} | ${format(view?.maeDifference)} | ${percent(view?.coverage)}` + }) + lines.push(`| ${row.rank} | ${row.id} | ${cells.join(' | ')} | ${format(row.breathingRate.tuning?.maeDifference)} | ${format(row.hrv.tuning?.maeDifference)} |`) + } + return `${lines.join('\n')}\n` +} + +const podResultSchema = z.object({ report: z.object({ split: z.enum(['tuning', 'validation', 'holdout']), datasetHash: z.string(), + numericNightSummary: z.array(z.object({ metric: z.string(), baseline: z.string(), candidate: z.string(), baselineHash: z.string(), candidateHash: z.string(), + nights: z.array(z.object({ cohortId: z.string(), commonSupport: z.object({ baseline: z.object({ pairedCount: z.number(), mae: z.number().nullable() }).passthrough() }).passthrough() }).passthrough()) }).passthrough()) }).passthrough() }).passthrough() + +/** Compare local per-night HR error for defaults with the pod's own result + * files (GET /api/biometric-tuning/backtests/{id}/result). */ +export function compareWithPod(defaults: LocalFitReport[], podResults: unknown[], toleranceBpm = 0.1) { + const rows = podResults.flatMap((raw) => { + const report = podResultSchema.parse(raw).report + if (report.split === 'holdout') throw new Error('Parity compares tuning and validation results only; holdout results are not compared') + const local = defaults.find(row => row.split === report.split) + const defaultsHash = defaults[0]?.candidate.resolvedHash + const pod = report.numericNightSummary.find(row => row.metric === 'heart_rate' && row.baseline === 'default' && row.baselineHash === defaultsHash) + if (!pod) throw new Error(`Pod ${report.split} result has no heart-rate summary for the shipped defaults`) + if (local && local.dataset.contentHash !== report.datasetHash) throw new Error('Pod result is for a different dataset') + return pod.nights.map((night) => { + const localNight = local?.summary.heart_rate.nights.find(row => row.cohortId === night.cohortId) + const podMae = night.commonSupport.baseline.mae, localMae = localNight?.defaultMae ?? null + const difference = podMae === null || localMae === null ? null : localMae - podMae + return { split: report.split, cohortId: night.cohortId, podMae, localMae, podPairedCount: night.commonSupport.baseline.pairedCount, + localPairedCount: localNight?.pairedCount ?? null, difference, withinTolerance: difference !== null && Math.abs(difference) <= toleranceBpm } + }) + }) + return { version: 'biometrics-local-fit-parity-v1' as const, toleranceBpm, rows, allWithinTolerance: rows.length > 0 && rows.every(row => row.withinTolerance) } +} diff --git a/scripts/import-watch-health.py b/scripts/import-watch-health.py index 3e117d8cb..42456e974 100755 --- a/scripts/import-watch-health.py +++ b/scripts/import-watch-health.py @@ -1,29 +1,32 @@ #!/usr/bin/env -S uv run --quiet --no-project """ -Import Apple Watch sleep stages and heart rate from a Health export into the pod. +Import Apple Watch sleep stages, heart rate, breathing rate and HRV from a Health export into the pod. scripts/import-watch-health.py ~/Desktop/apple_health_export --pod 192.168.1.88 --side left Reads export.xml (or the unzipped apple_health_export/ directory) with the same parser as sleep-stage-dataset.py, keeps samples whose sourceName matches ---source (default: Watch), and posts them to the pod's REST API: +--source (default: Watch) and was not written by sleepypod itself, and posts +them to the pod's REST API: POST /api/biometrics/reference-stages SleepAnalysis segments (wake/light/deep/rem) POST /api/biometrics/reference-vitals HeartRate samples + POST /api/biometrics/reference-samples RespiratoryRate (breathing_rate, breaths/min) + and HeartRateVariabilitySDNN (hrv_sdnn, ms) -Heart rate is first-write-wins on side + time + source, so re-running on a -newer export only adds what is new. Stages are stricter: a batch is rejected +Heart rate, breathing rate and HRV are first-write-wins on side + time + +source (+ metric), so re-running on a newer export only adds what is new. Stages are stricter: a batch is rejected (HTTP 400) when a segment overlaps a stored one with different boundaries, e.g. a night the iOS app already posted. Such a batch is skipped with a -warning, heart rate is still imported, and the run exits 1 at the end. +warning, the vitals are still imported, and the run exits 1 at the end. Nothing is written to the pod's own `vitals` table: Watch heart rate lives in -`reference_vitals`, and the Biometrics page draws it as a second line over the -pod's heart rate. +`reference_vitals` (the Biometrics page draws it as a second line over the +pod's heart rate), breathing rate and HRV in `reference_samples`. Options: --days N only samples from the last N days (default 30; 0 = everything) --dry-run parse and count, post nothing - --stages-only / --hr-only + --stages-only / --hr-only (heart rate alone, without breathing rate and HRV) """ import argparse import importlib.util @@ -43,6 +46,18 @@ _spec.loader.exec_module(_ds) parse_export = _ds.parse_export +HK_RR = 'HKQuantityTypeIdentifierRespiratoryRate' +HK_HRV = 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN' +# reference_samples metric, HK type, plausible range (the API enforces the same) +SAMPLE_METRICS = (('breathing_rate', HK_RR, 4, 60), ('hrv_sdnn', HK_HRV, 1, 500)) +# Factors into the canonical unit (breaths/min, ms). Health exports use +# count/min and ms; anything unlisted is skipped instead of stored unconverted. +SAMPLE_UNITS = {HK_RR: {'count/min': 1, 'count/s': 60, 'Hz': 60}, HK_HRV: {'ms': 1, 's': 1000}} +# The sleepypod iOS app writes the pod's own HR, breathing, HRV and sleep stages +# back into Health (sourceName "sleepypod", "sleepypod design"). Those are never +# a reference, whatever --source says. +POD_SOURCE = '(?i)sleepypod' + # The API caps both endpoints at 2000 items per call. BATCH = 2000 # importReferenceStages rejects a batch whose segments overlap each other. @@ -127,14 +142,18 @@ def export_stream(export): def run(export, pod, side, source_pattern='Watch', days=30, dry_run=False, - stages=True, hr=True, post=post_json, now=None, report=False): + stages=True, hr=True, extra=True, post=post_json, now=None, report=False): now = time.time() if now is None else now cutoff = now - days * 86400 if days > 0 else float('-inf') + quantities = {hk: [] for _, hk, _, _ in SAMPLE_METRICS} with export_stream(export) as xml: - segments, hr_samples = parse_export(xml, source_pattern, cutoff=cutoff) + segments, hr_samples = parse_export(xml, source_pattern, cutoff=cutoff, quantities=quantities, + exclude_pattern=POD_SOURCE, units=SAMPLE_UNITS) segs = [s for s in dedupe_segments(segments) if s[1] >= cutoff] if stages else [] samples = [(t, bpm) for t, bpm in hr_samples if t >= cutoff and 20 <= bpm <= 250] if hr else [] + extras = {metric: [(t, v) for t, v in quantities[hk] if t >= cutoff and lo <= v <= hi] if extra else [] + for metric, hk, lo, hi in SAMPLE_METRICS} def span(times): if not times: @@ -144,12 +163,14 @@ def span(times): return f'{fmt(lo)} → {fmt(hi)}' print(f'{len(segs)} stage segments ({span([s[0] for s in segs])}), ' - f'{len(samples)} heart-rate samples ({span([t for t, _ in samples])}) for side {side}', file=sys.stderr) + f'{len(samples)} heart-rate samples ({span([t for t, _ in samples])}), ' + f"{len(extras['breathing_rate'])} breathing-rate and {len(extras['hrv_sdnn'])} HRV samples for side {side}", + file=sys.stderr) if dry_run: - return {'stages': 0, 'vitals': 0} + return {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0} url = base_url(pod) - written = {'stages': 0, 'vitals': 0} + written = {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0} rejected = 0 for batch in chunks(segs): try: @@ -170,12 +191,21 @@ def span(times): 'samples': [{'timestamp': t, 'heartRate': bpm} for t, bpm in batch], }) written['vitals'] += int(out.get('written', 0)) - print(f"wrote {written['stages']} new stage segments and {written['vitals']} new heart-rate samples to {url}", + for metric, key in (('breathing_rate', 'breathing'), ('hrv_sdnn', 'hrv')): + for batch in chunks(extras[metric]): + out = post(f'{url}/api/biometrics/reference-samples', { + 'side': side, 'source': 'apple_watch', 'metric': metric, + 'samples': [{'timestamp': t, 'value': v} for t, v in batch], + }) + written[key] += int(out.get('written', 0)) + print(f"wrote {written['stages']} new stage segments, {written['vitals']} new heart-rate, " + f"{written['breathing']} new breathing-rate and {written['hrv']} new HRV samples to {url}", file=sys.stderr) if report: - return {**written, 'rejectedStageBatches': rejected, 'matchedStages': len(segs), 'matchedVitals': len(samples)} + return {**written, 'rejectedStageBatches': rejected, 'matchedStages': len(segs), 'matchedVitals': len(samples), + 'matchedBreathing': len(extras['breathing_rate']), 'matchedHrv': len(extras['hrv_sdnn'])} if rejected: - raise SystemExit(f'error: {rejected} stage batch(es) rejected by the pod; heart rate was still imported') + raise SystemExit(f'error: {rejected} stage batch(es) rejected by the pod; vitals were still imported') return written @@ -197,7 +227,8 @@ def main(argv=None): if not os.path.exists(export): ap.error(f'{export} not found') result = run(export, args.pod, args.side, args.source, args.days, args.dry_run, - stages=not args.hr_only, hr=not args.stages_only, report=args.json) + stages=not args.hr_only, hr=not args.stages_only, extra=not (args.hr_only or args.stages_only), + report=args.json) if args.json: print(json.dumps(result)) return 0 diff --git a/scripts/install b/scripts/install index 9d93211fe..b0f1bfb2a 100755 --- a/scripts/install +++ b/scripts/install @@ -279,9 +279,9 @@ reclaim_rootfs_caches() { rm -rf /var/lib/dnf fi - # Journal vacuum. The journald config below caps SystemMaxUse=50M, but - # only enforces it on rotation. A one-shot vacuum brings the on-disk - # size in line immediately; the cap keeps growth bounded after that. + # Journal vacuum. The journald config below keeps new logs in RAM, so + # the on-disk journal stops growing; a one-shot vacuum trims what the + # firmware's persistent journal already holds. if command -v journalctl &>/dev/null; then echo " Vacuuming systemd journal to 20M..." journalctl --vacuum-size=20M >/dev/null 2>&1 || true @@ -790,6 +790,23 @@ if id dac &>/dev/null; then usermod -aG dac sleepypod fi +# Add sleepypod and dac to systemd-journal (or adm on images without it) so the +# Next.js /system/logs endpoint can run journalctl without permission errors. +_group_exists() { + getent group "$1" &>/dev/null || grep -q "^$1:" /etc/group 2>/dev/null +} +if _group_exists systemd-journal; then + usermod -aG systemd-journal sleepypod 2>/dev/null || true + if id dac &>/dev/null; then + usermod -aG systemd-journal dac 2>/dev/null || true + fi +elif _group_exists adm; then + usermod -aG adm sleepypod 2>/dev/null || true + if id dac &>/dev/null; then + usermod -aG adm dac 2>/dev/null || true + fi +fi + # Allow the sleepypod service user to run sp-update as root without a # password. The next-server runs as User=sleepypod and exposes a tRPC # system.triggerUpdate mutation that spawns sp-update; without this @@ -931,7 +948,7 @@ fi # Install Node.js via binary download (works on any Linux, no apt required) # ============================================================================ NODE_WANTED=24 -NODE_FULL="24.16.0" +NODE_FULL="24.22.1" NODE_DIR="/usr/local/lib/nodejs" # Detect architecture @@ -943,9 +960,17 @@ case "$ARCH" in *) echo "Error: Unsupported architecture: $ARCH" >&2; exit 1 ;; esac -# Check if adequate Node is already installed -CURRENT_NODE_MAJOR=$(node -v 2>/dev/null | cut -d. -f1 | tr -d v || echo "0") -if [ "$CURRENT_NODE_MAJOR" -lt "$NODE_WANTED" ]; then +# Check the security patch floor as well as the supported major version. +# A major-only check would leave vulnerable Node 24 installations untouched. +node_version_is_supported() { + local version="${1#v}" major minor patch + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + IFS=. read -r major minor patch <<< "$version" + [ "$major" -eq "$NODE_WANTED" ] && + { [ "$minor" -gt 22 ] || { [ "$minor" -eq 22 ] && [ "$patch" -ge 1 ]; }; } +} +CURRENT_NODE_VERSION=$(node -v 2>/dev/null || true) +if ! node_version_is_supported "$CURRENT_NODE_VERSION"; then echo "Installing Node.js $NODE_FULL ($NODE_ARCH)..." NODE_TARBALL="node-v${NODE_FULL}-linux-${NODE_ARCH}.tar.gz" NODE_BASE_URL="https://nodejs.org/dist/v${NODE_FULL}" @@ -999,13 +1024,12 @@ if [ "$CURRENT_NODE_MAJOR" -lt "$NODE_WANTED" ]; then echo "Node.js $(node -v) installed." else - echo "Node.js $(node -v) already installed (>= $NODE_WANTED)." + echo "Node.js $(node -v) already installed (>= $NODE_FULL, major $NODE_WANTED)." fi # Verify Node.js version -NODE_MAJOR=$(node -v | cut -d. -f1 | tr -d v) -if [ "$NODE_MAJOR" -lt "$NODE_WANTED" ]; then - echo "Error: Node.js >= $NODE_WANTED required, found $(node -v)" >&2 +if ! node_version_is_supported "$(node -v)"; then + echo "Error: Node.js >= $NODE_FULL (major $NODE_WANTED) required, found $(node -v)" >&2 exit 1 fi @@ -1375,12 +1399,12 @@ mkdir -p /etc/iptables # to insufficient permissions" on pods where the journal isn't # world-readable. SUPP_GROUPS=() -if getent group dac &>/dev/null; then +if _group_exists dac; then SUPP_GROUPS+=(dac) fi -if getent group systemd-journal &>/dev/null; then +if _group_exists systemd-journal; then SUPP_GROUPS+=(systemd-journal) -elif getent group adm &>/dev/null; then +elif _group_exists adm; then SUPP_GROUPS+=(adm) fi SUPP_GROUPS_LINE="" @@ -1434,6 +1458,17 @@ Environment="XTABLES_LOCKFILE=/run/dac/xtables.lock" # (required DAC directory permissions and reboot authorization). ExecStartPre=+/bin/sh -c '[ "$DAC_SOCK_PATH" != "/deviceinfo/dac.sock" ] && ln -sf $DAC_SOCK_PATH /deviceinfo/dac.sock 2>/dev/null; true' ExecStartPre=+/usr/local/bin/sp-maintenance +# Replay and shadow workers (User=sleepypod) serialize on a host-wide flock +# at /run/sleepypod-shadow.lock, but cannot create files in root-owned /run. +# Create or adopt it for the service user on every start (/run is tmpfs). +# Append-open, never truncate or replace: a surviving worker may hold it. +ExecStartPre=+/bin/sh -c ': >> /run/sleepypod-shadow.lock && chown sleepypod:sleepypod /run/sleepypod-shadow.lock && chmod 600 /run/sleepypod-shadow.lock' +# Internet access toggles iptables as the sleepypod user, which has +# CAP_NET_ADMIN but cannot load kernel modules. Pod 4 boots without the +# iptables modules, so legacy iptables fails ("Table does not exist (do you +# need to insmod?)") until something running as root loads them. Load them +# here as root; best-effort, as some kernels build them in. +ExecStartPre=+/bin/sh -c 'PATH=/usr/sbin:/sbin:/usr/bin:/bin modprobe -a ip_tables iptable_filter iptable_nat 2>/dev/null; true' Environment="PATH=/usr/local/bin:/usr/bin:/bin" # No fallback when the build is missing: pnpm start cannot run as the # sleepypod user (home is /nonexistent), so it only crash-looped with a @@ -1472,15 +1507,9 @@ RuntimeDirectory=dac WantedBy=multi-user.target EOF -# Cap journal size to prevent unbounded log growth on limited disk -mkdir -p /etc/systemd/journald.conf.d -cat > /etc/systemd/journald.conf.d/sleepypod.conf << 'JOURNALD' -[Journal] -SystemMaxUse=50M -SystemKeepFree=200M -MaxFileSec=1week -JOURNALD -systemctl restart systemd-journald 2>/dev/null || true +# Keep the journal in RAM so firmware log chatter doesn't wear the eMMC +source "$INSTALL_DIR/scripts/lib/journald-helpers" +install_journald_config # Install CLI tools from scripts/bin/ BEFORE the service starts. The systemd # unit's ExecStartPre references /usr/local/bin/sp-maintenance — if these diff --git a/scripts/lib/biometrics-archiver-helpers b/scripts/lib/biometrics-archiver-helpers index 32e6c5df0..65d7e53c3 100644 --- a/scripts/lib/biometrics-archiver-helpers +++ b/scripts/lib/biometrics-archiver-helpers @@ -211,7 +211,7 @@ install_biometrics_archiver() { # Scripts local s - for s in sleepypod-tmpfs-prep sleepypod-biometrics-linker \ + for s in sleepypod-recording-policy sleepypod-tmpfs-prep sleepypod-biometrics-linker \ sleepypod-biometrics-archiver sleepypod-biometrics-pruner; do install -m 0755 "$archiver_src/$s" "/usr/local/bin/$s" done diff --git a/scripts/lib/journald-helpers b/scripts/lib/journald-helpers new file mode 100644 index 000000000..34e90bf9c --- /dev/null +++ b/scripts/lib/journald-helpers @@ -0,0 +1,45 @@ +# shellcheck shell=bash +# Keep the systemd journal in RAM (/run/log/journal). Eight's firmware sets +# Storage=persistent and links /var/log/journal to /persistent/journal, so +# frank's steady log stream otherwise lands on the eMMC around the clock. A +# drop-in overrides journald.conf; logs from earlier boots are the cost. +# Shared by install and sp-update so existing pods pick it up on update. +install_journald_config() { + local file="/etc/systemd/journald.conf.d/sleepypod.conf" tmp backup="" + mkdir -p /etc/systemd/journald.conf.d + tmp=$(mktemp) + cat > "$tmp" <<'JOURNALD' +[Journal] +Storage=volatile +RuntimeMaxUse=48M +RuntimeKeepFree=100M +MaxFileSec=1week +JOURNALD + if cmp -s "$tmp" "$file"; then + rm -f "$tmp" + return 0 + fi + if [ -f "$file" ]; then + backup=$(mktemp) + cp -fp "$file" "$backup" + fi + install -m 0644 "$tmp" "$file" + rm -f "$tmp" + # journald keeps service stdout streams in its fd store across a restart. + # If it won't restart, put the previous config back and bring journald up + # on it, so the next install or update retries. Non-fatal either way. + if ! systemctl restart systemd-journald 2>/dev/null; then + if [ -n "$backup" ]; then + install -m 0644 "$backup" "$file" + else + rm -f "$file" + fi + systemctl restart systemd-journald 2>/dev/null || true + if systemctl is-active --quiet systemd-journald; then + echo "Warning: systemd-journald rejected the RAM-only journal config — kept the previous one, will retry on next update" >&2 + else + echo "Warning: systemd-journald is not running after restoring its previous config — check 'systemctl status systemd-journald'" >&2 + fi + fi + rm -f "$backup" +} diff --git a/scripts/sleep-stage-dataset.py b/scripts/sleep-stage-dataset.py index 37321c606..69dc4e77c 100755 --- a/scripts/sleep-stage-dataset.py +++ b/scripts/sleep-stage-dataset.py @@ -82,21 +82,31 @@ def night_of(dt): # ── Health export ────────────────────────────────────────────────────────── -def parse_export(path, source_pattern='Watch', cutoff=None): +def parse_export(path, source_pattern='Watch', cutoff=None, quantities=None, exclude_pattern=None, units=None): """Stream export.xml and return (stage_segments, hr_samples) for sources - matching source_pattern. + matching source_pattern and, when given, not matching exclude_pattern. stage_segments: [(start_dt, end_dt, stage)] in file order (aware datetimes) hr_samples: [(unix_s, bpm)] sorted by time + quantities: optional {HK quantity type: list}; each list receives that + type's (unix_s, value) samples, sorted by time + units: optional {HK quantity type: {unit: factor}}; values of those + types are multiplied into the canonical unit, and records in + any other unit are skipped rather than imported unconverted """ if isinstance(path, (str, os.PathLike)) and os.path.isdir(path): path = os.path.join(path, 'export.xml') source_re = re.compile(source_pattern) + exclude_re = re.compile(exclude_pattern) if exclude_pattern else None segments, hr = [], [] + quantities = quantities or {} + units = units or {} def add(a): kind = a.get('type') - if kind not in (HK_SLEEP, HK_HR) or not source_re.search(a.get('sourceName', '')): + source = a.get('sourceName', '') + if (kind not in (HK_SLEEP, HK_HR) and kind not in quantities) \ + or not source_re.search(source) or (exclude_re and exclude_re.search(source)): return try: start = datetime.strptime(a['startDate'], '%Y-%m-%d %H:%M:%S %z') @@ -111,11 +121,16 @@ def add(a): segments.append((start, end, stage)) return try: - bpm = float(a['value']) + value = float(a['value']) except (KeyError, ValueError): return - if math.isfinite(bpm): - hr.append((int(start.timestamp()), bpm)) + if kind in units: + factor = units[kind].get(a.get('unit', '')) + if factor is None: + return + value *= factor + if math.isfinite(value): + (hr if kind == HK_HR else quantities[kind]).append((int(start.timestamp()), value)) # Incremental parse, so record layout (line breaks, attribute wrapping) # does not matter. Clearing the root after each record keeps memory flat. @@ -126,6 +141,8 @@ def add(a): add(elem.attrib) root.clear() hr.sort() + for samples in quantities.values(): + samples.sort() return segments, hr diff --git a/scripts/tests/biometricsArchiver.test.ts b/scripts/tests/biometricsArchiver.test.ts index 5f2403ae7..d603cc2bc 100644 --- a/scripts/tests/biometricsArchiver.test.ts +++ b/scripts/tests/biometricsArchiver.test.ts @@ -59,6 +59,7 @@ function scriptEnv(extraEnv: Partial): NodeJS.ProcessEnv { ...process.env, PATH: [stubBinDir, process.env.PATH ?? ''].join(':'), UNMOUNTED_FILE: unmountedFile, + SP_RECORDING_POLICY_FILE: join(root, 'recording-policy.json'), TMPFS_DIR: tmpfsDir, ARCHIVE_DIR: archiveDir, ...extraEnv, @@ -468,3 +469,28 @@ describe.each(['remove_biometrics_archiver_for_nats', 'remove_biometrics_archive expect(existsSync(recoveryTool)).toBe(true) }) }) + +describe('recording controls', () => { + it('releases only RAM pins when disabled and leaves live frames and saved archives intact', () => { + const live = writeRaw() + expect(runLinker().status).toBe(0) + expect(pendingEntries()).toEqual(['00001.RAW']) + mkdirSync(archiveDir, { recursive: true }) + const saved = join(archiveDir, 'saved.RAW.gz') + writeFileSync(saved, 'previous archive') + writeFileSync(join(root, 'recording-policy.json'), '{\n "rawArchive": false\n}\n') + expect(runLinker().status).toBe(0) + expect(pendingEntries()).toEqual([]) + expect(existsSync(live)).toBe(true) + const completed = writeRaw('completed.RAW') + utimesSync(completed, new Date(0), new Date(0)) + expect(runArchiver().status).toBe(0) + expect(existsSync(completed)).toBe(false) + expect(existsSync(live)).toBe(true) + expect(readdirSync(archiveDir)).toEqual(['saved.RAW.gz']) + expect(readFileSync(saved, 'utf8')).toBe('previous archive') + writeFileSync(join(root, 'recording-policy.json'), '{\n "rawArchive": true\n}\n') + expect(runLinker().status).toBe(0) + expect(pendingEntries()).toEqual(['00001.RAW']) + }) +}) diff --git a/scripts/tests/iptablesModules.test.ts b/scripts/tests/iptablesModules.test.ts new file mode 100644 index 000000000..d8a4c61a9 --- /dev/null +++ b/scripts/tests/iptablesModules.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +// The internet access toggle runs iptables as the sleepypod user (CAP_NET_ADMIN, +// no module loading). Pod 4 boots without the iptables modules, so the unit +// must load them as root before the service starts. +const installScript = readFileSync(resolve('scripts/install'), 'utf8') +const updateScript = readFileSync(resolve('scripts/bin/sp-update'), 'utf8') + +const MODULES_LINE = /^ExecStartPre=\+\/bin\/sh -c '[^']*modprobe -a ip_tables iptable_filter iptable_nat[^']*; true'$/m + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'sleepypod-iptables-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function dropInBlock(): string { + const match = updateScript.match(/IPTABLES_MODULES_DROPIN=[\s\S]*?\nfi\n/) + if (!match) throw new Error('Could not find the iptables modules drop-in in sp-update') + return match[0] +} + +describe('iptables kernel modules', () => { + it('fresh installs and OTA updates load the same modules as root, best-effort', () => { + const fromInstall = installScript.match(MODULES_LINE)?.[0] + const fromUpdate = updateScript.match(MODULES_LINE)?.[0] + expect(fromInstall).toBeDefined() + expect(fromUpdate).toBe(fromInstall) + // The unit's Environment PATH has no sbin; modprobe lives there. + expect(fromInstall).toContain('PATH=/usr/sbin:/sbin:') + expect(fromInstall).not.toContain('$') + }) + + it('sp-update writes the drop-in once and reloads systemd only when it changes', () => { + const dropIn = join(root, 'sleepypod.service.d', 'iptables-modules.conf') + const reloads = join(root, 'reloads') + writeFileSync(reloads, '') + const block = dropInBlock().replace( + /^IPTABLES_MODULES_DROPIN=.*$/m, + `IPTABLES_MODULES_DROPIN=${dropIn}`, + ) + const script = `systemctl() { echo "$*" >> ${reloads}; }\n${block}${block}` + const result = spawnSync('/bin/bash', ['-eu', '-o', 'pipefail', '-c', script], { encoding: 'utf8' }) + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(dropIn, 'utf8')).toMatch(/^\[Service\]\nExecStartPre=\+.*modprobe -a ip_tables/) + expect(readFileSync(reloads, 'utf8')).toBe('daemon-reload\n') + }) + + it('never fails the service start when modprobe is missing', () => { + const line = installScript.match(MODULES_LINE)?.[0] ?? '' + const command = (line.match(/-c '(.*)'$/)?.[1] ?? 'false') + .replace('PATH=/usr/sbin:/sbin:/usr/bin:/bin', `PATH=${root}`) + expect(command).toContain(`PATH=${root} modprobe`) + const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) + expect(result.status).toBe(0) + }) +}) diff --git a/scripts/tests/serviceGroups.test.ts b/scripts/tests/serviceGroups.test.ts new file mode 100644 index 000000000..81bad4d27 --- /dev/null +++ b/scripts/tests/serviceGroups.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment node +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +let root: string +let groupFile: string + +const installScript = readFileSync(resolve('scripts/install'), 'utf8') +const updateScript = readFileSync(resolve('scripts/bin/sp-update'), 'utf8') + +function extractHelper(source: string): string { + const match = source.match(/_group_exists\(\)\s*\{[\s\S]*?\n\}/) + if (!match) { + throw new Error('Could not find _group_exists in source') + } + return match[0] +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'sleepypod-groups-')) + groupFile = join(root, 'group') + writeFileSync(groupFile, 'dac:x:1000:sleepypod\nsystemd-journal:x:992:\nadm:x:4:\n') +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function runBash(script: string) { + return spawnSync('/bin/bash', ['-eu', '-o', 'pipefail', '-c', script], { + encoding: 'utf8', + }) +} + +describe('service groups on minimal Yocto images', () => { + it('defines _group_exists consistently in scripts/install and scripts/bin/sp-update', () => { + const installHelper = extractHelper(installScript) + const updateHelper = extractHelper(updateScript) + expect(installHelper).toBe(updateHelper) + }) + + it('finds exact local groups without getent using /etc/group fallback', () => { + const helper = extractHelper(installScript).replace('/etc/group', groupFile) + const result = runBash(` + getent() { return 127; } + ${helper} + _group_exists dac + ! _group_exists da + ! _group_exists dac-extra + _group_exists systemd-journal + ! _group_exists journal + `) + expect(result.status).toBe(0) + }) + + it('retains NSS lookup and falls back when getent fails', () => { + const helper = extractHelper(installScript).replace('/etc/group', groupFile) + // When getent succeeds, group is found even if missing from /etc/group + const nssSuccess = runBash(` + getent() { return 0; } + ${helper} + _group_exists remote-nss-group + `) + expect(nssSuccess.status).toBe(0) + + // When getent fails (returns non-zero), falls back to local /etc/group + const nssFallback = runBash(` + getent() { return 2; } + ${helper} + _group_exists dac + ! _group_exists non-existent-group + `) + expect(nssFallback.status).toBe(0) + }) + + it('resolves SupplementaryGroups in scripts/install without getent', () => { + const helper = extractHelper(installScript).replace('/etc/group', groupFile) + + const runInstallSuppGroups = () => runBash(` + getent() { return 127; } + ${helper} + SUPP_GROUPS=() + if _group_exists dac; then + SUPP_GROUPS+=(dac) + fi + if _group_exists systemd-journal; then + SUPP_GROUPS+=(systemd-journal) + elif _group_exists adm; then + SUPP_GROUPS+=(adm) + fi + SUPP_GROUPS_LINE="" + if [ \${#SUPP_GROUPS[@]} -gt 0 ]; then + SUPP_GROUPS_LINE="SupplementaryGroups=\${SUPP_GROUPS[*]}" + fi + printf '%s' "$SUPP_GROUPS_LINE" + `) + + // systemd-journal preferred when both systemd-journal and adm exist + expect(runInstallSuppGroups().stdout).toBe('SupplementaryGroups=dac systemd-journal') + + // adm fallback when systemd-journal is absent + writeFileSync(groupFile, 'dac:x:1000:sleepypod\nadm:x:4:\n') + expect(runInstallSuppGroups().stdout).toBe('SupplementaryGroups=dac adm') + + // only dac when neither journal group exists + writeFileSync(groupFile, 'dac:x:1000:sleepypod\n') + expect(runInstallSuppGroups().stdout).toBe('SupplementaryGroups=dac') + + // empty when no groups exist + writeFileSync(groupFile, '') + expect(runInstallSuppGroups().stdout).toBe('') + }) + + it('resolves supp_group in scripts/bin/sp-update without getent', () => { + const helper = extractHelper(updateScript).replace('/etc/group', groupFile) + + const runUpdateSuppGroup = () => runBash(` + getent() { return 127; } + ${helper} + supp_group="" + if _group_exists systemd-journal; then + supp_group="systemd-journal" + elif _group_exists adm; then + supp_group="adm" + fi + printf '%s' "$supp_group" + `) + + // systemd-journal preferred + expect(runUpdateSuppGroup().stdout).toBe('systemd-journal') + + // adm fallback + writeFileSync(groupFile, 'adm:x:4:\n') + expect(runUpdateSuppGroup().stdout).toBe('adm') + + // empty when neither exists + writeFileSync(groupFile, 'dac:x:1000:\n') + expect(runUpdateSuppGroup().stdout).toBe('') + }) +}) diff --git a/scripts/tests/test_import_watch_health.py b/scripts/tests/test_import_watch_health.py index 0055c07ca..ed15063cb 100644 --- a/scripts/tests/test_import_watch_health.py +++ b/scripts/tests/test_import_watch_health.py @@ -39,12 +39,22 @@ def hr_record(at, bpm, source=WATCH): f'startDate="{hk_time(at)}" endDate="{hk_time(at)}" value="{bpm}"/>\n') -def write_export(path, segments, hr): +def quantity_record(kind, at, value, unit, source=WATCH): + return (f' \n') + + +def write_export(path, segments, hr, rr=(), hrv=(), extra=''): parts = ['\n\n'] for a, b, v in segments: parts.append(sleep_record(T0 + timedelta(minutes=a), T0 + timedelta(minutes=b), v)) for m, bpm in hr: parts.append(hr_record(T0 + timedelta(minutes=m), bpm)) + for m, v in rr: + parts.append(quantity_record('RespiratoryRate', T0 + timedelta(minutes=m), v, 'count/min')) + for m, v in hrv: + parts.append(quantity_record('HeartRateVariabilitySDNN', T0 + timedelta(minutes=m), v, 'ms')) + parts.append(extra) parts.append('\n') with open(path, 'w', encoding='utf-8') as f: f.write(''.join(parts)) @@ -105,14 +115,14 @@ def test_posts_stages_and_heart_rate_to_the_pod_endpoints(self): {'timestamp': int((T0 + timedelta(minutes=6)).timestamp()), 'heartRate': 56.5}, {'timestamp': int((T0 + timedelta(minutes=11)).timestamp()), 'heartRate': 61.0}, ]) - self.assertEqual(written, {'stages': 3, 'vitals': 3}) - self.assertIn('wrote 3 new stage segments and 3 new heart-rate samples', err) + self.assertEqual(written, {'stages': 3, 'vitals': 3, 'breathing': 0, 'hrv': 0}) + self.assertIn('wrote 3 new stage segments, 3 new heart-rate', err) def test_dry_run_posts_nothing_but_reports_counts(self): write_export(self.export, [(0, 20, 'AsleepCore')], [(1, 58)]) post, written, err = self.run_import(dry_run=True) self.assertEqual(post.calls, []) - self.assertEqual(written, {'stages': 0, 'vitals': 0}) + self.assertEqual(written, {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0}) self.assertIn('1 stage segments', err) self.assertIn('1 heart-rate samples', err) @@ -123,7 +133,7 @@ def test_days_window_drops_old_samples(self): self.assertEqual(len(post.calls[0][1]['segments']), 1) self.assertEqual(len(post.calls[1][1]['samples']), 1) _, written_all, _ = self.run_import(days=0) - self.assertEqual(written_all, {'stages': 2, 'vitals': 2}) + self.assertEqual(written_all, {'stages': 2, 'vitals': 2, 'breathing': 0, 'hrv': 0}) def test_batches_at_the_api_limit(self): write_export(self.export, [], [(m, 60) for m in range(0, 4500)]) @@ -175,6 +185,52 @@ def test_ignores_other_sources(self): post, _, _ = self.run_import(source_pattern='Nope') self.assertEqual(post.calls, []) + def test_posts_breathing_rate_and_hrv_per_metric(self): + write_export(self.export, [], [(1, 58)], rr=[(2, 14.5), (7, 15), (8, 2), (9, 90)], hrv=[(3, 48), (4, 0), (5, 900)]) + post, written, err = self.run_import() + t = lambda m: int((T0 + timedelta(minutes=m)).timestamp()) + self.assertEqual([u.rsplit('/', 1)[1] for u, _ in post.calls], + ['reference-vitals', 'reference-samples', 'reference-samples']) + self.assertEqual(post.calls[1][1], {'side': 'left', 'source': 'apple_watch', 'metric': 'breathing_rate', + 'samples': [{'timestamp': t(2), 'value': 14.5}, {'timestamp': t(7), 'value': 15.0}]}) + self.assertEqual(post.calls[2][1], {'side': 'left', 'source': 'apple_watch', 'metric': 'hrv_sdnn', + 'samples': [{'timestamp': t(3), 'value': 48.0}]}) + self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 2, 'hrv': 1}) + self.assertIn('2 breathing-rate and 1 HRV samples', err) + # --hr-only and --stages-only leave breathing rate and HRV out. + post, _, _ = self.run_import(stages=False, extra=False) + self.assertEqual([u.rsplit('/', 1)[1] for u, _ in post.calls], ['reference-vitals']) + + def test_converts_breathing_and_hrv_units_and_skips_unknown_ones(self): + extra = (quantity_record('RespiratoryRate', T0 + timedelta(minutes=2), 0.25, 'count/s') + + quantity_record('RespiratoryRate', T0 + timedelta(minutes=3), 15, 'count/h') + + quantity_record('HeartRateVariabilitySDNN', T0 + timedelta(minutes=4), 0.042, 's') + + quantity_record('HeartRateVariabilitySDNN', T0 + timedelta(minutes=5), 42, 'us')) + write_export(self.export, [], [(1, 58)], extra=extra) + post, written, _ = self.run_import() + t = lambda m: int((T0 + timedelta(minutes=m)).timestamp()) + self.assertEqual(post.calls[1][1]['samples'], [{'timestamp': t(2), 'value': 15.0}]) + self.assertEqual(post.calls[2][1]['samples'], [{'timestamp': t(4), 'value': 42.0}]) + self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 1, 'hrv': 1}) + + def test_never_imports_records_sleepypod_wrote_to_health(self): + at = T0 + timedelta(minutes=1) + pod = ''.join([ + hr_record(at, 99, source='sleepypod'), + quantity_record('RespiratoryRate', at, 30, 'count/min', source='sleepypod'), + quantity_record('HeartRateVariabilitySDNN', at, 99, 'ms', source='sleepypod design'), + sleep_record(at, at + timedelta(minutes=30), 'AsleepDeep', source='sleepypod'), + # Even when the source pattern would match it. + hr_record(at, 98, source='sleepypod Watch'), + ]) + write_export(self.export, [(0, 20, 'AsleepCore')], [(2, 58)], rr=[(2, 14)], hrv=[(2, 40)], extra=pod) + post, written, _ = self.run_import() + values = [s.get('heartRate', s.get('value')) for _, p in post.calls for s in p.get('samples', [])] + self.assertEqual(values, [58.0, 14.0, 40.0]) + self.assertEqual([s['stage'] for s in post.calls[0][1]['segments']], ['light']) + _, written_any, _ = self.run_import(source_pattern='.') + self.assertEqual(written_any, {'stages': 1, 'vitals': 1, 'breathing': 1, 'hrv': 1}) + def test_base_url_forms(self): self.assertEqual(iw.base_url('192.168.1.88'), 'http://192.168.1.88:3000') self.assertEqual(iw.base_url('pod.local:8080'), 'http://pod.local:8080') diff --git a/scripts/tests/test_personal_models.py b/scripts/tests/test_personal_models.py new file mode 100644 index 000000000..642fe68ff --- /dev/null +++ b/scripts/tests/test_personal_models.py @@ -0,0 +1,109 @@ +"""Synthetic regressions for candidate uploads and explicit Watch label gaps.""" +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +train = load('train_personal', 'scripts/train-personal-models.py') +models = load('personal', 'docs/research/sleep-stage-calibration/personal-models.py') + + +class CandidateTests(unittest.TestCase): + def test_upload_routes_and_explicit_selection(self): + for select, activate in [(False, False), (True, False), (False, True)]: + with self.subTest(select=select, activate=activate), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'model.json' + path.write_text('{"version":"test"}') + calls = [] + + def response(request, timeout): + calls.append((request.method, request.full_url, json.loads(request.data))) + return io.StringIO('{"id":7}') + + with patch.object(train.urllib.request, 'urlopen', side_effect=response), patch('sys.stdout', new_callable=io.StringIO) as output: + train.upload('pod', 'left', path, select=select, activate=activate) + self.assertEqual(calls[0], ('POST', 'http://pod:3000/api/sleep-stages/models', {'side': 'left', 'model': {'version': 'test'}})) + self.assertEqual(calls[1], ('POST', 'http://pod:3000/api/sleep-stages/model/preview', {'side': 'left', 'nights': 14, 'model': {'version': 'test'}})) + self.assertIn('in-sample', output.getvalue()) + self.assertEqual(len(calls), 3 if select or activate else 2) + if select or activate: + self.assertEqual(calls[2], ('PUT', 'http://pod:3000/api/sleep-stages/models/selected', {'side': 'left', 'id': 7, 'active': activate})) + + def test_failed_preview_never_selects(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'model.json' + path.write_text('{}') + with patch.object(train, 'request_json', side_effect=[{'id': 7}, RuntimeError('offline')]) as request: + with self.assertRaises(RuntimeError): + train.upload('pod', 'left', path, activate=True) + self.assertEqual(request.call_count, 2) + + def test_conflicting_flags_refused_before_any_work(self): + for flags in [['--no-upload', '--select'], ['--no-upload', '--activate'], ['--select', '--activate']]: + with patch('sys.argv', ['train', '--pod', 'pod'] + flags), patch.object(train, 'snapshot') as snapshot: + with self.assertRaises(SystemExit) as error: + train.main() + self.assertEqual(error.exception.code, 2) + snapshot.assert_not_called() + + +class LabelTests(unittest.TestCase): + def test_explicit_gap_not_bridged_by_same_stage(self): + rows = [{'ts': t, 'watch_stage': stage} for t, stage in [(0, 'light'), (60, ''), (120, 'light'), (180, 'light')]] + epochs = [{'start': mid - 150} for mid in [30, 60, 90, 120, 150, 210, 240]] + self.assertEqual([e['start'] + 150 for e, _ in models.label(epochs, rows)], [30, 120, 150, 210]) + + def test_missing_sample_still_bridges_and_stage_change_does_not(self): + epochs = [{'start': -60}] + self.assertEqual(len(models.label(epochs, [{'ts': 0, 'watch_stage': 'light'}, {'ts': 120, 'watch_stage': 'light'}])), 1) + self.assertEqual(models.label(epochs, [{'ts': 0, 'watch_stage': 'light'}, {'ts': 120, 'watch_stage': 'deep'}]), []) + +class CommandTests(unittest.TestCase): + def test_command_defaults_candidate_and_propagates_explicit_flags(self): + from types import SimpleNamespace + for flag in [[], ['--select'], ['--activate'], ['--no-upload']]: + with tempfile.TemporaryDirectory() as tmp: + args = ['train', '--pod', 'pod', '--work', tmp, '--snapshot', 'existing.db'] + flag + with patch('sys.argv', args), patch.object(train, 'run', return_value=SimpleNamespace(stdout='logistic: pooled kappa 0.2\nforest: pooled kappa 0.3\n')), patch.object(train, 'upload') as upload, patch('sys.stdout', new_callable=io.StringIO): + train.main() + if flag == ['--no-upload']: + upload.assert_not_called() + else: + upload.assert_called_once_with('pod', 'left', Path(tmp) / 'contract-B/personal-forest.json', select=flag == ['--select'], activate=flag == ['--activate']) + + def test_snapshot_cleanup_and_no_scored_model(self): + from types import SimpleNamespace + with tempfile.TemporaryDirectory() as tmp: + db = Path(tmp) / 'biometrics.db'; db.write_text('synthetic') + with patch('sys.argv', ['train', '--pod', 'pod', '--work', tmp]), patch.object(train, 'snapshot', return_value=db), patch.object(train, 'run', return_value=SimpleNamespace(stdout='no models')), patch('sys.stdout', new_callable=io.StringIO): + with self.assertRaisesRegex(SystemExit, 'no model scored'): + train.main() + self.assertFalse(db.exists()) + + def test_http_error_reports_endpoint_without_selecting(self): + error = train.urllib.error.HTTPError('http://pod', 400, 'bad', {}, io.BytesIO(b'invalid model')) + with patch.object(train.urllib.request, 'urlopen', side_effect=error): + with self.assertRaisesRegex(SystemExit, 'pod refused models \\(400\\): invalid model'): + train.request_json('pod', 'models', {}) + + def test_snapshot_transport_and_subprocess_wrapper(self): + with patch.object(train, 'run') as run: + self.assertEqual(train.snapshot('pod', Path('/tmp/synthetic')), Path('/tmp/synthetic/biometrics.db')) + self.assertEqual(run.call_count, 3) + self.assertIn('src.backup(dst)', run.call_args_list[0].kwargs['input']) + with patch.object(train.subprocess, 'run') as run: + train.run([Path('/tmp/synthetic-command'), 'argument']) + run.assert_called_once_with(['/tmp/synthetic-command', 'argument'], check=True, text=True) diff --git a/scripts/train-personal-models.py b/scripts/train-personal-models.py new file mode 100755 index 000000000..d5341958c --- /dev/null +++ b/scripts/train-personal-models.py @@ -0,0 +1,139 @@ +#!/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 stores and previews a candidate 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 request_json(pod, endpoint, body, method='POST'): + request = urllib.request.Request(f'http://{pod}:3000/api/sleep-stages/{endpoint}', + data=json.dumps(body).encode(), method=method, + 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 {endpoint} ({error.code}): {detail[:400]}') + + +def upload(pod, side, path, *, select=False, activate=False): + """Store and preview a candidate. Selection requires explicit opt-in.""" + model = json.loads(path.read_text()) + stored = request_json(pod, 'models', {'side': side, 'model': model}) + print(f'pod stored candidate {stored}') + preview = request_json(pod, 'model/preview', {'side': side, 'nights': 14, 'model': model}) + print('Preview (training nights are in-sample):') + print(json.dumps(preview, indent=2)) + if select or activate: + selected = request_json(pod, 'models/selected', + {'side': side, 'id': stored['id'], 'active': activate}, 'PUT') + print(f'pod selected {selected}') + return stored + + +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('--select', action='store_true', help='select the candidate, inactive (disables personal staging)') + ap.add_argument('--activate', action='store_true', help='select and activate the candidate after preview') + 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() + if args.select and args.activate: + ap.error('--select and --activate cannot be combined') + if args.no_upload and (args.select or args.activate): + ap.error('--no-upload cannot be combined with --select or --activate') + + 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 + upload(args.pod, args.side, path, select=args.select, activate=args.activate) + + +if __name__ == '__main__': + main() diff --git a/src/biometrics/algorithms/registry-core.ts b/src/biometrics/algorithms/registry-core.ts index 2f62a2f65..33f318477 100644 --- a/src/biometrics/algorithms/registry-core.ts +++ b/src/biometrics/algorithms/registry-core.ts @@ -16,7 +16,7 @@ interface ParameterSchema { } export interface BiometricConfig { - heart_rate: { trackingToleranceBpm: number, trackingHistoryLength: number } + heart_rate: { trackingToleranceBpm: number, trackingHistoryLength: number, minimumPeakScore: number } breathing_rate: { intervalLowerRatio: number, intervalUpperRatio: number } hrv_index: { harmonicTolerance: number, minimumWindows: number } sleep_stages: StageClassifier diff --git a/src/biometrics/analysis-export-bundle.ts b/src/biometrics/analysis-export-bundle.ts new file mode 100644 index 000000000..4d6cb7842 --- /dev/null +++ b/src/biometrics/analysis-export-bundle.ts @@ -0,0 +1,177 @@ +import { createHash, hkdfSync } from 'node:crypto' +import { constants, createReadStream } from 'node:fs' +import { open, readFile, stat } from 'node:fs/promises' +import { hostname } from 'node:os' +import { join, resolve } from 'node:path' +import Database from 'better-sqlite3' +import { getDacMonitorIfRunning } from '@/src/hardware/dacMonitor.instance' +import { POD_CAPS } from '@/src/hardware/pods' +import { readSeed } from '@/src/homekit/storage' +import { ANALYSIS_EXPORT_FORMAT, RAW_MTIME_LAG_MS, RAW_MTIME_LEAD_MS, tarBytes, type AnalysisWindow, type WindowArchive } from './analysis-export' +import { TAR_END, tarHeader, tarPadding } from './backtest/bundle-tar' + +export const ANALYSIS_DATABASE_PATH = 'biometrics.db' +export const ANALYSIS_MANIFEST_PATH = 'manifest.json' + +export interface SchemaVersion { migrationHash: string, migrationCreatedAt: number, migrationTag: string | null, appliedMigrations: number } +export interface PodIdentity { + id: string | null + idSource: ReturnType['source'] + hostname: string + model: { podVersion: string, name: string, hardwareRevision: string | null } | null +} +export interface BuildInfo { branch: string, commitHash: string, commitTitle: string, buildDate: string } + +export interface AnalysisBundle { + generatedAtMs: number + window: AnalysisWindow + database: { path: string, bytes: number } + archives: WindowArchive[] + build: BuildInfo + pod: PodIdentity + schema: SchemaVersion | null +} + +/** Latest applied drizzle migration of a biometrics database file. */ +export async function readSchemaVersion(databasePath: string, journalPath = resolve(process.cwd(), 'src/db/biometrics-migrations/meta/_journal.json')): Promise { + let row: { hash: string, createdAt: number, applied: number } | undefined + const database = new Database(databasePath, { readonly: true, fileMustExist: true }) + try { + row = database.prepare('SELECT hash, created_at AS createdAt, (SELECT COUNT(*) FROM __drizzle_migrations) AS applied FROM __drizzle_migrations ORDER BY created_at DESC, id DESC LIMIT 1') + .get() as typeof row + } + catch { + return null + } + finally { + database.close() + } + if (!row) return null + let migrationTag: string | null = null + try { + const journal = JSON.parse(await readFile(journalPath, 'utf-8')) as { entries?: Array<{ when?: unknown, tag?: unknown }> } + const entry = journal.entries?.find(item => item.when === Number(row.createdAt)) + if (typeof entry?.tag === 'string') migrationTag = entry.tag + } + catch { /* tag is provenance only */ } + return { migrationHash: row.hash, migrationCreatedAt: Number(row.createdAt), migrationTag, appliedMigrations: row.applied } +} + +/** Online backup through a separate read-only connection; never writes the source. */ +export async function snapshotBiometricsDatabase(sourcePath: string, directory: string) { + const path = join(directory, ANALYSIS_DATABASE_PATH) + const source = new Database(sourcePath, { readonly: true, fileMustExist: true }) + try { + await source.backup(path) + } + finally { + source.close() + } + return { path, bytes: (await stat(path)).size, schema: await readSchemaVersion(path) } +} + +/** + * Same hardware-rooted seed chain as the HomeKit identity (ADR 0020), so the id + * survives reinstalls and /persistent wipes. Derived through HKDF so the eMMC + * CID or serial itself never leaves the pod. Null when only a random dev seed exists. + */ +export function podIdentity(): PodIdentity { + const seed = readSeed() + const id = seed.source === 'random-dev' ? null : Buffer.from(hkdfSync('sha256', seed.value, 'sleepypod-pod-id-v1', 'pod-id', 16)).toString('hex') + const status = getDacMonitorIfRunning()?.getLastStatus() + const caps = status ? POD_CAPS[status.podVersion] : undefined + const model = status && caps + ? { podVersion: status.podVersion, name: caps.modelName, + hardwareRevision: status.sensorLabel.split('-').reverse().find(segment => /^[A-Z]\d{2}$/.test(segment)) ?? null } + : null + return { id, idSource: seed.source, hostname: hostname(), model } +} + +export async function readBuildInfo(): Promise { + const info = { branch: 'unknown', commitHash: 'unknown', commitTitle: 'unknown', buildDate: 'unknown' } + try { + const parsed = JSON.parse(await readFile('.git-info', 'utf-8')) as Record + for (const key of Object.keys(info) as Array) { + if (typeof parsed[key] === 'string') info[key] = parsed[key] + } + } + catch { /* not built from a checkout */ } + return info +} + +const PLACEHOLDER_HASH = '0'.repeat(64) + +/** manifest.json: provenance plus every other file with its size and SHA-256. */ +export function analysisManifest(bundle: AnalysisBundle, hashes: { database: string, raw: readonly string[] }) { + const { window, archives } = bundle + const mtimes = archives.map(archive => archive.mtimeMs) + return { + formatVersion: ANALYSIS_EXPORT_FORMAT, + generatedAt: new Date(bundle.generatedAtMs).toISOString(), + generatedAtMs: bundle.generatedAtMs, + build: bundle.build, + pod: bundle.pod, + schema: { biometrics: bundle.schema }, + window: { + requested: { startMs: window.startMs, endMs: window.endMs, start: new Date(window.startMs).toISOString(), end: new Date(window.endMs).toISOString() }, + rawSelection: { includeRaw: window.includeRaw, mtimeFromMs: window.startMs - RAW_MTIME_LEAD_MS, mtimeToMs: window.endMs + RAW_MTIME_LAG_MS }, + served: { archiveCount: archives.length, rawMtimeMinMs: mtimes.length ? Math.min(...mtimes) : null, rawMtimeMaxMs: mtimes.length ? Math.max(...mtimes) : null }, + }, + files: [ + { role: 'database', path: ANALYSIS_DATABASE_PATH, bytes: bundle.database.bytes, sha256: hashes.database }, + ...archives.map((archive, index) => ({ role: 'raw', path: `raw/${archive.name}`, name: archive.name, mtimeMs: archive.mtimeMs, + bytes: archive.bytes, sha256: hashes.raw[index] })), + ], + privacy: 'Contains the full biometrics database for both sides of the bed. Keep it on trusted machines.', + } +} + +// Hashes are fixed-width hex, so a placeholder manifest has the final length and +// Content-Length can be sent before the files are read. +function manifestBytes(bundle: AnalysisBundle) { + return Buffer.byteLength(JSON.stringify(analysisManifest(bundle, { database: PLACEHOLDER_HASH, raw: bundle.archives.map(() => PLACEHOLDER_HASH) }))) +} + +/** Exact byte length of the tar analysisExportChunks yields. */ +export function analysisBundleTarBytes(bundle: AnalysisBundle) { + return tarBytes([bundle.database.bytes, ...bundle.archives.map(archive => archive.bytes), manifestBytes(bundle)]) +} + +/** Stream one file as a tar entry, hashing on the way; it must keep its listed size. */ +async function* fileEntry(path: string, name: string, bytes: number, mtimeSeconds: number, digests: string[]) { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) + try { + const info = await handle.stat() + if (!info.isFile() || info.size !== bytes) throw new Error(`${name} changed size before export`) + yield tarHeader(name, bytes, mtimeSeconds) + const digest = createHash('sha256') + let sent = 0 + if (bytes > 0) { + for await (const chunk of createReadStream('', { fd: handle.fd, autoClose: false, start: 0, end: bytes - 1, highWaterMark: 256 * 1024 })) { + digest.update(chunk as Buffer) + sent += (chunk as Buffer).length + yield chunk as Buffer + } + } + if (sent !== bytes) throw new Error(`${name} shrank during export`) + digests.push(digest.digest('hex')) + yield tarPadding(bytes) + } + finally { + await handle.close() + } +} + +/** biometrics.db, raw/.RAW.gz in mtime order, then manifest.json with the hashes taken while streaming. */ +export async function* analysisExportChunks(bundle: AnalysisBundle) { + const generatedSeconds = bundle.generatedAtMs / 1000 + const digests: string[] = [] + yield* fileEntry(bundle.database.path, ANALYSIS_DATABASE_PATH, bundle.database.bytes, generatedSeconds, digests) + for (const archive of bundle.archives) yield* fileEntry(archive.path, `raw/${archive.name}`, archive.bytes, archive.mtimeMs / 1000, digests) + const manifest = Buffer.from(JSON.stringify(analysisManifest(bundle, { database: digests[0], raw: digests.slice(1) }))) + if (manifest.length !== manifestBytes(bundle)) throw new Error('Manifest length changed while streaming') + yield tarHeader(ANALYSIS_MANIFEST_PATH, manifest.length, generatedSeconds) + yield manifest + yield tarPadding(manifest.length) + yield TAR_END +} diff --git a/src/biometrics/analysis-export.ts b/src/biometrics/analysis-export.ts new file mode 100644 index 000000000..00e3b330e --- /dev/null +++ b/src/biometrics/analysis-export.ts @@ -0,0 +1,93 @@ +import { lstat, readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { ARCHIVE_DIR } from '@/src/lib/podStorage' + +export const ANALYSIS_EXPORT_FORMAT = 'analysis-export-v1' +export const ANALYSIS_EXPORT_PATH = '/api/biometrics/analysis-export' +export const MAX_ANALYSIS_WINDOW_MS = 14 * 24 * 60 * 60 * 1000 +export const MAX_ANALYSIS_RAW_BYTES = 2 * 1024 ** 3 +// A frame's archive is written ~15 min after the frame closes, so a frame that +// starts just before the window is stamped up to 20 min later, and the last +// frame of the window up to 45 min after its end. +export const RAW_MTIME_LEAD_MS = 20 * 60 * 1000 +export const RAW_MTIME_LAG_MS = 45 * 60 * 1000 + +// Only finished archives: the archiver writes .RAW.gz.tmp. and renames. +const ARCHIVE_NAME = /^[A-Za-z0-9_-]+\.RAW\.gz$/ + +const instant = z.string().trim().min(1).max(40) +export const analysisExportRequestSchema = z.object({ start: instant, end: instant, includeRaw: z.boolean().default(true) }).strict() +export type AnalysisExportRequest = z.input + +/** Start/end as epoch ms or ISO 8601, start < end, at most 14 days apart. */ +export const analysisWindowSchema = analysisExportRequestSchema.transform((input, ctx) => { + const toMs = (value: string, key: 'start' | 'end') => { + const ms = /^\d+$/.test(value) ? Number(value) : Date.parse(value) + if (Number.isSafeInteger(ms) && ms >= 0) return ms + ctx.addIssue({ code: 'custom', path: [key], message: `${key} must be an ISO 8601 timestamp or epoch milliseconds` }) + return null + } + const startMs = toMs(input.start, 'start'), endMs = toMs(input.end, 'end') + if (startMs === null || endMs === null) return z.NEVER + if (startMs >= endMs) ctx.addIssue({ code: 'custom', path: ['end'], message: 'start must be before end' }) + else if (endMs - startMs > MAX_ANALYSIS_WINDOW_MS) ctx.addIssue({ code: 'custom', path: ['end'], message: 'The window must be 14 days or shorter' }) + return { startMs, endMs, includeRaw: input.includeRaw } +}) +export type AnalysisWindow = z.output + +export class AnalysisExportTooLargeError extends Error { + constructor(readonly rawBytes: number, readonly archiveCount: number) { + super(`The window holds ${archiveCount} RAW archives totalling ${rawBytes} bytes, over the ${MAX_ANALYSIS_RAW_BYTES}-byte limit. Narrow the window or pass raw=0.`) + } +} + +export interface WindowArchive { name: string, path: string, bytes: number, mtimeMs: number } + +/** Finished RAW archives whose mtime falls in [start − 20 min, end + 45 min], oldest first. */ +export async function listWindowArchives(window: Pick, directory = ARCHIVE_DIR): Promise { + let names: string[] + try { + names = await readdir(directory) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + const from = window.startMs - RAW_MTIME_LEAD_MS, to = window.endMs + RAW_MTIME_LAG_MS + const archives: WindowArchive[] = [] + for (const name of names) { + if (!ARCHIVE_NAME.test(name)) continue + const path = join(directory, name) + // The pruner may delete an archive between readdir and lstat. + const info = await lstat(path).catch(() => null) + if (!info?.isFile()) continue + const mtimeMs = Math.floor(info.mtimeMs) + if (mtimeMs < from || mtimeMs > to) continue + archives.push({ name, path, bytes: info.size, mtimeMs }) + } + return archives.sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name)) +} + +/** Archives to send for a window; throws AnalysisExportTooLargeError over the cap. */ +export async function planAnalysisExport(window: AnalysisWindow, directory = ARCHIVE_DIR) { + const archives = window.includeRaw ? await listWindowArchives(window, directory) : [] + const rawBytes = archives.reduce((sum, archive) => sum + archive.bytes, 0) + if (rawBytes > MAX_ANALYSIS_RAW_BYTES) throw new AnalysisExportTooLargeError(rawBytes, archives.length) + return { window, archives, rawBytes } +} + +/** Live database plus its WAL: an upper bound for the size of its backup. */ +export async function liveDatabaseBytes(file: string) { + const sizes = await Promise.all([file, `${file}-wal`].map(name => stat(name).then(info => info.size, () => 0))) + return sizes[0] + sizes[1] +} + +/** Uncompressed ustar size: a header per entry, 512-byte padding, two end blocks. */ +export function tarBytes(entryBytes: readonly number[]) { + return entryBytes.reduce((sum, bytes) => sum + 512 + Math.ceil(bytes / 512) * 512, 1024) +} + +export function analysisExportDownloadPath(window: AnalysisWindow) { + return `${ANALYSIS_EXPORT_PATH}?start=${window.startMs}&end=${window.endMs}&raw=${window.includeRaw ? 1 : 0}` +} diff --git a/src/biometrics/backtest/bundle-tar.ts b/src/biometrics/backtest/bundle-tar.ts new file mode 100644 index 000000000..7851f09c6 --- /dev/null +++ b/src/biometrics/backtest/bundle-tar.ts @@ -0,0 +1,59 @@ +/** Minimal ustar framing for calibration bundles: regular files with short, + * relative names only. Writer and reader live together so their limits agree. */ +const BLOCK = 512 +const NAME = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)?$/ +export const TAR_END = Buffer.alloc(BLOCK * 2) + +function field(header: Buffer, offset: number, length: number, value: string) { + header.write(value, offset, Math.min(length, Buffer.byteLength(value)), 'ascii') +} + +function octal(value: number, digits: number) { + return value.toString(8).padStart(digits, '0') +} + +export function tarHeader(name: string, bytes: number, mtimeSeconds = 0) { + if (!NAME.test(name) || name.length > 100 || name.split('/').some(part => part === '.' || part === '..')) throw new Error('Invalid bundle entry name') + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes >= 8 ** 11) throw new Error('Invalid bundle entry size') + const header = Buffer.alloc(BLOCK) + field(header, 0, 100, name) + field(header, 100, 8, '0000600\0') + field(header, 108, 8, '0000000\0') + field(header, 116, 8, '0000000\0') + field(header, 124, 12, `${octal(bytes, 11)}\0`) + field(header, 136, 12, `${octal(Math.max(0, Math.floor(mtimeSeconds)), 11)}\0`) + field(header, 148, 8, ' ') + field(header, 156, 1, '0') + field(header, 257, 6, 'ustar\0') + field(header, 263, 2, '00') + let sum = 0 + for (const byte of header) sum += byte + field(header, 148, 8, `${octal(sum, 6)}\0 `) + return header +} + +export function tarPadding(bytes: number) { + return Buffer.alloc((BLOCK - (bytes % BLOCK)) % BLOCK) +} + +export interface TarEntryHeader { name: string, bytes: number } + +/** Parse one header block. Returns null for the zero end-of-archive block and + * rejects anything other than a checksummed regular file with a safe name. */ +export function parseTarHeader(block: Buffer): TarEntryHeader | null { + if (block.length !== BLOCK) throw new Error('Truncated bundle archive') + if (block.every(byte => byte === 0)) return null + const text = (offset: number, length: number) => block.subarray(offset, offset + length).toString('ascii').replace(/\0.*$/s, '') + let sum = 0 + for (let index = 0; index < BLOCK; index++) sum += index >= 148 && index < 156 ? 32 : block[index] + if (Number.parseInt(text(148, 8).trim(), 8) !== sum) throw new Error('Bundle archive header checksum mismatch') + if (!text(257, 6).startsWith('ustar')) throw new Error('Bundle archive is not ustar') + const type = text(156, 1) + if (type !== '0' && type !== '') throw new Error('Bundle archive may contain only regular files') + if (text(345, 155)) throw new Error('Bundle archive entry names must be short') + const name = text(0, 100) + if (!NAME.test(name) || name.split('/').some(part => part === '.' || part === '..')) throw new Error('Unsafe bundle archive entry name') + const bytes = Number.parseInt(text(124, 12).trim(), 8) + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new Error('Invalid bundle archive entry size') + return { name, bytes } +} diff --git a/src/biometrics/backtest/coordinator-lanes.ts b/src/biometrics/backtest/coordinator-lanes.ts index 14477eec2..f9cd7745f 100644 --- a/src/biometrics/backtest/coordinator-lanes.ts +++ b/src/biometrics/backtest/coordinator-lanes.ts @@ -2,7 +2,7 @@ import type { CoordinatorTask } from './coordinator-tasks' export type CoordinatorLane = 'read' | 'work' export const coordinatorTaskLanes: Record = { readShadowReport: 'read', - pageRawSources: 'read', datasetDescriptor: 'read', readResult: 'read', readSummary: 'read', readTrace: 'read', readNumericTrace: 'read', readReferenceTrace: 'read', + pageRawSources: 'read', datasetDescriptor: 'read', exportDataset: 'read', readResult: 'read', readSummary: 'read', readTrace: 'read', readNumericTrace: 'read', readReferenceTrace: 'read', createWaveformDataset: 'work', inspectRawSource: 'work', createDataset: 'work', releaseDataset: 'work', releaseResult: 'work', createExperiment: 'work', collectArtifacts: 'work', execute: 'work', } diff --git a/src/biometrics/backtest/coordinator-tasks.ts b/src/biometrics/backtest/coordinator-tasks.ts index bcc8e6140..9ab1f2992 100644 --- a/src/biometrics/backtest/coordinator-tasks.ts +++ b/src/biometrics/backtest/coordinator-tasks.ts @@ -12,6 +12,7 @@ import { summarizeBacktestResult } from './report-summary' import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import type * as schema from '@/src/db/biometrics-schema' import { DatasetStore, createDatasetSchema } from './datasets' +import { prepareDatasetExport } from './dataset-export' import { BacktestJobStore, experimentProtocolSchema } from './jobs' import { readStageTraces, readNumericTraces, readReferenceTraces } from './traces' import { traceRequestSchema } from './trace-contracts' @@ -31,6 +32,7 @@ export const coordinatorTaskSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('pageRawSources'), input: rawSourcePageSchema }).strict(), z.object({ type: z.literal('createDataset'), input: createDatasetSchema }).strict(), z.object({ type: z.literal('datasetDescriptor'), input: idInput }).strict(), + z.object({ type: z.literal('exportDataset'), input: idInput }).strict(), z.object({ type: z.literal('readResult'), input: idInput }).strict(), z.object({ type: z.literal('readSummary'), input: idInput }).strict(), z.object({ type: z.literal('readReferenceTrace'), input: referenceTraceRequestSchema }).strict(), @@ -50,6 +52,7 @@ export interface CoordinatorResults { pageRawSources: ReturnType createDataset: ReturnType datasetDescriptor: ReturnType + exportDataset: Awaited> readResult: ReturnType readSummary: ReturnType>>> readReferenceTrace: ReturnType @@ -79,6 +82,7 @@ export async function performCoordinatorTask(db: BetterSQLite3Database file.path === `raw/${file.sha256}.RAW.gz`, 'Raw bundle path must name its content hash'), +]) +/** The bundle's own table of contents. Every other file is listed with its exact + * bytes and SHA-256; a reader must reject anything unlisted. */ +export const datasetBundleIndexSchema = z.object({ + version: z.literal('calibration-dataset-bundle-v1'), + dataset: z.object({ id: z.string().uuid(), side: z.enum(['left', 'right']), subjectId: z.string(), contentHash: hash, + snapshotVersion: z.enum(['processed-dataset-v1', 'waveform-dataset-v1']), createdAtMs: size }).strict(), + files: z.array(datasetBundleFileSchema).min(2).max(1026), + limitations: z.array(z.string().max(500)).max(10), +}).strict() +export type DatasetBundleIndex = z.infer + +export const DATASET_BUNDLE_INDEX_PATH = 'bundle.json' +const LIMITATIONS = [ + 'Contains only this frozen dataset: its manifest, snapshot and the retained RAW archives it references.', + 'Off-pod replay of this bundle is exploratory; only a backtest on the pod can serve as activation evidence.', + 'Dual-channel RAW archives include the other side of the bed.', +] + +export function retainedRawDirectory(db: BetterSQLite3Database) { + const path = db.all<{ name: string, file: string }>(sql`PRAGMA database_list`).find(row => row.name === 'main')?.file + if (!path) throw new ReferenceInputError('WORKER_UNAVAILABLE', 'Dataset export requires a persistent biometrics database') + return join(dirname(path), 'biometric-raw') +} + +/** Open a retained artifact without following links; size must match its record. */ +export async function openRetainedArtifact(directory: string, name: string, expectedBytes: number) { + if (!/^[a-f0-9]{64}\.RAW\.gz$/.test(name)) throw new ReferenceInputError('DATASET_CORRUPT', 'Invalid retained artifact name') + const handle = await open(join(directory, name), constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => { + throw new ReferenceInputError('DATASET_CORRUPT', 'A retained RAW archive is missing') + }) + const info = await handle.stat() + if (!info.isFile() || info.size !== expectedBytes) { + await handle.close() + throw new ReferenceInputError('DATASET_CORRUPT', 'A retained RAW archive changed size') + } + return handle +} + +async function hashRetainedArtifact(directory: string, name: string, expectedBytes: number) { + const handle = await openRetainedArtifact(directory, name, expectedBytes) + try { + const digest = createHash('sha256') + for await (const chunk of createReadStream('', { fd: handle.fd, autoClose: false, highWaterMark: 1024 * 1024 })) digest.update(chunk as Buffer) + return digest.digest('hex') + } + finally { + await handle.close() + } +} + +/** Verify a live dataset and every retained archive it references before anything + * is streamed. Released or missing datasets surface as NOT_FOUND/DATASET_RELEASED. */ +export async function prepareDatasetExport(db: BetterSQLite3Database, id: string, retainedDirectory = retainedRawDirectory(db)) { + const dataset = new DatasetStore(db).read(id) + const references = db.select({ position: schema.biometricDatasetRawArtifacts.position, hash: schema.biometricRawArtifacts.contentHash, + name: schema.biometricRawArtifacts.name, compressedBytes: schema.biometricRawArtifacts.compressedBytes, expandedBytes: schema.biometricRawArtifacts.expandedBytes }) + .from(schema.biometricDatasetRawArtifacts) + .innerJoin(schema.biometricRawArtifacts, eq(schema.biometricRawArtifacts.contentHash, schema.biometricDatasetRawArtifacts.artifactHash)) + .where(and(eq(schema.biometricDatasetRawArtifacts.datasetId, id), isNull(schema.biometricDatasetRawArtifacts.releasedAtMs))) + .orderBy(asc(schema.biometricDatasetRawArtifacts.position)).all() + const frozen = new Map(dataset.snapshot.version === 'waveform-dataset-v1' + ? dataset.snapshot.waveformInputs.flatMap(cohort => cohort.input.artifacts.map(source => [source.artifact.sha256, source.artifact] as const)) + : []) + if (references.length !== frozen.size || references.some((row) => { + const artifact = frozen.get(row.hash) + return !artifact || row.name !== artifact.name || row.compressedBytes !== artifact.compressed_bytes || row.expandedBytes !== artifact.expanded_bytes + })) throw new ReferenceInputError('DATASET_CORRUPT', 'Retained RAW references do not match the frozen dataset') + const raw = [] + for (const row of references) { + if (await hashRetainedArtifact(retainedDirectory, row.name, row.compressedBytes) !== row.hash) { + throw new ReferenceInputError('DATASET_CORRUPT', 'A retained RAW archive failed its SHA-256 check') + } + raw.push({ role: 'raw' as const, path: `raw/${row.name}`, sha256: row.hash, bytes: row.compressedBytes, expandedBytes: row.expandedBytes }) + } + const manifestText = JSON.stringify(dataset.manifest) + const snapshotText = JSON.stringify(dataset.snapshot) + const text = (role: 'manifest' | 'snapshot', path: 'manifest.json' | 'snapshot.json', value: string) => ({ role, path, + sha256: createHash('sha256').update(value).digest('hex'), bytes: Buffer.byteLength(value) }) + const index = datasetBundleIndexSchema.parse({ version: 'calibration-dataset-bundle-v1', + dataset: { id: dataset.id, side: dataset.side, subjectId: dataset.subjectId, contentHash: dataset.contentHash, + snapshotVersion: dataset.snapshot.version, createdAtMs: dataset.createdAtMs }, + files: [text('manifest', 'manifest.json', manifestText), text('snapshot', 'snapshot.json', snapshotText), ...raw], + limitations: LIMITATIONS }) + return { index, manifestText, snapshotText } +} + +/** Exact byte length of the uncompressed tar stream the export route writes. */ +export function datasetBundleTarBytes(index: DatasetBundleIndex) { + const indexBytes = Buffer.byteLength(JSON.stringify(index)) + return [indexBytes, ...index.files.map(file => file.bytes)].reduce((sum, bytes) => sum + 512 + Math.ceil(bytes / 512) * 512, 1024) +} + +/** Tar chunks for one prepared export, in index order. */ +export async function* datasetBundleChunks(index: DatasetBundleIndex, texts: { manifest: string, snapshot: string }, directory: string) { + const mtime = index.dataset.createdAtMs / 1000 + const indexText = Buffer.from(JSON.stringify(index)) + yield tarHeader(DATASET_BUNDLE_INDEX_PATH, indexText.length, mtime) + yield indexText + yield tarPadding(indexText.length) + for (const file of index.files) { + if (file.role !== 'raw') { + const body = Buffer.from(file.role === 'manifest' ? texts.manifest : texts.snapshot) + yield tarHeader(file.path, body.length, mtime) + yield body + yield tarPadding(body.length) + continue + } + // Hash again while streaming: verification ran in the coordinator, and the + // bytes sent must be the bytes verified. A mismatch aborts the download. + const handle = await openRetainedArtifact(directory, `${file.sha256}.RAW.gz`, file.bytes) + try { + yield tarHeader(file.path, file.bytes, mtime) + const digest = createHash('sha256') + let sent = 0 + for await (const chunk of createReadStream('', { fd: handle.fd, autoClose: false, highWaterMark: 256 * 1024 })) { + const bytes = chunk as Buffer + digest.update(bytes) + sent += bytes.length + if (sent > file.bytes) throw new Error('Retained archive grew during export') + yield bytes + } + if (sent !== file.bytes || digest.digest('hex') !== file.sha256) throw new Error('Retained archive changed during export') + yield tarPadding(file.bytes) + } + finally { + await handle.close() + } + } + yield TAR_END +} diff --git a/src/biometrics/backtest/evaluator-core.ts b/src/biometrics/backtest/evaluator-core.ts index 1db9c3fde..d2bc3be3e 100644 --- a/src/biometrics/backtest/evaluator-core.ts +++ b/src/biometrics/backtest/evaluator-core.ts @@ -71,7 +71,8 @@ export function createBacktestEvaluator(dependencies: { configHash: (value: unkn || output.run.config_hashes[dataset.snapshot.side] !== output.configHash || output.run.config_hashes[otherSide] !== job.waveform.otherSide.resolvedHash || output.run.counts.scored_outputs !== output.readings.length - || output.run.raw_records < output.run.decoded_records || output.run.decoded_records < output.run.counts.records + // A Pod 5 frame can pack several records, so decoded records may outnumber frames but never exist without one. + || (output.run.raw_records === 0 && output.run.decoded_records > 0) || output.run.decoded_records < output.run.counts.records || JSON.stringify(output.run.artifact_hashes) !== JSON.stringify(retained.input.artifacts.map(row => row.artifact.sha256)) || output.readings.some(row => row.configHash !== output.run.config_hashes[row.side] || row.timestampMs < retained.input.score_start_ms || row.timestampMs >= retained.input.score_end_ms)) throw new Error('Waveform output identity mismatch') diff --git a/src/biometrics/backtest/raw-locate.ts b/src/biometrics/backtest/raw-locate.ts new file mode 100644 index 000000000..69af45fa6 --- /dev/null +++ b/src/biometrics/backtest/raw-locate.ts @@ -0,0 +1,35 @@ +import { readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { podRawInspectionLocation, rawSourceNameSchema } from './raw-inspection-child' + +/** An archive holds about 15 minutes and is closed when full, so its write time marks the end of its events. */ +const ARCHIVE_SPAN_MS = 20 * 60_000 +const MAX_LOCATED = 400 + +/** + * Archives likely to hold events in [startMs, endMs], oldest first, each with + * the cursor that makes the inspector read exactly it (the inspector takes + * the first name after its cursor, in the same plain name order). + * Write times only narrow the search; inspection establishes the event bounds. + */ +export function locateRawArchives(startMs: number, endMs: number, directory = podRawInspectionLocation.archiveDirectory) { + let names: string[] + try { + names = readdirSync(directory).filter(name => rawSourceNameSchema.safeParse(name).success).sort() + } + catch { + return [] + } + const located: Array<{ name: string, afterName: string | null }> = [] + for (let i = 0; i < names.length && located.length < MAX_LOCATED; i++) { + let writtenMs: number + try { + writtenMs = statSync(join(directory, names[i])).mtimeMs + } + catch { + continue + } + if (writtenMs >= startMs && writtenMs - ARCHIVE_SPAN_MS <= endMs) located.push({ name: names[i], afterName: i > 0 ? names[i - 1] : null }) + } + return located +} diff --git a/src/biometrics/backtest/runner.ts b/src/biometrics/backtest/runner.ts index de48c293e..0c5e74d71 100644 --- a/src/biometrics/backtest/runner.ts +++ b/src/biometrics/backtest/runner.ts @@ -18,6 +18,17 @@ import type { RawInspectionLocation } from './raw-inspection-child' import { workerBuildHash, runBacktestChild } from './child' /** One leased attempt. Call only after the host's resource/ingest gate admits work. */ +const GENERIC_FAILURE = 'Replay failed validation, resources, or execution' +/** + * Say which step failed when the error is one of this pipeline's own fixed + * messages; schema errors and anything that could carry data stay generic. + */ +export function failureMessage(error: unknown) { + if (error instanceof z.ZodError) return `${GENERIC_FAILURE}: job input or report did not match its schema` + if (error instanceof Error && /^[A-Za-z][A-Za-z ,;'()-]{2,119}$/.test(error.message)) return `${GENERIC_FAILURE}: ${error.message}` + return GENERIC_FAILURE +} + export async function executeNextBacktest(db: BetterSQLite3Database, options: { owner: string workerPath: string @@ -137,14 +148,14 @@ export async function executeNextBacktest(db: BetterSQLite3Database ({ db: undefined as unknown, failure: null as Error | null })) +vi.mock('@/src/db', () => ({ + get biometricsDb() { + return state.db + }, +})) +vi.mock('@/src/biometrics/backtest/coordinator-service', () => ({ + coordinatorRequest: (db: Parameters[0], task: { type: string, input: { id: string } }) => { + if (task.type !== 'exportDataset') throw new Error('unexpected task') + if (state.failure) return Promise.reject(state.failure) + return prepareDatasetExport(db, task.input.id) + }, +})) + +let directory: string +let fixture: ReturnType +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'dataset-export-')) + fixture = exportableWaveformDataset(directory) + state.db = fixture.db +}) +afterEach(() => { + state.failure = null + fixture.sqlite.close() + rmSync(directory, { recursive: true, force: true }) +}) + +/** Split a tar buffer into its entries using the shared header parser. */ +function untar(buffer: Buffer) { + const entries = new Map() + let position = 0 + for (;;) { + const header = parseTarHeader(buffer.subarray(position, position + 512)) + position += 512 + if (!header) break + entries.set(header.name, buffer.subarray(position, position + header.bytes)) + position += Math.ceil(header.bytes / 512) * 512 + } + return entries +} +const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex') +function tamper(name: string) { + const path = join(fixture.rawDirectory, name) + const bytes = readFileSync(path) + bytes[bytes.length - 1] ^= 0xff + writeFileSync(path, bytes) +} + +describe('prepareDatasetExport', () => { + it('lists the manifest, snapshot and exactly the referenced archives with their SHA-256', async () => { + const { index, manifestText, snapshotText } = await prepareDatasetExport(fixture.db, fixture.id) + expect(index.dataset).toMatchObject({ id: fixture.id, side: 'left', contentHash: fixture.contentHash, snapshotVersion: 'waveform-dataset-v1' }) + expect(index.files.map(file => file.path)).toEqual(['manifest.json', 'snapshot.json', ...fixture.artifacts.map(artifact => `raw/${artifact.name}`)]) + expect(index.files.some(file => file.path.includes(fixture.unrelated.sha256))).toBe(false) + expect(index.files[0]).toMatchObject({ sha256: sha256(Buffer.from(manifestText)), bytes: Buffer.byteLength(manifestText) }) + expect(index.files[1]).toMatchObject({ sha256: sha256(Buffer.from(snapshotText)), bytes: Buffer.byteLength(snapshotText) }) + expect(dataHash({ manifest: JSON.parse(manifestText), snapshot: JSON.parse(snapshotText) })).toBe(fixture.contentHash) + for (const artifact of fixture.artifacts) { + expect(index.files).toContainEqual({ role: 'raw', path: `raw/${artifact.name}`, sha256: artifact.sha256, bytes: artifact.compressed_bytes, expandedBytes: artifact.expanded_bytes }) + } + }) + + it('treats a released dataset as not found', async () => { + new DatasetStore(fixture.db).release(fixture.id) + await expect(prepareDatasetExport(fixture.db, fixture.id)).rejects.toMatchObject({ code: 'DATASET_RELEASED' }) + await expect(prepareDatasetExport(fixture.db, '99999999-9999-4999-8999-999999999999')).rejects.toMatchObject({ code: 'NOT_FOUND' }) + }) + + it('refuses to export an archive whose bytes no longer match its hash', async () => { + tamper(fixture.artifacts[1].name) + await expect(prepareDatasetExport(fixture.db, fixture.id)).rejects.toMatchObject({ code: 'DATASET_CORRUPT', message: expect.stringContaining('SHA-256') }) + }) + + it('refuses missing archives and references that disagree with the frozen snapshot', async () => { + rmSync(join(fixture.rawDirectory, fixture.artifacts[0].name)) + await expect(prepareDatasetExport(fixture.db, fixture.id)).rejects.toMatchObject({ code: 'DATASET_CORRUPT' }) + fixture.sqlite.prepare('UPDATE biometric_dataset_raw_artifacts SET released_at_ms = 1 WHERE artifact_hash = ?').run(fixture.artifacts[0].sha256) + await expect(prepareDatasetExport(fixture.db, fixture.id)).rejects.toMatchObject({ code: 'DATASET_CORRUPT', message: expect.stringContaining('do not match') }) + }) + + it('streams a tar whose size matches the declared length and aborts if an archive changes mid-export', async () => { + const prepared = await prepareDatasetExport(fixture.db, fixture.id) + const chunks: Buffer[] = [] + for await (const chunk of datasetBundleChunks(prepared.index, { manifest: prepared.manifestText, snapshot: prepared.snapshotText }, fixture.rawDirectory)) chunks.push(chunk) + const tar = Buffer.concat(chunks) + expect(tar.length).toBe(datasetBundleTarBytes(prepared.index)) + const entries = untar(tar) + expect([...entries.keys()]).toEqual(['bundle.json', ...prepared.index.files.map(file => file.path)]) + for (const file of prepared.index.files) expect(sha256(entries.get(file.path) as Buffer)).toBe(file.sha256) + + tamper(fixture.artifacts[2].name) + const consume = async () => { + const chunks = datasetBundleChunks(prepared.index, { manifest: prepared.manifestText, snapshot: prepared.snapshotText }, fixture.rawDirectory) + while (!(await chunks.next()).done) { /* drain */ } + } + await expect(consume()).rejects.toThrow('changed during export') + }) +}) + +describe('GET /api/biometric-tuning/datasets/{id}/export', () => { + const get = async (id: string) => { + const { GET } = await import('@/app/api/biometric-tuning/datasets/[id]/export/route') + return GET(new Request(`http://pod/api/biometric-tuning/datasets/${id}/export`), { params: Promise.resolve({ id }) }) + } + + it('streams the verified bundle as an attachment', async () => { + const response = await get(fixture.id) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/x-tar') + expect(response.headers.get('x-dataset-content-hash')).toBe(fixture.contentHash) + const tar = Buffer.from(await response.arrayBuffer()) + expect(tar.length).toBe(Number(response.headers.get('content-length'))) + const index = JSON.parse((untar(tar).get('bundle.json') as Buffer).toString('utf8')) + expect(index.version).toBe('calibration-dataset-bundle-v1') + expect(untar(tar).size).toBe(1 + 2 + fixture.artifacts.length) + }) + + it('returns 404 for a released or unknown dataset and 400 for a malformed id', async () => { + new DatasetStore(fixture.db).release(fixture.id) + expect((await get(fixture.id)).status).toBe(404) + expect((await get('99999999-9999-4999-8999-999999999999')).status).toBe(404) + expect((await get('not-a-uuid')).status).toBe(400) + }) + + it('maps coordinator error codes even when the error is not this bundle\'s ReferenceInputError class', async () => { + state.failure = Object.assign(new Error('Retained dataset inputs were explicitly released'), { code: 'DATASET_RELEASED' }) + expect((await get(fixture.id)).status).toBe(404) + state.failure = Object.assign(new Error('A retained RAW archive failed its SHA-256 check'), { code: 'DATASET_CORRUPT' }) + expect((await get(fixture.id)).status).toBe(409) + state.failure = new Error('Biometrics coordinator failed') + expect((await get(fixture.id)).status).toBe(500) + }) + + it('returns 409 without streaming when a retained archive fails its hash check', async () => { + tamper(fixture.artifacts[0].name) + const response = await get(fixture.id) + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ error: expect.stringContaining('DATASET_CORRUPT') }) + // The failed attempt does not latch the one-at-a-time guard. + tamper(fixture.artifacts[0].name) + const retry = await get(fixture.id) + expect(retry.status).toBe(200) + await retry.arrayBuffer() + }) +}) diff --git a/src/biometrics/backtest/tests/evaluator.test.ts b/src/biometrics/backtest/tests/evaluator.test.ts index 7196f435c..4863249c2 100644 --- a/src/biometrics/backtest/tests/evaluator.test.ts +++ b/src/biometrics/backtest/tests/evaluator.test.ts @@ -119,7 +119,7 @@ describe('retained waveform evaluator', () => { { ...output, run: { ...output.run, build_hash: '0'.repeat(64) } }, { ...output, run: { ...output.run, config_hashes: { ...output.run.config_hashes, right: '0'.repeat(64) } } }, { ...output, run: { ...output.run, counts: { ...output.run.counts, scored_outputs: 1 } } }, - { ...output, run: { ...output.run, raw_records: 9 } }, + { ...output, run: { ...output.run, raw_records: 0 } }, { ...output, run: { ...output.run, artifact_hashes: ['c'.repeat(64)] } }, { ...output, readings: output.readings.map(row => ({ ...row, timestampMs: row.timestampMs + 600000 })) }, { ...output, configHash: 'c'.repeat(64), readings: output.readings.map(row => ({ ...row, configHash: 'c'.repeat(64) })) }, @@ -127,6 +127,8 @@ describe('retained waveform evaluator', () => { expect(() => evaluateBacktest(rehash({ ...base, waveform: { ...base.waveform, outputs: [bad] } }))).toThrow('Waveform output identity mismatch') } expect(() => evaluateBacktest(rehash({ ...base, waveform: { ...base.waveform, outputs: [output, output] } }))).toThrow('Waveform output identity mismatch') + // Pod 5 frames pack several records, so fewer frames than decoded records is valid. + expect(() => evaluateBacktest(rehash({ ...base, waveform: { ...base.waveform, outputs: [{ ...output, run: { ...output.run, raw_records: 1 } }] } }))).not.toThrow('Waveform output identity mismatch') const processed = fixture() expect(() => evaluateBacktest({ ...processed, waveform: base.waveform })).toThrow('Unexpected waveform inputs for processed replay') }) diff --git a/src/biometrics/backtest/tests/fixtures/export.ts b/src/biometrics/backtest/tests/fixtures/export.ts new file mode 100644 index 000000000..4a2285d3f --- /dev/null +++ b/src/biometrics/backtest/tests/fixtures/export.ts @@ -0,0 +1,63 @@ +import { createHash, randomUUID } from 'node:crypto' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { gzipSync } from 'node:zlib' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import * as schema from '@/src/db/biometrics-schema' +import { dataHash } from '../../../algorithms/canonical' +import { previewReferenceImport } from '../../../references/normalize' +import { datasetManifestSchema, datasetSnapshotSchema } from '../../contracts' + +export const exportStartMs = 1791673200000 +const DAY = 86400000, NIGHT = 600000 +export const exportNights = [ + { cohortId: 'night-a', split: 'tuning' as const, startMs: exportStartMs }, + { cohortId: 'night-b', split: 'validation' as const, startMs: exportStartMs + DAY }, + { cohortId: 'night-c', split: 'holdout' as const, startMs: exportStartMs + 2 * DAY }, +] + +/** A complete retained waveform dataset in a file-backed biometrics database: + * one gzip archive per night in DIRECTORY/biometric-raw, plus one unreferenced + * archive that an export must leave out. */ +export function exportableWaveformDataset(directory: string) { + const sqlite = new Database(join(directory, 'biometrics.db')) + sqlite.pragma('foreign_keys = ON') + const db = drizzle(sqlite, { schema }) + migrate(db, { migrationsFolder: 'src/db/biometrics-migrations' }) + const rawDirectory = join(directory, 'biometric-raw') + mkdirSync(rawDirectory, { mode: 0o700 }) + const archive = (text: string) => { + const expanded = Buffer.from(text) + const bytes = gzipSync(expanded) + const sha256 = createHash('sha256').update(bytes).digest('hex') + writeFileSync(join(rawDirectory, `${sha256}.RAW.gz`), bytes, { mode: 0o600 }) + return { name: `${sha256}.RAW.gz`, sha256, compressed_bytes: bytes.length, expanded_bytes: expanded.length } + } + const artifacts = exportNights.map(night => archive(`fixture waveform ${night.cohortId}`)) + const unrelated = archive('another dataset') + const sessions = exportNights.map((night, index) => ({ id: index + 1, startMs: night.startMs, endMs: night.startMs + NIGHT, sleepDurationSeconds: 600, + timesExitedBed: 0, presentIntervals: null, notPresentIntervals: null, movement: [], + vitals: Array.from({ length: 10 }, (_, id) => ({ id: index * 100 + id, timestampMs: night.startMs + id * 60000, heartRate: 60, hrv: 30, breathingRate: 14, processingRunId: null })) })) + const measurements = previewReferenceImport({ side: 'left', subjectId: 'A', source: 'fixture', rows: exportNights.flatMap(night => + Array.from({ length: 5 }, (_, index) => ({ recordKey: `${night.cohortId}-hr-${index}`, metric: 'heart_rate' as const, value: 62, unit: 'bpm', timestampMs: night.startMs + index * 120000 }))) }).measurements + const snapshot = datasetSnapshotSchema.parse({ version: 'waveform-dataset-v1', side: 'left', subjectId: 'A', sessions, processingRuns: [], + references: [{ importId: 'ref', revision: 1, contentHash: 'fixture', source: 'fixture', timePolicy: {}, measurements }], + waveformInputs: exportNights.map((night, index) => ({ cohortId: night.cohortId, input: { version: 'retained-waveform-input-v1', + retained_start_ms: night.startMs, score_start_ms: night.startMs, score_end_ms: night.startMs + NIGHT, + artifacts: [{ artifact: artifacts[index], first_event_ms: night.startMs, last_event_ms: night.startMs + NIGHT - 1 }], contexts: [] } })) }) + const manifest = datasetManifestSchema.parse({ version: 'waveform-dataset-manifest-v1', + splitProtocol: { version: 'chronological-whole-night-v1', timezone: 'UTC', timezoneRuntime: { icu: 'fixture', tz: 'fixture' }, grouping: 'local_noon_connected_sessions', + assignments: exportNights.map((night, index) => ({ cohortId: night.cohortId, nightLabels: [night.cohortId], sessionIds: [index + 1], startMs: night.startMs, endMs: night.startMs + NIGHT, split: night.split })), + evidence: 'partitioned_not_validated', limitations: [] }, + coverage: [], capabilities: { comparison: true, stageReplay: true, signalReplay: true }, limitations: [] }) + const id = randomUUID() + const contentHash = dataHash({ manifest, snapshot }) + db.insert(schema.biometricDatasets).values({ id, side: 'left', subjectId: 'A', idempotencyKey: id, requestHash: id, contentHash, + manifest, snapshot, byteLength: Buffer.byteLength(JSON.stringify({ manifest, snapshot })), createdAtMs: exportStartMs }).run() + for (const artifact of [...artifacts, unrelated]) db.insert(schema.biometricRawArtifacts).values({ contentHash: artifact.sha256, name: artifact.name, + compressedBytes: artifact.compressed_bytes, expandedBytes: artifact.expanded_bytes, createdAtMs: exportStartMs }).run() + artifacts.forEach((artifact, position) => db.insert(schema.biometricDatasetRawArtifacts).values({ id: randomUUID(), datasetId: id, artifactHash: artifact.sha256, position }).run()) + return { sqlite, db, id, rawDirectory, artifacts, unrelated, manifest, snapshot, contentHash } +} diff --git a/src/biometrics/backtest/tests/raw-locate.test.ts b/src/biometrics/backtest/tests/raw-locate.test.ts new file mode 100644 index 000000000..a35a8d917 --- /dev/null +++ b/src/biometrics/backtest/tests/raw-locate.test.ts @@ -0,0 +1,30 @@ +// @vitest-environment node +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { locateRawArchives } from '../raw-locate' + +let dir: string +afterEach(() => rmSync(dir, { recursive: true, force: true })) + +it('finds archives written during a window with the cursor that reads each one', () => { + dir = mkdtempSync(join(tmpdir(), 'raw-locate-')) + const base = Date.UTC(2026, 9, 10, 6) + const write = (name: string, minutes: number) => { + writeFileSync(join(dir, name), '') + utimesSync(join(dir, name), new Date(base + minutes * 60_000), new Date(base + minutes * 60_000)) + } + write('0A.RAW.gz', 0) + write('0B.RAW.gz', 15) + write('0C.RAW.gz', 30) + write('0D.RAW.gz', 45) + write('notes.txt', 20) + expect(locateRawArchives(base + 10 * 60_000, base + 32 * 60_000, dir)).toEqual([ + { name: '0B.RAW.gz', afterName: '0A.RAW.gz' }, + { name: '0C.RAW.gz', afterName: '0B.RAW.gz' }, + { name: '0D.RAW.gz', afterName: '0C.RAW.gz' }, + ]) + expect(locateRawArchives(base - 5 * 60_000, base, dir)[0]).toEqual({ name: '0A.RAW.gz', afterName: null }) + expect(locateRawArchives(0, 1, join(dir, 'missing'))).toEqual([]) +}) diff --git a/src/biometrics/backtest/tests/runner.test.ts b/src/biometrics/backtest/tests/runner.test.ts index f8eb58949..e5e7e58b3 100644 --- a/src/biometrics/backtest/tests/runner.test.ts +++ b/src/biometrics/backtest/tests/runner.test.ts @@ -13,11 +13,12 @@ import { tmpdir } from 'node:os' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { promisify } from 'node:util' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' import * as schema from '@/src/db/biometrics-schema' import { DatasetStore } from '../datasets' import { BacktestJobStore } from '../jobs' import { workerBuildHash } from '../child' -import { executeNextBacktest } from '../runner' +import { failureMessage, executeNextBacktest } from '../runner' import { backtestFixture } from './fixtures/backtest' let sqlite: Database.Database let db: ReturnType> @@ -217,7 +218,7 @@ describe('lease, cancellation and worker protocol failures', () => { ])('fails a worker reporting %s', async (_, body) => { const workerPath = await scriptedWorker('progress.cjs', `${body}setInterval(()=>{},1000)`) expect(await executeNextBacktest(db, { ...options, workerPath })).toEqual({ state: 'stopped', jobId }) - expect(jobs.get(jobId)).toMatchObject({ state: 'failed', error: 'Replay failed validation, resources, or execution' }) + expect(jobs.get(jobId)).toMatchObject({ state: 'failed', error: expect.stringMatching(/^Replay failed validation, resources, or execution/) }) }) it.each([ @@ -239,3 +240,14 @@ describe('lease, cancellation and worker protocol failures', () => { expect(jobs.get(jobId)).toMatchObject({ state: 'failed' }) }) }) + +describe('failure messages', () => { + it('names the failed step for the pipeline’s own messages and stays generic for anything that could carry data', () => { + expect(failureMessage(new Error('Waveform worker failed or returned an incomplete result'))) + .toBe('Replay failed validation, resources, or execution: Waveform worker failed or returned an incomplete result') + expect(failureMessage(new z.ZodError([]))).toBe('Replay failed validation, resources, or execution: job input or report did not match its schema') + for (const error of [new Error('ENOENT: /persistent/sleepypod-data/x.db'), new Error('{"heartRate":61}'), new Error(''), 'text', null]) { + expect(failureMessage(error)).toBe('Replay failed validation, resources, or execution') + } + }) +}) diff --git a/src/biometrics/backtest/tests/waveform-child.test.ts b/src/biometrics/backtest/tests/waveform-child.test.ts index 184e3a4d0..1952ff1e9 100644 --- a/src/biometrics/backtest/tests/waveform-child.test.ts +++ b/src/biometrics/backtest/tests/waveform-child.test.ts @@ -56,7 +56,7 @@ it.each([ [events[0], { type: 'result', result: { ...result, counts: { ...result.counts, scored_outputs: 2 } } }], [{ type: 'reading', reading: { ...reading, configHash: 'b'.repeat(64) } }, events[1]], [{ type: 'reading', reading: { ...reading, hrvMetric: 'rmssd' } }, events[1]], - [events[0], { type: 'result', result: { ...result, decoded_records: 2 } }], + [events[0], { type: 'result', result: { ...result, raw_records: 0 } }], [events[0], { type: 'result', result: { ...result, counts: { ...result.counts, records: 2 } } }], [events[0], { type: 'reading', reading: { ...reading, configHash: 'b'.repeat(64) } }, { type: 'result', result: { ...result, counts: { ...result.counts, scored_outputs: 2 } } }], @@ -159,3 +159,11 @@ it('retains valid measured process resources and rejects fabricated scopes or ne await expect(runWaveformChild(location(), {})).rejects.toThrow('invalid event') } }) + +it('accepts Pod 5 runs that decode more records than frames, but not records without any frame', async () => { + const packed = { ...result, decoded_records: 3, raw_records: 1 } + await worker([{ type: 'reading', reading }, { type: 'result', result: packed }].map(emit).join('')) + expect((await runWaveformChild(location(), {})).run).toEqual(packed) + await worker([{ type: 'reading', reading }, { type: 'result', result: { ...result, raw_records: 0 } }].map(emit).join('')) + await expect(runWaveformChild(location(), {})).rejects.toThrow('incomplete result') +}) diff --git a/src/biometrics/backtest/tests/waveform-contracts.test.ts b/src/biometrics/backtest/tests/waveform-contracts.test.ts index 1f78d7c36..c338b48f6 100644 --- a/src/biometrics/backtest/tests/waveform-contracts.test.ts +++ b/src/biometrics/backtest/tests/waveform-contracts.test.ts @@ -25,7 +25,9 @@ describe('retained waveform input contract', () => { expect(() => retainedWaveformInputSchema.parse(input({ artifacts: [source('a', 100, 900)] }))).toThrow('no events in the retained window') expect(() => retainedWaveformInputSchema.parse(input({ artifacts: [source('a', 5000, 6000)] }))).toThrow('no events in the retained window') expect(() => retainedWaveformInputSchema.parse(input({ artifacts: [source('a', 1000, 3000), source('a', 3500, 6000)] }))).toThrow('distinct with nonoverlapping ordered') - expect(() => retainedWaveformInputSchema.parse(input({ artifacts: [source('a', 1000, 3000), source('b', 3000, 6000)] }))).toThrow('distinct with nonoverlapping ordered') + expect(() => retainedWaveformInputSchema.parse(input({ artifacts: [source('a', 1000, 3000), source('b', 2999, 6000)] }))).toThrow('distinct with nonoverlapping ordered') + // Pod 5 archives can share their boundary millisecond. + expect(retainedWaveformInputSchema.parse(input({ artifacts: [source('a', 1000, 3000), source('b', 3000, 6000)] })).artifacts).toHaveLength(2) expect(() => retainedWaveformInputSchema.parse(input({ artifacts: [source('a', 1000, 3000, 1024 * 1024 * 1024 + 1)] }))).toThrow('compressed input budget') expect(() => retainedWaveformInputSchema.parse(input({ artifacts: [] }))).toThrow() }) diff --git a/src/biometrics/backtest/waveform-child.ts b/src/biometrics/backtest/waveform-child.ts index d0c901783..862184e74 100644 --- a/src/biometrics/backtest/waveform-child.ts +++ b/src/biometrics/backtest/waveform-child.ts @@ -14,6 +14,8 @@ export interface WaveformWorkerLocation { pythonPath: string modulesPath: string retainedDirectory: string + /** Off-pod entry script that calls the same replay_worker.run(); unset on the pod. */ + script?: string } /** Locations are trusted server configuration, never fields accepted by RPC. */ @@ -23,7 +25,7 @@ export function runWaveformChild(location: WaveformWorkerLocation, work: unknown expectedBuildHash?: string cpuSeconds?: number } = {}): Promise<{ readings: z.infer[], run: z.infer }> { - if (options.cpuSeconds !== undefined && (!Number.isSafeInteger(options.cpuSeconds) || options.cpuSeconds < 1 || options.cpuSeconds > 3600)) return Promise.reject(new Error('Invalid waveform CPU budget')) + if (options.cpuSeconds !== undefined && (location.script !== undefined || !Number.isSafeInteger(options.cpuSeconds) || options.cpuSeconds < 1 || options.cpuSeconds > 3600)) return Promise.reject(new Error('Invalid waveform CPU budget')) if (options.expectedBuildHash !== undefined) hash.parse(options.expectedBuildHash) const serialized = JSON.stringify(options.expectedBuildHash === undefined ? work : { ...z.record(z.string(), z.unknown()).parse(work), expected_build_hash: options.expectedBuildHash }) if (!serialized || Buffer.byteLength(serialized) > 8 * 1024 * 1024) return Promise.reject(new Error('Waveform input exceeds 8 MiB')) @@ -31,7 +33,8 @@ export function runWaveformChild(location: WaveformWorkerLocation, work: unknown if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 3600000) return Promise.reject(new Error('Invalid waveform time budget')) if (options.signal?.aborted) return Promise.reject(new Error('Waveform replay cancelled')) return new Promise((resolve, reject) => { - const child: ChildProcessWithoutNullStreams = spawn(location.pythonPath, ['-B', '-m', 'common.biometrics.replay_worker', '--retained-directory', location.retainedDirectory, ...(options.cpuSeconds === undefined ? [] : ['--cpu-seconds', String(options.cpuSeconds)])], { + const entry = location.script === undefined ? ['-m', 'common.biometrics.replay_worker'] : [location.script] + const child: ChildProcessWithoutNullStreams = spawn(location.pythonPath, ['-B', ...entry, '--retained-directory', location.retainedDirectory, ...(options.cpuSeconds === undefined ? [] : ['--cpu-seconds', String(options.cpuSeconds)])], { cwd: tmpdir(), env: { NODE_ENV: 'production', PYTHONPATH: location.modulesPath, LANG: 'C.UTF-8', TZ: 'UTC', OPENBLAS_NUM_THREADS: '1', OMP_NUM_THREADS: '1', MKL_NUM_THREADS: '1', NUMEXPR_NUM_THREADS: '1' }, stdio: ['pipe', 'pipe', 'pipe'], @@ -88,7 +91,7 @@ export function runWaveformChild(location: WaveformWorkerLocation, work: unknown options.signal?.removeEventListener('abort', abort) if (failure) reject(failure) else if (code !== 0 || !result || (options.expectedBuildHash !== undefined && result.build_hash !== options.expectedBuildHash) || pending.length || result.counts.scored_outputs !== readings.length - || result.raw_records < result.decoded_records || result.decoded_records < result.counts.records + || (result.raw_records === 0 && result.decoded_records > 0) || result.decoded_records < result.counts.records || readings.some(reading => reading.configHash !== result?.config_hashes[reading.side])) { reject(new Error('Waveform worker failed or returned an incomplete result')) } diff --git a/src/biometrics/backtest/waveform-contracts.ts b/src/biometrics/backtest/waveform-contracts.ts index 2f1e16cc8..b50b05510 100644 --- a/src/biometrics/backtest/waveform-contracts.ts +++ b/src/biometrics/backtest/waveform-contracts.ts @@ -25,7 +25,8 @@ export const retainedWaveformInputSchema = z.object({ let previous = -1, compressed = 0 for (const source of value.artifacts) { if (source.first_event_ms > source.last_event_ms || source.last_event_ms < value.retained_start_ms || source.first_event_ms >= value.score_end_ms) invalid('Artifact has no events in the retained window') - if (hashes.has(source.artifact.sha256) || source.first_event_ms <= previous) invalid('Retained artifacts must be distinct with nonoverlapping ordered event bounds') + // Bounds may touch: Pod 5 can end one archive with a pump record stamped at the next archive's first frame. + if (hashes.has(source.artifact.sha256) || source.first_event_ms < previous) invalid('Retained artifacts must be distinct with nonoverlapping ordered event bounds') hashes.add(source.artifact.sha256) previous = source.last_event_ms compressed += source.artifact.compressed_bytes diff --git a/src/biometrics/references/legacy.ts b/src/biometrics/references/legacy.ts index 392c8a83a..070996271 100644 --- a/src/biometrics/references/legacy.ts +++ b/src/biometrics/references/legacy.ts @@ -1,29 +1,67 @@ -import { inArray } from 'drizzle-orm' +import { and, asc, count, eq, inArray, isNull } from 'drizzle-orm' import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import * as schema from '@/src/db/biometrics-schema' -import type { ReferenceImportInput } from './contracts' +import { REFERENCE_LIMITS, type ReferenceImportInput } from './contracts' import { previewReferenceImport } from './normalize' import { ReferenceStore } from './store' type LegacyVital = typeof schema.referenceVitals.$inferSelect type LegacyStage = typeof schema.referenceStages.$inferSelect +type LegacySample = typeof schema.referenceSamples.$inferSelect /** * Called inside the existing legacy endpoint's insertion transaction. Only * newly accepted rows get provenance; first-write-wins and stage-overlap * rejection retain their old semantics. Historical unlinked rows stay unknown. */ -export function recordLegacyReferenceImport(db: BetterSQLite3Database, kind: 'vitals' | 'stages', rows: Array): void { +export function recordLegacyReferenceImport(db: BetterSQLite3Database, kind: 'vitals' | 'stages' | 'samples', rows: Array): void { const first = rows[0] if (!first) return const annotation = 'Legacy Apple Watch API: explicit side mapping; sleeper identity and source timezone were not provided. Original timestamps use Unix seconds.' const input: ReferenceImportInput = { side: first.side, subjectId: `legacy_unknown:${first.side}`, source: first.source, rows: rows.map(row => 'heartRate' in row ? { recordKey: `apple-watch-hr:${row.timestamp.getTime()}`, metric: 'heart_rate', value: row.heartRate, unit: 'bpm', timestampMs: row.timestamp.getTime(), annotation } - : { recordKey: `apple-watch-stage:${row.start.getTime()}`, metric: 'sleep_stages', value: row.stage, unit: 'stage', startMs: row.start.getTime(), endMs: row.end.getTime(), annotation }), + : 'metric' in row + ? { recordKey: `apple-watch-${row.metric}:${row.timestamp.getTime()}`, metric: row.metric, value: row.value, unit: row.metric === 'breathing_rate' ? 'breaths/min' : 'ms', timestampMs: row.timestamp.getTime(), annotation } + : { recordKey: `apple-watch-stage:${row.start.getTime()}`, metric: 'sleep_stages', value: row.stage, unit: 'stage', startMs: row.start.getTime(), endMs: row.end.getTime(), annotation }), } const preview = previewReferenceImport(input) const result = new ReferenceStore(db).commit({ import: input, previewHash: preview.summary.contentHash }) - const table = kind === 'vitals' ? schema.referenceVitals : schema.referenceStages + const table = kind === 'vitals' ? schema.referenceVitals : kind === 'samples' ? schema.referenceSamples : schema.referenceStages for (let i = 0; i < rows.length; i += 100) db.update(table).set({ importId: result.id }).where(inArray(table.id, rows.slice(i, i + 100).map(row => row.id))).run() } + +/** Watch rows stored before imports carried provenance, per side. */ +export function countUnlinkedLegacyReferences(db: BetterSQLite3Database, side: 'left' | 'right') { + const vitals = db.select({ n: count() }).from(schema.referenceVitals) + .where(and(eq(schema.referenceVitals.side, side), isNull(schema.referenceVitals.importId))).get()?.n ?? 0 + const stages = db.select({ n: count() }).from(schema.referenceStages) + .where(and(eq(schema.referenceStages.side, side), isNull(schema.referenceStages.importId))).get()?.n ?? 0 + return { vitals, stages } +} + +/** + * Give rows stored before provenance support the same import record new rows + * get, so calibration datasets can use them. Rows go in time order, one + * import per source and up to `REFERENCE_LIMITS.rows` rows; each batch + * commits on its own, so an interrupted run resumes where it stopped. + */ +export function linkLegacyReferences(db: BetterSQLite3Database, side: 'left' | 'right', batch: number = REFERENCE_LIMITS.rows) { + const linked = { vitals: 0, stages: 0, imports: 0 } + for (const kind of ['vitals', 'stages'] as const) { + for (;;) { + const rows = kind === 'vitals' + ? db.select().from(schema.referenceVitals).where(and(eq(schema.referenceVitals.side, side), isNull(schema.referenceVitals.importId))) + .orderBy(asc(schema.referenceVitals.source), asc(schema.referenceVitals.timestamp), asc(schema.referenceVitals.id)).limit(batch).all() + : db.select().from(schema.referenceStages).where(and(eq(schema.referenceStages.side, side), isNull(schema.referenceStages.importId))) + .orderBy(asc(schema.referenceStages.source), asc(schema.referenceStages.start), asc(schema.referenceStages.id)).limit(batch).all() + if (rows.length === 0) break + const source = rows[0].source + const sameSource = (rows as Array).filter(row => row.source === source) + db.transaction(tx => recordLegacyReferenceImport(tx, kind, sameSource)) + linked[kind] += sameSource.length + linked.imports++ + } + } + return linked +} diff --git a/src/biometrics/references/store.ts b/src/biometrics/references/store.ts index 16e0b8a0a..562a11557 100644 --- a/src/biometrics/references/store.ts +++ b/src/biometrics/references/store.ts @@ -100,6 +100,7 @@ export class ReferenceStore { tx.delete(measurements).where(eq(measurements.importId, id)).run() tx.delete(schema.referenceStages).where(eq(schema.referenceStages.importId, id)).run() tx.delete(schema.referenceVitals).where(eq(schema.referenceVitals.importId, id)).run() + tx.delete(schema.referenceSamples).where(eq(schema.referenceSamples.importId, id)).run() const deletedAtMs = this.now() tx.update(imports).set({ deletedAtMs }).where(eq(imports.id, id)).run() return { id, deletedAtMs } diff --git a/src/biometrics/references/tests/legacy.test.ts b/src/biometrics/references/tests/legacy.test.ts index 4e5baf928..c242e70e2 100644 --- a/src/biometrics/references/tests/legacy.test.ts +++ b/src/biometrics/references/tests/legacy.test.ts @@ -4,7 +4,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { afterEach, beforeEach, expect, it } from 'vitest' import * as schema from '@/src/db/biometrics-schema' -import { recordLegacyReferenceImport } from '../legacy' +import { countUnlinkedLegacyReferences, linkLegacyReferences, recordLegacyReferenceImport } from '../legacy' import { ReferenceStore } from '../store' let sqlite: Database.Database @@ -49,3 +49,19 @@ it('records stage intervals and ignores empty batches', () => { expect(page.items.map(row => [row.stage, row.startMs, row.endMs])).toEqual([['light', T, T + 1800000], ['deep', T + 1800000, T + 3600000]]) expect(new ReferenceStore(db).get(importId).summary).toMatchObject({ side: 'right', subjectId: 'legacy_unknown:right', accepted: 2, metrics: ['sleep_stages'] }) }) + +it('links rows stored before provenance support, per source and in bounded batches, and resumes', () => { + db.insert(schema.referenceVitals).values(Array.from({ length: 5 }, (_, i) => ({ side: 'left' as const, source: 'apple_watch' as const, + timestamp: new Date(T + i * 60000), heartRate: 60 + i }))).run() + db.insert(schema.referenceVitals).values({ side: 'right', source: 'apple_watch', timestamp: new Date(T), heartRate: 70 }).run() + db.insert(schema.referenceStages).values({ side: 'left', source: 'apple_watch', start: new Date(T), end: new Date(T + 1800000), stage: 'deep' }).run() + expect(countUnlinkedLegacyReferences(db, 'left')).toEqual({ vitals: 5, stages: 1 }) + expect(linkLegacyReferences(db, 'left', 2)).toEqual({ vitals: 5, stages: 1, imports: 4 }) + expect(countUnlinkedLegacyReferences(db, 'left')).toEqual({ vitals: 0, stages: 0 }) + expect(countUnlinkedLegacyReferences(db, 'right')).toEqual({ vitals: 1, stages: 0 }) + const imports = new ReferenceStore(db).list('left').items + expect(imports).toHaveLength(4) + expect(imports.every(item => item.summary.subjectId === 'legacy_unknown:left')).toBe(true) + expect(imports.map(item => item.summary.accepted).sort()).toEqual([1, 1, 2, 2]) + expect(linkLegacyReferences(db, 'left')).toEqual({ vitals: 0, stages: 0, imports: 0 }) +}) diff --git a/src/biometrics/references/tests/watch-deviation.test.ts b/src/biometrics/references/tests/watch-deviation.test.ts new file mode 100644 index 000000000..a5b621526 --- /dev/null +++ b/src/biometrics/references/tests/watch-deviation.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import type { SleepEpoch, SleepStage } from '@/src/lib/sleep-stages' +import { + compareNightsToWatch, + compareStages, + deviationStats, + meanWithin, + nearestWithin, + pairNight, + type DeviationNightInput, +} from '../watch-deviation' + +const T = Date.UTC(2026, 9, 10, 23) +const S = 1000 +const MIN = 60 * S + +const epoch = (minutes: number, stage: SleepStage): SleepEpoch => + ({ start: T + minutes * MIN, duration: 5 * MIN, stage, heartRate: null, hrv: null, breathingRate: null, movement: null }) + +describe('nearestWithin', () => { + const points = [{ t: 0, v: 60 }, { t: 40 * S, v: null }, { t: 50 * S, v: 70 }, { t: 100 * S, v: 80 }] + + it('takes the nearest non-null reading, the earlier one on a tie, and includes the window edge', () => { + expect(nearestWithin(points, 45 * S, 60 * S)).toBe(70) // 40 s is null, so 50 s (5 s away) wins + expect(nearestWithin(points, 75 * S, 60 * S)).toBe(70) // 50 s and 100 s are both 25 s away + expect(nearestWithin(points, 160 * S, 60 * S)).toBe(80) // exactly 60 s away + expect(nearestWithin(points, 161 * S, 60 * S)).toBeNull() + expect(nearestWithin([], 0, 60 * S)).toBeNull() + }) +}) + +describe('meanWithin', () => { + it('averages the non-null readings inside the window and is null without any', () => { + const points = [{ t: 0, v: 10 }, { t: 60 * S, v: 20 }, { t: 120 * S, v: null }, { t: 400 * S, v: 30 }] + expect(meanWithin(points, 60 * S, 300 * S)).toBe(15) // 400 s is 340 s away + expect(meanWithin(points, 700 * S, 300 * S)).toBe(30) + expect(meanWithin(points, 2000 * S, 300 * S)).toBeNull() + }) +}) + +describe('deviationStats', () => { + it('computes MAE, bias (pod − Watch), nearest-rank p90, Pearson r and coverage', () => { + // pod − Watch: +2, −2, +4, +6 → |err| 2, 2, 4, 6 + const out = deviationStats([[62, 60], [58, 60], [65, 61], [70, 64]], 5) + expect(out).toMatchObject({ n: 4, watchSamples: 5, coverage: 0.8, mae: 3.5, bias: 2.5, p90: 6 }) + // pod mean 63.75, Watch mean 61.25: cov 26.25, Σdp² 76.75, Σdw² 10.75 + expect(out.r).toBeCloseTo(26.25 / Math.sqrt(76.75 * 10.75), 12) + }) + + it('returns nulls, not zeros, without pairs and coverage only when the Watch had samples', () => { + expect(deviationStats([], 3)).toEqual({ n: 0, watchSamples: 3, coverage: 0, mae: null, bias: null, p90: null, r: null }) + expect(deviationStats([], 0)).toEqual({ n: 0, watchSamples: 0, coverage: null, mae: null, bias: null, p90: null, r: null }) + }) + + it('has no correlation for a single pair or a constant series', () => { + expect(deviationStats([[60, 58]], 1)).toMatchObject({ mae: 2, bias: 2, p90: 2, r: null }) + // n = 10: nearest rank ceil(9) is the 9th smallest error, not the maximum. + const ten = Array.from({ length: 10 }, (_, i) => [60 + i + 1, 60] as [number, number]) + expect(deviationStats(ten, 10).p90).toBe(9) + expect(deviationStats([[60, 58], [70, 58]], 2)).toMatchObject({ mae: 7, bias: 7, r: null }) + }) +}) + +describe('pairNight', () => { + it('uses ±60 s for HR, ±150 s for breathing and the ±5 min pod mean for HRV', () => { + const pod = { + heartRate: [{ t: T + 59 * S, v: 62 }, { t: T + 1000 * S + 61 * S, v: 99 }], + breathingRate: [{ t: T + 150 * S, v: 15 }, { t: T + 1000 * S + 151 * S, v: 30 }], + hrv: [{ t: T - 300 * S, v: 40 }, { t: T + 120 * S, v: 60 }, { t: T + 301 * S, v: 500 }], + } + const watch = { + heartRate: [{ t: T, v: 60 }, { t: T + 1000 * S, v: 60 }], + breathingRate: [{ t: T, v: 14 }, { t: T + 1000 * S, v: 14 }], + hrvSdnn: [{ t: T, v: 45 }], + } + expect(pairNight(pod, watch)).toEqual({ heartRate: [[62, 60]], breathingRate: [[15, 14]], hrv: [[50, 45]] }) + }) +}) + +describe('compareStages', () => { + it('scores epochs at their midpoint and counts minutes over the scored epochs only', () => { + const epochs = [epoch(0, 'light'), epoch(5, 'deep'), epoch(10, 'rem'), epoch(15, 'wake')] + const reference = [ + { start: T, end: T + 10 * MIN, stage: 'light' as const }, + { start: T + 10 * MIN, end: T + 15 * MIN, stage: 'rem' as const }, + ] + // 3 scored epochs, 2 match. Chance: light 1/3·2/3 + rem 1/3·1/3 = 1/3, so kappa = (2/3 − 1/3) / (2/3) = 0.5. + const out = compareStages(epochs, reference) + expect(out).toMatchObject({ epochs: 3, recall: { wake: null, light: 0.5, deep: null, rem: 1 } }) + expect(out?.accuracy).toBeCloseTo(2 / 3, 12) + expect(out?.kappa).toBeCloseTo(0.5, 12) + expect(out?.podMinutes).toEqual({ wake: 0, light: 5, deep: 5, rem: 5 }) + expect(out?.watchMinutes).toEqual({ wake: 0, light: 10, deep: 0, rem: 5 }) + }) + + it('is null when no epoch falls inside a Watch segment', () => { + expect(compareStages([epoch(0, 'light')], [{ start: T + 60 * MIN, end: T + 90 * MIN, stage: 'deep' }])).toBeNull() + expect(compareStages([], [])).toBeNull() + }) +}) + +describe('compareNightsToWatch', () => { + const night = (id: number, offsetMs: number, hr: Array<[pod: number, watch: number]>): DeviationNightInput => ({ + sleepRecordId: id, + enteredBedAt: new Date(T + offsetMs), + leftBedAt: new Date(T + offsetMs + 4.25 * 3_600_000), + pod: { heartRate: hr.map(([p], i) => ({ t: T + offsetMs + i * 5 * MIN, v: p })), breathingRate: [], hrv: [] }, + watch: { heartRate: hr.map(([, w], i) => ({ t: T + offsetMs + i * 5 * MIN, v: w })), breathingRate: [{ t: T + offsetMs, v: 14 }], hrvSdnn: [] }, + epochs: [], + reference: [], + }) + + it('reports each night and pools the summary over every pair, not a mean of nightly means', () => { + const out = compareNightsToWatch([night(1, 0, [[70, 60]]), night(2, 86_400_000, [[62, 60], [62, 60], [62, 60]])]) + expect(out.nights.map(n => [n.sleepRecordId, n.hours, n.heartRate.mae])).toEqual([[1, 4.3, 10], [2, 4.3, 2]]) + // Pooled: (10 + 2 + 2 + 2) / 4 = 4; the mean of nightly MAEs would be 6. + expect(out.summary).toMatchObject({ nights: 2, heartRate: { n: 4, watchSamples: 4, coverage: 1, mae: 4, bias: 4 } }) + expect(out.summary.breathingRate).toEqual({ n: 0, watchSamples: 2, coverage: 0, mae: null, bias: null, p90: null, r: null }) + expect(out.summary.hrv).toMatchObject({ watchSamples: 0, coverage: null }) + expect(out.summary.stages).toBeNull() + expect(out.nights[0].stages).toBeNull() + }) + + it('rounds the reported statistics', () => { + const out = compareNightsToWatch([night(1, 0, [[61, 60], [63, 60], [60, 60]])]) + expect(out.nights[0].heartRate).toMatchObject({ mae: 1.33, bias: 1.33, coverage: 1 }) + expect(out.summary.nights).toBe(1) + }) + + it('returns an empty report without nights', () => { + const out = compareNightsToWatch([]) + expect(out.nights).toEqual([]) + expect(out.summary).toMatchObject({ nights: 0, heartRate: { n: 0, watchSamples: 0, coverage: null, mae: null }, stages: null }) + }) +}) diff --git a/src/biometrics/references/watch-deviation.ts b/src/biometrics/references/watch-deviation.ts new file mode 100644 index 000000000..9d9fb9a87 --- /dev/null +++ b/src/biometrics/references/watch-deviation.ts @@ -0,0 +1,268 @@ +/** + * Pod-vs-Apple-Watch deviation per night: each Watch sample is paired with the + * pod reading(s) near it in time, then summarized. Deviation is always + * pod − Watch, so a positive bias means the pod reads high. Ported from the + * offline analysis/watch-deviation scripts so the app, API and MCP report the + * same numbers. + */ +import { z } from 'zod' +import { scoreAgainstReference, type SleepEpoch, type SleepStage, type SleepStageBlock } from '@/src/lib/sleep-stages' + +/** A pod reading (null when the pod had none) or a Watch sample, at unix ms. */ +export interface TimedValue { t: number, v: number | null } +export type DeviationPair = readonly [pod: number, watch: number] + +/** + * How far a pod reading may sit from a Watch sample. HR and breathing take + * the nearest pod reading; HRV averages every pod reading in the window + * because the Watch's SDNN summarizes a few minutes of beats. + */ +export const WATCH_PAIRING_WINDOW_MS = { heartRate: 60_000, breathingRate: 150_000, hrv: 300_000 } as const + +export const HRV_NOTE = 'HRV deviation is not like-for-like yet: the pod stores an IBI-variability index (window_ibi_variability_v1), ' + + 'not SDNN, while the Watch reports SDNN over a few minutes, usually only 1-4 samples a night. Track its trend, not its zero.' + +const STAGES: SleepStage[] = ['wake', 'light', 'deep', 'rem'] + +/** Index of the first point at or after t; points sorted by t. */ +function lowerBound(points: TimedValue[], t: number): number { + let lo = 0 + let hi = points.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (points[mid].t < t) lo = mid + 1 + else hi = mid + } + return lo +} + +/** The non-null value nearest in time to t within toleranceMs (earlier wins a tie); points sorted by t. */ +export function nearestWithin(points: TimedValue[], t: number, toleranceMs: number): number | null { + let best: number | null = null + let bestDt = Infinity + for (let i = lowerBound(points, t - toleranceMs); i < points.length && points[i].t <= t + toleranceMs; i++) { + const dt = Math.abs(points[i].t - t) + if (points[i].v !== null && dt < bestDt) { + best = points[i].v + bestDt = dt + } + } + return best +} + +/** Mean of the non-null values within toleranceMs of t; points sorted by t. */ +export function meanWithin(points: TimedValue[], t: number, toleranceMs: number): number | null { + let sum = 0 + let count = 0 + for (let i = lowerBound(points, t - toleranceMs); i < points.length && points[i].t <= t + toleranceMs; i++) { + const v = points[i].v + if (v !== null) { + sum += v + count++ + } + } + return count ? sum / count : null +} + +/** Pair each Watch sample with the pod value `pick` finds for its time; unmatched samples are dropped. */ +export function pairWatchSamples(watch: TimedValue[], pick: (t: number) => number | null): DeviationPair[] { + const pairs: DeviationPair[] = [] + for (const w of watch) { + if (w.v === null) continue + const pod = pick(w.t) + if (pod !== null) pairs.push([pod, w.v]) + } + return pairs +} + +export const watchDeviationSchema = z.object({ + /** Watch samples paired with a pod reading. */ + n: z.number().int(), + /** Watch samples in the night (or nights), paired or not. */ + watchSamples: z.number().int(), + /** n / watchSamples; null without Watch samples. */ + coverage: z.number().nullable(), + /** Mean absolute error, in the metric's unit. */ + mae: z.number().nullable(), + /** Mean of pod − Watch; positive means the pod reads high. */ + bias: z.number().nullable(), + /** 90th-percentile absolute error (nearest rank). */ + p90: z.number().nullable(), + /** Pearson correlation of pod and Watch values; null below 2 pairs or without variance. */ + r: z.number().nullable(), +}) +export type WatchDeviation = z.infer + +/** MAE, bias, p90 absolute error, Pearson r and coverage. Null statistics, never zeros, without pairs. */ +export function deviationStats(pairs: readonly DeviationPair[], watchSamples: number): WatchDeviation { + const n = pairs.length + const coverage = watchSamples > 0 ? n / watchSamples : null + if (n === 0) return { n, watchSamples, coverage, mae: null, bias: null, p90: null, r: null } + const abs = pairs.map(([p, w]) => Math.abs(p - w)).sort((a, b) => a - b) + const meanPod = pairs.reduce((s, [p]) => s + p, 0) / n + const meanWatch = pairs.reduce((s, [, w]) => s + w, 0) / n + let cov = 0 + let varPod = 0 + let varWatch = 0 + for (const [p, w] of pairs) { + cov += (p - meanPod) * (w - meanWatch) + varPod += (p - meanPod) ** 2 + varWatch += (w - meanWatch) ** 2 + } + return { + n, + watchSamples, + coverage, + mae: abs.reduce((s, x) => s + x, 0) / n, + bias: meanPod - meanWatch, + // Nearest-rank percentile: rank ceil(0.9 n), so n = 10 picks the 9th smallest, not the largest. + p90: abs[Math.ceil(n * 0.9) - 1], + r: varPod > 0 && varWatch > 0 ? cov / Math.sqrt(varPod * varWatch) : null, + } +} + +/** Pod readings per metric, each sorted by time. */ +export interface PodSeries { heartRate: TimedValue[], breathingRate: TimedValue[], hrv: TimedValue[] } +/** Watch samples per metric: heart rate (bpm), breathing rate (breaths/min), HRV SDNN (ms). */ +export interface WatchSeries { heartRate: TimedValue[], breathingRate: TimedValue[], hrvSdnn: TimedValue[] } +export type DeviationMetric = keyof PodSeries + +/** Pair one night's Watch samples with the pod readings near them, per metric. */ +export function pairNight(pod: PodSeries, watch: WatchSeries): Record { + const w = WATCH_PAIRING_WINDOW_MS + return { + heartRate: pairWatchSamples(watch.heartRate, t => nearestWithin(pod.heartRate, t, w.heartRate)), + breathingRate: pairWatchSamples(watch.breathingRate, t => nearestWithin(pod.breathingRate, t, w.breathingRate)), + hrv: pairWatchSamples(watch.hrvSdnn, t => meanWithin(pod.hrv, t, w.hrv)), + } +} + +const stageNumbers = z.object({ wake: z.number(), light: z.number(), deep: z.number(), rem: z.number() }) + +export const stageComparisonSchema = z.object({ + /** Pod epochs whose midpoint falls inside a Watch stage segment. */ + epochs: z.number().int(), + accuracy: z.number(), + kappa: z.number().nullable(), + recall: z.object({ wake: z.number().nullable(), light: z.number().nullable(), deep: z.number().nullable(), rem: z.number().nullable() }), + /** Minutes per stage over the scored epochs only, so pod and Watch cover the same time. */ + podMinutes: stageNumbers, + watchMinutes: stageNumbers, +}) +export type StageComparison = z.infer + +/** + * Agreement of classified epochs with Watch stages, pooled over every epoch + * given (scoreAgainstReference), plus minutes per stage on each side over the + * scored epochs. Null when no epoch falls inside a Watch segment. + */ +export function compareStages(epochs: SleepEpoch[], reference: SleepStageBlock[]): StageComparison | null { + const agreement = scoreAgainstReference(epochs, reference) + if (!agreement) return null + const podMs: Record = { wake: 0, light: 0, deep: 0, rem: 0 } + const watchMs: Record = { wake: 0, light: 0, deep: 0, rem: 0 } + for (const e of epochs) { + const mid = e.start + e.duration / 2 + const ref = reference.find(r => mid >= r.start && mid < r.end) + if (!ref) continue + podMs[e.stage] += e.duration + watchMs[ref.stage] += e.duration + } + const minutes = (ms: Record) => + Object.fromEntries(STAGES.map(s => [s, Math.round(ms[s] / 60_000)])) as Record + return { + epochs: agreement.epochs, + accuracy: agreement.accuracy, + kappa: agreement.kappa, + recall: agreement.recall, + podMinutes: minutes(podMs), + watchMinutes: minutes(watchMs), + } +} + +/** One night's inputs: the pod and Watch series and the classified epochs, all inside the record. */ +export interface DeviationNightInput { + sleepRecordId: number + enteredBedAt: Date + leftBedAt: Date + pod: PodSeries + watch: WatchSeries + epochs: SleepEpoch[] + reference: SleepStageBlock[] +} + +export const deviationNightSchema = z.object({ + sleepRecordId: z.number().int(), + enteredBedAt: z.date(), + leftBedAt: z.date(), + hours: z.number(), + heartRate: watchDeviationSchema, + breathingRate: watchDeviationSchema, + hrv: watchDeviationSchema, + stages: stageComparisonSchema.nullable(), +}) + +export const deviationSummarySchema = z.object({ + nights: z.number().int(), + heartRate: watchDeviationSchema, + breathingRate: watchDeviationSchema, + hrv: watchDeviationSchema, + stages: stageComparisonSchema.nullable(), +}) + +const round = (x: number | null, digits: number) => x === null ? null : Number(x.toFixed(digits)) + +function rounded(d: WatchDeviation): WatchDeviation { + return { ...d, coverage: round(d.coverage, 3), mae: round(d.mae, 2), bias: round(d.bias, 2), p90: round(d.p90, 2), r: round(d.r, 3) } +} + +function roundedStages(s: StageComparison | null): StageComparison | null { + return s && { + ...s, + accuracy: Number(s.accuracy.toFixed(3)), + kappa: round(s.kappa, 3), + recall: { wake: round(s.recall.wake, 3), light: round(s.recall.light, 3), deep: round(s.recall.deep, 3), rem: round(s.recall.rem, 3) }, + } +} + +/** + * Per-night deviation plus a summary pooled over every pair and every scored + * epoch of all nights, not a mean of the nightly figures: a night with more + * Watch samples weighs more, and a night with one HRV sample cannot swing it. + */ +export function compareNightsToWatch(nights: DeviationNightInput[]) { + const pooled: Record = { heartRate: [], breathingRate: [], hrv: [] } + const watchTotals: Record = { heartRate: 0, breathingRate: 0, hrv: 0 } + const report = nights.map((night) => { + const pairs = pairNight(night.pod, night.watch) + const watchCounts: Record = { + heartRate: night.watch.heartRate.length, + breathingRate: night.watch.breathingRate.length, + hrv: night.watch.hrvSdnn.length, + } + for (const metric of ['heartRate', 'breathingRate', 'hrv'] as const) { + pooled[metric].push(...pairs[metric]) + watchTotals[metric] += watchCounts[metric] + } + return { + sleepRecordId: night.sleepRecordId, + enteredBedAt: night.enteredBedAt, + leftBedAt: night.leftBedAt, + hours: Number(((night.leftBedAt.getTime() - night.enteredBedAt.getTime()) / 3_600_000).toFixed(1)), + heartRate: rounded(deviationStats(pairs.heartRate, watchCounts.heartRate)), + breathingRate: rounded(deviationStats(pairs.breathingRate, watchCounts.breathingRate)), + hrv: rounded(deviationStats(pairs.hrv, watchCounts.hrv)), + stages: roundedStages(compareStages(night.epochs, night.reference)), + } + }) + return { + nights: report, + summary: { + nights: nights.length, + heartRate: rounded(deviationStats(pooled.heartRate, watchTotals.heartRate)), + breathingRate: rounded(deviationStats(pooled.breathingRate, watchTotals.breathingRate)), + hrv: rounded(deviationStats(pooled.hrv, watchTotals.hrv)), + stages: roundedStages(compareStages(nights.flatMap(n => n.epochs), nights.flatMap(n => n.reference))), + }, + } +} diff --git a/src/biometrics/tests/analysis-export.test.ts b/src/biometrics/tests/analysis-export.test.ts new file mode 100644 index 000000000..e1cb9af9f --- /dev/null +++ b/src/biometrics/tests/analysis-export.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment node +import { mkdtempSync, rmSync, truncateSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AnalysisExportTooLargeError, analysisExportDownloadPath, analysisWindowSchema, listWindowArchives, planAnalysisExport, tarBytes } from '../analysis-export' + +const START = Date.UTC(2026, 9, 10, 6) +const END = Date.UTC(2026, 9, 10, 14) +let directory: string +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'analysis-window-')) +}) +afterEach(() => rmSync(directory, { recursive: true, force: true })) + +describe('analysisWindowSchema', () => { + it('accepts ISO 8601 and epoch ms and defaults includeRaw', () => { + expect(analysisWindowSchema.parse({ start: '2026-10-10T06:00:00Z', end: String(END) })).toEqual({ startMs: START, endMs: END, includeRaw: true }) + expect(analysisWindowSchema.parse({ start: String(START), end: '2026-10-10T07:00:00-07:00', includeRaw: false })).toEqual({ startMs: START, endMs: END, includeRaw: false }) + }) + + it.each([ + [{ start: 'soon', end: String(END) }, /start must be an ISO 8601/], + [{ start: String(END), end: String(START) }, /start must be before end/], + [{ start: String(START), end: String(START + 14 * 86_400_000 + 1) }, /14 days or shorter/], + [{ start: String(START), end: String(END), extra: 1 }, /Unrecognized key/], + ])('rejects %j', (input, message) => { + const result = analysisWindowSchema.safeParse(input) + expect(result.success).toBe(false) + expect(result.error?.issues.map(issue => issue.message).join('; ')).toMatch(message) + }) +}) + +describe('planAnalysisExport', () => { + const at = (name: string, mtimeMs: number, bytes = 10) => { + const path = join(directory, name) + writeFileSync(path, Buffer.alloc(bytes)) + utimesSync(path, mtimeMs / 1000, mtimeMs / 1000) + } + + it('includes the inclusive [start − 20 min, end + 45 min] mtime range, oldest first', async () => { + at('B.RAW.gz', END + 45 * 60_000) + at('A.RAW.gz', START - 20 * 60_000) + at('C.RAW.gz', START - 20 * 60_000 - 1000) + at('D.RAW.gz', END + 45 * 60_000 + 1000) + expect((await listWindowArchives({ startMs: START, endMs: END }, directory)).map(archive => archive.name)).toEqual(['A.RAW.gz', 'B.RAW.gz']) + }) + + it('treats a missing archive directory as empty and skips listing when RAW is off', async () => { + expect(await listWindowArchives({ startMs: START, endMs: END }, join(directory, 'missing'))).toEqual([]) + at('A.RAW.gz', START) + expect(await planAnalysisExport({ startMs: START, endMs: END, includeRaw: false }, directory)).toMatchObject({ archives: [], rawBytes: 0 }) + }) + + it('sums RAW bytes and rejects past the cap', async () => { + at('A.RAW.gz', START, 100) + at('B.RAW.gz', START + 1000, 28) + expect(await planAnalysisExport({ startMs: START, endMs: END, includeRaw: true }, directory)).toMatchObject({ rawBytes: 128 }) + const sparse = join(directory, 'C.RAW.gz') + writeFileSync(sparse, '') + truncateSync(sparse, 2 * 1024 ** 3) + utimesSync(sparse, START / 1000, START / 1000) + await expect(planAnalysisExport({ startMs: START, endMs: END, includeRaw: true }, directory)).rejects.toBeInstanceOf(AnalysisExportTooLargeError) + }) +}) + +it('sizes the tar and builds the download path', () => { + expect(tarBytes([0, 1, 512, 513])).toBe(1024 + 4 * 512 + 0 + 512 + 512 + 1024) + expect(analysisExportDownloadPath({ startMs: START, endMs: END, includeRaw: false })).toBe(`/api/biometrics/analysis-export?start=${START}&end=${END}&raw=0`) +}) diff --git a/src/components/AppShell/tests/navigation.test.tsx b/src/components/AppShell/tests/navigation.test.tsx index c4c4d47c0..7641b0862 100644 --- a/src/components/AppShell/tests/navigation.test.tsx +++ b/src/components/AppShell/tests/navigation.test.tsx @@ -8,13 +8,15 @@ import { activeNavId, langFromPath, pathWithoutLang } from '../navItems' const state = vi.hoisted(() => ({ path: '/en', query: '', active: true, base: undefined as { configured: boolean } | undefined, status: undefined as unknown, health: undefined as unknown, - version: undefined as unknown, water: undefined as unknown, + version: undefined as unknown, water: undefined as unknown, frames: {} as Record, start: vi.fn(), end: vi.fn(), })) vi.mock('next/navigation', () => ({ usePathname: () => state.path, useSearchParams: () => new URLSearchParams(state.query) })) vi.mock('@/src/hooks/useScheduleActive', () => ({ useScheduleActive: () => ({ isActive: state.active }) })) vi.mock('@/src/hooks/useSwipeNavigation', () => ({ useSwipeNavigation: () => ({ onTouchStart: state.start, onTouchEnd: state.end }) })) vi.mock('@/src/components/Schedule/CurveChart', () => ({ useNowMinute: () => 29000000 })) +const subscription = vi.hoisted(() => vi.fn()) +vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: (type: string) => state.frames[type], useSensorSubscription: subscription })) vi.mock('@/src/utils/trpc', () => ({ trpc: { base: { getAvailability: { useQuery: () => ({ data: state.base }) } }, device: { getStatus: { useQuery: () => ({ data: state.status }) } }, @@ -24,7 +26,7 @@ vi.mock('@/src/utils/trpc', () => ({ trpc: { } })) beforeEach(() => { - Object.assign(state, { path: '/en', query: '', active: true, status: undefined, health: undefined, version: undefined, water: undefined, base: undefined }) + Object.assign(state, { path: '/en', query: '', active: true, status: undefined, health: undefined, version: undefined, water: undefined, base: undefined, frames: {} }) vi.clearAllMocks() }) @@ -91,6 +93,19 @@ describe('responsive navigation', () => { fireEvent.pointerDown(document.body) expect(screen.queryByTestId('build-details')).toBeNull() }) + it('lists a disconnected cover sensor only when sensHealth reports it', () => { + state.health = { status: 'ok', database: { status: 'ok' }, scheduler: {}, iptables: { ok: true } } + const { rerender } = render() + // The shell keeps sensHealth subscribed on every page, not just System. + expect(subscription).toHaveBeenCalledWith({ sensors: ['sensHealth'] }) + expect(screen.queryByTestId('footer-issues')).toBeNull() + state.frames = { sensHealth: { type: 'sensHealth', ts: 1, version: 1, left: { connected: true }, right: { connected: true } } } + rerender() + expect(screen.queryByTestId('footer-issues')).toBeNull() + state.frames = { sensHealth: { type: 'sensHealth', ts: 2, version: 1, left: { connected: false }, right: { connected: true } } } + rerender() + expect(within(screen.getByTestId('footer-issues')).getAllByRole('listitem').map(li => li.textContent)).toEqual(['Left cover sensor disconnected']) + }) it('shows release versions and preserves an unknown model name', () => { state.status = { podVersion: 'Future pod' } state.version = { version: 'v1.2.3', commitHash: 'unknown' } diff --git a/src/components/AppShell/usePodHealth.ts b/src/components/AppShell/usePodHealth.ts index 26682d7cc..036a578d2 100644 --- a/src/components/AppShell/usePodHealth.ts +++ b/src/components/AppShell/usePodHealth.ts @@ -1,23 +1,27 @@ 'use client' -import { attentionItems } from '@/src/components/diagnostics/dashboardLogic' +import { attentionItems, disconnectedCovers } from '@/src/components/diagnostics/dashboardLogic' import { useNowMinute } from '@/src/components/Schedule/CurveChart' +import { useSensorFrame, useSensorSubscription } from '@/src/hooks/useSensorStream' import { trpc } from '@/src/utils/trpc' import { footerStatus } from './footerStatus' /** * The pod's health as the navigation shows it: failing system checks plus the * System Dashboard's attention items. Shared by the sidebar footer and the - * phone tab bar's System dot. + * phone tab bar's System dot. Cover health comes from the sensor stream's + * sensHealth frames (newer cover firmware; older pods never send one). */ export function usePodHealth() { const status = trpc.device.getStatus.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) const health = trpc.health.system.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) const maintenance = trpc.health.maintenance.useQuery({}, { staleTime: 30_000, refetchInterval: 60_000 }) const water = trpc.waterLevel.getLatest.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) + useSensorSubscription({ sensors: ['sensHealth'] }) + const sensHealth = useSensorFrame('sensHealth') const nowMinute = useNowMinute() const attention = nowMinute == null ? [] - : attentionItems(maintenance.data, water.data?.level ?? status.data?.waterLevel, nowMinute * 60_000) + : attentionItems(maintenance.data, water.data?.level ?? status.data?.waterLevel, nowMinute * 60_000, [], disconnectedCovers(sensHealth)) return { footer: footerStatus(health.data, attention), podVersion: status.data?.podVersion } } diff --git a/src/components/Schedule/AlarmCard.tsx b/src/components/Schedule/AlarmCard.tsx index 8c083cb0f..50655e546 100644 --- a/src/components/Schedule/AlarmCard.tsx +++ b/src/components/Schedule/AlarmCard.tsx @@ -23,9 +23,19 @@ export interface AlarmGroup { alarmTemperature: number /** Minutes before `time` in which movement fires the alarm early; 0 = off. */ wakeWindow: number + /** Snooze length in seconds. */ + snoozeDuration: number + /** Snooze taper: percent each later snooze is shorter; 0 = off. */ + snoozeTaper: number enabled: boolean } +/** "9 min snooze" or "9 min snooze −30%" (Snooze taper). */ +export function snoozeSummary(group: Pick): string { + const minutes = Math.round(group.snoozeDuration / 6) / 10 + return `${minutes} min snooze${group.snoozeTaper > 0 ? ` −${group.snoozeTaper}%` : ''}` +} + interface AlarmCardProps { group: AlarmGroup onEdit: () => void @@ -73,10 +83,11 @@ export function AlarmCard({ group, onEdit, onTest, isTesting = false }: AlarmCar {group.duration} s buzz {group.wakeWindow > 0 && ` · ${group.wakeWindow} min window`} + {` · ${snoozeSummary(group)}`} - {`${group.duration}s buzz · ${group.vibrationPattern} · ${formatSetpointF(group.alarmTemperature, unit, { includeUnit: false })}${group.wakeWindow > 0 ? ` · ${group.wakeWindow} min window` : ''}`} + {`${group.duration}s buzz · ${group.vibrationPattern} · ${formatSetpointF(group.alarmTemperature, unit, { includeUnit: false })}${group.wakeWindow > 0 ? ` · ${group.wakeWindow} min window` : ''} · ${snoozeSummary(group)}`}
diff --git a/src/components/Schedule/AlarmEditor.tsx b/src/components/Schedule/AlarmEditor.tsx index db726c2e8..2fd91c060 100644 --- a/src/components/Schedule/AlarmEditor.tsx +++ b/src/components/Schedule/AlarmEditor.tsx @@ -14,6 +14,7 @@ import type { DayOfWeek } from '@/src/lib/scheduleTime' import { displayToSetpointF, setpointFToDisplay } from '@/src/lib/tempUtils' import { FIXED_INTENSITY, FIXED_PATTERN } from '@/src/lib/vibrationPatterns' +import { nextSnoozeSeconds } from '@/src/hardware/snoozeLength' import type { AlarmGroup } from './AlarmCard' import { DAY_ORDER, DAY_PICKER_LABELS, WEEKDAYS, WEEKENDS, daysToIndexes, indexesToDays, sameDays } from './scheduleFormat' @@ -53,6 +54,25 @@ const WAKE_WINDOW_OPTIONS = [ ] as const type WakeWindowOption = typeof WAKE_WINDOW_OPTIONS[number]['value'] +const DEFAULT_SNOOZE_MINUTES = 5 +const MAX_SNOOZE_MINUTES = 30 + +const SNOOZE_TAPER_OPTIONS = [ + { value: '0', label: 'Off' }, + { value: '30', label: '30' }, + { value: '40', label: '40' }, + { value: '50', label: '50' }, + { value: '60', label: '60%' }, +] as const +type SnoozeTaperOption = typeof SNOOZE_TAPER_OPTIONS[number]['value'] + +/** "9, 6.3, 4.4, 3.1 min": the first snoozes of an alarm with this taper. */ +export function snoozeTaperPreview(minutes: number, taper: number, count = 4): string { + const lengths = Array.from({ length: count }, (_, snoozeCount) => + Math.round(nextSnoozeSeconds({ vibrationIntensity: 0, vibrationPattern: 'rise', duration: 0, snoozeDuration: minutes * 60, snoozeTaper: taper, snoozeCount }) / 6) / 10) + return `${lengths.join(', ')} min` +} + const QUICK_PICKS: Array<{ label: string, days: DayOfWeek[] }> = [ { label: 'Weekdays', days: WEEKDAYS }, { label: 'Weekends', days: WEEKENDS }, @@ -99,6 +119,9 @@ export function AlarmEditor({ const [duration, setDuration] = useState(DEFAULT_DURATION) const [displayTemperature, setDisplayTemperature] = useState(defaultDisplayTemp) const [wakeWindow, setWakeWindow] = useState(0) + const [snoozeDuration, setSnoozeDuration] = useState(DEFAULT_SNOOZE_MINUTES * 60) + const snoozeMinutes = snoozeDuration / 60 + const [snoozeTaper, setSnoozeTaper] = useState(0) const [saveError, setSaveError] = useState(null) const [testing, setTesting] = useState(false) @@ -122,6 +145,8 @@ export function AlarmEditor({ setDuration(existingGroup.duration) setDisplayTemperature(Math.round(setpointFToDisplay(existingGroup.alarmTemperature, unit) ?? existingGroup.alarmTemperature)) setWakeWindow(existingGroup.wakeWindow) + setSnoozeDuration(existingGroup.snoozeDuration) + setSnoozeTaper(existingGroup.snoozeTaper) } else { setDays(new Set()) @@ -132,6 +157,8 @@ export function AlarmEditor({ setDuration(DEFAULT_DURATION) setDisplayTemperature(defaultDisplayTemp) setWakeWindow(0) + setSnoozeDuration(DEFAULT_SNOOZE_MINUTES * 60) + setSnoozeTaper(0) } setSaveError(null) setTesting(false) @@ -184,6 +211,8 @@ export function AlarmEditor({ duration, alarmTemperature: temperatureF, wakeWindow, + snoozeDuration, + snoozeTaper, enabled, }))) @@ -200,7 +229,7 @@ export function AlarmEditor({ catch (err) { setSaveError(err instanceof Error ? err.message : 'Failed to save alarm') } - }, [days, sides, time, intensity, pattern, duration, displayTemperature, wakeWindow, unit, existingGroup, batchUpdate, utils, onSaved, onClose]) + }, [days, sides, time, intensity, pattern, duration, displayTemperature, wakeWindow, snoozeDuration, snoozeTaper, unit, existingGroup, batchUpdate, utils, onSaved, onClose]) const clock = formatTime(time) const [clockDigits, clockPeriod] = clock.split(' ') @@ -310,6 +339,36 @@ export function AlarmEditor({
+
+ Snooze +
+ Snooze for + setSnoozeDuration(Math.round(minutes * 60))} + min={1} + max={MAX_SNOOZE_MINUTES} + format={v => `${v} min`} + label="snooze length" + /> +
+
+ Snooze taper + + size="sm" + ariaLabel="Snooze taper" + value={String(snoozeTaper) as SnoozeTaperOption} + onChange={v => setSnoozeTaper(Number(v))} + options={SNOOZE_TAPER_OPTIONS} + /> +
+ + {snoozeTaper > 0 + ? `Each snooze is ${snoozeTaper}% shorter than the last, so you ease out of bed instead of drifting back off: ${snoozeTaperPreview(snoozeMinutes, snoozeTaper)}…` + : `Every snooze lasts ${snoozeMinutes} min. Snooze taper makes each one shorter than the last.`} + +
+
Vibration
diff --git a/src/components/Schedule/AlarmSection.tsx b/src/components/Schedule/AlarmSection.tsx index 5ed03742a..663c73075 100644 --- a/src/components/Schedule/AlarmSection.tsx +++ b/src/components/Schedule/AlarmSection.tsx @@ -23,6 +23,8 @@ interface AlarmRow { duration: number alarmTemperature: number wakeWindow: number + snoozeDuration: number + snoozeTaper: number enabled: boolean } @@ -43,6 +45,8 @@ export function groupAlarms(rows: AlarmRow[]): AlarmGroup[] { r.duration, r.alarmTemperature, r.wakeWindow, + r.snoozeDuration, + r.snoozeTaper, r.enabled ? 1 : 0, ].join('|') const existing = buckets.get(key) @@ -60,6 +64,8 @@ export function groupAlarms(rows: AlarmRow[]): AlarmGroup[] { duration: r.duration, alarmTemperature: r.alarmTemperature, wakeWindow: r.wakeWindow, + snoozeDuration: r.snoozeDuration, + snoozeTaper: r.snoozeTaper, enabled: r.enabled, }) } diff --git a/src/components/Schedule/CurveCard.tsx b/src/components/Schedule/CurveCard.tsx index 599e7a0fb..552728a98 100644 --- a/src/components/Schedule/CurveCard.tsx +++ b/src/components/Schedule/CurveCard.tsx @@ -29,6 +29,10 @@ interface CurveCardProps { bed?: { samples: BedSample[], label: string } /** The curve's optional note. */ notes?: string + /** Name of the library curve running these days. */ + name?: string + /** These days run a schedule that is not in the library. */ + unsaved?: boolean } /** "79–84°F" / "79–84°" — set-point range in the user's unit. */ @@ -147,12 +151,13 @@ function stop(fn: () => void) { } } -export function CurveCard({ group, onEdit, onDelete, isActive = false, nextEvent = null, featured = false, bed, notes }: CurveCardProps) { +export function CurveCard({ group, onEdit, onDelete, isActive = false, nextEvent = null, featured = false, bed, notes, name, unsaved = false }: CurveCardProps) { const { timeFormat } = useTimeFormatter() const { unit } = useTemperatureUnit() const hasSetPoints = group.setPoints.length > 0 const paused = !!group.allDisabled - const label = formatDayRange(group.days) + const days = formatDayRange(group.days) + const label = name ? `${name} · ${days}` : days const sleepWindow = formatWindow(group.setPoints, timeFormat) const active = isActive && hasSetPoints && !paused @@ -223,6 +228,7 @@ export function CurveCard({ group, onEdit, onDelete, isActive = false, nextEvent
{label} {paused && } + {unsaved && NOT SAVED} {actions}
{paused diff --git a/src/components/Schedule/CurveEditor.tsx b/src/components/Schedule/CurveEditor.tsx index 299a0bd83..cb520a9b4 100644 --- a/src/components/Schedule/CurveEditor.tsx +++ b/src/components/Schedule/CurveEditor.tsx @@ -4,15 +4,16 @@ import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 're import { Check, ChevronLeft, Flame, Loader2, Plus, Scale, Snowflake, Sparkles } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { cn } from '@/lib/utils' -import { Button, Card, DayPicker, InlineError, PageHeader, SectionLabel, Stepper } from '@/src/components/ds' +import { Button, Card, DayPicker, InlineError, PageHeader, SectionLabel, Stepper, TextField, Toggle } from '@/src/components/ds' import { AICurveWizard } from './AICurveWizard' import { CurveChart } from './CurveChart' import { SetPointCard, type EditorSetPoint } from './SetPointCard' import { SetPointEditor } from './SetPointEditor' import { TimeInput } from './TimeInput' import { ConfirmDialog } from './ConfirmDialog' -import { DAY_PICKER_LABELS, DAY_SHORT, daysToIndexes, formatDayRange, indexesToDays } from './scheduleFormat' -import { useSchedule } from '@/src/hooks/useSchedule' +import { DAY_PICKER_LABELS, daysToIndexes, formatDayRange, indexesToDays } from './scheduleFormat' +import { describeMoves } from './SavedCurveCard' +import { useCurveTemplates } from '@/src/hooks/useCurveTemplates' import { useSideNames } from '@/src/hooks/useSideNames' import { useSide } from '@/src/providers/SideProvider' import type { DayOfWeek } from '@/src/lib/scheduleTime' @@ -25,6 +26,7 @@ import { import { rebaseSetPoints } from '@/src/lib/sleepCurve/rebase' import { CURVE_NOTES_MAX } from '@/src/lib/sleepCurve/savePlan' import { type ScheduleEndAction, simplifySetPoints, sortChronological } from '@/src/lib/scheduleGrouping' +import { type CurveDays, type DayMove, hasDays, type LibrarySide, NO_DAYS, normalizeDays, planClaims, setPointsToPoints, uniqueCurveName } from '@/src/lib/scheduleLibrary' import { useTemperatureUnit } from '@/src/hooks/useTemperatureUnit' import { displayToSetpointF, setpointFToDisplay } from '@/src/lib/tempUtils' @@ -37,6 +39,13 @@ interface CurveEditorProps { initialSetPoints?: Array<{ time: string, temperature: number }> /** The curve's optional note. */ initialNotes?: string + /** The library curve being edited; absent for a new curve. */ + curveId?: number + initialName?: string + /** Whether the curve runs on its days; new curves start on. */ + initialActive?: boolean + /** The curve's days on every side; the side being edited is replaced on save, the other kept. */ + curveDays?: CurveDays } interface PresetDef { @@ -74,10 +83,16 @@ function useIsDesktop() { ) } +/** "Mon–Fri curve", or with a number when that name is taken. */ +function defaultName(days: Iterable, taken: Set): string { + return uniqueCurveName(`${formatDayRange(days, { named: true }) || 'New'} curve`, taken) +} + /** * Curve editor subpage (in-page, replaces the schedule list while open). * Desktop: 300px settings column + chart and set-point rows. Phone: stacked. - * Local state until "Save" — then writes via `useSchedule.saveCurve` (one batch). + * Local state until "Save" — then saves the curve to the library with its + * days and on/off state in one call (`curveTemplates.put`). */ export function CurveEditor({ onClose, @@ -85,16 +100,22 @@ export function CurveEditor({ initialSetPoints = [], initialEndAction = 'turn_off', initialNotes = '', + curveId, + initialName = '', + initialActive = true, + curveDays, }: CurveEditorProps) { - const { saveCurve, detectCurveConflicts, isMutating, allSchedules } = useSchedule() + const library = useCurveTemplates() + const isMutating = library.isSaving const { unit } = useTemperatureUnit() const { selectedSide: chosenSide, singleScheduleSide } = useSide() // One side away: curves go on the sleeper's side. const selectedSide = singleScheduleSide ?? chosenSide + const editSides = useMemo(() => (selectedSide === 'both' ? ['left', 'right'] : [selectedSide]), [selectedSide]) const { leftName, rightName } = useSideNames() const isDesktop = useIsDesktop() - const isEdit = initialDays.length > 0 + const isEdit = curveId !== undefined || initialDays.length > 0 const [initial] = useState(() => { const sorted = sortChronological(initialSetPoints) const temps = initialSetPoints.map(p => p.temperature) @@ -113,6 +134,8 @@ export function CurveEditor({ ) const [endAction, setEndAction] = useState(initialEndAction) const [notes, setNotes] = useState(initialNotes) + const [name, setName] = useState(initialName) + const [active, setActive] = useState(initialActive) const [bedtime, setBedtime] = useState(initial.bedtime) const [wakeTime, setWakeTime] = useState(initial.wake) const [minTemp, setMinTemp] = useState(initial.min) @@ -120,7 +143,7 @@ export function CurveEditor({ const [activePreset, setActivePreset] = useState(null) const [editorOpen, setEditorOpen] = useState(false) const [editingId, setEditingId] = useState(null) - const [pendingConflict, setPendingConflict] = useState(null) + const [pendingMoves, setPendingMoves] = useState(null) const [saveError, setSaveError] = useState(null) const [aiWizardOpen, setAIWizardOpen] = useState(false) const idCounter = useRef(-1000) @@ -140,12 +163,18 @@ export function CurveEditor({ const autoOnId = orderedPoints[0]?.id ?? null const autoOffId = endAction === 'turn_off' && orderedPoints.length > 1 ? orderedPoints[orderedPoints.length - 1].id : null - // Days outside this curve that another curve already schedules. - const otherCurveDays = useMemo(() => { - const scheduled = new Set((allSchedules?.temperature ?? []).filter(t => t.enabled).map(t => t.dayOfWeek)) - const original = new Set(initialDays) - return [...scheduled].filter(d => !original.has(d) && !days.has(d)) - }, [allSchedules, initialDays, days]) + // The curve's days after saving: the edited side(s) get the picked days, the other side keeps its own. + const nextDays = useMemo(() => { + const next = { ...(curveDays ?? NO_DAYS) } + for (const side of editSides) next[side] = [...days] + return normalizeDays(next) + }, [curveDays, editSides, days]) + const runs = active && hasDays(nextDays) + // Days the other active curves give up when this one saves. + const moves = useMemo( + () => planClaims(library.templates, curveId ?? null, nextDays, runs).moves, + [library.templates, curveId, nextDays, runs], + ) const editPoints = useCallback((fn: (prev: EditorSetPoint[]) => EditorSetPoint[]) => { setPoints(fn) @@ -223,46 +252,44 @@ export function CurveEditor({ }, [bedtime, wakeTime, minTemp, maxTemp]) const performSave = useCallback(async (force = false) => { - const targetDays = Array.from(days) - if (targetDays.length === 0) { - setSaveError('Pick at least one day') - return - } - if (points.length === 0) { - setSaveError('Add at least one set point') + if (points.length < 2) { + setSaveError('Add at least two set points') return } - if (!force) { - const conflicts = detectCurveConflicts(targetDays, initialDays) - if (conflicts.length > 0) { - setPendingConflict(conflicts) - return - } + if (!force && moves.length > 0) { + setPendingMoves(moves) + return } + const taken = new Set(library.templates.filter(t => t.id !== curveId).map(t => t.name)) + const sorted = sortChronological(points) try { - await saveCurve({ - targetDays, - setPoints: points.map(p => ({ time: p.time, temperature: p.temperature })), - originalDays: initialDays, - endAction, + await library.put({ + id: curveId, + name: name.trim() || defaultName(days, taken), + bedtime: sorted[0].time, + wake: sorted[sorted.length - 1].time, + points: setPointsToPoints(points), notes, + endAction, + days: nextDays, + active, }) - setPendingConflict(null) + setPendingMoves(null) onClose() } catch (err) { - setPendingConflict(null) + setPendingMoves(null) setSaveError(err instanceof Error ? err.message : 'Save failed') } - }, [days, points, initialDays, endAction, notes, detectCurveConflicts, saveCurve, onClose]) + }, [points, moves, library, curveId, name, days, notes, endAction, nextDays, active, onClose]) const editingPoint = editingId !== null ? points.find(p => p.id === editingId) ?? null : null const title = isEdit ? 'Edit curve' : 'New curve' const sideLabel = selectedSide === 'both' ? 'Both sides' : selectedSide === 'left' ? leftName : rightName const daysLabel = formatDayRange(days) - const canSave = !isMutating && days.size > 0 && points.length > 0 + const canSave = !isMutating && points.length > 0 const toDisplay = (f: number) => Math.round(setpointFToDisplay(f, unit) ?? f) const toF = (v: number) => clampTemp(displayToSetpointF(v, unit) ?? v) const saveLabel = isMutating ? 'Saving…' : 'Save curve' @@ -319,17 +346,33 @@ export function CurveEditor({
+ Days setDays(new Set(indexesToDays(v)))} labels={DAY_PICKER_LABELS} /> - {otherCurveDays.length > 0 && ( - - {`${formatDayRange(otherCurveDays)} ${otherCurveDays.length === 1 ? 'uses' : 'use'} another curve`} - - )} +
+ Run on these days + +
+ + {!active + ? 'Saved to your curves without running. Turn it on any time.' + : !hasDays(nextDays) + ? 'Pick days to run it. Without days it is saved without running.' + : moves.length > 0 + ? `${describeMoves(moves, { left: leftName, right: rightName }, editSides)}.` + : 'Other curves keep their days.'} +
@@ -509,15 +552,13 @@ export function CurveEditor({ /> DAY_SHORT[d]).join(', ')} ${pendingConflict.length === 1 ? 'is' : 'are'} already part of another curve. Saving will move ${pendingConflict.length === 1 ? 'it' : 'them'} to this curve.` - : ''} + message={pendingMoves ? `${describeMoves(pendingMoves, { left: leftName, right: rightName }, editSides)}. Saving moves them to this curve.` : ''} confirmLabel={isMutating ? 'Saving…' : 'Move & save'} busy={isMutating} onConfirm={() => void performSave(true)} - onCancel={() => setPendingConflict(null)} + onCancel={() => setPendingMoves(null)} />
) diff --git a/src/components/Schedule/SavedCurveCard.tsx b/src/components/Schedule/SavedCurveCard.tsx index ecd824dce..c518dd56a 100644 --- a/src/components/Schedule/SavedCurveCard.tsx +++ b/src/components/Schedule/SavedCurveCard.tsx @@ -1,13 +1,15 @@ 'use client' import type { MouseEvent } from 'react' -import { CalendarPlus, Trash2 } from 'lucide-react' -import { Card, GhostIcon } from '@/src/components/ds' +import { Pencil, Trash2 } from 'lucide-react' +import { Card, GhostIcon, Toggle } from '@/src/components/ds' import { useTemperatureUnit } from '@/src/hooks/useTemperatureUnit' import { useTimeFormatter } from '@/src/hooks/useTimeFormatter' import type { SavedCurve } from '@/src/hooks/useCurveTemplates' +import { type DayMove, LIBRARY_SIDES, type LibrarySide } from '@/src/lib/scheduleLibrary' import { formatTempRange, formatWindow } from './CurveCard' import { MiniCurve } from './CurveChart' +import { formatDayRange } from './scheduleFormat' /** Set points of a saved curve as the editor and charts take them. */ export function templateSetPoints(template: Pick): Array<{ time: string, temperature: number }> { @@ -15,13 +17,45 @@ export function templateSetPoints(template: Pick): Array<{ } /** - * A saved curve: named set points not assigned to any day yet. Clicking it - * opens the curve editor with those set points so the sleeper picks days. + * "Mon–Fri" for one side, or each side's days by name when the card shows + * both sides; "No days" when the curve has none there. */ -export function SavedCurveCard({ template, onUse, onDelete }: { +export function curveDaysLabel(curve: Pick, sides: readonly LibrarySide[], names: Record): string { + const shown = sides.filter(s => curve.days[s].length > 0) + if (shown.length === 0) return 'No days' + if (sides.length === 1) return formatDayRange(curve.days[sides[0]]) + const [left, right] = LIBRARY_SIDES.map(s => formatDayRange(curve.days[s])) + if (shown.length === 2 && left === right) return `Both · ${left}` + return shown.map(s => `${names[s]} ${formatDayRange(curve.days[s])}`).join(' · ') +} + +/** + * "Mon, Tue move from Weeknight; Right Sat moves from Lazy Sunday, which + * turns off" — what turning a curve on takes from the others. Side names + * appear when a curve gives up days on both sides or on a side not in view. + */ +export function describeMoves(moves: DayMove[], names: Record, viewSides: readonly LibrarySide[] = LIBRARY_SIDES): string { + const byCurve = new Map() + for (const m of moves) byCurve.set(m.id, [...(byCurve.get(m.id) ?? []), m]) + return [...byCurve.values()].map((list) => { + const named = list.length > 1 || !viewSides.includes(list[0].side) + const days = list.map(m => `${named ? `${names[m.side]} ` : ''}${formatDayRange(m.days)}`).join(' and ') + const count = list.reduce((n, m) => n + m.days.length, 0) + return `${days} ${count === 1 ? 'moves' : 'move'} from ${list[0].name}${list[0].deactivated ? ', which turns off' : ''}` + }).join('; ') +} + +/** + * A curve in the schedule library. The switch turns it on (on its days) or + * off; clicking the card opens it in the curve editor. + */ +export function SavedCurveCard({ template, daysLabel, onOpen, onToggle, onDelete, busy = false }: { template: SavedCurve - onUse: () => void + daysLabel: string + onOpen: () => void + onToggle: (next: boolean) => void onDelete: () => void + busy?: boolean }) { const { timeFormat } = useTimeFormatter() const { unit } = useTemperatureUnit() @@ -30,21 +64,34 @@ export function SavedCurveCard({ template, onUse, onDelete }: { e.stopPropagation() fn() } + const meta = [ + formatWindow(setPoints, timeFormat), + formatTempRange(setPoints, unit, false), + template.endAction === 'maintain' ? 'Maintains final temperature' : null, + ].filter(Boolean).join(' · ') return ( - +
{template.name} - NO DAYS -
- +
+ + e.stopPropagation()} className="flex"> + +
+ + {template.active ? daysLabel : `Off · ${daysLabel}`} + {template.notes &&

{template.notes}

} - - {[formatWindow(setPoints, timeFormat), formatTempRange(setPoints, unit, false)].filter(Boolean).join(' · ')} - + {meta} ) } diff --git a/src/components/Schedule/SchedulePage.tsx b/src/components/Schedule/SchedulePage.tsx index 19fdf840c..2baa41260 100644 --- a/src/components/Schedule/SchedulePage.tsx +++ b/src/components/Schedule/SchedulePage.tsx @@ -12,7 +12,8 @@ import { useScheduleActive } from '@/src/hooks/useScheduleActive' import { useSide } from '@/src/providers/SideProvider' import type { Side, SideSelection } from '@/src/providers/SideProvider' import { useSideNames } from '@/src/hooks/useSideNames' -import { useCurveTemplates } from '@/src/hooks/useCurveTemplates' +import { useCurveTemplates, type SavedCurve } from '@/src/hooks/useCurveTemplates' +import { activeCurveOn, type CurveDays, type DayMove, hasDays, planClaims } from '@/src/lib/scheduleLibrary' import { useTemperatureUnit } from '@/src/hooks/useTemperatureUnit' import { formatSetpointF } from '@/src/lib/tempUtils' import { groupDaysBySharedCurve } from '@/src/lib/scheduleGrouping' @@ -24,7 +25,7 @@ import { nextSetPoint, type TempRow } from './bothNight' import { CurveCard } from './CurveCard' import { useNowMinute } from './CurveChart' import { CurveEditor } from './CurveEditor' -import { SavedCurveCard, templateSetPoints } from './SavedCurveCard' +import { curveDaysLabel, describeMoves, SavedCurveCard, templateSetPoints } from './SavedCurveCard' import { ConfirmDialog } from './ConfirmDialog' import { ScheduleToggle } from './ScheduleToggle' import { SchedulerConfirmation } from './SchedulerConfirmation' @@ -36,17 +37,24 @@ interface EditingCurve { endAction?: 'turn_off' | 'maintain' setPoints: Array<{ time: string, temperature: number }> notes?: string + /** Set when editing a curve in the library. */ + id?: number + name?: string + active?: boolean + curveDays?: CurveDays } interface SchedulePageProps { - /** /schedule/: 'new' or a curve's days ('mon-tue-wed'); opens the editor. */ + /** + * /schedule/: 'new', a library curve's id ('12'), or the days of a + * schedule ('mon-tue-wed', which opens the library curve running them); + * opens the editor. + */ curve?: string /** ?side= on a curve link: the side (or both) the curve belongs to. */ curveSide?: SideSelection /** ?from=both: the editor was opened from the Both view and returns to it. */ fromBoth?: boolean - /** ?saved= on /schedule/new: start the new curve from this saved curve. */ - savedCurve?: string } /** @@ -56,7 +64,7 @@ interface SchedulePageProps { * at its own URL (/schedule/new, /schedule/mon-tue-wed?side=left), so a * curve can be linked to directly. */ -export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: SchedulePageProps = {}) { +export function SchedulePage({ curve, curveSide, fromBoth = false }: SchedulePageProps = {}) { // Focus one source only when the unused zone has no independent schedule. const { primarySide, selectedSide: chosenSide, selectSide, singleScheduleSide } = useSide() const side = singleScheduleSide ?? primarySide @@ -88,7 +96,9 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: const names: Record = { left: leftName, right: rightName } const nowMinute = useNowMinute() const saved = useCurveTemplates() - const [pendingSavedDelete, setPendingSavedDelete] = useState(null) + const [pendingSavedDelete, setPendingSavedDelete] = useState(null) + const [pendingToggle, setPendingToggle] = useState<{ curve: SavedCurve, moves: DayMove[] } | null>(null) + const [libraryError, setLibraryError] = useState(null) // Last night's measured bed temperature for the featured chart. const now = nowMinute === null ? null : new Date(nowMinute * 60_000) @@ -152,29 +162,69 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: const featured = visibleGroups.find(g => g.key === activeCurveKey) ?? visibleGroups.find(g => g.setPoints.length > 0 && !g.allDisabled) ?? null - const others = visibleGroups.filter(g => g !== featured) - - const curveHref = useCallback((slug: string, linkSide: SideSelection | null, from?: 'both', savedName?: string) => { + // The library curve running a schedule's days on a side, if any. + const ownerOf = (s: Side, group: ScheduleGroup) => activeCurveOn(saved.templates, s, group.days[0]) + const featuredOwner = featured ? ownerOf(side, featured) : undefined + // Schedules not in the library (rows written one at a time through the API). + const others = visibleGroups.filter(g => g !== featured && !ownerOf(side, g)) + const viewSides: Side[] = both ? ['left', 'right'] : [side] + // Library curves for this view: ones with days on a shown side, or no days at all. + const libraryCurves = saved.templates + .filter(t => !hasDays(t.days) || viewSides.some(s => t.days[s].length > 0)) + .sort((a, b) => Number(b.active) - Number(a.active)) + + const curveHref = useCallback((slug: string, linkSide: SideSelection | null, from?: 'both') => { const params = new URLSearchParams() if (linkSide) params.set('side', linkSide) if (from) params.set('from', from) - if (savedName) params.set('saved', savedName) const qs = params.toString() return `${schedulePath}/${slug}${qs ? `?${qs}` : ''}` }, [schedulePath]) const handleEdit = useCallback((group: ScheduleGroup) => { setSelectedDays(new Set(group.days)) - router.push(curveHref(curveSlug(group.days), singleScheduleSide ? null : side)) - }, [router, curveHref, setSelectedDays, singleScheduleSide, side]) + const owner = activeCurveOn(saved.templates, side, group.days[0]) + router.push(curveHref(owner ? String(owner.id) : curveSlug(group.days), singleScheduleSide ? null : side)) + }, [router, curveHref, setSelectedDays, singleScheduleSide, side, saved.templates]) const handleCreate = useCallback(() => { router.push(curveHref('new', singleScheduleSide ? null : selectedSide)) }, [router, curveHref, singleScheduleSide, selectedSide]) - const handleUseSaved = useCallback((name: string) => { - router.push(curveHref('new', singleScheduleSide ? null : selectedSide, undefined, name)) - }, [router, curveHref, singleScheduleSide, selectedSide]) + // A curve running one side only opens on that side, so saving it from the Both view can't copy it to the other. + const openCurve = useCallback((t: SavedCurve) => { + const only = t.days.left.length > 0 && t.days.right.length === 0 + ? 'left' + : t.days.right.length > 0 && t.days.left.length === 0 + ? 'right' + : selectedSide + router.push(curveHref(String(t.id), singleScheduleSide ? null : only, both && only !== 'both' ? 'both' : undefined)) + }, [router, curveHref, singleScheduleSide, selectedSide, both]) + + const runToggle = async (t: SavedCurve, next: boolean) => { + setLibraryError(null) + try { + await saved.setActive(t.id, next) + } + catch (err) { + setLibraryError(err instanceof Error ? err.message : `Failed to turn ${next ? 'on' : 'off'} ${t.name}`) + } + finally { + setPendingToggle(null) + } + } + + // Turning a curve on moves days other active curves run on; ask first. A + // curve without days opens in the editor to pick them. + const toggleCurve = (t: SavedCurve, next: boolean) => { + if (next && !hasDays(t.days)) { + openCurve(t) + return + } + const moves = next ? planClaims(saved.templates, t.id, t.days, true).moves : [] + if (moves.length > 0) setPendingToggle({ curve: t, moves }) + else void runToggle(t, next) + } const handleDelete = useCallback((group: ScheduleGroup) => { setPendingDelete({ days: group.days, label: deleteLabel(group.days) }) @@ -192,7 +242,8 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: } const editFor = (s: Side, group: ScheduleGroup) => { - router.push(curveHref(curveSlug(group.days), s, 'both')) + const owner = ownerOf(s, group) + router.push(curveHref(owner ? String(owner.id) : curveSlug(group.days), s, 'both')) } const deleteFor = (s: Side, group: ScheduleGroup) => { @@ -203,19 +254,32 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: if (curve) { const closeEditor = () => router.push(schedulePath) - const linkedDays = curve === 'new' ? null : parseCurveSlug(curve) + const curveId = /^\d+$/.test(curve) ? Number(curve) : null + const linkedDays = curve === 'new' || curveId !== null ? null : parseCurveSlug(curve) const group = linkedDays ? visibleGroups.find(g => sameDays(g.days, linkedDays)) : undefined + const owned = curveId !== null + ? saved.templates.find(t => t.id === curveId) + : linkedDays?.[0] ? activeCurveOn(saved.templates, side, linkedDays[0]) : undefined const sideReady = !wantSide || chosenSide === wantSide - const savedLoading = curve === 'new' && savedCurve !== undefined && saved.isLoading // The editor keeps its first notes, so an existing curve waits for them; saving an unloaded note would clear it. - if (!sideReady || savedLoading || (curve === 'new' ? settingsLoading : (isLoading && !data) || notesLoading)) { + if (!sideReady || (curve === 'new' ? settingsLoading || saved.isLoading : saved.isLoading || (isLoading && !data) || notesLoading)) { return } - const editorKey = `${curve}:${side}:${savedCurve ?? ''}` - const template = curve === 'new' && savedCurve !== undefined ? saved.templates.find(t => t.name === savedCurve) : undefined + const editorKey = `${curve}:${side}` const found: EditingCurve | null = curve === 'new' - ? { days: [], setPoints: template ? templateSetPoints(template) : [], endAction: settings?.device.defaultScheduleEndAction ?? 'turn_off', notes: template?.notes } - : group ? { days: group.days, setPoints: group.setPoints, endAction: group.endAction, notes: notesFor(group.days) } : null + ? { days: [], setPoints: [], endAction: settings?.device.defaultScheduleEndAction ?? 'turn_off' } + : owned + ? { + id: owned.id, + name: owned.name, + active: owned.active, + curveDays: owned.days, + days: [...new Set(viewSides.flatMap(s => owned.days[s]))], + setPoints: templateSetPoints(owned), + endAction: owned.endAction, + notes: owned.notes, + } + : group ? { days: group.days, setPoints: group.setPoints, endAction: group.endAction, notes: notesFor(group.days) } : null if (found && openedCurve?.key !== editorKey) setOpenedCurve({ key: editorKey, editor: found }) const editor = openedCurve?.key === editorKey ? openedCurve.editor : found if (!editor) { @@ -226,9 +290,11 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: {error ? `Failed to load schedules: ${error.message}` - : linkedDays - ? `No curve runs on exactly ${formatDayRange(linkedDays)}. It may have been changed or deleted.` - : `"${curve}" isn't a curve.`} + : curveId !== null + ? 'That curve is no longer in the library. It may have been deleted.' + : linkedDays + ? `No curve runs on exactly ${formatDayRange(linkedDays)}. It may have been changed or deleted.` + : `"${curve}" isn't a curve.`} @@ -242,6 +308,10 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: initialDays={editor.days} initialSetPoints={editor.setPoints} initialNotes={editor.notes} + curveId={editor.id} + initialName={editor.name} + initialActive={editor.active} + curveDays={editor.curveDays} /> ) } @@ -306,8 +376,6 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }:
- Curves - {error && ( @@ -320,7 +388,7 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: {isLoading && !data && } - {!isLoading && !error && !hasAnyCurves && ( + {!isLoading && !error && !hasAnyCurves && libraryCurves.length === 0 && ( No schedule yet @@ -349,10 +417,11 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: nextEvent={featured.key === activeCurveKey ? nextEvent : null} bed={bed} notes={notesFor(featured.days)} + name={featuredOwner?.name} /> )} - {!both && hasAnyCurves && ( + {!both && others.length > 0 && (
{others.map(group => ( handleEdit(group)} onDelete={() => handleDelete(group)} + unsaved /> ))} -
)} - {saved.templates.length > 0 && ( + {(hasAnyCurves || libraryCurves.length > 0) && ( <> - Saved curves + Curves + {libraryError && {libraryError}}
- {saved.templates.map(t => ( + {libraryCurves.map(t => ( handleUseSaved(t.name)} - onDelete={() => setPendingSavedDelete(t.name)} + daysLabel={curveDaysLabel(t, viewSides, names)} + busy={saved.isChanging} + onOpen={() => openCurve(t)} + onToggle={next => toggleCurve(t, next)} + onDelete={() => setPendingSavedDelete(t)} /> ))} +
)} @@ -399,17 +473,31 @@ export function SchedulePage({ curve, curveSide, fromBoth = false, savedCurve }: { - if (pendingSavedDelete) void saved.remove(pendingSavedDelete).finally(() => setPendingSavedDelete(null)) + if (pendingSavedDelete) void saved.remove(pendingSavedDelete.name).finally(() => setPendingSavedDelete(null)) }} onCancel={() => setPendingSavedDelete(null)} /> + { + if (pendingToggle) void runToggle(pendingToggle.curve, true) + }} + onCancel={() => setPendingToggle(null)} + /> + { duration: 30, alarmTemperature: 75, wakeWindow: 0, + snoozeDuration: 300, + snoozeTaper: 0, enabled: true, }) expect(payload.creates.alarm.map((a: { dayOfWeek: string }) => a.dayOfWeek)).toEqual(['monday', 'tuesday', 'wednesday', 'thursday', 'friday']) @@ -107,6 +111,8 @@ describe('AlarmEditor', () => { duration: 90, alarmTemperature: 85, wakeWindow: 20, + snoozeDuration: 120, + snoozeTaper: 30, enabled: false, }) }) @@ -154,10 +160,11 @@ describe('AlarmEditor', () => { it('shows and saves the wake window', async () => { const s = render() - expect(s.getByRole('tab', { name: '20' }).getAttribute('aria-selected')).toBe('true') + const wakeWindow = within(s.getByRole('tablist', { name: 'Wake window' })) + expect(wakeWindow.getByRole('tab', { name: '20' }).getAttribute('aria-selected')).toBe('true') expect(s.getByText(/up to 20 min early, the first time you move after 7:40 AM\. Otherwise it goes off at 8:00 AM/)).toBeTruthy() - fireEvent.click(s.getByRole('tab', { name: '30 min' })) + fireEvent.click(wakeWindow.getByRole('tab', { name: '30 min' })) expect(s.getByText(/up to 30 min early, the first time you move after 7:30 AM/)).toBeTruthy() fireEvent.click(saveButton(s)) await waitFor(() => expect(m.batch.mutateAsync).toHaveBeenCalled()) @@ -167,13 +174,33 @@ describe('AlarmEditor', () => { it('wraps the window start across midnight and turns off', async () => { const s = render() expect(s.getByText(/first time you move after 11:50 PM/)).toBeTruthy() - fireEvent.click(s.getByRole('tab', { name: 'Off' })) + fireEvent.click(within(s.getByRole('tablist', { name: 'Wake window' })).getByRole('tab', { name: 'Off' })) expect(s.getByText('Goes off at the set time.')).toBeTruthy() fireEvent.click(saveButton(s)) await waitFor(() => expect(m.batch.mutateAsync).toHaveBeenCalled()) expect(m.batch.mutateAsync.mock.calls[0][0].creates.alarm[0].wakeWindow).toBe(0) }) + it('shows and saves the snooze length and Snooze taper', async () => { + const s = render() + const taper = within(s.getByRole('tablist', { name: 'Snooze taper' })) + expect(taper.getByRole('tab', { name: '30' }).getAttribute('aria-selected')).toBe('true') + expect(taper.queryByRole('tab', { name: '20' })).toBeNull() + expect(s.getByText(/Each snooze is 30% shorter than the last.*: 2, 1\.4, 1, 0\.7 min…$/)).toBeTruthy() + + fireEvent.click(taper.getByRole('tab', { name: '50' })) + fireEvent.click(s.getByRole('button', { name: /Increase snooze length/i })) + expect(s.getByText(/Each snooze is 50% shorter than the last.*: 3, 1\.5, 0\.8, 0\.4 min…$/)).toBeTruthy() + fireEvent.click(saveButton(s)) + await waitFor(() => expect(m.batch.mutateAsync).toHaveBeenCalled()) + expect(m.batch.mutateAsync.mock.calls[0][0].creates.alarm[0]).toMatchObject({ snoozeDuration: 180, snoozeTaper: 50 }) + }) + + it('a new alarm snoozes 5 minutes with no taper', async () => { + const s = render() + expect(s.getByText('Every snooze lasts 5 min. Snooze taper makes each one shorter than the last.')).toBeTruthy() + }) + it('surfaces save failures and stays open', async () => { m.batch.mutateAsync.mockRejectedValue(new Error('db locked')) const onClose = vi.fn() @@ -230,3 +257,20 @@ it('selects individual repeat days and saves precisely that selection', async () await waitFor(() => expect(m.batch.mutateAsync).toHaveBeenCalled()) expect(m.batch.mutateAsync.mock.calls[0][0].creates.alarm).toEqual([expect.objectContaining({ dayOfWeek: 'monday' })]) }) + +it.each([90, 61, 1799])('preserves a %s-second snooze when editing only alarm time', async (snoozeDuration) => { + const s = render() + fireEvent.change(s.getByLabelText('Wake at'), { target: { value: '08:30' } }) + fireEvent.click(saveButton(s)) + await waitFor(() => expect(m.batch.mutateAsync).toHaveBeenCalled()) + expect(m.batch.mutateAsync.mock.calls[0][0].creates.alarm[0].snoozeDuration).toBe(snoozeDuration) +}) +it('increments a fractional-minute snooze by one minute when explicitly changed', async () => { + const s = render() + expect(s.getByText('1.5 min')).toBeTruthy() + fireEvent.click(s.getByRole('button', { name: 'Increase snooze length' })) + expect(s.getByText('2.5 min')).toBeTruthy() + fireEvent.click(saveButton(s)) + await waitFor(() => expect(m.batch.mutateAsync).toHaveBeenCalled()) + expect(m.batch.mutateAsync.mock.calls[0][0].creates.alarm[0].snoozeDuration).toBe(150) +}) diff --git a/src/components/Schedule/tests/AlarmSection.test.tsx b/src/components/Schedule/tests/AlarmSection.test.tsx index 52c2cb864..ba3f118a5 100644 --- a/src/components/Schedule/tests/AlarmSection.test.tsx +++ b/src/components/Schedule/tests/AlarmSection.test.tsx @@ -22,9 +22,10 @@ vi.mock('@/src/hooks/useSideNames', () => ({ useSideNames: () => ({ leftName: 'J vi.mock('@/src/hooks/useTemperatureUnit', () => ({ useTemperatureUnit: () => ({ unit: 'F' }) })) import { AlarmSection, groupAlarms } from '../AlarmSection' +import { snoozeSummary } from '../AlarmCard' const row = (id: number, dayOfWeek: string, over: Record = {}) => ({ - id, side: 'left', dayOfWeek, time: '06:45', vibrationIntensity: 100, vibrationPattern: 'rise', duration: 10, alarmTemperature: 84, wakeWindow: 0, enabled: true, ...over, + id, side: 'left', dayOfWeek, time: '06:45', vibrationIntensity: 100, vibrationPattern: 'rise', duration: 10, alarmTemperature: 84, wakeWindow: 0, snoozeDuration: 300, snoozeTaper: 0, enabled: true, ...over, }) beforeEach(() => { @@ -52,6 +53,22 @@ describe('groupAlarms', () => { }) }) +describe('groupAlarms snooze', () => { + it('keeps alarms with different snooze settings in separate groups', () => { + const groups = groupAlarms([ + row(1, 'monday', { snoozeDuration: 540, snoozeTaper: 20 }), + row(2, 'tuesday', { snoozeDuration: 540, snoozeTaper: 20 }), + row(3, 'wednesday', { snoozeDuration: 540 }), + ] as never) + expect(groups.map(g => [g.ids, g.snoozeDuration, g.snoozeTaper])).toEqual([[[1, 2], 540, 20], [[3], 540, 0]]) + }) + + it('summarizes the snooze length and Snooze taper', () => { + expect(snoozeSummary({ snoozeDuration: 540, snoozeTaper: 0 })).toBe('9 min snooze') + expect(snoozeSummary({ snoozeDuration: 90, snoozeTaper: 30 })).toBe('1.5 min snooze −30%') + }) +}) + describe('groupAlarms wake window', () => { it('keeps alarms with different wake windows in separate groups', () => { const groups = groupAlarms([ @@ -81,7 +98,7 @@ describe('AlarmSection', () => { m.query = { data: { alarm: [row(1, 'saturday', { enabled: false }), row(2, 'sunday', { enabled: false })] }, isLoading: false, error: null } const s = render() expect(s.getByText('6:45 AM')).toBeTruthy() - expect(s.getByText('10s buzz · rise · 84°')).toBeTruthy() + expect(s.getByText('10s buzz · rise · 84° · 5 min snooze')).toBeTruthy() expect(s.getByText('paused')).toBeTruthy() expect(s.getByText('1')).toBeTruthy() }) @@ -89,8 +106,8 @@ describe('AlarmSection', () => { it('shows the wake window in both mobile and desktop alarm details', () => { m.query = { data: { alarm: [row(1, 'monday', { wakeWindow: 20 })] }, isLoading: false, error: null } const s = render() - expect(s.getByText('· 10s buzz · 20 min window')).toBeTruthy() - expect(s.getByText('10s buzz · rise · 84° · 20 min window')).toBeTruthy() + expect(s.getByText('· 10s buzz · 20 min window · 5 min snooze')).toBeTruthy() + expect(s.getByText('10s buzz · rise · 84° · 20 min window · 5 min snooze')).toBeTruthy() }) it('tests an alarm on the listed side with its stored vibration', () => { @@ -142,3 +159,12 @@ describe('AlarmSection', () => { expect(within(dialog).getByRole('tab', { name: 'Both' }).getAttribute('aria-selected')).toBe('true') }) }) + +it('shows distinct snooze settings in mobile alarm details', () => { + m.query = { data: { alarm: [row(1, 'monday', { snoozeDuration: 540, snoozeTaper: 30 }), row(2, 'monday')] }, isLoading: false, error: null } + const s = render() + for (const summary of ['· 10s buzz · 9 min snooze −30%', '· 10s buzz · 5 min snooze']) { + const details = s.getByText(summary) + expect(details.className).toBe('min-[900px]:hidden') + } +}) diff --git a/src/components/Schedule/tests/CurveCard.test.tsx b/src/components/Schedule/tests/CurveCard.test.tsx index 9f0438152..f0a0c5f2a 100644 --- a/src/components/Schedule/tests/CurveCard.test.tsx +++ b/src/components/Schedule/tests/CurveCard.test.tsx @@ -127,3 +127,16 @@ it('shows the final target instead of off for a maintained curve', () => { expect(s.getByText('Maintain temperature')).toBeTruthy() expect(s.queryByText('Power off')).toBeNull() }) + +describe('CurveCard library labels', () => { + it('prefixes the library curve\'s name and marks schedules not in the library', () => { + const named = render() + expect(named.getByText('Weeknight · Mon–Fri')).toBeTruthy() + expect(named.getByRole('button', { name: 'Edit Weeknight · Mon–Fri' })).toBeTruthy() + expect(named.queryByText('NOT SAVED')).toBeNull() + cleanup() + const unsaved = render() + expect(unsaved.getByText('Mon–Fri')).toBeTruthy() + expect(unsaved.getByText('NOT SAVED')).toBeTruthy() + }) +}) diff --git a/src/components/Schedule/tests/CurveEditor.test.tsx b/src/components/Schedule/tests/CurveEditor.test.tsx index 055f86aab..1f77adea2 100644 --- a/src/components/Schedule/tests/CurveEditor.test.tsx +++ b/src/components/Schedule/tests/CurveEditor.test.tsx @@ -3,24 +3,20 @@ import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-libra import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CurveEditor } from '../CurveEditor' +interface Curve { id: number, name: string, days: { left: string[], right: string[] }, active: boolean } + const m = vi.hoisted(() => ({ - saveCurve: vi.fn(), - detectCurveConflicts: vi.fn(), - isMutating: false, - allSchedules: undefined as unknown, + put: vi.fn(), + isSaving: false, + templates: [] as Curve[], + side: { selectedSide: 'left' as string, singleScheduleSide: null as string | null }, })) -vi.mock('@/src/hooks/useSchedule', () => ({ - useSchedule: () => ({ - saveCurve: m.saveCurve, - detectCurveConflicts: m.detectCurveConflicts, - isMutating: m.isMutating, - allSchedules: m.allSchedules, - }), +vi.mock('@/src/hooks/useCurveTemplates', () => ({ + useCurveTemplates: () => ({ templates: m.templates, put: m.put, isSaving: m.isSaving, save: vi.fn() }), })) -vi.mock('@/src/providers/SideProvider', () => ({ useSide: () => ({ selectedSide: 'left' }) })) +vi.mock('@/src/providers/SideProvider', () => ({ useSide: () => m.side })) vi.mock('@/src/hooks/useSideNames', () => ({ useSideNames: () => ({ leftName: 'Jon', rightName: 'Heidi' }) })) vi.mock('@/src/hooks/useTemperatureUnit', () => ({ useTemperatureUnit: () => ({ unit: 'F' }) })) -vi.mock('@/src/hooks/useCurveTemplates', () => ({ useCurveTemplates: () => ({ save: vi.fn(), isSaving: false }) })) const INITIAL = [ { time: '07:00', temperature: 84 }, @@ -28,16 +24,20 @@ const INITIAL = [ { time: '00:30', temperature: 79 }, ] +const WEEKNIGHT: Curve = { id: 7, name: 'Weeknight', days: { left: ['monday', 'saturday'], right: [] }, active: true } + beforeEach(() => { - m.saveCurve.mockReset().mockResolvedValue(undefined) - m.detectCurveConflicts.mockReset().mockReturnValue([]) - m.isMutating = false - m.allSchedules = undefined + m.put.mockReset().mockResolvedValue({ curve: {}, moved: [] }) + m.isSaving = false + m.templates = [] + m.side.selectedSide = 'left' + m.side.singleScheduleSide = null }) afterEach(cleanup) const saveCurveButton = (s: ReturnType) => s.getByRole('button', { name: 'Save curve' }) as HTMLButtonElement const rows = (s: ReturnType) => s.queryAllByTestId('set-point-row') +const saved = () => m.put.mock.calls[0][0] describe('CurveEditor', () => { it('creates a curve from a preset for the picked days', async () => { @@ -53,18 +53,17 @@ describe('CurveEditor', () => { expect(s.getByRole('button', { name: /Balanced/ }).getAttribute('aria-pressed')).toBe('true') expect(rows(s).length).toBeGreaterThan(2) expect(s.getByText('Jon · Mon, Tue')).toBeTruthy() + expect(s.getByText('Other curves keep their days.')).toBeTruthy() expect(saveCurveButton(s).disabled).toBe(false) fireEvent.click(saveCurveButton(s)) await waitFor(() => expect(onClose).toHaveBeenCalled()) - expect(m.detectCurveConflicts).toHaveBeenCalledWith(['monday', 'tuesday'], []) - const arg = m.saveCurve.mock.calls[0][0] - expect(arg.targetDays).toEqual(['monday', 'tuesday']) - expect(arg.originalDays).toEqual([]) - expect(arg.setPoints).toHaveLength(rows(s).length) - for (const sp of arg.setPoints) { - expect(sp.temperature).toBeGreaterThanOrEqual(55) - expect(sp.temperature).toBeLessThanOrEqual(110) + const arg = saved() + expect(arg).toMatchObject({ id: undefined, name: 'Mon, Tue curve', days: { left: ['monday', 'tuesday'], right: [] }, active: true, endAction: 'turn_off' }) + expect(Object.keys(arg.points)).toHaveLength(rows(s).length) + for (const t of Object.values(arg.points) as number[]) { + expect(t).toBeGreaterThanOrEqual(55) + expect(t).toBeLessThanOrEqual(110) } }) @@ -82,13 +81,17 @@ describe('CurveEditor', () => { expect(rows(s)).toHaveLength(2) fireEvent.click(saveCurveButton(s)) - await waitFor(() => expect(m.saveCurve).toHaveBeenCalled()) - expect(m.saveCurve.mock.calls[0][0]).toEqual({ - targetDays: ['monday'], - setPoints: [{ time: '23:15', temperature: 84 }, { time: '07:00', temperature: 84 }], - originalDays: ['monday'], - endAction: 'turn_off', + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(saved()).toEqual({ + id: undefined, + name: 'Mon curve', + bedtime: '23:15', + wake: '07:00', + points: { '23:15': 84, '07:00': 84 }, notes: '', + endAction: 'turn_off', + days: { left: ['monday'], right: [] }, + active: true, }) }) @@ -117,45 +120,148 @@ describe('CurveEditor', () => { .toEqual(['11:15 PM', '12:30 AM', '3:00 AM', '7:00 AM']) }) - it('asks before moving days that belong to another curve', async () => { - m.detectCurveConflicts.mockReturnValue(['saturday']) + it('asks before moving days that another active curve runs, then saves', async () => { + m.templates = [WEEKNIGHT] const s = render() + expect(s.getByText('Mon moves from Weeknight.')).toBeTruthy() fireEvent.click(s.getByRole('button', { name: 'Sat' })) + expect(s.getByText('Mon, Sat move from Weeknight, which turns off.')).toBeTruthy() fireEvent.click(saveCurveButton(s)) const dialog = await waitFor(() => s.getByRole('dialog')) - expect(within(dialog).getByText(/Sat is already part of another curve/)).toBeTruthy() - expect(m.saveCurve).not.toHaveBeenCalled() + expect(within(dialog).getByText('Mon, Sat move from Weeknight, which turns off. Saving moves them to this curve.')).toBeTruthy() + expect(m.put).not.toHaveBeenCalled() fireEvent.click(within(dialog).getByRole('button', { name: 'Move & save' })) - await waitFor(() => expect(m.saveCurve).toHaveBeenCalled()) - expect(m.saveCurve.mock.calls[0][0].targetDays).toEqual(['monday', 'saturday']) + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(saved().days).toEqual({ left: ['monday', 'saturday'], right: [] }) }) - it('notes days that another curve already covers', () => { - m.allSchedules = { temperature: [ - { dayOfWeek: 'saturday', enabled: true }, - { dayOfWeek: 'sunday', enabled: true }, - { dayOfWeek: 'monday', enabled: true }, - { dayOfWeek: 'friday', enabled: false }, - ] } + it('does not ask when the curve is saved without running', async () => { + m.templates = [WEEKNIGHT] const s = render() - expect(s.getByText('Sat, Sun use another curve')).toBeTruthy() + fireEvent.click(s.getByRole('switch', { name: 'Run on these days' })) + expect(s.getByText('Saved to your curves without running. Turn it on any time.')).toBeTruthy() + fireEvent.click(saveCurveButton(s)) + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(s.queryByRole('dialog')).toBeNull() + expect(saved()).toMatchObject({ active: false, days: { left: ['monday'], right: [] } }) + }) + + it('ignores its own days and inactive curves when looking for moves', () => { + m.templates = [{ ...WEEKNIGHT, active: false }, { id: 3, name: 'Mine', days: { left: ['monday'], right: [] }, active: true }] + const s = render() + expect(s.getByText('Other curves keep their days.')).toBeTruthy() + }) + + it('saves to the library without days, explaining it will not run', async () => { + const s = render() + expect(s.getByText('Pick days to run it. Without days it is saved without running.')).toBeTruthy() + expect((s.getByLabelText('Name') as HTMLInputElement).placeholder).toBe('New curve') + fireEvent.click(saveCurveButton(s)) + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(saved()).toMatchObject({ name: 'New curve', days: { left: [], right: [] }, active: true }) + }) + + it('needs at least two set points', async () => { + const s = render() + fireEvent.click(saveCurveButton(s)) + expect(await s.findByText('Add at least two set points')).toBeTruthy() + expect(m.put).not.toHaveBeenCalled() + }) + + it('uses the typed name, or numbers the default when it is taken', async () => { + m.templates = [ + { id: 1, name: 'Weekdays curve', days: { left: [], right: [] }, active: false }, + { id: 2, name: 'Weekdays curve 2', days: { left: [], right: [] }, active: false }, + ] + const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'] as const + const s = render() + expect((s.getByLabelText('Name') as HTMLInputElement).placeholder).toBe('Weekdays curve') + fireEvent.click(saveCurveButton(s)) + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(saved().name).toBe('Weekdays curve 3') + cleanup() + + m.put.mockClear() + const named = render() + fireEvent.change(named.getByLabelText('Name'), { target: { value: ' Late warm ' } }) + fireEvent.click(saveCurveButton(named)) + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(saved().name).toBe('Late warm') + }) + + it('edits a library curve in place, keeping the other side\'s days and its own name', async () => { + m.templates = [{ id: 9, name: 'Weekdays curve', days: { left: ['monday'], right: ['friday'] }, active: true }] + const s = render( + , + ) + expect(s.getAllByText('Edit curve').length).toBeGreaterThan(0) + expect((s.getByLabelText('Name') as HTMLInputElement).value).toBe('Weekdays curve') + fireEvent.click(s.getByRole('button', { name: 'Tue' })) + fireEvent.click(saveCurveButton(s)) + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(saved()).toMatchObject({ id: 9, name: 'Weekdays curve', days: { left: ['monday', 'tuesday'], right: ['friday'] }, active: true }) + }) + + it('writes the picked days to both sides in the Both view', async () => { + m.side.selectedSide = 'both' + const s = render() + expect(s.getByText('Both sides · Mon, Sun')).toBeTruthy() + fireEvent.click(saveCurveButton(s)) + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(saved().days).toEqual({ left: ['sunday', 'monday'], right: ['sunday', 'monday'] }) + }) + + it('writes to the sleeper\'s side when the other side is away', async () => { + m.side.selectedSide = 'both' + m.side.singleScheduleSide = 'right' + const s = render() + expect(s.getByText('Heidi · Mon')).toBeTruthy() + fireEvent.click(saveCurveButton(s)) + await waitFor(() => expect(m.put).toHaveBeenCalled()) + expect(saved().days).toEqual({ left: [], right: ['monday'] }) }) it('shows save failures inline', async () => { - m.saveCurve.mockRejectedValue(new Error('scheduler reload failed')) + m.put.mockRejectedValue(new Error('Another curve already has that name')) const onClose = vi.fn() const s = render() fireEvent.click(saveCurveButton(s)) - await waitFor(() => expect(s.getByText('scheduler reload failed')).toBeTruthy()) + await waitFor(() => expect(s.getByText('Another curve already has that name')).toBeTruthy()) expect(onClose).not.toHaveBeenCalled() }) + it('reports a generic failure for non-Error rejections and closes the move dialog', async () => { + m.templates = [WEEKNIGHT] + m.put.mockRejectedValue('nope') + const s = render() + fireEvent.click(saveCurveButton(s)) + fireEvent.click(within(await waitFor(() => s.getByRole('dialog'))).getByRole('button', { name: 'Move & save' })) + await waitFor(() => expect(s.getByText('Save failed')).toBeTruthy()) + expect(s.queryByRole('dialog')).toBeNull() + }) + + it('shows saving state while the library write is pending', () => { + m.isSaving = true + const s = render() + const busy = s.getAllByRole('button', { name: /Saving…/ }) as HTMLButtonElement[] + expect(busy.length).toBe(2) + expect(busy.every(b => b.disabled)).toBe(true) + }) + it('cancels without saving', () => { const onClose = vi.fn() const s = render() fireEvent.click(s.getAllByRole('button', { name: 'Cancel' })[0]) expect(onClose).toHaveBeenCalled() - expect(m.saveCurve).not.toHaveBeenCalled() + expect(m.put).not.toHaveBeenCalled() }) it('keeps coolest at least 2° below warmest', () => { @@ -168,7 +274,8 @@ describe('CurveEditor', () => { }) }) -it('updates a point from its dialog and keyboard chart, and can cancel a day conflict', () => { +it('updates a point from its dialog and keyboard chart, and can cancel a day move', () => { + m.templates = [{ id: 7, name: 'Weeknight', days: { left: ['tuesday'], right: [] }, active: true }] const s = render() fireEvent.click(s.getByRole('button', { name: 'Edit set point 11:15 PM' })) const dialog = s.getByRole('dialog') @@ -176,12 +283,11 @@ it('updates a point from its dialog and keyboard chart, and can cancel a day con fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })) expect(s.getByRole('button', { name: 'Edit set point 10:00 PM' })).toBeTruthy() fireEvent.keyDown(s.getAllByRole('slider')[0], { key: 'ArrowUp' }) - m.detectCurveConflicts.mockReturnValue(['tuesday']) fireEvent.click(s.getByRole('button', { name: 'Tue' })) fireEvent.click(saveCurveButton(s)) fireEvent.click(within(s.getByRole('dialog')).getByRole('button', { name: 'Cancel' })) expect(s.queryByRole('dialog')).toBeNull() - expect(m.saveCurve).not.toHaveBeenCalled() + expect(m.put).not.toHaveBeenCalled() }) it('loads an imported AI curve into the local editor and saves only after review', async () => { @@ -195,10 +301,10 @@ it('loads an imported AI curve into the local editor and saves only after review fireEvent.click(s.getByRole('button', { name: 'Next' })) fireEvent.click(s.getByRole('button', { name: 'Use curve' })) expect(s.queryByRole('dialog')).toBeNull() - expect(m.saveCurve).not.toHaveBeenCalled() + expect(m.put).not.toHaveBeenCalled() expect(rows(s)).toHaveLength(3) fireEvent.click(saveCurveButton(s)) - await waitFor(() => expect(m.saveCurve).toHaveBeenCalledWith(expect.objectContaining({ setPoints: expect.arrayContaining([{ time: '02:00', temperature: 75 }]) }))) + await waitFor(() => expect(m.put).toHaveBeenCalledWith(expect.objectContaining({ points: { '22:00': 80, '02:00': 75, '07:00': 82 }, bedtime: '22:00', wake: '07:00' }))) }) it('edits the optional note and saves it with the curve', async () => { @@ -207,7 +313,7 @@ it('edits the optional note and saves it with the curve', async () => { expect(notes.value).toBe('Old note') fireEvent.change(notes, { target: { value: 'Based on 14 nights' } }) fireEvent.click(saveCurveButton(s)) - await waitFor(() => expect(m.saveCurve).toHaveBeenCalledWith(expect.objectContaining({ notes: 'Based on 14 nights' }))) + await waitFor(() => expect(m.put).toHaveBeenCalledWith(expect.objectContaining({ notes: 'Based on 14 nights' }))) }) it('subscribes to desktop layout changes and removes the listener on unmount', () => { @@ -241,7 +347,7 @@ it('preserves maintain on edit, removes the off badge, and saves the choice', as expect((s.getByLabelText('After schedule ends') as HTMLSelectElement).value).toBe('maintain') expect(s.queryByText('Power off')).toBeNull() fireEvent.click(saveCurveButton(s)) - await waitFor(() => expect(m.saveCurve).toHaveBeenCalledWith(expect.objectContaining({ endAction: 'maintain' }))) + await waitFor(() => expect(m.put).toHaveBeenCalledWith(expect.objectContaining({ endAction: 'maintain' }))) fireEvent.change(s.getByLabelText('After schedule ends'), { target: { value: 'turn_off' } }) expect(s.getByText('Power off')).toBeTruthy() }) diff --git a/src/components/Schedule/tests/SavedCurveCard.test.tsx b/src/components/Schedule/tests/SavedCurveCard.test.tsx new file mode 100644 index 000000000..a12a534f4 --- /dev/null +++ b/src/components/Schedule/tests/SavedCurveCard.test.tsx @@ -0,0 +1,110 @@ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/src/hooks/useTemperatureUnit', () => ({ useTemperatureUnit: () => ({ unit: 'F' }) })) + +import { curveDaysLabel, describeMoves, SavedCurveCard, templateSetPoints } from '../SavedCurveCard' +import type { SavedCurve } from '@/src/hooks/useCurveTemplates' + +afterEach(cleanup) + +const NAMES = { left: 'Jon', right: 'Heidi' } +const WEEKDAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'] as const +const CURVE: SavedCurve = { + id: 3, + name: 'Weeknight', + bedtime: '23:15', + wake: '07:00', + points: { '23:15': 83, '00:30': 79, '07:00': 84 }, + notes: 'Cooler first half.', + endAction: 'turn_off', + days: { left: [...WEEKDAYS], right: [] }, + active: true, + createdAt: 1, +} + +describe('templateSetPoints', () => { + it('lists a curve\'s stored points as set points', () => { + expect(templateSetPoints({ points: { '22:00': 70, '06:00': 80 } })).toEqual([{ time: '22:00', temperature: 70 }, { time: '06:00', temperature: 80 }]) + }) +}) + +describe('curveDaysLabel', () => { + it('shows one side\'s days, or No days', () => { + expect(curveDaysLabel(CURVE, ['left'], NAMES)).toBe('Mon–Fri') + expect(curveDaysLabel(CURVE, ['right'], NAMES)).toBe('No days') + expect(curveDaysLabel({ days: { left: [], right: [] } }, ['left', 'right'], NAMES)).toBe('No days') + }) + + it('names each side when showing both, and says Both when they match', () => { + expect(curveDaysLabel(CURVE, ['left', 'right'], NAMES)).toBe('Jon Mon–Fri') + expect(curveDaysLabel({ days: { left: ['monday'], right: ['saturday', 'sunday'] } }, ['left', 'right'], NAMES)).toBe('Jon Mon · Heidi Sat, Sun') + expect(curveDaysLabel({ days: { left: [...WEEKDAYS], right: [...WEEKDAYS] } }, ['left', 'right'], NAMES)).toBe('Both · Mon–Fri') + }) +}) + +describe('describeMoves', () => { + it('groups moves by curve, counts days for the verb and notes curves that turn off', () => { + expect(describeMoves([{ id: 1, name: 'Weeknight', side: 'left', days: ['monday'], deactivated: false }], NAMES)) + .toBe('Mon moves from Weeknight') + expect(describeMoves([ + { id: 1, name: 'Weeknight', side: 'left', days: ['monday', 'tuesday'], deactivated: false }, + { id: 2, name: 'Lazy Sunday', side: 'right', days: ['sunday'], deactivated: true }, + ], NAMES)).toBe('Mon, Tue move from Weeknight; Sun moves from Lazy Sunday, which turns off') + }) + + it('names the sides when one curve gives up days on both', () => { + expect(describeMoves([ + { id: 1, name: 'Shared', side: 'left', days: ['saturday'], deactivated: false }, + { id: 1, name: 'Shared', side: 'right', days: ['saturday'], deactivated: false }, + ], NAMES)).toBe('Jon Sat and Heidi Sat move from Shared') + }) +}) + +describe('SavedCurveCard', () => { + const renderCard = (template: SavedCurve, busy = false) => { + const handlers = { onOpen: vi.fn(), onToggle: vi.fn(), onDelete: vi.fn() } + const s = render() + return { s, ...handlers } + } + + it('shows an active curve with its days, notes and window, and opens on click', () => { + const { s, onOpen } = renderCard(CURVE) + const card = s.getByTestId('library-curve') + expect(card.className).not.toContain('border-dashed') + expect(s.getByText('Mon–Fri')).toBeTruthy() + expect(s.getByText('Cooler first half.')).toBeTruthy() + expect(s.getByText('11:15 PM → 7:00 AM · 79–84°')).toBeTruthy() + fireEvent.click(card) + expect(onOpen).toHaveBeenCalledOnce() + }) + + it('switches and deletes without opening the curve', () => { + const { s, onOpen, onToggle, onDelete } = renderCard(CURVE) + const sw = s.getByRole('switch', { name: 'Turn off Weeknight' }) + expect(sw.getAttribute('aria-checked')).toBe('true') + fireEvent.click(sw) + expect(onToggle).toHaveBeenCalledWith(false) + fireEvent.click(s.getByRole('button', { name: 'Delete Weeknight' })) + expect(onDelete).toHaveBeenCalledOnce() + expect(onOpen).not.toHaveBeenCalled() + }) + + it('shows an inactive curve dashed with Off before its days and the maintain end', () => { + const { s, onToggle } = renderCard({ ...CURVE, active: false, notes: '', endAction: 'maintain' }) + expect(s.getByTestId('library-curve').className).toContain('border-dashed') + expect(s.getByText('Off · Mon–Fri')).toBeTruthy() + expect(s.getByText(/Maintains final temperature$/)).toBeTruthy() + expect(s.queryByText('Cooler first half.')).toBeNull() + fireEvent.click(s.getByRole('switch', { name: 'Turn on Weeknight' })) + expect(onToggle).toHaveBeenCalledWith(true) + }) + + it('disables the switch while a change is in flight', () => { + const { s, onToggle } = renderCard(CURVE, true) + const sw = s.getByRole('switch', { name: 'Turn off Weeknight' }) as HTMLButtonElement + expect(sw.disabled).toBe(true) + fireEvent.click(sw) + expect(onToggle).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Schedule/tests/SchedulePage.test.tsx b/src/components/Schedule/tests/SchedulePage.test.tsx index 8eb32157c..06889c95e 100644 --- a/src/components/Schedule/tests/SchedulePage.test.tsx +++ b/src/components/Schedule/tests/SchedulePage.test.tsx @@ -25,7 +25,7 @@ const m = vi.hoisted(() => ({ push: vi.fn(), notes: [] as Array<{ dayOfWeek: string, notes: string }>, notesLoading: false, - saved: { templates: [] as unknown[], isLoading: false, isDeleting: false, remove: vi.fn() }, + saved: { templates: [] as unknown[], isLoading: false, isDeleting: false, isChanging: false, remove: vi.fn(), setActive: vi.fn() }, })) vi.mock('@/src/hooks/useCurveTemplates', () => ({ useCurveTemplates: () => m.saved })) vi.mock('next/navigation', () => ({ useRouter: () => ({ push: m.push }), usePathname: () => '/en/schedule' })) @@ -55,8 +55,24 @@ vi.mock('../AlarmSection', () => ({ AlarmSection: ({ side, selectedSide }: { side: string, selectedSide: string }) =>
{`${side}/${selectedSide}`}
, })) vi.mock('../CurveEditor', () => ({ - CurveEditor: ({ onClose, initialDays, initialEndAction, initialSetPoints, initialNotes }: { onClose: () => void, initialDays: string[], initialEndAction?: string, initialSetPoints: Array<{ time: string }>, initialNotes?: string }) => ( -
p.time).join(',')} data-notes={initialNotes ?? ''}> + CurveEditor: ({ onClose, initialDays, initialEndAction, initialSetPoints, initialNotes, curveId, initialName, initialActive, curveDays }: { + onClose: () => void + initialDays: string[] + initialEndAction?: string + initialSetPoints: Array<{ time: string }> + initialNotes?: string + curveId?: number + initialName?: string + initialActive?: boolean + curveDays?: unknown + }) => ( +
p.time).join(',')} + data-notes={initialNotes ?? ''} + data-curve={curveId === undefined ? '' : `${curveId}:${initialName}:${initialActive}:${JSON.stringify(curveDays)}`} + > {initialDays.join(',') || 'new'}
@@ -78,7 +94,9 @@ beforeEach(() => { m.notesLoading = false m.saved.templates = [] m.saved.isLoading = false + m.saved.isChanging = false m.saved.remove.mockReset().mockResolvedValue({ deleted: true }) + m.saved.setActive.mockReset().mockResolvedValue({ curve: {}, moved: [] }) m.side.selectedSide = 'left' m.side.singleScheduleSide = null m.side.selectSide.mockReset() @@ -333,43 +351,171 @@ it('falls back to turning off for new curves before the default setting loads', m.defaultAction = 'turn_off' }) -describe('saved curves', () => { - const LATE_WARM = { name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: { '00:45': 83, '02:30': 78, '11:00': 82 }, notes: 'Kept warm.', createdAt: 1 } +describe('curve library', () => { + const curve = (id: number, name: string, days: { left?: string[], right?: string[] }, active: boolean, extra: object = {}) => ({ + id, name, bedtime: '23:15', wake: '07:00', points: { '23:15': 83, '07:00': 84 }, notes: '', endAction: 'turn_off', + days: { left: days.left ?? [], right: days.right ?? [] }, active, createdAt: 1, ...extra, + }) + const WEEKNIGHT = curve(7, 'Weeknight', { left: WEEKDAYS }, true, { notes: 'Cooler first half.' }) + const HEATWAVE = curve(8, 'Heatwave', { left: [...WEEKDAYS, 'saturday', 'sunday'] }, false) + const IDEA = curve(9, 'Idea', {}, false) + const HEIDI = curve(10, 'Heidi nights', { right: WEEKDAYS }, true) + + it('lists curves with their days and switches, active first, and only those for the shown side', () => { + m.saved.templates = [IDEA, HEATWAVE, WEEKNIGHT, HEIDI] + const s = render() + expect(s.getByText('Curves')).toBeTruthy() + const cards = s.getAllByTestId('library-curve') + expect(cards.map(c => within(c).getAllByText(/./)[0].textContent)).toEqual(['Weeknight', 'Idea', 'Heatwave']) + expect(within(cards[0]).getByText('Mon–Fri')).toBeTruthy() + expect(within(cards[0]).getByRole('switch', { name: 'Turn off Weeknight' }).getAttribute('aria-checked')).toBe('true') + expect(within(cards[1]).getByText('Off · No days')).toBeTruthy() + expect(within(cards[2]).getByText('Off · Every day')).toBeTruthy() + expect(s.queryByText('Heidi nights')).toBeNull() + }) - it('lists saved curves under the day curves, opens one in a new curve and deletes after confirming', async () => { - m.saved.templates = [LATE_WARM] + it('shows both sides\' days in the Both view', () => { + m.side.selectedSide = 'both' + m.saved.templates = [WEEKNIGHT, HEIDI, curve(11, 'Shared', { left: ['monday'], right: ['monday'] }, false)] const s = render() - expect(s.getByText('Saved curves')).toBeTruthy() - expect(s.getByText('Late Warm')).toBeTruthy() - expect(s.getByText('Kept warm.')).toBeTruthy() - fireEvent.click(s.getByRole('button', { name: 'Use Late Warm' })) - expect(m.push).toHaveBeenLastCalledWith('/en/schedule/new?side=left&saved=Late+Warm') + const labels = s.getAllByTestId('library-curve').map(c => c.textContent) + expect(labels.some(t => t?.includes('Jon Mon–Fri'))).toBe(true) + expect(labels.some(t => t?.includes('Heidi Mon–Fri'))).toBe(true) + expect(labels.some(t => t?.includes('Off · Both · Mon'))).toBe(true) + }) - fireEvent.click(s.getByRole('button', { name: 'Delete Late Warm' })) + it('names the featured curve after the library curve running it and marks schedules not in the library', () => { + m.saved.templates = [WEEKNIGHT] + const s = render() + const featured = s.getByTestId('curve-card-featured') + expect(within(featured).getByText('Weeknight · Mon–Fri')).toBeTruthy() + fireEvent.click(within(featured).getByRole('button', { name: 'Edit Weeknight · Mon–Fri' })) + expect(m.push).toHaveBeenLastCalledWith('/en/schedule/7?side=left') + expect(s.getByText('NOT SAVED')).toBeTruthy() + fireEvent.click(s.getByRole('button', { name: 'Edit Sat, Sun' })) + expect(m.push).toHaveBeenLastCalledWith('/en/schedule/sat-sun?side=left') + }) + + it('opens a curve from its card and from Both view rows', () => { + m.saved.templates = [WEEKNIGHT] + const s = render() + fireEvent.click(s.getAllByTestId('library-curve')[0]) + expect(m.push).toHaveBeenLastCalledWith('/en/schedule/7?side=left') + cleanup() + m.side.selectedSide = 'both' + const both = render() + fireEvent.click(both.getByRole('button', { name: 'Edit Jon Mon–Fri' })) + expect(m.push).toHaveBeenLastCalledWith('/en/schedule/7?side=left&from=both') + // A one-side curve opens on its own side from Both, so saving can't copy it to the other side. + fireEvent.click(both.getAllByTestId('library-curve')[0]) + expect(m.push).toHaveBeenLastCalledWith('/en/schedule/7?side=left&from=both') + m.push.mockClear() + fireEvent.click(both.getByRole('button', { name: `Edit ${WEEKNIGHT.name}` })) + expect(m.push).toHaveBeenCalledTimes(1) + }) + + it('turns a curve off straight away', async () => { + m.saved.templates = [WEEKNIGHT] + const s = render() + fireEvent.click(s.getByRole('switch', { name: 'Turn off Weeknight' })) + await waitFor(() => expect(m.saved.setActive).toHaveBeenCalledWith(7, false)) + expect(m.push).not.toHaveBeenCalled() + expect(s.queryByRole('dialog')).toBeNull() + }) + + it('asks before turning on a curve that takes days from another, then turns it on', async () => { + m.saved.templates = [WEEKNIGHT, HEATWAVE] + const s = render() + fireEvent.click(s.getByRole('switch', { name: 'Turn on Heatwave' })) const dialog = s.getByRole('dialog') - expect(within(dialog).getByText(/Days already using it keep their schedule/)).toBeTruthy() + expect(within(dialog).getByText('Move days to this curve?')).toBeTruthy() + expect(within(dialog).getByText('Mon–Fri move from Weeknight, which turns off. Turn on Heatwave?')).toBeTruthy() + expect(m.saved.setActive).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(s.queryByRole('dialog')).toBeNull() + + fireEvent.click(s.getByRole('switch', { name: 'Turn on Heatwave' })) + fireEvent.click(within(s.getByRole('dialog')).getByRole('button', { name: 'Move & turn on' })) + await waitFor(() => expect(m.saved.setActive).toHaveBeenCalledWith(8, true)) + await waitFor(() => expect(s.queryByRole('dialog')).toBeNull()) + }) + + it('turns on a curve without overlap directly, and opens one without days to pick them', async () => { + m.saved.templates = [WEEKNIGHT, curve(12, 'Weekend', { left: ['saturday', 'sunday'] }, false), IDEA] + const s = render() + fireEvent.click(s.getByRole('switch', { name: 'Turn on Weekend' })) + await waitFor(() => expect(m.saved.setActive).toHaveBeenCalledWith(12, true)) + expect(s.queryByRole('dialog')).toBeNull() + fireEvent.click(s.getByRole('switch', { name: 'Turn on Idea' })) + expect(m.push).toHaveBeenLastCalledWith('/en/schedule/9?side=left') + expect(m.saved.setActive).toHaveBeenCalledTimes(1) + }) + + it('shows a failed switch inline', async () => { + m.saved.templates = [WEEKNIGHT] + m.saved.setActive.mockRejectedValueOnce(new Error('pump stall guard tripped')).mockRejectedValueOnce('nope') + const s = render() + fireEvent.click(s.getByRole('switch', { name: 'Turn off Weeknight' })) + expect(await s.findByText('pump stall guard tripped')).toBeTruthy() + fireEvent.click(s.getByRole('switch', { name: 'Turn off Weeknight' })) + expect(await s.findByText('Failed to turn off Weeknight')).toBeTruthy() + }) + + it('deletes a curve after confirming, warning when it is running', async () => { + m.saved.templates = [WEEKNIGHT, IDEA] + const s = render() + fireEvent.click(s.getByRole('button', { name: 'Delete Weeknight' })) + let dialog = s.getByRole('dialog') + expect(within(dialog).getByText(/"Weeknight" will be removed and stop running on Jon Mon–Fri\./)).toBeTruthy() + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(m.saved.remove).not.toHaveBeenCalled() + + fireEvent.click(s.getByRole('button', { name: 'Delete Idea' })) + dialog = s.getByRole('dialog') + expect(within(dialog).getByText('"Idea" will be removed from your curves.')).toBeTruthy() fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) - await waitFor(() => expect(m.saved.remove).toHaveBeenCalledWith('Late Warm')) + await waitFor(() => expect(m.saved.remove).toHaveBeenCalledWith('Idea')) await waitFor(() => expect(s.queryByRole('dialog')).toBeNull()) + expect(m.push).not.toHaveBeenCalled() }) - it('hides the section when nothing is saved', () => { - expect(render().queryByText('Saved curves')).toBeNull() + it('lists library curves without a schedule instead of the empty state', () => { + m.query = { data: { temperature: [] }, isLoading: false, error: null } + m.saved.templates = [IDEA] + const s = render() + expect(s.queryByText('No schedule yet')).toBeNull() + expect(s.getByText('Idea')).toBeTruthy() + fireEvent.click(s.getByRole('button', { name: 'Create curve' })) + expect(m.push).toHaveBeenLastCalledWith('/en/schedule/new?side=left') + }) + + it('opens a library curve by id with its name, state, days and set points', () => { + m.saved.templates = [WEEKNIGHT] + const editor = render().getByTestId('editor') + expect(editor.textContent).toContain('monday,tuesday,wednesday,thursday,friday') + expect(editor.getAttribute('data-curve')).toBe(`7:Weeknight:true:${JSON.stringify(WEEKNIGHT.days)}`) + expect(editor.getAttribute('data-points')).toBe('23:15,07:00') + expect(editor.getAttribute('data-notes')).toBe('Cooler first half.') + }) + + it('opens the library curve running a linked set of days', () => { + m.saved.templates = [WEEKNIGHT] + const editor = render().getByTestId('editor') + expect(editor.getAttribute('data-curve')).toMatch(/^7:Weeknight:true:/) + cleanup() + expect(render().getByTestId('editor').getAttribute('data-curve')).toBe('') }) - it('starts a new curve from a saved one, waiting for the list to load', () => { + it('explains a link to a curve that left the library, and waits for the library to load', () => { + const s = render() + expect(s.getByText('That curve is no longer in the library. It may have been deleted.')).toBeTruthy() + cleanup() m.saved.isLoading = true - const loading = render() + const loading = render() expect(loading.queryByTestId('editor')).toBeNull() + expect(loading.container.querySelector('.animate-pulse')).not.toBeNull() cleanup() - m.saved.isLoading = false - m.saved.templates = [LATE_WARM] - const editor = render().getByTestId('editor') - expect(editor.textContent).toContain('new') - expect(editor.getAttribute('data-points')).toBe('00:45,02:30,11:00') - expect(editor.getAttribute('data-notes')).toBe('Kept warm.') - cleanup() - expect(render().getByTestId('editor').getAttribute('data-points')).toBe('') + expect(render().queryByTestId('editor')).toBeNull() }) }) diff --git a/src/components/Sensors/FreezerHealthCard.tsx b/src/components/Sensors/FreezerHealthCard.tsx index abe687641..7fa046b69 100644 --- a/src/components/Sensors/FreezerHealthCard.tsx +++ b/src/components/Sensors/FreezerHealthCard.tsx @@ -8,6 +8,18 @@ import { trpc } from '@/src/utils/trpc' import { useTemperatureUnit } from '@/src/hooks/useTemperatureUnit' import { formatDisplayTemp, sensorCToDisplay } from '@/src/lib/tempUtils' import { Card, SectionLabel, Skeleton, StatusDot, type Tone } from '@/src/components/ds' +import { WaterTankGauge } from '@/src/components/status/WaterTankGauge' +import { waterTank } from '@/src/components/status/waterTank' + +/** Overcurrent threshold in Amperes. Eight Sleep TECs pull ~10-11.5A during startup + * circuit calibration and up to 12A under peak thermal demand. Real overcurrent is >13.5A. */ +export const TEC_OVERCURRENT_AMPS = 13.5 + +/** Minimum commanded fan duty percentage before evaluating stalled fan. + * When the heatsink is cold and thermal power is 0, firmware sets duty to 0% (0 RPM) + * for silent operation. */ +export const FAN_MIN_ACTIVE_DUTY = 20 +export const FAN_MIN_RPM = 100 /** Overall freezer verdict from live health + water level. */ export function freezerStatus( @@ -17,8 +29,12 @@ export function freezerStatus( ): { tone: Tone, label: string } { if (!hasData) return { tone: 'muted', label: 'No data' } if (waterLevel === 'low') return { tone: 'warn', label: 'Water low' } - if (health && (health.left.tecCurrent > 5 || health.right.tecCurrent > 5)) return { tone: 'warn', label: 'High TEC' } - if (health && health.fan.rpm < 100) return { tone: 'warn', label: 'Fan slow' } + if (health && (health.left.tecCurrent > TEC_OVERCURRENT_AMPS || health.right.tecCurrent > TEC_OVERCURRENT_AMPS)) { + return { tone: 'warn', label: 'High TEC' } + } + if (health && health.fan.duty > FAN_MIN_ACTIVE_DUTY && health.fan.rpm < FAN_MIN_RPM) { + return { tone: 'warn', label: 'Fan slow' } + } return { tone: 'ok', label: 'Normal' } } @@ -29,6 +45,9 @@ export function freezerStatus( * Combines live WebSocket frames (frzTemp, frzHealth, frzTherm) with tRPC data: * - environment.getLatestFreezerTemp for stored freezer temps when WS is not streaming * - waterLevel.getLatest for current water level status + * + * Newer thermal firmware also sends frzWater; while it does, the tank gauge + * replaces the binary water level in the secondary line. */ export function FreezerHealthCard() { const { unit } = useTemperatureUnit() @@ -37,6 +56,7 @@ export function FreezerHealthCard() { const frzTemp = useSensorFrame('frzTemp') const frzHealth = useSensorFrame('frzHealth') const frzTherm = useSensorFrame('frzTherm') + const tank = waterTank(useSensorFrame('frzWater')) // tRPC: latest freezer temp from DB (fallback when WS hasn't sent data) const latestFreezerTemp = trpc.environment.getLatestFreezerTemp.useQuery( @@ -86,7 +106,8 @@ export function FreezerHealthCard() { ) } - const status = freezerStatus(frzHealth, waterLevel, hasData) + // A low tank from frzWater warns too, even while the DAC still reads ok. + const status = freezerStatus(frzHealth, tank?.tone === 'warn' ? 'low' : waterLevel, hasData) const t = (v: number | null | undefined, decimals = 1) => formatDisplayTemp(v, unit, { decimals, includeUnit: false, nullDisplay: '--' }) @@ -102,7 +123,7 @@ export function FreezerHealthCard() { frzHealth && `tec ${frzHealth.left.tecCurrent.toFixed(1)}/${frzHealth.right.tecCurrent.toFixed(1)} A`, frzTherm && `therm ${frzTherm.left.toFixed(1)}/${frzTherm.right.toFixed(1)}`, temps && `hub ${t(temps.ambient)}`, - waterLevel && `water ${waterLevel}`, + !tank && waterLevel && `water ${waterLevel}`, ].filter(Boolean) as string[] return ( @@ -111,6 +132,7 @@ export function FreezerHealthCard() { Freezer + {tank && } {temps ? `${t(temps.heatsink, 0)} sink · ${t(temps.leftWater ?? temps.rightWater, 0)} water` : '--'} @@ -128,11 +150,19 @@ export function FreezerHealthCard() {
- {secondary.length > 0 && ( -
- {secondary.join(' · ')} -
- )} + {tank + ? ( +
+ {'tank '} + + {secondary.length > 0 && ` · ${secondary.join(' · ')}`} +
+ ) + : secondary.length > 0 && ( +
+ {secondary.join(' · ')} +
+ )}
) diff --git a/src/components/Sensors/PresenceCard.tsx b/src/components/Sensors/PresenceCard.tsx index dffb98442..32c2af568 100644 --- a/src/components/Sensors/PresenceCard.tsx +++ b/src/components/Sensors/PresenceCard.tsx @@ -17,6 +17,9 @@ import { Card, SectionLabel, Skeleton, StatusDot } from '@/src/components/ds' * agree on bed state. The head/torso/legs bars are a live visualization of * capSense channel variance — useful for seeing what the sensor is doing * right now, but NOT the source of truth for occupancy. + * + * Newer cover firmware sends sensHealth; a side it reports unlinked gets a + * Disconnected dot. Without the frame (older firmware) nothing is added. */ const VARIANCE_WINDOW = 20 @@ -55,6 +58,7 @@ export function PresenceCard() { const capSense = useSensorFrame('capSense') const capSense2 = useSensorFrame('capSense2') const frame: CapSenseFrame | CapSense2Frame | undefined = capSense2 ?? capSense + const sensHealth = useSensorFrame('sensHealth') const { sideName } = useSideNames() const occupancyQuery = trpc.biometrics.getOccupancy.useQuery(undefined, { @@ -125,6 +129,7 @@ export function PresenceCard() { sub={occ ? presenceSub(occ) : '--'} variance={side === 'left' ? variance.leftVariance : variance.rightVariance} hasFrames={!!frame} + disconnected={sensHealth?.[side]?.connected === false} /> ) })} @@ -134,7 +139,7 @@ export function PresenceCard() { ) } -function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }: { +function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames, disconnected }: { name: string side: 'left' | 'right' occupied: boolean @@ -142,6 +147,7 @@ function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }: sub: string variance: number[] hasFrames: boolean + disconnected: boolean }) { const accent = side === 'left' ? 'var(--accent-cool)' : 'var(--accent-warm)' const activity = ZONES.map((_, zone) => zoneActivity(variance, zone)) @@ -153,6 +159,7 @@ function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }: {`${name} · ${known ? (occupied ? 'in bed' : 'empty') : '--'}`} + {disconnected && } {sub}
{ZONES.map((zone, i) => ( diff --git a/src/components/Sensors/streamGroups.ts b/src/components/Sensors/streamGroups.ts index faae6cf38..91ac03c4f 100644 --- a/src/components/Sensors/streamGroups.ts +++ b/src/components/Sensors/streamGroups.ts @@ -12,9 +12,9 @@ export interface StreamGroup { export const STREAM_GROUPS: readonly StreamGroup[] = [ { key: 'status', label: 'Device', types: ['deviceStatus'] }, { key: 'piezo', label: 'Piezo', types: ['piezo-dual', 'lps'] }, - { key: 'presence', label: 'Presence', types: ['capSense2', 'capSense'] }, + { key: 'presence', label: 'Presence', types: ['capSense2', 'capSense', 'sensHealth'] }, { key: 'bedTemp', label: 'Bed temp', types: ['bedTemp2', 'bedTemp'] }, - { key: 'freezer', label: 'Freezer', types: ['frzHealth', 'frzTemp', 'frzTherm'] }, + { key: 'freezer', label: 'Freezer', types: ['frzHealth', 'frzTemp', 'frzTherm', 'frzWater'] }, { key: 'log', label: 'Log', types: ['log', 'gesture'] }, ] diff --git a/src/components/Sensors/tests/sensorCards.test.tsx b/src/components/Sensors/tests/sensorCards.test.tsx index 451e883e8..7d267b51b 100644 --- a/src/components/Sensors/tests/sensorCards.test.tsx +++ b/src/components/Sensors/tests/sensorCards.test.tsx @@ -94,6 +94,32 @@ it('shows stored and live freezer readings, water warnings and pump RPM', () => expect(screen.getByText(/therm 1.0\/2.0/)).toBeTruthy() }) +it('keeps the binary water level until a frzWater frame arrives, then shows the tank gauge', () => { + const live = { frzTemp: { left: 24, right: 26, amb: 20, hs: 30 }, frzHealth: { left: { tecCurrent: 1, pumpRpm: 2400, flowrate: null }, right: { tecCurrent: 2, pumpRpm: 2400, flowrate: 2 }, fan: { rpm: 200 } } } + mock.frames = { ...live } + mock.data.water = { level: 'ok' } + const { rerender } = render() + // Older firmware: no frzWater, so no gauge and the DAC level stays in the secondary line. + expect(screen.queryByTestId('water-tank')).toBeNull() + expect(screen.getByText(/water ok$/)).toBeTruthy() + + mock.frames = { ...live, frzWater: { type: 'frzWater', ts: 1, isPriming: true, hasWater: true, capValid: true, capLevel: 2, needsPriming: false } } + rerender() + expect(screen.getAllByText('Normal')).toHaveLength(2) + expect(screen.queryByText(/water ok/)).toBeNull() + expect(screen.getAllByRole('img', { name: 'Water tank 2 of 5' })).toHaveLength(2) + expect(screen.getAllByText('Priming…')).toHaveLength(2) + expect(screen.getByTestId('freezer-secondary').textContent).toMatch(/^tank 2\/5Priming… · fan 200 rpm/) + + mock.frames = { ...live, frzWater: { type: 'frzWater', ts: 2, isPriming: false, hasWater: false, capValid: false, capLevel: 5, needsPriming: true } } + rerender() + expect(screen.getAllByRole('img', { name: 'Water low, tank level unavailable' })).toHaveLength(2) + expect(screen.getAllByText('Needs prime')).toHaveLength(2) + // The DAC still reads ok, but the tank says no water: the status dot warns too. + expect(screen.getAllByText('Water low')).toHaveLength(2) + expect(screen.queryByText('Normal')).toBeNull() +}) + it('uses server occupancy while visualizing frame variance independently', () => { const { rerender } = render() expect(screen.getByTestId('presence-left').textContent).toContain('left · --') @@ -112,6 +138,23 @@ it('uses server occupancy while visualizing frame variance independently', () => expect(screen.getByTestId('presence-right').textContent).toContain('in bedstill') }) +it('marks a side Disconnected only when sensHealth reports it unlinked', () => { + mock.data.occupancy = { left: { occupied: false, available: true, classification: 'none', movement: { active: false } }, right: { occupied: false, available: true, classification: 'none', movement: { active: false } } } + const { rerender } = render() + // Older firmware: no sensHealth frame, nothing extra. + expect(screen.queryByText('Disconnected')).toBeNull() + mock.frames.sensHealth = { type: 'sensHealth', ts: 1, version: 1, left: { connected: true }, right: { connected: true } } + rerender() + expect(screen.queryByText('Disconnected')).toBeNull() + mock.frames.sensHealth = { type: 'sensHealth', ts: 2, version: 1, left: { connected: true }, right: { connected: false } } + rerender() + expect(within(screen.getByTestId('presence-right')).getByText('Disconnected')).toBeTruthy() + expect(within(screen.getByTestId('presence-left')).queryByText('Disconnected')).toBeNull() + mock.frames.sensHealth = { type: 'sensHealth', ts: 3, version: 1, left: { connected: false }, right: { connected: false } } + rerender() + expect(screen.getAllByText('Disconnected')).toHaveLength(2) +}) + it('switches flow units and ranges with live readings and downsampled history', () => { const { rerender } = render() expect(screen.getByText('No flow history')).toBeTruthy() diff --git a/src/components/Sensors/tests/sensorLogic.test.ts b/src/components/Sensors/tests/sensorLogic.test.ts index 934a44092..780a014fe 100644 --- a/src/components/Sensors/tests/sensorLogic.test.ts +++ b/src/components/Sensors/tests/sensorLogic.test.ts @@ -49,12 +49,12 @@ describe('cellTint', () => { }) describe('freezerStatus', () => { - const health = (over: Partial<{ tec: number, fan: number }> = {}): FrzHealthFrame => ({ + const health = (over: Partial<{ tec: number, fan: number, fanDuty: number }> = {}): FrzHealthFrame => ({ type: 'frzHealth', ts: 1, left: { pumpRpm: 2400, pumpDuty: 50, tecCurrent: over.tec ?? 1, flowrate: null }, right: { pumpRpm: 2400, pumpDuty: 50, tecCurrent: 1, flowrate: null }, - fan: { rpm: over.fan ?? 1200, duty: 50, bottomRpm: null }, + fan: { rpm: over.fan ?? 1200, duty: over.fanDuty ?? 50, bottomRpm: null }, }) it('reports no data before anything arrives', () => { @@ -67,11 +67,12 @@ describe('freezerStatus', () => { }) it('warns on low water, high TEC current, or a slow fan (in that priority)', () => { - expect(freezerStatus(health({ tec: 6, fan: 10 }), 'low', true).label).toBe('Water low') - expect(freezerStatus(health({ tec: 5.1 }), 'ok', true)).toEqual({ tone: 'warn', label: 'High TEC' }) - expect(freezerStatus(health({ tec: 5 }), 'ok', true).label).toBe('Normal') - expect(freezerStatus(health({ fan: 99 }), 'ok', true)).toEqual({ tone: 'warn', label: 'Fan slow' }) - expect(freezerStatus(health({ fan: 100 }), 'ok', true).label).toBe('Normal') + expect(freezerStatus(health({ tec: 14, fan: 10, fanDuty: 50 }), 'low', true).label).toBe('Water low') + expect(freezerStatus(health({ tec: 13.6 }), 'ok', true)).toEqual({ tone: 'warn', label: 'High TEC' }) + expect(freezerStatus(health({ tec: 11.3 }), 'ok', true).label).toBe('Normal') + expect(freezerStatus(health({ fan: 99, fanDuty: 50 }), 'ok', true)).toEqual({ tone: 'warn', label: 'Fan slow' }) + expect(freezerStatus(health({ fan: 0, fanDuty: 0 }), 'ok', true).label).toBe('Normal') + expect(freezerStatus(health({ fan: 100, fanDuty: 50 }), 'ok', true).label).toBe('Normal') }) }) diff --git a/src/components/Sensors/tests/streamGroups.test.ts b/src/components/Sensors/tests/streamGroups.test.ts new file mode 100644 index 000000000..5d20135fc --- /dev/null +++ b/src/components/Sensors/tests/streamGroups.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { ALL_SENSOR_TYPES } from '@/src/hooks/useSensorStream' +import { STREAM_GROUPS, fmtSeen, groupForType } from '../streamGroups' + +describe('groupForType', () => { + it('puts the firmware water and cover-health frames in Freezer and Presence', () => { + expect(groupForType('frzWater')).toBe('freezer') + expect(groupForType('sensHealth')).toBe('presence') + }) + + it('keeps the existing mappings', () => { + expect(groupForType('frzHealth')).toBe('freezer') + expect(groupForType('capSense2')).toBe('presence') + expect(groupForType('lps')).toBe('piezo') + expect(groupForType('deviceStatus')).toBe('status') + }) + + it('returns undefined for types outside every group', () => { + expect(groupForType('nope')).toBeUndefined() + }) + + it('assigns every streamed sensor type to exactly one group', () => { + for (const type of ALL_SENSOR_TYPES) { + expect(STREAM_GROUPS.filter(g => g.types.includes(type)), type).toHaveLength(1) + } + }) +}) + +describe('fmtSeen', () => { + it('compacts ages', () => { + expect(fmtSeen(null)).toBe('—') + expect(fmtSeen(1_234)).toBe('1.2s') + expect(fmtSeen(12_400)).toBe('12s') + expect(fmtSeen(4 * 60_000 + 5_000)).toBe('4m') + expect(fmtSeen(2 * 3_600_000)).toBe('2h') + }) +}) diff --git a/src/components/Settings/DeviceSettingsForm.tsx b/src/components/Settings/DeviceSettingsForm.tsx index 8be68c641..07d145aea 100644 --- a/src/components/Settings/DeviceSettingsForm.tsx +++ b/src/components/Settings/DeviceSettingsForm.tsx @@ -47,6 +47,7 @@ export const TIMEZONES = [ 'Europe/Paris', 'Europe/Berlin', 'Europe/Amsterdam', + 'Europe/Bucharest', 'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Kolkata', 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/TapGestureConfig.tsx b/src/components/Settings/TapGestureConfig.tsx index 11bcc0034..b8899cd07 100644 --- a/src/components/Settings/TapGestureConfig.tsx +++ b/src/components/Settings/TapGestureConfig.tsx @@ -42,46 +42,48 @@ export function idleDescription(g: GestureRecord | undefined): string { return g.alarmInactiveBehavior === 'power' ? 'Power on / off' : 'Nothing' } -/** What the gesture does while an alarm is ringing. */ +/** + * What the gesture does while an alarm is ringing — its own setting for any + * gesture (alarmBehavior); with none set the tap just stops the alarm, which + * the firmware does on any tap. + */ export function ringingDescription(g: GestureRecord | undefined): string { if (!g) return 'Not set' - if (g.actionType === 'temperature') return temperatureLabel(g) - if (g.alarmBehavior === 'snooze') return `Snooze ${Math.round((g.alarmSnoozeDuration ?? 300) / 60)} min` + if (g.alarmBehavior === 'snooze') return 'Snooze' return 'Stop alarm' } +/** The normal action: a temperature step, or (alarm-type rows) power toggle / nothing. */ +type IdleAction = 'temperature' | 'power' | 'none' + interface EditState { side: Side tapType: TapType - actionType: ActionType + idle: IdleAction temperatureChange: 'increment' | 'decrement' temperatureAmount: number - alarmBehavior: 'snooze' | 'dismiss' - alarmSnoozeDuration: number - alarmInactiveBehavior: 'power' | 'none' + ringing: 'snooze' | 'dismiss' } -const defaultEditState = (side: Side, tapType: TapType): EditState => ({ +// A new tap opened from the ringing column starts with no normal action, so +// saving only a ringing action doesn't also make it step the temperature. +const defaultEditState = (side: Side, tapType: TapType, fromRinging = false): EditState => ({ side, tapType, - actionType: 'temperature', + idle: fromRinging ? 'none' : 'temperature', temperatureChange: 'increment', temperatureAmount: 2, - alarmBehavior: 'snooze', - alarmSnoozeDuration: 300, - alarmInactiveBehavior: 'none', + ringing: 'dismiss', }) function editStateFromGesture(g: GestureRecord): EditState { return { side: g.side, tapType: g.tapType, - actionType: g.actionType, + idle: g.actionType === 'temperature' ? 'temperature' : g.alarmInactiveBehavior === 'power' ? 'power' : 'none', temperatureChange: g.temperatureChange ?? 'increment', temperatureAmount: g.temperatureAmount ?? 2, - alarmBehavior: g.alarmBehavior ?? 'snooze', - alarmSnoozeDuration: g.alarmSnoozeDuration ?? 300, - alarmInactiveBehavior: g.alarmInactiveBehavior ?? 'none', + ringing: g.alarmBehavior ?? 'dismiss', } } @@ -133,21 +135,25 @@ export function TapGestureConfig({ filterSide = 'left', onSideChange }: { [gestures] ) - const openEditor = (tapType: TapType) => { + const openEditor = (tapType: TapType, fromRinging: boolean) => { const gesture = findGesture(filterSide, tapType) - setEditing(gesture ? editStateFromGesture(gesture) : defaultEditState(filterSide, tapType)) + setEditing(gesture ? editStateFromGesture(gesture) : defaultEditState(filterSide, tapType, fromRinging)) } const handleSave = useCallback(() => { if (!editing) return - if (editing.actionType === 'temperature') { + // Both actions are saved together, whichever column opened the editor. + // The snooze length is the ringing alarm's (set on the alarm), not the tap's. + const ringing = { alarmBehavior: editing.ringing } + if (editing.idle === 'temperature') { setGesture.mutate({ side: editing.side, tapType: editing.tapType, actionType: 'temperature', temperatureChange: editing.temperatureChange, temperatureAmount: editing.temperatureAmount, + ...ringing, }) } else { @@ -155,10 +161,8 @@ export function TapGestureConfig({ filterSide = 'left', onSideChange }: { side: editing.side, tapType: editing.tapType, actionType: 'alarm', - alarmBehavior: editing.alarmBehavior, - alarmSnoozeDuration: - editing.alarmBehavior === 'snooze' ? editing.alarmSnoozeDuration : undefined, - alarmInactiveBehavior: editing.alarmInactiveBehavior, + ...ringing, + alarmInactiveBehavior: editing.idle, }) } }, [editing, setGesture]) @@ -192,7 +196,7 @@ export function TapGestureConfig({ filterSide = 'left', onSideChange }: { > openEditor(key)} + onClick={() => openEditor(key, ringing)} className={findGesture(filterSide, key) ? undefined : 'text-fg-2'} > {describe(findGesture(filterSide, key))} @@ -264,7 +268,8 @@ export function TapGestureConfig({ filterSide = 'left', onSideChange }: { } /** - * Body of the gesture editor: action type plus its parameters. + * Body of the gesture editor: the gesture's two independent actions — what + * it does normally, and what it does while an alarm is ringing. */ function GestureEditPanel({ state, @@ -275,15 +280,15 @@ function GestureEditPanel({ }) { return (
+ Normally onChange({ ...state, actionType })} + ariaLabel="Normally" + value={state.idle} + options={[{ value: 'temperature', label: 'Temperature' }, { value: 'power', label: 'Power on / off' }, { value: 'none', label: 'Nothing' }]} + onChange={idle => onChange({ ...state, idle })} /> - - {state.actionType === 'temperature' && ( + {state.idle === 'temperature' && ( <> )} - {state.actionType === 'alarm' && ( - <> - - onChange({ ...state, alarmBehavior })} - /> - - {state.alarmBehavior === 'snooze' && ( - - `${v} min`} - onChange={mins => onChange({ ...state, alarmSnoozeDuration: mins * 60 })} - /> - - )} - - onChange({ ...state, alarmInactiveBehavior })} - /> - - + While an alarm is ringing + onChange({ ...state, ringing })} + /> + {state.ringing === 'snooze' && ( + + How long a snooze lasts is set on each alarm. + )}
) diff --git a/src/components/Settings/sections.ts b/src/components/Settings/sections.ts index 756bb53b4..34d32bab9 100644 --- a/src/components/Settings/sections.ts +++ b/src/components/Settings/sections.ts @@ -3,7 +3,7 @@ import { Activity, Cog, Download, Globe, Hand, HardDriveUpload, House, Moon, Pal export const SECTIONS = [ { id: 'appearance', label: 'Appearance', icon: Palette, description: 'How this device shows the app' }, { id: 'backup', label: 'Backup', icon: HardDriveUpload, description: 'Nightly archive to your own storage' }, - { id: 'biometric-calibration', label: 'Biometric calibration', icon: Activity, description: 'Profiles, processing state and evaluation history' }, + { id: 'biometric-calibration', label: 'Biometric calibration', icon: Activity, description: 'Profiles, processing state and evaluation' }, { id: 'device', label: 'Device', icon: Cog, description: 'Pod-wide hardware and maintenance' }, { id: 'gestures', label: 'Gestures', icon: Hand, description: 'What taps on the cover do' }, { id: 'homekit', label: 'HomeKit', icon: House, description: 'Apple Home bridge' }, 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/Settings/tests/TapGestureConfig.test.tsx b/src/components/Settings/tests/TapGestureConfig.test.tsx index 99afeea98..14b96a7ee 100644 --- a/src/components/Settings/tests/TapGestureConfig.test.tsx +++ b/src/components/Settings/tests/TapGestureConfig.test.tsx @@ -53,14 +53,17 @@ describe('gesture descriptions', () => { expect(ringingDescription(undefined)).toBe('Not set') }) - it('shows temperature changes the same in both contexts', () => { + it('describes the normal and ringing actions independently', () => { expect(idleDescription(gesture({}))).toBe('Temperature +2°') - expect(ringingDescription(gesture({ temperatureChange: 'decrement', temperatureAmount: 1 }))).toBe('Temperature −1°') + // A temperature tap with no ringing action stops the alarm (the firmware does on any tap). + expect(ringingDescription(gesture({}))).toBe('Stop alarm') + expect(ringingDescription(gesture({ alarmBehavior: 'snooze', alarmSnoozeDuration: 420 }))).toBe('Snooze') + expect(idleDescription(gesture({ alarmBehavior: 'snooze' }))).toBe('Temperature +2°') }) - it('describes alarm gestures by context', () => { + it('describes alarm-type gestures by context', () => { const snooze = gesture({ actionType: 'alarm', alarmBehavior: 'snooze', alarmSnoozeDuration: 420, alarmInactiveBehavior: 'power' }) - expect(ringingDescription(snooze)).toBe('Snooze 7 min') + expect(ringingDescription(snooze)).toBe('Snooze') expect(idleDescription(snooze)).toBe('Power on / off') const dismiss = gesture({ actionType: 'alarm', alarmBehavior: 'dismiss', alarmInactiveBehavior: 'none' }) expect(ringingDescription(dismiss)).toBe('Stop alarm') @@ -69,7 +72,7 @@ describe('gesture descriptions', () => { }) describe('TapGestureConfig', () => { - it('saves a new temperature gesture with the default payload', () => { + it('saves a new gesture: +2° normally, stop the alarm while ringing', () => { render() fireEvent.click(screen.getByLabelText('Triple tap: Not set')) fireEvent.click(screen.getByText('Save')) @@ -79,23 +82,75 @@ describe('TapGestureConfig', () => { actionType: 'temperature', temperatureChange: 'increment', temperatureAmount: 2, + alarmBehavior: 'dismiss', + }) + }) + + it('changing the ringing action keeps the normal temperature action', () => { + trpcMock.state.gestures = { left: [gesture({ temperatureAmount: 3 })], right: [] } + render() + fireEvent.click(screen.getByLabelText('Double tap while ringing: Stop alarm')) + fireEvent.click(screen.getByText('Snooze')) + fireEvent.click(screen.getByText('Save')) + expect(trpcMock.setMutate).toHaveBeenCalledWith({ + side: 'left', + tapType: 'doubleTap', + actionType: 'temperature', + temperatureChange: 'increment', + temperatureAmount: 3, + alarmBehavior: 'snooze', }) }) - it('saves an alarm gesture, omitting snooze duration when dismissing', () => { + it('changing the normal action keeps the ringing action', () => { + trpcMock.state.gestures = { left: [gesture({ alarmBehavior: 'snooze', alarmSnoozeDuration: 420 })], right: [] } + render() + fireEvent.click(screen.getByLabelText('Double tap: Temperature +2°')) + fireEvent.click(screen.getByText('Power on / off')) + fireEvent.click(screen.getByText('Save')) + expect(trpcMock.setMutate).toHaveBeenCalledWith({ + side: 'left', + tapType: 'doubleTap', + actionType: 'alarm', + alarmBehavior: 'snooze', + alarmInactiveBehavior: 'power', + }) + }) + + it('leaves the snooze length to the alarm', () => { + render() + fireEvent.click(screen.getByLabelText('Double tap while ringing: Not set')) + fireEvent.click(screen.getByText('Snooze')) + expect(screen.getByText('How long a snooze lasts is set on each alarm.')).toBeTruthy() + expect(screen.queryByLabelText(/snooze duration/i)).toBeNull() + }) + + it('a new tap opened from the ringing column gets no normal action', () => { render() fireEvent.click(screen.getByLabelText('Quad tap while ringing: Not set')) - fireEvent.click(screen.getByText('Alarm & power')) + fireEvent.click(screen.getByText('Snooze')) + fireEvent.click(screen.getByText('Save')) + expect(trpcMock.setMutate).toHaveBeenCalledWith({ + side: 'left', + tapType: 'quadTap', + actionType: 'alarm', + alarmBehavior: 'snooze', + alarmInactiveBehavior: 'none', + }) + }) + + it('saves nothing normally and stop while ringing', () => { + render() + fireEvent.click(screen.getByLabelText('Quad tap: Not set')) + fireEvent.click(screen.getByText('Nothing')) fireEvent.click(screen.getByText('Stop alarm')) - fireEvent.click(screen.getByText('Power on / off')) fireEvent.click(screen.getByText('Save')) expect(trpcMock.setMutate).toHaveBeenCalledWith({ side: 'left', tapType: 'quadTap', actionType: 'alarm', alarmBehavior: 'dismiss', - alarmSnoozeDuration: undefined, - alarmInactiveBehavior: 'power', + alarmInactiveBehavior: 'none', }) }) diff --git a/src/components/Settings/tests/timezones.test.ts b/src/components/Settings/tests/timezones.test.ts new file mode 100644 index 000000000..8622f8983 --- /dev/null +++ b/src/components/Settings/tests/timezones.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { timezoneSchema } from '@/src/server/validation-schemas' +import { TIMEZONES } from '../DeviceSettingsForm' + +describe('TIMEZONES', () => { + it('only offers zones the server accepts', () => { + for (const tz of TIMEZONES) { + expect(timezoneSchema.safeParse(tz).success, tz).toBe(true) + } + }) + + it('has no duplicates', () => { + expect(new Set(TIMEZONES).size).toBe(TIMEZONES.length) + }) + + it('includes Europe/Bucharest', () => { + expect(TIMEZONES).toContain('Europe/Bucharest') + }) +}) 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..1912af06c --- /dev/null +++ b/src/components/Sleep/NightVitals.tsx @@ -0,0 +1,40 @@ +'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 })) + const sessionPoints = points.length + ? points + : [...new Set([...reference, ...referenceHrv, ...referenceBr].map(r => r.t))] + .sort((a, b) => a - b).map(t => ({ t, hr: null, hrv: null, br: null })) + 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 || hr.isLoading || hrv.isLoading || br.isLoading + ? + : sessionPoints.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..b3f6b5a51 --- /dev/null +++ b/src/components/Sleep/tests/NightVitals.test.tsx @@ -0,0 +1,58 @@ +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') +}) + +it.each(['hr', 'hrv_sdnn', 'breathing_rate'])('draws reference-only %s sessions with null pod metrics', (metric) => { + mock.chart.mockClear() + mock.query.mockImplementation(input => ({ data: input.metric === metric + ? [{ timestamp: new Date(2000), value: 35 }] + : metric === 'hr' && input.limit === 20000 && !input.metric + ? [{ timestamp: new Date(2000), heartRate: 62 }] + : [] })) + render() + expect(screen.queryByText('No recorded vitals for this night.')).toBeNull() + expect(mock.chart.mock.calls[0][0].sessions[0].points).toEqual([{ t: 2000, hr: null, hrv: null, br: null }]) +}) 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/System/DataRecordingCard.tsx b/src/components/System/DataRecordingCard.tsx new file mode 100644 index 000000000..0261497c9 --- /dev/null +++ b/src/components/System/DataRecordingCard.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Database } from 'lucide-react' +import { trpc } from '@/src/utils/trpc' +import { Button, Card, CardHeader, InlineError, SelectValue, Skeleton, Toggle } from '@/src/components/ds' +import { ConfirmDialog } from '@/src/components/Schedule/ConfirmDialog' +import { effectiveLoggingMode } from '@/src/lib/recordingPolicy' + +export function DataRecordingCard() { + const utils = trpc.useUtils() + const policy = trpc.system.getRecordingPolicy.useQuery({}, { refetchInterval: 15_000 }) + const [now, setNow] = useState(Date.now) + const [confirmArchiveOff, setConfirmArchiveOff] = useState(false) + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(timer) + }, []) + const save = trpc.system.setRecordingPolicy.useMutation({ + onSuccess: (data) => { + utils.system.getRecordingPolicy.setData({}, data) + setConfirmArchiveOff(false) + }, + }) + const data = policy.data + if (policy.isPending) return + if (policy.error || !data) return {policy.error?.message ?? 'Recording settings unavailable'} + const debugging = effectiveLoggingMode(data, now) === 'debug' + return ( + + +
+
+
+

App diagnostic logs

+

System journal · configured for RAM on installed pods

+
+ save.mutate({ loggingMode })} + /> +
+

+ Applies to core console logs and bundled Python module logs. Firmware, OS logs and third-party direct output are unaffected. + Off leaves no new app diagnostic trail for troubleshooting. RAM logs disappear on reboot. +

+
+ +

+ {debugging + ? `Debug logging active · returns to ${data.loggingMode === 'off' ? 'Off' : 'Quiet'} in ${Math.ceil(((data.debugUntil ?? now) - now) / 60_000)} min` + : `Logging: ${data.loggingMode === 'off' ? 'Off' : 'Quiet'}`} +

+
+
+
+
+

Health timeline

+

Flash · diagnostic status history

+

Stop saving new Health timeline samples. Live health checks, safety monitoring and module lifecycle status continue. Takes effect by the next sample, within a minute.

+
+ save.mutate({ healthHistory })} /> +
+
+
+

Raw waveform archive

+

Flash · compressed sensor recordings

+

For RAW-file firmware using the RAM archiver. Off stops future archive jobs and releases pending RAM pins within a minute; an in-progress file may finish. Live sensing continues. Saved archives remain, subject to normal retention. NATS-managed telemetry is unaffected.

+
+ rawArchive ? save.mutate({ rawArchive: true }) : setConfirmArchiveOff(true)} /> +
+

+ Biometric history stays enabled on flash: current vitals and sleep views depend on these records. + Hourly flash-write measurements also continue so you can compare the effect of these settings. +

+ {save.error && {save.error.message}} + save.mutate({ rawArchive: false })} + onCancel={() => setConfirmArchiveOff(false)} + /> +
+ ) +} diff --git a/src/components/System/FlashWrites.tsx b/src/components/System/FlashWrites.tsx new file mode 100644 index 000000000..29232d6b3 --- /dev/null +++ b/src/components/System/FlashWrites.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' +import type { flashInsights } from '@/src/lib/flashInsights' + +type Insights = ReturnType +const gb = (bytes: number) => `${(bytes / 1e9).toFixed(2)} GB` +const coverage = (hours: number) => `${hours.toFixed(1)} h measured` + +export function FlashWrites({ insights }: { insights: Insights }) { + const [selected, setSelected] = useState(23) + const point = insights.history[selected] + const max = Math.max(1, ...insights.history.map(p => p.bytesPerDay ?? 0)) + const labels = ['Last hour', 'Last 24 hours', 'Last 7 days'] + return ( +
+
+ {insights.windows.map((w, i) => ( +
+

{labels[i]}

+

+ {w.bytesPerDay == null ? 'Measuring…' : `${gb(w.bytesPerDay)}/day`} +

+

+ {coverage(w.coveredHours)} + {w.coveredHours < w.hours - 0.01 ? ' · partial' : ''} +

+
+ ))} +
+
+
+ Write rate · last 24 hours + {`${gb(max)}/day peak scale`} +
+
+ {insights.history.map((p, i) => { + const label = `${24 - i} to ${23 - i} hours ago: ${p.bytesPerDay == null ? 'insufficient coverage' : `${gb(p.bytesPerDay)}/day`}` + return ( + + ) + })} +
+
+ 24 hours ago + Now +
+ {point && ( +

+ {`${24 - selected}–${23 - selected} h ago · ${coverage(point.coveredHours)} · ${point.bytesPerDay == null ? 'Not enough data for a rate' : `${gb(point.bytes)} written (estimated)`}`} +

+ )} +
+

+ Daily rates are projections, not daily totals. Hourly samples capture firmware, sleepypod and OS writes together. + Reboots, counter resets and long sampling gaps are excluded; missing data is not zero writes. + Window boundaries are estimated between samples. GB uses 1,000,000,000 bytes. +

+

+ Keeping transient data in RAM, saving only changed values and batching database updates can reduce writes. + Smaller log or archive retention limits mainly reclaim space: deleting data does not undo its writes. + RAM buffers lose unflushed data on power loss. Compare full 24-hour windows with similar usage; + updates and archive jobs can cause short bursts. These counters do not measure the flash chip’s internal write amplification. +

+
+ ) +} diff --git a/src/components/System/StorageTab.tsx b/src/components/System/StorageTab.tsx index 441a74400..4c98a3a08 100644 --- a/src/components/System/StorageTab.tsx +++ b/src/components/System/StorageTab.tsx @@ -1,15 +1,19 @@ 'use client' import { useState } from 'react' -import { HardDrive, Trash2 } from 'lucide-react' +import { FlashWrites } from './FlashWrites' +import { DataRecordingCard } from './DataRecordingCard' +import { Gauge, HardDrive, Trash2 } from 'lucide-react' import type { inferRouterOutputs } from '@trpc/server' import type { AppRouter } from '@/src/server/routers/app' import { trpc } from '@/src/utils/trpc' -import { Button, Card, CardHeader, InlineError, Skeleton, Toggle } from '@/src/components/ds' +import { Button, Card, CardHeader, InlineError, KeyValue, Skeleton, StatusDot, Toggle } from '@/src/components/ds' import { ConfirmDialog } from '@/src/components/Schedule/ConfirmDialog' import { formatBytes } from '@/src/components/status/SystemInfoCard' type Storage = inferRouterOutputs['system']['getStorage'] +type Flash = NonNullable['system']['getFlashHealth']> +type Wear = Flash['lifeTime'][number] type SegmentKey = Storage['segments'][number]['key'] const SEGMENTS: Record = { @@ -26,6 +30,54 @@ function shortPath(p: string): string { return p.replace(/^\/persistent\//, '') } +const PRE_EOL = { + normal: { label: 'Normal', tone: 'ok' }, + warning: { label: 'Warning', tone: 'warn' }, + urgent: { label: 'Urgent', tone: 'danger' }, +} as const + +/** JEDEC reports wear in 10% steps; "exceeded" has no upper bound. */ +function wearLabel(w: NonNullable): string { + return w.maxPercent == null ? 'Past rated life' : `${w.minPercent}–${w.maxPercent}%` +} + +/** Card tone from the chip's own estimates: 80%+ worn or reserve blocks low is a warning. */ +function flashTone(f: Flash): 'warn' | 'danger' | undefined { + const worst = Math.max(...f.lifeTime.map(w => w?.minPercent ?? 0)) + if (f.preEol === 'urgent' || worst >= 100) return 'danger' + if (f.preEol === 'warning' || worst >= 80) return 'warn' + return undefined +} + +/** + * eMMC endurance: how worn the chip says it is, and how fast the pod is + * writing to it. Hidden off-pod, where there's no eMMC to read. + */ +function FlashCard({ flash }: { flash: Flash }) { + const [slc, main] = flash.lifeTime + const eol = flash.preEol && PRE_EOL[flash.preEol] + return ( + + } + /> +
+ {main && } + {slc && } + + +
+

+ Wear is the chip’s own estimate, in 10% steps. It is separate from the write rates below. +

+ +
+ ) +} + /** * System → Storage: what fills /persistent, how much raw history that leaves * once the pruner trims to its target, and a one-button cleanup of @@ -34,6 +86,7 @@ function shortPath(p: string): string { export function StorageTab() { const utils = trpc.useUtils() const storage = trpc.system.getStorage.useQuery({}, { refetchInterval: 60_000 }) + const flash = trpc.system.getFlashHealth.useQuery({}, { refetchInterval: 60_000 }) const [includeBackups, setIncludeBackups] = useState(false) const [confirming, setConfirming] = useState(false) const free = trpc.system.freeStorage.useMutation({ @@ -127,6 +180,9 @@ export function StorageTab() {

+ {flash.data && } + + ({ data: {} as Record, mutate: vi.fn(), pending: false, error: null as null | Error })) +vi.mock('@/src/utils/trpc', () => ({ + trpc: { + useUtils: () => ({ system: { getRecordingPolicy: { setData: vi.fn() } } }), + system: { + getRecordingPolicy: { useQuery: () => ({ data: mock.data, isPending: false, error: null }) }, + setRecordingPolicy: { useMutation: () => ({ mutate: mock.mutate, isPending: mock.pending, error: mock.error }) }, + }, + }, +})) +import { DataRecordingCard } from '../DataRecordingCard' +beforeEach(() => { + mock.data = { ...DEFAULT_RECORDING_POLICY } + mock.mutate.mockReset() + mock.pending = false + mock.error = null +}) +afterEach(() => vi.useRealTimers()) + +describe('data recording controls', () => { + it('changes logging and Health recording independently', () => { + render() + fireEvent.change(screen.getByRole('combobox', { name: 'App logging mode' }), { target: { value: 'off' } }) + expect(mock.mutate).toHaveBeenLastCalledWith({ loggingMode: 'off' }) + fireEvent.click(screen.getByRole('switch', { name: 'Record Health timeline' })) + expect(mock.mutate).toHaveBeenLastCalledWith({ healthHistory: false }) + expect(screen.getByText(/Biometric history stays enabled/)).toBeTruthy() + }) + + it('requires confirmation before discarding pending raw waveforms', () => { + render() + fireEvent.click(screen.getByRole('switch', { name: 'Record raw waveform archive' })) + expect(mock.mutate).not.toHaveBeenCalled() + expect(screen.getByText(/Pending, unsaved waveforms/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Stop archiving' })) + expect(mock.mutate).toHaveBeenCalledWith({ rawArchive: false }) + }) + + it('starts a bounded debug session and reflects expiry without saving again', () => { + vi.useFakeTimers() + vi.setSystemTime(1000) + mock.data = { ...DEFAULT_RECORDING_POLICY, loggingMode: 'off', debugUntil: 2000 } + render() + expect(screen.getByText(/returns to Off in 1 min/)).toBeTruthy() + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(screen.getByText('Logging: Off')).toBeTruthy() + expect(mock.mutate).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Debug for 15 minutes' })) + expect(mock.mutate).toHaveBeenCalledWith({ debug: true }) + }) + + it('disables conflicting changes while saving and reports errors without claiming success', () => { + mock.pending = true + mock.error = new Error('Read-only filesystem') + render() + expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(true) + expect(screen.getByText('Read-only filesystem')).toBeTruthy() + }) +}) diff --git a/src/components/System/tests/StorageTab.test.tsx b/src/components/System/tests/StorageTab.test.tsx index fe9d0e090..8a12f8f9d 100644 --- a/src/components/System/tests/StorageTab.test.tsx +++ b/src/components/System/tests/StorageTab.test.tsx @@ -1,3 +1,4 @@ +import { flashInsights } from '@/src/lib/flashInsights' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,6 +9,7 @@ const mocks = vi.hoisted(() => ({ mutate: vi.fn(), result: undefined as unknown, pending: false, + flash: null as unknown, })) vi.mock('@/src/utils/trpc', () => ({ @@ -15,17 +17,21 @@ vi.mock('@/src/utils/trpc', () => ({ useUtils: () => ({ system: { getStorage: { invalidate: vi.fn() } } }), system: { getStorage: { useQuery: () => ({ data: mocks.pending ? undefined : mocks.data, isPending: mocks.pending, isLoading: false, error: null }) }, + getFlashHealth: { useQuery: () => ({ data: mocks.flash }) }, freeStorage: { useMutation: () => ({ mutate: mocks.mutate, isPending: false, error: null, data: mocks.result }) }, }, }, })) +vi.mock('../DataRecordingCard', () => ({ DataRecordingCard: () =>
Data recording
})) + import { StorageTab } from '../StorageTab' beforeEach(() => { mocks.mutate.mockReset() mocks.result = undefined mocks.pending = false + mocks.flash = null mocks.data = { persistent: { totalBytes: 15 * GB, usedBytes: 12 * GB, availableBytes: 3 * GB, usedPercent: 80 }, prunerTargetPercent: 80, @@ -96,4 +102,65 @@ describe('StorageTab', () => { expect(screen.getByText(/Cleanup tool not installed/)).toBeTruthy() expect(screen.queryByRole('button', { name: /Free up/ })).toBeNull() }) + + describe('flash endurance', () => { + const flash = (over: Record = {}) => ({ + insights: flashInsights([ + { at: 0, bootId: 'a', sectorsWritten: 0 }, + { at: 3_600_000, bootId: 'a', sectorsWritten: 100_000 }, + ], 3_600_000), + device: 'mmcblk0', + name: 'C9A551', + capacityBytes: 29 * GB, + lifeTime: [{ minPercent: 30, maxPercent: 40 }, null], + preEol: 'normal', + sinceBoot: { bytes: 1.5 * GB, uptimeSeconds: 2600 }, + perDay: { bytes: 3.1 * GB, coveredHours: 168 }, + ...over, + }) + + it('is hidden off-pod', () => { + render() + expect(screen.queryByTestId('flash-card')).toBeNull() + }) + + it('shows wear, reserve blocks and writes per day', () => { + mocks.flash = flash() + render() + expect(screen.getByText('C9A551 · 29.0 GB eMMC')).toBeTruthy() + expect(screen.getByText('30–40%')).toBeTruthy() + expect(screen.queryByText('Wear (main area)')).toBeNull() + expect(screen.getAllByText('1.23 GB/day')).toHaveLength(3) + expect(screen.getByText('Last 24 hours')).toBeTruthy() + expect(screen.getByRole('group', { name: 'Hourly flash write rates' })).toBeTruthy() + expect(screen.getByText(/Keeping transient data in RAM/)).toBeTruthy() + expect(document.querySelector('details')).toBeNull() + expect(screen.getAllByText('Normal').length).toBeGreaterThan(0) + }) + + it('says it is still measuring before an hour of samples', () => { + mocks.flash = flash({ perDay: null, insights: flashInsights([], 0) }) + render() + expect(screen.getAllByText('Measuring…')).toHaveLength(3) + expect(screen.getByText(/missing data is not zero writes/)).toBeTruthy() + }) + + it('lets keyboard and pointer users inspect an interval without treating missing data as zero', () => { + mocks.flash = flash() + render() + const missing = screen.getByRole('button', { name: '24 to 23 hours ago: insufficient coverage' }) + fireEvent.click(missing) + expect(missing.getAttribute('aria-pressed')).toBe('true') + expect(screen.getByText(/24–23 h ago.*Not enough data/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '1 to 0 hours ago: 1.23 GB/day' })) + expect(screen.getByText(/1–0 h ago.*0.05 GB written/)).toBeTruthy() + }) + + it('flags a chip past rated life', () => { + mocks.flash = flash({ lifeTime: [null, { minPercent: 100, maxPercent: null }], preEol: 'urgent' }) + render() + expect(screen.getByText('Past rated life')).toBeTruthy() + expect(screen.getByTestId('flash-card').className).toContain('border-danger-line') + }) + }) }) diff --git a/src/components/biometrics/BiometricsPage.tsx b/src/components/biometrics/BiometricsPage.tsx index aa8b455df..1718149d6 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,10 @@ function BiometricsBody({ sectionSwitch, now }: { sectionSwitch?: ReactNode, now : sessions.length === 0 && !stall ?
No vitals in this range
: ( - + )} - 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.

} + 0} referenceHrv={referenceHrv.length > 0} bandLabel={bandLabel} stalled={!!stall} reference={reference.length > 0} />
diff --git a/src/components/biometrics/Calibration/BiometricCalibration.tsx b/src/components/biometrics/Calibration/BiometricCalibration.tsx index c32e7a9ad..e9565f959 100644 --- a/src/components/biometrics/Calibration/BiometricCalibration.tsx +++ b/src/components/biometrics/Calibration/BiometricCalibration.tsx @@ -1,63 +1,89 @@ 'use client' import { useState } from 'react' +import { ChevronLeft } from 'lucide-react' import { Trans, useLingui } from '@lingui/react/macro' -import { Button, Card } from '@/src/components/ds' -import { SideTabs } from '@/src/components/Settings/SettingsLayout' -import { SleepStageRules } from '@/src/components/Settings/SleepStageRules' -import { CalibrationOverview } from './CalibrationOverview' +import { cn } from '@/lib/utils' import { CalibrationReferences } from './CalibrationReferences' import { ReferenceLibrary } from './ReferenceLibrary' import { ProfileLibrary } from './ProfileLibrary' -import { ProfileRecovery } from './ProfileRecovery' import { CalibrationBacktests } from './CalibrationBacktests' -import { CalibrationHistory } from './CalibrationHistory' import { ShadowRuns } from './ShadowRuns' +import { CalibrationPage } from './CalibrationPage' +import { SideTabs } from '@/src/components/Settings/SettingsLayout' type Side = 'left' | 'right' +type View = 'overview' | 'references' | 'profiles' | 'backtests' | 'observations' + +/** + * The calibration page is the overview. The workbench views behind it — + * reference imports, profile revisions, datasets and backtests, prospective + * runs — stay one click away for the cases the page links out to. + */ export function BiometricCalibration({ side, onSideChange }: { side: Side, onSideChange: (side: Side) => void }) { const { t } = useLingui() - const [view, setView] = useState<'overview' | 'references' | 'backtests' | 'profiles' | 'history' | 'observations'>('overview') + const [view, setView] = useState('overview') + const views: Array<{ id: View, label: string }> = [ + { id: 'overview', label: t`Overview` }, + { id: 'references', label: t`References` }, + { id: 'profiles', label: t`Profiles` }, + { id: 'backtests', label: t`Backtests` }, + { id: 'observations', label: t`Observations` }, + ] + const nav = ( + + ) + + if (view === 'overview') { + return ( +
+ + {nav} +
+ ) + } return ( -
- +
+ {nav} +
- - - {view === 'overview' +
+ {view === 'references' ? (
- - + +
) - : view === 'references' - ? ( -
- - -
- ) - : view === 'backtests' - ? - : view === 'observations' - ? - : view === 'history' - ? - : ( - <> - -

Direct sleep-stage changes are unvalidated manual settings. Saving requests a profile change; processor adoption is reported separately in Overview.

- - - )} + : view === 'backtests' + ? + : view === 'observations' + ? + : ( + <> + +

Profile revisions here are candidates. Applying one requests it like Save and request does; the overview shows when the processor acknowledges it.

+ + )}
) } diff --git a/src/components/biometrics/Calibration/CalibrationActivity.tsx b/src/components/biometrics/Calibration/CalibrationActivity.tsx new file mode 100644 index 000000000..d00858c57 --- /dev/null +++ b/src/components/biometrics/Calibration/CalibrationActivity.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useState } from 'react' +import { useLingui } from '@lingui/react/macro' +import { Card } from '@/src/components/ds' +import type { Actor } from './calibration-model' +import { ActorBadge } from './CalibrationProfileCard' + +export interface ActivityRow { id: string, atMs: number, actor: Actor, what: string, detail: string | null, when?: string } + +const VISIBLE = 6 + +/** Who did what to this side's profile, newest first. */ +export function ActivityCard({ rows, timezone }: { rows: ActivityRow[], timezone: string }) { + const { t } = useLingui() + const [all, setAll] = useState(false) + const shown = all ? rows : rows.slice(0, VISIBLE) + return ( + +
+ {t`Activity`} + {timezone} +
+ {rows.length === 0 &&

{t`Nothing has happened on this side yet.`}

} +
    + {shown.map(row => ( +
  1. +
    + + + {row.actor.kind === 'agent' && {`${row.actor.name} `}} + {row.what} + + {row.when} +
    + {row.detail &&
    {row.detail}
    } +
  2. + ))} +
+ {rows.length > VISIBLE && ( + + )} +
+ ) +} diff --git a/src/components/biometrics/Calibration/CalibrationControls.tsx b/src/components/biometrics/Calibration/CalibrationControls.tsx new file mode 100644 index 000000000..a1a404330 --- /dev/null +++ b/src/components/biometrics/Calibration/CalibrationControls.tsx @@ -0,0 +1,167 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { ChevronRight } from 'lucide-react' +import { useLingui } from '@lingui/react/macro' +import { trpc } from '@/src/utils/trpc' +import { Button, InlineError, Modal, Skeleton, StatusDot } from '@/src/components/ds' +import { cn } from '@/lib/utils' +import type { Segment } from './calibration-model' + +export type RestoreTarget = 'previous' | 'defaults' + +/** Saved › Requested › Processor, each with a dot and a mono value. */ +export function LifecycleStrip({ saved, requested, processor }: { saved: Segment, requested: Segment, processor: Segment }) { + const { t } = useLingui() + const segments = [ + { key: 'saved', label: t`Saved`, segment: saved }, + { key: 'requested', label: t`Requested`, segment: requested }, + { key: 'processor', label: t`Processor`, segment: processor }, + ] + return ( +
+ {segments.map(({ key, label, segment }, i) => ( +
+ {i > 0 && } +
+ + {label} + {segment.value} +
+
+ ))} +
+ ) +} + +export function RestoreMenu({ hasPrevious, disabled, onSelect, className }: { + hasPrevious: boolean + disabled?: boolean + onSelect: (target: RestoreTarget) => void + className?: string +}) { + const { t } = useLingui() + const [open, setOpen] = useState(false) + const root = useRef(null) + useEffect(() => { + if (!open) return + const close = (e: MouseEvent | KeyboardEvent) => { + if (e instanceof KeyboardEvent ? e.key === 'Escape' : !root.current?.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', close) + document.addEventListener('keydown', close) + return () => { + document.removeEventListener('mousedown', close) + document.removeEventListener('keydown', close) + } + }, [open]) + const pick = (target: RestoreTarget) => { + setOpen(false) + onSelect(target) + } + return ( +
+ + {open && ( +
+ + +
+ )} +
+ ) +} + +const randomKey = () => Array.from(crypto.getRandomValues(new Uint8Array(24)), b => b.toString(16).padStart(2, '0')).join('') +const show = (value: unknown) => typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean' ? String(value) : value === undefined ? '—' : JSON.stringify(value) + +/** + * Restore review: what changes, then one request. Restoring saves the target + * as the profile and requests it; the current saved profile becomes previous. + */ +export function RestoreDialog({ side, target, binding, onClose, onDone }: { + side: 'left' | 'right' + target: RestoreTarget + binding: { generation: number, desiredRevisionId: string | null, previousRevisionId: string | null } + onClose: () => void + onDone: () => void +}) { + const { t } = useLingui() + const diff = trpc.biometricTuning.diffProfileRevision.useMutation() + const rollback = trpc.biometricTuning.rollbackProfile.useMutation() + const reset = trpc.biometricTuning.resetProfile.useMutation() + const key = useRef(randomKey()) + const revisionId = target === 'previous' ? binding.previousRevisionId : null + const { mutate } = diff + useEffect(() => { + mutate({ side, revisionId, againstRevisionId: binding.desiredRevisionId }) + }, [mutate, side, revisionId, binding.desiredRevisionId]) + + const busy = rollback.isPending || reset.isPending + const error = diff.error ?? rollback.error ?? reset.error + async function confirm() { + if (!diff.data) return + const common = { side, expectedGeneration: binding.generation, expectedResolvedHash: diff.data.afterHash, boundary: 'next_session' as const, + requester: 'web-calibration', idempotencyKey: key.current, allowUnvalidated: true as const } + if (target === 'previous') await rollback.mutateAsync({ ...common, revisionId }) + else await reset.mutateAsync(common) + onDone() + } + + const changes = diff.data?.changes ?? [] + const numeric = changes.filter(c => !c.path.startsWith('sleep_stages')) + const stages = changes.length - numeric.length + const title = target === 'previous' ? t`Restore the previous profile` : t`Restore system defaults` + return ( + + + + + )} + > +

+ {t`Saves it as the profile and requests it; the current saved profile becomes previous. The processor keeps running what it has until it acknowledges the request.`} +

+ {!diff.data && !diff.error + ? + : diff.data && ( + changes.length === 0 + ?

{t`Identical to the saved profile; restoring only re-requests it.`}

+ : ( +
    + {numeric.map(c => ( +
  • + {c.path} + + {show(c.before)} + {' → '} + {show(c.after)} + +
  • + ))} + {stages > 0 &&
  • {t`${stages} sleep-stage values`}
  • } +
+ ) + )} + {error ? {error.message} : null} +
+ ) +} diff --git a/src/components/biometrics/Calibration/CalibrationEvidence.tsx b/src/components/biometrics/Calibration/CalibrationEvidence.tsx new file mode 100644 index 000000000..4f9b3a06a --- /dev/null +++ b/src/components/biometrics/Calibration/CalibrationEvidence.tsx @@ -0,0 +1,348 @@ +'use client' + +import { useLingui } from '@lingui/react/macro' +import { Upload } from 'lucide-react' +import { trpc } from '@/src/utils/trpc' +import { Card, CardHeader } from '@/src/components/ds' +import { cn } from '@/lib/utils' +import type { SetupProgress } from './evaluation-setup' +import { + aggregate, + barHeight, + clockLabel, + compareTone, + coverage, + nightLabel, + shortDuration, + TRACE, + traceTicks, + tracePath, + type ConfigLabel, + type JobLike, + type NightScore, + type RunStatus, + type Split, +} from './calibration-model' + +export interface DatasetNight { + cohortId: string + split: Split + startMs: number + endMs: number + score: NightScore | null + /** Reference sources ("watch + ring"); null = no reference for this night. */ + reference: string | null +} + +const SPLIT_LETTER: Record = { tuning: 'T', validation: 'V', holdout: 'H' } +const splitTone = (split: Split) => split === 'holdout' ? 'text-warn' : 'text-fg-3' + +function formatBytes(n: number) { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + return `${(n / 1024 / 1024).toFixed(1)} MB` +} +function formatDay(ms: number, timeZone: string) { + try { + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone }).format(ms) + } + catch { + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(ms) + } +} + +/** The frozen nights the backtest replays, split whole into tuning/validation/holdout. */ +export function DatasetCard({ dataset, nights, selected, onSelect, onImport, onFreeze, setup, timezone }: { + dataset: { createdAtMs: number, byteLength: number, available: boolean } | null + nights: DatasetNight[] + selected: string | null + onSelect: (cohortId: string) => void + onImport: () => void + onFreeze: () => void + setup: SetupProgress + timezone: string +}) { + const { t } = useLingui() + const counts = { tuning: 0, validation: 0, holdout: 0 } + for (const n of nights) counts[n.split]++ + const sources = [...new Set(nights.flatMap(n => n.reference ? n.reference.split(' + ') : []))] + const explainer = dataset + ? sources.length + ? t`Frozen copy of ${nights.length} nights and ${sources.join(' + ')} reference. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own.` + : t`Frozen copy of ${nights.length} nights. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own.` + : t`No dataset is frozen for this side yet. Freezing takes the newest nights a reference covers, with their RAW archives, so a backtest can replay them.` + const running = !['idle', 'done', 'failed'].includes(setup.step) + const status = { + idle: null, + linking: t`Linking Watch data already on the pod…`, + choosing: t`Choosing nights the reference covers…`, + freezing: t`Freezing the nights…`, + inspecting: t`Reading RAW archives · ${setup.done ?? 0} / ${setup.total ?? 0}`, + retaining: t`Retaining the archives…`, + protocol: t`Setting up the replay…`, + queueing: t`Queueing the backtest…`, + done: t`Frozen and queued. The backtest replays every split; results appear as each finishes.`, + failed: setup.error ?? t`Freezing failed.`, + }[setup.step] + return ( + + + {dataset.available ? t`frozen ${formatDay(dataset.createdAtMs, timezone)} · ${formatBytes(dataset.byteLength)}` : t`released`} + + )} + /> + {nights.length > 0 && ( +
+ {nights.map((n) => { + const label = nightLabel(n.cohortId) + const on = n.cohortId === selected + return ( + + ) + })} +
+ )} +
+ {nights.length > 0 && ( + <> + + T + {` ${t`tuning`} · ${counts.tuning}`} + + + V + {` ${t`validation`} · ${counts.validation}`} + + + H + {` ${t`holdout`} · ${counts.holdout}`} + + + )} + + +
+ {status &&

{status}

} +
+ ) +} + +const fmt1 = (v: number | null) => v === null ? '—' : v.toFixed(1) + +/** "What the saved profile did": per-night error, one night's trace, and the split table. */ +/** What is missing before a backtest can replay the saved profile. */ +export type BacktestReadiness = 'no-reference' | 'no-dataset' | 'no-experiment' | 'ready' + +export function BacktestCard({ side, group, readiness, run, nights, scores, selected, onSelect, trace, timezone, h24 }: { + side: 'left' | 'right' + group: { latest: JobLike } | null + readiness: BacktestReadiness + run: RunStatus | null + nights: DatasetNight[] + scores: NightScore[] + selected: DatasetNight | null + onSelect: (cohortId: string) => void + trace: { job: JobLike, contentHash: string, previous: ConfigLabel } | null + timezone: string + h24: boolean +}) { + const { t } = useLingui() + const statusText = run && (run.kind === 'done' ? t`done · ${shortDuration(run.durationMs)}` : run.kind === 'running' ? t`running` : run.kind === 'queued' ? t`queued` : t`failed`) + const statusTone = run ? { ok: 'text-ok', warn: 'text-warn', danger: 'text-danger', muted: 'text-fg-3' }[run.tone] : '' + + const pending = { + 'no-reference': t`No backtest yet. Import a reference for this side, then freeze nights that have it; the replay scores the previous and saved profiles against it. Nothing here changes the pod.`, + 'no-dataset': t`No backtest yet. Freeze a new dataset below; it replays the newest referenced nights through the previous and saved profiles. Nothing here changes the pod.`, + 'no-experiment': t`No backtest yet. This dataset has no waveform replay; freeze a new dataset below to set one up. Nothing here changes the pod.`, + 'ready': t`No backtest has replayed the saved profile yet. Saving a change replays the frozen dataset on its tuning, validation and holdout nights; nothing here changes the pod.`, + }[readiness] + + // Up to two weeks of nights; older ones stay in the table aggregates. + const shown: Array = nights.length ? nights.slice(-14) : Array.from({ length: 7 }, () => null) + const split = (s: Split) => aggregate(scores, 'heart_rate', s) + const rows: Array<{ label: string, trust?: boolean, previous: number | null, saved: number | null, unit: string, higherIsBetter?: boolean, percent?: boolean }> = [ + { label: t`HR error · tuning`, ...split('tuning'), unit: '' }, + { label: t`HR error · validation`, ...split('validation'), unit: '' }, + { label: t`HR error · holdout`, trust: true, ...split('holdout'), unit: '' }, + { label: t`Night covered`, ...coverage(scores), unit: '%', higherIsBetter: true, percent: true }, + { label: t`HRV index error`, ...aggregate(scores, 'window_ibi_variability_v1'), unit: ' ms' }, + { label: t`Breathing error`, ...aggregate(scores, 'breathing_rate'), unit: ' br/m' }, + ] + const cell = (v: number | null, percent?: boolean, unit = '') => v === null ? '—' : percent ? `${Math.round(v * 100)}%` : `${v.toFixed(1)}${unit}` + + return ( + + {group ? t`Backtest ${group.latest.id.slice(0, 4)}. Frozen dataset replayed through the previous and saved profiles; nothing here changes the pod.` : pending}} + right={statusText + ? {statusText} + : {t`not run`}} + /> + +
+ {t`HR error per night · bpm vs reference`} + {t`click a night`} +
+
+ {shown.map((n, i) => { + if (!n) { + return ( +
+
+
·
+
+ — + — +
+
+ ) + } + const on = n.cohortId === selected?.cohortId + const prev = n.score?.previous.heart_rate.mae ?? null, saved = n.score?.saved.heart_rate.mae ?? null + const label = nightLabel(n.cohortId) + return ( + + ) + })} +
+
+ + + {t`previous`} + + + + {t`saved`} + + {t`H holdout`} +
+ +
+ {selected && trace && trace.job.evaluationScope === 'retained_waveform_replay' + ? + : ( +

+ {selected && trace + ? t`This backtest compared stored readings, so there is no recomputed heart-rate trace to draw.` + : group ? t`Pick a scored night to see its heart-rate trace.` : t`Each night's heart rate against the reference appears here once a backtest has run.`} +

+ )} + +
+
+ + {t`previous`} + {t`saved`} + {rows.map((r) => { + const tone = compareTone(r.previous, r.saved, r.higherIsBetter) + return ( +
+ {r.label} + {cell(r.previous, r.percent, r.unit)} + {cell(r.saved, r.percent, r.unit)} +
+ ) + })} +
+

{t`Holdout is the number to trust; tuning nights were used to pick these values.`}

+ + ) +} + +/** One night: reference, previous and saved heart rate on a 40–100 bpm scale. */ +function NightTrace({ side, night, trace, timezone, h24 }: { + side: 'left' | 'right' + night: DatasetNight + trace: { job: JobLike, contentHash: string, previous: ConfigLabel } + timezone: string + h24: boolean +}) { + const { t } = useLingui() + const base = { id: trace.job.id, expectedContentHash: trace.contentHash, cohortId: night.cohortId, offset: 0, limit: 500 } + const saved = trpc.biometricTuning.getBacktestNumericTrace.useQuery({ ...base, configuration: 'candidate' }, { staleTime: Infinity }) + const previous = trpc.biometricTuning.getBacktestNumericTrace.useQuery({ ...base, configuration: trace.previous }, { staleTime: Infinity }) + const reference = trpc.biometricTuning.getBacktestReferenceTrace.useQuery({ id: trace.job.id, expectedContentHash: trace.contentHash, cohortId: night.cohortId, metric: 'heart_rate', offset: 0, limit: 500 }, + { staleTime: Infinity, enabled: night.reference !== null }) + const label = nightLabel(night.cohortId) + const savedSide = (saved.data?.items ?? []).filter(i => i.side === side) + const points = (rows: Array<{ timestampMs: number, heartRate: number | null }>) => rows.map(r => ({ t: r.timestampMs, v: r.heartRate })) + const refPoints = (reference.data?.items ?? []).filter(r => r.compatibility === null && typeof r.value === 'number').map(r => ({ t: r.startMs, v: r.value as number })) + const truncated = Boolean(saved.data?.nextCursor || previous.data?.nextCursor || reference.data?.nextCursor) + const ticks = traceTicks(night.startMs, night.endMs) + const yAt = (bpm: number) => TRACE.height - ((bpm - TRACE.minBpm) / (TRACE.maxBpm - TRACE.minBpm)) * TRACE.height + const quality = savedSide.length ? savedSide.reduce((s, r) => s + (r.qualityScore ?? 0), 0) / savedSide.length : null + const note = night.reference === null + ? t`no reference device · excluded from error` + : quality === null ? night.reference : `${night.reference} · ${t`quality`} ${quality.toFixed(2)}` + return ( + <> +
+ {t`Heart rate · ${label.weekday} ${label.day}`} + {note} +
+
+ + {[100, 70, 40].map(bpm => )} + + + + +
+ {ticks.map(tick => {clockLabel(tick, timezone, h24)})} +
+
+ 100 + 70 + 40 +
+
+
+ {`— ${t`saved`}`} + {`┄ ${t`previous`}`} + {`— ${t`reference`}`} + {truncated && {t`first 500 readings`}} +
+ + ) +} diff --git a/src/components/biometrics/Calibration/CalibrationHistory.tsx b/src/components/biometrics/Calibration/CalibrationHistory.tsx deleted file mode 100644 index 0d3212224..000000000 --- a/src/components/biometrics/Calibration/CalibrationHistory.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Trans, useLingui } from '@lingui/react/macro' -import { trpc } from '@/src/utils/trpc' -import { Button, Card, CardHeader, InlineError, Skeleton } from '@/src/components/ds' -import { useTimeFormat } from '@/src/providers/TimeFormatProvider' - -export function CalibrationHistory({ side }: { side: 'left' | 'right' }) { - const { t, i18n } = useLingui() - const timeFormat = useTimeFormat() - const [beforeGeneration, setBeforeGeneration] = useState() - const history = trpc.biometricTuning.listActivationHistory.useQuery({ side, beforeGeneration }, { refetchInterval: beforeGeneration === undefined ? 5000 : false }) - const settings = trpc.settings.getAll.useQuery({}, { staleTime: 60000 }) - const timezone = settings.data?.device.timezone ?? 'UTC' - const dates = new Intl.DateTimeFormat(i18n.locale || 'en', { dateStyle: 'medium', timeStyle: 'short', timeZone: timezone, hour12: timeFormat !== '24h' }) - const actions: Record = { activate: t`Apply profile`, reset: t`Use system defaults`, rollback: t`Restore previous profile`, migration: t`Migrate settings`, fallback: t`Compatibility fallback` } - const states: Record = { saved: t`Saved`, pending_delivery: t`Waiting for delivery`, pending_boundary: t`Waiting for a processing boundary`, applied: t`Applied`, failed: t`Failed` } - return ( - - Activation history} subtitle={Past acknowledgements do not establish which profile is running now.} /> -

- {t`Times shown in ${timezone}`} -

- {history.error - ? {history.error.message} - : !history.data - ? - : history.data.items.length === 0 - ?

No activation history for this side.

- : ( -
    - {history.data.items.map(event => ( -
  1. -
    - {actions[event.action]} - {states[event.state]} -
    - -

    - {t`Generation ${event.generation}`} -

    - {event.reason ?

    {event.reason}

    : null} -
    - Exact profile transition -

    - {event.previousHash} - {' '} - → - {' '} - {event.nextHash} -

    -

    - {t`Requested by ${event.requester}`} -

    -
    -
  2. - ))} -
- )} -
- {beforeGeneration !== undefined ? : null} - {history.data?.nextGeneration != null ? : null} -
-
- ) -} diff --git a/src/components/biometrics/Calibration/CalibrationOverview.tsx b/src/components/biometrics/Calibration/CalibrationOverview.tsx deleted file mode 100644 index 26d68f645..000000000 --- a/src/components/biometrics/Calibration/CalibrationOverview.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client' - -import { Trans, useLingui } from '@lingui/react/macro' -import { trpc } from '@/src/utils/trpc' -import { Card, CardHeader, InlineError, SettingRow, Skeleton } from '@/src/components/ds' -import { SectionColumns } from '@/src/components/Settings/SettingsLayout' - -export function CalibrationOverview({ side }: { side: 'left' | 'right' }) { - const { t } = useLingui() - const overview = trpc.biometricTuning.getProfileOverview.useQuery({ side }, { refetchInterval: 5000 }) - const activation = trpc.biometricTuning.getActivationStatus.useQuery({ side }, { refetchInterval: 5000 }) - if (overview.error) return {overview.error.message} - if (!overview.data) return - const data = overview.data - const states: Record = { unreported: t`Not reported`, current: t`Current`, stale: t`Stale`, failed: t`Failed`, - mixed_generations: t`Different versions reported`, clock_skew: t`Clock mismatch`, pending_delivery: t`Waiting for delivery`, - pending_boundary: t`Waiting for a processing boundary`, applied: t`Applied` } - const components: Record = { heart_rate: t`Heart rate`, breathing_rate: t`Breathing rate`, hrv_index: t`HRV index` } - return ( - - - Profile and processor} subtitle={Saved settings and processor reports are separate.} /> - Saved profile}>{data.desired.isDefault ? System defaults : Personalized profile} - Requested change}>{activation.data ? states[activation.data.observedState] : t`Not reported`} - Processor health}>{states[data.runtimeHealth.state]} - {activation.error ? {activation.error.message} : null} - {data.desired.warning ?

{data.desired.warning}

: null} - {data.runtimeHealth.components.map(component => ( - - {states[component.state]} - - ))} -
- Configuration identity -

{data.desired.hash}

-

- {t`Generation ${data.desired.binding.generation}`} -

-
-
- - What these metrics mean} /> -

The HRV index measures window IBI variability. It is not clinical beat-to-beat RMSSD, and incompatible reference metrics cannot validate it.

-

Historical sleep stages are recomputed with the current classifier. Original stored vitals remain separate from replay results.

- {data.stageView.provenance.warning ?

{data.stageView.provenance.warning}

: null} -
- - )} - right={( - - Measured sensor calibration} subtitle={Sensor measurements are separate from algorithm personalization.} /> - {data.sensorCalibration.length === 0 - ?

No sensor calibration has been reported for this side.

- : data.sensorCalibration.map(sensor => ( - - {sensor.freshness === 'current' ? t`Current` : sensor.freshness === 'expired' ? t`Expired` : t`Freshness unknown`} - - ))} - {data.limitations.map(limitation =>

{limitation}

)} -
- )} - /> - ) -} diff --git a/src/components/biometrics/Calibration/CalibrationPage.tsx b/src/components/biometrics/Calibration/CalibrationPage.tsx new file mode 100644 index 000000000..8caa457c6 --- /dev/null +++ b/src/components/biometrics/Calibration/CalibrationPage.tsx @@ -0,0 +1,408 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { useLingui } from '@lingui/react/macro' +import { defaultConfig, type BiometricConfig } from '@/src/biometrics/algorithms/registry-core' +import { trpc } from '@/src/utils/trpc' +import { cn } from '@/lib/utils' +import { InlineError, SegmentedControl, Skeleton } from '@/src/components/ds' +import { useSideNames } from '@/src/hooks/useSideNames' +import { useTimeFormat } from '@/src/providers/TimeFormatProvider' +import { numericReportViewSchema } from './report-view' +import { + actorOf, + activityTime, + baselineLabel, + changedPaths, + draftFrom, + lifecycle, + nightScores, + overridesFor, + paramDiffs, + runStatus, + savedBacktest, + shortDuration, + type Draft, + type NightScore, + type Split, +} from './calibration-model' +import { LifecycleStrip, RestoreMenu, RestoreDialog, type RestoreTarget } from './CalibrationControls' +import { ProfileCard } from './CalibrationProfileCard' +import { BacktestCard, DatasetCard, type BacktestReadiness, type DatasetNight } from './CalibrationEvidence' +import { ActivityCard, type ActivityRow } from './CalibrationActivity' +import { WatchDeviationTrend } from './WatchDeviationTrend' +import { useEvaluationSetup } from './evaluation-setup' + +type Side = 'left' | 'right' +export type CalibrationTool = 'references' | 'backtests' +const NIL = '00000000-0000-0000-0000-000000000000' +const randomKey = () => Array.from(crypto.getRandomValues(new Uint8Array(24)), b => b.toString(16).padStart(2, '0')).join('') + +/** Wall clock for relative times, ticking at `intervalMs`. */ +function useNowMs(intervalMs: number) { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), intervalMs) + return () => clearInterval(id) + }, [intervalMs]) + return now +} + +function asConfig(value: unknown): BiometricConfig | null { + const v = value as BiometricConfig | null + return v && typeof v === 'object' && v.heart_rate && v.breathing_rate && v.hrv_index && v.sleep_stages ? v : null +} + +/** + * Biometric calibration (design 2a): the saved profile against the previous + * one, what the processor is running, the frozen dataset and backtest that + * judged the change, and who did what. Agents act through the same API, so + * their saves, runs and requests show up here as they happen. + */ +export function CalibrationPage({ side, onSideChange, onOpenTool }: { + side: Side + onSideChange: (side: Side) => void + onOpenTool: (tool: CalibrationTool) => void +}) { + const { t } = useLingui() + const { leftName, rightName } = useSideNames() + const timeFormat = useTimeFormat() + const h24 = timeFormat === '24h' + const utils = trpc.useUtils() + + const overview = trpc.biometricTuning.getProfileOverview.useQuery({ side }, { refetchInterval: 5000 }) + const binding = overview.data?.desired.binding + const savedId = binding?.desiredRevisionId ?? null + const previousId = binding?.previousRevisionId ?? null + const savedRevision = trpc.biometricTuning.getProfileRevision.useQuery({ id: savedId ?? NIL }, { enabled: Boolean(savedId), staleTime: Infinity }) + const previousRevision = trpc.biometricTuning.getProfileRevision.useQuery({ id: previousId ?? NIL }, { enabled: Boolean(previousId), staleTime: Infinity }) + const status = trpc.biometricTuning.getActivationStatus.useQuery({ side }, { refetchInterval: 5000 }) + const history = trpc.biometricTuning.listActivationHistory.useQuery({ side }, { refetchInterval: 15000 }) + const revisions = trpc.biometricTuning.listProfileRevisions.useQuery({ side }, { refetchInterval: 30000 }) + const datasets = trpc.biometricTuning.listDatasets.useQuery({ side }, { refetchInterval: 30000 }) + const imports = trpc.biometricTuning.listImports.useQuery({ side }, { refetchInterval: 30000 }) + const experiments = trpc.biometricTuning.listExperiments.useQuery({ side }, { refetchInterval: 30000 }) + const legacy = trpc.biometricTuning.countLegacyReferences.useQuery({ side }, { refetchInterval: 60000 }) + const settings = trpc.settings.getAll.useQuery({}, { staleTime: 60000 }) + const timezone = settings.data?.device.timezone ?? 'UTC' + + const backtests = trpc.biometricTuning.listBacktests.useQuery({ side }, { + refetchInterval: query => query.state.data?.items.some(job => job.state === 'queued' || job.state === 'running' || job.state === 'cancel_requested') ? 3000 : 15000, + }) + const jobs = useMemo(() => backtests.data?.items ?? [], [backtests.data]) + const savedHash = overview.data?.desired.hash ?? null + const group = useMemo(() => savedBacktest(jobs, savedId, savedHash), [jobs, savedId, savedHash]) + const run = runStatus(group?.jobs ?? []) + + const finished = (group?.jobs ?? []).filter(job => job.state === 'succeeded') + const summaries = trpc.useQueries(q => finished.map(job => q.biometricTuning.getBacktestSummary({ id: job.id }, { staleTime: 60000 }))) + + const datasetId = group?.datasetId ?? datasets.data?.items.find(d => d.available)?.id ?? null + const listed = datasets.data?.items.find(d => d.id === datasetId) + const fetched = trpc.biometricTuning.getDataset.useQuery({ id: datasetId ?? NIL }, { enabled: Boolean(datasetId) && !listed && Boolean(datasets.data) }) + const dataset = listed ?? fetched.data ?? null + + // The replay a save re-runs: the one behind the current evidence, else the + // newest set up on this dataset (waveform replay first, since only it + // recomputes the numbers these parameters change). + const onDataset = (experiments.data?.items ?? []).filter(e => e.datasetId === datasetId) + const experimentId = group?.experimentId ?? (onDataset.find(e => e.protocol.mode === 'signal_replay') ?? onDataset[0])?.id ?? jobs[0]?.experimentId ?? null + const readiness: BacktestReadiness = experimentId + ? 'ready' + : dataset ? 'no-experiment' : (imports.data?.items ?? []).some(i => i.available) || (legacy.data?.vitals ?? 0) > 0 ? 'no-dataset' : 'no-reference' + const setup = useEvaluationSetup({ side, timezone, savedRevisionId: savedId, generation: binding?.generation ?? 0 }) + + const saved = overview.data?.desired.resolved ?? null + const previous = previousId ? asConfig(previousRevision.data?.resolved) : defaultConfig + const previousHash = previousId ? previousRevision.data?.resolvedHash ?? null : null + + // Draft follows the saved profile; a new save (here or by an agent) resets it. + const savedKey = `${side}:${overview.data?.desired.hash ?? ''}` + const [draftState, setDraftState] = useState<{ key: string, draft: Draft } | null>(null) + const draft = saved ? (draftState?.key === savedKey ? draftState.draft : draftFrom(saved)) : null + const setDraft = (next: Draft) => setDraftState({ key: savedKey, draft: next }) + const dirty = saved && draft ? changedPaths(draft, saved).length > 0 : false + + // Per-night scores across the splits that finished. + const scores = useMemo(() => { + const rows: NightScore[] = [] + finished.forEach((job, i) => { + const parsed = numericReportViewSchema.safeParse(summaries[i]?.data?.report) + if (parsed.success) rows.push(...nightScores(parsed.data, job.split, baselineLabel(job, previousHash))) + }) + return rows + }, [finished, summaries, previousHash]) + + const [selected, setSelected] = useState(null) + const assignments = dataset?.manifest.splitProtocol.assignments ?? [] + const nights: DatasetNight[] = assignments.map((a) => { + const score = scores.find(s => s.cohortId === a.cohortId) + const sources = (imports.data?.items ?? []).filter(i => i.available) + .filter(i => i.summary.startMs !== null && i.summary.endMs !== null && i.summary.startMs < a.endMs && i.summary.endMs > a.startMs) + .map(i => i.summary.source) + return { cohortId: a.cohortId, split: a.split, startMs: a.startMs, endMs: a.endMs, score: score ?? null, + reference: score && !score.hasReference ? null : [...new Set(sources)].join(' + ') || (score ? t`reference` : null) } + }) + const selectedNight = nights.find(n => n.cohortId === selected) ?? nights.find(n => n.split === 'holdout' && n.score?.hasReference) ?? nights.find(n => n.score) ?? null + const traceJob = selectedNight ? finished.find(job => job.split === selectedNight.split) : undefined + const traceSummary = traceJob ? summaries[finished.indexOf(traceJob)]?.data : undefined + + /* ─── Mutations ─────────────────────────────────────────────────────── */ + const createRevision = trpc.biometricTuning.createProfileRevision.useMutation() + const activate = trpc.biometricTuning.activateProfile.useMutation() + const enqueue = trpc.biometricTuning.enqueueBacktest.useMutation() + const [saveError, setSaveError] = useState() + const pending = useRef<{ fingerprint: string, key: string } | undefined>(undefined) + const saving = createRevision.isPending || activate.isPending || enqueue.isPending + + function refresh() { + void utils.biometricTuning.getProfileOverview.invalidate({ side }) + void utils.biometricTuning.getActivationStatus.invalidate({ side }) + void utils.biometricTuning.listActivationHistory.invalidate({ side }) + void utils.biometricTuning.listProfileRevisions.invalidate({ side }) + void utils.biometricTuning.listBacktests.invalidate({ side }) + } + + async function saveAndRequest() { + if (!binding || !draft || !saved || !dirty) return + setSaveError(undefined) + const overrides = overridesFor((savedRevision.data?.overrides ?? []) as Array<{ path: string, value: unknown }>, draft, defaultConfig) + const fingerprint = JSON.stringify({ side, generation: binding.generation, savedId, overrides }) + if (pending.current?.fingerprint !== fingerprint) pending.current = { fingerprint, key: randomKey() } + const key = pending.current.key + try { + const revision = await createRevision.mutateAsync({ side, ...(savedId ? { parentRevisionId: savedId } : {}), overrides, + origin: 'manual', requester: 'web-calibration', idempotencyKey: key }) + const event = await activate.mutateAsync({ side, revisionId: revision.id, expectedGeneration: binding.generation, + expectedResolvedHash: revision.resolvedHash, boundary: 'next_session', requester: 'web-calibration', idempotencyKey: key, allowUnvalidated: true }) + // Re-run the evidence for the new profile on the same frozen dataset, + // every split, so the table always compares like with like. Each + // holdout exposure is counted by the job store. + const splits = new Set(assignments.map(a => a.split)) + if (experimentId) { + for (const split of (['tuning', 'validation', 'holdout'] as Split[]).filter(s => splits.size === 0 || splits.has(s))) { + await enqueue.mutateAsync({ experimentId, candidateRevisionId: revision.id, expectedBindingGeneration: event.generation, split, idempotencyKey: `${key}:${split}` }) + } + } + pending.current = undefined + } + catch (cause) { + setSaveError(cause instanceof Error ? cause.message : t`Save failed.`) + } + finally { + refresh() + } + } + + const [restore, setRestore] = useState(null) + + /* ─── Activity ──────────────────────────────────────────────────────── */ + const nowMs = useNowMs(15_000) + const activity = useMemo(() => { + const rows: ActivityRow[] = [] + const byId = new Map((revisions.data?.items ?? []).map(r => [r.id, r])) + const resolvedOf = (id: string | null) => id ? asConfig(byId.get(id)?.resolved) : defaultConfig + for (const event of history.data?.items ?? []) { + const requester = actorOf(event.requester) + if (event.action === 'activate') { + const revision = event.nextRevisionId ? byId.get(event.nextRevisionId) : undefined + const before = resolvedOf(event.previousRevisionId), after = resolvedOf(event.nextRevisionId) + const diffs = before && after ? paramDiffs(before, after) : [] + rows.push({ id: `${event.id}:saved`, atMs: revision?.createdAtMs ?? event.createdAtMs, + actor: revision ? actorOf(revision.requester, revision.origin) : requester, what: t`Saved the profile`, + detail: diffs.length ? diffs.join(' · ') : null }) + rows.push({ id: `${event.id}:requested`, atMs: event.createdAtMs, actor: requester, what: t`Requested activation · generation ${event.generation}`, detail: null }) + } + else if (event.action === 'rollback') rows.push({ id: event.id, atMs: event.createdAtMs, actor: requester, what: t`Restored the previous profile and requested it`, detail: null }) + else if (event.action === 'reset') rows.push({ id: event.id, atMs: event.createdAtMs, actor: requester, what: t`Restored system defaults and requested them`, detail: null }) + else rows.push({ id: event.id, atMs: event.createdAtMs, actor: { kind: 'pod', name: 'pod' }, what: event.action === 'migration' ? t`Migrated sleep-stage settings into the profile` : t`Fell back to a compatible profile`, detail: event.reason }) + if (event.state === 'applied') rows.push({ id: `${event.id}:ack`, atMs: event.updatedAtMs, actor: { kind: 'pod', name: 'pod' }, what: t`Processor acknowledged generation ${event.generation}`, detail: null }) + if (event.state === 'failed') rows.push({ id: `${event.id}:failed`, atMs: event.updatedAtMs, actor: { kind: 'pod', name: 'pod' }, what: t`Processor rejected generation ${event.generation}`, detail: event.reason }) + } + const splitLabel: Record = { tuning: t`tuning`, validation: t`validation`, holdout: t`holdout` } + for (const job of jobs.slice(0, 20)) { + if (job.state !== 'succeeded' && job.state !== 'failed') continue + const index = finished.findIndex(f => f.id === job.id) + const parsed = index >= 0 ? numericReportViewSchema.safeParse(summaries[index]?.data?.report) : null + let detail: string | null = null + if (parsed?.success) { + const rows = nightScores(parsed.data, job.split, baselineLabel(job, previousHash)).filter(n => n.previous.heart_rate.mae !== null && n.saved.heart_rate.mae !== null) + if (rows.length) { + const avg = (pick: (n: NightScore) => number) => (rows.reduce((s, n) => s + pick(n), 0) / rows.length).toFixed(1) + detail = t`${splitLabel[job.split]} HR error ${avg(n => n.previous.heart_rate.mae as number)} → ${avg(n => n.saved.heart_rate.mae as number)} bpm · ${shortDuration(job.updatedAtMs - job.createdAtMs)}` + } + } + rows.push({ id: job.id, atMs: job.updatedAtMs, actor: { kind: 'pod', name: 'pod' }, + what: job.state === 'succeeded' ? t`Finished a backtest on ${splitLabel[job.split]} nights` : t`Backtest on ${splitLabel[job.split]} nights failed`, + detail: job.state === 'failed' ? job.error : detail }) + } + for (const d of datasets.data?.items ?? []) { + rows.push({ id: d.id, atMs: d.createdAtMs, actor: { kind: 'pod', name: 'pod' }, what: t`Froze a dataset of ${d.manifest.splitProtocol.assignments.length} nights`, detail: null }) + } + for (const i of imports.data?.items ?? []) { + rows.push({ id: i.id, atMs: i.createdAtMs, actor: { kind: 'pod', name: 'pod' }, what: t`Stored a ${i.summary.source} reference import`, detail: t`${i.summary.accepted} records` }) + } + return rows.sort((a, b) => b.atMs - a.atMs).map(row => ({ ...row, when: activityTime(row.atMs, nowMs, timezone, h24) })) + }, [history.data, revisions.data, jobs, datasets.data, imports.data, summaries, finished, previousHash, nowMs, timezone, h24, t]) + + /* ─── Render ────────────────────────────────────────────────────────── */ + const error = overview.error ?? status.error + if (error) return {error.message} + if (!overview.data || !binding || !saved || !draft || (savedId && !savedRevision.data) || (previousId && !previousRevision.data)) { + return ( +
+ +
+ + +
+
+ ) + } + + const event = status.data?.event ?? null + const savedActor = savedRevision.data ? actorOf(savedRevision.data.requester, savedRevision.data.origin) : null + const strip = lifecycle({ + nowMs, + saved: savedRevision.data && savedActor ? { actor: savedActor, atMs: savedRevision.data.createdAtMs } : null, + bindingUpdatedAtMs: binding.updatedAtMs, + generation: binding.generation, + request: event && status.data?.isCurrent + ? { observedState: status.data.observedState, createdAtMs: event.createdAtMs, updatedAtMs: event.updatedAtMs, previousIsDefaults: event.previousRevisionId === null } + : null, + runtimeReported: overview.data.runtimeStatus === 'receipts_available', + }) + + return ( +
+
+ + + 0} onSelect={setRestore} /> +
+ +
+
+ + onOpenTool('references')} + onFreeze={setup.run} + setup={setup.progress} + timezone={timezone} + /> + + +
+ +
+ +
+ {/* Clean, the primary reads as the page's resting answer ("keep it"), not a dead control. */} + + +
+

+ {dirty ? t`Overwrites the saved profile and requests it; the current one becomes previous.` : t`Nothing changes until you move a control or restore.`} +

+ {saveError ? {saveError} : null} + +
+
+ + {restore + ? ( + setRestore(null)} + onDone={() => { + setRestore(null) + refresh() + }} + /> + ) + : null} +
+ ) +} + +function SensorLine({ sensors, hash }: { sensors: Array<{ id: number, sensorType: string, status: string, freshness: 'expired' | 'current' | 'unknown' }>, hash: string }) { + const { t } = useLingui() + const [full, setFull] = useState(false) + const current = sensors.filter(s => s.status === 'completed') + // One line, as the mock has it: sensors grouped by freshness ("capacitance, piezo current"). + const freshness = { current: t`current`, expired: t`expired`, unknown: t`freshness unknown` } + const value = current.length + ? (['current', 'expired', 'unknown'] as const).flatMap((f) => { + const types = current.filter(s => s.freshness === f).map(s => s.sensorType) + return types.length ? [`${types.join(', ')} ${freshness[f]}`] : [] + }).join(' · ') + : t`not reported` + return ( +
+ + {t`Measured sensor calibration for this side:`} + {' '} + {value} + {t`. Separate from the profile.`} + + + {t`config id`} + {' '} + {full ? hash : `${hash.slice(0, 4)}…${hash.slice(-2)}`} + {' · '} + + +
+ ) +} diff --git a/src/components/biometrics/Calibration/CalibrationProfileCard.tsx b/src/components/biometrics/Calibration/CalibrationProfileCard.tsx new file mode 100644 index 000000000..0b9cc5855 --- /dev/null +++ b/src/components/biometrics/Calibration/CalibrationProfileCard.tsx @@ -0,0 +1,277 @@ +'use client' + +import type { ReactNode } from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { useLingui } from '@lingui/react/macro' +import { defaultConfig, type BiometricConfig } from '@/src/biometrics/algorithms/registry-core' +import { STAGE_METRIC_LABELS } from '@/src/lib/sleep-stage-rules' +import { formatMetricValue } from '@/src/components/Settings/sleepStageEditing' +import { Card, CardHeader, Slider } from '@/src/components/ds' +import { cn } from '@/lib/utils' +import { aggregate, coverage, PARAMS, readParam, roundParam, stageOverrides, type Actor, type Draft, type NightScore, type ParamPath } from './calibration-model' + +export const ACTOR_BADGE: Record = { + agent: { label: 'AGENT', className: 'bg-actor-agent-bg text-actor-agent' }, + you: { label: 'YOU', className: 'bg-actor-you-bg text-actor-you' }, + pod: { label: 'POD', className: 'bg-actor-pod-bg text-actor-pod' }, +} + +export function ActorBadge({ kind }: { kind: Actor['kind'] }) { + const badge = ACTOR_BADGE[kind] + return {badge.label} +} + +function Eyebrow({ children }: { children: ReactNode }) { + return
{children}
+} + +/** Shared 0.25×–2× axis for both breathing ratios; 1× is the median. */ +const RATIO_AXIS = { min: PARAMS['breathing_rate.intervalLowerRatio'].min, max: PARAMS['breathing_rate.intervalUpperRatio'].max } +const THUMB = 18 +const axisPct = (v: number) => (v - RATIO_AXIS.min) / (RATIO_AXIS.max - RATIO_AXIS.min) +/** Native thumb centre for a fraction of the track, so the visual thumb sits on the hit area. */ +const thumbLeft = (f: number) => `calc(${f} * (100% - ${THUMB}px) + ${THUMB / 2}px)` + +/** + * Both breathing cut-offs on one axis around the median: the left handle is + * "shorter than", the right "longer than", and the band between them is the + * intervals that are kept. Each handle stays inside its own manifest range. + */ +function RatioRange({ low, high, onLow, onHigh, lowLabel, highLabel, disabled }: { + low: number + high: number + onLow: (v: number) => void + onHigh: (v: number) => void + lowLabel: string + highLabel: string + disabled?: boolean +}) { + const lo = PARAMS['breathing_rate.intervalLowerRatio'], hi = PARAMS['breathing_rate.intervalUpperRatio'] + const clamp = (v: number, spec: { min: number, max: number }) => Math.min(spec.max, Math.max(spec.min, v)) + const input = 'pointer-events-none absolute inset-0 m-0 !min-h-0 w-full appearance-none bg-transparent opacity-0 ' + + '[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-[18px] [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none ' + + '[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-[18px] [&::-moz-range-thumb]:cursor-pointer' + const a = axisPct(low), b = axisPct(high), median = axisPct(1) + return ( +
+
+
+
+
+ {[a, b].map((f, i) => ( +
+ ))} + onLow(clamp(Number(e.target.value), lo))} + className={input} + /> + onHigh(clamp(Number(e.target.value), hi))} + className={input} + /> +
+
+ {`${RATIO_AXIS.min}×`} + 1× + {`${RATIO_AXIS.max}×`} +
+
+ ) +} + +/** + * The saved profile, value by value against the previous one. Values that + * differ from previous are white; moving a slider edits a draft of the saved + * profile that only takes effect through Save and request. + */ +export function ProfileCard({ saved, previous, previousIsDefaults, draft, onDraft, disabled, actor, savedAgo, scores }: { + saved: BiometricConfig + previous: BiometricConfig + previousIsDefaults: boolean + draft: Draft + onDraft: (draft: Draft) => void + disabled?: boolean + actor: Actor | null + savedAgo: string | null + scores: NightScore[] +}) { + const { t } = useLingui() + const pathname = usePathname() + const lang = pathname?.split('/')[1] || 'en' + const set = (path: ParamPath, value: number) => onDraft({ ...draft, [path]: roundParam(path, value) }) + + // What the saved profile did on the most trusted split that ran. + const split = (['holdout', 'validation', 'tuning'] as const).find(s => aggregate(scores, 'heart_rate', s).nights > 0) + const effect = (paths: ParamPath[], metric: 'heart_rate' | 'breathing_rate' | 'window_ibi_variability_v1', unit: string) => { + if (paths.every(p => draft[p] === readParam(previous, p))) return t`unchanged` + if (paths.some(p => draft[p] !== readParam(saved, p))) return t`tested on save` + if (!split) return t`not tested` + const a = aggregate(scores, metric, split) + if (a.previous === null || a.saved === null) return t`not tested` + const delta = a.saved - a.previous + const sign = delta > 0 ? '+' : delta < 0 ? '−' : '±' + const covered = metric === 'heart_rate' ? coverage(scores.filter(s => s.split === split)).saved : null + return `${split} ${sign}${Math.abs(delta).toFixed(1)} ${unit}${covered === null ? '' : ` · ${t`covered`} ${Math.round(covered * 100)}%`}` + } + const tone = (path: ParamPath) => draft[path] === readParam(previous, path) ? 'text-fg-2' : 'text-fg' + const fmt = (path: ParamPath) => String(draft[path]) + const sub = (path: ParamPath) => `${PARAMS[path].key} · ${t`previous`}: ${readParam(previous, path)} · ${t`range`} ${PARAMS[path].min}–${PARAMS[path].max}` + + // Overrides are counted against the shipped rules the Sleep stages page starts from. + const stages = stageOverrides(defaultConfig.sleep_stages, saved.sleep_stages) + const count = stages.overrides.length + (stages.fallbackChanged ? 1 : 0) + + const row = (label: ReactNode, sublines: ReactNode, control: ReactNode, effectText: string, divider = false) => ( +
+
+ {label} +
{sublines}
+
+
{control}
+
{effectText}
+
+ ) + const value = (path: ParamPath, suffix = '') => {`${fmt(path)}${suffix}`} + const slider = (path: ParamPath, label: string) => ( + set(path, v)} /> + ) + + const tol = 'heart_rate.trackingToleranceBpm', hist = 'heart_rate.trackingHistoryLength', peak = 'heart_rate.minimumPeakScore' + const low = 'breathing_rate.intervalLowerRatio', high = 'breathing_rate.intervalUpperRatio', windows = 'hrv_index.minimumWindows' + + return ( + + + + {actor.kind === 'agent' ? `${actor.name} · ${savedAgo}` : savedAgo} + + ) + : {t`System defaults`}} + /> + + {t`HEART RATE`} + {row( + <> + {t`Accept an estimate when it is within`} + {' '} + {value(tol)} + {' '} + {t`bpm of the tracker`} + , + sub(tol), slider(tol, t`Tracking tolerance (bpm)`), effect([tol], 'heart_rate', 'bpm'), + )} + {row( + <> + {t`Keep the last`} + {' '} + {value(hist)} + {' '} + {t`accepted estimates in the tracker`} + , + sub(hist), slider(hist, t`Tracking history length`), effect([hist], 'heart_rate', 'bpm'), true, + )} + {row( + <> + {t`Report no heart rate when the beat signal scores below`} + {' '} + {value(peak)} + , + sub(peak), slider(peak, t`Minimum beat signal score`), effect([peak], 'heart_rate', 'bpm'), true, + )} + + {t`BREATHING`} + {row( + <> + {t`Reject intervals shorter than`} + {' '} + {value(low, '×')} + {' '} + {t`or longer than`} + {' '} + {value(high, '×')} + {' '} + {t`the median`} + , + `${PARAMS[low].key} ${PARAMS[low].min}–${PARAMS[low].max} · ${PARAMS[high].key} ${PARAMS[high].min}–${PARAMS[high].max} · ${t`previous`}: ${readParam(previous, low)} / ${readParam(previous, high)}`, + set(low, v)} + onHigh={v => set(high, v)} + />, + effect([low, high], 'breathing_rate', 'br/m'), + )} + + {t`HRV INDEX`} + {row( + <> + {t`Report the index only after`} + {' '} + {value(windows)} + {' '} + {t`accepted 10-second IBI windows`} + , + `${sub(windows)} · ${t`window IBI variability, not clinical RMSSD`}`, + slider(windows, t`Minimum IBI windows`), effect([windows], 'window_ibi_variability_v1', 'ms'), + )} + + {t`SLEEP STAGES`} +
+
+ + {t`Inherits the ${stages.ruleCount} rules and fallback from`} + {' '} + {t`Sleep stages`} + {count === 0 ? t`, with no overrides` : count === 1 ? t`, with 1 override` : t`, with ${count} overrides`} + + {stages.restructured && {t`The rule list itself differs from the shipped rules.`}} + {stages.overrides.map(o => ( + + {t`Rule ${o.rule}`} + {' · '} + {`${o.stage[0].toUpperCase()}${o.stage.slice(1)} ${t`when`} ${STAGE_METRIC_LABELS[o.metric].toLowerCase()} ${o.op} `} + {formatMetricValue(o.metric, o.before)} + {' '} + {formatMetricValue(o.metric, o.after)} + + ))} + {stages.fallbackChanged && ( + + {t`Fallback`} + {' '} + {defaultConfig.sleep_stages.engine === 'rules' ? defaultConfig.sleep_stages.ruleset.fallback : ''} + {' '} + {saved.sleep_stages.engine === 'rules' ? saved.sleep_stages.ruleset.fallback : ''} + + )} +
+ {t`Edit overrides`} +
+
+ ) +} diff --git a/src/components/biometrics/Calibration/ProfileRecovery.tsx b/src/components/biometrics/Calibration/ProfileRecovery.tsx deleted file mode 100644 index 611b8db35..000000000 --- a/src/components/biometrics/Calibration/ProfileRecovery.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Trans } from '@lingui/react/macro' -import { trpc } from '@/src/utils/trpc' -import { Button, Card, CardHeader, InlineError } from '@/src/components/ds' -import { ManualProfileActivation } from './ManualProfileActivation' -import { ProfileComparison } from './ProfileComparison' - -export function ProfileRecovery({ side }: { side: 'left' | 'right' }) { - const overview = trpc.biometricTuning.getProfileOverview.useQuery({ side }, { refetchInterval: 5000 }) - const [selection, setSelection] = useState<{ action: 'reset' | 'rollback', revisionId: string | null, generation: number, fallback?: { hash: string, warning: string, revisionId: string | null } }>() - const desired = overview.data?.desired - const binding = desired?.binding - return ( - - Restore a profile} subtitle={Review changes before requesting system defaults or the exact previous profile.} /> - {overview.error ? {overview.error.message} : null} -
- - -
- {selection?.fallback - ? ( -
- {selection.fallback.warning} -

The pinned profile is incompatible. Its revision will be preserved, but a valid before-and-after comparison is unavailable.

-
-
Preserved incompatible revision
-
{selection.fallback.revisionId ?? '—'}
-
Default recovery configuration hash
-
{selection.fallback.hash}
-
- {selection.generation !== binding?.generation ?

The saved profile changed. Select a recovery action again to make a new request.

: null} - -
- ) - : selection ? : null} -
- ) -} diff --git a/src/components/biometrics/Calibration/WatchDeviationTrend.tsx b/src/components/biometrics/Calibration/WatchDeviationTrend.tsx new file mode 100644 index 000000000..c66afc9c5 --- /dev/null +++ b/src/components/biometrics/Calibration/WatchDeviationTrend.tsx @@ -0,0 +1,312 @@ +'use client' + +import { useState } from 'react' +import type { inferRouterOutputs } from '@trpc/server' +import { useLingui } from '@lingui/react/macro' +import type { AppRouter } from '@/src/server/routers/app' +import { trpc } from '@/src/utils/trpc' +import { Card, CardHeader, InlineError, LineChart, SegmentedControl, type ChartGuide, type ChartMarker } from '@/src/components/ds' +import { useSideNames } from '@/src/hooks/useSideNames' +import { WatchUpload } from '../WatchUpload' + +type Report = inferRouterOutputs['biometrics']['compareToWatch'] +type Night = Report['nights'][number] +type Metric = 'heartRate' | 'breathingRate' | 'hrv' + +const DAY_MS = 24 * 60 * 60 * 1000 +/** One query covers the widest window; the selector narrows it on the client. */ +const FETCH_DAYS = 31 +const WINDOWS = ['7', '14', '31'] as const +type WindowDays = typeof WINDOWS[number] +const MAE_COLOR = 'var(--chart-1)' +const BIAS_COLOR = 'var(--chart-2)' +const KAPPA_COLOR = 'var(--chart-3)' + +/** Any Watch pairing at all: a scored HR, breathing or HRV sample, or scored stage epochs. */ +export const hasWatchPairing = (n: Night) => n.heartRate.n + n.breathingRate.n + n.hrv.n > 0 || n.stages !== null + +const nan = (v: number | null | undefined) => v ?? Number.NaN + +/** 1, 2 or 5 × 10^k, the smallest at or above `raw`. */ +function niceStep(raw: number) { + const pow = 10 ** Math.floor(Math.log10(raw)) + const f = raw / pow + return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * pow +} + +/** A domain that always includes zero, snapped to about three steps, with a guide per step and zero drawn strong. */ +export function deviationScale(values: number[]) { + const finite = values.filter(Number.isFinite) + const lo = Math.min(0, ...finite) + const hi = Math.max(0, ...finite) + const step = niceStep((hi - lo) / 3 || 1) + const min = Math.floor(lo / step) * step + const max = Math.max(Math.ceil(hi / step) * step, min + step) + const guides: ChartGuide[] = [] + for (let v = min; v <= max + step / 2; v += step) { + const value = Number(v.toFixed(6)) + guides.push({ value, label: String(value), strong: value === 0 }) + } + return { min, max, guides } +} + +/** + * Profile changes drawn between the nights they fall between, as a fractional + * index; changes before the first or after the last shown night have no + * before/after to separate, so they are left out. Changes between the same + * two nights share one hairline. + */ +export function changeMarkers(nights: Array<{ enteredBedAt: Date }>, changes: Report['configChanges']): ChartMarker[] { + const byAt = new Map() + for (const c of changes) { + const next = nights.findIndex(n => n.enteredBedAt.getTime() >= c.atMs) + if (next <= 0) continue + byAt.set(next - 0.5, [...(byAt.get(next - 0.5) ?? []), c.generation]) + } + return [...byAt].map(([at, gens]) => ({ at, label: `gen ${gens.join(', ')}` })) +} + +function dayFormatter(timeZone: string) { + try { + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone }) + } + catch { + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) + } +} + +const signed = (v: number, digits: number) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(v).toFixed(digits)}` + +/** + * Night-by-night deviation of the pod from the Apple Watch for this side, one + * small chart per metric, with the side's profile changes marked, so a change + * can be judged by whether the nights after it sit closer to zero. Renders + * only a one-line upload hint until a night in the last 31 days pairs with + * Watch data. + */ +export function WatchDeviationTrend({ side, timezone }: { side: 'left' | 'right', timezone: string }) { + const { t } = useLingui() + const { sideName } = useSideNames() + const [endMs] = useState(() => Date.now()) + const [days, setDays] = useState('14') + const report = trpc.biometrics.compareToWatch.useQuery( + { side, startDate: new Date(endMs - FETCH_DAYS * DAY_MS), endDate: new Date(endMs) }, + { staleTime: 5 * 60_000 }, + ) + + if (report.error) return {report.error.message} + if (!report.data) return null + const all = report.data.nights.map(n => ({ ...n, enteredBedAt: new Date(n.enteredBedAt) })) + if (!all.some(hasWatchPairing)) { + return ( +
+ {t`Upload Apple Watch data to see how far this side's readings sit from the Watch, night by night.`} + +
+ ) + } + + const nights = all.filter(n => n.enteredBedAt.getTime() >= endMs - Number(days) * DAY_MS) + const day = dayFormatter(timezone) + const dates = nights.map(n => day.format(n.enteredBedAt)) + const markers = changeMarkers(nights, report.data.configChanges) + const units: Record = { heartRate: t`bpm`, breathingRate: t`br/min`, hrv: t`ms` } + const titles: Record = { heartRate: t`Heart rate`, breathingRate: t`Breathing rate`, hrv: t`HRV` } + const any = nights.some(hasWatchPairing) + + return ( + + ({ value: w, label: t`${w} d` }))} + /> + )} + /> +
+ + + {t`MAE`} + + + + {t`bias (+ = pod reads high)`} + + + + {t`stage kappa`} + + {markers.length > 0 && {t`┆ gen N profile change`}} +
+ {!any + ?

{t`No night in the last ${days} days pairs with Watch data. Pick a longer window.`}

+ : ( +
+
+ {(['heartRate', 'breathingRate', 'hrv'] as const).map(metric => ( + nan(n[metric].mae))} + bias={nights.map(n => nan(n[metric].bias))} + dates={dates} + markers={markers} + note={metric === 'hrv' ? report.data.hrvNote : undefined} + /> + ))} + nan(n.stages?.kappa))} dates={dates} markers={markers} /> +
+
+ )} + {any && } +
+ ) +} + +function Ticks({ dates }: { dates: string[] }) { + return ( +
+ {dates[0]} + {dates.length > 1 && {dates[dates.length - 1]}} +
+ ) +} + +function lastFinite(values: number[]) { + for (let i = values.length - 1; i >= 0; i--) if (Number.isFinite(values[i])) return values[i] + return null +} + +function DeviationPanel({ title, unit, mae, bias, dates, markers, note }: { + title: string + unit: string + mae: number[] + bias: number[] + dates: string[] + markers: ChartMarker[] + note?: string +}) { + const { t } = useLingui() + const scale = deviationScale([...mae, ...bias]) + const lastMae = lastFinite(mae) + const lastBias = lastFinite(bias) + const latest = lastMae === null || lastBias === null ? null : t`latest MAE ${lastMae.toFixed(1)}, bias ${signed(lastBias, 1)} ${unit}` + const hasData = mae.some(Number.isFinite) || bias.some(Number.isFinite) + return ( +
+
+ + {title} + {` · ${unit}`} + + {latest && {latest}} +
+ {hasData + ? ( +
+ dates[i] ?? ''} + format={(v, s) => s === 0 ? `MAE ${v.toFixed(1)}` : `bias ${signed(v, 1)}`} + /> + +
+ ) + :

{t`No Watch pairs in these nights`}

} + {note &&

{note}

} +
+ ) +} + +function KappaPanel({ kappa, dates, markers }: { kappa: number[], dates: string[], markers: ChartMarker[] }) { + const { t } = useLingui() + const last = lastFinite(kappa) + const min = Math.min(0, ...kappa.filter(Number.isFinite)) + const guides: ChartGuide[] = [ + ...(min < 0 ? [{ value: -0.5, label: '-0.5' }] : []), + { value: 0, label: '0', strong: true }, + { value: 0.5, label: '0.5' }, + { value: 1, label: '1' }, + ] + return ( +
+
+ + {t`Sleep stages`} + {` · ${t`kappa`}`} + + {last !== null && {t`latest ${last.toFixed(2)}`}} +
+ {kappa.some(Number.isFinite) + ? ( +
+ dates[i] ?? ''} + format={v => `kappa ${v.toFixed(2)}`} + /> + +
+ ) + :

{t`No Watch stages in these nights`}

} +

{t`Every night is re-scored with the current stage rules; higher is better.`}

+
+ ) +} + +function DeviationTable({ nights, dates, units }: { nights: Array, dates: string[], units: Record }) { + const { t } = useLingui() + const cell = (v: number | null | undefined, digits: number, sign = false) => v == null ? '—' : sign ? signed(v, digits) : v.toFixed(digits) + return ( +
+ {t`Nights as a table`} +
+
+ + + + + + + + + + + + {nights.map((n, i) => ( + + + {(['heartRate', 'breathingRate', 'hrv'] as const).map(m => ( + + ))} + + + ))} + +
{t`Pod minus Watch per night: MAE and bias per metric, and stage kappa`}
{t`Night`}{t`HR MAE / bias (${units.heartRate})`}{t`Breathing MAE / bias (${units.breathingRate})`}{t`HRV MAE / bias (${units.hrv})`}{t`Kappa`}
{dates[i]}{`${cell(n[m].mae, 1)} / ${cell(n[m].bias, 1, true)}`}{cell(n.stages?.kappa, 2)}
+ + + ) +} diff --git a/src/components/biometrics/Calibration/calibration-model.ts b/src/components/biometrics/Calibration/calibration-model.ts new file mode 100644 index 000000000..4749eb381 --- /dev/null +++ b/src/components/biometrics/Calibration/calibration-model.ts @@ -0,0 +1,402 @@ +import type { BiometricConfig } from '@/src/biometrics/algorithms/registry' +import type { StageClassifier, StageMetric, StageOperator } from '@/src/lib/sleep-stage-rules' +import type { NumericReportView } from './report-view' + +/** + * View model for the single-page calibration screen: one saved profile, one + * previous, the processor's acknowledgement, and the backtest that compared + * them. Pure functions only; the page component owns queries and wording. + */ + +export type ParamPath + = | 'heart_rate.trackingToleranceBpm' + | 'heart_rate.trackingHistoryLength' + | 'heart_rate.minimumPeakScore' + | 'breathing_rate.intervalLowerRatio' + | 'breathing_rate.intervalUpperRatio' + | 'hrv_index.minimumWindows' + +export interface ParamSpec { path: ParamPath, key: string, min: number, max: number, step: number, decimals: number } + +/** Bounds mirror modules/common/biometrics/manifest.v1.json. */ +export const PARAMS: Record = { + 'heart_rate.trackingToleranceBpm': { path: 'heart_rate.trackingToleranceBpm', key: 'trackingToleranceBpm', min: 5, max: 30, step: 1, decimals: 0 }, + 'heart_rate.trackingHistoryLength': { path: 'heart_rate.trackingHistoryLength', key: 'trackingHistoryLength', min: 1, max: 10, step: 1, decimals: 0 }, + 'heart_rate.minimumPeakScore': { path: 'heart_rate.minimumPeakScore', key: 'minimumPeakScore', min: 0.1, max: 0.8, step: 0.05, decimals: 2 }, + 'breathing_rate.intervalLowerRatio': { path: 'breathing_rate.intervalLowerRatio', key: 'intervalLowerRatio', min: 0.25, max: 0.9, step: 0.05, decimals: 2 }, + 'breathing_rate.intervalUpperRatio': { path: 'breathing_rate.intervalUpperRatio', key: 'intervalUpperRatio', min: 1.1, max: 2, step: 0.05, decimals: 2 }, + 'hrv_index.minimumWindows': { path: 'hrv_index.minimumWindows', key: 'minimumWindows', min: 10, max: 30, step: 1, decimals: 0 }, +} +export const PARAM_PATHS = Object.keys(PARAMS) as ParamPath[] + +export type Draft = Record + +export function readParam(config: BiometricConfig, path: ParamPath): number { + const [group, key] = path.split('.') as [keyof BiometricConfig, string] + return (config[group] as unknown as Record)[key] +} + +export function draftFrom(config: BiometricConfig): Draft { + return Object.fromEntries(PARAM_PATHS.map(path => [path, readParam(config, path)])) as Draft +} + +/** Trim float noise from slider steps (0.1 + 0.05 → 0.15000000000000002). */ +export function roundParam(path: ParamPath, value: number): number { + const factor = 10 ** PARAMS[path].decimals + return Math.round(value * factor) / factor +} + +export function changedPaths(draft: Draft, saved: BiometricConfig): ParamPath[] { + return PARAM_PATHS.filter(path => draft[path] !== readParam(saved, path)) +} + +/** + * The saved overrides with the edited numeric paths replaced. A value equal to + * the shipped default drops its override so it inherits again. + */ +export function overridesFor(saved: Array<{ path: string, value: unknown }>, draft: Draft, defaults: BiometricConfig) { + const edited = new Set(PARAM_PATHS) + const kept = saved.filter(row => !edited.has(row.path)) + const numeric = PARAM_PATHS + .filter(path => draft[path] !== readParam(defaults, path)) + .map(path => ({ path, value: draft[path] })) + return [...kept, ...numeric] +} + +/* ─── Actors ───────────────────────────────────────────────────────────── */ + +export type ActorKind = 'agent' | 'you' | 'pod' +export interface Actor { kind: ActorKind, name: string } + +/** Requesters the web app writes as; anything else came over MCP or the API. */ +const WEB_REQUESTERS = new Set(['web-calibration', 'legacy-stage-settings', 'web']) + +export function actorOf(requester: string, origin?: 'manual' | 'migration' | 'agent' | 'system'): Actor { + if (origin === 'migration' || origin === 'system' || requester === 'system' || requester === 'migration') return { kind: 'pod', name: 'pod' } + if (origin === 'manual' || WEB_REQUESTERS.has(requester)) return { kind: 'you', name: 'you' } + return { kind: 'agent', name: requester } +} + +/* ─── Time ─────────────────────────────────────────────────────────────── */ + +/** "38 s", "14 min", "3 h", "2 d" */ +export function shortDuration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)) + if (s < 60) return `${s} s` + const m = Math.round(s / 60) + if (m < 60) return `${m} min` + const h = Math.round(m / 60) + if (h < 48) return `${h} h` + return `${Math.round(h / 24)} d` +} + +export function relativeTime(atMs: number, nowMs: number): string { + const ms = nowMs - atMs + return ms < 45_000 ? 'just now' : `${shortDuration(ms)} ago` +} + +/* ─── Lifecycle strip ──────────────────────────────────────────────────── */ + +export type DotTone = 'ok' | 'warn' | 'danger' | 'muted' +export interface Segment { tone: DotTone, value: string } + +type ObservedState = 'unreported' | 'pending_delivery' | 'pending_boundary' | 'applied' | 'failed' + +export interface LifecycleInput { + nowMs: number + /** Desired revision; null when the saved profile is system defaults. */ + saved: { actor: Actor, atMs: number } | null + bindingUpdatedAtMs: number + generation: number + /** Activation for the current generation. */ + request: { observedState: ObservedState, createdAtMs: number, updatedAtMs: number, previousIsDefaults: boolean } | null + runtimeReported: boolean +} + +export function lifecycle(input: LifecycleInput): { saved: Segment, requested: Segment, processor: Segment } { + const { nowMs, request } = input + const saved: Segment = input.saved + ? { tone: 'ok', value: `by ${input.saved.actor.name} · ${relativeTime(input.saved.atMs, nowMs)}` } + : input.generation > 0 + ? { tone: 'ok', value: `system defaults · ${relativeTime(input.bindingUpdatedAtMs, nowMs)}` } + : { tone: 'ok', value: 'system defaults' } + + let requested: Segment + let processor: Segment + if (!request || request.observedState === 'unreported') { + requested = { tone: 'muted', value: 'none' } + processor = input.runtimeReported + ? { tone: 'ok', value: input.saved ? 'running saved' : 'running system defaults' } + : { tone: 'muted', value: 'not reported' } + } + else if (request.observedState === 'applied') { + requested = { tone: 'ok', value: `acknowledged ${relativeTime(request.updatedAtMs, nowMs)}` } + processor = { tone: 'ok', value: 'running saved' } + } + else { + const failed = request.observedState === 'failed' + requested = failed + ? { tone: 'danger', value: 'failed' } + : { tone: 'warn', value: `waiting ${shortDuration(nowMs - request.createdAtMs)}` } + processor = { tone: failed ? 'danger' : 'ok', value: request.previousIsDefaults ? 'running system defaults' : 'running previous' } + } + return { saved, requested, processor } +} + +/* ─── Sleep-stage overrides ────────────────────────────────────────────── */ + +export interface StageOverride { rule: number, stage: string, metric: StageMetric, op: StageOperator, before: number, after: number } + +/** + * Condition values that differ from the shipped rules, rule by rule. When the + * rule list itself was reshaped, values can't be paired; that is reported as + * `restructured` instead. + */ +export function stageOverrides(defaults: StageClassifier, saved: StageClassifier) { + if (defaults.engine !== 'rules' || saved.engine !== 'rules') return { ruleCount: 0, overrides: [] as StageOverride[], restructured: defaults.engine !== saved.engine, fallbackChanged: false } + const a = defaults.ruleset, b = saved.ruleset + const overrides: StageOverride[] = [] + let restructured = a.rules.length !== b.rules.length + if (!restructured) { + b.rules.forEach((rule, i) => { + const base = a.rules[i] + if (base.stage !== rule.stage || base.when.length !== rule.when.length) { + restructured = true + return + } + rule.when.forEach((condition, j) => { + const was = base.when[j] + if (was.metric !== condition.metric || was.op !== condition.op) restructured = true + else if (was.value !== condition.value) overrides.push({ rule: i + 1, stage: rule.stage, metric: condition.metric, op: condition.op, before: was.value, after: condition.value }) + }) + }) + } + return { ruleCount: b.rules.length, overrides, restructured, fallbackChanged: a.fallback !== b.fallback } +} + +/* ─── Backtests ────────────────────────────────────────────────────────── */ + +export type Split = 'tuning' | 'validation' | 'holdout' +export type ConfigLabel = 'default' | 'current' | 'candidate' +export interface JobLike { + id: string + experimentId: string + datasetId: string + split: Split + state: 'queued' | 'running' | 'cancel_requested' | 'cancelled' | 'succeeded' | 'failed' | 'interrupted' + createdAtMs: number + updatedAtMs: number + configurations: Array<{ label: string, revisionId: string | null, hash: string }> + evaluationScope: 'original_readings_comparison' | 'stage_rules_on_original_readings' | 'retained_waveform_replay' +} + +/** + * The latest run of the saved profile, as one job per split on the dataset + * that run used. With no saved revision (system defaults), a run of any + * candidate that resolves to the saved configuration stands in for it. + */ +export function savedBacktest(jobs: J[], savedRevisionId: string | null, savedHash?: string | null) { + const isSaved = (c: JobLike['configurations'][number]) => c.label === 'candidate' + && (savedRevisionId ? c.revisionId === savedRevisionId : Boolean(savedHash) && c.hash === savedHash) + const mine = jobs + .filter(job => job.configurations.some(isSaved)) + .sort((a, b) => b.createdAtMs - a.createdAtMs) + if (!mine.length) return null + const datasetId = mine[0].datasetId + const bySplit: Partial> = {} + for (const job of mine) { + if (job.datasetId !== datasetId || job.state === 'cancelled') continue + const prior = bySplit[job.split] + // Prefer the newest finished run of a split over a newer failed retry. + if (!prior || (prior.state !== 'succeeded' && job.state === 'succeeded')) bySplit[job.split] = job + } + const jobsInGroup = Object.values(bySplit) as J[] + return { datasetId, experimentId: mine[0].experimentId, bySplit, latest: mine[0], jobs: jobsInGroup } +} + +export interface RunStatus { tone: DotTone, kind: 'done' | 'running' | 'queued' | 'failed', durationMs: number } + +export function runStatus(jobs: JobLike[]): RunStatus | null { + if (!jobs.length) return null + if (jobs.some(job => job.state === 'running' || job.state === 'cancel_requested')) return { tone: 'warn', kind: 'running', durationMs: 0 } + if (jobs.some(job => job.state === 'queued')) return { tone: 'warn', kind: 'queued', durationMs: 0 } + if (jobs.some(job => job.state === 'failed' || job.state === 'interrupted')) return { tone: 'danger', kind: 'failed', durationMs: 0 } + const start = Math.min(...jobs.map(job => job.createdAtMs)), end = Math.max(...jobs.map(job => job.updatedAtMs)) + return { tone: 'ok', kind: 'done', durationMs: end - start } +} + +/** Previous = the pinned configuration matching the previous profile, else the runtime baseline. */ +export function baselineLabel(job: JobLike, previousHash: string | null): ConfigLabel { + const byHash = previousHash ? job.configurations.find(c => c.hash === previousHash && c.label !== 'candidate') : undefined + if (byHash) return byHash.label as ConfigLabel + return job.configurations.some(c => c.label === 'current') ? 'current' : 'default' +} + +type Metric = 'heart_rate' | 'breathing_rate' | 'window_ibi_variability_v1' +export interface MetricScore { mae: number | null, paired: number, coverage: number | null } +export interface NightScore { + cohortId: string + split: Split + previous: Record + saved: Record + /** Saved configuration was paired with at least one heart-rate reference. */ + hasReference: boolean +} + +function score(evaluation: NumericReportView['nights'][number]['evaluations'][number] | undefined, metric: Metric): MetricScore { + let paired = 0, weighted = 0, coverage = 0, observations = 0 + for (const window of evaluation?.windows ?? []) { + const row = window.numeric.find(n => n.metric === metric) + if (!row) continue + if (row.status === 'compared' && row.metrics?.mae != null && row.pairedCount > 0) { + paired += row.pairedCount + weighted += row.metrics.mae * row.pairedCount + } + if (row.outputCoverage != null && window.observationCount > 0) { + coverage += row.outputCoverage * window.observationCount + observations += window.observationCount + } + } + return { mae: paired ? weighted / paired : null, paired, coverage: observations ? coverage / observations : null } +} + +const METRICS: Metric[] = ['heart_rate', 'breathing_rate', 'window_ibi_variability_v1'] + +/** Per-night agreement of previous and saved from one split's report. */ +export function nightScores(report: NumericReportView, split: Split, previous: ConfigLabel): NightScore[] { + return report.nights.map((night) => { + const prev = night.evaluations.find(e => e.label === previous) + const saved = night.evaluations.find(e => e.label === 'candidate') + const p = Object.fromEntries(METRICS.map(m => [m, score(prev, m)])) as Record + const s = Object.fromEntries(METRICS.map(m => [m, score(saved, m)])) as Record + return { cohortId: night.cohortId, split, previous: p, saved: s, hasReference: s.heart_rate.paired > 0 } + }) +} + +const mean = (values: number[]) => values.length ? values.reduce((a, b) => a + b, 0) / values.length : null + +/** Equal-night mean error; nights without a reference are excluded. */ +export function aggregate(nights: NightScore[], metric: Metric, split?: Split) { + const rows = nights.filter(n => (!split || n.split === split) && n.previous[metric].mae !== null && n.saved[metric].mae !== null) + return { + previous: mean(rows.map(n => n.previous[metric].mae as number)), + saved: mean(rows.map(n => n.saved[metric].mae as number)), + nights: rows.length, + } +} + +export function coverage(nights: NightScore[]) { + const pick = (side: 'previous' | 'saved') => mean(nights.flatMap(n => n[side].heart_rate.coverage === null ? [] : [n[side].heart_rate.coverage as number])) + return { previous: pick('previous'), saved: pick('saved') } +} + +/** + * Lower is better for errors; the colour only marks a real difference. The + * threshold is in the metric's own unit: 0.05 for errors (bpm, ms, br/min), + * 0.005 (half a point) for fractions such as coverage. + */ +export function compareTone(previous: number | null, saved: number | null, higherIsBetter = false, threshold = higherIsBetter ? 0.005 : 0.05): 'better' | 'worse' | 'same' { + if (previous === null || saved === null) return 'same' + const delta = higherIsBetter ? previous - saved : saved - previous + if (delta < -threshold) return 'better' + if (delta > 0) return 'worse' + return 'same' +} + +/** 0–5 bpm → 0–72 px. */ +export function barHeight(mae: number | null, px = 72, top = 5): number { + if (mae === null) return 0 + return Math.round(Math.max(0, Math.min(1, mae / top)) * px) +} + +/* ─── Nights ───────────────────────────────────────────────────────────── */ + +/** "2026-10-11" → { weekday: 'Sun', day: 11 }, read as a calendar date. */ +export function nightLabel(cohortId: string) { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(cohortId) + if (!m) return { weekday: cohortId, day: '' } + const date = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))) + return { weekday: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()], day: String(date.getUTCDate()) } +} + +/** Reference sources whose time span overlaps the night, e.g. "watch + ring". */ +export function nightSources(night: { startMs: number, endMs: number }, imports: Array<{ source: string, startMs: number | null, endMs: number | null }>) { + const sources = imports + .filter(i => i.startMs !== null && i.endMs !== null && i.startMs < night.endMs && i.endMs > night.startMs) + .map(i => i.source) + return [...new Set(sources)].join(' + ') +} + +/* ─── Trace ────────────────────────────────────────────────────────────── */ + +export const TRACE = { width: 364, height: 150, minBpm: 40, maxBpm: 100 } + +/** SVG path for timestamped values; gaps over `gapMs` break the line. */ +export function tracePath(points: Array<{ t: number, v: number | null }>, startMs: number, endMs: number, gapMs = 10 * 60_000) { + const span = Math.max(1, endMs - startMs) + let d = '', last: number | null = null + for (const p of points) { + if (p.v === null || p.t < startMs || p.t > endMs) { + last = null + continue + } + const x = ((p.t - startMs) / span) * TRACE.width + const clamped = Math.max(TRACE.minBpm, Math.min(TRACE.maxBpm, p.v)) + const y = TRACE.height - ((clamped - TRACE.minBpm) / (TRACE.maxBpm - TRACE.minBpm)) * TRACE.height + d += `${last === null || p.t - last > gapMs ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)} ` + last = p.t + } + return d.trim() +} + +/** Session start, every two hours on the hour, session end. */ +export function traceTicks(startMs: number, endMs: number) { + const ticks = [startMs] + const hour = 3_600_000 + let t = Math.ceil(startMs / (2 * hour)) * 2 * hour + for (; t < endMs; t += 2 * hour) { + if (t - startMs > 40 * 60_000 && endMs - t > 40 * 60_000) ticks.push(t) + } + ticks.push(endMs) + return ticks +} + +/* ─── Diffs ────────────────────────────────────────────────────────────── */ + +/** "trackingToleranceBpm 15 → 12" for each numeric parameter that moved. */ +export function paramDiffs(before: BiometricConfig, after: BiometricConfig) { + return PARAM_PATHS + .filter(path => readParam(before, path) !== readParam(after, path)) + .map(path => `${PARAMS[path].key} ${readParam(before, path)} → ${readParam(after, path)}`) +} + +/* ─── Clock labels ─────────────────────────────────────────────────────── */ + +function zoned(ms: number, timeZone: string) { + try { + const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', minute: '2-digit', hourCycle: 'h23', month: 'short', day: 'numeric' }).formatToParts(ms) + const get = (type: string) => parts.find(p => p.type === type)?.value ?? '' + return { hour: Number(get('hour')), minute: Number(get('minute')), month: get('month'), day: get('day') } + } + catch { + return zoned(ms, 'UTC') + } +} + +/** Axis label: "11:15p", "1a" (12h) or "23:15", "01:00" (24h). */ +export function clockLabel(ms: number, timeZone: string, h24: boolean) { + const { hour, minute } = zoned(ms, timeZone) + const mm = String(minute).padStart(2, '0') + if (h24) return `${String(hour).padStart(2, '0')}:${mm}` + const h = hour % 12 || 12 + return `${h}${minute ? `:${mm}` : ''}${hour < 12 ? 'a' : 'p'}` +} + +/** Relative under a day, else "Oct 9 · 11:43 PM". */ +export function activityTime(ms: number, nowMs: number, timeZone: string, h24: boolean) { + if (nowMs - ms < 86_400_000) return relativeTime(ms, nowMs) + const { hour, minute, month, day } = zoned(ms, timeZone) + const mm = String(minute).padStart(2, '0') + const clock = h24 ? `${String(hour).padStart(2, '0')}:${mm}` : `${hour % 12 || 12}:${mm} ${hour < 12 ? 'AM' : 'PM'}` + return `${month} ${day} · ${clock}` +} diff --git a/src/components/biometrics/Calibration/evaluation-setup.ts b/src/components/biometrics/Calibration/evaluation-setup.ts new file mode 100644 index 000000000..09e80429f --- /dev/null +++ b/src/components/biometrics/Calibration/evaluation-setup.ts @@ -0,0 +1,200 @@ +'use client' + +import { useRef, useState } from 'react' +import { trpc } from '@/src/utils/trpc' +import type { Split } from './calibration-model' + +export type SetupStep = 'idle' | 'linking' | 'choosing' | 'freezing' | 'inspecting' | 'retaining' | 'protocol' | 'queueing' | 'done' | 'failed' +export interface SetupProgress { step: SetupStep, done?: number, total?: number, error?: string } + +const PREROLL_MS = 5 * 60_000 +const MIN_SESSION_S = 3600 +const MAX_SESSION_MS = 15 * 3600_000 +const DEFERRED_RETRY_MS = 5000 +const DEFERRED_ATTEMPTS = 120 +const randomKey = () => Array.from(crypto.getRandomValues(new Uint8Array(24)), b => b.toString(16).padStart(2, '0')).join('') +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** A replay protocol that recomputes the numbers these parameters change. */ +export const WAVEFORM_PROTOCOL = { version: 'descriptive-evaluation-v1', mode: 'signal_replay', assumedStageCalibrationQuality: 0, + pointToleranceMs: 30000, clockOffsetMs: 0 } as const + +/** Local calendar day of the evening an instant belongs to; nights run noon to noon. */ +export function nightKey(atMs: number, timezone: string) { + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(atMs - 12 * 3600_000) + } + catch { + return new Date(atMs - 12 * 3600_000).toISOString().slice(0, 10) + } +} + +/** Every noon-to-noon night a session touches. The server joins sessions that share one into a single cohort. */ +export function nightsTouched(startMs: number, endMs: number, timezone: string) { + const keys = new Set() + for (let at = startMs; at < endMs; at += 3600_000) keys.add(nightKey(at, timezone)) + keys.add(nightKey(Math.max(startMs, endMs - 1), timezone)) + return keys +} + +/** + * The newest `nights` covered sessions, oldest first, one per night: a session + * that shares a night with one already chosen is skipped, so every frozen + * cohort is a single session. Sessions longer than a replay can retain are + * skipped too (64 archives of 15 minutes, with five minutes of pre-roll). + */ +export function chooseSessions( + sessions: S[], covered: Array<{ startMs: number, endMs: number }>, nights: number, timezone: string) { + const usable = sessions.filter(s => s.sleepDurationSeconds >= MIN_SESSION_S && s.endMs - s.startMs <= MAX_SESSION_MS + && covered.some(c => c.startMs < s.endMs && c.endMs > s.startMs)) + // Newest night first; within a night, the main sleep before any nap. + .sort((a, b) => nightKey(b.startMs, timezone).localeCompare(nightKey(a.startMs, timezone)) || (b.endMs - b.startMs) - (a.endMs - a.startMs)) + const taken = new Set() + const chosen: S[] = [] + for (const session of usable) { + if (chosen.length === nights) break + const touched = nightsTouched(session.startMs, session.endMs, timezone) + if ([...touched].some(key => taken.has(key))) continue + touched.forEach(key => taken.add(key)) + chosen.push(session) + } + return chosen.sort((a, b) => a.startMs - b.startMs) +} + +/** + * One click from "a reference is imported" to "the saved profile has been + * replayed": link older Watch rows, freeze the newest referenced nights, + * inspect and retain their RAW archives, set up a waveform replay and queue + * it on every split. Each step is idempotent under one key, so a retry after + * a failure resumes instead of duplicating work. + */ +export function useEvaluationSetup({ side, timezone, savedRevisionId, generation, nights = 7 }: { + side: 'left' | 'right' + timezone: string + savedRevisionId: string | null + generation: number + nights?: number +}) { + const utils = trpc.useUtils() + const [progress, setProgress] = useState({ step: 'idle' }) + const key = useRef(randomKey()) + + async function deferred(attempt: () => Promise): Promise> { + for (let i = 0; i < DEFERRED_ATTEMPTS; i++) { + const result = await attempt() + if (result.status !== 'deferred') return result as Exclude + await sleep(DEFERRED_RETRY_MS) + } + throw new Error('The pod kept deferring archive work; try again when it is less busy.') + } + + async function run() { + const api = utils.client.biometricTuning + try { + setProgress({ step: 'linking' }) + const legacy = await api.countLegacyReferences.query({ side }) + if (legacy.vitals + legacy.stages > 0) await api.linkLegacyReferences.mutate({ side }) + + setProgress({ step: 'choosing' }) + const imports = [] + let importCursor: { createdAtMs: number, id: string } | null = null + for (let page = 0; page < 10; page++) { + const result = await api.listImports.query({ side, ...(importCursor ? { beforeCreatedAtMs: importCursor.createdAtMs, beforeId: importCursor.id } : {}) }) + imports.push(...result.items) + importCursor = result.nextCursor + if (!importCursor) break + } + const available = imports.filter(i => i.available && i.summary.startMs !== null && i.summary.endMs !== null) + if (!available.length) throw new Error('Import a reference for this side first.') + const subjectId = available[0].summary.subjectId + const references = available.filter(i => i.summary.subjectId === subjectId) + const covered = references.map(i => ({ startMs: i.summary.startMs as number, endMs: i.summary.endMs as number })) + const earliest = Math.min(...covered.map(c => c.startMs)) + + const sessions = [] + let sessionCursor: { startMs: number, id: number } | null = null + for (let page = 0; page < 20; page++) { + const result = await api.listSessions.query({ side, ...(sessionCursor ? { beforeStartMs: sessionCursor.startMs, beforeId: sessionCursor.id } : {}) }) + sessions.push(...result.items) + sessionCursor = result.nextCursor + if (!sessionCursor || result.items.every(s => s.endMs < earliest)) break + } + // Only nights whose RAW archives still exist can be replayed. + const withArchives = [] + for (const session of chooseSessions(sessions, covered, nights * 2, timezone).reverse()) { + if ((await api.locateRawArchives.query({ startMs: session.startMs, endMs: session.endMs })).length) withArchives.push(session) + } + const chosen = chooseSessions(withArchives, covered, nights, timezone) + if (!chosen.length) throw new Error('No recorded night with retained RAW archives overlaps the reference.') + const span = { startMs: chosen[0].startMs, endMs: chosen[chosen.length - 1].endMs } + const referenceImportIds = references.filter(i => (i.summary.startMs as number) < span.endMs && (i.summary.endMs as number) > span.startMs) + .slice(0, 20).map(i => i.id) + + setProgress({ step: 'freezing' }) + const dataset = await api.createDataset.mutate({ side, subjectId, sessionIds: chosen.map(s => s.id), referenceImportIds, idempotencyKey: `${key.current}:dataset` }) + const cohorts = dataset.manifest.splitProtocol.assignments + + const located = new Map() + for (const cohort of cohorts) { + for (const archive of await api.locateRawArchives.query({ startMs: cohort.startMs - PREROLL_MS, endMs: cohort.endMs })) located.set(archive.name, archive.afterName) + } + const inspected = new Map() + const remember = (source: { id: string, inspection: { source_name: string, inventory: { first_event_ms: number | null, last_event_ms: number | null, ordered_waveform_available: boolean } } }) => + inspected.set(source.inspection.source_name, { id: source.id, first: source.inspection.inventory.first_event_ms, + last: source.inspection.inventory.last_event_ms, ordered: source.inspection.inventory.ordered_waveform_available }) + for (const cohort of cohorts) { + let afterId: string | undefined + for (let page = 0; page < 20; page++) { + const result = await api.listRawSources.query({ startMs: cohort.startMs - PREROLL_MS, endMs: cohort.endMs, ...(afterId ? { afterId } : {}) }) + result.items.filter(s => s.unavailableAtMs === null).forEach(remember) + if (!result.nextCursor) break + afterId = result.nextCursor + } + } + const pending = [...located].filter(([name]) => !inspected.has(name)) + let done = 0 + setProgress({ step: 'inspecting', done, total: pending.length }) + for (const [, afterName] of pending) { + const result = await deferred(() => api.inspectRawSource.mutate({ afterName })) + if (result.status === 'inspected') remember(result.source) + setProgress({ step: 'inspecting', done: ++done, total: pending.length }) + } + + setProgress({ step: 'retaining' }) + const selection = cohorts.map((cohort) => { + const ids = [...new Set([...inspected.values()].filter(s => s.ordered && s.first !== null && s.last !== null + && s.first <= cohort.endMs && s.last >= cohort.startMs - PREROLL_MS).map(s => s.id))].slice(0, 64) + return { cohortId: cohort.cohortId, sourceIds: ids } + }) + if (selection.some(c => !c.sourceIds.length)) throw new Error('A frozen night has no readable RAW archive; freeze again once its archives are inspected.') + const retained = await deferred(() => api.createWaveformDataset.mutate({ parentDatasetId: dataset.id, expectedContentHash: dataset.contentHash, + cohorts: selection, idempotencyKey: `${key.current}:waveform` })) + + setProgress({ step: 'protocol' }) + const experiment = await api.createExperiment.mutate({ datasetId: retained.dataset.id, protocol: WAVEFORM_PROTOCOL, idempotencyKey: `${key.current}:experiment` }) + + setProgress({ step: 'queueing' }) + // On system defaults there is no saved revision; replay an unrequested one that resolves to the defaults. + const candidate = savedRevisionId ?? (await api.createProfileRevision.mutate({ side, overrides: [], origin: 'manual', + requester: 'web-calibration', idempotencyKey: `${key.current}:defaults` })).id + const splits = new Set(retained.dataset.manifest.splitProtocol.assignments.map(a => a.split)) + for (const split of (['tuning', 'validation', 'holdout'] as Split[]).filter(s => splits.has(s))) { + await api.enqueueBacktest.mutate({ experimentId: experiment.id, candidateRevisionId: candidate, expectedBindingGeneration: generation, + split, idempotencyKey: `${key.current}:${split}` }) + } + setProgress({ step: 'done' }) + } + catch (cause) { + setProgress({ step: 'failed', error: cause instanceof Error ? cause.message : String(cause) }) + } + finally { + void utils.biometricTuning.invalidate() + } + } + + const running = !['idle', 'done', 'failed'].includes(progress.step) + const start = () => { + if (!running) void run() + } + return { progress, running, run: start } +} diff --git a/src/components/biometrics/Calibration/tests/BiometricCalibration.test.tsx b/src/components/biometrics/Calibration/tests/BiometricCalibration.test.tsx index 77c6928e9..ed777614a 100644 --- a/src/components/biometrics/Calibration/tests/BiometricCalibration.test.tsx +++ b/src/components/biometrics/Calibration/tests/BiometricCalibration.test.tsx @@ -1,78 +1,350 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, within } from '@testing-library/react' import { setupI18n } from '@lingui/core' import { I18nProvider } from '@lingui/react' import { beforeEach, expect, it, vi } from 'vitest' +import { defaultConfig } from '@/src/biometrics/algorithms/registry-core' import { BiometricCalibration } from '../BiometricCalibration' -const mock = vi.hoisted(() => ({ overview: undefined as unknown, overviewError: null as Error | null, activation: undefined as unknown, - history: { items: [] as unknown[], nextGeneration: null as number | null }, historyQuery: vi.fn(), sideChange: vi.fn() })) -vi.mock('@/src/utils/trpc', () => ({ trpc: { - biometricTuning: { - getProfileOverview: { useQuery: () => ({ data: mock.overview, error: mock.overviewError }) }, - getActivationStatus: { useQuery: () => ({ data: mock.activation }) }, - listActivationHistory: { useQuery: (input: unknown) => { - mock.historyQuery(input) - return { data: mock.history } - } }, - }, - settings: { getAll: { useQuery: () => ({ data: { device: { timezone: 'UTC' } } }) } }, -} })) -vi.mock('../ProfileRecovery', () => ({ ProfileRecovery: () =>

Recovery controls

})) + +const HASH = 'a4f2'.padEnd(62, '0') + '9c' +const mock = vi.hoisted(() => ({ + now: 0, + overview: undefined as unknown, + overviewError: null as Error | null, + revisions: {} as Record, + status: undefined as unknown, + history: { items: [] as unknown[], nextGeneration: null }, + jobs: [] as unknown[], + summaries: [] as unknown[], + datasets: [] as unknown[], + imports: [] as unknown[], + experiments: [] as unknown[], + legacy: 0, + diffData: undefined as unknown, + traces: {} as Record, + referenceTrace: undefined as unknown, + create: vi.fn(), + activate: vi.fn(), + enqueue: vi.fn(), + diff: vi.fn(), + reset: vi.fn(), + rollback: vi.fn(), + sideChange: vi.fn(), +})) +vi.mock('@/src/utils/trpc', () => { + const query = (data: () => unknown) => ({ useQuery: () => ({ data: data(), error: null }) }) + const mutation = (fn: (input: unknown) => unknown, data: () => unknown = () => undefined) => ({ useMutation: () => ({ mutateAsync: async (input: unknown) => fn(input), mutate: fn, isPending: false, error: null, data: data() }) }) + return { trpc: { + useUtils: () => new Proxy({}, { get: () => new Proxy({}, { get: () => ({ invalidate: vi.fn() }) }) }), + useQueries: () => mock.summaries.map(data => ({ data })), + settings: { getAll: query(() => ({ device: { timezone: 'UTC' }, sides: { left: { name: 'Alex' }, right: { name: 'Sam' } } })) }, + biometricTuning: { + getProfileOverview: { useQuery: () => ({ data: mock.overview, error: mock.overviewError }) }, + getProfileRevision: { useQuery: ({ id }: { id: string }) => ({ data: mock.revisions[id], error: null }) }, + getActivationStatus: query(() => mock.status), + listActivationHistory: query(() => mock.history), + listProfileRevisions: query(() => ({ items: Object.values(mock.revisions), nextCursor: null })), + listBacktests: query(() => ({ items: mock.jobs, nextCursor: null })), + listDatasets: query(() => ({ items: mock.datasets, nextCursor: null })), + listImports: query(() => ({ items: mock.imports, nextCursor: null })), + listExperiments: query(() => ({ items: mock.experiments, nextCursor: null })), + countLegacyReferences: query(() => ({ vitals: mock.legacy, stages: 0 })), + getDataset: query(() => undefined), + getBacktestNumericTrace: { useQuery: ({ configuration }: { configuration: string }) => ({ data: mock.traces[configuration], error: null }) }, + getBacktestReferenceTrace: query(() => mock.referenceTrace), + createProfileRevision: mutation(mock.create), + activateProfile: mutation(mock.activate), + enqueueBacktest: mutation(mock.enqueue), + diffProfileRevision: mutation(mock.diff, () => mock.diffData), + resetProfile: mutation(mock.reset), + rollbackProfile: mutation(mock.rollback), + }, + } } +}) +vi.mock('next/navigation', () => ({ usePathname: () => '/en/settings' })) +vi.mock('@/src/providers/TimeFormatProvider', () => ({ useTimeFormat: () => '12h' })) +vi.mock('../ProfileLibrary', () => ({ ProfileLibrary: ({ side }: { side: string }) =>

{`Revision library ${side}`}

})) vi.mock('../ShadowRuns', () => ({ ShadowRuns: ({ side }: { side: string }) =>

{`Prospective runs ${side}`}

})) -vi.mock('../ProfileLibrary', () => ({ ProfileLibrary: () =>

Candidate editor

})) -vi.mock('@/src/hooks/useSideNames', () => ({ useSideNames: () => ({ leftName: 'Left', rightName: 'Right' }) })) -vi.mock('@/src/components/Settings/SleepStageRules', () => ({ SleepStageRules: ({ side }: { side: string }) =>

{`Existing stage editor ${side}`}

})) +vi.mock('../CalibrationReferences', () => ({ CalibrationReferences: () =>

Reference import

})) +vi.mock('../ReferenceLibrary', () => ({ ReferenceLibrary: () => null })) +vi.mock('../CalibrationBacktests', () => ({ CalibrationBacktests: () =>

Dataset builder

})) +vi.mock('../WatchDeviationTrend', () => ({ WatchDeviationTrend: ({ side }: { side: string }) =>

{`Watch deviation ${side}`}

})) + const i18n = setupI18n({ locale: 'en', messages: {} }) const view = (side: 'left' | 'right' = 'left') => +const min = 60_000 +const window = (mae: number) => ({ startMs: 0, endMs: 1, observationCount: 10, numeric: [{ metric: 'heart_rate', status: 'compared', metrics: { mae, medianAbsoluteError: mae, p95AbsoluteError: mae, signedBias: 0 }, + pairedCount: 10, referenceCoverage: 1, outputCoverage: 0.9, unmatchedReferences: 0, unmatchedObservations: 0, exclusions: {} }] }) +const night = (cohortId: string, previous: number, saved: number) => ({ cohortId, evaluations: [ + { label: 'current', configHash: 'h-current', windows: [window(previous)] }, + { label: 'candidate', configHash: 'h-saved', windows: [window(saved)] }, +] }) + beforeEach(() => { vi.clearAllMocks() + mock.now = Date.now() + mock.experiments = [] + mock.legacy = 0 + const saved = structuredClone(defaultConfig) + saved.heart_rate.trackingToleranceBpm = 12 mock.overviewError = null - mock.overview = { desired: { isDefault: false, hash: 'exact-config', binding: { generation: 2 }, warning: 'Using compatible fallback' }, - runtimeHealth: { state: 'stale', components: [{ component: 'heart_rate', state: 'stale' }] }, - stageView: { provenance: { warning: null } }, sensorCalibration: [], limitations: [] } - mock.activation = { observedState: 'pending_boundary' } - mock.history = { items: [], nextGeneration: null } + mock.overview = { desired: { binding: { side: 'left', generation: 3, desiredRevisionId: 'saved-id', previousRevisionId: null, updatedAtMs: mock.now - 14 * min }, + resolved: saved, hash: HASH, isDefault: false, warning: null }, runtimeStatus: 'receipts_available', sensorCalibration: [] } + mock.revisions = { 'saved-id': { id: 'saved-id', side: 'left', origin: 'agent', requester: 'tuner', overrides: [{ path: 'heart_rate.trackingToleranceBpm', value: 12 }], + resolved: saved, resolvedHash: 'h-saved', createdAtMs: mock.now - 16 * min } } + const event = { id: 'event', side: 'left', generation: 3, action: 'activate', previousRevisionId: null, nextRevisionId: 'saved-id', previousHash: 'h-default', nextHash: 'h-saved', + requester: 'tuner', state: 'pending_boundary', reason: null, createdAtMs: mock.now - 14 * min, updatedAtMs: mock.now - 14 * min } + mock.status = { desiredGeneration: 3, isCurrent: true, event, observedState: 'pending_boundary' } + mock.history = { items: [event], nextGeneration: null } + const configurations = [{ label: 'default', revisionId: null, hash: 'h-default' }, { label: 'current', revisionId: null, hash: 'h-current' }, { label: 'candidate', revisionId: 'saved-id', hash: 'h-saved' }] + mock.jobs = [ + { id: 'b42a-tuning', experimentId: 'exp', datasetId: 'ds', split: 'tuning', state: 'succeeded', createdAtMs: mock.now - 15 * min, updatedAtMs: mock.now - 15 * min + 38_000, configurations, evaluationScope: 'original_readings_comparison', error: null }, + { id: 'b42b-holdout', experimentId: 'exp', datasetId: 'ds', split: 'holdout', state: 'succeeded', createdAtMs: mock.now - 15 * min, updatedAtMs: mock.now - 15 * min + 30_000, configurations, evaluationScope: 'original_readings_comparison', error: null }, + ] + mock.summaries = [ + { contentHash: 'c1', available: true, report: { nights: [night('2026-10-10', 3.1, 2.4), night('2026-10-11', 2.9, 2.2)] } }, + { contentHash: 'c2', available: true, report: { nights: [night('2026-10-12', 3.4, 2.6)] } }, + ] + mock.datasets = [{ id: 'ds', side: 'left', available: true, createdAtMs: Date.UTC(2026, 9, 12), byteLength: 2.1 * 1024 * 1024, manifest: { splitProtocol: { assignments: [ + { cohortId: '2026-10-10', split: 'tuning', startMs: 1, endMs: 2, nightLabels: [], sessionIds: [1] }, + { cohortId: '2026-10-11', split: 'tuning', startMs: 3, endMs: 4, nightLabels: [], sessionIds: [2] }, + { cohortId: '2026-10-12', split: 'holdout', startMs: 5, endMs: 6, nightLabels: [], sessionIds: [3] }, + ] } } }] + mock.imports = [{ id: 'imp', available: true, createdAtMs: mock.now - 2 * 86_400_000, summary: { source: 'watch', startMs: 0, endMs: 10, accepted: 120 } }] + mock.create.mockResolvedValue({ id: 'new-id', resolvedHash: 'n'.repeat(64) }) + mock.activate.mockResolvedValue({ id: 'event-2', generation: 4 }) + mock.diff.mockImplementation(() => undefined) + mock.diffData = undefined + mock.traces = {} + mock.referenceTrace = undefined }) -it('distinguishes saved personalization, pending adoption and stale health', () => { + +it('shows who saved what, that it is still waiting, and what the processor runs', () => { render(view()) - expect(screen.getByText('Personalized profile')).toBeTruthy() - expect(screen.getByText('Waiting for a processing boundary')).toBeTruthy() - expect(screen.getAllByText('Stale')).toHaveLength(2) - expect(screen.queryByText('Applied')).toBeNull() - expect(screen.getByText('Using compatible fallback')).toBeTruthy() - expect(screen.getByText('No sensor calibration has been reported for this side.')).toBeTruthy() - expect(screen.getByText(/not clinical beat-to-beat RMSSD/)).toBeTruthy() -}) -it('reuses the existing stage editor and keeps manual activation semantics visible', () => { + const strip = screen.getByRole('group', { name: 'Profile lifecycle' }) + expect(within(strip).getByText('by tuner · 16 min ago')).toBeTruthy() + expect(within(strip).getByText('waiting 14 min')).toBeTruthy() + expect(within(strip).getByText('running system defaults')).toBeTruthy() + expect(screen.getByText('trackingToleranceBpm · previous: 15 · range 5–30')).toBeTruthy() + expect(screen.getByText(/window IBI variability, not clinical RMSSD/)).toBeTruthy() + expect(screen.getByText('Measured sensor calibration for this side:', { exact: false })).toBeTruthy() + expect(screen.getByText('Watch deviation left')).toBeTruthy() + expect(screen.getByText('a4f2…9c')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Keep saved profile' }).getAttribute('aria-disabled')).toBe('true') + expect(screen.getByText('Nothing changes until you move a control or restore.')).toBeTruthy() +}) + +it('reads the backtest per night and per split, with holdout the one to trust', () => { render(view()) - fireEvent.click(screen.getByRole('button', { name: 'Profiles' })) - expect(screen.getByText('Existing stage editor left')).toBeTruthy() - expect(screen.getByText(/unvalidated manual settings/)).toBeTruthy() + expect(screen.getByText('done · 38 s')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Mon 12: previous 3.4, saved 2.6 bpm' })).toBeTruthy() + expect(screen.getByText('HR error · holdout')).toBeTruthy() + expect(screen.getAllByText('3.4').length).toBeGreaterThan(0) + expect(screen.getByText('frozen Oct 12 · 2.1 MB')).toBeTruthy() + expect(screen.getAllByText('watch').length).toBe(3) + expect(screen.getByText('holdout −0.8 bpm · covered 90%')).toBeTruthy() + expect(screen.getAllByText('unchanged').length).toBe(4) + const nights = screen.getByRole('listbox', { name: 'Dataset nights' }) + fireEvent.click(within(nights).getByRole('option', { name: /Sat 10/ })) + expect(within(nights).getByRole('option', { name: /Sat 10/ }).getAttribute('aria-selected')).toBe('true') + expect(screen.getByText(/compared stored readings/)).toBeTruthy() }) -it('opens prospective observations for the selected side', () => { - const rendered = render(view()) + +it('lists agent and pod activity newest first', () => { + render(view()) + const activity = screen.getByText('Activity').closest('div')?.parentElement as HTMLElement + const items = within(activity).getAllByRole('listitem').map(li => li.textContent) + expect(items[0]).toContain('Requested activation · generation 3') + expect(items.some(text => text?.includes('tuner Saved the profile') && text.includes('trackingToleranceBpm 15 → 12'))).toBe(true) + expect(items.some(text => text?.includes('Finished a backtest on holdout nights') && text.includes('holdout HR error 3.4 → 2.6 bpm'))).toBe(true) +}) + +it('saves a moved control as a new revision, requests it, and re-runs every split in the dataset', async () => { + render(view()) + fireEvent.change(screen.getByLabelText('Minimum IBI windows'), { target: { value: '14' } }) + expect(screen.getByText('tested on save')).toBeTruthy() + expect(screen.getByText('Overwrites the saved profile and requests it; the current one becomes previous.')).toBeTruthy() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save and request' })) + }) + expect(mock.create).toHaveBeenCalledWith(expect.objectContaining({ side: 'left', parentRevisionId: 'saved-id', origin: 'manual', requester: 'web-calibration', + overrides: [{ path: 'heart_rate.trackingToleranceBpm', value: 12 }, { path: 'hrv_index.minimumWindows', value: 14 }] })) + expect(mock.activate).toHaveBeenCalledWith(expect.objectContaining({ revisionId: 'new-id', expectedGeneration: 3, expectedResolvedHash: 'n'.repeat(64), boundary: 'next_session', allowUnvalidated: true })) + expect(mock.enqueue.mock.calls.map(([input]) => [input.split, input.experimentId, input.expectedBindingGeneration, input.candidateRevisionId])).toEqual([['tuning', 'exp', 4, 'new-id'], ['holdout', 'exp', 4, 'new-id']]) +}) + +it('uses a replay set up on the dataset when no job has run yet, and says what is missing otherwise', async () => { + mock.jobs = [] + mock.summaries = [] + const result = render(view()) + expect(screen.getByText(/This dataset has no waveform replay/)).toBeTruthy() + mock.experiments = [{ id: 'exp-stored', datasetId: 'other', protocol: { mode: 'signal_replay' } }, { id: 'exp-cmp', datasetId: 'ds', protocol: { mode: 'comparison' } }, + { id: 'exp-wave', datasetId: 'ds', protocol: { mode: 'signal_replay' } }] + result.rerender(view()) + expect(screen.getByText(/replays the frozen dataset on its tuning, validation and holdout nights/)).toBeTruthy() + fireEvent.change(screen.getByLabelText('Minimum IBI windows'), { target: { value: '14' } }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save and request' })) + }) + expect(mock.enqueue.mock.calls.map(([input]) => [input.split, input.experimentId])).toEqual([['tuning', 'exp-wave'], ['holdout', 'exp-wave']]) +}) + +it('reviews a restore before requesting system defaults', async () => { + const result = render(view()) + fireEvent.click(screen.getByRole('button', { name: 'Restore… ▾' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'System defaults' })) + expect(mock.diff).toHaveBeenCalledWith({ side: 'left', revisionId: null, againstRevisionId: 'saved-id' }) + expect(screen.getByRole('dialog', { name: 'Restore system defaults' })).toBeTruthy() + expect((screen.getByRole('button', { name: 'Restore and request' }) as HTMLButtonElement).disabled).toBe(true) + result.unmount() +}) + +it('opens workbench views from the page and returns', () => { + render(view()) + fireEvent.click(screen.getByRole('button', { name: 'Import reference' })) + expect(screen.getByText('Reference import')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Backtests' })) + expect(screen.getByText('Dataset builder')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Observations' })) expect(screen.getByText('Prospective runs left')).toBeTruthy() - rendered.rerender(view('right')) - expect(screen.getByText('Prospective runs right')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Profiles' })) + expect(screen.getByText('Revision library left')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Calibration' })) + expect(screen.getByRole('group', { name: 'Profile lifecycle' })).toBeTruthy() }) -it('resets history pagination on side changes and labels historical acknowledgements', () => { - mock.history = { items: [{ id: 'event', generation: 4, action: 'rollback', state: 'applied', createdAtMs: 1791673200000, - previousHash: 'before', nextHash: 'after', requester: 'fixture', reason: null }], nextGeneration: 4 } - const result = render(view()) - fireEvent.click(screen.getByRole('button', { name: 'History' })) - expect(screen.getByText(/Past acknowledgements/)).toBeTruthy() - expect(screen.getByText('Restore previous profile')).toBeTruthy() - expect(screen.getByText('Times shown in UTC')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Older changes' })) - expect(mock.historyQuery).toHaveBeenLastCalledWith({ side: 'left', beforeGeneration: 4 }) - result.rerender(view('right')) - expect(mock.historyQuery).toHaveBeenLastCalledWith({ side: 'right', beforeGeneration: undefined }) -}) -it('shows loading or errors without substituting healthy state', () => { + +it('shows loading or errors without substituting a healthy state', () => { mock.overview = undefined const result = render(view()) - expect(screen.queryByText('Applied')).toBeNull() + expect(screen.queryByRole('group', { name: 'Profile lifecycle' })).toBeNull() mock.overviewError = new Error('Processor data unavailable') result.rerender(view()) expect(screen.getByText('Processor data unavailable')).toBeTruthy() }) + +it('confirms a reviewed restore of the previous profile with the reviewed hash', async () => { + mock.overview = { ...(mock.overview as object), desired: { ...(mock.overview as { desired: object }).desired, + binding: { side: 'left', generation: 3, desiredRevisionId: 'saved-id', previousRevisionId: 'saved-id', updatedAtMs: mock.now } } } + mock.diffData = { afterHash: 'p'.repeat(64), changes: [{ path: 'heart_rate.trackingToleranceBpm', before: 12, after: 15 }, { path: 'sleep_stages.ruleset.fallback', before: 'deep', after: 'light' }] } + render(view()) + fireEvent.click(screen.getByRole('button', { name: 'Restore previous' })) + const dialog = screen.getByRole('dialog', { name: 'Restore the previous profile' }) + expect(within(dialog).getByText('heart_rate.trackingToleranceBpm')).toBeTruthy() + expect(within(dialog).getByText('12 → 15')).toBeTruthy() + expect(within(dialog).getByText('1 sleep-stage values')).toBeTruthy() + await act(async () => { + fireEvent.click(within(dialog).getByRole('button', { name: 'Restore and request' })) + }) + expect(mock.rollback).toHaveBeenCalledWith(expect.objectContaining({ side: 'left', revisionId: 'saved-id', expectedGeneration: 3, + expectedResolvedHash: 'p'.repeat(64), boundary: 'next_session', allowUnvalidated: true })) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it('restores defaults from the menu and says when nothing would change', async () => { + mock.diffData = { afterHash: 'd'.repeat(64), changes: [] } + render(view()) + fireEvent.click(screen.getByRole('button', { name: 'Restore… ▾' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Restore… ▾' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'System defaults' })) + expect(screen.getByText('Identical to the saved profile; restoring only re-requests it.')).toBeTruthy() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Restore and request' })) + }) + expect(mock.reset).toHaveBeenCalledWith(expect.objectContaining({ expectedResolvedHash: 'd'.repeat(64), expectedGeneration: 3 })) + fireEvent.click(screen.getByRole('button', { name: 'Restore… ▾' })) + fireEvent.mouseDown(document.body) + expect(screen.queryByRole('menu')).toBeNull() +}) + +it('draws the selected night from recomputed and reference traces', () => { + for (const job of mock.jobs as Array<{ evaluationScope: string }>) job.evaluationScope = 'retained_waveform_replay' + const at = (minutes: number) => Date.UTC(2026, 9, 12, 7) + minutes * min + const readings = (bpm: number) => ({ items: [0, 1, 2, 30].map(m => ({ timestampMs: at(m), heartRate: bpm, side: 'left', qualityScore: 0.84 })), nextCursor: null }) + mock.traces = { candidate: readings(62), current: readings(66) } + mock.referenceTrace = { items: [{ startMs: at(0), value: 61, compatibility: null }, { startMs: at(1), value: null, compatibility: 'unscorable' }], nextCursor: { offset: 500 } } + const assignments = (mock.datasets[0] as { manifest: { splitProtocol: { assignments: Array<{ startMs: number, endMs: number }> } } }).manifest.splitProtocol.assignments + for (const a of assignments) Object.assign(a, { startMs: at(-45), endMs: at(8 * 60) }) + ;(mock.imports[0] as { summary: object }).summary = { source: 'watch', startMs: at(-60), endMs: at(9 * 60), accepted: 1 } + const { container } = render(view()) + expect(screen.getByText('Heart rate · Mon 12')).toBeTruthy() + expect(screen.getByText('watch · quality 0.84')).toBeTruthy() + expect(screen.getByText('first 500 readings')).toBeTruthy() + const paths = [...container.querySelectorAll('svg[aria-label="Heart-rate trace"] path')].map(p => p.getAttribute('d')) + expect(paths.every(d => d && d.startsWith('M'))).toBe(true) + expect(screen.getByText('6:15a')).toBeTruthy() +}) + +it('explains missing evidence instead of drawing empty charts', () => { + mock.jobs = [] + mock.summaries = [] + mock.datasets = [] + mock.imports = [] + mock.history = { items: [], nextGeneration: null } + mock.revisions = {} + mock.overview = { ...(mock.overview as object), desired: { ...(mock.overview as { desired: object }).desired, + binding: { side: 'left', generation: 0, desiredRevisionId: null, previousRevisionId: null, updatedAtMs: 0 } }, runtimeStatus: 'unreported', + sensorCalibration: [{ id: 1, sensorType: 'piezo', status: 'completed', freshness: 'expired' }] } + mock.status = { desiredGeneration: 0, isCurrent: false, event: null, observedState: 'unreported' } + const result = render(view()) + expect(screen.getByText(/No backtest yet. Import a reference for this side/)).toBeTruthy() + expect(screen.getByText('not run')).toBeTruthy() + expect(screen.getByText(/with no overrides/)).toBeTruthy() + expect(screen.getByText(/No dataset is frozen for this side yet/)).toBeTruthy() + expect(screen.getByText('Nothing has happened on this side yet.')).toBeTruthy() + expect(screen.getByText('piezo expired')).toBeTruthy() + expect((screen.getByRole('button', { name: 'Restore previous' }) as HTMLButtonElement).disabled).toBe(true) + const strip = screen.getByRole('group', { name: 'Profile lifecycle' }) + expect(within(strip).getByText('not reported')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'show' })) + expect(screen.getByText(HASH)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'hide' })) + expect(screen.getByText('a4f2…9c')).toBeTruthy() + mock.legacy = 1597 + result.rerender(view()) + expect(screen.getByText(/No backtest yet. Freeze a new dataset below/)).toBeTruthy() +}) + +it('reports in-flight and failed runs and pages through long activity', () => { + ;(mock.jobs[0] as { state: string }).state = 'running' + const extra = Array.from({ length: 6 }, (_, i) => ({ id: `ds-${i}`, side: 'left', available: true, createdAtMs: mock.now - (i + 2) * 86_400_000, byteLength: 10, + manifest: { splitProtocol: { assignments: [] } } })) + mock.datasets = [...mock.datasets, ...extra] + const result = render(view()) + expect(screen.getByText('running')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /Show all \d+/ })) + expect(screen.getByRole('button', { name: 'Show recent' })).toBeTruthy() + ;(mock.jobs[0] as { state: string }).state = 'failed' + ;(mock.jobs[0] as { error: string }).error = 'Worker exited' + result.rerender(view()) + expect(screen.getAllByText('failed').length).toBeGreaterThan(0) + expect(screen.getByText('Worker exited')).toBeTruthy() + ;(mock.jobs[0] as { state: string }).state = 'queued' + result.rerender(view()) + expect(screen.getByText('queued')).toBeTruthy() +}) + +it('surfaces a failed save without losing the edit', async () => { + mock.create.mockRejectedValue(new Error('STALE_BINDING: inspect the current profile')) + render(view()) + fireEvent.change(screen.getByLabelText('Tracking history length'), { target: { value: '7' } }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save and request' })) + }) + expect(screen.getByText('STALE_BINDING: inspect the current profile')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Save and request' })).toBeTruthy() + expect(mock.activate).not.toHaveBeenCalled() +}) + +it('edits both breathing cut-offs on one axis, each kept inside its own range', () => { + render(view()) + const shortest = screen.getByLabelText('Shortest breathing interval (× median)') as HTMLInputElement + const longest = screen.getByLabelText('Longest breathing interval (× median)') as HTMLInputElement + expect([shortest.min, shortest.max, longest.min, longest.max]).toEqual(['0.25', '2', '0.25', '2']) + fireEvent.change(shortest, { target: { value: '1.4' } }) + fireEvent.change(longest, { target: { value: '0.6' } }) + expect(screen.getByText('0.9×')).toBeTruthy() + expect(screen.getByText('1.1×')).toBeTruthy() + expect(screen.getByText('1×')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Save and request' })).toBeTruthy() +}) diff --git a/src/components/biometrics/Calibration/tests/CalibrationOverview.test.tsx b/src/components/biometrics/Calibration/tests/CalibrationOverview.test.tsx deleted file mode 100644 index b587ee5a6..000000000 --- a/src/components/biometrics/Calibration/tests/CalibrationOverview.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { setupI18n } from '@lingui/core' -import { I18nProvider } from '@lingui/react' -import { beforeEach, expect, it, vi } from 'vitest' -import { CalibrationOverview } from '../CalibrationOverview' -const mock = vi.hoisted(() => ({ overview: { data: undefined as unknown, error: null as Error | null }, activation: { data: undefined as unknown, error: null as Error | null } })) -vi.mock('@/src/utils/trpc', () => ({ trpc: { biometricTuning: { - getProfileOverview: { useQuery: () => mock.overview }, - getActivationStatus: { useQuery: () => mock.activation }, -} } })) -const i18n = setupI18n({ locale: 'en', messages: {} }) -const element = () => -beforeEach(() => { - mock.overview = { data: { desired: { isDefault: true, hash: 'hash', binding: { generation: 1 }, warning: null }, - runtimeHealth: { state: 'current', components: [{ component: 'heart_rate', state: 'current' }, { component: 'custom', state: 'failed' }] }, - stageView: { provenance: { warning: 'Recomputed stages' } }, - sensorCalibration: [{ id: 'a', sensorType: 'piezo', status: 'ok', freshness: 'current' }, { id: 'b', sensorType: 'cap', status: 'ok', freshness: 'expired' }, { id: 'c', sensorType: 'temp', status: 'ok', freshness: 'other' }], - limitations: ['Limited to one night'] }, error: null } - mock.activation = { data: undefined, error: new Error('Activation unavailable') } -}) -it('shows the error or skeleton before data arrives', () => { - mock.overview = { data: undefined, error: new Error('Overview unavailable') } - const result = render(element()) - expect(screen.getByText('Overview unavailable')).toBeTruthy() - mock.overview = { data: undefined, error: null } - result.rerender(element()) - expect(screen.queryByText('Overview unavailable')).toBeNull() - expect(screen.queryByText('Saved profile')).toBeNull() -}) -it('falls back to raw component names, activation errors and sensor freshness labels', () => { - render(element()) - expect(screen.getByText('System defaults')).toBeTruthy() - expect(screen.getByText('Not reported')).toBeTruthy() - expect(screen.getByText('Activation unavailable')).toBeTruthy() - expect(screen.getByText('Heart rate')).toBeTruthy() - expect(screen.getByText('custom')).toBeTruthy() - expect(screen.getByText('Failed')).toBeTruthy() - expect(screen.getByText('Recomputed stages')).toBeTruthy() - expect(screen.getByText('Expired')).toBeTruthy() - expect(screen.getByText('Freshness unknown')).toBeTruthy() - expect(screen.getAllByText('Current')).toHaveLength(3) - expect(screen.getByText('Limited to one night')).toBeTruthy() - expect(screen.getByText('Generation 1')).toBeTruthy() -}) diff --git a/src/components/biometrics/Calibration/tests/ProfileRecovery.test.tsx b/src/components/biometrics/Calibration/tests/ProfileRecovery.test.tsx deleted file mode 100644 index c35b6f0ac..000000000 --- a/src/components/biometrics/Calibration/tests/ProfileRecovery.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { setupI18n } from '@lingui/core' -import { I18nProvider } from '@lingui/react' -import { beforeEach, expect, it, vi } from 'vitest' -import { ProfileRecovery } from '../ProfileRecovery' -const mock = vi.hoisted(() => ({ generation: 3, previousRevisionId: 'previous-id' as string | null, comparison: vi.fn(), activation: vi.fn(), warning: null as string | null, hash: 'default-hash' })) -vi.mock('@/src/utils/trpc', () => ({ trpc: { biometricTuning: { getProfileOverview: { useQuery: () => ({ data: { desired: { warning: mock.warning, hash: mock.hash, binding: { generation: mock.generation, desiredRevisionId: 'unavailable-revision', previousRevisionId: mock.previousRevisionId } } } }) } } } })) -vi.mock('../ProfileComparison', () => ({ ProfileComparison: (props: unknown) => { - mock.comparison(props) - return

Review changes

-} })) -vi.mock('../ManualProfileActivation', () => ({ ManualProfileActivation: (props: unknown) => { - mock.activation(props) - return

Explicit reset controls

-} })) -const i18n = setupI18n({ locale: 'en', messages: {} }) -beforeEach(() => { - vi.clearAllMocks() - mock.warning = null - mock.hash = 'default-hash' - mock.generation = 3 - mock.previousRevisionId = 'previous-id' -}) -const view = () => render() -it('selects defaults separately from the exact previous revision without making writes', () => { - view() - fireEvent.click(screen.getByRole('button', { name: 'Review previous profile' })) - expect(mock.comparison).toHaveBeenLastCalledWith({ side: 'left', action: 'rollback', revisionId: 'previous-id', selectionGeneration: 3 }) - fireEvent.click(screen.getByRole('button', { name: 'Review system defaults' })) - expect(mock.comparison).toHaveBeenLastCalledWith({ side: 'left', action: 'reset', revisionId: null, selectionGeneration: 3 }) -}) -it('disables rollback before any activation but allows rollback to previous defaults', () => { - mock.generation = 0 - const result = view() - expect((screen.getByRole('button', { name: 'Review previous profile' }) as HTMLButtonElement).disabled).toBe(true) - result.unmount() - mock.generation = 1 - mock.previousRevisionId = null - view() - fireEvent.click(screen.getByRole('button', { name: 'Review previous profile' })) - expect(mock.comparison).toHaveBeenLastCalledWith({ side: 'left', action: 'rollback', revisionId: null, selectionGeneration: 1 }) -}) - -it('reviews fallback defaults honestly and invalidates recovery after a concurrent generation change', () => { - mock.warning = 'Pinned engine unavailable' - const rendered = view() - fireEvent.click(screen.getByRole('button', { name: 'Review system defaults' })) - expect(screen.getByText(/a valid before-and-after comparison is unavailable/)).toBeTruthy() - expect(screen.getByText('unavailable-revision')).toBeTruthy() - expect(mock.comparison).not.toHaveBeenCalled() - expect(mock.activation).toHaveBeenLastCalledWith({ side: 'left', action: 'reset', revisionId: null, review: { generation: 3, hash: 'default-hash' } }) - mock.generation = 4 - mock.warning = null - rendered.rerender() - expect(mock.activation).toHaveBeenLastCalledWith({ side: 'left', action: 'reset', revisionId: null, review: undefined }) - expect(screen.getByText('Explicit reset controls')).toBeTruthy() -}) diff --git a/src/components/biometrics/Calibration/tests/WatchDeviationTrend.test.tsx b/src/components/biometrics/Calibration/tests/WatchDeviationTrend.test.tsx new file mode 100644 index 000000000..9a6f7f8ad --- /dev/null +++ b/src/components/biometrics/Calibration/tests/WatchDeviationTrend.test.tsx @@ -0,0 +1,149 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { setupI18n } from '@lingui/core' +import { I18nProvider } from '@lingui/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { changeMarkers, deviationScale, WatchDeviationTrend } from '../WatchDeviationTrend' + +const DAY = 24 * 60 * 60 * 1000 +const NOW = Date.UTC(2026, 9, 18, 12) +const mock = vi.hoisted(() => ({ + data: undefined as unknown, + error: null as Error | null, + input: undefined as unknown, +})) +vi.mock('@/src/utils/trpc', () => ({ + trpc: { + biometrics: { + compareToWatch: { + useQuery: (input: unknown) => { + mock.input = input + return { data: mock.data, error: mock.error } + }, + }, + }, + }, +})) +vi.mock('@/src/hooks/useSideNames', () => ({ useSideNames: () => ({ sideName: (side: string) => side === 'left' ? 'Alex' : 'Sam' }) })) +vi.mock('../../WatchUpload', () => ({ WatchUpload: ({ name }: { name: string }) => })) + +const i18n = setupI18n({ locale: 'en', messages: {} }) +const view = () => + +const none = { n: 0, watchSamples: 0, coverage: null, mae: null, bias: null, p90: null, r: null } +const dev = (mae: number | null, bias: number | null) => mae === null ? none : { n: 10, watchSamples: 12, coverage: 0.83, mae, bias, p90: mae + 1, r: 0.5 } +const stages = (kappa: number | null) => ({ epochs: 90, accuracy: 0.6, kappa, recall: { wake: null, light: 0.7, deep: 0.4, rem: 0.3 }, + podMinutes: { wake: 30, light: 250, deep: 80, rem: 90 }, watchMinutes: { wake: 20, light: 240, deep: 90, rem: 100 } }) +/** A night entered `daysAgo` days before NOW at 23:00 UTC the evening before. */ +function night(id: number, daysAgo: number, hr: [number | null, number | null], kappa: number | null = 0.4) { + const enteredBedAt = new Date(NOW - daysAgo * DAY - 13 * 60 * 60 * 1000) + return { sleepRecordId: id, enteredBedAt, leftBedAt: new Date(enteredBedAt.getTime() + 8 * 3_600_000), hours: 8, + heartRate: dev(...hr), breathingRate: dev(1.2, 0.8), hrv: dev(18, -12), stages: kappa === null ? null : stages(kappa) } +} +function report(nights: ReturnType[], configChanges: Array<{ generation: number, atMs: number }> = []) { + return { side: 'left', startDate: new Date(NOW - 31 * DAY), endDate: new Date(NOW), minHours: 3, + classifier: { isDefault: true, source: 'default', generation: null, stageHash: 'h' }, + hrvNote: 'HRV deviation is not like-for-like yet: SDNN vs an IBI index.', nights, summary: {}, configChanges } +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + mock.data = undefined + mock.error = null +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +it('asks for the last 31 days and renders nothing while they load', () => { + const { container } = render(view()) + expect(container.textContent).toBe('') + expect(mock.input).toEqual({ side: 'left', startDate: new Date(NOW - 31 * DAY), endDate: new Date(NOW) }) +}) + +it('shows only an upload hint when no night pairs with the Watch', () => { + const unpaired = { ...night(1, 2, [null, null], null), breathingRate: none, hrv: none } + mock.data = report([unpaired]) + render(view()) + expect(screen.queryByText('Deviation from Apple Watch')).toBeNull() + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByText(/Upload Apple Watch data to see how far/)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Upload Apple Watch data for Alex' })).toBeTruthy() + + cleanup() + mock.data = report([]) + render(view()) + expect(screen.queryByText('Deviation from Apple Watch')).toBeNull() + expect(screen.getByRole('button', { name: 'Upload Apple Watch data for Alex' })).toBeTruthy() +}) + +it('shows a chart per metric with MAE, bias, a zero line and the HRV caveat', () => { + mock.data = report([night(1, 3, [4, 3.5]), night(2, 2, [3, 2]), night(3, 1, [1.5, -0.5], 0.62)]) + render(view()) + expect(screen.getByText('Deviation from Apple Watch')).toBeTruthy() + const charts = screen.getAllByRole('img') + expect(charts.map(c => c.getAttribute('aria-label')?.split(':')[0])).toEqual(['Heart rate', 'Breathing rate', 'HRV', 'Sleep stages']) + const hr = screen.getByRole('region', { name: 'Heart rate' }) + expect(within(hr).getByText('latest MAE 1.5, bias −0.5 bpm')).toBeTruthy() + // MAE and bias as two lines, plus dots, over guides with zero drawn strong. + const lines = [...hr.querySelectorAll('path')].filter(p => p.getAttribute('stroke-width') === '2') + expect(lines.map(p => p.getAttribute('stroke'))).toEqual(['var(--chart-1)', 'var(--chart-2)']) + const zero = [...hr.querySelectorAll('line')].filter(l => l.getAttribute('stroke') === 'var(--text-3)') + expect(zero).toHaveLength(1) + expect(within(screen.getByRole('region', { name: 'HRV' })).getByText(/not like-for-like yet/)).toBeTruthy() + expect(within(screen.getByRole('region', { name: 'Sleep stages' })).getByText('latest 0.62')).toBeTruthy() + expect(screen.getByText('Oct 15')).toBeTruthy() +}) + +it('leaves a gap for a night without data instead of drawing zero', () => { + mock.data = report([night(1, 4, [4, 3]), night(2, 3, [3, 2]), night(3, 2, [null, null]), night(4, 1, [2, 1])]) + render(view()) + const hr = screen.getByRole('region', { name: 'Heart rate' }) + const mae = [...hr.querySelectorAll('path')].find(p => p.getAttribute('stroke') === 'var(--chart-1)' && p.getAttribute('stroke-width') === '2') + // Two runs (nights 1–2 and night 4), each starting with a move; night 3 is skipped, not drawn at 0. + expect(mae?.getAttribute('d')?.match(/M/g)).toHaveLength(2) + expect(mae?.getAttribute('d')?.match(/L/g)).toHaveLength(1) + // The lone point after the gap still shows as a dot. + const dots = within(hr).getAllByTestId('chart-dots')[0] + expect(dots.getAttribute('d')?.match(/h0/g)).toHaveLength(3) + fireEvent.click(screen.getByText('Nights as a table')) + const row = screen.getAllByRole('row')[3] + expect(within(row).getAllByRole('cell')[0].textContent).toBe('— / —') +}) + +it('marks profile changes between the nights they fall between', () => { + const nights = [night(1, 4, [4, 3]), night(2, 3, [3, 2]), night(3, 2, [2, 1]), night(4, 1, [1, 0.5])] + const between = (a: number, b: number) => (nights[a].enteredBedAt.getTime() + nights[b].enteredBedAt.getTime()) / 2 + mock.data = report(nights, [ + { generation: 1, atMs: nights[0].enteredBedAt.getTime() - DAY }, + { generation: 2, atMs: between(0, 1) }, + { generation: 3, atMs: between(2, 3) }, + { generation: 4, atMs: between(2, 3) + 1000 }, + ]) + render(view()) + const hr = screen.getByRole('region', { name: 'Heart rate' }) + expect(within(hr).getAllByTestId('chart-marker').map(m => m.textContent)).toEqual(['gen 2', 'gen 3, 4']) + expect(screen.getAllByTestId('chart-marker')).toHaveLength(8) +}) + +it('narrows to the chosen window and says so when it has no Watch pairs', () => { + const paired = night(1, 20, [4, 3]) + const unpaired = { ...night(2, 1, [null, null], null), breathingRate: none, hrv: none } + mock.data = report([paired, unpaired]) + render(view()) + expect(screen.getByText('No night in the last 14 days pairs with Watch data. Pick a longer window.')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: '31 d' })) + expect(screen.getAllByRole('img')).toHaveLength(4) +}) + +it('scales to include zero in about three steps', () => { + expect(deviationScale([1.2, 3.8, -0.4])).toMatchObject({ min: -2, max: 4 }) + expect(deviationScale([Number.NaN, 0])).toMatchObject({ min: 0, max: 1 }) + expect(deviationScale([12, 26]).guides.map(g => [g.value, g.strong])).toEqual([[0, true], [10, false], [20, false], [30, false]]) +}) + +it('drops changes before the first or after the last night', () => { + const nights = [{ enteredBedAt: new Date(1000) }, { enteredBedAt: new Date(2000) }] + expect(changeMarkers(nights, [{ generation: 1, atMs: 500 }, { generation: 2, atMs: 1500 }, { generation: 3, atMs: 2500 }])).toEqual([{ at: 0.5, label: 'gen 2' }]) +}) diff --git a/src/components/biometrics/Calibration/tests/calibration-model.test.ts b/src/components/biometrics/Calibration/tests/calibration-model.test.ts new file mode 100644 index 000000000..5247960ab --- /dev/null +++ b/src/components/biometrics/Calibration/tests/calibration-model.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest' +import { defaultConfig, type BiometricConfig } from '@/src/biometrics/algorithms/registry-core' +import { + activityTime, + actorOf, + aggregate, + barHeight, + baselineLabel, + changedPaths, + clockLabel, + compareTone, + coverage, + draftFrom, + lifecycle, + nightLabel, + nightScores, + overridesFor, + paramDiffs, + roundParam, + runStatus, + savedBacktest, + shortDuration, + stageOverrides, + tracePath, + traceTicks, + type JobLike, +} from '../calibration-model' +import type { NumericReportView } from '../report-view' + +const withConfig = (patch: (c: BiometricConfig) => void) => { + const c = structuredClone(defaultConfig) as BiometricConfig + patch(c) + return c +} +const job = (over: Partial): JobLike => ({ + id: 'j', experimentId: 'e', datasetId: 'd', split: 'tuning', state: 'succeeded', createdAtMs: 0, updatedAtMs: 1000, + configurations: [{ label: 'default', revisionId: null, hash: 'h-default' }, { label: 'current', revisionId: null, hash: 'h-current' }, { label: 'candidate', revisionId: 'saved', hash: 'h-saved' }], + evaluationScope: 'retained_waveform_replay', ...over, +}) + +describe('draft and overrides', () => { + it('reads the five tunable values and detects edits', () => { + const draft = draftFrom(defaultConfig) + expect(draft['heart_rate.trackingToleranceBpm']).toBe(15) + expect(changedPaths(draft, defaultConfig)).toEqual([]) + expect(changedPaths({ ...draft, 'hrv_index.minimumWindows': 12 }, defaultConfig)).toEqual(['hrv_index.minimumWindows']) + }) + + it('trims slider float noise to the parameter precision', () => { + expect(roundParam('breathing_rate.intervalLowerRatio', 0.1 + 0.2 + 0.15000000000000002)).toBe(0.45) + expect(roundParam('heart_rate.trackingHistoryLength', 4.6)).toBe(5) + }) + + it('keeps unrelated overrides, replaces edited ones and drops values back at default', () => { + const saved = [ + { path: 'sleep_stages.ruleset.rules.0.when.0.value', value: 240 }, + { path: 'heart_rate.trackingToleranceBpm', value: 12 }, + ] + const draft = { ...draftFrom(defaultConfig), 'heart_rate.trackingToleranceBpm': 15, 'hrv_index.minimumWindows': 14 } + expect(overridesFor(saved, draft, defaultConfig)).toEqual([ + { path: 'sleep_stages.ruleset.rules.0.when.0.value', value: 240 }, + { path: 'hrv_index.minimumWindows', value: 14 }, + ]) + }) + + it('describes numeric changes as key old → new', () => { + const after = withConfig((c) => { + c.heart_rate.trackingToleranceBpm = 12 + }) + expect(paramDiffs(defaultConfig, after)).toEqual(['trackingToleranceBpm 15 → 12']) + }) +}) + +describe('actors and time', () => { + it('classifies web, agent and pod requesters', () => { + expect(actorOf('web-calibration')).toEqual({ kind: 'you', name: 'you' }) + expect(actorOf('tuner', 'agent')).toEqual({ kind: 'agent', name: 'tuner' }) + expect(actorOf('tuner')).toEqual({ kind: 'agent', name: 'tuner' }) + expect(actorOf('anything', 'migration').kind).toBe('pod') + expect(actorOf('someone', 'manual').kind).toBe('you') + }) + + it('formats durations and relative times', () => { + expect(shortDuration(38_000)).toBe('38 s') + expect(shortDuration(14 * 60_000)).toBe('14 min') + expect(shortDuration(3 * 3_600_000)).toBe('3 h') + expect(shortDuration(3 * 86_400_000)).toBe('3 d') + expect(activityTime(1000, 1000 + 16 * 60_000, 'UTC', false)).toBe('16 min ago') + expect(activityTime(1000, 1000 + 10_000, 'UTC', false)).toBe('just now') + expect(activityTime(Date.UTC(2026, 9, 9, 23, 43), Date.UTC(2026, 9, 12), 'UTC', false)).toBe('Oct 9 · 11:43 PM') + expect(activityTime(Date.UTC(2026, 9, 9, 23, 43), Date.UTC(2026, 9, 12), 'UTC', true)).toBe('Oct 9 · 23:43') + }) + + it('labels clock ticks compactly in the device timezone', () => { + expect(clockLabel(Date.UTC(2026, 9, 12, 6, 15), 'America/Los_Angeles', false)).toBe('11:15p') + expect(clockLabel(Date.UTC(2026, 9, 12, 8, 0), 'America/Los_Angeles', false)).toBe('1a') + expect(clockLabel(Date.UTC(2026, 9, 12, 8, 0), 'America/Los_Angeles', true)).toBe('01:00') + expect(clockLabel(Date.UTC(2026, 9, 12, 8, 0), 'Not/AZone', true)).toBe('08:00') + }) +}) + +describe('lifecycle', () => { + const now = 10 * 3_600_000 + const base = { nowMs: now, bindingUpdatedAtMs: now - 60_000, generation: 3, runtimeReported: true } + + it('shows a pending request with the processor still on the previous profile', () => { + const strip = lifecycle({ ...base, saved: { actor: { kind: 'agent', name: 'tuner' }, atMs: now - 16 * 60_000 }, + request: { observedState: 'pending_boundary', createdAtMs: now - 14 * 60_000, updatedAtMs: now, previousIsDefaults: false } }) + expect(strip.saved).toEqual({ tone: 'ok', value: 'by tuner · 16 min ago' }) + expect(strip.requested).toEqual({ tone: 'warn', value: 'waiting 14 min' }) + expect(strip.processor).toEqual({ tone: 'ok', value: 'running previous' }) + }) + + it('names system defaults when the previous profile was the shipped one', () => { + const strip = lifecycle({ ...base, saved: { actor: { kind: 'you', name: 'you' }, atMs: now }, + request: { observedState: 'pending_delivery', createdAtMs: now, updatedAtMs: now, previousIsDefaults: true } }) + expect(strip.processor.value).toBe('running system defaults') + }) + + it('reports acknowledgement and failure', () => { + const acked = lifecycle({ ...base, saved: null, request: { observedState: 'applied', createdAtMs: now - 120_000, updatedAtMs: now - 60_000, previousIsDefaults: false } }) + expect(acked.requested).toEqual({ tone: 'ok', value: 'acknowledged 1 min ago' }) + expect(acked.processor.value).toBe('running saved') + expect(acked.saved.value).toBe('system defaults · 1 min ago') + const failed = lifecycle({ ...base, saved: null, request: { observedState: 'failed', createdAtMs: now, updatedAtMs: now, previousIsDefaults: false } }) + expect(failed.requested.tone).toBe('danger') + expect(failed.processor.tone).toBe('danger') + }) + + it('does not claim a running profile the processor never reported', () => { + const fresh = lifecycle({ ...base, generation: 0, runtimeReported: false, saved: null, request: null }) + expect(fresh.saved.value).toBe('system defaults') + expect(fresh.requested).toEqual({ tone: 'muted', value: 'none' }) + expect(fresh.processor).toEqual({ tone: 'muted', value: 'not reported' }) + expect(lifecycle({ ...base, saved: null, request: null }).processor.value).toBe('running system defaults') + }) +}) + +describe('sleep-stage overrides', () => { + it('pairs changed condition values rule by rule', () => { + const saved = withConfig((c) => { + if (c.sleep_stages.engine === 'rules') c.sleep_stages.ruleset.rules[0].when[0].value = 240 + }) + const result = stageOverrides(defaultConfig.sleep_stages, saved.sleep_stages) + expect(result.ruleCount).toBe(5) + expect(result.restructured).toBe(false) + expect(result.overrides).toEqual([{ rule: 1, stage: 'wake', metric: 'movement', op: '>', before: 200, after: 240 }]) + }) + + it('flags a reshaped rule list instead of guessing pairs', () => { + const saved = withConfig((c) => { + if (c.sleep_stages.engine === 'rules') { + c.sleep_stages.ruleset.rules.pop() + c.sleep_stages.ruleset.fallback = 'deep' + } + }) + const result = stageOverrides(defaultConfig.sleep_stages, saved.sleep_stages) + expect(result).toMatchObject({ ruleCount: 4, restructured: true, fallbackChanged: true, overrides: [] }) + }) +}) + +describe('backtests', () => { + it('groups the latest runs of the saved revision by split on one dataset', () => { + const jobs = [ + job({ id: 'old-tuning', split: 'tuning', createdAtMs: 1 }), + job({ id: 'tuning', split: 'tuning', createdAtMs: 5 }), + job({ id: 'holdout', split: 'holdout', createdAtMs: 4 }), + job({ id: 'failed-retry', split: 'holdout', createdAtMs: 6, state: 'failed' }), + job({ id: 'other-dataset', datasetId: 'x', split: 'validation', createdAtMs: 2 }), + job({ id: 'other-revision', split: 'validation', createdAtMs: 9, configurations: [{ label: 'candidate', revisionId: 'other', hash: 'z' }] }), + ] + const group = savedBacktest(jobs, 'saved') + expect(group?.latest.id).toBe('failed-retry') + expect(group?.bySplit.tuning?.id).toBe('tuning') + expect(group?.bySplit.holdout?.id).toBe('holdout') + expect(group?.bySplit.validation).toBeUndefined() + expect(savedBacktest(jobs, null)).toBeNull() + expect(savedBacktest(jobs, 'missing')).toBeNull() + }) + it('stands in a run that resolves to the saved configuration when the saved profile is system defaults', () => { + const jobs = [job({ id: 'defaults-run', split: 'tuning', createdAtMs: 3, configurations: [{ label: 'candidate', revisionId: 'unrequested', hash: 'defaults' }] })] + expect(savedBacktest(jobs, null, 'defaults')?.latest.id).toBe('defaults-run') + expect(savedBacktest(jobs, null, 'other')).toBeNull() + expect(savedBacktest(jobs, 'saved', 'defaults')).toBeNull() + }) + + it('summarizes run state, preferring in-flight work', () => { + expect(runStatus([])).toBeNull() + expect(runStatus([job({ createdAtMs: 0, updatedAtMs: 38_000 })])).toEqual({ tone: 'ok', kind: 'done', durationMs: 38_000 }) + expect(runStatus([job({}), job({ state: 'running' })])?.kind).toBe('running') + expect(runStatus([job({}), job({ state: 'queued' })])?.kind).toBe('queued') + expect(runStatus([job({}), job({ state: 'failed' })])?.tone).toBe('danger') + }) + + it('matches the previous profile by hash, else the runtime baseline', () => { + expect(baselineLabel(job({}), 'h-default')).toBe('default') + expect(baselineLabel(job({}), 'unknown')).toBe('current') + expect(baselineLabel(job({ configurations: [{ label: 'default', revisionId: null, hash: 'a' }] }), null)).toBe('default') + }) + + const window = (metric: 'heart_rate' | 'breathing_rate' | 'window_ibi_variability_v1', mae: number | null, paired: number, cover = 0.9) => ({ + metric, status: mae === null ? 'no_reference' as const : 'compared' as const, + metrics: mae === null ? null : { mae, medianAbsoluteError: mae, p95AbsoluteError: mae, signedBias: 0 }, + pairedCount: paired, referenceCoverage: 1, outputCoverage: cover, unmatchedReferences: 0, unmatchedObservations: 0, exclusions: {}, + }) + const report: NumericReportView = { nights: [ + { cohortId: '2026-10-11', evaluations: [ + { label: 'current', configHash: 'h-current', windows: [{ startMs: 0, endMs: 1, observationCount: 10, numeric: [window('heart_rate', 3, 10, 0.8), window('breathing_rate', 1, 5)] }] }, + { label: 'candidate', configHash: 'h-saved', windows: [ + { startMs: 0, endMs: 1, observationCount: 10, numeric: [window('heart_rate', 2, 10, 0.9)] }, + { startMs: 1, endMs: 2, observationCount: 30, numeric: [window('heart_rate', 4, 30, 0.5)] }, + ] }, + ] }, + { cohortId: '2026-10-12', evaluations: [ + { label: 'current', configHash: 'h-current', windows: [{ startMs: 0, endMs: 1, observationCount: 10, numeric: [window('heart_rate', null, 0)] }] }, + { label: 'candidate', configHash: 'h-saved', windows: [{ startMs: 0, endMs: 1, observationCount: 10, numeric: [window('heart_rate', null, 0)] }] }, + ] }, + ] } + + it('weights per-night error by paired references and excludes nights without them', () => { + const nights = nightScores(report, 'holdout', 'current') + expect(nights[0].saved.heart_rate.mae).toBeCloseTo(3.5) + expect(nights[0].saved.heart_rate.coverage).toBeCloseTo((0.9 * 10 + 0.5 * 30) / 40) + expect(nights[0].previous.breathing_rate.mae).toBe(1) + expect(nights[0].hasReference).toBe(true) + expect(nights[1].hasReference).toBe(false) + expect(aggregate(nights, 'heart_rate', 'holdout')).toEqual({ previous: 3, saved: 3.5, nights: 1 }) + expect(aggregate(nights, 'heart_rate', 'tuning').nights).toBe(0) + expect(coverage(nights).previous).toBeCloseTo(0.85) + }) + + it('colours only real improvements green and any regression amber', () => { + expect(compareTone(3, 2.9)).toBe('better') + expect(compareTone(3, 2.97)).toBe('same') + expect(compareTone(3, 3.01)).toBe('worse') + expect(compareTone(0.9, 0.95, true)).toBe('better') + expect(compareTone(0.9, 0.903, true)).toBe('same') + expect(compareTone(3, 2.9, false, 0.2)).toBe('same') + expect(compareTone(0.9, 0.8, true)).toBe('worse') + expect(compareTone(null, 1)).toBe('same') + }) + + it('scales bars 0–5 bpm onto 72 px and clamps', () => { + expect(barHeight(null)).toBe(0) + expect(barHeight(2.5)).toBe(36) + expect(barHeight(9)).toBe(72) + expect(barHeight(-1)).toBe(0) + }) +}) + +describe('nights and traces', () => { + it('labels a night from its calendar date', () => { + expect(nightLabel('2026-10-11')).toEqual({ weekday: 'Sun', day: '11' }) + expect(nightLabel('cohort-x')).toEqual({ weekday: 'cohort-x', day: '' }) + }) + + it('draws gaps instead of bridging missing readings', () => { + const d = tracePath([{ t: 0, v: 70 }, { t: 60_000, v: 70 }, { t: 120_000, v: null }, { t: 180_000, v: 100 }, { t: 3_600_000, v: 40 }], 0, 3_600_000) + expect(d.match(/M/g)).toHaveLength(3) + expect(d.startsWith('M0.0 75.0 L')).toBe(true) + expect(d).toContain('364.0 150.0') + }) + + it('ticks session start, every two hours, and session end', () => { + const start = Date.UTC(2026, 9, 12, 6, 15), end = Date.UTC(2026, 9, 12, 14, 0) + const ticks = traceTicks(start, end) + expect(ticks[0]).toBe(start) + expect(ticks[ticks.length - 1]).toBe(end) + expect(ticks.slice(1, -1).map(t => new Date(t).getUTCHours())).toEqual([8, 10, 12]) + }) +}) diff --git a/src/components/biometrics/Calibration/tests/evaluation-setup.test.ts b/src/components/biometrics/Calibration/tests/evaluation-setup.test.ts new file mode 100644 index 000000000..da66731fd --- /dev/null +++ b/src/components/biometrics/Calibration/tests/evaluation-setup.test.ts @@ -0,0 +1,100 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { chooseSessions, nightKey, nightsTouched, useEvaluationSetup, WAVEFORM_PROTOCOL } from '../evaluation-setup' + +const H = 3600_000 +const night = (day: number) => Date.UTC(2026, 9, day, 6) // 11 PM Pacific +interface Call { query: ReturnType, mutate: ReturnType } +const holder = vi.hoisted(() => ({ api: {} as Record })) +const invalidate = vi.hoisted(() => vi.fn()) +vi.mock('@/src/utils/trpc', () => ({ trpc: { useUtils: () => ({ client: { biometricTuning: holder.api }, biometricTuning: { invalidate } }) } })) + +const source = (name: string, first: number, last: number, ordered = true) => ({ id: `id-${name}`, unavailableAtMs: null, + inspection: { source_name: name, inventory: { first_event_ms: first, last_event_ms: last, ordered_waveform_available: ordered } } }) +const cohort = (day: number, split: string) => ({ cohortId: `2026-10-${day}`, startMs: night(day), endMs: night(day) + 8 * H, split, nightLabels: [], sessionIds: [day] }) + +beforeEach(() => { + vi.clearAllMocks() + const q = (fn: (input: never) => unknown): Call => ({ query: vi.fn(async (input: never) => fn(input)), mutate: vi.fn() }) + const m = (fn: (input: never) => unknown): Call => ({ query: vi.fn(), mutate: vi.fn(async (input: never) => fn(input)) }) + holder.api = { + countLegacyReferences: q(() => ({ vitals: 1597, stages: 0 })), + linkLegacyReferences: m(() => ({ vitals: 1597, stages: 0, imports: 1 })), + listImports: q(() => ({ items: [ + { id: 'ref', available: true, summary: { subjectId: 'legacy_unknown:left', startMs: night(9), endMs: night(14) } }, + { id: 'stale', available: false, summary: { subjectId: 'legacy_unknown:left', startMs: 0, endMs: 1 } }, + ], nextCursor: null })), + listSessions: q(() => ({ items: [11, 12, 13].map(day => ({ id: day, startMs: night(day), endMs: night(day) + 8 * H, sleepDurationSeconds: 8 * 3600 })), nextCursor: null })), + locateRawArchives: q(({ startMs }: { startMs: number }) => startMs < night(12) - H ? [] : [{ name: startMs < night(13) - H ? 'known' : 'fresh', afterName: 'prev.RAW.gz' }]), + createDataset: m(() => ({ id: 'ds', contentHash: 'c'.repeat(64), manifest: { splitProtocol: { assignments: [cohort(12, 'tuning'), cohort(13, 'holdout')] } } })), + listRawSources: q(({ startMs }: { startMs: number }) => ({ items: startMs < night(13) - H ? [source('known', night(12) - 10 * 60_000, night(12) + 9 * H)] : [], nextCursor: null })), + inspectRawSource: m(() => ({ status: 'inspected', nextCursor: 'x', source: source('fresh', night(13) - 4 * 60_000, night(13) + 9 * H) })), + createWaveformDataset: m(() => ({ status: 'published', dataset: { id: 'wave', manifest: { splitProtocol: { assignments: [cohort(12, 'tuning'), cohort(13, 'holdout')] } } } })), + createExperiment: m(() => ({ id: 'exp' })), + createProfileRevision: m(() => ({ id: 'defaults-rev' })), + enqueueBacktest: m(() => ({ id: 'job' })), + } +}) + +it('groups sessions after midnight with the evening before, in the device timezone', () => { + expect(nightKey(Date.UTC(2026, 9, 12, 9), 'America/Los_Angeles')).toBe('2026-10-11') + expect(nightKey(Date.UTC(2026, 9, 12, 9), 'Not/AZone')).toBe('2026-10-11') +}) + +it('chooses one session per noon-to-noon night, newest nights first and the main sleep over a nap', () => { + const tz = 'America/Los_Angeles' + const sessions = [ + { id: 1, startMs: night(10), endMs: night(10) + 8 * H, sleepDurationSeconds: 28800 }, + { id: 2, startMs: night(11), endMs: night(11) + 8 * H, sleepDurationSeconds: 28800 }, + { id: 3, startMs: night(11) + 9 * H, endMs: night(11) + 10 * H, sleepDurationSeconds: 3600 }, + { id: 4, startMs: night(12), endMs: night(12) + 1 * H, sleepDurationSeconds: 600 }, + { id: 5, startMs: night(13), endMs: night(13) + 8 * H, sleepDurationSeconds: 28800 }, + ] + expect(chooseSessions(sessions, [{ startMs: night(11), endMs: night(14) }], 2, tz).map(s => s.id)).toEqual([2, 5]) + expect(chooseSessions(sessions, [], 2, 'UTC')).toEqual([]) + // Sleeping past noon touches the next night, which then belongs to that session alone. + const late = [ + { id: 6, startMs: night(10), endMs: night(10) + 14 * H, sleepDurationSeconds: 50400 }, + { id: 7, startMs: night(11), endMs: night(11) + 8 * H, sleepDurationSeconds: 28800 }, + { id: 8, startMs: night(12), endMs: night(12) + 16 * H, sleepDurationSeconds: 57600 }, + ] + expect([...nightsTouched(night(10), night(10) + 14 * H, tz)]).toEqual(['2026-10-09', '2026-10-10']) + expect(chooseSessions(late, [{ startMs: 0, endMs: night(14) }], 7, tz).map(s => s.id)).toEqual([7]) +}) + +it('links, freezes referenced nights with archives, inspects only new archives, retains, and queues every split', async () => { + const { result } = renderHook(() => useEvaluationSetup({ side: 'left', timezone: 'America/Los_Angeles', savedRevisionId: null, generation: 0 })) + act(() => result.current.run()) + await waitFor(() => expect(result.current.progress.step).toBe('done')) + expect(holder.api.linkLegacyReferences.mutate).toHaveBeenCalledWith({ side: 'left' }) + const dataset = holder.api.createDataset.mutate.mock.calls[0][0] + expect(dataset).toMatchObject({ side: 'left', subjectId: 'legacy_unknown:left', sessionIds: [12, 13], referenceImportIds: ['ref'] }) + expect(holder.api.inspectRawSource.mutate).toHaveBeenCalledTimes(1) + expect(holder.api.createWaveformDataset.mutate.mock.calls[0][0]).toMatchObject({ parentDatasetId: 'ds', + cohorts: [{ cohortId: '2026-10-12', sourceIds: ['id-known'] }, { cohortId: '2026-10-13', sourceIds: ['id-fresh'] }] }) + expect(holder.api.createExperiment.mutate.mock.calls[0][0]).toMatchObject({ datasetId: 'wave', protocol: WAVEFORM_PROTOCOL }) + expect(holder.api.createProfileRevision.mutate.mock.calls[0][0]).toMatchObject({ side: 'left', overrides: [] }) + expect(holder.api.enqueueBacktest.mutate.mock.calls.map(([input]) => [input.split, input.candidateRevisionId, input.expectedBindingGeneration])) + .toEqual([['tuning', 'defaults-rev', 0], ['holdout', 'defaults-rev', 0]]) + expect(invalidate).toHaveBeenCalled() +}) + +it('waits out deferred archive work and reports what is missing instead of half-freezing', async () => { + let calls = 0 + holder.api.inspectRawSource.mutate = vi.fn(async () => (++calls === 1 ? { status: 'deferred', nextCursor: null } : { status: 'source_unavailable', nextCursor: 'x' })) + vi.useFakeTimers({ shouldAdvanceTime: true }) + const { result } = renderHook(() => useEvaluationSetup({ side: 'left', timezone: 'UTC', savedRevisionId: 'saved', generation: 3 })) + act(() => result.current.run()) + await act(async () => { + await vi.advanceTimersByTimeAsync(6000) + }) + vi.useRealTimers() + await waitFor(() => expect(result.current.progress.step).toBe('failed')) + expect(calls).toBe(2) + expect(result.current.progress.error).toMatch(/no readable RAW archive/) + expect(holder.api.createWaveformDataset.mutate).not.toHaveBeenCalled() + + holder.api.listImports.query = vi.fn(async () => ({ items: [], nextCursor: null })) + act(() => result.current.run()) + await waitFor(() => expect(result.current.progress.error).toBe('Import a reference for this side first.')) +}) 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/WatchUpload.tsx b/src/components/biometrics/WatchUpload.tsx index 1375275c7..02e020b24 100644 --- a/src/components/biometrics/WatchUpload.tsx +++ b/src/components/biometrics/WatchUpload.tsx @@ -6,7 +6,7 @@ import { Button, Modal } from '@/src/components/ds' import { trpc } from '@/src/utils/trpc' import { WATCH_REQUEST_TIMEOUT_MS } from '@/src/utils/watchUploadLimits' -interface ImportResult { stages: number, vitals: number, rejectedStageBatches: number, matchedStages: number, matchedVitals: number } +interface ImportResult { stages: number, vitals: number, breathing: number, hrv: number, rejectedStageBatches: number, matchedStages: number, matchedVitals: number, matchedBreathing: number, matchedHrv: number } export function WatchUpload({ side, name }: { side: 'left' | 'right', name: string }) { const [open, setOpen] = useState(false) @@ -75,7 +75,7 @@ export function WatchUpload({ side, name }: { side: 'left' | 'right', name: stri

What this does

-

Imports Apple Watch heart rate and sleep stages for the selected period. Watch heart rate appears alongside the pod’s readings in Vitals so you can compare them; sleep stages are saved as reference data.

+

Imports Apple Watch heart rate, breathing rate, HRV and sleep stages for the selected period. Watch heart rate appears alongside the pod’s readings in Vitals so you can compare them; breathing rate, HRV and sleep stages are saved as reference data.

This is a one-time import. Upload a fresh export whenever you want to add recent data. Previously imported records are skipped, and the pod’s own readings are preserved.

@@ -88,7 +88,7 @@ export function WatchUpload({ side, name }: { side: 'left' | 'right', name: stri Apple’s export guide
-

The full export is sent to your pod, which imports only Watch heart rate and sleep stages and removes the uploaded file after processing. ZIP or extracted export.xml, up to 512 MiB.

+

The full export is sent to your pod, which imports only Watch heart rate, breathing rate, HRV and sleep stages and removes the uploaded file after processing. ZIP or extracted export.xml, up to 512 MiB.

Health export {/* Hidden native picker; the Choose file button opens it and is the keyboard and screen-reader target. */} @@ -122,9 +122,9 @@ export function WatchUpload({ side, name }: { side: 'left' | 'right', name: stri {progress !== null &&

{progress < 100 ? `Uploading… ${progress}%` : 'Importing on the pod… Keep this window open.'}

} {result && (
-

{`Imported ${result.vitals} new heart-rate samples and ${result.stages} new sleep-stage segments.`}

-

{`Found ${result.matchedVitals} heart-rate samples and ${result.matchedStages} stages in this period. Previously imported records are skipped.`}

- {result.rejectedStageBatches > 0 &&

{`${result.rejectedStageBatches} stage batches conflicted with stored stages and were skipped. Heart-rate import completed.`}

} +

{`Imported ${result.vitals} new heart-rate, ${result.breathing} new breathing-rate and ${result.hrv} new HRV samples, and ${result.stages} new sleep-stage segments.`}

+

{`Found ${result.matchedVitals} heart-rate, ${result.matchedBreathing} breathing-rate and ${result.matchedHrv} HRV samples and ${result.matchedStages} stages in this period. Previously imported records are skipped.`}

+ {result.rejectedStageBatches > 0 &&

{`${result.rejectedStageBatches} stage batches conflicted with stored stages and were skipped. Heart-rate, breathing-rate and HRV import completed.`}

}
)} {error &&

{error}

} diff --git a/src/components/biometrics/tests/BiometricsPage.test.tsx b/src/components/biometrics/tests/BiometricsPage.test.tsx index e15cb5d51..a72cac26d 100644 --- a/src/components/biometrics/tests/BiometricsPage.test.tsx +++ b/src/components/biometrics/tests/BiometricsPage.test.tsx @@ -20,6 +20,7 @@ const state = vi.hoisted(() => ({ timeFormat: '12h' as '12h' | '24h', shown: ['left', 'right'] as Array<'left' | 'right'>, reference: [] as Array<{ timestamp: Date, heartRate: number }>, + referenceHrv: [] as Array<{ timestamp: Date, value: number }>, referenceOpts: undefined as Record | undefined, })) @@ -55,6 +56,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: (input: { metric: string }) => ({ data: input.metric === 'hrv_sdnn' ? state.referenceHrv : [] }) }, getReferenceVitals: { useQuery: (_input: unknown, opts: Record) => { state.referenceOpts = opts @@ -82,6 +84,7 @@ beforeAll(() => { beforeEach(() => { vi.useFakeTimers({ now: NOW, toFake: ['Date'] }) + state.referenceHrv = [] state.occupied = true state.timeFormat = '12h' state.dataPath = { occupancy: { left: 'occupied', right: 'empty' } } @@ -301,3 +304,11 @@ it('clears session focus when switching people and ignores clearing the date fie fireEvent.change(screen.getByLabelText('Biometrics date'), { target: { value: '' } }) expect(state.vitals.mock.calls.at(-1)?.[0]).toEqual(input) }) + +it('explains SDNN comparability only when Watch HRV is overlaid', () => { + const view = render() + expect(screen.queryByText(/Watch SDNN and sleepypod/)).toBeNull() + state.referenceHrv = [{ timestamp: new Date(LAST_VITAL), value: 30 }] + view.rerender() + expect(screen.getByText(/Watch SDNN and sleepypod/)).toBeTruthy() +}) 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/biometrics/tests/WatchUpload.test.tsx b/src/components/biometrics/tests/WatchUpload.test.tsx index 217b4fecd..71ed84ad4 100644 --- a/src/components/biometrics/tests/WatchUpload.test.tsx +++ b/src/components/biometrics/tests/WatchUpload.test.tsx @@ -12,7 +12,7 @@ afterEach(() => { it('uploads for the named sleeper, reports partial conflicts, and refreshes charts', async () => { const xhr = { open: vi.fn(), setRequestHeader: vi.fn(), send: vi.fn(), upload: {}, timeout: 0, - status: 200, responseText: JSON.stringify({ vitals: 12, stages: 0, matchedVitals: 14, matchedStages: 3, rejectedStageBatches: 1 }), + status: 200, responseText: JSON.stringify({ vitals: 12, stages: 0, breathing: 5, hrv: 2, matchedVitals: 14, matchedStages: 3, matchedBreathing: 6, matchedHrv: 2, rejectedStageBatches: 1 }), onload: null as (() => void) | null, } vi.stubGlobal('XMLHttpRequest', function XMLHttpRequest() { @@ -32,7 +32,8 @@ it('uploads for the named sleeper, reports partial conflicts, and refreshes char xhr.onload?.() }) await waitFor(() => expect(invalidate).toHaveBeenCalled()) - expect(screen.getByText(/Imported 12 new/)).toBeTruthy() + expect(screen.getByText(/Imported 12 new heart-rate, 5 new breathing-rate and 2 new HRV samples/)).toBeTruthy() + expect(screen.getByText(/Found 14 heart-rate, 6 breathing-rate and 2 HRV samples/)).toBeTruthy() expect(screen.getByText(/stage batches conflicted/)).toBeTruthy() }) @@ -67,7 +68,7 @@ it('shows transfer progress, protects an active import, and allows closing after expect(screen.getByRole('status').textContent).toContain('Importing on the pod') fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(screen.getByRole('dialog')).toBeTruthy() - xhr.responseText = JSON.stringify({ vitals: 0, stages: 2, matchedVitals: 0, matchedStages: 2, rejectedStageBatches: 0 }) + xhr.responseText = JSON.stringify({ vitals: 0, stages: 2, breathing: 0, hrv: 0, matchedVitals: 0, matchedStages: 2, matchedBreathing: 0, matchedHrv: 0, rejectedStageBatches: 0 }) await act(async () => xhr.onload?.()) expect(screen.getByRole('status').textContent).toContain('2 new sleep-stage segments') fireEvent.click(screen.getByRole('button', { name: 'Close' })) diff --git a/src/components/diagnostics/DashboardPanel.tsx b/src/components/diagnostics/DashboardPanel.tsx index 5c3841784..f139e3349 100644 --- a/src/components/diagnostics/DashboardPanel.tsx +++ b/src/components/diagnostics/DashboardPanel.tsx @@ -12,6 +12,7 @@ import type { AppRouter } from '@/src/server/routers/app' import { trpc } from '@/src/utils/trpc' import { useSideNames } from '@/src/hooks/useSideNames' import { useShownSides } from '@/src/providers/SideProvider' +import { useSensorFrame } from '@/src/hooks/useSensorStream' import { useTemperatureUnit } from '@/src/hooks/useTemperatureUnit' import { Button, Card, HoverMark, InlineError, KeyValue, Skeleton, StatusDot, useHoverFraction } from '@/src/components/ds' import { cn } from '@/lib/utils' @@ -20,11 +21,13 @@ import { langFromPath } from '@/src/components/AppShell/navItems' import { HealthRing } from '@/src/components/status/HealthCircle' import { PumpAlertsCard } from '@/src/components/status/PumpAlertsCard' import { formatUptime, useStatusSummary } from '@/src/components/status/StatusScreen' +import { WaterTankGauge } from '@/src/components/status/WaterTankGauge' +import { waterTank } from '@/src/components/status/waterTank' import { CurveChart, CurveLegend, buildTimeline, readAt, useNowMinute } from '@/src/components/Schedule/CurveChart' import { bedTrace } from '@/src/components/Schedule/bedTrace' import { formatCountdown, nightSetPoints } from '@/src/components/Schedule/bothNight' import { fmtClock, fmtF, thermalDirection, type SchedJob } from './diagnosticsLogic' -import { attentionItems, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobLabel, tonightWindow, TONIGHT_END_H, TONIGHT_START_H } from './dashboardLogic' +import { attentionItems, disconnectedCovers, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobLabel, tonightWindow, TONIGHT_END_H, TONIGHT_START_H } from './dashboardLogic' import { nearestPoint, panelDomain, seriesPath, THERMAL_PANELS } from './thermalHistoryLogic' import type { DiagSection } from './DiagnosticsConsole' import { OccupancyCheck } from './OccupancyCheck' @@ -64,6 +67,8 @@ export function DashboardPanel({ thermal, onJump }: { thermal: ThermalData | und function StatusLine() { const s = useStatusSummary() + // Newer thermal firmware only; older pods keep the line as it was. + const tank = waterTank(useSensorFrame('frzWater')) const checks = Object.values(s.groups).flat() const failing = checks.filter(c => c.status !== 'ok') const commit = s.version && s.version.commitHash !== 'unknown' ? s.version.commitHash.slice(0, 7) : null @@ -121,6 +126,7 @@ function StatusLine() { : , title: s.firewall && !s.firewall.ok ? `Missing firewall rules: ${s.firewall.missing.join(', ')}` : undefined, }, + ...(tank ? [{ label: 'Water', value: }] : []), { label: 'Disk', value: s.diskPercent !== undefined @@ -200,6 +206,7 @@ function AttentionCard() { const water = trpc.waterLevel.getLatest.useQuery({}, { refetchInterval: 30_000 }) const device = trpc.device.getStatus.useQuery({}, { refetchInterval: 10_000 }) const dataPath = trpc.health.dataPath.useQuery({}, { refetchInterval: 60_000 }) + const sensHealth = useSensorFrame('sensHealth') const enableGuard = trpc.settings.updateDevice.useMutation({ onSuccess: () => { void utils.health.maintenance.invalidate() @@ -214,7 +221,7 @@ function AttentionCard() { if (nowMinute == null) return null const occupancy = dataPath.data?.occupancy const suspectSides = occupancy ? shown.filter(s => occupancy[s] === 'suspect') : [] - const items = attentionItems(maintenance.data, water.data?.level ?? device.data?.waterLevel, nowMinute * 60_000, suspectSides) + const items = attentionItems(maintenance.data, water.data?.level ?? device.data?.waterLevel, nowMinute * 60_000, suspectSides, disconnectedCovers(sensHealth)) if (items.length === 0) return null const priming = device.data?.isPriming ?? false diff --git a/src/components/diagnostics/dashboardLogic.ts b/src/components/diagnostics/dashboardLogic.ts index 6325afc38..32f83b92f 100644 --- a/src/components/diagnostics/dashboardLogic.ts +++ b/src/components/diagnostics/dashboardLogic.ts @@ -1,3 +1,4 @@ +import type { SensHealthFrame } from '@/src/hooks/useSensorStream' import { DAYS_OF_WEEK, type DayOfWeek } from '@/src/lib/scheduleTime' import type { SchedJob } from './diagnosticsLogic' @@ -58,7 +59,7 @@ export function nextTemperatureJob(jobs: SchedJob[] | undefined, now: number): S } export interface AttentionItem { - id: 'pump-stall' | 'prime' | 'water' | 'occupancy' + id: 'pump-stall' | 'prime' | 'water' | 'occupancy' | 'cover' title: string detail: string } @@ -73,6 +74,16 @@ export interface MaintenanceFacts { const PRIME_STALE_MS = 7 * 24 * HOUR +/** + * Sides whose cover sensor reports no link in the latest `sensHealth` frame. + * Empty without a frame: older firmware never sends one. Only an explicit + * `connected: false` counts. + */ +export function disconnectedCovers(frame: SensHealthFrame | undefined): Array<'left' | 'right'> { + if (!frame) return [] + return (['left', 'right'] as const).filter(side => frame[side]?.connected === false) +} + /** * Things on the pod that need someone to act. Empty means the Dashboard hides * the card. A daily prime schedule counts as covered; otherwise a missing @@ -83,8 +94,19 @@ export function attentionItems( waterLevel: string | undefined, now: number, suspectSides: ReadonlyArray = [], + disconnected: ReadonlyArray<'left' | 'right'> = [], ): AttentionItem[] { const out: AttentionItem[] = [] + if (disconnected.length > 0) { + const both = disconnected.length === 2 + out.push({ + id: 'cover', + title: both ? 'Left and right cover sensors disconnected' : `${disconnected[0] === 'left' ? 'Left' : 'Right'} cover sensor disconnected`, + detail: both + ? 'No presence or vitals from either side until the cover reconnects. Check its connection at the hub.' + : 'No presence or vitals from that side until it reconnects. Check the cover\'s connection at the hub.', + }) + } if (suspectSides.length > 0) { out.push({ id: 'occupancy', diff --git a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx index 243541a3c..91caad646 100644 --- a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx +++ b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as SideProviderModule from '@/src/providers/SideProvider' import type * as CurveChartModule from '@/src/components/Schedule/CurveChart' +import type * as SensorStreamModule from '@/src/hooks/useSensorStream' const mocks = vi.hoisted(() => ({ thermal: undefined as unknown, @@ -13,6 +14,8 @@ const mocks = vi.hoisted(() => ({ schedules: {} as Record, mutations: {} as Record>, summary: undefined as unknown, + frames: {} as Record, + device: undefined as unknown, timeFormat: '12h' as '12h' | '24h', // 8 PM local on a Monday: inside tonight's 5 PM → 9 AM window. nowMinute: Math.floor(new Date(2026, 8, 28, 20, 0).getTime() / 60_000), @@ -23,6 +26,10 @@ vi.mock('@/src/components/status/StatusScreen', () => ({ formatUptime: (s: number) => `${Math.floor(s / 3600)}h`, })) vi.mock('@/src/components/status/PumpAlertsCard', () => ({ PumpAlertsCard: () => null })) +vi.mock('@/src/hooks/useSensorStream', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useSensorFrame: (type: string) => mocks.frames[type] } +}) vi.mock('@/src/components/Schedule/CurveChart', async (importOriginal) => { const actual = await importOriginal() return { @@ -57,6 +64,7 @@ vi.mock('@/src/utils/trpc', () => { if (key === 'health.maintenance') return { data: mocks.maintenance } if (key === 'health.system') return { data: { scheduler: { drift: { drifted: false } } } } if (key === 'waterLevel.getLatest') return { data: mocks.water } + if (key === 'device.getStatus') return { data: mocks.device } if (key === 'schedules.getAll') return { data: mocks.schedules[input?.side ?? ''] } if (key === 'health.thermal') return { data: mocks.thermal, isLoading: false, isFetching: false, error: null, dataUpdatedAt: 1 } if (key === 'health.scheduler') return { data: mocks.scheduler, isLoading: false } @@ -105,6 +113,8 @@ vi.mock('@/src/providers/SideProvider', async (importOriginal) => { beforeEach(() => { mocks.timeFormat = '12h' mocks.dataPath = undefined + mocks.frames = {} + mocks.device = undefined single.sides = null mocks.thermal = { pumpStallProtectionEnabled: true, @@ -202,6 +212,42 @@ describe('DiagnosticsConsole dashboard', () => { expect(screen.queryByTestId('attention')).toBeNull() }) + it('warns per side when sensHealth reports a disconnected cover sensor', () => { + const health = (left: boolean, right: boolean) => ({ type: 'sensHealth', ts: 1, version: 1, left: { connected: left }, right: { connected: right } }) + mocks.frames = { sensHealth: health(true, true) } + const { rerender } = render() + expect(screen.queryByTestId('attention')).toBeNull() + mocks.frames = { sensHealth: health(true, false) } + rerender() + expect(within(screen.getByTestId('attention')).getByText('Right cover sensor disconnected')).toBeTruthy() + mocks.frames = { sensHealth: health(false, true) } + rerender() + expect(within(screen.getByTestId('attention')).getByText('Left cover sensor disconnected')).toBeTruthy() + // Older firmware never sends sensHealth: no warning. + mocks.frames = {} + rerender() + expect(screen.queryByTestId('attention')).toBeNull() + }) + + it('falls back to the DAC water level before the first water reading', () => { + mocks.water = undefined + mocks.device = { waterLevel: 'low', isPriming: false } + render() + expect(within(screen.getByTestId('attention')).getByText('Water level is low')).toBeTruthy() + }) + + it('adds a water tank fact only once frzWater arrives', () => { + const { rerender } = render() + const line = () => screen.getByTestId('status-line') + expect(within(line()).queryByText('Water')).toBeNull() + expect(within(line()).queryByTestId('water-tank')).toBeNull() + mocks.frames = { frzWater: { type: 'frzWater', ts: 1, isPriming: false, hasWater: true, capValid: true, capLevel: 3, needsPriming: true } } + rerender() + expect(within(line()).getByText('Water')).toBeTruthy() + expect(within(line()).getByRole('img', { name: 'Water tank 3 of 5' })).toBeTruthy() + expect(within(line()).getByText('Needs prime')).toBeTruthy() + }) + it.each([['left'], ['right'], ['left', 'right']] as Array>)('shows one loading placeholder per displayed side (%j)', (...sides) => { single.sides = sides mocks.thermal = undefined diff --git a/src/components/diagnostics/tests/dashboardLogic.test.ts b/src/components/diagnostics/tests/dashboardLogic.test.ts index d7e6373c8..e1ad2d682 100644 --- a/src/components/diagnostics/tests/dashboardLogic.test.ts +++ b/src/components/diagnostics/tests/dashboardLogic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - attentionItems, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobLabel, tonightWindow, + attentionItems, disconnectedCovers, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobLabel, tonightWindow, } from '../dashboardLogic' import type { SchedJob } from '../diagnosticsLogic' @@ -85,4 +85,28 @@ describe('attentionItems', () => { it('flags low water', () => { expect(attentionItems(ok, 'low', now).map(i => i.id)).toEqual(['water']) }) + it('leads with a disconnected cover sensor, per side', () => { + const [right] = attentionItems(ok, 'ok', now, ['left'], ['right']) + expect(right).toMatchObject({ id: 'cover', title: 'Right cover sensor disconnected' }) + expect(attentionItems(ok, 'ok', now, [], ['left'])[0].title).toBe('Left cover sensor disconnected') + expect(attentionItems(ok, 'ok', now, [], ['left', 'right'])[0].title).toBe('Left and right cover sensors disconnected') + expect(attentionItems(ok, 'ok', now, [], [])).toEqual([]) + }) +}) + +describe('disconnectedCovers', () => { + const frame = (left: boolean, right: boolean) => ({ type: 'sensHealth' as const, ts: 1, version: 1, left: { connected: left }, right: { connected: right } }) + it('is empty without a sensHealth frame (older firmware)', () => { + expect(disconnectedCovers(undefined)).toEqual([]) + }) + it('lists each side that reports no link', () => { + expect(disconnectedCovers(frame(true, true))).toEqual([]) + expect(disconnectedCovers(frame(false, true))).toEqual(['left']) + expect(disconnectedCovers(frame(true, false))).toEqual(['right']) + expect(disconnectedCovers(frame(false, false))).toEqual(['left', 'right']) + }) + it('only counts an explicit connected: false', () => { + expect(disconnectedCovers({ type: 'sensHealth', ts: 1 } as never)).toEqual([]) + expect(disconnectedCovers({ type: 'sensHealth', ts: 1, left: {}, right: { connected: null } } as never)).toEqual([]) + }) }) diff --git a/src/components/ds/charts.tsx b/src/components/ds/charts.tsx index 6fb9fd4a9..db471dd5a 100644 --- a/src/components/ds/charts.tsx +++ b/src/components/ds/charts.tsx @@ -51,6 +51,21 @@ export interface LineSeries { dash?: boolean fill?: boolean width?: number + /** Mark every point, so a value between two gaps still shows. */ + dots?: boolean +} + +/** A horizontal rule at `value`; `strong` for a reference such as zero. */ +export interface ChartGuide { + value: number + label?: string + strong?: boolean +} + +/** A vertical hairline at a fractional point index, e.g. 2.5 between points 2 and 3. */ +export interface ChartMarker { + at: number + label: string } /** @@ -58,7 +73,7 @@ export interface LineSeries { * `xLabel` turns on the hover readout: the label for the point under the * pointer and each series' value there (through `format`, default 1 decimal). */ -export function LineChart({ series, min, max, height = 80, className, xLabel, format = v => v.toFixed(1) }: { +export function LineChart({ series, min, max, height = 80, className, xLabel, format = v => v.toFixed(1), guides = [], markers = [] }: { series: LineSeries[] min?: number max?: number @@ -66,6 +81,8 @@ export function LineChart({ series, min, max, height = 80, className, xLabel, fo className?: string xLabel?: (index: number) => string format?: (value: number, series: number) => string + guides?: ChartGuide[] + markers?: ChartMarker[] }) { const width = 300 const hover = useHoverFraction() @@ -75,23 +92,49 @@ export function LineChart({ series, min, max, height = 80, className, xLabel, fo const idx = xLabel && hover.frac !== null && n > 0 ? Math.round(hover.frac * (n - 1)) : null const lo = min ?? Math.min(...all) const hi = max ?? Math.max(...all) + const yOf = (v: number) => 4 + (1 - (v - lo) / (hi - lo || 1)) * (height - 8) + const xPct = (i: number) => (i / Math.max(1, n - 1)) * 100 + const points = (d: number[]) => d.flatMap((v, i) => Number.isFinite(v) + ? [{ i, x: ((i / Math.max(1, d.length - 1)) * width).toFixed(1), y: yOf(v).toFixed(1) }] + : []) const path = (d: number[]) => { let out = '' - let pen = false - d.forEach((v, i) => { - if (!Number.isFinite(v)) { - pen = false - return - } - const x = ((i / Math.max(1, d.length - 1)) * width).toFixed(1) - const y = (4 + (1 - (v - lo) / (hi - lo || 1)) * (height - 8)).toFixed(1) - out += `${pen ? 'L' : 'M'}${x},${y} ` - pen = true - }) + let last = -2 + for (const p of points(d)) { + out += `${p.i === last + 1 ? 'L' : 'M'}${p.x},${p.y} ` + last = p.i + } return out.trim() } + const labelled = guides.filter(g => g.label) + const bare = !xLabel && markers.length === 0 && labelled.length === 0 const svg = ( - + + {guides.map(g => ( + + ))} + {markers.map(m => ( + + ))} {series.map((s, i) => { const d = path(s.data) if (!d) return null @@ -106,18 +149,46 @@ export function LineChart({ series, min, max, height = 80, className, xLabel, fo strokeDasharray={s.dash ? '4 4' : undefined} vectorEffect="non-scaling-stroke" /> + {/* Zero-length round-capped segments: dots that stay round under preserveAspectRatio="none". */} + {s.dots && ( + `M${p.x},${p.y} h0`).join(' ')} + fill="none" + stroke={s.color} + strokeWidth={(s.width ?? 1.75) + 3} + strokeLinecap="round" + vectorEffect="non-scaling-stroke" + /> + )} ) })} ) - if (!xLabel) return svg + if (bare) return svg const values = idx === null ? [] : series.map((s, i) => (Number.isFinite(s.data[idx]) ? format(s.data[idx], i) : null)).filter((v): v is string => v !== null) return ( -
+
{svg} - {idx !== null && ( - + {labelled.map(g => ( + + {g.label} + + ))} + {markers.map(m => ( + 55 ? 'pr-1' : 'pl-1')} + style={xPct(m.at) > 55 ? { right: `${100 - xPct(m.at)}%` } : { left: `${xPct(m.at)}%` }} + > + {m.label} + + ))} + {idx !== null && xLabel && ( + )}
) @@ -141,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 @@ -174,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/components/ds/tests/charts.test.tsx b/src/components/ds/tests/charts.test.tsx index 1b9010eb6..4630861a1 100644 --- a/src/components/ds/tests/charts.test.tsx +++ b/src/components/ds/tests/charts.test.tsx @@ -33,6 +33,31 @@ describe('LineChart hover', () => { }) }) +describe('LineChart guides, markers and dots', () => { + it('draws guides at their values, labels them, and puts markers at fractional indices', () => { + const { container, getAllByTestId, getByText } = render( + , + ) + const lines = [...container.querySelectorAll('line')] + // y = 4 + (1 − v / 10) × 80: zero at the bottom, 10 at the top. + expect(lines.slice(0, 2).map(l => [l.getAttribute('y1'), l.getAttribute('stroke')])).toEqual([['84', 'var(--text-3)'], ['4', 'var(--border-1)']]) + expect(lines.slice(2).map(l => l.getAttribute('x1'))).toEqual(['37.5', '262.5']) + expect(getByText('10').style.top).toBe('4px') + const markers = getAllByTestId('chart-marker') + expect(markers.map(m => [m.textContent, m.style.left || `right ${m.style.right}`])).toEqual([['gen 2', '12.5%'], ['gen 3', 'right 12.5%']]) + // The point between two gaps has no line through it but still gets a dot. + expect(container.querySelector('path:not([data-testid])')?.getAttribute('d')).toBe('M0.0,84.0 L75.0,4.0 M225.0,44.0') + expect(getAllByTestId('chart-dots')[0].getAttribute('d')).toBe('M0.0,84.0 h0 M75.0,4.0 h0 M225.0,44.0 h0') + }) +}) + describe('HoverMark', () => { it('flips the label to the left past the middle', () => { const { getByTestId, rerender } = render() diff --git a/src/components/status/WaterLevelCard.tsx b/src/components/status/WaterLevelCard.tsx index 4536ca057..a1574e908 100644 --- a/src/components/status/WaterLevelCard.tsx +++ b/src/components/status/WaterLevelCard.tsx @@ -2,9 +2,12 @@ import { Droplets, Play } from 'lucide-react' import { trpc } from '@/src/utils/trpc' +import { useSensorFrame, useSensorSubscription } from '@/src/hooks/useSensorStream' import { Button, Card, CardHeader, Skeleton } from '@/src/components/ds' import { cn } from '@/lib/utils' import { dailyWaterLevels, useWaterHistory, type WaterDay } from './waterHistory' +import { WaterTankGauge } from './WaterTankGauge' +import { waterTank } from './waterTank' const DAY_TONE: Record<'ok' | 'low' | 'none', { past: string, today: string }> = { ok: { past: 'bg-ok-line', today: 'bg-ok' }, @@ -25,10 +28,16 @@ function dayLabel(level: WaterDay): string { * Wires into: * - waterLevel.getHistory → 7-day strip * - device.getStatus → priming state + prime-complete timestamp + * - frzWater (newer thermal firmware) → tank gauge in the header; its + * isPriming also blocks Start prime */ export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { const { data: history, isLoading: historyLoading } = useWaterHistory() const { data: status, isLoading: statusLoading } = trpc.device.getStatus.useQuery({}, { refetchInterval: 10_000 }) + useSensorSubscription({ sensors: ['frzWater'] }) + const frzWater = useSensorFrame('frzWater') + const tank = waterTank(frzWater) + const priming = !!status?.isPriming || !!frzWater?.isPriming const days = dailyWaterLevels(history) const lastPrimed = status?.primeCompletedNotification?.timestamp @@ -39,7 +48,14 @@ export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { title="Water level" icon={Droplets} iconClassName="text-cool" - right={7 days} + right={tank + ? ( + + + 7 days + + ) + : 7 days} /> {historyLoading ? @@ -64,14 +80,14 @@ export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { ? : ( - {status?.isPriming + {priming ? 'Priming…' : lastPrimed ? `Last primed ${new Date(lastPrimed).toLocaleDateString([], { month: 'short', day: 'numeric' })}` : 'No recent prime'} )} -
diff --git a/src/components/status/WaterTankGauge.tsx b/src/components/status/WaterTankGauge.tsx new file mode 100644 index 000000000..29c3cb6d6 --- /dev/null +++ b/src/components/status/WaterTankGauge.tsx @@ -0,0 +1,31 @@ +'use client' + +import { cn } from '@/lib/utils' +import { TANK_BARS, type WaterTank } from './waterTank' + +/** + * Water tank gauge from `frzWater`: five bars filling bottom-up, with the + * tank label and priming state beside it. Bars pulse while the pod primes; an + * invalid capacitive reading leaves every bar unlit and labels the tank from + * `hasWater`. + */ +export function WaterTankGauge({ tank, className }: { tank: WaterTank, className?: string }) { + const lit = tank.level ?? 0 + const bar = tank.tone === 'warn' ? 'bg-warn' : 'bg-cool' + const prime = tank.prime === 'priming' ? 'Priming…' : tank.prime === 'needed' ? 'Needs prime' : null + return ( + + + {Array.from({ length: TANK_BARS }, (_, i) => ( + + ))} + + {tank.label} + {prime && {prime}} + + ) +} diff --git a/src/components/status/tests/WaterLevelCard.test.tsx b/src/components/status/tests/WaterLevelCard.test.tsx index ef31b5803..2b9153ec5 100644 --- a/src/components/status/tests/WaterLevelCard.test.tsx +++ b/src/components/status/tests/WaterLevelCard.test.tsx @@ -12,7 +12,10 @@ const mock = vi.hoisted(() => ({ historyLoading: false, status: undefined as unknown, statusLoading: false, + frames: {} as Record, })) +const subscription = vi.hoisted(() => vi.fn()) +vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: (type: string) => mock.frames[type], useSensorSubscription: subscription })) vi.mock('@/src/utils/trpc', () => ({ trpc: { waterLevel: { getHistory: { useQuery: () => ({ data: mock.history, isLoading: mock.historyLoading }) } }, device: { getStatus: { useQuery: () => ({ data: mock.status, isLoading: mock.statusLoading }) } }, @@ -23,6 +26,7 @@ beforeEach(() => { mock.historyLoading = false mock.status = undefined mock.statusLoading = false + mock.frames = {} }) it('holds the day strip and prime line with skeletons while queries load', () => { @@ -57,3 +61,26 @@ it('opens the prime dialog and disables the button while priming', () => { expect(screen.getByText('Priming…')).toBeTruthy() expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(true) }) + +it('keeps the header and prime state from device status without a frzWater frame', () => { + mock.status = { isPriming: false } + render( {}} />) + expect(subscription).toHaveBeenCalledWith({ sensors: ['frzWater'] }) + expect(screen.queryByTestId('water-tank')).toBeNull() + expect(screen.getByText('7 days')).toBeTruthy() + expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(false) +}) + +it('shows the tank gauge from frzWater and blocks Start prime while it reports priming', () => { + mock.status = { isPriming: false } + mock.frames.frzWater = { type: 'frzWater', version: 1, ts: 1, isPriming: 0, hasWater: 1, capValid: 1, capLevel: 3, needsPriming: 0 } + const { rerender } = render( {}} />) + expect(screen.getByRole('img', { name: 'Water tank 3 of 5' })).toBeTruthy() + expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(false) + // Pod 3 live payload: integer flags, invalid capacitive level, priming. + mock.frames.frzWater = { type: 'frzWater', version: 1, ts: 2, isPriming: 1, hasWater: 1, capValid: 0, capLevel: 0, needsPriming: 0 } + rerender( {}} />) + expect(screen.getByRole('img', { name: 'Water ok, tank level unavailable' })).toBeTruthy() + expect(screen.getAllByText('Priming…')).toHaveLength(2) + expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(true) +}) diff --git a/src/components/status/tests/WaterTankGauge.test.tsx b/src/components/status/tests/WaterTankGauge.test.tsx new file mode 100644 index 000000000..30a2ba4db --- /dev/null +++ b/src/components/status/tests/WaterTankGauge.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { FrzWaterFrame } from '@/src/hooks/useSensorStream' +import { WaterTankGauge } from '../WaterTankGauge' +import { waterTank, type WaterTank } from '../waterTank' + +const frame = (over: Partial = {}): FrzWaterFrame => ({ + type: 'frzWater', ts: 1, isPriming: false, hasWater: true, capValid: true, capLevel: 4, needsPriming: false, ...over, +}) + +describe('waterTank', () => { + it('is undefined without a frzWater frame (older firmware)', () => { + expect(waterTank(undefined)).toBeUndefined() + }) + + it('uses capLevel when capValid', () => { + expect(waterTank(frame())).toEqual({ level: 4, label: '4/5', tone: 'ok', prime: null }) + expect(waterTank(frame({ capLevel: 1 }))).toMatchObject({ level: 1, tone: 'warn' }) + expect(waterTank(frame({ capLevel: 0, hasWater: false }))).toMatchObject({ level: 0, label: '0/5', tone: 'warn' }) + }) + + it('clamps out-of-range levels to the five bars', () => { + expect(waterTank(frame({ capLevel: 9 }))?.level).toBe(5) + expect(waterTank(frame({ capLevel: -2 }))?.level).toBe(0) + }) + + it('ignores capLevel when the capacitive reading is invalid', () => { + expect(waterTank(frame({ capValid: false, capLevel: 5 }))).toEqual({ level: null, label: 'OK', tone: 'ok', prime: null }) + expect(waterTank(frame({ capValid: false, capLevel: 5, hasWater: false }))).toEqual({ level: null, label: 'Low', tone: 'warn', prime: null }) + }) + + it('warns whenever the controller reports no water', () => { + expect(waterTank(frame({ capLevel: 3, hasWater: false }))?.tone).toBe('warn') + }) + + it('reads the 0/1 integer flags a Pod 3 on thermal v1.6.114 sends', () => { + const live = JSON.parse('{"type":"frzWater","version":1,"ts":1793239885,"isPriming":1,"hasWater":1,"capValid":0,"capLevel":0,"needsPriming":0}') as FrzWaterFrame + expect(waterTank(live)).toEqual({ level: null, label: 'OK', tone: 'ok', prime: 'priming' }) + expect(waterTank({ ...live, isPriming: 0, capValid: 1, capLevel: 3 })).toEqual({ level: 3, label: '3/5', tone: 'ok', prime: null }) + expect(waterTank({ ...live, isPriming: 0, hasWater: 0, needsPriming: 1 })).toEqual({ level: null, label: 'Low', tone: 'warn', prime: 'needed' }) + }) + + it('reports priming ahead of a pending prime', () => { + expect(waterTank(frame({ needsPriming: true }))?.prime).toBe('needed') + expect(waterTank(frame({ isPriming: true, needsPriming: true }))?.prime).toBe('priming') + }) +}) + +describe('WaterTankGauge', () => { + const tank = (over: Partial = {}) => waterTank(frame(over)) as WaterTank + const lit = () => screen.getByRole('img').querySelectorAll('[data-lit="true"]').length + + it('lights one bar per level with the level label', () => { + render() + expect(screen.getByRole('img', { name: 'Water tank 3 of 5' })).toBeTruthy() + expect(screen.getByRole('img').children).toHaveLength(5) + expect(lit()).toBe(3) + expect(screen.getByText('3/5')).toBeTruthy() + }) + + it('shows no level when the reading is invalid', () => { + render() + expect(screen.getByRole('img', { name: 'Water low, tank level unavailable' })).toBeTruthy() + expect(lit()).toBe(0) + expect(screen.getByText('Low').className).toContain('text-warn') + }) + + it('pulses while priming and flags a needed prime', () => { + const { rerender } = render() + expect(screen.getByText('Priming…')).toBeTruthy() + expect(screen.getByRole('img').className).toContain('animate-pulse') + rerender() + expect(screen.getByText('Needs prime').className).toContain('text-warn') + expect(screen.getByRole('img').className).not.toContain('animate-pulse') + }) +}) diff --git a/src/components/status/waterTank.ts b/src/components/status/waterTank.ts new file mode 100644 index 000000000..81f61ed7f --- /dev/null +++ b/src/components/status/waterTank.ts @@ -0,0 +1,31 @@ +import type { FrzWaterFrame } from '@/src/hooks/useSensorStream' + +/** Bars on the tank gauge; `capLevel` runs 0–5. */ +export const TANK_BARS = 5 + +export interface WaterTank { + /** Lit bars, or null when the capacitive reading isn't valid (no gauge level). */ + level: number | null + /** "4/5" when the level is valid, otherwise OK / Low from `hasWater`. */ + label: string + tone: 'ok' | 'warn' + /** Priming now, firmware asking for a prime, or neither. */ + prime: 'priming' | 'needed' | null +} + +/** + * Tank state from the thermal controller's `frzWater` frame (newer firmware). + * Undefined without a frame so callers keep the binary DAC water level. + * `capLevel` is used only when `capValid`. + */ +export function waterTank(frame: FrzWaterFrame | undefined): WaterTank | undefined { + if (!frame) return undefined + const level = frame.capValid ? Math.min(TANK_BARS, Math.max(0, Math.round(frame.capLevel))) : null + const low = !frame.hasWater || (level != null && level <= 1) + return { + level, + label: level != null ? `${level}/${TANK_BARS}` : frame.hasWater ? 'OK' : 'Low', + tone: low ? 'warn' : 'ok', + prime: frame.isPriming ? 'priming' : frame.needsPriming ? 'needed' : null, + } +} diff --git a/src/db/biometrics-migrations/0031_wide_black_knight.sql b/src/db/biometrics-migrations/0031_wide_black_knight.sql new file mode 100644 index 000000000..670381943 --- /dev/null +++ b/src/db/biometrics-migrations/0031_wide_black_knight.sql @@ -0,0 +1,8 @@ +CREATE TABLE `flash_write_samples` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `timestamp` integer NOT NULL, + `boot_id` text NOT NULL, + `sectors_written` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_flash_write_samples_timestamp` ON `flash_write_samples` (`timestamp`); \ No newline at end of file diff --git a/src/db/biometrics-migrations/0032_reference_samples.sql b/src/db/biometrics-migrations/0032_reference_samples.sql new file mode 100644 index 000000000..2e16beba7 --- /dev/null +++ b/src/db/biometrics-migrations/0032_reference_samples.sql @@ -0,0 +1,12 @@ +CREATE TABLE `reference_samples` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `side` text NOT NULL, + `source` text NOT NULL, + `metric` text NOT NULL, + `timestamp` integer NOT NULL, + `value` real NOT NULL, + `created_at` integer NOT NULL, + `import_id` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uq_reference_samples_side_metric_timestamp_source` ON `reference_samples` (`side`,`metric`,`timestamp`,`source`); \ No newline at end of file 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/0031_snapshot.json b/src/db/biometrics-migrations/meta/0031_snapshot.json new file mode 100644 index 000000000..b97b4ff95 --- /dev/null +++ b/src/db/biometrics-migrations/meta/0031_snapshot.json @@ -0,0 +1,3006 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "53709beb-0cba-4645-9770-0ef733082ef6", + "prevId": "8642f917-fd30-4e9e-8b99-de14b5ea5dca", + "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": {} + }, + "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_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/0032_snapshot.json b/src/db/biometrics-migrations/meta/0032_snapshot.json new file mode 100644 index 000000000..cd6355bf3 --- /dev/null +++ b/src/db/biometrics-migrations/meta/0032_snapshot.json @@ -0,0 +1,3083 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8d572e89-425e-4ebe-a930-4a353fde8ded", + "prevId": "53709beb-0cba-4645-9770-0ef733082ef6", + "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": {} + }, + "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/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 abb988136..84e43f0c1 100644 --- a/src/db/biometrics-migrations/meta/_journal.json +++ b/src/db/biometrics-migrations/meta/_journal.json @@ -218,6 +218,27 @@ "when": 1791878980742, "tag": "0030_icy_maginty", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1792274814771, + "tag": "0031_wide_black_knight", + "breakpoints": true + }, + { + "idx": 32, + "version": "6", + "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 fbacef079..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(), @@ -231,6 +251,20 @@ export const healthRuns = sqliteTable('health_runs', { index('idx_health_runs_last_seen').on(t.lastSeenAt), ]) +/** + * Hourly readings of the eMMC's cumulative sectors-written counter + * (/sys/block//stat) for System → Storage's writes-per-day. The kernel + * resets the counter at boot, so each row carries the boot it was read in. + */ +export const flashWriteSamples = sqliteTable('flash_write_samples', { + id: integer('id').primaryKey({ autoIncrement: true }), + timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(), + bootId: text('boot_id').notNull(), + sectorsWritten: integer('sectors_written').notNull(), +}, t => [ + index('idx_flash_write_samples_timestamp').on(t.timestamp), +]) + /** * Externally scored sleep stages (Apple Watch SleepAnalysis posted by the iOS * app) used as a named comparison source for the pod's stage classifier. One @@ -275,6 +309,28 @@ export const referenceVitals = sqliteTable('reference_vitals', { uniqueIndex('uq_reference_vitals_side_timestamp_source').on(t.side, t.timestamp, t.source), ]) +/** + * Externally measured breathing rate (breaths/min) and HRV SDNN (ms) from an + * Apple Health export, one row per sample and metric. Kept apart from + * reference_vitals because the Watch records each metric at its own times, so + * a shared row keyed on timestamp would make one metric's first write block + * another's. Same rules as reference_vitals: never mixed into `vitals`, a + * repeated post is a no-op (unique on side, metric, timestamp, source), low + * volume, kept indefinitely. + */ +export const referenceSamples = sqliteTable('reference_samples', { + id: integer('id').primaryKey({ autoIncrement: true }), + side: text('side', { enum: ['left', 'right'] }).notNull(), + source: text('source', { enum: ['apple_watch'] }).notNull(), + metric: text('metric', { enum: ['breathing_rate', 'hrv_sdnn'] }).notNull(), + timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(), + value: real('value').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), + importId: text('import_id'), // set in the inserting transaction, like reference_vitals +}, t => [ + uniqueIndex('uq_reference_samples_side_metric_timestamp_source').on(t.side, t.metric, t.timestamp, t.source), +]) + // ── Calibration tables ── export const calibrationProfiles = sqliteTable('calibration_profiles', { diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 8c52763ef..87e1c0a97 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -1,8 +1,32 @@ import path from 'node:path' +import { sql } from 'drizzle-orm' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { db, sqlite } from './index' import { biometricsDb, closeBiometricsDatabase } from './biometrics' +/** + * Schedules that predate the schedule library join it once. Migration 0028 + * leaves a marker table that is dropped in the same transaction as the + * adoption, so a failed attempt is retried on the next start. Failures are + * logged, not thrown: the schedules keep running exactly as before. + */ +async function adoptPreLibrarySchedules() { + try { + const pending = sqlite.prepare('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = \'schedule_library_adoption\'').get() + if (!pending) return + const { adoptUnmanagedSchedules } = await import('@/src/server/scheduleLibrary') + const adopted = db.transaction((tx) => { + const count = adoptUnmanagedSchedules(tx) + tx.run(sql`DROP TABLE schedule_library_adoption`) + return count + }) + console.log(`✓ Added ${adopted} existing schedule${adopted === 1 ? '' : 's'} to the schedule library`) + } + catch (error) { + console.error('✗ Adding existing schedules to the schedule library failed; retrying on next start:', error) + } +} + /** * Run pending database migrations for all databases. * This should be called on server startup. @@ -14,6 +38,7 @@ export async function runMigrations() { migrate(db, { migrationsFolder: path.resolve(process.cwd(), 'src/db/migrations'), }) + await adoptPreLibrarySchedules() migrate(biometricsDb, { migrationsFolder: path.resolve(process.cwd(), 'src/db/biometrics-migrations'), diff --git a/src/db/migrations/0028_schedule_library.sql b/src/db/migrations/0028_schedule_library.sql new file mode 100644 index 000000000..63df061f4 --- /dev/null +++ b/src/db/migrations/0028_schedule_library.sql @@ -0,0 +1,9 @@ +ALTER TABLE `curve_templates` ADD `end_action` text DEFAULT 'turn_off' NOT NULL;--> statement-breakpoint +ALTER TABLE `curve_templates` ADD `left_days` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +ALTER TABLE `curve_templates` ADD `right_days` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +ALTER TABLE `curve_templates` ADD `active` integer DEFAULT false NOT NULL;--> statement-breakpoint +-- One-time marker, deliberately not in schema.ts: runMigrations (src/db/migrate.ts) +-- adds the schedules that predate the library to it and drops this table in the +-- same transaction, so an attempt that fails is retried on the next start. +CREATE TABLE `schedule_library_adoption` (`id` integer PRIMARY KEY NOT NULL);--> statement-breakpoint +INSERT INTO `schedule_library_adoption` (`id`) VALUES (1); diff --git a/src/db/migrations/0029_clear_stale_gesture_alarm_fields.sql b/src/db/migrations/0029_clear_stale_gesture_alarm_fields.sql new file mode 100644 index 000000000..c29adf8ca --- /dev/null +++ b/src/db/migrations/0029_clear_stale_gesture_alarm_fields.sql @@ -0,0 +1,5 @@ +-- Temperature taps never used the alarm fields, but switching a tap from an +-- alarm action to a temperature one left them in the row. Ringing actions now +-- apply to temperature taps too, so leftovers would silently snooze or stop +-- alarms; clear them. New saves write every field. +UPDATE `tap_gestures` SET `alarm_behavior` = NULL, `alarm_snooze_duration` = NULL, `alarm_inactive_behavior` = NULL WHERE `action_type` = 'temperature'; 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/0033_alarm_snooze.sql b/src/db/migrations/0033_alarm_snooze.sql new file mode 100644 index 000000000..a5b7a7fe9 --- /dev/null +++ b/src/db/migrations/0033_alarm_snooze.sql @@ -0,0 +1,2 @@ +ALTER TABLE `alarm_schedules` ADD `snooze_duration` integer DEFAULT 300 NOT NULL;--> statement-breakpoint +ALTER TABLE `alarm_schedules` ADD `snooze_taper` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/0034_alarm_snooze_from_gestures.sql b/src/db/migrations/0034_alarm_snooze_from_gestures.sql new file mode 100644 index 000000000..e20ae7f22 --- /dev/null +++ b/src/db/migrations/0034_alarm_snooze_from_gestures.sql @@ -0,0 +1,17 @@ +-- The snooze length moved from tap gestures to alarms. Carry over what each +-- side's snoozing gesture was set to (the first by tap count), so existing +-- snoozes keep their length; sides without one keep the 5 minute default. +UPDATE `alarm_schedules` SET `snooze_duration` = ( + SELECT MIN(MAX(`g`.`alarm_snooze_duration`, 60), 1800) FROM `tap_gestures` `g` + WHERE `g`.`side` = `alarm_schedules`.`side` + AND `g`.`alarm_behavior` = 'snooze' + AND `g`.`alarm_snooze_duration` IS NOT NULL + ORDER BY CASE `g`.`tap_type` WHEN 'doubleTap' THEN 0 WHEN 'tripleTap' THEN 1 ELSE 2 END + LIMIT 1 +) +WHERE EXISTS ( + SELECT 1 FROM `tap_gestures` `g` + WHERE `g`.`side` = `alarm_schedules`.`side` + AND `g`.`alarm_behavior` = 'snooze' + AND `g`.`alarm_snooze_duration` IS NOT NULL +); diff --git a/src/db/migrations/meta/0028_snapshot.json b/src/db/migrations/meta/0028_snapshot.json new file mode 100644 index 000000000..cfe0240d5 --- /dev/null +++ b/src/db/migrations/meta/0028_snapshot.json @@ -0,0 +1,1993 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ea019b0d-8bf2-4553-bcdf-9f3c50031e80", + "prevId": "ac3c5d78-3ecd-4eb5-8a65-afba0f623eef", + "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": {} + }, + "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/0029_snapshot.json b/src/db/migrations/meta/0029_snapshot.json new file mode 100644 index 000000000..8b7c52689 --- /dev/null +++ b/src/db/migrations/meta/0029_snapshot.json @@ -0,0 +1,1993 @@ +{ + "id": "c880c477-91bf-47e7-b901-e39bc7651a83", + "prevId": "ea019b0d-8bf2-4553-bcdf-9f3c50031e80", + "version": "6", + "dialect": "sqlite", + "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", + "columnsFrom": [ + "automation_id" + ], + "tableTo": "automations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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": {} + }, + "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": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file 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/0033_snapshot.json b/src/db/migrations/meta/0033_snapshot.json new file mode 100644 index 000000000..ebf98d971 --- /dev/null +++ b/src/db/migrations/meta/0033_snapshot.json @@ -0,0 +1,2108 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a736d89b-af20-4d82-bdf4-8fcc43ed7380", + "prevId": "b0786f57-48da-4ac9-94ff-cd1c3eaa372e", + "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 + }, + "snooze_duration": { + "name": "snooze_duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "snooze_taper": { + "name": "snooze_taper", + "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/0034_snapshot.json b/src/db/migrations/meta/0034_snapshot.json new file mode 100644 index 000000000..9660e9f08 --- /dev/null +++ b/src/db/migrations/meta/0034_snapshot.json @@ -0,0 +1,2108 @@ +{ + "id": "f486e492-6c28-42ba-b5f9-aa74624eb220", + "prevId": "a736d89b-af20-4d82-bdf4-8fcc43ed7380", + "version": "6", + "dialect": "sqlite", + "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 + }, + "snooze_duration": { + "name": "snooze_duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "snooze_taper": { + "name": "snooze_taper", + "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", + "columnsFrom": [ + "automation_id" + ], + "tableTo": "automations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "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 010907aeb..f4e4c7c2d 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -197,6 +197,55 @@ "when": 1791962178346, "tag": "0027_presence_auto_on", "breakpoints": true + }, + { + "idx": 28, + "version": "6", + "when": 1791967721108, + "tag": "0028_schedule_library", + "breakpoints": true + }, + { + "idx": 29, + "version": "6", + "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 + }, + { + "idx": 33, + "version": "6", + "when": 1792676512992, + "tag": "0033_alarm_snooze", + "breakpoints": true + }, + { + "idx": 34, + "version": "6", + "when": 1792676513633, + "tag": "0034_alarm_snooze_from_gestures", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/retention.ts b/src/db/retention.ts index 921b7ebc0..a37fdb01d 100644 --- a/src/db/retention.ts +++ b/src/db/retention.ts @@ -4,9 +4,11 @@ import { biometricsDb } from './biometrics' import { ambientLight, bedTemp, + flashWriteSamples, flowReadings, freezerTemp, healthRuns, + heartbeats, movement, pumpAlerts, thermalState, @@ -26,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 + * 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 — @@ -38,12 +41,14 @@ import { * calibration_* — small, correctness-critical, keep indefinitely * reference_stages — external ground-truth labels, low volume, keep indefinitely * reference_vitals — external (Apple Watch) heart rate, low volume, keep indefinitely + * reference_samples — external (Apple Watch) breathing rate and HRV, low volume, keep indefinitely */ 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' }, @@ -52,6 +57,7 @@ const RETENTION_TABLES = [ { table: pumpAlerts, column: pumpAlerts.timestamp, name: 'pump_alerts' }, { table: thermalState, column: thermalState.timestamp, name: 'thermal_state' }, { table: healthRuns, column: healthRuns.lastSeenAt, name: 'health_runs' }, + { table: flashWriteSamples, column: flashWriteSamples.timestamp, name: 'flash_write_samples' }, ] as const /** Tables the daily retention pass prunes, for System → Databases. */ diff --git a/src/db/schema.ts b/src/db/schema.ts index ab65a6e45..1074fe9b0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,6 @@ import { sql } from 'drizzle-orm' import { index, integer, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' +import type { DayOfWeek } from '@/src/lib/scheduleTime' // ============================================================================ // Device Settings & Configuration @@ -118,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(), @@ -150,6 +175,7 @@ export const tapGestures = sqliteTable('tap_gestures', { temperatureAmount: integer('temperature_amount'), // 0-10 // For alarm actions alarmBehavior: text('alarm_behavior', { enum: ['snooze', 'dismiss'] }), + // No longer read: the snooze length is set per alarm (alarm_schedules). alarmSnoozeDuration: integer('alarm_snooze_duration'), // 60-600 seconds alarmInactiveBehavior: text('alarm_inactive_behavior', { enum: ['power', 'none'], @@ -266,6 +292,11 @@ export const alarmSchedules = sqliteTable('alarm_schedules', { alarmTemperature: real('alarm_temperature').notNull(), // Temperature during alarm // Minutes before `time` in which movement fires the alarm early; 0 = off. wakeWindow: integer('wake_window').notNull().default(0), + // How long a snooze lasts (seconds), and Snooze taper: each later snooze of + // the same alarm is this percent shorter (0 = off). Tap gestures and the + // HomeKit switch snooze with these. + snoozeDuration: integer('snooze_duration').notNull().default(300), + snoozeTaper: integer('snooze_taper').notNull().default(0), enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() @@ -338,6 +369,13 @@ export const curveTemplates = sqliteTable('curve_templates', { points: text('points', { mode: 'json' }).notNull().$type>(), // {"HH:mm": tempF} // Column predates the notes name (it held the AI wizard's reasoning). notes: text('reasoning').notNull().default(''), + endAction: text('end_action', { enum: ['turn_off', 'maintain'] }).notNull().default('turn_off'), + // Days the curve runs on per side. While active, no other active curve + // claims the same side and day, and that day's temperature and power rows + // are this curve's (src/server/scheduleLibrary.ts). + leftDays: text('left_days', { mode: 'json' }).notNull().$type().default(sql`'[]'`), + rightDays: text('right_days', { mode: 'json' }).notNull().$type().default(sql`'[]'`), + active: integer('active', { mode: 'boolean' }).notNull().default(false), createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .default(sql`(unixepoch())`), diff --git a/src/db/tests/__snapshots__/schema.test.ts.snap b/src/db/tests/__snapshots__/schema.test.ts.snap index 4fa986f74..4f1eaf57f 100644 --- a/src/db/tests/__snapshots__/schema.test.ts.snap +++ b/src/db/tests/__snapshots__/schema.test.ts.snap @@ -415,6 +415,23 @@ 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", + "timestamp | sql:integer | logical:date/SQLiteTimestamp | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "boot_id | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "sectors_written | sql:integer | logical:number/SQLiteInteger | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + ], + "exportName": "flashWriteSamples", + "foreignKeys": [], + "indexes": [ + "idx_flash_write_samples_timestamp | non-unique | columns:timestamp | where:none", + ], + "name": "flash_write_samples", + "primaryKeys": [], + "uniqueConstraints": [], + }, { "checks": 0, "columns": [ @@ -473,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": [ @@ -598,6 +634,27 @@ 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", + "source | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:apple_watch", + "metric | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:breathing_rate,hrv_sdnn", + "timestamp | sql:integer | logical:date/SQLiteTimestamp | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "value | sql:real | logical:number/SQLiteReal | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "created_at | sql:integer | logical:date/SQLiteTimestamp | not-null | default:fn:Date | not-primary-key | not-unique | not-autoincrement | enum:none", + "import_id | sql:text | logical:string/SQLiteText | nullable | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + ], + "exportName": "referenceSamples", + "foreignKeys": [], + "indexes": [ + "uq_reference_samples_side_metric_timestamp_source | unique | columns:side,metric,timestamp,source | where:none", + ], + "name": "reference_samples", + "primaryKeys": [], + "uniqueConstraints": [], + }, { "checks": 0, "columns": [ @@ -779,6 +836,8 @@ exports[`complete Drizzle schema contracts > pins every main-database table, col "duration | sql:integer | logical:number/SQLiteInteger | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", "alarm_temperature | sql:real | logical:number/SQLiteReal | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", "wake_window | sql:integer | logical:number/SQLiteInteger | not-null | default:0 | not-primary-key | not-unique | not-autoincrement | enum:none", + "snooze_duration | sql:integer | logical:number/SQLiteInteger | not-null | default:300 | not-primary-key | not-unique | not-autoincrement | enum:none", + "snooze_taper | sql:integer | logical:number/SQLiteInteger | not-null | default:0 | not-primary-key | not-unique | not-autoincrement | enum:none", "enabled | sql:integer | logical:boolean/SQLiteBoolean | not-null | default:true | not-primary-key | not-unique | not-autoincrement | enum:none", "created_at | sql:integer | logical:date/SQLiteTimestamp | not-null | default:sql:(unixepoch()) | not-primary-key | not-unique | not-autoincrement | enum:none", "updated_at | sql:integer | logical:date/SQLiteTimestamp | not-null | default:sql:(unixepoch()) | not-primary-key | not-unique | not-autoincrement | enum:none", @@ -971,6 +1030,10 @@ exports[`complete Drizzle schema contracts > pins every main-database table, col "wake | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", "points | sql:text | logical:json/SQLiteTextJson | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", "reasoning | sql:text | logical:string/SQLiteText | not-null | default: | not-primary-key | not-unique | not-autoincrement | enum:none", + "end_action | sql:text | logical:string/SQLiteText | not-null | default:turn_off | not-primary-key | not-unique | not-autoincrement | enum:turn_off,maintain", + "left_days | sql:text | logical:json/SQLiteTextJson | not-null | default:sql:'[]' | not-primary-key | not-unique | not-autoincrement | enum:none", + "right_days | sql:text | logical:json/SQLiteTextJson | not-null | default:sql:'[]' | 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", "created_at | sql:integer | logical:date/SQLiteTimestamp | not-null | default:sql:(unixepoch()) | not-primary-key | not-unique | not-autoincrement | enum:none", "updated_at | sql:integer | logical:date/SQLiteTimestamp | not-null | default:sql:(unixepoch()) | not-primary-key | not-unique | not-autoincrement | enum:none", ], @@ -1137,6 +1200,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": [ @@ -1158,6 +1237,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/migrate-module.test.ts b/src/db/tests/migrate-module.test.ts index bbe44dc6b..5076bf9b8 100644 --- a/src/db/tests/migrate-module.test.ts +++ b/src/db/tests/migrate-module.test.ts @@ -20,7 +20,10 @@ const mocks = vi.hoisted(() => { select: vi.fn(), transaction: vi.fn(), }, - sqlite: { close: vi.fn() }, + sqlite: { close: vi.fn(), prepare: vi.fn() }, + marker: undefined as unknown, + adoptUnmanagedSchedules: vi.fn(), + txRun: vi.fn(), biometricsDb: { name: 'biometrics-db' }, closeBiometricsDatabase: vi.fn(), migrate: vi.fn(), @@ -49,6 +52,10 @@ vi.mock('../biometrics', () => ({ vi.mock('../schema', () => mocks.schema) +vi.mock('@/src/server/scheduleLibrary', () => ({ + adoptUnmanagedSchedules: mocks.adoptUnmanagedSchedules, +})) + const originalArgv1 = process.argv[1] const migrateModulePath = fileURLToPath(new URL('../migrate.ts', import.meta.url)) @@ -62,6 +69,11 @@ describe('database migration module', () => { beforeEach(() => { mocks.migrate.mockReset() mocks.sqlite.close.mockReset() + mocks.sqlite.prepare.mockReset() + mocks.adoptUnmanagedSchedules.mockReset() + mocks.txRun.mockReset() + mocks.marker = undefined + mocks.sqlite.prepare.mockImplementation(() => ({ get: () => mocks.marker })) mocks.closeBiometricsDatabase.mockReset() mocks.db.select.mockReset() mocks.db.transaction.mockReset() @@ -221,4 +233,89 @@ describe('database migration module', () => { expect(mocks.closeBiometricsDatabase).toHaveBeenCalledTimes(1) expect(mocks.sqlite.close).toHaveBeenCalledTimes(1) }) + + describe('adding pre-library schedules to the schedule library', () => { + const adoptionTx = () => ({ insert: mocks.insert, run: mocks.txRun }) + + beforeEach(() => { + mocks.db.transaction.mockImplementation((callback: (tx: ReturnType) => unknown) => callback(adoptionTx())) + }) + + it('adopts and drops the marker in one transaction, between the two migration runs', async () => { + mocks.marker = { 1: 1 } + mocks.adoptUnmanagedSchedules.mockImplementation(() => { + expect(mocks.migrate).toHaveBeenCalledTimes(1) + return 2 + }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const { runMigrations } = await importMigrate() + + await runMigrations() + + expect(mocks.sqlite.prepare).toHaveBeenCalledWith('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = \'schedule_library_adoption\'') + expect(mocks.db.transaction).toHaveBeenCalledTimes(1) + expect(mocks.adoptUnmanagedSchedules).toHaveBeenCalledWith(expect.objectContaining({ run: mocks.txRun })) + expect(mocks.txRun).toHaveBeenCalledTimes(1) + const dropped = mocks.txRun.mock.calls[0][0] as { queryChunks: Array<{ value?: string[] }> } + expect(dropped.queryChunks.flatMap(c => c.value ?? []).join('')).toBe('DROP TABLE schedule_library_adoption') + expect(mocks.migrate).toHaveBeenCalledTimes(2) + expect(log).toHaveBeenCalledWith('✓ Added 2 existing schedules to the schedule library') + expect(log).toHaveBeenLastCalledWith('✓ Database migrations completed successfully') + }) + + it('says "schedule" for a single adoption', async () => { + mocks.marker = { 1: 1 } + mocks.adoptUnmanagedSchedules.mockReturnValue(1) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const { runMigrations } = await importMigrate() + + await runMigrations() + + expect(log).toHaveBeenCalledWith('✓ Added 1 existing schedule to the schedule library') + }) + + it('does nothing once the marker is gone', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + const { runMigrations } = await importMigrate() + + await runMigrations() + + expect(mocks.sqlite.prepare).toHaveBeenCalledTimes(1) + expect(mocks.db.transaction).not.toHaveBeenCalled() + expect(mocks.adoptUnmanagedSchedules).not.toHaveBeenCalled() + expect(mocks.migrate).toHaveBeenCalledTimes(2) + }) + + it('logs a failed adoption, keeps the marker and still finishes migrating', async () => { + mocks.marker = { 1: 1 } + const failure = new Error('adoption exploded') + mocks.adoptUnmanagedSchedules.mockImplementation(() => { + throw failure + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { runMigrations } = await importMigrate() + + await expect(runMigrations()).resolves.toBeUndefined() + + expect(mocks.txRun).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith('✗ Adding existing schedules to the schedule library failed; retrying on next start:', failure) + expect(mocks.migrate).toHaveBeenCalledTimes(2) + }) + + it('logs and skips when the marker cannot be read', async () => { + const failure = new Error('no sqlite_master') + mocks.sqlite.prepare.mockImplementation(() => { + throw failure + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { runMigrations } = await importMigrate() + + await expect(runMigrations()).resolves.toBeUndefined() + + expect(error).toHaveBeenCalledWith('✗ Adding existing schedules to the schedule library failed; retrying on next start:', failure) + expect(mocks.adoptUnmanagedSchedules).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/db/tests/migrations.test.ts b/src/db/tests/migrations.test.ts index b03c12110..32b0db5ec 100644 --- a/src/db/tests/migrations.test.ts +++ b/src/db/tests/migrations.test.ts @@ -63,6 +63,79 @@ describe('migrations smoke test', () => { } }) + it('clears alarm fields left on temperature taps and keeps alarm taps', () => { + const migrationsDir = path.resolve(process.cwd(), 'src/db/migrations') + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gesture-cleanup-')) + const raw = new Database(':memory:') + try { + fs.cpSync(migrationsDir, tmpDir, { recursive: true }) + const journalPath = path.join(tmpDir, 'meta/_journal.json') + const journal = JSON.parse(fs.readFileSync(journalPath, 'utf-8')) as { + entries: Array<{ idx: number, tag: string }> + } + journal.entries = journal.entries.filter(entry => entry.tag < '0029') + fs.writeFileSync(journalPath, JSON.stringify(journal)) + const db = drizzle(raw, { schema }) + migrate(db, { migrationsFolder: tmpDir }) + const insert = raw.prepare(`INSERT INTO tap_gestures (side, tap_type, action_type, temperature_change, + temperature_amount, alarm_behavior, alarm_snooze_duration, alarm_inactive_behavior) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + insert.run('left', 'doubleTap', 'temperature', 'increment', 2, 'snooze', 420, 'power') + insert.run('left', 'tripleTap', 'alarm', null, null, 'dismiss', null, 'power') + migrate(db, { migrationsFolder: migrationsDir }) + expect(raw.prepare(`SELECT tap_type AS tapType, temperature_amount AS amount, alarm_behavior AS behavior, + alarm_snooze_duration AS snooze, alarm_inactive_behavior AS inactive FROM tap_gestures ORDER BY tap_type`).all()) + .toEqual([ + { tapType: 'doubleTap', amount: 2, behavior: null, snooze: null, inactive: null }, + { tapType: 'tripleTap', amount: null, behavior: 'dismiss', snooze: null, inactive: 'power' }, + ]) + } + finally { + raw.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('moves each side\'s gesture snooze length onto its alarms', () => { + const migrationsDir = path.resolve(process.cwd(), 'src/db/migrations') + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'alarm-snooze-')) + const raw = new Database(':memory:') + try { + fs.cpSync(migrationsDir, tmpDir, { recursive: true }) + const journalPath = path.join(tmpDir, 'meta/_journal.json') + const journal = JSON.parse(fs.readFileSync(journalPath, 'utf-8')) as { + entries: Array<{ idx: number, tag: string }> + } + journal.entries = journal.entries.filter(entry => entry.tag < '0033') + fs.writeFileSync(journalPath, JSON.stringify(journal)) + const db = drizzle(raw, { schema }) + migrate(db, { migrationsFolder: tmpDir }) + const gesture = raw.prepare(`INSERT INTO tap_gestures (side, tap_type, action_type, alarm_behavior, alarm_snooze_duration) + VALUES (?, ?, ?, ?, ?)`) + // Left: the double tap's length wins over the triple tap's; a stop tap doesn't count. + gesture.run('left', 'tripleTap', 'alarm', 'snooze', 420) + gesture.run('left', 'doubleTap', 'temperature', 'snooze', 120) + gesture.run('left', 'quadTap', 'alarm', 'dismiss', 600) + // Right: no snoozing gesture. + gesture.run('right', 'doubleTap', 'alarm', 'dismiss', null) + const alarm = raw.prepare(`INSERT INTO alarm_schedules (side, day_of_week, time, vibration_intensity, duration, alarm_temperature) + VALUES (?, 'monday', '07:00', 50, 60, 80)`) + alarm.run('left') + alarm.run('right') + + migrate(db, { migrationsFolder: migrationsDir }) + + expect(raw.prepare('SELECT side, snooze_duration AS snooze, snooze_taper AS taper FROM alarm_schedules ORDER BY side').all()) + .toEqual([ + { side: 'left', snooze: 120, taper: 0 }, + { side: 'right', snooze: 300, taper: 0 }, + ]) + } + finally { + raw.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + it('biometrics DB migrations apply cleanly from empty', () => { const raw = new Database(':memory:') try { diff --git a/src/db/tests/retention.test.ts b/src/db/tests/retention.test.ts index 7eff879e0..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([ @@ -142,13 +146,19 @@ describe('pruneOldBiometrics', () => { { checkId: 'dac', status: 'ok', startedAt: old, lastSeenAt: fresh }, ]).run() + db.insert(schema.flashWriteSamples).values([ + { timestamp: old, bootId: 'a', sectorsWritten: 1 }, + { timestamp: fresh, bootId: 'a', sectorsWritten: 2 }, + ]).run() + const result = pruneOldBiometrics(cutoff, db) - expect(result.rowsDeleted).toBe(11) + 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, @@ -157,6 +167,7 @@ describe('pruneOldBiometrics', () => { pump_alerts: 1, thermal_state: 1, health_runs: 1, + flash_write_samples: 1, }) // Verify fresh rows remain @@ -257,6 +268,7 @@ describe('pruneOldBiometrics', () => { vitals: 0, vitals_quality: 0, movement: 0, + heartbeats: 0, bed_temp: 0, freezer_temp: 0, flow_readings: 0, @@ -265,6 +277,7 @@ describe('pruneOldBiometrics', () => { pump_alerts: 0, thermal_state: 0, health_runs: 0, + flash_write_samples: 0, }) }) diff --git a/src/db/tests/scheduleLibraryAdoption.test.ts b/src/db/tests/scheduleLibraryAdoption.test.ts new file mode 100644 index 000000000..c4d365905 --- /dev/null +++ b/src/db/tests/scheduleLibraryAdoption.test.ts @@ -0,0 +1,102 @@ +/** + * runMigrations on real in-memory databases: a pod upgrading from before the + * schedule library (0028) gets its schedules adopted exactly once. + */ + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as schema from '../schema' +import * as biometricsSchema from '../biometrics-schema' + +const holder = vi.hoisted(() => ({ main: null as unknown, sqlite: null as unknown, biometrics: null as unknown })) + +vi.mock('../index', () => ({ + get db() { return holder.main }, + get sqlite() { return holder.sqlite }, +})) +vi.mock('../biometrics', () => ({ + get biometricsDb() { return holder.biometrics }, + closeBiometricsDatabase: vi.fn(), +})) +vi.mock('@/src/scheduler', () => ({ getJobManager: vi.fn() })) + +const { runMigrations } = await import('../migrate') + +const MIGRATIONS = path.resolve(process.cwd(), 'src/db/migrations') + +function marker(raw: Database.Database) { + return raw.prepare('SELECT name FROM sqlite_master WHERE type = \'table\' AND name = \'schedule_library_adoption\'').get() +} + +describe('schedule library adoption on upgrade', () => { + let raw: Database.Database + let bio: Database.Database + let tmpDir: string + + beforeEach(() => { + raw = new Database(':memory:') + bio = new Database(':memory:') + holder.sqlite = raw + holder.main = drizzle(raw, { schema }) + holder.biometrics = drizzle(bio, { schema: biometricsSchema }) + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'schedule-library-')) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + raw.close() + bio.close() + fs.rmSync(tmpDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('leaves the marker after a fresh full migration', () => { + migrate(drizzle(raw, { schema }), { migrationsFolder: MIGRATIONS }) + expect(marker(raw)).toBeDefined() + expect(raw.prepare('SELECT count(*) AS n FROM schedule_library_adoption').get()).toEqual({ n: 1 }) + }) + + it('adopts pre-library schedules on the upgrade and drops the marker, then never again', async () => { + // Migrate to the release before the library, as an existing pod would be. + fs.cpSync(MIGRATIONS, tmpDir, { recursive: true }) + const journalPath = path.join(tmpDir, 'meta/_journal.json') + const journal = JSON.parse(fs.readFileSync(journalPath, 'utf-8')) as { entries: Array<{ tag: string }> } + journal.entries = journal.entries.filter(e => e.tag < '0028') + fs.writeFileSync(journalPath, JSON.stringify(journal)) + migrate(drizzle(raw, { schema }), { migrationsFolder: tmpDir }) + for (const day of ['monday', 'tuesday']) { + raw.prepare('INSERT INTO temperature_schedules (side, day_of_week, time, temperature) VALUES (?, ?, ?, ?), (?, ?, ?, ?)') + .run('left', day, '22:00', 70, 'left', day, '06:30', 78) + } + + await runMigrations() + + expect(marker(raw)).toBeUndefined() + const curves = raw.prepare('SELECT name, left_days AS leftDays, active FROM curve_templates').all() + expect(curves).toEqual([{ name: 'Left schedule', leftDays: '["monday","tuesday"]', active: 1 }]) + expect(console.log).toHaveBeenCalledWith('✓ Added 1 existing schedule to the schedule library') + + // Later schedules written row by row stay out of the library. + raw.prepare('INSERT INTO temperature_schedules (side, day_of_week, time, temperature) VALUES (?, ?, ?, ?)').run('right', 'friday', '23:00', 72) + await runMigrations() + expect(raw.prepare('SELECT count(*) AS n FROM curve_templates').get()).toEqual({ n: 1 }) + }) + + it('keeps the marker when adoption fails, so the next start retries', async () => { + migrate(drizzle(raw, { schema }), { migrationsFolder: MIGRATIONS }) + raw.exec('DROP TABLE side_settings') + raw.prepare('INSERT INTO temperature_schedules (side, day_of_week, time, temperature) VALUES (?, ?, ?, ?)').run('left', 'monday', '22:00', 70) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(runMigrations()).resolves.toBeUndefined() + + expect(error).toHaveBeenCalledWith(expect.stringContaining('retrying on next start'), expect.any(Error)) + expect(marker(raw)).toBeDefined() + expect(raw.prepare('SELECT count(*) AS n FROM curve_templates').get()).toEqual({ n: 0 }) + }) +}) diff --git a/src/db/tests/schema.test.ts b/src/db/tests/schema.test.ts index b8c24c807..de5bdce75 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: [ @@ -341,6 +352,8 @@ const expectedTables: Record = { { name: 'duration', notNull: true, default: undefined }, { name: 'alarm_temperature', notNull: true, default: undefined }, { name: 'wake_window', notNull: true, default: 0 }, + { name: 'snooze_duration', notNull: true, default: 300 }, + { name: 'snooze_taper', notNull: true, default: 0 }, { name: 'enabled', notNull: true, default: true }, { name: 'created_at', notNull: true, default: 'sql' }, { name: 'updated_at', notNull: true, default: 'sql' }, @@ -384,6 +397,10 @@ const expectedTables: Record = { { name: 'wake', notNull: true, default: undefined }, { name: 'points', notNull: true, default: undefined }, { name: 'reasoning', notNull: true, default: '' }, + { name: 'end_action', notNull: true, default: 'turn_off' }, + { name: 'left_days', notNull: true, default: 'sql' }, + { name: 'right_days', notNull: true, default: 'sql' }, + { name: 'active', notNull: true, default: false }, { name: 'created_at', notNull: true, default: 'sql' }, { name: 'updated_at', notNull: true, default: 'sql' }, ], @@ -468,7 +485,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 8b37d1d2a..a10792930 100644 --- a/src/demo/biometrics/worker-build.json +++ b/src/demo/biometrics/worker-build.json @@ -1,5 +1,5 @@ { - "buildHash": "68a7805034b07a83cd61788260aeb8a0e08b07d1f8873ef959929b8afca3eb83", + "buildHash": "66ce9399442d8b91bdb7467afca07cfb234d1cdbd33a42e8c919f86df0912715", "identity": "esbuild-browser-evaluator-bundle", "simulated": true } diff --git a/src/demo/handlers/biometric-datasets.ts b/src/demo/handlers/biometric-datasets.ts index 07c8c4572..8a4533b84 100644 --- a/src/demo/handlers/biometric-datasets.ts +++ b/src/demo/handlers/biometric-datasets.ts @@ -37,6 +37,7 @@ export const datasetHandlers: DemoHandlers<'biometricTuning'> = { // qualified pod resource telemetry. Do not fabricate waveform publication. getReplayAdmission: () => ({ configured: false, qualificationId: null, admitted: false, reasons: ['unqualified'] }), listRawSources: () => ({ items: [], nextCursor: null }), + locateRawArchives: () => [], inspectRawSource: input => ({ status: 'deferred', nextCursor: input.afterName }), createWaveformDataset: () => ({ status: 'deferred' }), listSessions: (input) => { diff --git a/src/demo/handlers/biometric-references.ts b/src/demo/handlers/biometric-references.ts index 5859d763e..fde12f290 100644 --- a/src/demo/handlers/biometric-references.ts +++ b/src/demo/handlers/biometric-references.ts @@ -40,6 +40,9 @@ export const referenceHandlers: DemoHandlers<'biometricTuning'> = { imports.push({ descriptor, measurements: result.measurements.map(row => ({ ...row, id: ++rowId })) }) return { ...structuredClone(descriptor), alreadyImported: false } }, + // Demo imports always carry provenance; there are no older unlinked rows. + countLegacyReferences: () => ({ vitals: 0, stages: 0 }), + linkLegacyReferences: () => ({ vitals: 0, stages: 0, imports: 0 }), getImport: ({ id }) => structuredClone(get(id).descriptor), listImports: (input) => { const rows = imports.map(row => row.descriptor).filter(row => row.summary.side === input.side diff --git a/src/demo/handlers/biometricTuning.ts b/src/demo/handlers/biometricTuning.ts index e336a9a36..34538c64e 100644 --- a/src/demo/handlers/biometricTuning.ts +++ b/src/demo/handlers/biometricTuning.ts @@ -159,6 +159,7 @@ export const biometricTuning: DemoHandlers<'biometricTuning'> = { { label: 'candidate', revisionId, manifestHash: candidate.manifestHash, resolvedHash: candidate.hash, resolved: candidate.resolved }, ] }), + describeAnalysisExport: () => { throw new Error('SIMULATED DEMO: analysis snapshots need a real pod database and RAW archives.') }, getManifest: async () => ({ manifest: biometricManifest, manifestHash: await hash(biometricManifest), activationAvailable: true }), resolveProfile: input => resolve(input.overrides, input.baseManifestHash), createProfileRevision: createDemoRevision, diff --git a/src/demo/handlers/biometrics.ts b/src/demo/handlers/biometrics.ts index 4872bbe7d..dd8f7e8f7 100644 --- a/src/demo/handlers/biometrics.ts +++ b/src/demo/handlers/biometrics.ts @@ -6,17 +6,22 @@ import { demoMovement, demoNights, demoNightsOverlapping, + demoStageEpochs, demoVitals, editDemoNight, findDemoNight, summarizeEpochs, type DemoNight, + type DemoVital, type Side, } from '../history' import type { DemoHandlers, RouterOutputs } from '../types' -import { DAY, MINUTE } from '../util' +import { DAY, HOUR, MINUTE, hashSeed, randomBetween, seededRandom } from '../util' import { classifyDemoWindow } from '../biometrics/stages' import { demoStageView } from './biometricTuning' +import { mergeIntoBlocks, type SleepStageBlock } from '@/src/lib/sleep-stages' +import { compareNightsToWatch, HRV_NOTE, WATCH_PAIRING_WINDOW_MS, type DeviationNightInput } from '@/src/biometrics/references/watch-deviation' +import type { StageClassifier } from '@/src/lib/sleep-stage-rules' type SleepRecord = RouterOutputs['biometrics']['getSleepRecords'][number] @@ -47,6 +52,26 @@ function range(input: { startDate?: Date, endDate?: Date }) { const sidesFor = (side?: Side) => (side ? [side] : SIDES) const sideBit = (side: Side) => (side === 'right' ? 1 : 0) +// Watch samples have their own exact five-minute Unix grid. Select from the +// complete night's unchanged two-minute pod series so query edges cannot alter values. +const REFERENCE_STEP = 5 * MINUTE +function referenceGrid(side: Side, start: number, end: number): DemoVital[] { + return demoNightsOverlapping(side, start, end).flatMap((night) => { + const points = night.vitals + const first = points[0] + const last = points.at(-1) + if (!first || !last) return [] + const result: DemoVital[] = [] + let index = 0 + for (let t = Math.ceil(Math.max(start, first.t) / REFERENCE_STEP) * REFERENCE_STEP; t <= Math.min(end, last.t); t += REFERENCE_STEP) { + while (index + 1 < points.length && Math.abs(points[index + 1].t - t) < Math.abs(points[index].t - t)) index++ + // Don't bridge missing pod intervals when synthesizing reference values. + if (Math.abs(points[index].t - t) <= MINUTE) result.push({ ...points[index], t }) + } + return result + }).sort((a, b) => a.t - b.t) +} + const emptyStages = (sleepRecordId: number | null = null, enteredBedAt: number | null = null, leftBedAt: number | null = null) => ({ epochs: [], blocks: [], @@ -82,6 +107,61 @@ function lastNight(side: Side): DemoNight | undefined { }) ?? recent[0] } +/** + * Two simulated profile changes, the mornings after the 10th and 5th newest + * finished nights, so the deviation trend has markers and a story: each one + * brings the pod closer to the Watch. + */ +function demoConfigChanges(side: Side) { + const finished = demoNights(side).filter(n => n.leftBedAt !== null) + return [[1, 9], [2, 4]].flatMap(([generation, index]) => { + const night = finished[index] + return night?.leftBedAt ? [{ generation, atMs: night.leftBedAt.getTime() + 2 * HOUR }] : [] + }) +} + +/** How far the demo pod reads above the Watch before the first change, between them and after the second. */ +const DEMO_POD_OFFSET = { heartRate: [4.5, 2.8, 1.5], breathingRate: [2.4, 1.9, 1.4] } + +/** + * A finished demo night as compareToWatch sees it. The Watch reads a little + * below the pod (less so after each simulated profile change, `step`) with + * some scatter (HR every 10 min, breathing every 15 min, two SDNN samples a + * night), and the scripted hypnogram stands in for its stages, as in the + * sleepStages demo. + */ +function demoDeviationNight(n: DemoNight & { leftBedAt: Date }, classifier: StageClassifier, step: number): DeviationNightInput { + const start = n.enteredBedAt.getTime() + const end = n.leftBedAt.getTime() + const rand = seededRandom(hashSeed(`watch:${n.id}`)) + const pad = WATCH_PAIRING_WINDOW_MS.hrv + const vitals = demoVitals(n.side, start - pad, end + pad) + const inNight = vitals.filter(v => v.t >= start && v.t <= end) + const watch = (every: number, value: (v: typeof vitals[number]) => number) => + inNight.filter((_, i) => i % every === 0).map(v => ({ t: v.t, v: Math.round(value(v) * 10) / 10 })) + const reference: SleepStageBlock[] = mergeIntoBlocks(demoStageEpochs(n, start, end)) + return { + sleepRecordId: n.id, + enteredBedAt: n.enteredBedAt, + leftBedAt: n.leftBedAt, + pod: { + heartRate: vitals.map(v => ({ t: v.t, v: v.heartRate })), + breathingRate: vitals.map(v => ({ t: v.t, v: v.breathingRate })), + hrv: vitals.map(v => ({ t: v.t, v: v.hrv })), + }, + watch: { + heartRate: watch(5, v => v.heartRate - DEMO_POD_OFFSET.heartRate[step] + randomBetween(rand, -3, 3)), + breathingRate: watch(8, v => v.breathingRate - DEMO_POD_OFFSET.breathingRate[step] + randomBetween(rand, -1, 1)), + hrvSdnn: [1 / 3, 2 / 3].flatMap((f) => { + const v = inNight[Math.floor(inNight.length * f)] + return v ? [{ t: v.t, v: Math.round(v.hrv * randomBetween(rand, 0.6, 0.8)) }] : [] + }), + }, + epochs: classifyDemoWindow(n.side, start, end, classifier), + reference, + } +} + export const biometrics: DemoHandlers<'biometrics'> = { getSleepRecords: (input) => { const { start, end } = range(input) @@ -124,8 +204,7 @@ export const biometrics: DemoHandlers<'biometrics'> = { 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) + return referenceGrid(input.side, start, end) .map(v => ({ id: Math.floor(v.t / 1000) * 2 + sideBit(input.side), side: input.side, @@ -138,6 +217,54 @@ 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 referenceGrid(input.side, start, end) + .map(v => ({ timestamp: new Date(v.t), value: Math.round((input.metric === 'hrv_sdnn' ? v.hrv * 0.7 : v.breathingRate - 0.8) * 10) / 10 })) + .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) + if (startDate > endDate) throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) + if (endDate.getTime() - startDate.getTime() > 31 * DAY) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Select at most 31 days' }) + const minHours = input.minHours ?? 3 + const view = await demoStageView(input.side) + const changes = demoConfigChanges(input.side) + const nights = demoNights(input.side) + .filter((n): n is DemoNight & { leftBedAt: Date } => n.leftBedAt !== null + && n.enteredBedAt >= startDate && n.enteredBedAt <= endDate + && n.leftBedAt.getTime() - n.enteredBedAt.getTime() >= minHours * 3_600_000) + .reverse() + .map(n => demoDeviationNight(n, view.classifier, changes.filter(c => c.atMs <= n.enteredBedAt.getTime()).length)) + return { + side: input.side, + startDate, + endDate, + minHours, + classifier: { isDefault: view.isDefault, source: view.provenance.source, generation: view.provenance.generation, stageHash: view.provenance.stageHash }, + hrvNote: HRV_NOTE, + ...compareNightsToWatch(nights), + configChanges: changes.filter(c => c.atMs >= startDate.getTime() && c.atMs <= endDate.getTime()), + } + }, + getVitalsSummary: (input) => { const end = input.endDate?.getTime() ?? Date.now() const start = input.startDate?.getTime() ?? Date.now() - 7 * DAY diff --git a/src/demo/handlers/curveTemplates.ts b/src/demo/handlers/curveTemplates.ts index cb1d80d9f..15011aaa2 100644 --- a/src/demo/handlers/curveTemplates.ts +++ b/src/demo/handlers/curveTemplates.ts @@ -1,28 +1,121 @@ import type { DemoHandlers, RouterOutputs } from '../types' +import { hasDays, LIBRARY_SIDES, type LibrarySide, movedDays, normalizeDays, planClaims } from '@/src/lib/scheduleLibrary' +import type { DayOfWeek } from '@/src/lib/scheduleTime' +import { CURVES, WEEKEND_NIGHTS, WEEKNIGHTS, writeDemoCurveRows } from './schedules' type Template = RouterOutputs['curveTemplates']['list'][number] const templates: Template[] = [] let nextId = 1 +let seeded = false + +/** The demo schedules as library curves, plus one that is saved but off. Seeded lazily: schedules.ts imports this module. */ +function library(): Template[] { + if (seeded) return templates + seeded = true + const at = new Date(Date.now() - 30 * 24 * 3600_000) + const add = (name: string, side: LibrarySide | null, days: DayOfWeek[], points: [string, number][], notes = '') => { + const p = Object.fromEntries(points) + templates.push({ + id: nextId++, name, bedtime: points[0][0], wake: points[points.length - 1][0], points: p, notes, endAction: 'turn_off', + days: normalizeDays(side ? { [side]: days } : {}), active: side !== null, createdAt: at, updatedAt: at, + }) + } + add('Cool weeknights', 'left', WEEKNIGHTS, CURVES.left.weeknight) + add('Cool weekends', 'left', WEEKEND_NIGHTS, CURVES.left.weekend) + add('Warm weeknights', 'right', WEEKNIGHTS, CURVES.right.weeknight) + add('Warm weekends', 'right', WEEKEND_NIGHTS, CURVES.right.weekend) + add('Heatwave', null, [], [['22:00', 66], ['23:00', 62], ['03:00', 64], ['06:30', 72]], 'Colder all night for hot summer weeks.') + return templates +} + +/** Write `curve`'s rows after a change, clearing days it held `before`. */ +function sync(curve: Template, before: { days: Template['days'], active: boolean } | null) { + for (const side of LIBRARY_SIDES) { + const target = curve.active ? curve.days[side] : [] + const clear = before?.active ? before.days[side] : [] + if (target.length > 0 || clear.length > 0) writeDemoCurveRows(side, target, clear, curve) + } +} + +function claim(target: Template, before: { days: Template['days'], active: boolean } | null) { + const plan = planClaims(library(), target.id, target.days, target.active) + for (const u of plan.updates) { + const other = templates.find(t => t.id === u.id) + if (other) Object.assign(other, { days: u.days, active: u.active }) + } + sync(target, before) + return { curve: { ...target }, moved: movedDays(plan.moves) } +} + +/** Rows edited one by one leave the library curve that claimed their day (see releaseDays on the server). */ +export function releaseDemoDays(rows: Array<{ side: LibrarySide, dayOfWeek: DayOfWeek }>) { + for (const t of library()) { + if (!t.active) continue + const days = { left: [...t.days.left], right: [...t.days.right] } + for (const r of rows) days[r.side] = days[r.side].filter(d => d !== r.dayOfWeek) + if (days.left.length !== t.days.left.length || days.right.length !== t.days.right.length) { + Object.assign(t, { days, active: hasDays(days) }) + } + } +} export const curveTemplates: DemoHandlers<'curveTemplates'> = { - list: () => [...templates].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()), + list: () => [...library()].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()), save: (input) => { const now = new Date() - const existing = templates.find(t => t.name === input.name) + const existing = library().find(t => t.name === input.name) if (existing) { - Object.assign(existing, { ...input, notes: input.notes ?? '', updatedAt: now }) + const before = { days: existing.days, active: existing.active } + Object.assign(existing, { ...input, endAction: input.endAction ?? existing.endAction, notes: input.notes ?? '', updatedAt: now }) + if (existing.active) sync(existing, before) return existing } - const created: Template = { id: nextId++, ...input, notes: input.notes ?? '', createdAt: now, updatedAt: now } + const created: Template = { + id: nextId++, ...input, endAction: input.endAction ?? 'turn_off', notes: input.notes ?? '', + days: { left: [], right: [] }, active: false, createdAt: now, updatedAt: now, + } templates.push(created) return created }, + put: (input) => { + const now = new Date() + const days = normalizeDays(input.days) + const existing = input.id === undefined ? undefined : library().find(t => t.id === input.id) + if (input.id !== undefined && !existing) throw new Error(`No curve with id ${input.id}`) + if (library().some(t => t.name === input.name && t.id !== input.id)) throw new Error('Another curve already has that name') + const fields = { + name: input.name, bedtime: input.bedtime, wake: input.wake, points: input.points, notes: input.notes ?? '', + endAction: input.endAction ?? 'turn_off', days, active: input.active && hasDays(days), updatedAt: now, + } + if (existing) { + const before = { days: existing.days, active: existing.active } + Object.assign(existing, fields) + return claim(existing, before) + } + const created: Template = { id: nextId++, ...fields, createdAt: now } + templates.push(created) + return claim(created, null) + }, + + setActive: (input) => { + const t = library().find(c => c.id === input.id) + if (!t) throw new Error(`No curve with id ${input.id}`) + if (input.active && !hasDays(t.days)) throw new Error(`Pick days for ${t.name} before turning it on`) + if (t.active === input.active) return { curve: { ...t }, moved: [] } + const before = { days: t.days, active: t.active } + t.active = input.active + return claim(t, before) + }, + delete: (input) => { - const i = templates.findIndex(t => t.name === input.name) - if (i >= 0) templates.splice(i, 1) + const i = library().findIndex(t => t.name === input.name) + if (i >= 0) { + const [t] = templates.splice(i, 1) + if (t.active) sync({ ...t, active: false }, { days: t.days, active: true }) + } return { deleted: i >= 0 } }, } diff --git a/src/demo/handlers/schedules.ts b/src/demo/handlers/schedules.ts index a78223552..637537936 100644 --- a/src/demo/handlers/schedules.ts +++ b/src/demo/handlers/schedules.ts @@ -1,5 +1,7 @@ import type { DemoHandlers, RouterOutputs } from '../types' import { DAY, toCelsius } from '../util' +import { planCurveSave } from '@/src/lib/sleepCurve/savePlan' +import { releaseDemoDays } from './curveTemplates' type Side = 'left' | 'right' type Collection = RouterOutputs['schedules']['getAll'] @@ -8,8 +10,8 @@ type PowerRow = Collection['power'][number] type AlarmRow = Collection['alarm'][number] type DayOfWeek = TempRow['dayOfWeek'] -const WEEKNIGHTS: DayOfWeek[] = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'] -const WEEKEND_NIGHTS: DayOfWeek[] = ['friday', 'saturday'] +export const WEEKNIGHTS: DayOfWeek[] = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'] +export const WEEKEND_NIGHTS: DayOfWeek[] = ['friday', 'saturday'] const WEEKDAYS: DayOfWeek[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'] const createdAt = new Date(Date.now() - 30 * DAY) @@ -22,7 +24,7 @@ let alarm: AlarmRow[] = [] const notes = new Map() /** Night set points belong to the evening's day, post-midnight times included. */ -const CURVES: Record = { +export const CURVES: Record = { left: { weeknight: [['22:00', 72], ['22:30', 68], ['01:00', 66], ['04:00', 69], ['06:00', 74]], weekend: [['23:00', 72], ['23:30', 68], ['02:00', 66], ['05:00', 69], ['07:30', 75]], @@ -52,10 +54,10 @@ function seed() { } } for (const dayOfWeek of WEEKDAYS) { - alarm.push({ id: nextId++, side: 'left', dayOfWeek, time: '06:45', vibrationIntensity: 60, vibrationPattern: 'rise', duration: 30, alarmTemperature: 78, wakeWindow: 0, ...meta }) - alarm.push({ id: nextId++, side: 'right', dayOfWeek, time: '07:15', vibrationIntensity: 40, vibrationPattern: 'double', duration: 20, alarmTemperature: 84, wakeWindow: 0, ...meta }) + alarm.push({ id: nextId++, side: 'left', dayOfWeek, time: '06:45', vibrationIntensity: 60, vibrationPattern: 'rise', duration: 30, alarmTemperature: 78, wakeWindow: 0, snoozeDuration: 300, snoozeTaper: 0, ...meta }) + alarm.push({ id: nextId++, side: 'right', dayOfWeek, time: '07:15', vibrationIntensity: 40, vibrationPattern: 'double', duration: 20, alarmTemperature: 84, wakeWindow: 0, snoozeDuration: 300, snoozeTaper: 0, ...meta }) } - alarm.push({ id: nextId++, side: 'right', dayOfWeek: 'saturday', time: '08:30', vibrationIntensity: 30, vibrationPattern: 'rise', duration: 20, alarmTemperature: 84, wakeWindow: 0, ...meta, enabled: false }) + alarm.push({ id: nextId++, side: 'right', dayOfWeek: 'saturday', time: '08:30', vibrationIntensity: 30, vibrationPattern: 'rise', duration: 20, alarmTemperature: 84, wakeWindow: 0, snoozeDuration: 300, snoozeTaper: 0, ...meta, enabled: false }) } seed() @@ -69,6 +71,11 @@ function collection(side: Side, unit: 'F' | 'C' = 'F', day?: DayOfWeek): Collect } } +/** True when a row update changes more than its enabled flag. */ +function editsRow(changes: Record): boolean { + return Object.entries(changes).some(([key, value]) => key !== 'id' && key !== 'enabled' && value !== undefined) +} + function update(rows: T[], kind: string, id: number, changes: Partial): T { const row = rows.find(r => r.id === id) if (!row) throw new Error(`${kind} schedule with ID ${id} not found`) @@ -86,6 +93,32 @@ const stamp = () => { return { id: nextId++, createdAt: now, updatedAt: now } } +/** Write a library curve's rows on one side, as src/server/scheduleLibrary.ts does on the pod. */ +export function writeDemoCurveRows(side: Side, targetDays: DayOfWeek[], clearDays: DayOfWeek[], curve: { + points: Record + endAction: 'turn_off' | 'maintain' + notes: string +}) { + const plan = planCurveSave({ + sides: [side], + targetDays, + clearDays, + setPoints: Object.entries(curve.points).map(([time, temp]) => ({ time, temperature: temp })), + endAction: curve.endAction, + notes: curve.notes, + existing: { temperature: temperature.filter(r => r.side === side), power: power.filter(r => r.side === side) }, + }) + const deleted = { temperature: new Set(plan.deletes.temperature), power: new Set(plan.deletes.power) } + temperature = temperature.filter(r => !deleted.temperature.has(r.id)) + power = power.filter(r => !deleted.power.has(r.id)) + for (const c of plan.creates.temperature) temperature.push({ ...c, ...stamp() }) + for (const c of plan.creates.power) power.push({ ...c, ...stamp() }) + for (const n of plan.notes) { + if (n.notes) notes.set(`${n.side}:${n.dayOfWeek}`, n.notes) + else notes.delete(`${n.side}:${n.dayOfWeek}`) + } +} + export const schedules: DemoHandlers<'schedules'> = { getAll: input => collection(input.side, input.unit), @@ -97,10 +130,16 @@ export const schedules: DemoHandlers<'schedules'> = { createTemperatureSchedule: (input) => { const row: TempRow = { ...input, enabled: input.enabled ?? true, ...stamp() } temperature.push(row) + releaseDemoDays([row]) return { ...row } }, - updateTemperatureSchedule: ({ id, ...changes }) => update(temperature, 'Temperature', id, changes), + updateTemperatureSchedule: ({ id, ...changes }) => { + const row = update(temperature, 'Temperature', id, changes) + if (editsRow(changes)) releaseDemoDays([row]) + return row + }, deleteTemperatureSchedule: (input) => { + releaseDemoDays(temperature.filter(r => r.id === input.id)) temperature = remove(temperature, 'Temperature', input.id) return { success: true } }, @@ -108,16 +147,22 @@ export const schedules: DemoHandlers<'schedules'> = { createPowerSchedule: (input) => { const row: PowerRow = { ...input, endAction: input.endAction ?? 'turn_off', enabled: input.enabled ?? true, ...stamp() } power.push(row) + releaseDemoDays([row]) return { ...row } }, - updatePowerSchedule: ({ id, ...changes }) => update(power, 'Power', id, changes), + updatePowerSchedule: ({ id, ...changes }) => { + const row = update(power, 'Power', id, changes) + if (editsRow(changes)) releaseDemoDays([row]) + return row + }, deletePowerSchedule: (input) => { + releaseDemoDays(power.filter(r => r.id === input.id)) power = remove(power, 'Power', input.id) return { success: true } }, createAlarmSchedule: (input) => { - const row: AlarmRow = { ...input, vibrationPattern: input.vibrationPattern ?? 'rise', wakeWindow: input.wakeWindow ?? 0, enabled: input.enabled ?? true, ...stamp() } + const row: AlarmRow = { ...input, vibrationPattern: input.vibrationPattern ?? 'rise', wakeWindow: input.wakeWindow ?? 0, snoozeDuration: input.snoozeDuration ?? 300, snoozeTaper: input.snoozeTaper ?? 0, enabled: input.enabled ?? true, ...stamp() } alarm.push(row) return { ...row } }, @@ -135,13 +180,21 @@ export const schedules: DemoHandlers<'schedules'> = { ?? missing(alarm, input.updates?.alarm?.map(u => u.id)) if (bad !== undefined) throw new Error(`Schedule with ID ${bad} not found`) + const edited = [ + ...temperature.filter(r => input.deletes?.temperature?.includes(r.id)), + ...power.filter(r => input.deletes?.power?.includes(r.id)), + ...(input.creates?.temperature ?? []), + ...(input.creates?.power ?? []), + ...temperature.filter(r => input.updates?.temperature?.some(u => u.id === r.id && editsRow(u))), + ...power.filter(r => input.updates?.power?.some(u => u.id === r.id && editsRow(u))), + ] for (const id of input.deletes?.temperature ?? []) temperature = remove(temperature, 'Temperature', id) for (const id of input.deletes?.power ?? []) power = remove(power, 'Power', id) for (const id of input.deletes?.alarm ?? []) alarm = remove(alarm, 'Alarm', id) for (const c of input.creates?.temperature ?? []) temperature.push({ ...c, enabled: c.enabled ?? true, ...stamp() }) for (const c of input.creates?.power ?? []) power.push({ ...c, endAction: c.endAction ?? 'turn_off', enabled: c.enabled ?? true, ...stamp() }) - for (const c of input.creates?.alarm ?? []) alarm.push({ ...c, vibrationPattern: c.vibrationPattern ?? 'rise', wakeWindow: c.wakeWindow ?? 0, enabled: c.enabled ?? true, ...stamp() }) + for (const c of input.creates?.alarm ?? []) alarm.push({ ...c, vibrationPattern: c.vibrationPattern ?? 'rise', wakeWindow: c.wakeWindow ?? 0, snoozeDuration: c.snoozeDuration ?? 300, snoozeTaper: c.snoozeTaper ?? 0, enabled: c.enabled ?? true, ...stamp() }) for (const { id, ...changes } of input.updates?.temperature ?? []) update(temperature, 'Temperature', id, changes) for (const { id, ...changes } of input.updates?.power ?? []) update(power, 'Power', id, changes) @@ -152,6 +205,7 @@ export const schedules: DemoHandlers<'schedules'> = { if (text) notes.set(`${n.side}:${n.dayOfWeek}`, text) else notes.delete(`${n.side}:${n.dayOfWeek}`) } + releaseDemoDays(edited) return { success: true } }, 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/handlers/system.ts b/src/demo/handlers/system.ts index 2a24b66ce..cc308cff4 100644 --- a/src/demo/handlers/system.ts +++ b/src/demo/handlers/system.ts @@ -1,3 +1,5 @@ +import { changeRecordingPolicy, DEFAULT_RECORDING_POLICY } from '@/src/lib/recordingPolicy' +import { flashInsights } from '@/src/lib/flashInsights' import type { DemoHandlers, RouterOutputs } from '../types' import { DAY, HOUR, MINUTE, hashSeed, seededRandom } from '../util' @@ -12,6 +14,7 @@ const COMMIT = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA?.slice(0, 7) || 'de const COMMIT_TITLE = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_MESSAGE?.split('\n')[0] || 'Demo build' const loadedAt = Date.now() +let recordingPolicy = { ...DEFAULT_RECORDING_POLICY } let internetBlocked = true let buildDate = new Date(loadedAt - 3 * DAY).toISOString() let pendingUpdateAt: number | null = null @@ -152,8 +155,31 @@ export const system: DemoHandlers<'system'> = { biometricsArchive: { usedBytes: Math.round(2.6 * GB), fileCount: 168 }, }), + getRecordingPolicy: () => recordingPolicy, + setRecordingPolicy: (input) => { + recordingPolicy = changeRecordingPolicy(recordingPolicy, input) + return recordingPolicy + }, getStorage: () => storage(), + getFlashHealth: () => { + const now = Date.now() + return ({ + insights: flashInsights(Array.from({ length: 169 }, (_, i) => ({ + at: now - (168 - i) * 3_600_000, + bootId: 'demo', + sectorsWritten: Math.round((i * 120 + Math.sin(i / 3) * 25) * MB / 512), + })), now), + device: 'mmcblk0', + name: 'C9A551', + capacityBytes: Math.round(29.1 * GB), + lifeTime: [{ minPercent: 0, maxPercent: 10 }, { minPercent: 10, maxPercent: 20 }], + preEol: 'normal', + sinceBoot: { bytes: Math.round(1.4 * GB), uptimeSeconds: Math.round((Date.now() - loadedAt) / 1000) + 9 * 3600 }, + perDay: { bytes: Math.round(3.1 * GB), coveredHours: 168 }, + }) + }, + freeStorage: (input) => { const removed = reclaimable.filter(i => input.includeDbBackups || i.kind !== 'backup') reclaimable = reclaimable.filter(i => !removed.includes(i)) diff --git a/src/demo/sensorFrames.ts b/src/demo/sensorFrames.ts index 31160a526..b45130b2d 100644 --- a/src/demo/sensorFrames.ts +++ b/src/demo/sensorFrames.ts @@ -2,7 +2,7 @@ import { recordedBedTemperature } from './bedTemperatureSamples' import { getDemoDeviceStatus } from './handlers/device' import { randomBetween, toCelsius } from './util' -export const ALL_SENSORS = ['piezo-dual', 'capSense', 'capSense2', 'bedTemp', 'bedTemp2', 'frzTemp', 'frzTherm', 'frzHealth', 'log', 'deviceStatus', 'gesture', 'lps'] +export const ALL_SENSORS = ['piezo-dual', 'capSense', 'capSense2', 'bedTemp', 'bedTemp2', 'frzTemp', 'frzTherm', 'frzHealth', 'log', 'deviceStatus', 'gesture', 'lps', 'frzWater', 'sensHealth'] const PIEZO_HZ = 500 const NO_SENSOR = -327.68 @@ -79,9 +79,14 @@ export function freezerFrames(ts: number) { { type: 'frzTemp', ts, left: waterC(status.leftSide.currentTemperature), right: waterC(status.rightSide.currentTemperature), amb: 2140, hs: 2530 }, { type: 'frzTherm', ts, version: 1, left: therm(status.leftSide), right: therm(status.rightSide) }, { type: 'frzHealth', ts, version: 1, left: sideFrz(status.leftSide), right: sideFrz(status.rightSide), fan: { top: { rpm: Math.round(randomBetween(Math.random, 1100, 1250)) }, bottom: { rpm: 0 } } }, + { type: 'frzWater', ts, isPriming: status.isPriming, hasWater: true, capValid: true, capLevel: 4, needsPriming: false }, ] } +export function sensHealthFrame(ts: number) { + return { type: 'sensHealth', ts, version: 1, left: { connected: true }, right: { connected: true } } +} + export function deviceStatusFrame() { return { type: 'deviceStatus', ts: Date.now(), ...getDemoDeviceStatus('F') } } @@ -95,7 +100,7 @@ export function snapshotMessage() { const ts = nowSec() return { type: 'snapshot', - latest: [bedTempFrame(ts), capSenseFrame(ts), ...freezerFrames(ts), logFrame(ts), deviceStatusFrame()], + latest: [bedTempFrame(ts), capSenseFrame(ts), ...freezerFrames(ts), sensHealthFrame(ts), logFrame(ts), deviceStatusFrame()], waveform: Array.from({ length: 10 }, (_, i) => piezoFrame(ts - 10 + i)), range: { min: ts - 6 * 3600, max: ts }, } diff --git a/src/demo/socket.ts b/src/demo/socket.ts index 42fa2e3e6..30ea11149 100644 --- a/src/demo/socket.ts +++ b/src/demo/socket.ts @@ -82,6 +82,7 @@ class DemoSensorSocket { const ts = f.nowSec() this.emit(f.bedTempFrame(ts)) for (const frame of f.freezerFrames(ts)) this.emit(frame) + this.emit(f.sensHealthFrame(ts)) }) } } diff --git a/src/demo/tests/biometrics.compareToWatch.test.ts b/src/demo/tests/biometrics.compareToWatch.test.ts new file mode 100644 index 000000000..e0d3534a9 --- /dev/null +++ b/src/demo/tests/biometrics.compareToWatch.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { biometrics } from '../handlers/biometrics' +import { demoNights } from '../history' +import { DAY } from '../util' + +function required(handler: T | undefined): T { + if (!handler) throw new Error('Missing demo handler') + return handler +} + +const NOW = Date.UTC(2026, 9, 10, 12) + +describe('demo biometrics.compareToWatch', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports the finished nights of the last 14 days, oldest first, with a pod that reads a little high', async () => { + const out = await required(biometrics.compareToWatch)({ side: 'left', minHours: 3 }) + const expected = demoNights('left') + .filter(n => n.leftBedAt !== null && n.enteredBedAt.getTime() >= NOW - 14 * DAY && n.sleepDurationSeconds >= 3 * 3600) + .map(n => n.id) + .reverse() + expect(out.nights.map(n => n.sleepRecordId)).toEqual(expected) + expect(out.nights.length).toBeGreaterThan(5) + expect(out.summary.nights).toBe(out.nights.length) + expect(out.summary.heartRate.n).toBe(out.nights.reduce((s, n) => s + n.heartRate.n, 0)) + expect(out.summary.heartRate.bias).toBeGreaterThan(0) + expect(out.summary.breathingRate.bias).toBeGreaterThan(0) + expect(out.summary.hrv.watchSamples).toBe(2 * out.nights.length) + expect(out.summary.stages?.epochs).toBeGreaterThan(0) + expect(out.hrvNote).toContain('SDNN') + // Deterministic across calls, like the rest of the demo history. + expect(await required(biometrics.compareToWatch)({ side: 'left', minHours: 3 })).toEqual(out) + }) + + it('marks two simulated profile changes, each bringing heart rate closer to the Watch', async () => { + const out = await required(biometrics.compareToWatch)({ side: 'left', minHours: 3 }) + expect(out.configChanges.map(c => c.generation)).toEqual([1, 2]) + const [first, second] = out.configChanges + const bias = (from: number, to: number) => { + const nights = out.nights.filter(n => n.enteredBedAt.getTime() > from && n.enteredBedAt.getTime() < to && n.heartRate.bias !== null) + expect(nights.length).toBeGreaterThan(0) + return nights.reduce((s, n) => s + (n.heartRate.bias ?? 0), 0) / nights.length + } + expect(bias(0, first.atMs)).toBeGreaterThan(bias(first.atMs, second.atMs)) + expect(bias(first.atMs, second.atMs)).toBeGreaterThan(bias(second.atMs, Infinity)) + + const recent = await required(biometrics.compareToWatch)({ side: 'left', minHours: 3, startDate: new Date(second.atMs - 1), endDate: new Date(NOW) }) + expect(recent.configChanges).toEqual([second]) + }) + + it('rejects a reversed window and one over 31 days', async () => { + await expect(async () => required(biometrics.compareToWatch)({ side: 'left', startDate: new Date(NOW), endDate: new Date(NOW - 1000), minHours: 3 })) + .rejects.toThrow(/startDate must be before or equal to endDate/) + await expect(async () => required(biometrics.compareToWatch)({ side: 'left', startDate: new Date(NOW - 32 * DAY), endDate: new Date(NOW), minHours: 3 })) + .rejects.toThrow(/at most 31 days/) + }) +}) diff --git a/src/demo/tests/biometrics.referenceVitals.test.ts b/src/demo/tests/biometrics.referenceVitals.test.ts index bfbe30139..1932f3bd7 100644 --- a/src/demo/tests/biometrics.referenceVitals.test.ts +++ b/src/demo/tests/biometrics.referenceVitals.test.ts @@ -9,28 +9,33 @@ function required(handler: T | undefined): T { const NOW = Date.UTC(2026, 9, 10, 12) -describe('demo biometrics.getReferenceVitals', () => { - beforeEach(() => { - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(NOW) - }) +// Every case needs a completed synthetic night, even when CI runs at bedtime. +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) +}) - afterEach(() => { - vi.useRealTimers() - }) +afterEach(() => { + vi.useRealTimers() +}) - it('returns the Watch overlay for the latest night, ascending and every fifth pod sample', async () => { +describe('demo biometrics.getReferenceVitals', () => { + it('returns the Watch overlay for the latest night, ascending on a fixed five-minute grid', async () => { const night = demoNights('left')[0] const startDate = new Date(night.vitals[0].t) const endDate = new Date(night.vitals[night.vitals.length - 1].t) const out = await required(biometrics.getReferenceVitals)({ side: 'left', startDate, endDate, limit: 5000 }) const pod = demoVitals('left', startDate.getTime(), endDate.getTime()) - expect(out.length).toBe(Math.ceil(pod.length / 5)) + const expectedTimes: number[] = [] + for (let t = Math.ceil(pod[0].t / 300_000) * 300_000; t <= pod[pod.length - 1].t; t += 300_000) { + if (pod.some(p => Math.abs(p.t - t) <= 60_000)) expectedTimes.push(t) + } + expect(out.map(r => r.timestamp.getTime())).toEqual(expectedTimes) expect(out.every(s => s.side === 'left' && s.source === 'apple_watch')).toBe(true) const times = out.map(s => s.timestamp.getTime()) expect(times).toEqual([...times].sort((a, b) => a - b)) - expect(out[0].heartRate).toBeCloseTo(Math.round((pod[0].heartRate - 1.5) * 10) / 10, 5) + expect(out[0].heartRate).toBeCloseTo(Math.round((pod.reduce((a, b) => Math.abs(b.t - out[0].timestamp.getTime()) < Math.abs(a.t - out[0].timestamp.getTime()) ? b : a).heartRate - 1.5) * 10) / 10, 5) expect(out[0].createdAt.getTime()).toBe(out[0].timestamp.getTime()) }) @@ -50,3 +55,43 @@ 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/) + }) +}) + +it.each(['hrv_sdnn', 'breathing_rate'] as const)('keeps %s samples stable across overlapping windows and distinct from pod values', 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) + const startDate = new Date(all[1].timestamp.getTime() - 60_000) + const partial = await required(biometrics.getReferenceSamples)({ ...input, startDate }) + expect(partial).toEqual(all.filter(r => r.timestamp >= startDate)) + const pod = demoVitals('left', input.startDate.getTime(), input.endDate.getTime()) + for (const sample of all) { + expect(sample.timestamp.getTime() % 300_000).toBe(0) + const point = pod.reduce((a, b) => Math.abs(b.t - sample.timestamp.getTime()) < Math.abs(a.t - sample.timestamp.getTime()) ? b : a) + expect(point).toBeDefined() + if (!point) throw new Error('Expected matching pod sample') + expect(sample.value).not.toBe(metric === 'hrv_sdnn' ? point.hrv : point.breathingRate) + } + expect(all.slice(1).some((r, i) => r.timestamp.getTime() - all[i].timestamp.getTime() === 300_000)).toBe(true) + expect(await required(biometrics.getReferenceSamples)({ ...input, startDate: all[1].timestamp, endDate: all[1].timestamp })).toEqual([all[1]]) +}) diff --git a/src/demo/tests/curveTemplates.test.ts b/src/demo/tests/curveTemplates.test.ts new file mode 100644 index 000000000..3c0897bdf --- /dev/null +++ b/src/demo/tests/curveTemplates.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DemoHandlers, RouterInputs } from '../types' + +let curveTemplates: DemoHandlers<'curveTemplates'> +let schedules: DemoHandlers<'schedules'> + +function required(handler: T | undefined): T { + if (!handler) throw new Error('Missing demo handler') + return handler +} + +beforeEach(async () => { + vi.resetModules() + ;({ curveTemplates } = await import('../handlers/curveTemplates')) + ;({ schedules } = await import('../handlers/schedules')) +}) + +const list = () => required(curveTemplates.list)({}) +const curve = async (name: string) => { + const found = (await list()).find(t => t.name === name) + if (!found) throw new Error(`no ${name}`) + return found +} +const rows = async (side: 'left' | 'right', day: string) => { + const all = await required(schedules.getAll)({ side }) + return { + temperature: all.temperature.filter(r => r.dayOfWeek === day).map(r => `${r.time}@${r.temperature}`).sort(), + power: all.power.filter(r => r.dayOfWeek === day), + } +} +const HEATWAVE: Omit = { + name: 'Heatwave', bedtime: '22:00', wake: '06:30', points: { '22:00': 66, '23:00': 62, '03:00': 64, '06:30': 72 }, notes: 'Hot week', +} + +describe('demo schedule library', () => { + it('seeds the demo schedules as active curves plus one that is off', async () => { + const curves = await list() + expect(curves.map(c => [c.name, c.active, c.days])).toEqual(expect.arrayContaining([ + ['Cool weeknights', true, { left: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'], right: [] }], + ['Cool weekends', true, { left: ['friday', 'saturday'], right: [] }], + ['Warm weeknights', true, { left: [], right: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'] }], + ['Warm weekends', true, { left: [], right: ['friday', 'saturday'] }], + ['Heatwave', false, { left: [], right: [] }], + ])) + expect(curves).toHaveLength(5) + }) + + it('refuses to turn on a curve without days', async () => { + const heatwave = await curve('Heatwave') + expect(() => required(curveTemplates.setActive)({ id: heatwave.id, active: true })).toThrow('Pick days for Heatwave') + expect(() => required(curveTemplates.setActive)({ id: 999, active: true })).toThrow('No curve with id 999') + }) + + it('moves days from the curve running them and writes the rows', async () => { + const heatwave = await curve('Heatwave') + const result = await required(curveTemplates.put)({ ...HEATWAVE, id: heatwave.id, days: { left: ['monday', 'tuesday'] }, active: true }) + expect(result.moved).toEqual([{ name: 'Cool weeknights', side: 'left', days: ['monday', 'tuesday'], deactivated: false }]) + expect(result.curve).toMatchObject({ active: true, days: { left: ['monday', 'tuesday'], right: [] } }) + expect((await curve('Cool weeknights')).days.left).toEqual(['sunday', 'wednesday', 'thursday']) + + const monday = await rows('left', 'monday') + expect(monday.temperature).toEqual(['03:00@64', '06:30@72', '22:00@66', '23:00@62']) + expect(monday.power).toMatchObject([{ onTime: '22:00', offTime: '06:30', onTemperature: 66, endAction: 'turn_off' }]) + expect((await required(schedules.getNotes)({ side: 'left' })).filter(n => n.dayOfWeek === 'monday')).toEqual([{ dayOfWeek: 'monday', notes: 'Hot week' }]) + + await required(curveTemplates.setActive)({ id: heatwave.id, active: false }) + expect(await rows('left', 'monday')).toEqual({ temperature: [], power: [] }) + expect((await curve('Heatwave')).days.left).toEqual(['monday', 'tuesday']) + expect(await required(curveTemplates.setActive)({ id: heatwave.id, active: false })).toMatchObject({ moved: [] }) + }) + + it('turns off a curve that gives up all its days', async () => { + const result = await required(curveTemplates.put)({ ...HEATWAVE, days: { right: ['friday', 'saturday'] }, active: true, name: 'Weekend heat' }) + expect(result.moved).toEqual([{ name: 'Warm weekends', side: 'right', days: ['friday', 'saturday'], deactivated: true }]) + expect(await curve('Warm weekends')).toMatchObject({ active: false, days: { left: [], right: [] } }) + }) + + it('saves a curve without days off, and rejects duplicate names and unknown ids', async () => { + const result = await required(curveTemplates.put)({ ...HEATWAVE, name: 'Later', days: {}, active: true }) + expect(result.curve).toMatchObject({ active: false, days: { left: [], right: [] } }) + expect(() => required(curveTemplates.put)({ ...HEATWAVE, days: {}, active: false })).toThrow('Another curve already has that name') + expect(() => required(curveTemplates.put)({ ...HEATWAVE, id: 999, name: 'X', days: {}, active: false })).toThrow('No curve with id 999') + }) + + it('save rewrites an active curve by name and stores a new one off', async () => { + await required(curveTemplates.save)({ name: 'Cool weekends', bedtime: '23:00', wake: '07:00', points: { '23:00': 70, '07:00': 78 }, endAction: 'maintain' }) + expect((await rows('left', 'friday')).temperature).toEqual(['07:00@78', '23:00@70']) + expect((await rows('left', 'friday')).power).toMatchObject([{ endAction: 'maintain' }]) + + const created = await required(curveTemplates.save)({ name: 'New one', bedtime: '22:00', wake: '06:00', points: { '22:00': 70, '06:00': 80 } }) + expect(created).toMatchObject({ active: false, endAction: 'turn_off', notes: '', days: { left: [], right: [] } }) + }) + + it('delete clears an active curve\'s days', async () => { + expect(await required(curveTemplates.delete)({ name: 'Cool weekends' })).toEqual({ deleted: true }) + expect(await rows('left', 'saturday')).toEqual({ temperature: [], power: [] }) + expect(await required(curveTemplates.delete)({ name: 'Cool weekends' })).toEqual({ deleted: false }) + expect(await required(curveTemplates.delete)({ name: 'Heatwave' })).toEqual({ deleted: true }) + }) + + it('releases days whose rows are edited one at a time, but not when only paused', async () => { + const monday = (await required(schedules.getAll)({ side: 'left' })).temperature.filter(r => r.dayOfWeek === 'monday') + await required(schedules.updateTemperatureSchedule)({ id: monday[0].id, enabled: false }) + expect((await curve('Cool weeknights')).days.left).toContain('monday') + await required(schedules.updateTemperatureSchedule)({ id: monday[0].id, temperature: 71 }) + expect((await curve('Cool weeknights')).days.left).not.toContain('monday') + + await required(schedules.createTemperatureSchedule)({ side: 'left', dayOfWeek: 'tuesday', time: '23:45', temperature: 70 }) + expect((await curve('Cool weeknights')).days.left).not.toContain('tuesday') + + const right = await required(schedules.getAll)({ side: 'right' }) + await required(schedules.deletePowerSchedule)({ id: required(right.power.find(p => p.dayOfWeek === 'friday')).id }) + expect((await curve('Warm weekends')).days.right).toEqual(['saturday']) + + const saturday = right.temperature.filter(r => r.dayOfWeek === 'saturday').map(r => r.id) + await required(schedules.batchUpdate)({ deletes: { temperature: saturday } }) + expect(await curve('Warm weekends')).toMatchObject({ active: false, days: { left: [], right: [] } }) + }) +}) 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/demo/tests/socket.test.ts b/src/demo/tests/socket.test.ts index ef3351238..0d887ffb2 100644 --- a/src/demo/tests/socket.test.ts +++ b/src/demo/tests/socket.test.ts @@ -30,7 +30,7 @@ describe('demo sensor socket', () => { expect(messages[0]).toMatchObject({ type: 'subscribed' }) const snapshot = messages[1] as unknown as { type: string, latest: Message[], waveform: Message[], range: { min: number, max: number } } expect(snapshot.type).toBe('snapshot') - expect(snapshot.latest.map(f => f.type)).toEqual(expect.arrayContaining(['bedTemp2', 'capSense2', 'frzTemp', 'frzTherm', 'frzHealth', 'log', 'deviceStatus'])) + expect(snapshot.latest.map(f => f.type)).toEqual(expect.arrayContaining(['bedTemp2', 'capSense2', 'frzTemp', 'frzTherm', 'frzHealth', 'frzWater', 'sensHealth', 'log', 'deviceStatus'])) expect(snapshot.waveform).toHaveLength(10) expect(snapshot.range.max).toBeGreaterThan(snapshot.range.min) socket.close() @@ -61,6 +61,8 @@ describe('demo sensor socket', () => { const frz = normalizeFrame(ofType('frzTemp')[0]) as { left: number | null } expect(frz.left).toBeGreaterThan(5) expect(normalizeFrame(ofType('frzTherm')[0])).toMatchObject({ type: 'frzTherm' }) + expect(normalizeFrame(ofType('frzWater')[0])).toMatchObject({ capValid: true, capLevel: 4, isPriming: false }) + expect(normalizeFrame(ofType('sensHealth')[0])).toMatchObject({ left: { connected: true }, right: { connected: true } }) expect(ofType('deviceStatus').length).toBeGreaterThanOrEqual(4) expect(ofType('log').length).toBeGreaterThan(0) diff --git a/src/hardware/alarmPersistence.ts b/src/hardware/alarmPersistence.ts index 0cd5a72db..1dbf07c3f 100644 --- a/src/hardware/alarmPersistence.ts +++ b/src/hardware/alarmPersistence.ts @@ -18,6 +18,12 @@ export interface AlarmConfig { vibrationPattern: 'double' | 'rise' /** Vibration length in seconds. */ duration: number + /** Snooze length in seconds set on the alarm; callers fall back without it. */ + snoozeDuration?: number + /** Snooze taper: percent each later snooze is shorter (0 = off). */ + snoozeTaper?: number + /** Snoozes already taken for this alarm occurrence. */ + snoozeCount?: number } export interface TimedAlarm { diff --git a/src/hardware/gestureActionHandler.ts b/src/hardware/gestureActionHandler.ts index 835c71471..f2055d930 100644 --- a/src/hardware/gestureActionHandler.ts +++ b/src/hardware/gestureActionHandler.ts @@ -2,6 +2,7 @@ import type { HardwareClient } from './client' import { MAX_TEMP, MIN_TEMP, type Side } from './types' import type { GestureEvent } from './dacMonitor' import type { AlarmConfig } from './alarmPersistence' +import { nextSnoozeSeconds } from './snoozeLength' import { getTemperatureController } from '@/src/temperature/instance' import { shouldBlock as pumpStallShouldBlock } from './pumpStallGuard' import { withSideLock } from '@/src/hardware/sideLock' @@ -61,8 +62,12 @@ export const DEFAULT_SNOOZE_ALARM: AlarmConfig = { * kept by alarmState: every path that starts an alarm marks it vibrating * and it is cleared when the alarm stops or its duration runs out. * - * The firmware itself stops a vibrating alarm on any tap gesture, so a - * gesture that isn't an alarm action still marks the alarm ended. + * Each tap has two actions: what it does normally (actionType: a + * temperature step, or alarmInactiveBehavior's power toggle / nothing) and + * what it does while an alarm is ringing (alarmBehavior: snooze or stop), + * for any actionType. While ringing a tap only does the latter — the + * firmware itself stops a vibrating alarm on any tap, so a tap with no + * ringing action just records the alarm ended. * * Pass `deps` to override DB/hardware behaviour in tests (dependency injection). */ @@ -90,10 +95,12 @@ export class GestureActionHandler { private execute = async (event: GestureEvent): Promise => { const gesture = await this.deps.findGestureConfig(event.side, event.tapType) - if (gesture?.actionType !== 'alarm') { - // The firmware already stopped any vibrating alarm on this tap. - const state = await this.deps.findDeviceState(event.side) - if (state?.isAlarmVibrating) await this.deps.alarm.ended(event.side) + const state = await this.deps.findDeviceState(event.side) + if (state?.isAlarmVibrating) { + if (gesture?.alarmBehavior) await this.handleRingingAction(event, gesture) + // The firmware already stopped the alarm on this tap. + else await this.deps.alarm.ended(event.side) + return } if (!gesture) return @@ -101,7 +108,7 @@ export class GestureActionHandler { await this.handleTemperatureAction(event, gesture) } else if (gesture.actionType === 'alarm') { - await this.handleAlarmAction(event, gesture) + await this.handleIdleAlarmAction(event, gesture) } } @@ -132,72 +139,68 @@ export class GestureActionHandler { }) } - private handleAlarmAction = async ( + /** A tap while an alarm is ringing: snooze or stop it. */ + private handleRingingAction = async ( event: GestureEvent, gesture: TapGestureRow ): Promise => { - const state = await this.deps.findDeviceState(event.side) - const isAlarmVibrating = state?.isAlarmVibrating ?? false + const client = this.deps.newHardwareClient(this.socketPath) + // The firmware already stopped the vibration on the tap, so the clear only + // confirms it. A failed write must not drop the snooze or leave the alarm + // marked vibrating. + const clear = async () => { + try { + await client.connect() + await client.clearAlarm(event.side) + } + catch (error) { + console.error(`[gestureActionHandler] clearAlarm failed for ${event.side}:`, error) + } + } + try { + if (gesture.alarmBehavior === 'snooze') { + // Read before ended() forgets it: the snoozed alarm comes back as it was. + const config = this.deps.alarm.activeConfig(event.side) ?? DEFAULT_SNOOZE_ALARM + await clear() + await this.deps.alarm.ended(event.side) + // The alarm sets the snooze length (and how it tapers), not the tap. + this.deps.alarm.snooze(event.side, nextSnoozeSeconds(config), config) + } + else { + await clear() + this.deps.alarm.cancelSnooze(event.side) + await this.deps.alarm.ended(event.side) + } + } + finally { + client.disconnect() + } + } - if (isAlarmVibrating) { - const client = this.deps.newHardwareClient(this.socketPath) - // The firmware already stopped the vibration on the tap, so the clear only - // confirms it. A failed write must not drop the snooze or leave the alarm - // marked vibrating. - const clear = async () => { - try { - await client.connect() - await client.clearAlarm(event.side) - } - catch (error) { - console.error(`[gestureActionHandler] clearAlarm failed for ${event.side}:`, error) - } + /** An alarm-type tap with no alarm ringing: toggle power, or nothing. */ + private handleIdleAlarmAction = async ( + event: GestureEvent, + gesture: TapGestureRow + ): Promise => { + if (gesture.alarmInactiveBehavior !== 'power') return + await withSideLock(event.side, async () => { + // Resolve the toggle after older queued commands have updated state. + const current = await this.deps.findDeviceState(event.side) + const nextPowered = !(current?.isPowered ?? false) + const target = current?.targetTemperature ?? 75 + if (nextPowered && pumpStallShouldBlock(event.side)) { + console.warn(`[gestureActionHandler] skipped power-on: pump stall guard blocks ${event.side}`) + return } + const client = this.deps.newHardwareClient(this.socketPath) try { - if (gesture.alarmBehavior === 'dismiss') { - await clear() - this.deps.alarm.cancelSnooze(event.side) - await this.deps.alarm.ended(event.side) - } - else if (gesture.alarmBehavior === 'snooze') { - // Read before ended() forgets it: the snoozed alarm comes back as it was. - const config = this.deps.alarm.activeConfig(event.side) ?? DEFAULT_SNOOZE_ALARM - await clear() - await this.deps.alarm.ended(event.side) - this.deps.alarm.snooze(event.side, gesture.alarmSnoozeDuration ?? 300, config) - } - else { - // No alarm behavior configured: the firmware still stopped it. - await this.deps.alarm.ended(event.side) - } + await client.connect() + if (nextPowered) await getTemperatureController().setManualLocked(event.side, target) + else await getTemperatureController().powerOffLocked(event.side) } finally { client.disconnect() } - } - else { - if (gesture.alarmInactiveBehavior === 'power') { - await withSideLock(event.side, async () => { - // Resolve the toggle after older queued commands have updated state. - const current = await this.deps.findDeviceState(event.side) - const nextPowered = !(current?.isPowered ?? false) - const target = current?.targetTemperature ?? 75 - if (nextPowered && pumpStallShouldBlock(event.side)) { - console.warn(`[gestureActionHandler] skipped power-on: pump stall guard blocks ${event.side}`) - return - } - const client = this.deps.newHardwareClient(this.socketPath) - try { - await client.connect() - if (nextPowered) await getTemperatureController().setManualLocked(event.side, target) - else await getTemperatureController().powerOffLocked(event.side) - } - finally { - client.disconnect() - } - }) - } - // alarmInactiveBehavior === 'none': no-op - } + }) } } diff --git a/src/hardware/iptablesCheck.ts b/src/hardware/iptablesCheck.ts index f2b96a3de..feaaeaad8 100644 --- a/src/hardware/iptablesCheck.ts +++ b/src/hardware/iptablesCheck.ts @@ -89,7 +89,8 @@ export function checkIptablesCached(): Promise { const outputs = Object.fromEntries(chains) const rules = buildRequiredRules(iptables).map(rule => ({ name: rule.name, chain: rule.chain, critical: rule.critical, - present: outputs[rule.chain] === null || !!outputs[rule.chain]?.includes(rule.check), + present: outputs[rule.chain] === null || chainAllowsAll(outputs[rule.chain] ?? '') + || !!outputs[rule.chain]?.includes(rule.check), })) const value = { ok: rules.every(rule => !rule.critical || rule.present), rules, repaired: [] } if (state.generation === generation) { @@ -139,6 +140,24 @@ function resolveIptablesPath(override?: string): string { return 'iptables' } +/** + * True when a `iptables -L -n` listing shows a chain that lets + * everything through: policy ACCEPT and only ACCEPT/LOG/RETURN rules. The + * required allow rules only matter in front of a drop, so with internet + * access allowed (all rules flushed) they are satisfied, not missing. Any + * other target — DROP, REJECT, or a jump to a custom chain that may drop + * (e.g. scripts/internet-control's SLEEPYPOD-BLOCK) — falls back to the + * exact-rule check. + */ +export function chainAllowsAll(listing: string): boolean { + const lines = listing.split('\n') + if (!lines.some(l => /^Chain \S+ \(policy ACCEPT/.test(l))) return false + return lines + .filter(l => l.trim() && !/^Chain \S+ \(policy ACCEPT[^)]*\)\s*$/.test(l) + && !/^target\s+prot\s+opt\s+source\s+destination\s*$/.test(l)) + .every(l => /^(?:ACCEPT|LOG|RETURN)\b/.test(l)) +} + function buildRequiredRules(iptables: string) { return [ { @@ -196,7 +215,7 @@ export function checkIptables(iptablesPath?: string): IptablesStatus { encoding: 'utf-8', timeout: 5000, }) - present = output.includes(rule.check) + present = chainAllowsAll(output) || output.includes(rule.check) } catch (e) { const msg = e instanceof Error ? e.message : '' diff --git a/src/hardware/snoozeLength.ts b/src/hardware/snoozeLength.ts new file mode 100644 index 000000000..5ee7ff1d6 --- /dev/null +++ b/src/hardware/snoozeLength.ts @@ -0,0 +1,23 @@ +/** + * How long a snooze lasts. Pure, so the gesture handler and HomeKit switch can + * use it without pulling in the snooze timers' hardware client. + */ +import type { AlarmConfig } from './alarmPersistence' + +/** The shortest a tapered snooze gets. */ +export const MIN_SNOOZE_SECONDS = 5 + +/** + * Length of the next snooze for the alarm ringing with `config`: the alarm's + * snooze length (or `fallbackSeconds` for an alarm without one), shortened + * by its snooze taper for each snooze already taken — 9 min at 30% gives 9, + * 6.3, 4.4, 3.1 min… — but never under MIN_SNOOZE_SECONDS (or the base + * length, if that is shorter). + */ +export function nextSnoozeSeconds(config: AlarmConfig, fallbackSeconds = 300): number { + const base = config.snoozeDuration ?? fallbackSeconds + const taper = Math.min(Math.max(config.snoozeTaper ?? 0, 0), 90) / 100 + const taken = Math.max(config.snoozeCount ?? 0, 0) + const seconds = Math.round(base * (1 - taper) ** taken) + return Math.max(seconds, Math.min(base, MIN_SNOOZE_SECONDS)) +} diff --git a/src/hardware/snoozeManager.ts b/src/hardware/snoozeManager.ts index 075d7bd4c..88ae248fd 100644 --- a/src/hardware/snoozeManager.ts +++ b/src/hardware/snoozeManager.ts @@ -14,6 +14,7 @@ import { getSharedHardwareClient } from './dacMonitor.instance' import { loadAlarmState, updateAlarmState, type AlarmConfig } from './alarmPersistence' import { markAlarmStarted } from './alarmState' +export { MIN_SNOOZE_SECONDS, nextSnoozeSeconds } from './snoozeLength' import type { Side } from './types' /** A snooze whose restart was missed by at most this much (the server was @@ -72,9 +73,11 @@ export function snoozeAlarm( durationSeconds = Math.min(durationSeconds, Math.floor(MAX_DELAY_MS / 1000)) const now = Date.now() const snoozeUntil = new Date(now + durationSeconds * 1000) - schedule(side, snoozeUntil, config, now) + // The alarm comes back counting this snooze, so a tapered next one is shorter. + const restart = { ...config, snoozeCount: (config.snoozeCount ?? 0) + 1 } + schedule(side, snoozeUntil, restart, now) updateAlarmState((s) => { - s.snoozes[side] = { until: snoozeUntil.getTime(), config } + s.snoozes[side] = { until: snoozeUntil.getTime(), config: restart } }) return snoozeUntil } diff --git a/src/hardware/tests/gestureActionHandler.test.ts b/src/hardware/tests/gestureActionHandler.test.ts index 550542d13..7a6c966ad 100644 --- a/src/hardware/tests/gestureActionHandler.test.ts +++ b/src/hardware/tests/gestureActionHandler.test.ts @@ -257,14 +257,15 @@ describe('GestureActionHandler', () => { expect(alarm.snooze).toHaveBeenCalledWith('left', 300, DEFAULT_SNOOZE_ALARM) }) - test('a snoozed alarm comes back with its own settings', async () => { - const scheduled: AlarmConfig = { vibrationIntensity: 100, vibrationPattern: 'rise', duration: 120 } + test('a snoozed alarm comes back with its own settings, for its own snooze length', async () => { + const scheduled: AlarmConfig = { vibrationIntensity: 100, vibrationPattern: 'rise', duration: 120, snoozeDuration: 540 } const alarm = makeAlarmHooks(scheduled) // ended() forgets the active alarm, so its settings must be read first. alarm.ended.mockImplementation(async () => { alarm.activeConfig.mockReturnValue(null) }) - const gesture = { actionType: 'alarm', alarmBehavior: 'snooze', alarmSnoozeDuration: 540 } + // The tap's own (legacy) length is ignored: the alarm sets it. + const gesture = { actionType: 'alarm', alarmBehavior: 'snooze', alarmSnoozeDuration: 120 } const { deps } = makeDeps(gesture, { isAlarmVibrating: true }, makeMockClient(), alarm) await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('right', 'doubleTap')) @@ -272,6 +273,18 @@ describe('GestureActionHandler', () => { expect(alarm.snooze).toHaveBeenCalledWith('right', 540, scheduled) }) + test('each later snooze of an alarm with a snooze taper is shorter', async () => { + // Third snooze of a 9 min alarm tapering 20%: 9 × 0.8² ≈ 5.8 min. + const ringing: AlarmConfig = { vibrationIntensity: 100, vibrationPattern: 'rise', duration: 60, snoozeDuration: 540, snoozeTaper: 20, snoozeCount: 2 } + const alarm = makeAlarmHooks(ringing) + const gesture = { actionType: 'alarm', alarmBehavior: 'snooze' } + const { deps } = makeDeps(gesture, { isAlarmVibrating: true }, makeMockClient(), alarm) + + await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('left', 'doubleTap')) + + expect(alarm.snooze).toHaveBeenCalledWith('left', 346, ringing) + }) + test.each([ ['clear write', { clearAlarm: vi.fn().mockRejectedValue(new Error('DAC write failed')) }], ['connection', { connect: vi.fn().mockRejectedValue(new Error('socket unavailable')) }], @@ -317,22 +330,47 @@ describe('GestureActionHandler', () => { await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('right', 'quadTap')) + expect(deps.newHardwareClient).not.toHaveBeenCalled() expect(client.clearAlarm).not.toHaveBeenCalled() expect(alarm.ended).toHaveBeenCalledWith('right') expect(alarm.snooze).not.toHaveBeenCalled() - expect(client.disconnect).toHaveBeenCalledOnce() }) }) describe('other gestures during a vibrating alarm', () => { - test('a temperature gesture records the alarm stopped and still adjusts temperature', async () => { - const gesture = { actionType: 'temperature', temperatureChange: 'increment', temperatureAmount: 2 } + test('a temperature gesture with no ringing action only records the alarm stopped', async () => { + // While an alarm rings a tap is about the alarm, not the temperature. + const gesture = { actionType: 'temperature', temperatureChange: 'increment', temperatureAmount: 2, alarmBehavior: null } const { deps, client, alarm } = makeDeps(gesture, { isAlarmVibrating: true, targetTemperature: 70 }) await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('left', 'tripleTap')) expect(alarm.ended).toHaveBeenCalledWith('left') - expect(client.setTemperature).toHaveBeenCalledWith('left', 72) + expect(client.setTemperature).not.toHaveBeenCalled() + }) + + test('a temperature gesture can snooze while ringing and still step temperature otherwise', async () => { + const gesture = { actionType: 'temperature', temperatureChange: 'increment', temperatureAmount: 2, alarmBehavior: 'snooze' } + const ringing = makeDeps(gesture, { isAlarmVibrating: true, targetTemperature: 70 }) + await new GestureActionHandler(SOCKET_PATH, ringing.deps).handle(makeEvent('left', 'doubleTap')) + expect(ringing.client.clearAlarm).toHaveBeenCalledWith('left') + expect(ringing.alarm.snooze).toHaveBeenCalledWith('left', 300, expect.anything()) + expect(ringing.client.setTemperature).not.toHaveBeenCalled() + + const idle = makeDeps(gesture, { isAlarmVibrating: false, targetTemperature: 70 }) + await new GestureActionHandler(SOCKET_PATH, idle.deps).handle(makeEvent('left', 'doubleTap')) + expect(idle.client.setTemperature).toHaveBeenCalledWith('left', 72) + expect(idle.alarm.snooze).not.toHaveBeenCalled() + }) + + test('a temperature gesture can stop the alarm while ringing', async () => { + const gesture = { actionType: 'temperature', temperatureChange: 'decrement', temperatureAmount: 1, alarmBehavior: 'dismiss' } + const { deps, client, alarm } = makeDeps(gesture, { isAlarmVibrating: true, targetTemperature: 70 }) + await new GestureActionHandler(SOCKET_PATH, deps).handle(makeEvent('left', 'quadTap')) + expect(client.clearAlarm).toHaveBeenCalledWith('left') + expect(alarm.cancelSnooze).toHaveBeenCalledWith('left') + expect(alarm.ended).toHaveBeenCalledWith('left') + expect(client.setTemperature).not.toHaveBeenCalled() }) test('an unconfigured gesture still records the alarm stopped', async () => { diff --git a/src/hardware/tests/iptablesCheck.cache.test.ts b/src/hardware/tests/iptablesCheck.cache.test.ts index c2f202ed0..18d395d38 100644 --- a/src/hardware/tests/iptablesCheck.cache.test.ts +++ b/src/hardware/tests/iptablesCheck.cache.test.ts @@ -71,6 +71,17 @@ describe('asynchronous firewall health', () => { expect(mocks.runFile).toHaveBeenCalledTimes(5) }) + it('reports no missing rules while internet access is allowed (chains open)', async () => { + mocks.runFile.mockImplementation(async (file, args) => ({ + stdout: file === 'which' + ? '/sbin/iptables\n' + : `Chain ${args[1]} (policy ACCEPT)\ntarget prot opt source destination\n`, + })) + const status = await checkIptablesCached() + expect(status.ok).toBe(true) + expect(status.rules.every(rule => rule.present)).toBe(true) + }) + it('does not let an in-flight pre-repair result overwrite a newer check', async () => { let release!: (value: { stdout: string }) => void mocks.runFile.mockImplementationOnce(() => new Promise((resolve) => { diff --git a/src/hardware/tests/iptablesCheck.test.ts b/src/hardware/tests/iptablesCheck.test.ts index 3e5a39319..23655e649 100644 --- a/src/hardware/tests/iptablesCheck.test.ts +++ b/src/hardware/tests/iptablesCheck.test.ts @@ -189,6 +189,61 @@ describe('iptablesCheck — checkIptables rule classification', () => { expect(inboundMdns?.present).toBe(false) }) + it('treats a chain that allows everything as satisfying its allow rules', async () => { + // Internet access allowed: every rule flushed, policy ACCEPT, nothing dropped. + setExecHandler(({ cmd }) => { + if (cmd.includes('which iptables')) return '/sbin/iptables\n' + if (cmd.includes('-L INPUT')) return 'Chain INPUT (policy ACCEPT)\ntarget prot opt source destination\n' + if (cmd.includes('-L OUTPUT')) return 'Chain OUTPUT (policy ACCEPT)\ntarget prot opt source destination\n' + return '' + }) + + const { checkIptables } = await import('../iptablesCheck') + expect(checkIptables().ok).toBe(true) + }) + + it('still requires the allow rules in front of a jump to a blocking custom chain', async () => { + // scripts/internet-control block: OUTPUT jumps to SLEEPYPOD-BLOCK, which ends in DROP. + setExecHandler(({ cmd }) => { + if (cmd.includes('which iptables')) return '/sbin/iptables\n' + if (cmd.includes('-L INPUT')) return 'Chain INPUT (policy ACCEPT)\ntarget prot opt source destination\nSLEEPYPOD-BLOCK all -- 0.0.0.0/0 0.0.0.0/0\n' + if (cmd.includes('-L OUTPUT')) return 'Chain OUTPUT (policy ACCEPT)\ntarget prot opt source destination\nSLEEPYPOD-BLOCK all -- 0.0.0.0/0 0.0.0.0/0\n' + return '' + }) + + const { checkIptables } = await import('../iptablesCheck') + const result = checkIptables() + expect(result.ok).toBe(false) + expect(result.rules.find(r => r.name === 'NTP outbound (UDP 123)')?.present).toBe(false) + expect(result.rules.find(r => r.name === 'mDNS outbound (UDP 5353)')?.present).toBe(false) + expect(result.rules.find(r => r.name === 'mDNS inbound (UDP 5353)')?.present).toBe(false) + }) + + it('treats a chain of only ACCEPT, LOG and RETURN rules as open', async () => { + const { chainAllowsAll } = await import('../iptablesCheck') + const header = 'Chain OUTPUT (policy ACCEPT)\ntarget prot opt source destination\n' + expect(chainAllowsAll(header + 'ACCEPT all -- 0.0.0.0/0 0.0.0.0/0\nLOG all -- 0.0.0.0/0 0.0.0.0/0 LOG flags 0 level 4\nRETURN all -- 0.0.0.0/0 0.0.0.0/0\n')).toBe(true) + expect(chainAllowsAll(header + 'REJECT all -- 0.0.0.0/0 0.0.0.0/0 reject-with icmp-port-unreachable\n')).toBe(false) + expect(chainAllowsAll('Chain OUTPUT (policy DROP)\ntarget prot opt source destination\n')).toBe(false) + expect(chainAllowsAll('')).toBe(false) + }) + + it('still requires the allow rules in front of a drop', async () => { + setExecHandler(({ cmd }) => { + if (cmd.includes('which iptables')) return '/sbin/iptables\n' + if (cmd.includes('-L INPUT')) return 'Chain INPUT (policy ACCEPT)\nDROP all -- 0.0.0.0/0 0.0.0.0/0\n' + if (cmd.includes('-L OUTPUT')) return 'Chain OUTPUT (policy DROP)\nACCEPT udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:123\n' + return '' + }) + + const { checkIptables } = await import('../iptablesCheck') + const result = checkIptables() + expect(result.ok).toBe(false) + expect(result.rules.find(r => r.name === 'NTP outbound (UDP 123)')?.present).toBe(true) + expect(result.rules.find(r => r.name === 'mDNS outbound (UDP 5353)')?.present).toBe(false) + expect(result.rules.find(r => r.name === 'LAN access (192.168.0.0/16)')?.present).toBe(false) + }) + it('treats Permission denied / Operation not permitted from listing as "assume ok"', async () => { setExecHandler(({ cmd }) => { if (cmd.includes('which iptables')) return '/sbin/iptables\n' @@ -477,3 +532,16 @@ describe('iptablesCheck — checkAndRepairIptables', () => { log.mockRestore() }) }) + +it.each(['target', 'Chain'])('retains a jump named %s when checking whether a chain is open', async (target) => { + const { chainAllowsAll, checkIptables } = await import('../iptablesCheck') + const listing = `Chain OUTPUT (policy ACCEPT) +target prot opt source destination +${target} all -- 0.0.0.0/0 0.0.0.0/0 +` + expect(chainAllowsAll(listing)).toBe(false) + setExecHandler(() => listing) + const status = checkIptables('/sbin/iptables') + expect(status.rules.every(rule => !rule.present)).toBe(true) + expect(status.ok).toBe(false) +}) diff --git a/src/hardware/tests/snoozeLength.test.ts b/src/hardware/tests/snoozeLength.test.ts new file mode 100644 index 000000000..1606970ac --- /dev/null +++ b/src/hardware/tests/snoozeLength.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { MIN_SNOOZE_SECONDS, nextSnoozeSeconds } from '../snoozeLength' + +const alarm = { vibrationIntensity: 50, vibrationPattern: 'rise' as const, duration: 60 } + +describe('nextSnoozeSeconds', () => { + it('uses the alarm snooze length, or the caller fallback without one', () => { + expect(nextSnoozeSeconds({ ...alarm, snoozeDuration: 540 })).toBe(540) + expect(nextSnoozeSeconds(alarm)).toBe(300) + expect(nextSnoozeSeconds(alarm, 540)).toBe(540) + }) + + it('every snooze is the same length without a taper', () => { + expect(nextSnoozeSeconds({ ...alarm, snoozeDuration: 540, snoozeTaper: 0, snoozeCount: 4 })).toBe(540) + }) + + it('shortens each later snooze by the taper percent', () => { + const lengths = [0, 1, 2, 3].map(snoozeCount => + nextSnoozeSeconds({ ...alarm, snoozeDuration: 540, snoozeTaper: 20, snoozeCount })) + expect(lengths).toEqual([540, 432, 346, 276]) + }) + + it('tapers below a minute but never under 5 seconds, or the set length if that is shorter', () => { + expect(MIN_SNOOZE_SECONDS).toBe(5) + expect(nextSnoozeSeconds({ ...alarm, snoozeDuration: 60, snoozeTaper: 60, snoozeCount: 2 })).toBe(10) + expect(nextSnoozeSeconds({ ...alarm, snoozeDuration: 300, snoozeTaper: 60, snoozeCount: 5 })).toBe(5) + expect(nextSnoozeSeconds({ ...alarm, snoozeDuration: 3, snoozeTaper: 60, snoozeCount: 5 })).toBe(3) + }) + + it('ignores out-of-range taper and counts', () => { + expect(nextSnoozeSeconds({ ...alarm, snoozeDuration: 600, snoozeTaper: -20, snoozeCount: 2 })).toBe(600) + expect(nextSnoozeSeconds({ ...alarm, snoozeDuration: 600, snoozeTaper: 20, snoozeCount: -1 })).toBe(600) + }) +}) diff --git a/src/hardware/tests/snoozeManager.test.ts b/src/hardware/tests/snoozeManager.test.ts index a405f470a..998f12864 100644 --- a/src/hardware/tests/snoozeManager.test.ts +++ b/src/hardware/tests/snoozeManager.test.ts @@ -74,9 +74,10 @@ describe('snoozeManager', () => { expect(mockSetAlarm).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) - expect(mockSetAlarm).toHaveBeenCalledWith('left', config) + // Comes back as it was, counting the snooze (for Snooze taper). + expect(mockSetAlarm).toHaveBeenCalledWith('left', { ...config, snoozeCount: 1 }) // Recorded as vibrating (so a tap can snooze it again), not just broadcast. - expect(mockMarkAlarmStarted).toHaveBeenCalledWith('left', config) + expect(mockMarkAlarmStarted).toHaveBeenCalledWith('left', { ...config, snoozeCount: 1 }) expect(getSnoozeStatus('left')).toEqual({ active: false, snoozeUntil: null }) expect(loadAlarmState().snoozes.left).toBeUndefined() }) @@ -101,10 +102,16 @@ describe('snoozeManager', () => { vi.advanceTimersByTime(60_000) await vi.runAllTimersAsync() - expect(mockSetAlarm).toHaveBeenCalledWith('left', config2) + expect(mockSetAlarm).toHaveBeenCalledWith('left', { ...config2, snoozeCount: 1 }) expect(mockSetAlarm).toHaveBeenCalledTimes(1) }) + it('counts each snooze of the same alarm', async () => { + snoozeAlarm('left', 60, { ...config, snoozeCount: 2 }) + await vi.advanceTimersByTimeAsync(60_000) + expect(mockMarkAlarmStarted).toHaveBeenCalledWith('left', { ...config, snoozeCount: 3 }) + }) + it('clamps delays to the signed 32-bit setTimeout ceiling', () => { vi.setSystemTime(0) const maxSeconds = Math.floor((2 ** 31 - 1) / 1000) @@ -134,7 +141,7 @@ describe('snoozeManager', () => { it('keeps a pending snooze on disk until it fires or is cancelled', async () => { vi.setSystemTime(10_000) snoozeAlarm('left', 300, config) - expect(loadAlarmState().snoozes.left).toEqual({ until: 310_000, config }) + expect(loadAlarmState().snoozes.left).toEqual({ until: 310_000, config: { ...config, snoozeCount: 1 } }) cancelSnooze('left') expect(loadAlarmState().snoozes.left).toBeUndefined() }) diff --git a/src/homekit/accessories/snoozeSwitch.ts b/src/homekit/accessories/snoozeSwitch.ts index ee39e64f4..aaef880a8 100644 --- a/src/homekit/accessories/snoozeSwitch.ts +++ b/src/homekit/accessories/snoozeSwitch.ts @@ -1,6 +1,7 @@ /** * HomeKit Switch that snoozes (on) or cancels snooze (off) a side's alarm. - * Snooze duration mirrors the iOS default (9 minutes) at neutral pattern. + * Snooze length comes from the ringing alarm (with its snooze taper); an + * alarm without one snoozes for the iOS default, 9 minutes. */ import { Service, Characteristic } from 'hap-nodejs' @@ -9,6 +10,7 @@ import { getSnoozeStatus, snoozeAlarm, } from '@/src/hardware/snoozeManager' +import { nextSnoozeSeconds } from '@/src/hardware/snoozeLength' import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' import { getActiveAlarmConfig, markAlarmEnded } from '@/src/hardware/alarmState' import type { Side } from '@/src/hardware/types' @@ -42,7 +44,7 @@ export function buildSnoozeSwitch(side: Side): SnoozeSwitchAccessory { catch (e) { console.warn(`[homekit] clearAlarm(${side}) failed during snooze:`, e instanceof Error ? e.message : e) } - snoozeAlarm(side, SNOOZE_SECONDS, config) + snoozeAlarm(side, nextSnoozeSeconds(config, SNOOZE_SECONDS), config) await markAlarmEnded(side) } else { diff --git a/src/hooks/tests/useCurveTemplates.test.ts b/src/hooks/tests/useCurveTemplates.test.ts index 18eea2bb2..96d2ca852 100644 --- a/src/hooks/tests/useCurveTemplates.test.ts +++ b/src/hooks/tests/useCurveTemplates.test.ts @@ -5,24 +5,59 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const state = vi.hoisted(() => ({ data: undefined as unknown, save: vi.fn(), + put: vi.fn(), + setActive: vi.fn(), remove: vi.fn(), - invalidate: vi.fn(), -})) -vi.mock('@/src/utils/trpc', () => ({ - trpc: { - useUtils: () => ({ curveTemplates: { list: { invalidate: state.invalidate } } }), - curveTemplates: { - list: { useQuery: (_input: unknown, opts: { enabled: boolean }) => ({ data: opts.enabled ? state.data : undefined }) }, - save: { useMutation: () => ({ mutateAsync: state.save, isPending: false }) }, - delete: { useMutation: () => ({ mutateAsync: state.remove }) }, - }, + invalidate: { + list: vi.fn(), + getAll: vi.fn(), + getByDay: vi.fn(), + getNotes: vi.fn(), }, + pending: { save: false, put: false, setActive: false, delete: false }, + onSuccess: {} as Record unknown>, + // tRPC hands back a fresh mutateAsync on some renders; record which render's copy each call went through. + generation: 0, + calledGeneration: {} as Record, })) +vi.mock('@/src/utils/trpc', () => { + const mutation = (key: 'save' | 'put' | 'setActive' | 'delete', fn: () => unknown) => ({ + useMutation: (opts: { onSuccess: () => unknown }) => { + state.onSuccess[key] = opts.onSuccess + const generation = state.generation + const mutateAsync = (...args: unknown[]) => { + state.calledGeneration[key] = generation + return (fn as (...a: unknown[]) => unknown)(...args) + } + return { mutateAsync, isPending: state.pending[key] } + }, + }) + return { + trpc: { + useUtils: () => ({ + curveTemplates: { list: { invalidate: state.invalidate.list } }, + schedules: { + getAll: { invalidate: state.invalidate.getAll }, + getByDay: { invalidate: state.invalidate.getByDay }, + getNotes: { invalidate: state.invalidate.getNotes }, + }, + }), + curveTemplates: { + list: { useQuery: (_input: unknown, opts: { enabled: boolean }) => ({ data: opts.enabled ? state.data : undefined }) }, + save: mutation('save', (...args: unknown[]) => state.save(...args)), + put: mutation('put', (...args: unknown[]) => state.put(...args)), + setActive: mutation('setActive', (...args: unknown[]) => state.setActive(...args)), + delete: mutation('delete', (...args: unknown[]) => state.remove(...args)), + }, + }, + } +}) const { useCurveTemplates } = await import('@/src/hooks/useCurveTemplates') const row = (name: string) => ({ id: 1, name, bedtime: '22:00', wake: '07:00', points: { '22:00': 78, '07:00': 80 }, notes: '', + endAction: 'maintain' as const, days: { left: ['monday' as const], right: [] }, active: true, createdAt: new Date(5), updatedAt: new Date(5), }) const local = (name: string) => ({ name, bedtime: '23:00', wake: '06:00', points: { '23:00': 75, '06:00': 82 }, reasoning: 'r', createdAt: 1 }) @@ -32,13 +67,32 @@ beforeEach(() => { localStorage.clear() state.data = [row('On Pod')] state.save.mockReset().mockResolvedValue(undefined) + state.put.mockReset().mockResolvedValue(undefined) + state.setActive.mockReset().mockResolvedValue(undefined) state.remove.mockReset().mockResolvedValue(undefined) + for (const fn of Object.values(state.invalidate)) fn.mockReset() + state.pending = { save: false, put: false, setActive: false, delete: false } + state.generation = 0 + state.calledGeneration = {} }) +const flush = () => act(() => new Promise(resolve => setTimeout(resolve, 0))) + describe('useCurveTemplates', () => { it('maps pod rows to templates and saves or deletes by name', async () => { const { result } = renderHook(() => useCurveTemplates(true)) - expect(result.current.templates).toEqual([{ name: 'On Pod', bedtime: '22:00', wake: '07:00', points: { '22:00': 78, '07:00': 80 }, notes: '', createdAt: 5 }]) + expect(result.current.templates).toEqual([{ + id: 1, + name: 'On Pod', + bedtime: '22:00', + wake: '07:00', + points: { '22:00': 78, '07:00': 80 }, + notes: '', + endAction: 'maintain', + days: { left: ['monday'], right: [] }, + active: true, + createdAt: 5, + }]) await act(() => result.current.save({ name: 'New', bedtime: '22:00', wake: '07:00', points: { '22:00': 70 }, reasoning: 'x' })) expect(state.save).toHaveBeenCalledWith({ name: 'New', bedtime: '22:00', wake: '07:00', points: { '22:00': 70 }, notes: 'x' }) await act(() => result.current.save({ name: 'Long', bedtime: '22:00', wake: '07:00', points: { '22:00': 70 }, reasoning: 'y'.repeat(1500) })) @@ -47,6 +101,43 @@ describe('useCurveTemplates', () => { expect(state.remove).toHaveBeenCalledWith({ name: 'On Pod' }) }) + it('puts a curve with its days and turns curves on or off by id', async () => { + const { result } = renderHook(() => useCurveTemplates(true)) + const curve = { + id: 1, name: 'Late', bedtime: '22:00', wake: '07:00', points: { '22:00': 70, '07:00': 80 }, notes: '', + endAction: 'turn_off' as const, days: { left: ['monday' as const], right: [] }, active: true, + } + await act(() => result.current.put(curve)) + expect(state.put).toHaveBeenCalledWith(curve) + await act(() => result.current.setActive(1, false)) + expect(state.setActive).toHaveBeenCalledWith({ id: 1, active: false }) + }) + + it('refetches the library, schedules and notes after any library change', async () => { + renderHook(() => useCurveTemplates(true)) + for (const key of ['save', 'put', 'setActive', 'delete']) { + for (const fn of Object.values(state.invalidate)) fn.mockReset() + await act(async () => { + await state.onSuccess[key]() + }) + expect(state.invalidate.list).toHaveBeenCalledTimes(1) + expect(state.invalidate.getAll).toHaveBeenCalledTimes(1) + expect(state.invalidate.getByDay).toHaveBeenCalledTimes(1) + expect(state.invalidate.getNotes).toHaveBeenCalledTimes(1) + } + }) + + it('reports saving for save or put, and changing and deleting separately', () => { + const { result, rerender } = renderHook(() => useCurveTemplates(true)) + expect(result.current).toMatchObject({ isSaving: false, isChanging: false, isDeleting: false }) + state.pending.put = true + rerender() + expect(result.current.isSaving).toBe(true) + state.pending = { save: true, put: false, setActive: true, delete: true } + rerender() + expect(result.current).toMatchObject({ isSaving: true, isChanging: true, isDeleting: true }) + }) + it('moves browser-only templates to the pod once and keeps any the pod rejects', async () => { localStorage.setItem(KEY, JSON.stringify([local('On Pod'), local('Moves'), local('Rejected')])) state.save.mockImplementation(async ({ name }: { name: string }) => { @@ -65,4 +156,67 @@ describe('useCurveTemplates', () => { expect(result.current.templates).toEqual([]) expect(state.save).not.toHaveBeenCalled() }) + + it('maps rows that load after the first render', () => { + state.data = undefined + const { result, rerender } = renderHook(() => useCurveTemplates(true)) + expect(result.current.templates).toEqual([]) + state.data = [row('Later')] + rerender() + expect(result.current.templates.map(t => t.name)).toEqual(['Later']) + }) + + it('calls the latest mutation after tRPC hands back a new one', async () => { + const { result, rerender } = renderHook(() => useCurveTemplates(true)) + state.generation = 1 + rerender() + await act(async () => { + await result.current.save(local('New')) + await result.current.put({ ...row('Put'), days: { left: [], right: [] } }) + await result.current.setActive(1, true) + await result.current.remove('On Pod') + }) + expect(state.calledGeneration).toEqual({ save: 1, put: 1, setActive: 1, delete: 1 }) + }) + + it('migrates once the library loads, not only on the first render', async () => { + localStorage.setItem(KEY, JSON.stringify([local('Moves')])) + state.data = undefined + const { rerender } = renderHook(() => useCurveTemplates(true)) + expect(state.save).not.toHaveBeenCalled() + state.data = [row('On Pod')] + rerender() + await waitFor(() => expect(localStorage.getItem(KEY)).toBeNull()) + expect(state.save).toHaveBeenCalledTimes(1) + expect(state.save.mock.calls[0][0]).toMatchObject({ name: 'Moves' }) + }) + + it('does not migrate again when the library refetches', async () => { + localStorage.setItem(KEY, JSON.stringify([local('Rejected')])) + state.save.mockRejectedValue(new Error('bad')) + const { rerender } = renderHook(() => useCurveTemplates(true)) + await flush() + expect(state.save).toHaveBeenCalledTimes(1) + state.data = [row('On Pod')] + rerender() + await flush() + expect(state.save).toHaveBeenCalledTimes(1) + }) + + it('leaves browser storage alone when it has nothing to move', async () => { + localStorage.setItem(KEY, '[]') + renderHook(() => useCurveTemplates(true)) + await flush() + expect(localStorage.getItem(KEY)).toBe('[]') + }) + + it('matches pod names after trimming and trims long reasoning to the notes limit', async () => { + localStorage.setItem(KEY, JSON.stringify([local(' On Pod '), { ...local('Long'), reasoning: 'z'.repeat(1500) }])) + renderHook(() => useCurveTemplates(true)) + await waitFor(() => expect(localStorage.getItem(KEY)).toBeNull()) + expect(state.save).toHaveBeenCalledTimes(1) + expect(state.save.mock.calls[0][0]).toEqual({ + name: 'Long', bedtime: '23:00', wake: '06:00', points: { '23:00': 75, '06:00': 82 }, notes: 'z'.repeat(1000), + }) + }) }) diff --git a/src/hooks/tests/useSchedule.test.ts b/src/hooks/tests/useSchedule.test.ts index bba7f6687..b055e0976 100644 --- a/src/hooks/tests/useSchedule.test.ts +++ b/src/hooks/tests/useSchedule.test.ts @@ -2,7 +2,7 @@ * Tests for useSchedule — the larger schedule manager that coordinates * temperature, power, and alarm schedules across days. Verifies derived * state (isPowerEnabled, isGlobalEnabled, hasScheduleData), bulk toggles, - * curve save/delete, conflict detection, and apply-to-other-days flow. + * curve delete and apply-to-other-days flow. */ /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -23,6 +23,9 @@ const trpcMock = vi.hoisted(() => { getAll: { invalidate: vi.fn() }, getByDay: { invalidate: vi.fn() }, }, + curveTemplates: { + list: { invalidate: vi.fn() }, + }, } // eslint-disable-next-line @typescript-eslint/no-unused-vars const batchMutate = vi.fn(async (_payload: any) => undefined) @@ -69,12 +72,6 @@ const trpcMock = vi.hoisted(() => { // Stable reference for getCurrentDay so tests don't depend on real time. const dayMock = vi.hoisted(() => ({ current: 'monday' as const })) -const scheduleGroupingMock = vi.hoisted(() => ({ - sortChronological: vi.fn((points: any[]) => - [...points].sort((a, b) => a.time.localeCompare(b.time))), - simplifySetPoints: vi.fn((points: any[]) => - [...points].sort((a, b) => a.time.localeCompare(b.time))), -})) vi.mock('@/src/providers/SideProvider', () => ({ useSide: () => sideMock.state, @@ -83,7 +80,6 @@ vi.mock('@/src/utils/trpc', () => ({ trpc: trpcMock.trpc })) vi.mock('@/src/components/Schedule/DaySelector', () => ({ getCurrentDay: () => dayMock.current, })) -vi.mock('@/src/lib/scheduleGrouping', () => scheduleGroupingMock) import { useSchedule } from '../useSchedule' @@ -99,8 +95,7 @@ afterEach(() => { trpcMock.batchMutate.mockClear() trpcMock.utils.schedules.getAll.invalidate.mockReset() trpcMock.utils.schedules.getByDay.invalidate.mockReset() - scheduleGroupingMock.sortChronological.mockClear() - scheduleGroupingMock.simplifySetPoints.mockClear() + trpcMock.utils.curveTemplates.list.invalidate.mockReset() sideMock.state.primarySide = 'left' sideMock.state.activeSides = ['left'] sideMock.state.singleScheduleSide = null @@ -251,77 +246,6 @@ describe('useSchedule — curves', () => { expect(result.current.getCurveForDay('monday')).toEqual({ days: ['monday'], setPoints: [] }) }) - it('detectCurveConflicts flags target days with existing enabled rows outside originalDays', () => { - const { result } = renderHook(() => useSchedule()) - const conflicts = result.current.detectCurveConflicts(['wednesday', 'thursday'], ['monday']) - expect(conflicts).toEqual(['wednesday']) - }) - - it('detectCurveConflicts excludes days listed in originalDays', () => { - const { result } = renderHook(() => useSchedule()) - const conflicts = result.current.detectCurveConflicts(['monday', 'wednesday'], ['monday', 'wednesday']) - expect(conflicts).toEqual([]) - }) - - it('saveCurve clears matching days and writes new rows for each active side', async () => { - sideMock.state.activeSides = ['left'] - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '07:00', temperature: 68 }, - { time: '22:00', temperature: 60 }, - ], - originalDays: ['monday'], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(arg.deletes.temperature).toEqual([1]) - expect(arg.creates.temperature).toHaveLength(2) - expect(arg.creates.power).toEqual([ - expect.objectContaining({ side: 'left', dayOfWeek: 'monday', onTime: '07:00', offTime: '22:00', onTemperature: 68 }), - ]) - }) - - it('saveCurve writes the simplified set points, not the raw list', async () => { - sideMock.state.activeSides = ['left'] - const raw = [ - { time: '22:00', temperature: 76 }, - { time: '23:00', temperature: 76 }, - { time: '23:30', temperature: 76 }, - ] - scheduleGroupingMock.simplifySetPoints.mockReturnValueOnce([raw[0], raw[2]]) - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ targetDays: ['monday'], setPoints: raw }) - }) - expect(scheduleGroupingMock.simplifySetPoints).toHaveBeenCalledWith(raw) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(arg.creates.temperature.map((t: { time: string }) => t.time)).toEqual(['22:00', '23:30']) - expect(arg.creates.power).toEqual([expect.objectContaining({ onTime: '22:00', offTime: '23:30' })]) - }) - - it('saveCurve skips power create when only one set point is provided', async () => { - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [{ time: '07:00', temperature: 68 }], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(arg.creates.power).toEqual([]) - }) - - it('saveCurve does nothing when both targetDays and originalDays are empty', async () => { - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ targetDays: [], setPoints: [] }) - }) - expect(trpcMock.batchMutate).not.toHaveBeenCalled() - }) - it('deleteCurve removes temperature and power rows for the given days', async () => { const { result } = renderHook(() => useSchedule()) await act(async () => { @@ -451,7 +375,7 @@ describe('useSchedule — applyToOtherDays', () => { }) describe('useSchedule — onSuccess invalidation', () => { - it('mutation onSuccess callbacks invalidate getAll + getByDay', () => { + it('mutation onSuccess callbacks invalidate getAll + getByDay and the curve library', () => { renderHook(() => useSchedule()) // Find the registered batchUpdate onSuccess and fire it const batchCall = trpcMock.trpc.schedules.batchUpdate.useMutation.mock.calls[0] @@ -459,6 +383,8 @@ describe('useSchedule — onSuccess invalidation', () => { opts.onSuccess() expect(trpcMock.utils.schedules.getAll.invalidate).toHaveBeenCalled() expect(trpcMock.utils.schedules.getByDay.invalidate).toHaveBeenCalled() + // Row edits release days from their library curve, so the library refetches too. + expect(trpcMock.utils.curveTemplates.list.invalidate).toHaveBeenCalled() }) }) @@ -506,108 +432,9 @@ describe('useSchedule — early returns + global toggle guards', () => { expect(trpcMock.batchMutate).not.toHaveBeenCalled() expect(result.current.confirmMessage).toMatch(/All schedules/i) }) - - it('detectCurveConflicts returns empty without allSchedules data', () => { - const { result } = renderHook(() => useSchedule()) - expect(result.current.detectCurveConflicts(['monday'], [])).toEqual([]) - }) }) -describe('useSchedule — saveCurve / deleteCurve edge cases', () => { - it('saveCurve returns early when allSchedules data is missing', async () => { - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [{ time: '07:00', temperature: 68 }], - }) - }) - expect(trpcMock.batchMutate).not.toHaveBeenCalled() - }) - - it('saveCurve no-ops when there are no rows to delete and no set points to write', async () => { - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - // targetDays is non-empty (so we pass the first guard) but setPoints is empty - // and the day has no existing rows → nothing to delete or create. - await result.current.saveCurve({ targetDays: ['monday'], setPoints: [] }) - }) - expect(trpcMock.batchMutate).not.toHaveBeenCalled() - }) - - it('saveCurve in both-mode clears the other side rows too', async () => { - sideMock.state.activeSides = ['left', 'right'] - trpcMock.overrides.allLeft = { - temperature: [{ id: 1, dayOfWeek: 'monday', time: '07:00', temperature: 70, enabled: true }], - power: [{ id: 11, dayOfWeek: 'monday' }], - alarm: [], - } - trpcMock.overrides.allRight = { - temperature: [{ id: 2, dayOfWeek: 'monday', time: '07:00', temperature: 70, enabled: true }], - power: [{ id: 22, dayOfWeek: 'monday' }], - alarm: [], - } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '07:00', temperature: 68 }, - { time: '22:00', temperature: 60 }, - ], - originalDays: ['monday'], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(arg.deletes.temperature).toEqual(expect.arrayContaining([1, 2])) - expect(arg.deletes.power).toEqual(expect.arrayContaining([11, 22])) - // Should write to both sides - const sides = new Set(arg.creates.temperature.map((c: any) => c.side)) - expect(sides.has('left')).toBe(true) - expect(sides.has('right')).toBe(true) - }) - - it('saveCurve writes only the sleeper\'s side when the other is away, even with both selected', async () => { - sideMock.state.activeSides = ['left', 'right'] - sideMock.state.singleScheduleSide = 'right' - trpcMock.overrides.allRight = { - temperature: [{ id: 2, dayOfWeek: 'monday', time: '07:00', temperature: 70, enabled: true }], - power: [], - alarm: [], - } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '07:00', temperature: 68 }, - { time: '22:00', temperature: 60 }, - ], - originalDays: ['monday'], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(new Set(arg.creates.temperature.map((c: { side: string }) => c.side))).toEqual(new Set(['right'])) - expect(arg.deletes.temperature).toEqual([2]) - }) - - it('saveCurve clamps onTemperature into the 55–110 range', async () => { - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '07:00', temperature: 999 }, - { time: '22:00', temperature: 999 }, - ], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(arg.creates.power[0].onTemperature).toBe(110) - }) - +describe('useSchedule — deleteCurve edge cases', () => { it('deleteCurve in both-mode clears the other side rows too', async () => { sideMock.state.activeSides = ['left', 'right'] trpcMock.overrides.allLeft = { @@ -758,6 +585,7 @@ describe('useSchedule — per-row mutation hooks register invalidating onSuccess trpcMock.utils.schedules.getAll.invalidate.mockClear() trpcMock.utils.schedules.getByDay.invalidate.mockClear() + trpcMock.utils.curveTemplates.list.invalidate.mockClear() for (const call of calls) { const opts = call[0] as { onSuccess: () => void } | undefined @@ -769,6 +597,7 @@ describe('useSchedule — per-row mutation hooks register invalidating onSuccess // Kills ArrowFunction -> () => undefined (would skip both invalidations) expect(trpcMock.utils.schedules.getAll.invalidate).toHaveBeenCalledTimes(9) expect(trpcMock.utils.schedules.getByDay.invalidate).toHaveBeenCalledTimes(9) + expect(trpcMock.utils.curveTemplates.list.invalidate).toHaveBeenCalledTimes(9) }) }) @@ -1112,108 +941,6 @@ describe('useSchedule — applyToOtherDays guards and side effects', () => { }) }) -describe('useSchedule — saveCurve clamping, scheduler create, and side handling', () => { - it('clamps onTemperature below the 55F floor', async () => { - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '07:00', temperature: 10 }, - { time: '22:00', temperature: 10 }, - ], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(arg.creates.power[0].onTemperature).toBe(55) - }) - - it('does not create a power row when on == off times', async () => { - // canCreatePower's `onTime !== offTime` clause — same-time set points - // should skip the power create entirely. - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '07:00', temperature: 68 }, - { time: '07:00', temperature: 70 }, - ], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(arg.creates.power).toEqual([]) - }) - - it('marks created temperature rows enabled (kills BooleanLiteral L540 mutant)', async () => { - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [{ time: '07:00', temperature: 68 }], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - for (const c of arg.creates.temperature) { - expect(c.enabled).toBe(true) - } - }) - - it('marks created power rows enabled (kills BooleanLiteral L550 mutant)', async () => { - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '07:00', temperature: 68 }, - { time: '22:00', temperature: 60 }, - ], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - expect(arg.creates.power[0].enabled).toBe(true) - }) - - it('saveCurve no-ops when targetDays is empty but originalDays is non-empty AND allData is empty', async () => { - // ConditionalExpression L498: with target empty and original non-empty, - // the function must still try (originalDays alone is sufficient to enter - // the body). With allData also empty there is nothing to delete or - // create, so batchMutate must not fire. - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: [], - setPoints: [], - originalDays: ['monday'], - }) - }) - expect(trpcMock.batchMutate).not.toHaveBeenCalled() - }) - - it('saveCurve early-returns when BOTH targetDays and originalDays are empty', async () => { - trpcMock.overrides.allLeft = { - temperature: [{ id: 99, dayOfWeek: 'monday', enabled: true }], - power: [], - alarm: [], - } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: [], - setPoints: [], - originalDays: [], - }) - }) - // No mutation even though there are rows that could match. - expect(trpcMock.batchMutate).not.toHaveBeenCalled() - }) -}) - describe('useSchedule — getCurveForDay fingerprint correctness', () => { it('returns days that share the enabled set-point fingerprint and excludes disabled rows', () => { trpcMock.overrides.allLeft = { @@ -1252,34 +979,7 @@ describe('useSchedule — getCurveForDay fingerprint correctness', () => { }) }) -describe('useSchedule — saveCurve / deleteCurve other-side gating', () => { - it('saveCurve does not touch otherSide when only one active side', async () => { - sideMock.state.activeSides = ['left'] - trpcMock.overrides.allLeft = { - temperature: [{ id: 1, dayOfWeek: 'monday', enabled: true }], - power: [], - alarm: [], - } - trpcMock.overrides.allRight = { - temperature: [{ id: 99, dayOfWeek: 'monday', enabled: true }], - power: [], - alarm: [], - } - const { result } = renderHook(() => useSchedule()) - await act(async () => { - await result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [{ time: '07:00', temperature: 68 }], - originalDays: ['monday'], - }) - }) - const arg = trpcMock.batchMutate.mock.calls[0][0] - // L506 ConditionalExpression `true` mutant would always read otherData → - // would include id 99 in deletes. Single-side must omit it. - expect(arg.deletes.temperature).not.toContain(99) - expect(arg.deletes.temperature).toEqual([1]) - }) - +describe('useSchedule — deleteCurve other-side gating', () => { it('deleteCurve does not touch otherSide when only one active side', async () => { sideMock.state.activeSides = ['left'] trpcMock.overrides.allLeft = { @@ -1668,7 +1368,7 @@ describe('useSchedule — mutation boundary and dependency regressions', () => { expect(result.current.getCurveForDay('monday').days).toEqual(['monday']) }) - it('getCurveForDay and detectCurveConflicts use schedule data that arrives after mount', () => { + it('getCurveForDay uses schedule data that arrives after mount', () => { const { result, rerender } = renderHook(() => useSchedule()) trpcMock.overrides.allLeft = { temperature: [{ id: 1, dayOfWeek: 'monday', time: '07:00', temperature: 68, enabled: true }], @@ -1677,110 +1377,6 @@ describe('useSchedule — mutation boundary and dependency regressions', () => { } rerender() expect(result.current.getCurveForDay('monday').setPoints).toEqual([{ time: '07:00', temperature: 68 }]) - expect(result.current.detectCurveConflicts(['monday'])).toEqual(['monday']) - }) - - it('saveCurve returns before sorting when both day lists are empty', async () => { - trpcMock.overrides.allLeft = { - temperature: [{ id: 1, dayOfWeek: 'monday' }], - power: [], - alarm: [], - } - const { result } = renderHook(() => useSchedule()) - await act(async () => result.current.saveCurve({ targetDays: [], originalDays: [], setPoints: [] })) - expect(scheduleGroupingMock.sortChronological).not.toHaveBeenCalled() - }) - - it('saveCurve clears original days even when there are no target days', async () => { - trpcMock.overrides.allLeft = { - temperature: [{ id: 1, dayOfWeek: 'monday' }], - power: [], - alarm: [], - } - const { result } = renderHook(() => useSchedule()) - await act(async () => result.current.saveCurve({ - targetDays: [], - originalDays: ['monday'], - setPoints: [], - })) - expect(trpcMock.batchMutate).toHaveBeenCalledWith(expect.objectContaining({ - deletes: { temperature: [1], power: [] }, - })) - }) - - it('saveCurve batches when only a power row needs deletion', async () => { - trpcMock.overrides.allLeft = { - temperature: [], - power: [{ id: 2, dayOfWeek: 'monday' }], - alarm: [], - } - const { result } = renderHook(() => useSchedule()) - await act(async () => result.current.saveCurve({ - targetDays: [], - originalDays: ['monday'], - setPoints: [], - })) - expect(trpcMock.batchMutate).toHaveBeenCalledWith(expect.objectContaining({ - deletes: { temperature: [], power: [2] }, - })) - }) - - it('saveCurve uses all-schedule data that arrives after mount', async () => { - const { result, rerender } = renderHook(() => useSchedule()) - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - rerender() - await act(async () => result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [{ time: '07:00', temperature: 68 }], - })) - expect(trpcMock.batchMutate).toHaveBeenCalledOnce() - }) - - it('does not create power when a derived boundary time is empty', async () => { - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '', temperature: 68 }, - { time: '22:00', temperature: 60 }, - ], - })) - expect(trpcMock.batchMutate.mock.calls[0][0].creates.power).toEqual([]) - }) - - it('does not create power when the first set point has no temperature', async () => { - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - const { result } = renderHook(() => useSchedule()) - await act(async () => result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [ - { time: '07:00', temperature: undefined as any }, - { time: '22:00', temperature: 60 }, - ], - })) - expect(trpcMock.batchMutate.mock.calls[0][0].creates.power).toEqual([]) - }) - - it('saveCurve filters wrong-day rows on both sides before collecting ids', async () => { - sideMock.state.activeSides = ['left', 'right'] - trpcMock.overrides.allLeft = { - temperature: [{ id: 1, dayOfWeek: 'monday' }, { id: 11, dayOfWeek: 'friday' }], - power: [{ id: 2, dayOfWeek: 'monday' }, { id: 22, dayOfWeek: 'friday' }], - alarm: [], - } - trpcMock.overrides.allRight = { - temperature: [{ id: 3, dayOfWeek: 'monday' }, { id: 33, dayOfWeek: 'friday' }], - power: [{ id: 4, dayOfWeek: 'monday' }, { id: 44, dayOfWeek: 'friday' }], - alarm: [], - } - const { result } = renderHook(() => useSchedule()) - await act(async () => result.current.saveCurve({ - targetDays: ['monday'], - setPoints: [{ time: '07:00', temperature: 68 }], - })) - const payload = trpcMock.batchMutate.mock.calls[0][0] - expect(payload.deletes).toEqual({ temperature: [1, 3], power: [2, 4] }) }) it('deleteCurve filters wrong-day rows on both sides before collecting ids', async () => { @@ -1835,18 +1431,6 @@ describe('useSchedule — mutation boundary and dependency regressions', () => { }) }) -it('saves a single-point maintain curve to both sides', async () => { - trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } - trpcMock.overrides.allRight = { temperature: [], power: [], alarm: [] } - sideMock.state.activeSides = ['left', 'right'] - const { result } = renderHook(() => useSchedule()) - await act(async () => result.current.saveCurve({ targetDays: ['monday'], setPoints: [{ time: '22:00', temperature: 75 }], endAction: 'maintain' })) - expect(trpcMock.batchMutate.mock.calls[0][0].creates.power).toEqual([ - expect.objectContaining({ side: 'left', endAction: 'maintain', onTime: '22:00' }), - expect.objectContaining({ side: 'right', endAction: 'maintain', onTime: '22:00' }), - ]) -}) - it('preserves maintain when copying a day', async () => { trpcMock.overrides.allLeft = { temperature: [], power: [], alarm: [] } trpcMock.overrides.day = { temperature: [], power: [{ id: 1, side: 'left', dayOfWeek: 'monday', onTime: '22:00', offTime: '07:00', onTemperature: 75, enabled: true, endAction: 'maintain' }], alarm: [] } diff --git a/src/hooks/tests/useSensorStream.test.ts b/src/hooks/tests/useSensorStream.test.ts index 806706dd0..3e27b648f 100644 --- a/src/hooks/tests/useSensorStream.test.ts +++ b/src/hooks/tests/useSensorStream.test.ts @@ -16,6 +16,7 @@ import { useSensorFrame, useSensorStream, useSensorStreamStatus, + useSensorSubscription, } from '../useSensorStream' const sensorSingleton = (globalThis as any).__sleepypod_sensorStream @@ -397,6 +398,56 @@ describe('useSensorStream', () => { }) }) +describe('useSensorSubscription', () => { + it('merges its sensors with other consumers without re-rendering on frames', async () => { + const stream = renderHook(() => useSensorStream({ sensors: ['capSense'] })) + let renders = 0 + const sub = renderHook(() => { + renders++ + useSensorSubscription({ sensors: ['sensHealth'] }) + }) + await waitFor(() => expect(wsMock.sockets.length).toBeGreaterThan(0)) + const ws = wsMock.sockets[0] as FakeWS + act(() => ws.triggerOpen()) + const last = () => JSON.parse(ws.sent.at(-1) as string) + expect(last()).toEqual({ type: 'subscribe', snapshot: true, sensors: expect.arrayContaining(['capSense', 'sensHealth']) }) + const before = renders + act(() => ws.triggerMessage({ type: 'capSense', ts: 1, left: 1, right: 2 })) + expect(renders).toBe(before) + + // The other consumer leaves: the connection stays up with sensHealth alone. + stream.unmount() + expect(ws.readyState).toBe(1) + expect(last().sensors).toEqual(['sensHealth']) + sub.unmount() + expect(ws.readyState).toBe(3) + }) + + it('re-requests a snapshot after a dropped connection so frames recover', async () => { + const sub = renderHook(() => useSensorSubscription({ sensors: ['sensHealth'] })) + const frame = renderHook(() => useSensorFrame('sensHealth')) + await waitFor(() => expect(wsMock.sockets.length).toBeGreaterThan(0)) + vi.useFakeTimers() + const ws = wsMock.sockets[0] as FakeWS + act(() => ws.triggerOpen()) + act(() => ws.triggerClose()) + act(() => vi.advanceTimersByTime(1_000)) + const next = wsMock.sockets[1] as FakeWS + act(() => next.triggerOpen()) + expect(JSON.parse(next.sent.at(-1) as string)).toEqual({ type: 'subscribe', snapshot: true, sensors: ['sensHealth'] }) + const health = { type: 'sensHealth', ts: 5, version: 1, left: { connected: false }, right: { connected: true } } + act(() => next.triggerMessage({ type: 'snapshot', latest: [health], waveform: [] })) + expect(frame.result.current).toEqual(health) + frame.unmount() + sub.unmount() + }) + + it('stays closed when disabled', () => { + renderHook(() => useSensorSubscription({ sensors: ['frzWater'], enabled: false })) + expect(wsMock.sockets.length).toBe(0) + }) +}) + describe('useSensorFrame', () => { it('exposes lps diagnostic payloads without normalizing bytes or temperatures', async () => { const stream = renderHook(() => useSensorStream({ sensors: ['lps'] })) diff --git a/src/hooks/useCurveTemplates.ts b/src/hooks/useCurveTemplates.ts index b16f407f2..d79c0e615 100644 --- a/src/hooks/useCurveTemplates.ts +++ b/src/hooks/useCurveTemplates.ts @@ -5,27 +5,44 @@ import { trpc } from '@/src/utils/trpc' import type { GeneratedCurve } from '@/src/lib/sleepCurve/curvePrompt' import { loadTemplates, removeLocalTemplates } from '@/src/lib/sleepCurve/curvePrompt' import { CURVE_NOTES_MAX } from '@/src/lib/sleepCurve/savePlan' +import type { CurveDays } from '@/src/lib/scheduleLibrary' +import type { inferRouterInputs } from '@trpc/server' +import type { AppRouter } from '@/src/server/routers/app' -/** A saved curve as the Schedule screen shows it; set points are °F. */ +/** A curve in the schedule library as the Schedule screen shows it; set points are °F. */ export interface SavedCurve { + id: number name: string bedtime: string wake: string points: Record notes: string + endAction: 'turn_off' | 'maintain' + /** Days it runs on per side while active. */ + days: CurveDays + active: boolean createdAt: number } /** - * Saved curves, stored on the pod so every browser and the MCP server see the - * same list. Curves saved by older versions live in this browser's - * localStorage; the first successful load moves them to the pod. + * The schedule library, stored on the pod so every browser and the MCP server + * see the same list. Changing a curve's days or active flag rewrites the + * schedule, so schedules and notes are refetched too. Curves saved by older + * versions live in this browser's localStorage; the first successful load + * moves them to the pod. */ export function useCurveTemplates(enabled = true) { const utils = trpc.useUtils() const query = trpc.curveTemplates.list.useQuery({}, { enabled }) - const invalidate = () => utils.curveTemplates.list.invalidate() + const invalidate = () => Promise.all([ + utils.curveTemplates.list.invalidate(), + utils.schedules.getAll.invalidate(), + utils.schedules.getByDay.invalidate(), + utils.schedules.getNotes.invalidate(), + ]) const saveMutation = trpc.curveTemplates.save.useMutation({ onSuccess: invalidate }) + const putMutation = trpc.curveTemplates.put.useMutation({ onSuccess: invalidate }) + const activeMutation = trpc.curveTemplates.setActive.useMutation({ onSuccess: invalidate }) const deleteMutation = trpc.curveTemplates.delete.useMutation({ onSuccess: invalidate }) const { mutateAsync: saveAsync } = saveMutation @@ -48,7 +65,16 @@ export function useCurveTemplates(enabled = true) { const templates = useMemo( () => (query.data ?? []).map(t => ({ - name: t.name, bedtime: t.bedtime, wake: t.wake, points: t.points, notes: t.notes, createdAt: t.createdAt.getTime(), + id: t.id, + name: t.name, + bedtime: t.bedtime, + wake: t.wake, + points: t.points, + notes: t.notes, + endAction: t.endAction, + days: t.days, + active: t.active, + createdAt: t.createdAt.getTime(), })), [query.data], ) @@ -61,5 +87,21 @@ export function useCurveTemplates(enabled = true) { const { mutateAsync: deleteAsync } = deleteMutation const remove = useCallback((name: string) => deleteAsync({ name }), [deleteAsync]) - return { templates, save, remove, isLoading: query.isLoading, isSaving: saveMutation.isPending, isDeleting: deleteMutation.isPending } + const { mutateAsync: putAsync } = putMutation + const put = useCallback((curve: inferRouterInputs['curveTemplates']['put']) => putAsync(curve), [putAsync]) + + const { mutateAsync: activeAsync } = activeMutation + const setActive = useCallback((id: number, active: boolean) => activeAsync({ id, active }), [activeAsync]) + + return { + templates, + save, + put, + setActive, + remove, + isLoading: query.isLoading, + isSaving: saveMutation.isPending || putMutation.isPending, + isChanging: activeMutation.isPending, + isDeleting: deleteMutation.isPending, + } } diff --git a/src/hooks/useSchedule.ts b/src/hooks/useSchedule.ts index 38162725c..584112989 100644 --- a/src/hooks/useSchedule.ts +++ b/src/hooks/useSchedule.ts @@ -5,7 +5,6 @@ import { getCurrentDay } from '@/src/components/Schedule/DaySelector' import { trpc } from '@/src/utils/trpc' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSide } from '@/src/providers/SideProvider' -import { planCurveSave } from '@/src/lib/sleepCurve/savePlan' type Side = 'left' | 'right' @@ -39,6 +38,8 @@ export interface AlarmSchedule { duration: number alarmTemperature: number wakeWindow: number + snoozeDuration: number + snoozeTaper: number enabled: boolean } @@ -126,6 +127,8 @@ export function useSchedule() { function invalidateAll() { void utils.schedules.getAll.invalidate() void utils.schedules.getByDay.invalidate() + // Editing rows directly releases those days from their library curve. + void utils.curveTemplates.list.invalidate() } // Get current day's schedule from the query @@ -346,7 +349,7 @@ export function useSchedule() { const alarmDeletes: number[] = [] const tempCreates: Array<{ side: Side, dayOfWeek: DayOfWeek, time: string, temperature: number, enabled: boolean }> = [] const powerCreates: Array<{ side: Side, dayOfWeek: DayOfWeek, onTime: string, offTime: string, endAction?: 'turn_off' | 'maintain', onTemperature: number, enabled: boolean }> = [] - const alarmCreates: Array<{ side: Side, dayOfWeek: DayOfWeek, time: string, vibrationIntensity: number, vibrationPattern: 'double' | 'rise', duration: number, alarmTemperature: number, wakeWindow: number, enabled: boolean }> = [] + const alarmCreates: Array<{ side: Side, dayOfWeek: DayOfWeek, time: string, vibrationIntensity: number, vibrationPattern: 'double' | 'rise', duration: number, alarmTemperature: number, wakeWindow: number, snoozeDuration: number, snoozeTaper: number, enabled: boolean }> = [] const sourceTemp = daySchedule.temperature || [] const sourcePower = daySchedule.power || [] @@ -395,6 +398,8 @@ export function useSchedule() { duration: a.duration, alarmTemperature: Math.round(a.alarmTemperature), wakeWindow: a.wakeWindow, + snoozeDuration: a.snoozeDuration, + snoozeTaper: a.snoozeTaper, enabled: a.enabled, }) } @@ -463,87 +468,6 @@ export function useSchedule() { [allSchedulesQuery.data] ) - /** - * Detect day conflicts before saving a curve. - * Returns days from `targetDays` that are part of an existing curve other than `originalDays`. - */ - const detectCurveConflicts = useCallback( - (targetDays: DayOfWeek[], originalDays: DayOfWeek[] = []): DayOfWeek[] => { - const allData = allSchedulesQuery.data as { temperature: TemperatureSchedule[] } | undefined - if (!allData) return [] - const originalSet = new Set(originalDays) - return targetDays.filter((d) => { - if (originalSet.has(d)) return false - return allData.temperature.some(t => t.dayOfWeek === d && t.enabled) - }) - }, - [allSchedulesQuery.data] - ) - - /** - * Save a curve atomically: delete temperature + power schedules for the union of - * `originalDays ∪ targetDays` across all active sides, then create new ones for - * `targetDays × setPoints × activeSides`. In "both" mode this writes to both - * sides in a single batch so they stay in sync. - * - * The Pod's auto on/off times are derived from the chronologically-first and - * -last set points (with overnight wrap detection), so the schedule respects - * a sleep window instead of running 24/7. - * - * Both sides' loaded rows are cleared when editing linked schedules. - */ - const saveCurve = useCallback( - async ({ - targetDays, - setPoints, - originalDays = [], - endAction = 'turn_off', - notes, - }: { - targetDays: DayOfWeek[] - setPoints: Array<{ time: string, temperature: number }> - originalDays?: DayOfWeek[] - endAction?: 'turn_off' | 'maintain' - notes?: string - }): Promise => { - if (targetDays.length === 0 && originalDays.length === 0) return - - const allData = allSchedulesQuery.data as - | { temperature: TemperatureSchedule[], power: PowerSchedule[] } - | undefined - if (!allData) return - - // In "both" mode we also need to clear the other side's rows - const otherData = activeSides.length > 1 - ? otherSchedulesQuery.data as - | { temperature: TemperatureSchedule[], power: PowerSchedule[] } - | undefined - : undefined - - const plan = planCurveSave({ - sides: activeSides, - targetDays, - clearDays: originalDays, - setPoints, - endAction, - notes, - existing: { - temperature: [...allData.temperature, ...(otherData?.temperature ?? [])], - power: [...allData.power, ...(otherData?.power ?? [])], - }, - }) - - const { deletes, creates } = plan - const hasChanges - = deletes.temperature.length > 0 || creates.temperature.length > 0 - || deletes.power.length > 0 || creates.power.length > 0 - if (!hasChanges) return - - await batchUpdate.mutateAsync({ deletes, creates, notes: plan.notes }) - }, - [allSchedulesQuery.data, otherSchedulesQuery.data, activeSides, batchUpdate] - ) - /** * Delete a curve: removes all temperature + power schedules for the given days, * across all currently active sides. @@ -621,10 +545,8 @@ export function useSchedule() { toggleAllSchedules, toggleGlobalSchedules, applyToOtherDays, - saveCurve, deleteCurve, getCurveForDay, - detectCurveConflicts, // Refetch refetch: () => { diff --git a/src/hooks/useSchedules.ts b/src/hooks/useSchedules.ts index c418ca61a..be63049e1 100644 --- a/src/hooks/useSchedules.ts +++ b/src/hooks/useSchedules.ts @@ -39,6 +39,8 @@ export interface AlarmSchedule { duration: number alarmTemperature: number wakeWindow: number + snoozeDuration: number + snoozeTaper: number enabled: boolean createdAt: Date updatedAt: Date diff --git a/src/hooks/useSensorStream.ts b/src/hooks/useSensorStream.ts index 5a78d84a1..1c1131ecc 100644 --- a/src/hooks/useSensorStream.ts +++ b/src/hooks/useSensorStream.ts @@ -14,6 +14,7 @@ export const ALL_SENSOR_TYPES = [ 'piezo-dual', 'capSense', 'capSense2', 'bedTemp', 'bedTemp2', 'frzTemp', 'frzTherm', 'frzHealth', 'log', 'deviceStatus', 'gesture', 'lps', + 'frzWater', 'sensHealth', ] as const export type SensorType = typeof ALL_SENSOR_TYPES[number] @@ -134,6 +135,28 @@ export interface GestureFrame { tapType: string } +/** Freezer water reservoir and priming status frame. Firmware sends the + * flags as 0/1 integers; read them by truthiness, never `=== true`. */ +export interface FrzWaterFrame { + type: 'frzWater' + ts: number + version?: number + isPriming: boolean | 0 | 1 + hasWater: boolean | 0 | 1 + capValid: boolean | 0 | 1 + capLevel: number + needsPriming: boolean | 0 | 1 +} + +/** Cover sensor health and connectivity status frame. */ +export interface SensHealthFrame { + type: 'sensHealth' + ts: number + left: { connected: boolean } + right: { connected: boolean } + version?: number +} + /** Device status frame — pushed on DacMonitor's adaptive poll (1s active / * 2s default / 5s idle) and immediately after mutations via * broadcastMutationStatus. Sides carry the full SideStatus payload the @@ -181,6 +204,8 @@ export type SensorFrame | LogFrame | DeviceStatusFrame | GestureFrame + | FrzWaterFrame + | SensHealthFrame // --------------------------------------------------------------------------- // Server → Client control messages @@ -671,6 +696,32 @@ export interface UseSensorStreamOptions { * ``` */ export function useSensorStream(options: UseSensorStreamOptions = {}) { + useSensorSubscription(options) + + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + + // Stable references for seek API (no deps — they use the singleton directly) + const seek = useCallback((timestamp: number) => sendSeek(timestamp), []) + const getTimeRange = useCallback(() => sendGetTimeRange(), []) + + return { + ...snapshot, + seekWaveform, + goLive, + /** Send a seek request — server replays frames from the given timestamp (epoch seconds). */ + seek, + /** Request the available time range for scrubbing. */ + getTimeRange, + } +} + +/** + * Keep the shared connection open and add `sensors` to the merged + * subscription, without re-rendering on stream state. For long-lived + * consumers (the app shell) that read frames through `useSensorFrame`. + * Each reconnect re-requests a snapshot, so the latest frames come back. + */ +export function useSensorSubscription(options: UseSensorStreamOptions = {}) { const { sensors = null, enabled = true } = options const sensorsKey = sensors ? sensors.slice().sort().join(',') : 'all' @@ -710,22 +761,6 @@ export function useSensorStream(options: UseSensorStreamOptions = {}) { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sensorsKey, enabled, subId]) - - const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) - - // Stable references for seek API (no deps — they use the singleton directly) - const seek = useCallback((timestamp: number) => sendSeek(timestamp), []) - const getTimeRange = useCallback(() => sendGetTimeRange(), []) - - return { - ...snapshot, - seekWaveform, - goLive, - /** Send a seek request — server replays frames from the given timestamp (epoch seconds). */ - seek, - /** Request the available time range for scrubbing. */ - getTimeRange, - } } /** diff --git a/src/lib/appLogging.ts b/src/lib/appLogging.ts new file mode 100644 index 000000000..01220c4a6 --- /dev/null +++ b/src/lib/appLogging.ts @@ -0,0 +1,26 @@ +import { effectiveLoggingMode } from './recordingPolicy' +import { getRecordingPolicy } from './recordingStore' + +const methods = ['log', 'info', 'debug', 'warn', 'error', 'trace'] as const +export function shouldLog(method: typeof methods[number], mode: 'quiet' | 'off' | 'debug'): boolean { + return mode === 'debug' || (mode === 'quiet' && (method === 'warn' || method === 'error')) +} + +/** Filter before console formats/serializes arguments. One wrapper per process. */ +export function installAppLogging(): void { + const shared = globalThis as typeof globalThis & { __recordingLoggingInstalled?: boolean } + if (shared.__recordingLoggingInstalled) return + shared.__recordingLoggingInstalled = true + for (const method of methods) { + const original = console[method].bind(console) + console[method] = (...args: unknown[]) => { + try { + if (shouldLog(method, effectiveLoggingMode(getRecordingPolicy()))) original(...args) + } + catch { + // Keep errors observable if policy storage is damaged, without recursively logging. + if (method === 'warn' || method === 'error') original(...args) + } + } + } +} diff --git a/src/lib/flashHealth.ts b/src/lib/flashHealth.ts new file mode 100644 index 000000000..b2b89c8cf --- /dev/null +++ b/src/lib/flashHealth.ts @@ -0,0 +1,169 @@ +import { readFile } from 'node:fs/promises' +import { flashInsights } from './flashInsights' +import { asc, gte } from 'drizzle-orm' +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' +import { flashWriteSamples } from '@/src/db/biometrics-schema' +import type * as biometricsSchema from '@/src/db/biometrics-schema' + +/** + * eMMC endurance for System → Storage: the chip's own wear estimates + * (JEDEC life_time / pre_eol_info) and how much the pod writes per day. + * + * The kernel has no per-process I/O accounting on the pods, so writes are + * the whole device — firmware, sleepypod and the OS together. + */ + +type Db = BetterSQLite3Database + +export const FLASH_DEVICE = process.env.SP_FLASH_DEVICE || 'mmcblk0' +/** /sys/block//stat counts in 512-byte sectors regardless of the device's block size. */ +const SECTOR_BYTES = 512 +const DAY_MS = 86_400_000 +const RATE_WINDOW_MS = 7 * DAY_MS +/** Shortest stretch of samples worth turning into a per-day rate. */ +const MIN_COVERAGE_MS = 3_600_000 + +/** One JEDEC life_time estimate: 0x01 = 0–10% of rated endurance used … 0x0A = 90–100%, 0x0B = exceeded. */ +export interface WearRange { minPercent: number, maxPercent: number | null } + +export type PreEol = 'normal' | 'warning' | 'urgent' + +export interface FlashReading { + name: string | null + capacityBytes: number | null + /** [type A (SLC area), type B (MLC area)]; null where the chip doesn't report one. */ + lifeTime: [WearRange | null, WearRange | null] + preEol: PreEol | null + sectorsWritten: number + /** null when /proc/sys/kernel/random/boot_id is unreadable; such readings never feed the rate. */ + bootId: string | null + uptimeSeconds: number +} + +export interface FlashReport { + insights: ReturnType + device: string + name: string | null + capacityBytes: number | null + lifeTime: [WearRange | null, WearRange | null] + preEol: PreEol | null + sinceBoot: { bytes: number, uptimeSeconds: number } + /** Legacy rate retained for existing clients. The card uses gap-aware insights. */ + perDay: { bytes: number, coveredHours: number } | null +} + +export function parseWear(code: string | undefined): WearRange | null { + const n = Number.parseInt(code ?? '', 16) + if (!Number.isFinite(n) || n < 1 || n > 11) return null + if (n === 11) return { minPercent: 100, maxPercent: null } + return { minPercent: (n - 1) * 10, maxPercent: n * 10 } +} + +export function parseLifeTime(text: string): [WearRange | null, WearRange | null] { + const [a, b] = text.trim().split(/\s+/) + return [parseWear(a), parseWear(b)] +} + +export function parsePreEol(text: string): PreEol | null { + switch (Number.parseInt(text.trim(), 16)) { + case 1: return 'normal' + case 2: return 'warning' + case 3: return 'urgent' + default: return null + } +} + +/** Field 7 of /sys/block//stat: sectors written since boot. */ +export function parseSectorsWritten(stat: string): number | null { + const n = Number(stat.trim().split(/\s+/)[6]) + return Number.isFinite(n) ? n : null +} + +async function readText(path: string): Promise { + try { + return await readFile(path, 'utf-8') + } + catch { + return null + } +} + +/** Read the device's sysfs entries, or null when it isn't there (dev machines, non-eMMC storage). */ +export async function readFlash(device = FLASH_DEVICE): Promise { + const base = `/sys/block/${device}` + const [stat, name, size, lifeTime, preEol, bootId, uptime] = await Promise.all([ + readText(`${base}/stat`), + readText(`${base}/device/name`), + readText(`${base}/size`), + readText(`${base}/device/life_time`), + readText(`${base}/device/pre_eol_info`), + readText('/proc/sys/kernel/random/boot_id'), + readText('/proc/uptime'), + ]) + const sectorsWritten = stat == null ? null : parseSectorsWritten(stat) + if (sectorsWritten == null) return null + const sizeSectors = Number(size?.trim()) + return { + name: name?.trim() || null, + capacityBytes: sizeSectors > 0 ? sizeSectors * SECTOR_BYTES : null, + lifeTime: lifeTime == null ? [null, null] : parseLifeTime(lifeTime), + preEol: preEol == null ? null : parsePreEol(preEol), + sectorsWritten, + bootId: bootId?.trim() || null, + uptimeSeconds: Number(uptime?.trim().split(/\s+/)[0]) || 0, + } +} + +export interface CounterSample { at: number, bootId: string, sectorsWritten: number } + +/** + * Bytes per day across consecutive samples. Within a boot the delta is the + * counter's growth; across a reboot the counter restarted from zero, so the + * new reading is all writes since that boot. Writes between the last sample + * of a boot and its shutdown are never seen. + */ +export function writeRate(samples: CounterSample[]): { bytesPerDay: number, coveredMs: number } | null { + if (samples.length < 2) return null + let sectors = 0 + for (let i = 1; i < samples.length; i++) { + const prev = samples[i - 1] + const cur = samples[i] + // A counter that went backwards also restarted. + const sameBoot = cur.bootId === prev.bootId && cur.sectorsWritten >= prev.sectorsWritten + sectors += sameBoot ? cur.sectorsWritten - prev.sectorsWritten : cur.sectorsWritten + } + const coveredMs = samples[samples.length - 1].at - samples[0].at + if (coveredMs < MIN_COVERAGE_MS) return null + return { bytesPerDay: (sectors * SECTOR_BYTES * DAY_MS) / coveredMs, coveredMs } +} + +export function recordFlashSample(db: Db, reading: FlashReading, now: number): void { + // Without a boot id a reboot can't be told apart from growth. + if (reading.bootId == null) return + db.insert(flashWriteSamples) + .values({ timestamp: new Date(now), bootId: reading.bootId, sectorsWritten: reading.sectorsWritten }) + .run() +} + +/** Average over the last week of samples; the live reading closes the window at now. */ +export function buildFlashReport(db: Db, reading: FlashReading, now: number, device = FLASH_DEVICE): FlashReport { + const rows = db.select().from(flashWriteSamples) + .where(gte(flashWriteSamples.timestamp, new Date(now - RATE_WINDOW_MS - 2 * MIN_COVERAGE_MS))) + .orderBy(asc(flashWriteSamples.timestamp)) + .all() + const samples = [ + ...rows.map(r => ({ at: r.timestamp.getTime(), bootId: r.bootId, sectorsWritten: r.sectorsWritten })), + ...(reading.bootId == null ? [] : [{ at: now, bootId: reading.bootId, sectorsWritten: reading.sectorsWritten }]), + ] + const rate = writeRate(samples.filter(s => s.at >= now - RATE_WINDOW_MS)) + return { + insights: flashInsights(samples, now), + device, + name: reading.name, + capacityBytes: reading.capacityBytes, + lifeTime: reading.lifeTime, + preEol: reading.preEol, + sinceBoot: { bytes: reading.sectorsWritten * SECTOR_BYTES, uptimeSeconds: Math.round(reading.uptimeSeconds) }, + perDay: rate && { bytes: Math.round(rate.bytesPerDay), coveredHours: Math.round((rate.coveredMs / 3_600_000) * 10) / 10 }, + } +} diff --git a/src/lib/flashInsights.ts b/src/lib/flashInsights.ts new file mode 100644 index 000000000..85b6672f7 --- /dev/null +++ b/src/lib/flashInsights.ts @@ -0,0 +1,49 @@ +/** Pure counter analysis; no additional polling or persistent writes. */ +export interface FlashSample { at: number, bootId: string, sectorsWritten: number } +export interface FlashWindow { + hours: number + coveredHours: number + bytes: number + bytesPerDay: number | null +} +const HOUR = 3_600_000 + +/** + * Only continuous, same-boot intervals are usable. Skip resets, invalid + * counters and gaps over two sampling periods. Boundary intervals are + * prorated assuming a constant rate within the hourly sample. + */ +export function flashInsights(samples: FlashSample[], now: number) { + const ordered = samples.filter(s => Number.isFinite(s.at) && s.at <= now + && Number.isSafeInteger(s.sectorsWritten) && s.sectorsWritten >= 0 && s.bootId) + .toSorted((a, b) => a.at - b.at) + const intervals = ordered.slice(1).flatMap((cur, i) => { + const prev = ordered[i] + const duration = cur.at - prev.at + if (cur.bootId !== prev.bootId || cur.sectorsWritten < prev.sectorsWritten + || duration <= 0 || duration > 2 * HOUR) return [] + return [{ start: prev.at, end: cur.at, bytes: (cur.sectorsWritten - prev.sectorsWritten) * 512 }] + }) + function window(hours: number, end = now): FlashWindow { + let covered = 0 + let bytes = 0 + for (const interval of intervals) { + const overlap = Math.max(0, Math.min(end, interval.end) - Math.max(end - hours * HOUR, interval.start)) + covered += overlap + bytes += interval.bytes * overlap / (interval.end - interval.start) + } + return { + hours, + coveredHours: covered / HOUR, + bytes: Math.round(bytes), + bytesPerDay: covered >= HOUR ? Math.round(bytes * 24 * HOUR / covered) : null, + } + } + return { + windows: [1, 24, 168].map(hours => window(hours)), + history: Array.from({ length: 24 }, (_, i) => { + const end = now - (23 - i) * HOUR + return { end, ...window(1, end) } + }), + } +} diff --git a/src/lib/flashSampler.ts b/src/lib/flashSampler.ts new file mode 100644 index 000000000..144bf41c5 --- /dev/null +++ b/src/lib/flashSampler.ts @@ -0,0 +1,44 @@ +import { biometricsDb } from '@/src/db' +import { buildFlashReport, readFlash, recordFlashSample, type FlashReport } from '@/src/lib/flashHealth' + +const SAMPLE_INTERVAL_MS = 3_600_000 + +let timer: NodeJS.Timeout | null = null + +async function sampleOnce(): Promise { + const reading = await readFlash() + if (reading) recordFlashSample(biometricsDb as never, reading, Date.now()) +} + +/** System → Storage's flash report, or null when the pod's eMMC isn't visible. */ +export async function getFlashReport(): Promise { + const reading = await readFlash() + return reading && buildFlashReport(biometricsDb as never, reading, Date.now()) +} + +/** + * Record the eMMC's sectors-written counter hourly so System → Storage can + * average writes per day across reboots. A row an hour is the whole cost. + */ +export function startFlashSampler(initialDelayMs = 60_000): void { + if (timer) return + const tick = () => { + sampleOnce().catch((err) => { + console.warn('[flash] sample failed:', err instanceof Error ? err.message : err) + }) + } + timer = setTimeout(() => { + tick() + timer = setInterval(tick, SAMPLE_INTERVAL_MS) + timer.unref() + }, initialDelayMs) + timer.unref() +} + +export function stopFlashSampler(): void { + if (timer) { + clearTimeout(timer) + clearInterval(timer) + timer = null + } +} diff --git a/src/lib/healthSampler.ts b/src/lib/healthSampler.ts index e4b4a13aa..d26db9cc5 100644 --- a/src/lib/healthSampler.ts +++ b/src/lib/healthSampler.ts @@ -1,15 +1,27 @@ +import { getRecordingPolicy } from '@/src/lib/recordingStore' import { biometricsDb } from '@/src/db' import { getDataPath } from '@/src/lib/dataPathCollect' import { loadOpenRuns, recordSample, SAMPLE_INTERVAL_MS, type OpenRun } from '@/src/lib/healthHistory' -let timer: NodeJS.Timeout | null = null -let open: Map | null = null +const shared = globalThis as typeof globalThis & { + __healthSampler?: { timer: NodeJS.Timeout | null, open: Map | null } +} +const sampler = shared.__healthSampler ??= { timer: null, open: null } async function sampleOnce(): Promise { const now = Date.now() + if (!getRecordingPolicy(now).healthHistory) { + sampler.open = new Map() + return + } const state = await getDataPath(now) - open ??= loadOpenRuns(biometricsDb, now) - recordSample(biometricsDb, open, state.nodes.map(n => ({ id: n.id, status: n.status, detail: n.detail })), now) + // A setting may change while the asynchronous probe is in flight. + if (!getRecordingPolicy(Date.now(), true).healthHistory) { + sampler.open = new Map() + return + } + sampler.open ??= loadOpenRuns(biometricsDb, now) + recordSample(biometricsDb, sampler.open, state.nodes.map(n => ({ id: n.id, status: n.status, detail: n.detail })), now) } /** @@ -18,25 +30,25 @@ async function sampleOnce(): Promise { * startup doesn't record a burst of "no frames yet". */ export function startHealthSampler(initialDelayMs = 90_000): void { - if (timer) return + if (sampler.timer) return const tick = () => { sampleOnce().catch((err) => { console.warn('[health] sample failed:', err instanceof Error ? err.message : err) }) } - timer = setTimeout(() => { + sampler.timer = setTimeout(() => { tick() - timer = setInterval(tick, SAMPLE_INTERVAL_MS) - timer.unref() + sampler.timer = setInterval(tick, SAMPLE_INTERVAL_MS) + sampler.timer.unref() }, initialDelayMs) - timer.unref() + sampler.timer.unref() } export function stopHealthSampler(): void { - if (timer) { - clearTimeout(timer) - clearInterval(timer) - timer = null + if (sampler.timer) { + clearTimeout(sampler.timer) + clearInterval(sampler.timer) + sampler.timer = null } - open = null + sampler.open = null } diff --git a/src/lib/hostAllowlist.ts b/src/lib/hostAllowlist.ts index 947c58e4f..3f1488191 100644 --- a/src/lib/hostAllowlist.ts +++ b/src/lib/hostAllowlist.ts @@ -55,3 +55,19 @@ export function isHostAllowed(host: string, extraHosts: readonly string[] = []): return extraHosts.some(entry => matchesEntry(normalized, hostname, entry)) } + +// DNS-rebinding guard for proxy.ts and routes it skips. A request without a +// Host header passes: Node rejects HTTP/1.1 requests that lack one, and the +// browsers that carry a rebinding attack always send it, so only HTTP/1.0 +// tools get here without one. The Vercel demo is served from a public domain +// with no pod behind it, so it skips the check. +export function rejectUnknownHost(request: Request): Response | undefined { + if (process.env.NEXT_PUBLIC_DEMO === '1') return + const host = request.headers.get('host') + if (host === null) return + if (isHostAllowed(host, parseAllowedHosts(process.env[ALLOWED_HOSTS_ENV]))) return + return new Response( + `Host not allowed. Use the pod's IP address, or add this host to ${ALLOWED_HOSTS_ENV}.\n`, + { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } }, + ) +} diff --git a/src/lib/i18n/locales/en.po b/src/lib/i18n/locales/en.po index fea688439..2343f4025 100644 --- a/src/lib/i18n/locales/en.po +++ b/src/lib/i18n/locales/en.po @@ -13,6 +13,22 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with {count} overrides" +msgstr ", with {count} overrides" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with 1 override" +msgstr ", with 1 override" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with no overrides" +msgstr ", with no overrides" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:393 +msgid ". Separate from the profile." +msgstr ". Separate from the profile." + #. placeholder {0}: item.summary.source #. placeholder {1}: item.summary.subjectId #. placeholder {2}: item.revision @@ -20,6 +36,31 @@ msgstr "" msgid "{0} · {1} · revision {2}" msgstr "{0} · {1} · revision {2}" +#. placeholder {0}: label.weekday +#. placeholder {1}: label.day +#. placeholder {2}: fmt1(prev) +#. placeholder {3}: fmt1(saved) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:228 +msgid "{0} {1}: previous {2}, saved {3} bpm" +msgstr "{0} {1}: previous {2}, saved {3} bpm" + +#. placeholder {0}: splitLabel[job.split] +#. placeholder {1}: avg(n => n.previous.heart_rate.mae as number) +#. placeholder {2}: avg(n => n.saved.heart_rate.mae as number) +#. placeholder {3}: shortDuration(job.updatedAtMs - job.createdAtMs) +#: src/components/biometrics/Calibration/CalibrationPage.tsx:231 +msgid "{0} HR error {1} → {2} bpm · {3}" +msgstr "{0} HR error {1} → {2} bpm · {3}" + +#. placeholder {0}: i.summary.accepted +#: src/components/biometrics/Calibration/CalibrationPage.tsx:242 +msgid "{0} records" +msgstr "{0} records" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:160 +msgid "{stages} sleep-stage values" +msgstr "{stages} sleep-stage values" + #: src/components/biometrics/Calibration/NumericReport.tsx:175 msgid "95th percentile absolute error" msgstr "95th percentile absolute error" @@ -32,6 +73,18 @@ msgstr "A saved request does not mean execution has started or succeeded. Inspec msgid "A saved revision is not evidence of validation or processor adoption." msgstr "A saved revision is not evidence of validation or processor adoption." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:177 +msgid "Accept an estimate when it is within" +msgstr "Accept an estimate when it is within" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:237 +msgid "accepted 10-second IBI windows" +msgstr "accepted 10-second IBI windows" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:191 +msgid "accepted estimates in the tracker" +msgstr "accepted estimates in the tracker" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:87 msgid "Accepted records are not necessarily usable for validation. Incompatible HRV definitions remain unscorable." msgstr "Accepted records are not necessarily usable for validation. Incompatible HRV definitions remain unscorable." @@ -53,10 +106,6 @@ msgstr "Accepted: {0}; rejected: {1}; duplicates: {2}; unscorable: {3}" msgid "Accepted: {0}; rejected: {1}; duplicates: {2}; unscorable: {3}; conflicts: {4}" msgstr "Accepted: {0}; rejected: {1}; duplicates: {2}; unscorable: {3}; conflicts: {4}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:21 -msgid "Activation history" -msgstr "Activation history" - #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:76 msgid "Activation request saved. Processor acknowledgement is reported separately below." msgstr "Activation request saved. Processor acknowledgement is reported separately below." @@ -80,6 +129,10 @@ msgstr "Active generations: left {0}, right {1}." msgid "Active profile hash" msgstr "Active profile hash" +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:21 +msgid "Activity" +msgstr "Activity" + #: src/components/biometrics/Calibration/NumericReport.tsx:111 msgid "Added readings" msgstr "Added readings" @@ -122,8 +175,6 @@ msgstr "An additional report issue needs review." msgid "Another revision from this reference family is selected." msgstr "Another revision from this reference family is selected." -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Applied" msgstr "Applied" @@ -132,10 +183,6 @@ msgstr "Applied" msgid "Apply evaluated revision" msgstr "Apply evaluated revision" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Apply profile" -msgstr "Apply profile" - #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:32 msgid "Apply this exact candidate using the selected evaluation. Evidence and current settings are checked again when applying." msgstr "Apply this exact candidate using the selected evaluation. Evidence and current settings are checked again when applying." @@ -172,10 +219,20 @@ msgstr "Attempt {0}" msgid "Available reference records by metric (across revisions)" msgstr "Available reference records by metric (across revisions)" +#. placeholder {0}: group.latest.id.slice(0, 4) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:197 +msgid "Backtest {0}. Frozen dataset replayed through the previous and saved profiles; nothing here changes the pod." +msgstr "Backtest {0}. Frozen dataset replayed through the previous and saved profiles; nothing here changes the pod." + #: src/components/biometrics/Calibration/BacktestJobs.tsx:21 msgid "Backtest jobs" msgstr "Backtest jobs" +#. placeholder {0}: splitLabel[job.split] +#: src/components/biometrics/Calibration/CalibrationPage.tsx:235 +msgid "Backtest on {0} nights failed" +msgstr "Backtest on {0} nights failed" + #. placeholder {0}: enqueue.data.id #: src/components/biometrics/Calibration/BacktestSubmission.tsx:93 msgid "Backtest request saved: {0}" @@ -185,7 +242,7 @@ msgstr "Backtest request saved: {0}" msgid "Backtest submission" msgstr "Backtest submission" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:29 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:30 msgid "Backtests" msgstr "Backtests" @@ -201,11 +258,26 @@ msgstr "Before" msgid "Before configuration hash" msgstr "Before configuration hash" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:24 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:68 msgid "Biometric calibration" msgstr "Biometric calibration" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:279 +msgid "Biometric calibration side" +msgstr "Biometric calibration side" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:181 +msgid "bpm of the tracker" +msgstr "bpm of the tracker" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:204 +msgid "BREATHING" +msgstr "BREATHING" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:189 +msgid "Breathing error" +msgstr "Breathing error" + #: src/components/biometrics/Calibration/ShadowReport.tsx:30 msgid "Breathing rate" msgstr "Breathing rate" @@ -216,10 +288,18 @@ msgstr "Breathing rate" msgid "Breathing rate (breaths/min)" msgstr "Breathing rate (breaths/min)" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:25 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:38 +msgid "Calibration" +msgstr "Calibration" + +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:34 msgid "Calibration views" msgstr "Calibration views" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:136 +msgid "Cancel" +msgstr "Cancel" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:79 msgid "Cancel backtest" msgstr "Cancel backtest" @@ -318,6 +398,14 @@ msgstr "Candidate sleep-stage rules" msgid "Candidate spike threshold" msgstr "Candidate spike threshold" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:162 +msgid "Changed values are white; the previous profile (System defaults) sits underneath. Moving a control edits the saved profile; saving again re-requests it. The processor adopts a profile only after it acknowledges a request." +msgstr "Changed values are white; the previous profile (System defaults) sits underneath. Moving a control edits the saved profile; saving again re-requests it. The processor adopts a profile only after it acknowledges a request." + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:163 +msgid "Changed values are white; the previous profile sits underneath. Moving a control edits the saved profile; saving again re-requests it. The processor adopts a profile only after it acknowledges a request." +msgstr "Changed values are white; the previous profile sits underneath. Moving a control edits the saved profile; saving again re-requests it. The processor adopts a profile only after it acknowledges a request." + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:86 msgid "Check Overview for current processor health; a past acknowledgement does not prove current health." msgstr "Check Overview for current processor health; a past acknowledgement does not prove current health." @@ -342,6 +430,10 @@ msgstr "Choose a template" msgid "Choose the protocol before looking at results. Creating an experiment does not run a job or apply a profile." msgstr "Choose the protocol before looking at results. Creating an experiment does not run a job or apply a profile." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:78 +msgid "Choosing nights the reference covers…" +msgstr "Choosing nights the reference covers…" + #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:38 msgid "Circles are recomputed readings; squares are reference points; horizontal bars are reference intervals. Only the two displayed pages are shown. This visual overlay does not create new matched pairs or scores." msgstr "Circles are recomputed readings; squares are reference points; horizontal bars are reference intervals. Only the two displayed pages are shown. This visual overlay does not create new matched pairs or scores." @@ -350,9 +442,9 @@ msgstr "Circles are recomputed readings; squares are reference points; horizonta msgid "Clear this night’s selection" msgstr "Clear this night’s selection" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 -msgid "Clock mismatch" -msgstr "Clock mismatch" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:205 +msgid "click a night" +msgstr "click a night" #. placeholder {0}: minutes(score.pairedDurationMs) #: src/components/biometrics/Calibration/StageReport.tsx:17 @@ -443,10 +535,6 @@ msgstr "Comparison shared MAE" msgid "Compatibility" msgstr "Compatibility" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Compatibility fallback" -msgstr "Compatibility fallback" - #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:90 msgid "Compatible" msgstr "Compatible" @@ -464,15 +552,15 @@ msgstr "Completed nights" msgid "Compressed bytes: {0}" msgstr "Compressed bytes: {0}" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:396 +msgid "config id" +msgstr "config id" + #: src/components/biometrics/Calibration/BacktestReport.tsx:98 #: src/components/biometrics/Calibration/StageReport.tsx:107 msgid "Configuration" msgstr "Configuration" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:36 -msgid "Configuration identity" -msgstr "Configuration identity" - #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:58 msgid "Confirm import deletion" msgstr "Confirm import deletion" @@ -506,10 +594,13 @@ msgstr "Coverage reduced" msgid "Coverage reduced in at least one evaluated window" msgstr "Coverage reduced in at least one evaluated window" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Current" -msgstr "Current" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:129 +msgid "covered" +msgstr "covered" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "current" +msgstr "current" #: src/components/biometrics/Calibration/BacktestReport.tsx:60 #: src/components/biometrics/Calibration/NumericReport.tsx:26 @@ -543,6 +634,10 @@ msgstr "Dataset" msgid "Dataset hash" msgstr "Dataset hash" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:99 +msgid "Dataset nights" +msgstr "Dataset nights" + #. placeholder {0}: create.data.id #: src/components/biometrics/Calibration/DatasetBuilder.tsx:57 msgid "Dataset saved: {0}" @@ -557,10 +652,6 @@ msgstr "Dataset sleeper identifier" msgid "Deep" msgstr "Deep" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:31 -msgid "Default recovery configuration hash" -msgstr "Default recovery configuration hash" - #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:52 msgid "Delete import" msgstr "Delete import" @@ -577,13 +668,10 @@ msgstr "Deleting an import removes its source measurements. Previously frozen da msgid "Descriptive evaluation only" msgstr "Descriptive evaluation only" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 -msgid "Different versions reported" -msgstr "Different versions reported" - -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:57 -msgid "Direct sleep-stage changes are unvalidated manual settings. Saving requests a profile change; processor adoption is reported separately in Overview." -msgstr "Direct sleep-stage changes are unvalidated manual settings. Saving requests a profile change; processor adoption is reported separately in Overview." +#. placeholder {0}: shortDuration(run.durationMs) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "done · {0}" +msgstr "done · {0}" #: src/components/biometrics/Calibration/NumericReport.tsx:151 msgid "Dropping readings can lower own-support error. Duplicate timestamps are excluded from shared support. These counts do not measure continuous time coverage." @@ -597,10 +685,18 @@ msgstr "Durations cover sequential Python work through result generation. Memory msgid "Each configuration on its own available intervals" msgstr "Each configuration on its own available intervals" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:268 +msgid "Each night's heart rate against the reference appears here once a backtest has run." +msgstr "Each night's heart rate against the reference appears here once a backtest has run." + #: src/components/biometrics/Calibration/ProfileLibrary.tsx:34 msgid "Edit a new candidate from this revision" msgstr "Edit a new candidate from this revision" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:273 +msgid "Edit overrides" +msgstr "Edit overrides" + #: src/components/biometrics/Calibration/ProfileLibrary.tsx:20 msgid "Editing from revision {revisionId}" msgstr "Editing from revision {revisionId}" @@ -660,6 +756,10 @@ msgstr "Estimate exploratory whole-night uncertainty" msgid "Evaluated minutes: {0}" msgstr "Evaluated minutes: {0}" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:90 +msgid "Evaluation dataset" +msgstr "Evaluation dataset" + #: src/components/biometrics/Calibration/ExperimentBuilder.tsx:47 msgid "Evaluation mode" msgstr "Evaluation mode" @@ -693,10 +793,6 @@ msgstr "Exact normalized timestamps are shown in UTC. Values are not interpolate msgid "Exact processed-data coverage" msgstr "Exact processed-data coverage" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:45 -msgid "Exact profile transition" -msgstr "Exact profile transition" - #: src/components/biometrics/Calibration/NumericTrace.tsx:93 msgid "Exact recomputed readings, without reference clock adjustment" msgstr "Exact recomputed readings, without reference clock adjustment" @@ -728,9 +824,9 @@ msgstr "Execution state is separate from the strength of biometric evidence." msgid "Experiment saved: {0}" msgstr "Experiment saved: {0}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Expired" -msgstr "Expired" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "expired" +msgstr "expired" #: src/components/biometrics/Calibration/CandidateStageFields.tsx:39 msgid "Explicit stage overrides" @@ -757,19 +853,38 @@ msgstr "Exploratory interval estimated" msgid "F1" msgstr "F1" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "failed" +msgstr "failed" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 #: src/components/biometrics/Calibration/ShadowRuns.tsx:12 msgid "Failed" msgstr "Failed" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:265 +msgid "Fallback" +msgstr "Fallback" + #: src/components/biometrics/Calibration/CandidateStageFields.tsx:21 #: src/components/biometrics/Calibration/CandidateStageFields.tsx:21 msgid "Fallback stage" msgstr "Fallback stage" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:217 +msgid "Fell back to a compatible profile" +msgstr "Fell back to a compatible profile" + +#. placeholder {0}: splitLabel[job.split] +#: src/components/biometrics/Calibration/CalibrationPage.tsx:235 +msgid "Finished a backtest on {0} nights" +msgstr "Finished a backtest on {0} nights" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:344 +msgid "first 500 readings" +msgstr "first 500 readings" + #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:125 msgid "First archive page" msgstr "First archive page" @@ -794,6 +909,10 @@ msgstr "Fixed offset added to pod timestamps (ms, ±3600000)" msgid "Flags" msgstr "Flags" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:143 +msgid "Freeze a new dataset" +msgstr "Freeze a new dataset" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:33 msgid "Freeze evaluation inputs" msgstr "Freeze evaluation inputs" @@ -802,9 +921,43 @@ msgstr "Freeze evaluation inputs" msgid "Freeze selected inputs" msgstr "Freeze selected inputs" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Freshness unknown" -msgstr "Freshness unknown" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:85 +msgid "Freezing failed." +msgstr "Freezing failed." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:79 +msgid "Freezing the nights…" +msgstr "Freezing the nights…" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "freshness unknown" +msgstr "freshness unknown" + +#. placeholder {0}: d.manifest.splitProtocol.assignments.length +#: src/components/biometrics/Calibration/CalibrationPage.tsx:239 +msgid "Froze a dataset of {0} nights" +msgstr "Froze a dataset of {0} nights" + +#. placeholder {0}: formatDay(dataset.createdAtMs, timezone) +#. placeholder {1}: formatBytes(dataset.byteLength) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:94 +msgid "frozen {0} · {1}" +msgstr "frozen {0} · {1}" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:84 +msgid "Frozen and queued. The backtest replays every split; results appear as each finishes." +msgstr "Frozen and queued. The backtest replays every split; results appear as each finishes." + +#. placeholder {0}: nights.length +#. placeholder {1}: sources.join(' + ') +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:71 +msgid "Frozen copy of {0} nights and {1} reference. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own." +msgstr "Frozen copy of {0} nights and {1} reference. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own." + +#. placeholder {0}: nights.length +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:72 +msgid "Frozen copy of {0} nights. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own." +msgstr "Frozen copy of {0} nights. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own." #: src/components/biometrics/Calibration/DatasetLibrary.tsx:85 msgid "Frozen coverage and capabilities" @@ -838,26 +991,32 @@ msgstr "Frozen source records in UTC; incompatible records are listed but not pl msgid "Frozen stage objective thresholds" msgstr "Frozen stage objective thresholds" -#. placeholder {0}: data.desired.binding.generation -#. placeholder {0}: event.generation -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:41 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:39 -msgid "Generation {0}" -msgstr "Generation {0}" - #: src/components/biometrics/Calibration/NumericReport.tsx:44 msgid "Groups use native reference timestamps and record types, independently of candidate output. Half-window groups exclude intervals crossing the midpoint. Groups overlap and are not independent experiments. These are not motion or artifact subgroups." msgstr "Groups use native reference timestamps and record types, independently of candidate output. Half-window groups exclude intervals crossing the midpoint. Groups overlap and are not independent experiments. These are not motion or artifact subgroups." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:258 +msgid "H holdout" +msgstr "H holdout" + #: src/components/biometrics/Calibration/ReplayResources.tsx:49 msgid "Health was sampled at admission checks, normally five seconds apart. Unobserved intervals are not continuous health measurements." msgstr "Health was sampled at admission checks, normally five seconds apart. Unobserved intervals are not continuous health measurements." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 #: src/components/biometrics/Calibration/ShadowReport.tsx:30 msgid "Heart rate" msgstr "Heart rate" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:174 +msgid "HEART RATE" +msgstr "HEART RATE" + +#. placeholder {0}: label.weekday +#. placeholder {1}: label.day +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:321 +msgid "Heart rate · {0} {1}" +msgstr "Heart rate · {0} {1}" + #: src/components/biometrics/Calibration/NumericReport.tsx:27 #: src/components/biometrics/Calibration/NumericTrace.tsx:38 #: src/components/biometrics/Calibration/ShadowComparison.tsx:44 @@ -868,10 +1027,18 @@ msgstr "Heart rate (bpm)" msgid "Heart-rate spike threshold (standard deviations)" msgstr "Heart-rate spike threshold (standard deviations)" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:325 +msgid "Heart-rate trace" +msgstr "Heart-rate trace" + #: app/[lang]/health/HealthcheckDemo.tsx:13 msgid "Hello, tRPC test page!" msgstr "Hello, tRPC test page!" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:401 +msgid "hide" +msgstr "hide" + #. placeholder {0}: summary.improvedAgreementNights #. placeholder {1}: summary.regressedAgreementNights #. placeholder {2}: summary.unchangedAgreementNights @@ -879,13 +1046,10 @@ msgstr "Hello, tRPC test page!" msgid "Higher agreement: {0}; lower: {1}; unchanged: {2}" msgstr "Higher agreement: {0}; lower: {1}; unchanged: {2}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:46 -msgid "Historical sleep stages are recomputed with the current classifier. Original stored vitals remain separate from replay results." -msgstr "Historical sleep stages are recomputed with the current classifier. Original stored vitals remain separate from replay results." - -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:30 -msgid "History" -msgstr "History" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:135 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "holdout" +msgstr "holdout" #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 #: src/components/biometrics/Calibration/BacktestReport.tsx:61 @@ -900,6 +1064,26 @@ msgstr "Holdout" msgid "Holdout accesses recorded for this experiment: {0}" msgstr "Holdout accesses recorded for this experiment: {0}" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:288 +msgid "Holdout is the number to trust; tuning nights were used to pick these values." +msgstr "Holdout is the number to trust; tuning nights were used to pick these values." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:186 +msgid "HR error · holdout" +msgstr "HR error · holdout" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:184 +msgid "HR error · tuning" +msgstr "HR error · tuning" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:185 +msgid "HR error · validation" +msgstr "HR error · validation" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:204 +msgid "HR error per night · bpm vs reference" +msgstr "HR error per night · bpm vs reference" + #: src/components/biometrics/BiometricsPage.tsx:532 msgid "HRV definition" msgstr "HRV definition" @@ -908,9 +1092,13 @@ msgstr "HRV definition" msgid "HRV definitions differ or are partly unknown. A combined HRV average is unavailable." msgstr "HRV definitions differ or are partly unknown. A combined HRV average is unavailable." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 -msgid "HRV index" -msgstr "HRV index" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:230 +msgid "HRV INDEX" +msgstr "HRV INDEX" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:188 +msgid "HRV index error" +msgstr "HRV index error" #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:60 msgid "I intend to apply this exact revision without validation evidence." @@ -928,6 +1116,10 @@ msgstr "I intend to restore system defaults without validation evidence." msgid "I intend to restore the exact previous profile without validation evidence." msgstr "I intend to restore the exact previous profile without validation evidence." +#: src/components/biometrics/Calibration/CalibrationControls.tsx:147 +msgid "Identical to the saved profile; restoring only re-requests it." +msgstr "Identical to the saved profile; restoring only re-requests it." + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:53 #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:38 msgid "Immediate processing boundary" @@ -941,6 +1133,10 @@ msgstr "Import accepted records and omit rejected records" msgid "Import preview" msgstr "Import preview" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:141 +msgid "Import reference" +msgstr "Import reference" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:53 msgid "Import reference measurements" msgstr "Import reference measurements" @@ -972,6 +1168,11 @@ msgstr "Ingestion samples: {0} of {1} admission checks" msgid "Inherited default" msgstr "Inherited default" +#. placeholder {0}: stages.ruleCount +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:247 +msgid "Inherits the {0} rules and fallback from" +msgstr "Inherits the {0} rules and fallback from" + #: src/components/biometrics/Calibration/DatasetLibrary.tsx:31 msgid "Inputs released; metadata retained" msgstr "Inputs released; metadata retained" @@ -1063,6 +1264,14 @@ msgstr "Keep report" msgid "Keep retained inputs" msgstr "Keep retained inputs" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Keep saved profile" +msgstr "Keep saved profile" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:187 +msgid "Keep the last" +msgstr "Keep the last" + #. placeholder {0}: display(operations.ingestion.maximumObservationGapMs, 1000) #: src/components/biometrics/Calibration/ReplayResources.tsx:47 msgid "Largest observation gap: {0} seconds" @@ -1095,6 +1304,14 @@ msgstr "Light" msgid "Limitations" msgstr "Limitations" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:77 +msgid "Linking Watch data already on the pod…" +msgstr "Linking Watch data already on the pod…" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:223 +msgid "Longest breathing interval (× median)" +msgstr "Longest breathing interval (× median)" + #: src/components/biometrics/Calibration/NumericReport.tsx:110 msgid "Lost readings" msgstr "Lost readings" @@ -1144,9 +1361,9 @@ msgstr "Mean absolute error on shared timestamps: active {0}, candidate {1}." msgid "Measured Python processes: {0} of {1}" msgstr "Measured Python processes: {0} of {1}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:53 -msgid "Measured sensor calibration" -msgstr "Measured sensor calibration" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:390 +msgid "Measured sensor calibration for this side:" +msgstr "Measured sensor calibration for this side:" #: src/components/biometrics/Calibration/NumericReport.tsx:174 msgid "Median absolute error" @@ -1176,9 +1393,17 @@ msgstr "Metric window: {0} seconds." msgid "Metrics: {0}" msgstr "Metrics: {0}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Migrate settings" -msgstr "Migrate settings" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:217 +msgid "Migrated sleep-stage settings into the profile" +msgstr "Migrated sleep-stage settings into the profile" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:201 +msgid "Minimum beat signal score" +msgstr "Minimum beat signal score" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:240 +msgid "Minimum IBI windows" +msgstr "Minimum IBI windows" #: src/components/biometrics/Calibration/ExperimentBuilder.tsx:97 msgid "Minimum paired nights (3–60)" @@ -1205,10 +1430,6 @@ msgstr "New archive work is currently deferred." msgid "New observation plan" msgstr "New observation plan" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:62 -msgid "Newest" -msgstr "Newest" - #: src/components/biometrics/Calibration/DatasetLibrary.tsx:41 msgid "Newest datasets" msgstr "Newest datasets" @@ -1280,6 +1501,10 @@ msgstr "Next trace page" msgid "Night {0}" msgstr "Night {0}" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:187 +msgid "Night covered" +msgstr "Night covered" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:74 msgid "Night progress is not available yet." msgstr "Night progress is not available yet." @@ -1288,14 +1513,30 @@ msgstr "Night progress is not available yet." msgid "No accepted time range." msgstr "No accepted time range." -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:30 -msgid "No activation history for this side." -msgstr "No activation history for this side." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:158 +#~ msgid "No backtest has replayed the saved profile yet. Saving a change re-runs the latest experiment on its tuning and validation nights; nothing here changes the pod." +#~ msgstr "No backtest has replayed the saved profile yet. Saving a change re-runs the latest experiment on its tuning and validation nights; nothing here changes the pod." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:177 +msgid "No backtest has replayed the saved profile yet. Saving a change replays the frozen dataset on its tuning, validation and holdout nights; nothing here changes the pod." +msgstr "No backtest has replayed the saved profile yet. Saving a change replays the frozen dataset on its tuning, validation and holdout nights; nothing here changes the pod." #: src/components/biometrics/Calibration/BacktestJobs.tsx:28 msgid "No backtest jobs for this side." msgstr "No backtest jobs for this side." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:175 +msgid "No backtest yet. Freeze a new dataset below; it replays the newest referenced nights through the previous and saved profiles. Nothing here changes the pod." +msgstr "No backtest yet. Freeze a new dataset below; it replays the newest referenced nights through the previous and saved profiles. Nothing here changes the pod." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:174 +msgid "No backtest yet. Import a reference for this side, then freeze nights that have it; the replay scores the previous and saved profiles against it. Nothing here changes the pod." +msgstr "No backtest yet. Import a reference for this side, then freeze nights that have it; the replay scores the previous and saved profiles against it. Nothing here changes the pod." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:176 +msgid "No backtest yet. This dataset has no waveform replay; freeze a new dataset below to set one up. Nothing here changes the pod." +msgstr "No backtest yet. This dataset has no waveform replay; freeze a new dataset below to set one up. Nothing here changes the pod." + #: src/components/biometrics/Calibration/BacktestReport.tsx:38 msgid "No committed report is available yet." msgstr "No committed report is available yet." @@ -1312,6 +1553,14 @@ msgstr "No completed worker resource receipt is available." msgid "No coverage reduction detected" msgstr "No coverage reduction detected" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:71 +#~ msgid "No dataset is frozen for this side yet. Freeze recorded nights with an imported reference to backtest a profile." +#~ msgstr "No dataset is frozen for this side yet. Freeze recorded nights with an imported reference to backtest a profile." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:73 +msgid "No dataset is frozen for this side yet. Freezing takes the newest nights a reference covers, with their RAW archives, so a backtest can replay them." +msgstr "No dataset is frozen for this side yet. Freezing takes the newest nights a reference covers, with their RAW archives, so a backtest can replay them." + #: src/components/biometrics/Calibration/BacktestReport.tsx:62 #: src/components/biometrics/Calibration/NumericReport.tsx:28 msgid "No estimable variation" @@ -1366,10 +1615,18 @@ msgstr "No prospective runs for this side." msgid "No recorded nights for this side." msgstr "No recorded nights for this side." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:116 +msgid "no reference" +msgstr "no reference" + #: src/components/biometrics/Calibration/NumericReport.tsx:29 msgid "No reference" msgstr "No reference" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:316 +msgid "no reference device · excluded from error" +msgstr "no reference device · excluded from error" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:91 #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:35 msgid "No reference imports for this side." @@ -1404,10 +1661,6 @@ msgstr "No saved candidate revisions." msgid "No saved experiments for this side." msgstr "No saved experiments for this side." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:55 -msgid "No sensor calibration has been reported for this side." -msgstr "No sensor calibration has been reported for this side." - #: src/components/biometrics/Calibration/StageTrace.tsx:84 msgid "No stage intervals were produced for this selection." msgstr "No stage intervals were produced for this selection." @@ -1434,8 +1687,10 @@ msgstr "None" msgid "Normalized reference measurements" msgstr "Normalized reference measurements" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:26 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:386 +msgid "not reported" +msgstr "not reported" + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Not reported" msgstr "Not reported" @@ -1445,6 +1700,10 @@ msgstr "Not reported" msgid "Not requested" msgstr "Not requested" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:200 +msgid "not run" +msgstr "not run" + #: src/components/biometrics/Calibration/BacktestReport.tsx:63 #: src/components/biometrics/Calibration/NumericReport.tsx:24 #: src/components/biometrics/Calibration/NumericReport.tsx:25 @@ -1453,6 +1712,19 @@ msgstr "Not requested" msgid "Not scored" msgstr "Not scored" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:123 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:125 +msgid "not tested" +msgstr "not tested" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:349 +msgid "Nothing changes until you move a control or restore." +msgstr "Nothing changes until you move a control or restore." + +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:24 +msgid "Nothing has happened on this side yet." +msgstr "Nothing has happened on this side yet." + #: src/components/biometrics/Calibration/NumericTrace.tsx:158 msgid "Numeric trace configuration" msgstr "Numeric trace configuration" @@ -1523,10 +1795,6 @@ msgstr "Observed process-group peak RSS: {0} MiB" msgid "Observing" msgstr "Observing" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:63 -msgid "Older changes" -msgstr "Older changes" - #: src/components/biometrics/Calibration/DatasetLibrary.tsx:42 msgid "Older datasets" msgstr "Older datasets" @@ -1558,6 +1826,10 @@ msgstr "Older profiles" msgid "Only the current page is plotted. Dots represent individual readings; gaps and missing values are not interpolated. Original stored readings remain unchanged." msgstr "Only the current page is plotted. Dots represent individual readings; gaps and missing values are not interpolated. Original stored readings remain unchanged." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:211 +msgid "or longer than" +msgstr "or longer than" + #: src/components/biometrics/Calibration/StageTrace.tsx:61 msgid "Original replay timestamps, without reference clock adjustment" msgstr "Original replay timestamps, without reference clock adjustment" @@ -1601,10 +1873,14 @@ msgstr "Overridden" msgid "Override intent can change even when the resolved value stays the same." msgstr "Override intent can change even when the resolved value stays the same." -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:26 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:27 msgid "Overview" msgstr "Overview" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:349 +msgid "Overwrites the saved profile and requests it; the current one becomes previous." +msgstr "Overwrites the saved profile and requests it; the current one becomes previous." + #: src/components/biometrics/Calibration/NumericReport.tsx:109 msgid "Own-support MAE difference" msgstr "Own-support MAE difference" @@ -1630,10 +1906,6 @@ msgstr "Parent revision" msgid "Partition timezone: {0}" msgstr "Partition timezone: {0}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:21 -msgid "Past acknowledgements do not establish which profile is running now." -msgstr "Past acknowledgements do not establish which profile is running now." - #: src/components/biometrics/Calibration/ShadowComparison.tsx:50 msgid "Paste JSON records or CSV with recordKey, metric, value, unit, and timestamp (or start and end). Use offset-bearing ISO times or Unix milliseconds. This comparison does not save a reference import." msgstr "Paste JSON records or CSV with recordKey, metric, value, unit, and timestamp (or start and end). Use offset-bearing ISO times or Unix milliseconds. This comparison does not save a reference import." @@ -1654,9 +1926,9 @@ msgstr "Permanently release inputs" msgid "Permanently release report" msgstr "Permanently release report" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 -msgid "Personalized profile" -msgstr "Personalized profile" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:268 +msgid "Pick a scored night to see its heart-rate trace." +msgstr "Pick a scored night to see its heart-rate trace." #: src/components/biometrics/Calibration/ProfileLibrary.tsx:38 msgid "Pinned configuration and overrides" @@ -1733,10 +2005,6 @@ msgstr "Prepared, not admitted. The pod checks current qualification and health msgid "Presence and pump context reflect host observations, not proof of the live processor’s cached state." msgstr "Presence and pump context reflect host observations, not proof of the live processor’s cached state." -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:29 -msgid "Preserved incompatible revision" -msgstr "Preserved incompatible revision" - #: src/components/biometrics/Calibration/CalibrationReferences.tsx:78 msgid "Preview import" msgstr "Preview import" @@ -1745,6 +2013,18 @@ msgstr "Preview import" msgid "Preview normalized records before saving them on the pod." msgstr "Preview normalized records before saving them on the pod." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:252 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:275 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:342 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:133 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:217 +msgid "previous" +msgstr "previous" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:77 +msgid "Previous" +msgstr "Previous" + #: src/components/biometrics/Calibration/NumericTrace.tsx:134 msgid "Previous numeric page" msgstr "Previous numeric page" @@ -1770,19 +2050,33 @@ msgstr "Processing failed." msgid "Processing run" msgstr "Processing run" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:27 -msgid "Processor health" -msgstr "Processor health" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:19 +msgid "Processor" +msgstr "Processor" + +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:218 +msgid "Processor acknowledged generation {0}" +msgstr "Processor acknowledged generation {0}" + +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:219 +msgid "Processor rejected generation {0}" +msgstr "Processor rejected generation {0}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:24 -msgid "Profile and processor" -msgstr "Profile and processor" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:22 +msgid "Profile lifecycle" +msgstr "Profile lifecycle" #: src/components/biometrics/Calibration/ProfileCandidateEditor.tsx:69 msgid "Profile resolution failed." msgstr "Profile resolution failed." -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:28 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:84 +msgid "Profile revisions here are candidates. Applying one requests it like Save and request does; the overview shows when the processor acknowledges it." +msgstr "Profile revisions here are candidates. Applying one requests it like Save and request does; the overview shows when the processor acknowledges it." + +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:29 msgid "Profiles" msgstr "Profiles" @@ -1804,6 +2098,10 @@ msgstr "Prospective observations" msgid "Protocol hash" msgstr "Protocol hash" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:317 +msgid "quality" +msgstr "quality" + #: src/components/biometrics/Calibration/NumericTrace.tsx:98 msgid "Quality" msgstr "Quality" @@ -1812,10 +2110,28 @@ msgstr "Quality" msgid "Queue backtest" msgstr "Queue backtest" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "queued" +msgstr "queued" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 msgid "Queued" msgstr "Queued" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:83 +msgid "Queueing the backtest…" +msgstr "Queueing the backtest…" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:133 +msgid "range" +msgstr "range" + +#. placeholder {0}: setup.done ?? 0 +#. placeholder {1}: setup.total ?? 0 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:80 +msgid "Reading RAW archives · {0} / {1}" +msgstr "Reading RAW archives · {0} / {1}" + #: src/components/biometrics/Calibration/StageReport.tsx:52 msgid "Recall" msgstr "Recall" @@ -1854,6 +2170,11 @@ msgstr "Recorded configurations: {0}; readings with unknown configuration: {1}." msgid "Recorded nights" msgstr "Recorded nights" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:343 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:143 +msgid "reference" +msgstr "reference" + #: src/components/biometrics/Calibration/StageReport.tsx:31 msgid "Reference / predicted" msgstr "Reference / predicted" @@ -1974,7 +2295,7 @@ msgstr "Reference source" msgid "Reference-defined subgroup comparisons" msgstr "Reference-defined subgroup comparisons" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:27 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:28 msgid "References" msgstr "References" @@ -1986,6 +2307,10 @@ msgstr "Refresh report" msgid "Regression under the evaluation policy" msgstr "Regression under the evaluation policy" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:207 +msgid "Reject intervals shorter than" +msgstr "Reject intervals shorter than" + #: src/components/biometrics/Calibration/BacktestReport.tsx:162 msgid "Release detailed report" msgstr "Release detailed report" @@ -2002,6 +2327,10 @@ msgstr "Release retained inputs" msgid "Release these retained inputs permanently? Existing reports and audit metadata remain. Active jobs must finish or be cancelled first." msgstr "Release these retained inputs permanently? Existing reports and audit metadata remain. Active jobs must finish or be cancelled first." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:94 +msgid "released" +msgstr "released" + #: src/components/biometrics/Calibration/StageReport.tsx:12 #: src/components/biometrics/Calibration/StageTrace.tsx:30 msgid "REM" @@ -2059,6 +2388,14 @@ msgstr "Report hash" msgid "Report identity" msgstr "Report identity" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:197 +msgid "Report no heart rate when the beat signal scores below" +msgstr "Report no heart rate when the beat signal scores below" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:233 +msgid "Report the index only after" +msgstr "Report the index only after" + #: src/components/biometrics/Calibration/ShadowRuns.tsx:51 msgid "Reports describe observed coverage and reference agreement; they do not authorize activation." msgstr "Reports describe observed coverage and reference agreement; they do not authorize activation." @@ -2067,14 +2404,18 @@ msgstr "Reports describe observed coverage and reference agreement; they do not msgid "Reproducible sampling seed" msgstr "Reproducible sampling seed" -#. placeholder {0}: event.requester -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:54 -msgid "Requested by {0}" -msgstr "Requested by {0}" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:18 +msgid "Requested" +msgstr "Requested" + +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:213 +msgid "Requested activation · generation {0}" +msgstr "Requested activation · generation {0}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:26 -msgid "Requested change" -msgstr "Requested change" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:135 +msgid "Requesting…" +msgstr "Requesting…" #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:117 msgid "Requires re-verification" @@ -2100,21 +2441,41 @@ msgstr "Resource observations are partial. Worker health reflects the last valid msgid "Restart archive scan" msgstr "Restart archive scan" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:17 -msgid "Restore a profile" -msgstr "Restore a profile" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:135 +msgid "Restore and request" +msgstr "Restore and request" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:345 +msgid "Restore previous" +msgstr "Restore previous" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ProfileComparison.tsx:28 msgid "Restore previous profile" msgstr "Restore previous profile" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:127 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ProfileComparison.tsx:28 msgid "Restore system defaults" msgstr "Restore system defaults" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:127 +msgid "Restore the previous profile" +msgstr "Restore the previous profile" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:72 +msgid "Restore… ▾" +msgstr "Restore… ▾" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:216 +msgid "Restored system defaults and requested them" +msgstr "Restored system defaults and requested them" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:215 +msgid "Restored the previous profile and requested it" +msgstr "Restored the previous profile and requested it" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:33 msgid "Retain a copy of the selected nights and reference revisions. Future edits or source deletion do not alter this dataset." msgstr "Retain a copy of the selected nights and reference revisions. Future edits or source deletion do not alter this dataset." @@ -2144,6 +2505,10 @@ msgstr "Retained input integrity checked by the server." msgid "Retained waveform replay" msgstr "Retained waveform replay" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:81 +msgid "Retaining the archives…" +msgstr "Retaining the archives…" + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:46 msgid "Retry exact activation request" @@ -2161,10 +2526,6 @@ msgstr "Retry same scheduling request" msgid "Review candidate" msgstr "Review candidate" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:17 -msgid "Review changes before requesting system defaults or the exact previous profile." -msgstr "Review changes before requesting system defaults or the exact previous profile." - #: src/components/biometrics/Calibration/ShadowRuns.tsx:88 msgid "Review observation report" msgstr "Review observation report" @@ -2173,18 +2534,10 @@ msgstr "Review observation report" msgid "Review prepared plan" msgstr "Review prepared plan" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:21 -msgid "Review previous profile" -msgstr "Review previous profile" - #: src/components/biometrics/Calibration/BacktestJobs.tsx:81 msgid "Review report" msgstr "Review report" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:20 -msgid "Review system defaults" -msgstr "Review system defaults" - #. placeholder {0}: profile.revision #. placeholder {1}: profile.origin #. placeholder {2}: profile.requester @@ -2209,6 +2562,15 @@ msgstr "Right" msgid "Row {0}: {1}" msgstr "Row {0}: {1}" +#. placeholder {0}: o.rule +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:255 +msgid "Rule {0}" +msgstr "Rule {0}" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "running" +msgstr "running" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 msgid "Running" msgstr "Running" @@ -2222,6 +2584,10 @@ msgstr "Sampled worker memory peak: {0} MiB. Short peaks between samples may be msgid "Save a candidate for evaluation. This does not change live processing." msgstr "Save a candidate for evaluation. This does not change live processing." +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Save and request" +msgstr "Save and request" + #: src/components/biometrics/Calibration/ProfileCandidateEditor.tsx:111 msgid "Save candidate without applying" msgstr "Save candidate without applying" @@ -2230,11 +2596,21 @@ msgstr "Save candidate without applying" msgid "Save evaluation protocol" msgstr "Save evaluation protocol" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:189 +msgid "Save failed." +msgstr "Save failed." + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:103 msgid "Save previewed import" msgstr "Save previewed import" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:256 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:276 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:341 +msgid "saved" +msgstr "saved" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:17 msgid "Saved" msgstr "Saved" @@ -2251,9 +2627,9 @@ msgstr "Saved experiments" msgid "Saved observations: {0}" msgstr "Saved observations: {0}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 -msgid "Saved profile" -msgstr "Saved profile" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:160 +msgid "Saved profile against previous" +msgstr "Saved profile against previous" #: src/components/biometrics/Calibration/ProfileLibrary.tsx:19 msgid "Saved profile revisions" @@ -2265,9 +2641,17 @@ msgstr "Saved profile revisions" msgid "Saved sample times: {0}. Planned observation intervals: {1}." msgstr "Saved sample times: {0}. Planned observation intervals: {1}." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:24 -msgid "Saved settings and processor reports are separate." -msgstr "Saved settings and processor reports are separate." +#: src/components/biometrics/Calibration/CalibrationPage.tsx:211 +msgid "Saved the profile" +msgstr "Saved the profile" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:141 +msgid "Saves it as the profile and requests it; the current saved profile becomes previous. The processor keeps running what it has until it acknowledges the request." +msgstr "Saves it as the profile and requests it; the current saved profile becomes previous. The processor keeps running what it has until it acknowledges the request." + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Saving…" +msgstr "Saving…" #: src/components/biometrics/Calibration/ShadowSchedule.tsx:129 #: src/components/biometrics/Calibration/ShadowSchedule.tsx:130 @@ -2339,15 +2723,15 @@ msgstr "Selected reference revisions: {0} / 20" msgid "Selected sessions: {0} / 60" msgstr "Selected sessions: {0} / 60" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:53 -msgid "Sensor measurements are separate from algorithm personalization." -msgstr "Sensor measurements are separate from algorithm personalization." - #. placeholder {0}: cohort.sessionIds.join(', ') #: src/components/biometrics/Calibration/DatasetLibrary.tsx:78 msgid "Sessions: {0}" msgstr "Sessions: {0}" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:82 +msgid "Setting up the replay…" +msgstr "Setting up the replay…" + #: src/components/biometrics/Calibration/NumericReport.tsx:108 msgid "Shared MAE difference" msgstr "Shared MAE difference" @@ -2370,6 +2754,23 @@ msgstr "Shared-timestamp comparisons are unavailable in this report." msgid "Shared-timestamp comparisons. Differences are comparison minus baseline; negative error differences mean lower error on shared observations." msgstr "Shared-timestamp comparisons. Differences are comparison minus baseline; negative error differences mean lower error on shared observations." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:222 +msgid "Shortest breathing interval (× median)" +msgstr "Shortest breathing interval (× median)" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:401 +msgid "show" +msgstr "show" + +#. placeholder {0}: rows.length +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:42 +msgid "Show all {0}" +msgstr "Show all {0}" + +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:42 +msgid "Show recent" +msgstr "Show recent" + #: src/components/biometrics/Calibration/ReplaySessionPicker.tsx:67 msgid "Signal replay unavailable: raw waveforms and processing context have not been retained by this service." msgstr "Signal replay unavailable: raw waveforms and processing context have not been retained by this service." @@ -2382,6 +2783,14 @@ msgstr "Signed bias" msgid "Simulated demo: generated readings, no hardware execution or qualification." msgstr "Simulated demo: generated readings, no hardware execution or qualification." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:249 +msgid "Sleep stages" +msgstr "Sleep stages" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:243 +msgid "SLEEP STAGES" +msgstr "SLEEP STAGES" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:59 msgid "Sleeper identifier" msgstr "Sleeper identifier" @@ -2464,10 +2873,6 @@ msgstr "Stage rules on original readings" msgid "Stage trace for the displayed intervals; exact times and stages follow in the table." msgstr "Stage trace for the displayed intervals; exact times and stages follow in the table." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -msgid "Stale" -msgstr "Stale" - #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:67 #: src/components/biometrics/Calibration/StageTrace.tsx:64 msgid "Start" @@ -2498,6 +2903,11 @@ msgstr "Stop observation" msgid "Stop recorded" msgstr "Stop recorded" +#. placeholder {0}: i.summary.source +#: src/components/biometrics/Calibration/CalibrationPage.tsx:242 +msgid "Stored a {0} reference import" +msgstr "Stored a {0} reference import" + #. placeholder {0}: window.observationCount #: src/components/biometrics/Calibration/NumericReport.tsx:166 msgid "Stored observations: {0}" @@ -2515,7 +2925,8 @@ msgstr "Stored-reading comparison available" #: src/components/biometrics/Calibration/BacktestJobs.tsx:64 #: src/components/biometrics/Calibration/BacktestReport.tsx:60 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 +#: src/components/biometrics/Calibration/CalibrationControls.tsx:80 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:171 #: src/components/biometrics/Calibration/NumericReport.tsx:26 #: src/components/biometrics/Calibration/NumericTrace.tsx:147 #: src/components/biometrics/Calibration/ProfileComparison.tsx:29 @@ -2525,6 +2936,10 @@ msgstr "Stored-reading comparison available" msgid "System defaults" msgstr "System defaults" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:122 +msgid "tested on save" +msgstr "tested on save" + #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:84 msgid "The archive directory is unavailable." msgstr "The archive directory is unavailable." @@ -2538,14 +2953,14 @@ msgstr "The current pod algorithm reports a window IBI variability index, not cl msgid "The frozen offset of {clockOffsetMs} ms is added to recomputed timestamps in this overlay. Reference times stay unchanged. Point matching tolerance: {0} ms." msgstr "The frozen offset of {clockOffsetMs} ms is added to recomputed timestamps in this overlay. Reference times stay unchanged. Point matching tolerance: {0} ms." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:45 -msgid "The HRV index measures window IBI variability. It is not clinical beat-to-beat RMSSD, and incompatible reference metrics cannot validate it." -msgstr "The HRV index measures window IBI variability. It is not clinical beat-to-beat RMSSD, and incompatible reference metrics cannot validate it." - #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:83 msgid "The last archive was unavailable. Continue to inspect the next archive." msgstr "The last archive was unavailable. Continue to inspect the next archive." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:215 +msgid "the median" +msgstr "the median" + #: src/components/biometrics/Calibration/ShadowReport.tsx:19 msgid "The observation gap exceeded the planned limit." msgstr "The observation gap exceeded the planned limit." @@ -2554,10 +2969,6 @@ msgstr "The observation gap exceeded the planned limit." msgid "The observation window was missed." msgstr "The observation window was missed." -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:27 -msgid "The pinned profile is incompatible. Its revision will be preserved, but a valid before-and-after comparison is unavailable." -msgstr "The pinned profile is incompatible. Its revision will be preserved, but a valid before-and-after comparison is unavailable." - #: src/components/biometrics/Calibration/ShadowSchedule.tsx:77 msgid "The plan could not be prepared. Check the selected profile, window and installed qualification." msgstr "The plan could not be prepared. Check the selected profile, window and installed qualification." @@ -2579,12 +2990,15 @@ msgstr "The request could not be saved for recovery. This attempt was not sent. msgid "The retained inputs are unavailable for new evaluations." msgstr "The retained inputs are unavailable for new evaluations." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:252 +msgid "The rule list itself differs from the shipped rules." +msgstr "The rule list itself differs from the shipped rules." + #: src/components/biometrics/Calibration/ProfileComparison.tsx:33 msgid "The saved profile changed. Compare again before reviewing these changes." msgstr "The saved profile changed. Compare again before reviewing these changes." #: src/components/biometrics/Calibration/ProfileComparison.tsx:33 -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:34 msgid "The saved profile changed. Select a recovery action again to make a new request." msgstr "The saved profile changed. Select a recovery action again to make a new request." @@ -2636,6 +3050,10 @@ msgstr "These scores compare original stored readings. Identical numerical resul msgid "These scores compare readings recomputed from retained waveforms. Shared-timestamp scores use observations available in every configuration. Differences describe agreement with the reference and do not establish validated improvement." msgstr "These scores compare readings recomputed from retained waveforms. Shared-timestamp scores use observations available in every configuration. Differences describe agreement with the reference and do not establish validated improvement." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:267 +msgid "This backtest compared stored readings, so there is no recomputed heart-rate trace to draw." +msgstr "This backtest compared stored readings, so there is no recomputed heart-rate trace to draw." + #: src/components/biometrics/Calibration/StageTrace.tsx:41 msgid "This chart shows only the current page of replay intervals. Gaps are left empty; no stages are inferred between intervals." msgstr "This chart shows only the current page of replay intervals. Gaps are left empty; no stages are inferred between intervals." @@ -2709,7 +3127,6 @@ msgstr "Time" msgid "Times shown in {0}" msgstr "Times shown in {0}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:23 #: src/components/biometrics/Calibration/NumericReport.tsx:85 #: src/components/biometrics/Calibration/StageTrace.tsx:43 msgid "Times shown in {timezone}" @@ -2744,6 +3161,19 @@ msgstr "Trace intervals {first}–{last} of {0}" msgid "Trace night" msgstr "Trace night" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:193 +msgid "Tracking history length" +msgstr "Tracking history length" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:183 +msgid "Tracking tolerance (bpm)" +msgstr "Tracking tolerance (bpm)" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:127 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "tuning" +msgstr "tuning" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 #: src/components/biometrics/Calibration/BacktestReport.tsx:61 #: src/components/biometrics/Calibration/BacktestSubmission.tsx:73 @@ -2763,6 +3193,10 @@ msgstr "Unavailable" msgid "Unavailable or belongs to a different sleeper." msgstr "Unavailable or belongs to a different sleeper." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:121 +msgid "unchanged" +msgstr "unchanged" + #. placeholder {0}: parameter.unit #. placeholder {1}: parameter.minimum #. placeholder {2}: parameter.maximum @@ -2790,10 +3224,6 @@ msgstr "Unmatched references" msgid "Use offset-bearing ISO timestamps or Unix milliseconds. Each record needs recordKey, metric, value, unit, and timestamp (or start and end). JSON uses an array; CSV uses these field names as headers. Imports are limited to 2 MiB and 2000 records." msgstr "Use offset-bearing ISO timestamps or Unix milliseconds. Each record needs recordKey, metric, value, unit, and timestamp (or start and end). JSON uses an array; CSV uses these field names as headers. Imports are limited to 2 MiB and 2000 records." -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Use system defaults" -msgstr "Use system defaults" - #: src/components/biometrics/Calibration/ShadowSchedule.tsx:145 msgid "Use the same sleeper identity as the reference records you intend to compare." msgstr "Use the same sleeper identity as the reference records you intend to compare." @@ -2802,6 +3232,11 @@ msgstr "Use the same sleeper identity as the reference records you intend to com msgid "Validated activation" msgstr "Validated activation" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:131 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "validation" +msgstr "validation" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 #: src/components/biometrics/Calibration/BacktestReport.tsx:61 #: src/components/biometrics/Calibration/BacktestSubmission.tsx:74 @@ -2827,8 +3262,6 @@ msgstr "Value / unit" msgid "Vitals with unknown original processing provenance: {0}" msgstr "Vitals with unknown original processing provenance: {0}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Waiting for a processing boundary" msgstr "Waiting for a processing boundary" @@ -2837,8 +3270,6 @@ msgstr "Waiting for a processing boundary" msgid "Waiting for a worker and resource admission. No execution is implied by a queued request." msgstr "Waiting for a worker and resource admission. No execution is implied by a queued request." -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Waiting for delivery" msgstr "Waiting for delivery" @@ -2856,9 +3287,13 @@ msgstr "Wake" msgid "Waveform dataset retained: {published}" msgstr "Waveform dataset retained: {published}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:44 -msgid "What these metrics mean" -msgstr "What these metrics mean" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:196 +msgid "What the saved profile did" +msgstr "What the saved profile did" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:257 +msgid "when" +msgstr "when" #: src/components/biometrics/Calibration/DatasetBuilder.tsx:59 #: src/components/biometrics/Calibration/DatasetLibrary.tsx:70 @@ -2875,6 +3310,10 @@ msgstr "Window IBI variability index" msgid "Window IBI variability index (ms)" msgstr "Window IBI variability index (ms)" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:239 +msgid "window IBI variability, not clinical RMSSD" +msgstr "window IBI variability, not clinical RMSSD" + #: src/components/biometrics/Calibration/ShadowReport.tsx:34 msgid "Within the observed limits." msgstr "Within the observed limits." @@ -2883,6 +3322,10 @@ msgstr "Within the observed limits." msgid "Work was deferred; your selection and retry key are preserved." msgstr "Work was deferred; your selection and retry key are preserved." +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:41 +msgid "WORKBENCH" +msgstr "WORKBENCH" + #: src/components/biometrics/Calibration/ShadowReport.tsx:18 msgid "Worker cleanup has not been confirmed." msgstr "Worker cleanup has not been confirmed." diff --git a/src/lib/i18n/locales/es.po b/src/lib/i18n/locales/es.po index 6abcc6ce5..4e374744e 100644 --- a/src/lib/i18n/locales/es.po +++ b/src/lib/i18n/locales/es.po @@ -13,6 +13,22 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with {count} overrides" +msgstr ", con {count} ajustes propios" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with 1 override" +msgstr ", con 1 ajuste propio" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with no overrides" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:393 +msgid ". Separate from the profile." +msgstr ". Independiente del perfil." + #. placeholder {0}: item.summary.source #. placeholder {1}: item.summary.subjectId #. placeholder {2}: item.revision @@ -20,6 +36,31 @@ msgstr "" msgid "{0} · {1} · revision {2}" msgstr "{0} · {1} · revisión {2}" +#. placeholder {0}: label.weekday +#. placeholder {1}: label.day +#. placeholder {2}: fmt1(prev) +#. placeholder {3}: fmt1(saved) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:228 +msgid "{0} {1}: previous {2}, saved {3} bpm" +msgstr "{0} {1}: anterior {2}, guardado {3} lpm" + +#. placeholder {0}: splitLabel[job.split] +#. placeholder {1}: avg(n => n.previous.heart_rate.mae as number) +#. placeholder {2}: avg(n => n.saved.heart_rate.mae as number) +#. placeholder {3}: shortDuration(job.updatedAtMs - job.createdAtMs) +#: src/components/biometrics/Calibration/CalibrationPage.tsx:231 +msgid "{0} HR error {1} → {2} bpm · {3}" +msgstr "{0} error de FC {1} → {2} lpm · {3}" + +#. placeholder {0}: i.summary.accepted +#: src/components/biometrics/Calibration/CalibrationPage.tsx:242 +msgid "{0} records" +msgstr "{0} registros" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:160 +msgid "{stages} sleep-stage values" +msgstr "{stages} valores de fases del sueño" + #: src/components/biometrics/Calibration/NumericReport.tsx:175 msgid "95th percentile absolute error" msgstr "Percentil 95 del error absoluto" @@ -32,6 +73,18 @@ msgstr "Una solicitud guardada no significa que la ejecución haya comenzado o f msgid "A saved revision is not evidence of validation or processor adoption." msgstr "Una revisión guardada no demuestra validación ni adopción por el procesador." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:177 +msgid "Accept an estimate when it is within" +msgstr "Aceptar una estimación cuando esté a menos de" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:237 +msgid "accepted 10-second IBI windows" +msgstr "ventanas IBI de 10 segundos aceptadas" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:191 +msgid "accepted estimates in the tracker" +msgstr "estimaciones aceptadas en el seguimiento" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:87 msgid "Accepted records are not necessarily usable for validation. Incompatible HRV definitions remain unscorable." msgstr "Los registros aceptados no son necesariamente válidos para la validación. Las definiciones incompatibles de VFC no se pueden evaluar." @@ -53,10 +106,6 @@ msgstr "Aceptados: {0}; rechazados: {1}; duplicados: {2}; no evaluables: {3}" msgid "Accepted: {0}; rejected: {1}; duplicates: {2}; unscorable: {3}; conflicts: {4}" msgstr "Aceptados: {0}; rechazados: {1}; duplicados: {2}; no evaluables: {3}; conflictos: {4}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:21 -msgid "Activation history" -msgstr "Historial de activación" - #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:76 msgid "Activation request saved. Processor acknowledgement is reported separately below." msgstr "Solicitud de activación guardada. La confirmación del procesador se indica por separado a continuación." @@ -80,6 +129,10 @@ msgstr "Generaciones activas: izquierda {0}, derecha {1}." msgid "Active profile hash" msgstr "Hash del perfil activo" +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:21 +msgid "Activity" +msgstr "Actividad" + #: src/components/biometrics/Calibration/NumericReport.tsx:111 msgid "Added readings" msgstr "Lecturas añadidas" @@ -122,8 +175,6 @@ msgstr "Hay otro problema en el informe que requiere revisión." msgid "Another revision from this reference family is selected." msgstr "Se ha seleccionado otra revisión de esta familia de referencias." -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Applied" msgstr "Aplicado" @@ -132,10 +183,6 @@ msgstr "Aplicado" msgid "Apply evaluated revision" msgstr "Aplicar la revisión evaluada" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Apply profile" -msgstr "Aplicar perfil" - #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:32 msgid "Apply this exact candidate using the selected evaluation. Evidence and current settings are checked again when applying." msgstr "Aplica este candidato exacto usando la evaluación seleccionada. La evidencia y la configuración actual se comprueban de nuevo al aplicarlo." @@ -172,10 +219,20 @@ msgstr "Intento {0}" msgid "Available reference records by metric (across revisions)" msgstr "Registros de referencia disponibles por métrica (entre revisiones)" +#. placeholder {0}: group.latest.id.slice(0, 4) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:197 +msgid "Backtest {0}. Frozen dataset replayed through the previous and saved profiles; nothing here changes the pod." +msgstr "Prueba retrospectiva {0}. El conjunto congelado se reprodujo con los perfiles anterior y guardado; nada aquí cambia el pod." + #: src/components/biometrics/Calibration/BacktestJobs.tsx:21 msgid "Backtest jobs" msgstr "Trabajos de evaluación retrospectiva" +#. placeholder {0}: splitLabel[job.split] +#: src/components/biometrics/Calibration/CalibrationPage.tsx:235 +msgid "Backtest on {0} nights failed" +msgstr "Falló la prueba retrospectiva en noches de {0}" + #. placeholder {0}: enqueue.data.id #: src/components/biometrics/Calibration/BacktestSubmission.tsx:93 msgid "Backtest request saved: {0}" @@ -185,7 +242,7 @@ msgstr "Solicitud de evaluación retrospectiva guardada: {0}" msgid "Backtest submission" msgstr "Envío de evaluación retrospectiva" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:29 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:30 msgid "Backtests" msgstr "Evaluaciones retrospectivas" @@ -201,11 +258,26 @@ msgstr "Antes" msgid "Before configuration hash" msgstr "Hash de configuración anterior" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:24 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:68 msgid "Biometric calibration" msgstr "Calibración biométrica" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:279 +msgid "Biometric calibration side" +msgstr "Lado de la calibración biométrica" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:181 +msgid "bpm of the tracker" +msgstr "lpm del seguimiento" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:204 +msgid "BREATHING" +msgstr "RESPIRACIÓN" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:189 +msgid "Breathing error" +msgstr "Error de respiración" + #: src/components/biometrics/Calibration/ShadowReport.tsx:30 msgid "Breathing rate" msgstr "Frecuencia respiratoria" @@ -216,10 +288,18 @@ msgstr "Frecuencia respiratoria" msgid "Breathing rate (breaths/min)" msgstr "Frecuencia respiratoria (respiraciones/min)" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:25 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:38 +msgid "Calibration" +msgstr "Calibración" + +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:34 msgid "Calibration views" msgstr "Vistas de calibración" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:136 +msgid "Cancel" +msgstr "Cancelar" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:79 msgid "Cancel backtest" msgstr "Cancelar evaluación retrospectiva" @@ -318,6 +398,14 @@ msgstr "Reglas de fases del sueño del candidato" msgid "Candidate spike threshold" msgstr "Umbral de picos del candidato" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:162 +msgid "Changed values are white; the previous profile (System defaults) sits underneath. Moving a control edits the saved profile; saving again re-requests it. The processor adopts a profile only after it acknowledges a request." +msgstr "Los valores cambiados aparecen en blanco; el perfil anterior (valores del sistema) queda debajo. Mover un control edita el perfil guardado; guardarlo de nuevo lo vuelve a solicitar. El procesador adopta un perfil solo después de confirmar una solicitud." + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:163 +msgid "Changed values are white; the previous profile sits underneath. Moving a control edits the saved profile; saving again re-requests it. The processor adopts a profile only after it acknowledges a request." +msgstr "Los valores cambiados aparecen en blanco; el perfil anterior queda debajo. Mover un control edita el perfil guardado; guardarlo de nuevo lo vuelve a solicitar. El procesador adopta un perfil solo después de confirmar una solicitud." + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:86 msgid "Check Overview for current processor health; a past acknowledgement does not prove current health." msgstr "Consulte Resumen para ver el estado actual del procesador; una confirmación anterior no demuestra el estado actual." @@ -342,6 +430,10 @@ msgstr "Elige una plantilla" msgid "Choose the protocol before looking at results. Creating an experiment does not run a job or apply a profile." msgstr "Elija el protocolo antes de ver los resultados. Crear un experimento no ejecuta un trabajo ni aplica un perfil." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:78 +msgid "Choosing nights the reference covers…" +msgstr "" + #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:38 msgid "Circles are recomputed readings; squares are reference points; horizontal bars are reference intervals. Only the two displayed pages are shown. This visual overlay does not create new matched pairs or scores." msgstr "Los círculos son lecturas recalculadas; los cuadrados son puntos de referencia; las barras horizontales son intervalos de referencia. Solo se muestran las dos páginas seleccionadas. Esta superposición visual no crea nuevos pares emparejados ni puntuaciones." @@ -350,9 +442,9 @@ msgstr "Los círculos son lecturas recalculadas; los cuadrados son puntos de ref msgid "Clear this night’s selection" msgstr "Borrar la selección de esta noche" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 -msgid "Clock mismatch" -msgstr "Desajuste de reloj" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:205 +msgid "click a night" +msgstr "elige una noche" #. placeholder {0}: minutes(score.pairedDurationMs) #: src/components/biometrics/Calibration/StageReport.tsx:17 @@ -443,10 +535,6 @@ msgstr "MAE de la comparación con observaciones comunes" msgid "Compatibility" msgstr "Compatibilidad" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Compatibility fallback" -msgstr "Configuración alternativa compatible" - #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:90 msgid "Compatible" msgstr "Compatible" @@ -464,15 +552,15 @@ msgstr "Noches completadas" msgid "Compressed bytes: {0}" msgstr "Bytes comprimidos: {0}" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:396 +msgid "config id" +msgstr "id de configuración" + #: src/components/biometrics/Calibration/BacktestReport.tsx:98 #: src/components/biometrics/Calibration/StageReport.tsx:107 msgid "Configuration" msgstr "Configuración" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:36 -msgid "Configuration identity" -msgstr "Identidad de la configuración" - #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:58 msgid "Confirm import deletion" msgstr "Confirmar la eliminación de la importación" @@ -506,10 +594,13 @@ msgstr "Cobertura reducida" msgid "Coverage reduced in at least one evaluated window" msgstr "Cobertura reducida en al menos una ventana evaluada" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Current" -msgstr "Actual" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:129 +msgid "covered" +msgstr "cubierto" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "current" +msgstr "vigente" #: src/components/biometrics/Calibration/BacktestReport.tsx:60 #: src/components/biometrics/Calibration/NumericReport.tsx:26 @@ -543,6 +634,10 @@ msgstr "Conjunto de datos" msgid "Dataset hash" msgstr "Hash del conjunto de datos" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:99 +msgid "Dataset nights" +msgstr "Noches del conjunto" + #. placeholder {0}: create.data.id #: src/components/biometrics/Calibration/DatasetBuilder.tsx:57 msgid "Dataset saved: {0}" @@ -557,10 +652,6 @@ msgstr "Identificador de la persona del conjunto de datos" msgid "Deep" msgstr "Profundo" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:31 -msgid "Default recovery configuration hash" -msgstr "Hash de la configuración predeterminada de recuperación" - #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:52 msgid "Delete import" msgstr "Eliminar importación" @@ -577,13 +668,10 @@ msgstr "Eliminar una importación borra sus mediciones de origen. Los conjuntos msgid "Descriptive evaluation only" msgstr "Solo evaluación descriptiva" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 -msgid "Different versions reported" -msgstr "Se han informado versiones diferentes" - -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:57 -msgid "Direct sleep-stage changes are unvalidated manual settings. Saving requests a profile change; processor adoption is reported separately in Overview." -msgstr "Los cambios directos de las fases del sueño son ajustes manuales sin validar. Al guardar se solicita un cambio de perfil; su adopción por el procesador se informa por separado en Resumen." +#. placeholder {0}: shortDuration(run.durationMs) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "done · {0}" +msgstr "listo · {0}" #: src/components/biometrics/Calibration/NumericReport.tsx:151 msgid "Dropping readings can lower own-support error. Duplicate timestamps are excluded from shared support. These counts do not measure continuous time coverage." @@ -597,10 +685,18 @@ msgstr "Las duraciones abarcan el trabajo secuencial de Python hasta la generaci msgid "Each configuration on its own available intervals" msgstr "Cada configuración en sus propios intervalos disponibles" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:268 +msgid "Each night's heart rate against the reference appears here once a backtest has run." +msgstr "" + #: src/components/biometrics/Calibration/ProfileLibrary.tsx:34 msgid "Edit a new candidate from this revision" msgstr "Editar un nuevo candidato a partir de esta revisión" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:273 +msgid "Edit overrides" +msgstr "Editar ajustes" + #: src/components/biometrics/Calibration/ProfileLibrary.tsx:20 msgid "Editing from revision {revisionId}" msgstr "Editando a partir de la revisión {revisionId}" @@ -660,6 +756,10 @@ msgstr "Estimar incertidumbre exploratoria por noches completas" msgid "Evaluated minutes: {0}" msgstr "Minutos evaluados: {0}" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:90 +msgid "Evaluation dataset" +msgstr "Conjunto de evaluación" + #: src/components/biometrics/Calibration/ExperimentBuilder.tsx:47 msgid "Evaluation mode" msgstr "Modo de evaluación" @@ -693,10 +793,6 @@ msgstr "Las fechas normalizadas exactas se muestran en UTC. Los valores no se in msgid "Exact processed-data coverage" msgstr "Cobertura exacta de los datos procesados" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:45 -msgid "Exact profile transition" -msgstr "Cambio exacto de perfil" - #: src/components/biometrics/Calibration/NumericTrace.tsx:93 msgid "Exact recomputed readings, without reference clock adjustment" msgstr "Lecturas recalculadas exactas, sin ajuste del reloj de referencia" @@ -728,9 +824,9 @@ msgstr "El estado de ejecución es independiente de la solidez de la evidencia b msgid "Experiment saved: {0}" msgstr "Experimento guardado: {0}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Expired" -msgstr "Caducada" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "expired" +msgstr "caducada" #: src/components/biometrics/Calibration/CandidateStageFields.tsx:39 msgid "Explicit stage overrides" @@ -757,19 +853,38 @@ msgstr "Intervalo exploratorio estimado" msgid "F1" msgstr "F1" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "failed" +msgstr "falló" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 #: src/components/biometrics/Calibration/ShadowRuns.tsx:12 msgid "Failed" msgstr "Fallido" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:265 +msgid "Fallback" +msgstr "Fase por defecto" + #: src/components/biometrics/Calibration/CandidateStageFields.tsx:21 #: src/components/biometrics/Calibration/CandidateStageFields.tsx:21 msgid "Fallback stage" msgstr "Fase alternativa" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:217 +msgid "Fell back to a compatible profile" +msgstr "Se recurrió a un perfil compatible" + +#. placeholder {0}: splitLabel[job.split] +#: src/components/biometrics/Calibration/CalibrationPage.tsx:235 +msgid "Finished a backtest on {0} nights" +msgstr "Terminó una prueba retrospectiva en noches de {0}" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:344 +msgid "first 500 readings" +msgstr "primeras 500 lecturas" + #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:125 msgid "First archive page" msgstr "Primera página de archivos" @@ -794,6 +909,10 @@ msgstr "Desplazamiento fijo añadido a las fechas del pod (ms, ±3600000)" msgid "Flags" msgstr "Indicadores" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:143 +msgid "Freeze a new dataset" +msgstr "Congelar un conjunto nuevo" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:33 msgid "Freeze evaluation inputs" msgstr "Fijar datos de evaluación" @@ -802,9 +921,43 @@ msgstr "Fijar datos de evaluación" msgid "Freeze selected inputs" msgstr "Fijar datos seleccionados" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Freshness unknown" -msgstr "Vigencia desconocida" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:85 +msgid "Freezing failed." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:79 +msgid "Freezing the nights…" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "freshness unknown" +msgstr "vigencia desconocida" + +#. placeholder {0}: d.manifest.splitProtocol.assignments.length +#: src/components/biometrics/Calibration/CalibrationPage.tsx:239 +msgid "Froze a dataset of {0} nights" +msgstr "Congeló un conjunto de {0} noches" + +#. placeholder {0}: formatDay(dataset.createdAtMs, timezone) +#. placeholder {1}: formatBytes(dataset.byteLength) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:94 +msgid "frozen {0} · {1}" +msgstr "congelado {0} · {1}" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:84 +msgid "Frozen and queued. The backtest replays every split; results appear as each finishes." +msgstr "" + +#. placeholder {0}: nights.length +#. placeholder {1}: sources.join(' + ') +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:71 +msgid "Frozen copy of {0} nights and {1} reference. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own." +msgstr "Copia congelada de {0} noches y la referencia de {1}. Las ediciones o eliminaciones posteriores no la afectan. Las noches se reparten enteras antes de puntuar; un reparto no es evidencia por sí solo." + +#. placeholder {0}: nights.length +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:72 +msgid "Frozen copy of {0} nights. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own." +msgstr "Copia congelada de {0} noches. Las ediciones o eliminaciones posteriores no la afectan. Las noches se reparten enteras antes de puntuar; un reparto no es evidencia por sí solo." #: src/components/biometrics/Calibration/DatasetLibrary.tsx:85 msgid "Frozen coverage and capabilities" @@ -838,26 +991,32 @@ msgstr "Registros de origen fijados en UTC; los registros incompatibles se enume msgid "Frozen stage objective thresholds" msgstr "Umbrales fijados de los objetivos de fases del sueño" -#. placeholder {0}: data.desired.binding.generation -#. placeholder {0}: event.generation -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:41 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:39 -msgid "Generation {0}" -msgstr "Generación {0}" - #: src/components/biometrics/Calibration/NumericReport.tsx:44 msgid "Groups use native reference timestamps and record types, independently of candidate output. Half-window groups exclude intervals crossing the midpoint. Groups overlap and are not independent experiments. These are not motion or artifact subgroups." msgstr "Los grupos usan las marcas de tiempo y los tipos de registro originales de la referencia, independientemente de la salida del candidato. Los grupos de medio período excluyen los intervalos que cruzan el punto medio. Los grupos se solapan y no son experimentos independientes. No son subgrupos de movimiento ni de artefactos." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:258 +msgid "H holdout" +msgstr "H reserva" + #: src/components/biometrics/Calibration/ReplayResources.tsx:49 msgid "Health was sampled at admission checks, normally five seconds apart. Unobserved intervals are not continuous health measurements." msgstr "El estado se muestreó en las comprobaciones de admisión, normalmente cada cinco segundos. Los intervalos sin observaciones no constituyen mediciones continuas del estado." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 #: src/components/biometrics/Calibration/ShadowReport.tsx:30 msgid "Heart rate" msgstr "Frecuencia cardíaca" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:174 +msgid "HEART RATE" +msgstr "FRECUENCIA CARDÍACA" + +#. placeholder {0}: label.weekday +#. placeholder {1}: label.day +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:321 +msgid "Heart rate · {0} {1}" +msgstr "Frecuencia cardíaca · {0} {1}" + #: src/components/biometrics/Calibration/NumericReport.tsx:27 #: src/components/biometrics/Calibration/NumericTrace.tsx:38 #: src/components/biometrics/Calibration/ShadowComparison.tsx:44 @@ -868,10 +1027,18 @@ msgstr "Frecuencia cardíaca (latidos/min)" msgid "Heart-rate spike threshold (standard deviations)" msgstr "Umbral de picos de frecuencia cardíaca (desviaciones estándar)" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:325 +msgid "Heart-rate trace" +msgstr "Trazo de frecuencia cardíaca" + #: app/[lang]/health/HealthcheckDemo.tsx:13 msgid "Hello, tRPC test page!" msgstr "¡Hola, tRPC test page!" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:401 +msgid "hide" +msgstr "ocultar" + #. placeholder {0}: summary.improvedAgreementNights #. placeholder {1}: summary.regressedAgreementNights #. placeholder {2}: summary.unchangedAgreementNights @@ -879,13 +1046,10 @@ msgstr "¡Hola, tRPC test page!" msgid "Higher agreement: {0}; lower: {1}; unchanged: {2}" msgstr "Mayor concordancia: {0}; menor: {1}; sin cambios: {2}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:46 -msgid "Historical sleep stages are recomputed with the current classifier. Original stored vitals remain separate from replay results." -msgstr "Las fases históricas del sueño se recalculan con el clasificador actual. Los signos vitales originales almacenados se mantienen separados de los resultados del reprocesamiento." - -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:30 -msgid "History" -msgstr "Historial" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:135 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "holdout" +msgstr "reserva" #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 #: src/components/biometrics/Calibration/BacktestReport.tsx:61 @@ -900,6 +1064,26 @@ msgstr "Reserva" msgid "Holdout accesses recorded for this experiment: {0}" msgstr "Accesos a la reserva registrados para este experimento: {0}" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:288 +msgid "Holdout is the number to trust; tuning nights were used to pick these values." +msgstr "La reserva es la cifra fiable; las noches de ajuste se usaron para elegir estos valores." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:186 +msgid "HR error · holdout" +msgstr "Error de FC · reserva" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:184 +msgid "HR error · tuning" +msgstr "Error de FC · ajuste" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:185 +msgid "HR error · validation" +msgstr "Error de FC · validación" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:204 +msgid "HR error per night · bpm vs reference" +msgstr "Error de FC por noche · lpm frente a la referencia" + #: src/components/biometrics/BiometricsPage.tsx:532 msgid "HRV definition" msgstr "Definición de VFC" @@ -908,9 +1092,13 @@ msgstr "Definición de VFC" msgid "HRV definitions differ or are partly unknown. A combined HRV average is unavailable." msgstr "Las definiciones de VFC difieren o se desconocen en parte. No se puede calcular un promedio combinado de VFC." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 -msgid "HRV index" -msgstr "Índice de VFC" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:230 +msgid "HRV INDEX" +msgstr "ÍNDICE DE VFC" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:188 +msgid "HRV index error" +msgstr "Error del índice de VFC" #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:60 msgid "I intend to apply this exact revision without validation evidence." @@ -928,6 +1116,10 @@ msgstr "Quiero restaurar los valores predeterminados del sistema sin evidencia d msgid "I intend to restore the exact previous profile without validation evidence." msgstr "Quiero restaurar el perfil anterior exacto sin evidencia de validación." +#: src/components/biometrics/Calibration/CalibrationControls.tsx:147 +msgid "Identical to the saved profile; restoring only re-requests it." +msgstr "Idéntico al perfil guardado; restaurar solo lo vuelve a solicitar." + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:53 #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:38 msgid "Immediate processing boundary" @@ -941,6 +1133,10 @@ msgstr "Importar los registros aceptados y omitir los rechazados" msgid "Import preview" msgstr "Vista previa de la importación" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:141 +msgid "Import reference" +msgstr "Importar referencia" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:53 msgid "Import reference measurements" msgstr "Importar mediciones de referencia" @@ -972,6 +1168,11 @@ msgstr "Muestras de recepción de datos: {0} de {1} comprobaciones de admisión" msgid "Inherited default" msgstr "Valor predeterminado heredado" +#. placeholder {0}: stages.ruleCount +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:247 +msgid "Inherits the {0} rules and fallback from" +msgstr "Hereda las {0} reglas y la fase por defecto de" + #: src/components/biometrics/Calibration/DatasetLibrary.tsx:31 msgid "Inputs released; metadata retained" msgstr "Datos liberados; metadatos conservados" @@ -1063,6 +1264,14 @@ msgstr "Conservar informe" msgid "Keep retained inputs" msgstr "Conservar los datos retenidos" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Keep saved profile" +msgstr "Mantener el perfil guardado" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:187 +msgid "Keep the last" +msgstr "Conservar las últimas" + #. placeholder {0}: display(operations.ingestion.maximumObservationGapMs, 1000) #: src/components/biometrics/Calibration/ReplayResources.tsx:47 msgid "Largest observation gap: {0} seconds" @@ -1095,6 +1304,14 @@ msgstr "Ligero" msgid "Limitations" msgstr "Limitaciones" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:77 +msgid "Linking Watch data already on the pod…" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:223 +msgid "Longest breathing interval (× median)" +msgstr "Intervalo respiratorio más largo (× mediana)" + #: src/components/biometrics/Calibration/NumericReport.tsx:110 msgid "Lost readings" msgstr "Lecturas perdidas" @@ -1144,9 +1361,9 @@ msgstr "Error absoluto medio en marcas de tiempo comunes: activo {0}, candidato msgid "Measured Python processes: {0} of {1}" msgstr "Procesos Python medidos: {0} de {1}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:53 -msgid "Measured sensor calibration" -msgstr "Calibración medida de los sensores" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:390 +msgid "Measured sensor calibration for this side:" +msgstr "Calibración medida del sensor para este lado:" #: src/components/biometrics/Calibration/NumericReport.tsx:174 msgid "Median absolute error" @@ -1176,9 +1393,17 @@ msgstr "Ventana de la métrica: {0} segundos." msgid "Metrics: {0}" msgstr "Métricas: {0}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Migrate settings" -msgstr "Migrar ajustes" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:217 +msgid "Migrated sleep-stage settings into the profile" +msgstr "Migró los ajustes de fases del sueño al perfil" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:201 +msgid "Minimum beat signal score" +msgstr "Puntuación mínima de la señal del latido" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:240 +msgid "Minimum IBI windows" +msgstr "Ventanas IBI mínimas" #: src/components/biometrics/Calibration/ExperimentBuilder.tsx:97 msgid "Minimum paired nights (3–60)" @@ -1205,10 +1430,6 @@ msgstr "El procesamiento de nuevos archivos está aplazado por ahora." msgid "New observation plan" msgstr "Nuevo plan de observación" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:62 -msgid "Newest" -msgstr "Más recientes" - #: src/components/biometrics/Calibration/DatasetLibrary.tsx:41 msgid "Newest datasets" msgstr "Conjuntos de datos recientes" @@ -1280,6 +1501,10 @@ msgstr "Siguiente página de la traza" msgid "Night {0}" msgstr "Noche {0}" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:187 +msgid "Night covered" +msgstr "Noche cubierta" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:74 msgid "Night progress is not available yet." msgstr "El progreso por noches aún no está disponible." @@ -1288,14 +1513,30 @@ msgstr "El progreso por noches aún no está disponible." msgid "No accepted time range." msgstr "No hay un intervalo de tiempo aceptado." -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:30 -msgid "No activation history for this side." -msgstr "No hay historial de activación para este lado." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:158 +#~ msgid "No backtest has replayed the saved profile yet. Saving a change re-runs the latest experiment on its tuning and validation nights; nothing here changes the pod." +#~ msgstr "Ninguna prueba retrospectiva ha reproducido aún el perfil guardado. Guardar un cambio vuelve a ejecutar el último experimento en sus noches de ajuste y validación; nada aquí cambia el pod." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:177 +msgid "No backtest has replayed the saved profile yet. Saving a change replays the frozen dataset on its tuning, validation and holdout nights; nothing here changes the pod." +msgstr "" #: src/components/biometrics/Calibration/BacktestJobs.tsx:28 msgid "No backtest jobs for this side." msgstr "No hay trabajos de evaluación retrospectiva para este lado." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:175 +msgid "No backtest yet. Freeze a new dataset below; it replays the newest referenced nights through the previous and saved profiles. Nothing here changes the pod." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:174 +msgid "No backtest yet. Import a reference for this side, then freeze nights that have it; the replay scores the previous and saved profiles against it. Nothing here changes the pod." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:176 +msgid "No backtest yet. This dataset has no waveform replay; freeze a new dataset below to set one up. Nothing here changes the pod." +msgstr "" + #: src/components/biometrics/Calibration/BacktestReport.tsx:38 msgid "No committed report is available yet." msgstr "Todavía no hay ningún informe guardado disponible." @@ -1312,6 +1553,14 @@ msgstr "No hay ningún comprobante de recursos de un proceso de trabajo completa msgid "No coverage reduction detected" msgstr "No se detectó reducción de cobertura" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:71 +#~ msgid "No dataset is frozen for this side yet. Freeze recorded nights with an imported reference to backtest a profile." +#~ msgstr "Aún no hay un conjunto congelado para este lado. Congela noches grabadas con una referencia importada para probar un perfil." + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:73 +msgid "No dataset is frozen for this side yet. Freezing takes the newest nights a reference covers, with their RAW archives, so a backtest can replay them." +msgstr "" + #: src/components/biometrics/Calibration/BacktestReport.tsx:62 #: src/components/biometrics/Calibration/NumericReport.tsx:28 msgid "No estimable variation" @@ -1366,10 +1615,18 @@ msgstr "No hay ejecuciones prospectivas para este lado." msgid "No recorded nights for this side." msgstr "No hay noches registradas para este lado." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:116 +msgid "no reference" +msgstr "sin referencia" + #: src/components/biometrics/Calibration/NumericReport.tsx:29 msgid "No reference" msgstr "Sin referencia" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:316 +msgid "no reference device · excluded from error" +msgstr "sin dispositivo de referencia · excluida del error" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:91 #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:35 msgid "No reference imports for this side." @@ -1404,10 +1661,6 @@ msgstr "No hay revisiones de candidatos guardadas." msgid "No saved experiments for this side." msgstr "No hay experimentos guardados para este lado." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:55 -msgid "No sensor calibration has been reported for this side." -msgstr "No se ha informado ninguna calibración de sensores para este lado." - #: src/components/biometrics/Calibration/StageTrace.tsx:84 msgid "No stage intervals were produced for this selection." msgstr "No se generaron intervalos de fases para esta selección." @@ -1434,8 +1687,10 @@ msgstr "Ninguno" msgid "Normalized reference measurements" msgstr "Mediciones de referencia normalizadas" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:26 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:386 +msgid "not reported" +msgstr "sin informar" + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Not reported" msgstr "Sin información" @@ -1445,6 +1700,10 @@ msgstr "Sin información" msgid "Not requested" msgstr "No solicitado" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:200 +msgid "not run" +msgstr "" + #: src/components/biometrics/Calibration/BacktestReport.tsx:63 #: src/components/biometrics/Calibration/NumericReport.tsx:24 #: src/components/biometrics/Calibration/NumericReport.tsx:25 @@ -1453,6 +1712,19 @@ msgstr "No solicitado" msgid "Not scored" msgstr "Sin puntuación" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:123 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:125 +msgid "not tested" +msgstr "sin probar" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:349 +msgid "Nothing changes until you move a control or restore." +msgstr "Nada cambia hasta que muevas un control o restaures." + +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:24 +msgid "Nothing has happened on this side yet." +msgstr "Aún no ha pasado nada en este lado." + #: src/components/biometrics/Calibration/NumericTrace.tsx:158 msgid "Numeric trace configuration" msgstr "Configuración de la traza numérica" @@ -1523,10 +1795,6 @@ msgstr "Pico observado de RSS del grupo de procesos: {0} MiB" msgid "Observing" msgstr "Observando" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:63 -msgid "Older changes" -msgstr "Cambios anteriores" - #: src/components/biometrics/Calibration/DatasetLibrary.tsx:42 msgid "Older datasets" msgstr "Conjuntos de datos anteriores" @@ -1558,6 +1826,10 @@ msgstr "Perfiles anteriores" msgid "Only the current page is plotted. Dots represent individual readings; gaps and missing values are not interpolated. Original stored readings remain unchanged." msgstr "Solo se representa la página actual. Los puntos son lecturas individuales; los huecos y los valores faltantes no se interpolan. Las lecturas originales almacenadas no se modifican." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:211 +msgid "or longer than" +msgstr "o más largos que" + #: src/components/biometrics/Calibration/StageTrace.tsx:61 msgid "Original replay timestamps, without reference clock adjustment" msgstr "Marcas de tiempo originales del reprocesamiento, sin ajuste al reloj de referencia" @@ -1601,10 +1873,14 @@ msgstr "Personalizado" msgid "Override intent can change even when the resolved value stays the same." msgstr "La intención de personalización puede cambiar aunque el valor resuelto sea el mismo." -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:26 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:27 msgid "Overview" msgstr "Resumen" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:349 +msgid "Overwrites the saved profile and requests it; the current one becomes previous." +msgstr "Sobrescribe el perfil guardado y lo solicita; el actual pasa a ser el anterior." + #: src/components/biometrics/Calibration/NumericReport.tsx:109 msgid "Own-support MAE difference" msgstr "Diferencia de MAE con observaciones propias" @@ -1630,10 +1906,6 @@ msgstr "Revisión de origen" msgid "Partition timezone: {0}" msgstr "Zona horaria de las particiones: {0}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:21 -msgid "Past acknowledgements do not establish which profile is running now." -msgstr "Las confirmaciones anteriores no indican qué perfil se está ejecutando ahora." - #: src/components/biometrics/Calibration/ShadowComparison.tsx:50 msgid "Paste JSON records or CSV with recordKey, metric, value, unit, and timestamp (or start and end). Use offset-bearing ISO times or Unix milliseconds. This comparison does not save a reference import." msgstr "Pega registros JSON o CSV con recordKey, metric, value, unit y timestamp (o start y end). Usa fechas ISO con desfase horario o milisegundos Unix. Esta comparación no guarda una importación de referencia." @@ -1654,9 +1926,9 @@ msgstr "Liberar datos permanentemente" msgid "Permanently release report" msgstr "Liberar informe permanentemente" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 -msgid "Personalized profile" -msgstr "Perfil personalizado" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:268 +msgid "Pick a scored night to see its heart-rate trace." +msgstr "Elige una noche puntuada para ver su trazo de frecuencia cardíaca." #: src/components/biometrics/Calibration/ProfileLibrary.tsx:38 msgid "Pinned configuration and overrides" @@ -1733,10 +2005,6 @@ msgstr "Preparado, aún no admitido. El pod comprueba de nuevo la cualificación msgid "Presence and pump context reflect host observations, not proof of the live processor’s cached state." msgstr "El contexto de presencia y de la bomba refleja observaciones del host, no demuestra el estado almacenado en la caché del procesador en vivo." -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:29 -msgid "Preserved incompatible revision" -msgstr "Revisión incompatible conservada" - #: src/components/biometrics/Calibration/CalibrationReferences.tsx:78 msgid "Preview import" msgstr "Vista previa de la importación" @@ -1745,6 +2013,18 @@ msgstr "Vista previa de la importación" msgid "Preview normalized records before saving them on the pod." msgstr "Revise los registros normalizados antes de guardarlos en el pod." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:252 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:275 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:342 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:133 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:217 +msgid "previous" +msgstr "anterior" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:77 +msgid "Previous" +msgstr "Anterior" + #: src/components/biometrics/Calibration/NumericTrace.tsx:134 msgid "Previous numeric page" msgstr "Página numérica anterior" @@ -1770,19 +2050,33 @@ msgstr "El procesamiento falló." msgid "Processing run" msgstr "Ejecución de procesamiento" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:27 -msgid "Processor health" -msgstr "Estado del procesador" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:19 +msgid "Processor" +msgstr "Procesador" + +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:218 +msgid "Processor acknowledged generation {0}" +msgstr "El procesador confirmó la generación {0}" + +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:219 +msgid "Processor rejected generation {0}" +msgstr "El procesador rechazó la generación {0}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:24 -msgid "Profile and processor" -msgstr "Perfil y procesador" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:22 +msgid "Profile lifecycle" +msgstr "Ciclo del perfil" #: src/components/biometrics/Calibration/ProfileCandidateEditor.tsx:69 msgid "Profile resolution failed." msgstr "Error al resolver el perfil." -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:28 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:84 +msgid "Profile revisions here are candidates. Applying one requests it like Save and request does; the overview shows when the processor acknowledges it." +msgstr "Las revisiones de perfil aquí son candidatas. Aplicar una la solicita igual que Guardar y solicitar; la vista general muestra cuándo la confirma el procesador." + +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:29 msgid "Profiles" msgstr "Perfiles" @@ -1804,6 +2098,10 @@ msgstr "Observaciones prospectivas" msgid "Protocol hash" msgstr "Hash del protocolo" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:317 +msgid "quality" +msgstr "calidad" + #: src/components/biometrics/Calibration/NumericTrace.tsx:98 msgid "Quality" msgstr "Calidad" @@ -1812,10 +2110,28 @@ msgstr "Calidad" msgid "Queue backtest" msgstr "Poner evaluación retrospectiva en cola" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "queued" +msgstr "en cola" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 msgid "Queued" msgstr "En cola" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:83 +msgid "Queueing the backtest…" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:133 +msgid "range" +msgstr "rango" + +#. placeholder {0}: setup.done ?? 0 +#. placeholder {1}: setup.total ?? 0 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:80 +msgid "Reading RAW archives · {0} / {1}" +msgstr "" + #: src/components/biometrics/Calibration/StageReport.tsx:52 msgid "Recall" msgstr "Sensibilidad" @@ -1854,6 +2170,11 @@ msgstr "Configuraciones registradas: {0}; lecturas con configuración desconocid msgid "Recorded nights" msgstr "Noches registradas" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:343 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:143 +msgid "reference" +msgstr "referencia" + #: src/components/biometrics/Calibration/StageReport.tsx:31 msgid "Reference / predicted" msgstr "Referencia / predicción" @@ -1974,7 +2295,7 @@ msgstr "Fuente de referencia" msgid "Reference-defined subgroup comparisons" msgstr "Comparaciones de subgrupos definidos por la referencia" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:27 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:28 msgid "References" msgstr "Referencias" @@ -1986,6 +2307,10 @@ msgstr "Actualizar informe" msgid "Regression under the evaluation policy" msgstr "Empeoramiento según la política de evaluación" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:207 +msgid "Reject intervals shorter than" +msgstr "Rechazar intervalos más cortos que" + #: src/components/biometrics/Calibration/BacktestReport.tsx:162 msgid "Release detailed report" msgstr "Liberar informe detallado" @@ -2002,6 +2327,10 @@ msgstr "Liberar datos conservados" msgid "Release these retained inputs permanently? Existing reports and audit metadata remain. Active jobs must finish or be cancelled first." msgstr "¿Liberar estos datos conservados permanentemente? Se mantienen los informes existentes y los metadatos de auditoría. Los trabajos activos deben finalizar o cancelarse primero." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:94 +msgid "released" +msgstr "liberado" + #: src/components/biometrics/Calibration/StageReport.tsx:12 #: src/components/biometrics/Calibration/StageTrace.tsx:30 msgid "REM" @@ -2059,6 +2388,14 @@ msgstr "Hash del informe" msgid "Report identity" msgstr "Identidad del informe" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:197 +msgid "Report no heart rate when the beat signal scores below" +msgstr "No informar la frecuencia cardíaca cuando la señal del latido puntúa por debajo de" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:233 +msgid "Report the index only after" +msgstr "Informar el índice solo después de" + #: src/components/biometrics/Calibration/ShadowRuns.tsx:51 msgid "Reports describe observed coverage and reference agreement; they do not authorize activation." msgstr "Los informes describen la cobertura observada y la concordancia con la referencia; no autorizan la activación." @@ -2067,14 +2404,18 @@ msgstr "Los informes describen la cobertura observada y la concordancia con la r msgid "Reproducible sampling seed" msgstr "Semilla de muestreo reproducible" -#. placeholder {0}: event.requester -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:54 -msgid "Requested by {0}" -msgstr "Solicitado por {0}" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:18 +msgid "Requested" +msgstr "Solicitado" + +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:213 +msgid "Requested activation · generation {0}" +msgstr "Solicitó la activación · generación {0}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:26 -msgid "Requested change" -msgstr "Cambio solicitado" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:135 +msgid "Requesting…" +msgstr "Solicitando…" #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:117 msgid "Requires re-verification" @@ -2100,21 +2441,41 @@ msgstr "Las observaciones de recursos son parciales. El estado del proceso refle msgid "Restart archive scan" msgstr "Reiniciar la exploración de archivos" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:17 -msgid "Restore a profile" -msgstr "Restaurar un perfil" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:135 +msgid "Restore and request" +msgstr "Restaurar y solicitar" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:345 +msgid "Restore previous" +msgstr "Restaurar el anterior" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ProfileComparison.tsx:28 msgid "Restore previous profile" msgstr "Restaurar el perfil anterior" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:127 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ProfileComparison.tsx:28 msgid "Restore system defaults" msgstr "Restaurar valores predeterminados del sistema" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:127 +msgid "Restore the previous profile" +msgstr "Restaurar el perfil anterior" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:72 +msgid "Restore… ▾" +msgstr "Restaurar… ▾" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:216 +msgid "Restored system defaults and requested them" +msgstr "Restauró los valores del sistema y los solicitó" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:215 +msgid "Restored the previous profile and requested it" +msgstr "Restauró el perfil anterior y lo solicitó" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:33 msgid "Retain a copy of the selected nights and reference revisions. Future edits or source deletion do not alter this dataset." msgstr "Conserve una copia de las noches y revisiones de referencia seleccionadas. Los cambios posteriores o la eliminación del origen no alteran este conjunto de datos." @@ -2144,6 +2505,10 @@ msgstr "El servidor ha verificado la integridad de los datos conservados." msgid "Retained waveform replay" msgstr "Reprocesamiento de formas de onda conservadas" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:81 +msgid "Retaining the archives…" +msgstr "" + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:46 msgid "Retry exact activation request" @@ -2161,10 +2526,6 @@ msgstr "Reintentar la misma solicitud de programación" msgid "Review candidate" msgstr "Revisar candidato" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:17 -msgid "Review changes before requesting system defaults or the exact previous profile." -msgstr "Revise los cambios antes de solicitar los valores predeterminados del sistema o el perfil anterior exacto." - #: src/components/biometrics/Calibration/ShadowRuns.tsx:88 msgid "Review observation report" msgstr "Revisar informe de observación" @@ -2173,18 +2534,10 @@ msgstr "Revisar informe de observación" msgid "Review prepared plan" msgstr "Revisar plan preparado" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:21 -msgid "Review previous profile" -msgstr "Revisar perfil anterior" - #: src/components/biometrics/Calibration/BacktestJobs.tsx:81 msgid "Review report" msgstr "Revisar informe" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:20 -msgid "Review system defaults" -msgstr "Revisar valores predeterminados del sistema" - #. placeholder {0}: profile.revision #. placeholder {1}: profile.origin #. placeholder {2}: profile.requester @@ -2209,6 +2562,15 @@ msgstr "Derecha" msgid "Row {0}: {1}" msgstr "Fila {0}: {1}" +#. placeholder {0}: o.rule +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:255 +msgid "Rule {0}" +msgstr "Regla {0}" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "running" +msgstr "en curso" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 msgid "Running" msgstr "En ejecución" @@ -2222,6 +2584,10 @@ msgstr "Pico de memoria muestreado del proceso de trabajo: {0} MiB. Los picos br msgid "Save a candidate for evaluation. This does not change live processing." msgstr "Guarde un candidato para evaluarlo. Esto no cambia el procesamiento en vivo." +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Save and request" +msgstr "Guardar y solicitar" + #: src/components/biometrics/Calibration/ProfileCandidateEditor.tsx:111 msgid "Save candidate without applying" msgstr "Guardar candidato sin aplicar" @@ -2230,11 +2596,21 @@ msgstr "Guardar candidato sin aplicar" msgid "Save evaluation protocol" msgstr "Guardar protocolo de evaluación" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:189 +msgid "Save failed." +msgstr "No se pudo guardar." + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:103 msgid "Save previewed import" msgstr "Guardar la importación revisada" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:256 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:276 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:341 +msgid "saved" +msgstr "guardado" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:17 msgid "Saved" msgstr "Guardado" @@ -2251,9 +2627,9 @@ msgstr "Experimentos guardados" msgid "Saved observations: {0}" msgstr "Observaciones guardadas: {0}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 -msgid "Saved profile" -msgstr "Perfil guardado" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:160 +msgid "Saved profile against previous" +msgstr "Perfil guardado frente al anterior" #: src/components/biometrics/Calibration/ProfileLibrary.tsx:19 msgid "Saved profile revisions" @@ -2265,9 +2641,17 @@ msgstr "Revisiones de perfiles guardadas" msgid "Saved sample times: {0}. Planned observation intervals: {1}." msgstr "Marcas de tiempo de muestras guardadas: {0}. Intervalos de observación previstos: {1}." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:24 -msgid "Saved settings and processor reports are separate." -msgstr "Los ajustes guardados y los informes del procesador son independientes." +#: src/components/biometrics/Calibration/CalibrationPage.tsx:211 +msgid "Saved the profile" +msgstr "Guardó el perfil" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:141 +msgid "Saves it as the profile and requests it; the current saved profile becomes previous. The processor keeps running what it has until it acknowledges the request." +msgstr "Lo guarda como perfil y lo solicita; el perfil guardado actual pasa a ser el anterior. El procesador sigue con lo que tiene hasta confirmar la solicitud." + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Saving…" +msgstr "Guardando…" #: src/components/biometrics/Calibration/ShadowSchedule.tsx:129 #: src/components/biometrics/Calibration/ShadowSchedule.tsx:130 @@ -2339,15 +2723,15 @@ msgstr "Revisiones de referencia seleccionadas: {0} / 20" msgid "Selected sessions: {0} / 60" msgstr "Sesiones seleccionadas: {0} / 60" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:53 -msgid "Sensor measurements are separate from algorithm personalization." -msgstr "Las mediciones de los sensores son independientes de la personalización del algoritmo." - #. placeholder {0}: cohort.sessionIds.join(', ') #: src/components/biometrics/Calibration/DatasetLibrary.tsx:78 msgid "Sessions: {0}" msgstr "Sesiones: {0}" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:82 +msgid "Setting up the replay…" +msgstr "" + #: src/components/biometrics/Calibration/NumericReport.tsx:108 msgid "Shared MAE difference" msgstr "Diferencia de MAE con observaciones comunes" @@ -2370,6 +2754,23 @@ msgstr "Este informe no dispone de comparaciones en marcas de tiempo comunes." msgid "Shared-timestamp comparisons. Differences are comparison minus baseline; negative error differences mean lower error on shared observations." msgstr "Comparaciones en marcas de tiempo comunes. Las diferencias se calculan como comparación menos base; las diferencias negativas de error indican un menor error en las observaciones comunes." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:222 +msgid "Shortest breathing interval (× median)" +msgstr "Intervalo respiratorio más corto (× mediana)" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:401 +msgid "show" +msgstr "mostrar" + +#. placeholder {0}: rows.length +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:42 +msgid "Show all {0}" +msgstr "Mostrar los {0}" + +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:42 +msgid "Show recent" +msgstr "Mostrar recientes" + #: src/components/biometrics/Calibration/ReplaySessionPicker.tsx:67 msgid "Signal replay unavailable: raw waveforms and processing context have not been retained by this service." msgstr "Reprocesamiento de señales no disponible: este servicio no ha conservado las formas de onda originales ni el contexto de procesamiento." @@ -2382,6 +2783,14 @@ msgstr "Sesgo con signo" msgid "Simulated demo: generated readings, no hardware execution or qualification." msgstr "Demostración simulada: lecturas generadas, sin ejecución en hardware ni cualificación." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:249 +msgid "Sleep stages" +msgstr "Fases del sueño" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:243 +msgid "SLEEP STAGES" +msgstr "FASES DEL SUEÑO" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:59 msgid "Sleeper identifier" msgstr "Identificador de la persona" @@ -2464,10 +2873,6 @@ msgstr "Reglas de fases sobre lecturas originales" msgid "Stage trace for the displayed intervals; exact times and stages follow in the table." msgstr "Traza de fases de los intervalos mostrados; las horas exactas y las fases figuran en la tabla siguiente." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -msgid "Stale" -msgstr "Desactualizado" - #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:67 #: src/components/biometrics/Calibration/StageTrace.tsx:64 msgid "Start" @@ -2498,6 +2903,11 @@ msgstr "Detener observación" msgid "Stop recorded" msgstr "Parada registrada" +#. placeholder {0}: i.summary.source +#: src/components/biometrics/Calibration/CalibrationPage.tsx:242 +msgid "Stored a {0} reference import" +msgstr "Guardó una importación de referencia de {0}" + #. placeholder {0}: window.observationCount #: src/components/biometrics/Calibration/NumericReport.tsx:166 msgid "Stored observations: {0}" @@ -2515,7 +2925,8 @@ msgstr "Comparación de lecturas almacenadas disponible" #: src/components/biometrics/Calibration/BacktestJobs.tsx:64 #: src/components/biometrics/Calibration/BacktestReport.tsx:60 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 +#: src/components/biometrics/Calibration/CalibrationControls.tsx:80 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:171 #: src/components/biometrics/Calibration/NumericReport.tsx:26 #: src/components/biometrics/Calibration/NumericTrace.tsx:147 #: src/components/biometrics/Calibration/ProfileComparison.tsx:29 @@ -2525,6 +2936,10 @@ msgstr "Comparación de lecturas almacenadas disponible" msgid "System defaults" msgstr "Valores predeterminados del sistema" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:122 +msgid "tested on save" +msgstr "se prueba al guardar" + #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:84 msgid "The archive directory is unavailable." msgstr "El directorio de archivos no está disponible." @@ -2538,14 +2953,14 @@ msgstr "El algoritmo actual del pod informa un índice de variabilidad de los in msgid "The frozen offset of {clockOffsetMs} ms is added to recomputed timestamps in this overlay. Reference times stay unchanged. Point matching tolerance: {0} ms." msgstr "El desfase fijado de {clockOffsetMs} ms se añade a las marcas de tiempo recalculadas en esta superposición. Las horas de referencia no cambian. Tolerancia de emparejamiento de puntos: {0} ms." -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:45 -msgid "The HRV index measures window IBI variability. It is not clinical beat-to-beat RMSSD, and incompatible reference metrics cannot validate it." -msgstr "El índice de VFC mide la variabilidad de los intervalos entre latidos dentro de una ventana. No es la RMSSD clínica entre latidos consecutivos y no puede validarse con métricas de referencia incompatibles." - #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:83 msgid "The last archive was unavailable. Continue to inspect the next archive." msgstr "El último archivo no estaba disponible. Continúa con la inspección del siguiente archivo." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:215 +msgid "the median" +msgstr "la mediana" + #: src/components/biometrics/Calibration/ShadowReport.tsx:19 msgid "The observation gap exceeded the planned limit." msgstr "El intervalo sin observaciones superó el límite previsto." @@ -2554,10 +2969,6 @@ msgstr "El intervalo sin observaciones superó el límite previsto." msgid "The observation window was missed." msgstr "Se perdió el período de observación." -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:27 -msgid "The pinned profile is incompatible. Its revision will be preserved, but a valid before-and-after comparison is unavailable." -msgstr "El perfil fijado es incompatible. Se conservará su revisión, pero no se puede realizar una comparación válida del antes y el después." - #: src/components/biometrics/Calibration/ShadowSchedule.tsx:77 msgid "The plan could not be prepared. Check the selected profile, window and installed qualification." msgstr "No se pudo preparar el plan. Comprueba el perfil y el período seleccionados y la cualificación instalada." @@ -2579,12 +2990,15 @@ msgstr "No se pudo guardar la solicitud para recuperarla. Este intento no se env msgid "The retained inputs are unavailable for new evaluations." msgstr "Los datos conservados no están disponibles para nuevas evaluaciones." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:252 +msgid "The rule list itself differs from the shipped rules." +msgstr "La lista de reglas difiere de las reglas de fábrica." + #: src/components/biometrics/Calibration/ProfileComparison.tsx:33 msgid "The saved profile changed. Compare again before reviewing these changes." msgstr "El perfil guardado ha cambiado. Vuelva a comparar antes de revisar estos cambios." #: src/components/biometrics/Calibration/ProfileComparison.tsx:33 -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:34 msgid "The saved profile changed. Select a recovery action again to make a new request." msgstr "El perfil guardado ha cambiado. Seleccione de nuevo una acción de restauración para realizar una nueva solicitud." @@ -2636,6 +3050,10 @@ msgstr "Estas puntuaciones comparan las lecturas originales almacenadas. Los res msgid "These scores compare readings recomputed from retained waveforms. Shared-timestamp scores use observations available in every configuration. Differences describe agreement with the reference and do not establish validated improvement." msgstr "Estas puntuaciones comparan lecturas recalculadas a partir de formas de onda conservadas. Las puntuaciones en marcas de tiempo comunes usan observaciones disponibles en todas las configuraciones. Las diferencias describen la concordancia con la referencia y no demuestran una mejora validada." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:267 +msgid "This backtest compared stored readings, so there is no recomputed heart-rate trace to draw." +msgstr "Esta prueba comparó lecturas almacenadas, así que no hay un trazo de frecuencia cardíaca recalculado que dibujar." + #: src/components/biometrics/Calibration/StageTrace.tsx:41 msgid "This chart shows only the current page of replay intervals. Gaps are left empty; no stages are inferred between intervals." msgstr "Este gráfico muestra solo la página actual de intervalos reprocesados. Los huecos quedan vacíos; no se infieren fases entre intervalos." @@ -2709,7 +3127,6 @@ msgstr "Hora" msgid "Times shown in {0}" msgstr "Horas mostradas en {0}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:23 #: src/components/biometrics/Calibration/NumericReport.tsx:85 #: src/components/biometrics/Calibration/StageTrace.tsx:43 msgid "Times shown in {timezone}" @@ -2744,6 +3161,19 @@ msgstr "Intervalos de la traza {first}–{last} de {0}" msgid "Trace night" msgstr "Noche de la traza" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:193 +msgid "Tracking history length" +msgstr "Longitud del historial de seguimiento" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:183 +msgid "Tracking tolerance (bpm)" +msgstr "Tolerancia de seguimiento (lpm)" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:127 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "tuning" +msgstr "ajuste" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 #: src/components/biometrics/Calibration/BacktestReport.tsx:61 #: src/components/biometrics/Calibration/BacktestSubmission.tsx:73 @@ -2763,6 +3193,10 @@ msgstr "No disponible" msgid "Unavailable or belongs to a different sleeper." msgstr "No disponible o pertenece a otra persona." +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:121 +msgid "unchanged" +msgstr "sin cambios" + #. placeholder {0}: parameter.unit #. placeholder {1}: parameter.minimum #. placeholder {2}: parameter.maximum @@ -2790,10 +3224,6 @@ msgstr "Referencias sin emparejar" msgid "Use offset-bearing ISO timestamps or Unix milliseconds. Each record needs recordKey, metric, value, unit, and timestamp (or start and end). JSON uses an array; CSV uses these field names as headers. Imports are limited to 2 MiB and 2000 records." msgstr "Use fechas ISO con desplazamiento horario o milisegundos Unix. Cada registro requiere recordKey, metric, value, unit y timestamp (o start y end). JSON usa un array; CSV usa estos nombres como encabezados. Las importaciones se limitan a 2 MiB y 2000 registros." -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Use system defaults" -msgstr "Usar los valores predeterminados del sistema" - #: src/components/biometrics/Calibration/ShadowSchedule.tsx:145 msgid "Use the same sleeper identity as the reference records you intend to compare." msgstr "Usa la misma identidad de la persona que duerme que en los registros de referencia que quieras comparar." @@ -2802,6 +3232,11 @@ msgstr "Usa la misma identidad de la persona que duerme que en los registros de msgid "Validated activation" msgstr "Activación validada" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:131 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "validation" +msgstr "validación" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 #: src/components/biometrics/Calibration/BacktestReport.tsx:61 #: src/components/biometrics/Calibration/BacktestSubmission.tsx:74 @@ -2827,8 +3262,6 @@ msgstr "Valor / unidad" msgid "Vitals with unknown original processing provenance: {0}" msgstr "Signos vitales con procedencia de procesamiento original desconocida: {0}" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Waiting for a processing boundary" msgstr "Esperando un límite de procesamiento" @@ -2837,8 +3270,6 @@ msgstr "Esperando un límite de procesamiento" msgid "Waiting for a worker and resource admission. No execution is implied by a queued request." msgstr "Esperando un ejecutor y recursos disponibles. Una solicitud en cola no implica que se esté ejecutando." -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Waiting for delivery" msgstr "Esperando la entrega" @@ -2856,9 +3287,13 @@ msgstr "Vigilia" msgid "Waveform dataset retained: {published}" msgstr "Conjunto de datos de formas de onda conservado: {published}" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:44 -msgid "What these metrics mean" -msgstr "Qué significan estas métricas" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:196 +msgid "What the saved profile did" +msgstr "Qué hizo el perfil guardado" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:257 +msgid "when" +msgstr "cuando" #: src/components/biometrics/Calibration/DatasetBuilder.tsx:59 #: src/components/biometrics/Calibration/DatasetLibrary.tsx:70 @@ -2875,6 +3310,10 @@ msgstr "Índice de variabilidad IBI por ventana" msgid "Window IBI variability index (ms)" msgstr "Índice de variabilidad IBI por ventanas (ms)" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:239 +msgid "window IBI variability, not clinical RMSSD" +msgstr "variabilidad IBI por ventana, no RMSSD clínico" + #: src/components/biometrics/Calibration/ShadowReport.tsx:34 msgid "Within the observed limits." msgstr "Dentro de los límites observados." @@ -2883,6 +3322,10 @@ msgstr "Dentro de los límites observados." msgid "Work was deferred; your selection and retry key are preserved." msgstr "El trabajo se aplazó; se conservan tu selección y la clave de reintento." +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:41 +msgid "WORKBENCH" +msgstr "TALLER" + #: src/components/biometrics/Calibration/ShadowReport.tsx:18 msgid "Worker cleanup has not been confirmed." msgstr "No se ha confirmado la limpieza del proceso de trabajo." diff --git a/src/lib/i18n/locales/pseudo.po b/src/lib/i18n/locales/pseudo.po index e4e89d512..3cbab9781 100644 --- a/src/lib/i18n/locales/pseudo.po +++ b/src/lib/i18n/locales/pseudo.po @@ -13,6 +13,22 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with {count} overrides" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with 1 override" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:250 +msgid ", with no overrides" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:393 +msgid ". Separate from the profile." +msgstr "" + #. placeholder {0}: item.summary.source #. placeholder {1}: item.summary.subjectId #. placeholder {2}: item.revision @@ -20,6 +36,31 @@ msgstr "" msgid "{0} · {1} · revision {2}" msgstr "" +#. placeholder {0}: label.weekday +#. placeholder {1}: label.day +#. placeholder {2}: fmt1(prev) +#. placeholder {3}: fmt1(saved) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:228 +msgid "{0} {1}: previous {2}, saved {3} bpm" +msgstr "" + +#. placeholder {0}: splitLabel[job.split] +#. placeholder {1}: avg(n => n.previous.heart_rate.mae as number) +#. placeholder {2}: avg(n => n.saved.heart_rate.mae as number) +#. placeholder {3}: shortDuration(job.updatedAtMs - job.createdAtMs) +#: src/components/biometrics/Calibration/CalibrationPage.tsx:231 +msgid "{0} HR error {1} → {2} bpm · {3}" +msgstr "" + +#. placeholder {0}: i.summary.accepted +#: src/components/biometrics/Calibration/CalibrationPage.tsx:242 +msgid "{0} records" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:160 +msgid "{stages} sleep-stage values" +msgstr "" + #: src/components/biometrics/Calibration/NumericReport.tsx:175 msgid "95th percentile absolute error" msgstr "" @@ -32,6 +73,18 @@ msgstr "" msgid "A saved revision is not evidence of validation or processor adoption." msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:177 +msgid "Accept an estimate when it is within" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:237 +msgid "accepted 10-second IBI windows" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:191 +msgid "accepted estimates in the tracker" +msgstr "" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:87 msgid "Accepted records are not necessarily usable for validation. Incompatible HRV definitions remain unscorable." msgstr "" @@ -53,10 +106,6 @@ msgstr "" msgid "Accepted: {0}; rejected: {1}; duplicates: {2}; unscorable: {3}; conflicts: {4}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:21 -msgid "Activation history" -msgstr "" - #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:76 msgid "Activation request saved. Processor acknowledgement is reported separately below." msgstr "" @@ -80,6 +129,10 @@ msgstr "" msgid "Active profile hash" msgstr "" +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:21 +msgid "Activity" +msgstr "" + #: src/components/biometrics/Calibration/NumericReport.tsx:111 msgid "Added readings" msgstr "" @@ -122,8 +175,6 @@ msgstr "" msgid "Another revision from this reference family is selected." msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Applied" msgstr "" @@ -132,10 +183,6 @@ msgstr "" msgid "Apply evaluated revision" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Apply profile" -msgstr "" - #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:32 msgid "Apply this exact candidate using the selected evaluation. Evidence and current settings are checked again when applying." msgstr "" @@ -172,10 +219,20 @@ msgstr "" msgid "Available reference records by metric (across revisions)" msgstr "" +#. placeholder {0}: group.latest.id.slice(0, 4) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:197 +msgid "Backtest {0}. Frozen dataset replayed through the previous and saved profiles; nothing here changes the pod." +msgstr "" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:21 msgid "Backtest jobs" msgstr "" +#. placeholder {0}: splitLabel[job.split] +#: src/components/biometrics/Calibration/CalibrationPage.tsx:235 +msgid "Backtest on {0} nights failed" +msgstr "" + #. placeholder {0}: enqueue.data.id #: src/components/biometrics/Calibration/BacktestSubmission.tsx:93 msgid "Backtest request saved: {0}" @@ -185,7 +242,7 @@ msgstr "" msgid "Backtest submission" msgstr "" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:29 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:30 msgid "Backtests" msgstr "" @@ -201,11 +258,26 @@ msgstr "" msgid "Before configuration hash" msgstr "" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:24 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:68 msgid "Biometric calibration" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:279 +msgid "Biometric calibration side" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:181 +msgid "bpm of the tracker" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:204 +msgid "BREATHING" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:189 +msgid "Breathing error" +msgstr "" + #: src/components/biometrics/Calibration/ShadowReport.tsx:30 msgid "Breathing rate" msgstr "" @@ -216,10 +288,18 @@ msgstr "" msgid "Breathing rate (breaths/min)" msgstr "" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:25 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:38 +msgid "Calibration" +msgstr "" + +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:34 msgid "Calibration views" msgstr "" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:136 +msgid "Cancel" +msgstr "" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:79 msgid "Cancel backtest" msgstr "" @@ -318,6 +398,14 @@ msgstr "" msgid "Candidate spike threshold" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:162 +msgid "Changed values are white; the previous profile (System defaults) sits underneath. Moving a control edits the saved profile; saving again re-requests it. The processor adopts a profile only after it acknowledges a request." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:163 +msgid "Changed values are white; the previous profile sits underneath. Moving a control edits the saved profile; saving again re-requests it. The processor adopts a profile only after it acknowledges a request." +msgstr "" + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:86 msgid "Check Overview for current processor health; a past acknowledgement does not prove current health." msgstr "" @@ -342,6 +430,10 @@ msgstr "" msgid "Choose the protocol before looking at results. Creating an experiment does not run a job or apply a profile." msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:78 +msgid "Choosing nights the reference covers…" +msgstr "" + #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:38 msgid "Circles are recomputed readings; squares are reference points; horizontal bars are reference intervals. Only the two displayed pages are shown. This visual overlay does not create new matched pairs or scores." msgstr "" @@ -350,8 +442,8 @@ msgstr "" msgid "Clear this night’s selection" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 -msgid "Clock mismatch" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:205 +msgid "click a night" msgstr "" #. placeholder {0}: minutes(score.pairedDurationMs) @@ -443,10 +535,6 @@ msgstr "" msgid "Compatibility" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Compatibility fallback" -msgstr "" - #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:90 msgid "Compatible" msgstr "" @@ -464,15 +552,15 @@ msgstr "" msgid "Compressed bytes: {0}" msgstr "" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:396 +msgid "config id" +msgstr "" + #: src/components/biometrics/Calibration/BacktestReport.tsx:98 #: src/components/biometrics/Calibration/StageReport.tsx:107 msgid "Configuration" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:36 -msgid "Configuration identity" -msgstr "" - #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:58 msgid "Confirm import deletion" msgstr "" @@ -506,9 +594,12 @@ msgstr "" msgid "Coverage reduced in at least one evaluated window" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Current" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:129 +msgid "covered" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "current" msgstr "" #: src/components/biometrics/Calibration/BacktestReport.tsx:60 @@ -543,6 +634,10 @@ msgstr "" msgid "Dataset hash" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:99 +msgid "Dataset nights" +msgstr "" + #. placeholder {0}: create.data.id #: src/components/biometrics/Calibration/DatasetBuilder.tsx:57 msgid "Dataset saved: {0}" @@ -557,10 +652,6 @@ msgstr "" msgid "Deep" msgstr "" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:31 -msgid "Default recovery configuration hash" -msgstr "" - #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:52 msgid "Delete import" msgstr "" @@ -577,12 +668,9 @@ msgstr "" msgid "Descriptive evaluation only" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 -msgid "Different versions reported" -msgstr "" - -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:57 -msgid "Direct sleep-stage changes are unvalidated manual settings. Saving requests a profile change; processor adoption is reported separately in Overview." +#. placeholder {0}: shortDuration(run.durationMs) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "done · {0}" msgstr "" #: src/components/biometrics/Calibration/NumericReport.tsx:151 @@ -597,10 +685,18 @@ msgstr "" msgid "Each configuration on its own available intervals" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:268 +msgid "Each night's heart rate against the reference appears here once a backtest has run." +msgstr "" + #: src/components/biometrics/Calibration/ProfileLibrary.tsx:34 msgid "Edit a new candidate from this revision" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:273 +msgid "Edit overrides" +msgstr "" + #: src/components/biometrics/Calibration/ProfileLibrary.tsx:20 msgid "Editing from revision {revisionId}" msgstr "" @@ -660,6 +756,10 @@ msgstr "" msgid "Evaluated minutes: {0}" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:90 +msgid "Evaluation dataset" +msgstr "" + #: src/components/biometrics/Calibration/ExperimentBuilder.tsx:47 msgid "Evaluation mode" msgstr "" @@ -693,10 +793,6 @@ msgstr "" msgid "Exact processed-data coverage" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:45 -msgid "Exact profile transition" -msgstr "" - #: src/components/biometrics/Calibration/NumericTrace.tsx:93 msgid "Exact recomputed readings, without reference clock adjustment" msgstr "" @@ -728,8 +824,8 @@ msgstr "" msgid "Experiment saved: {0}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Expired" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "expired" msgstr "" #: src/components/biometrics/Calibration/CandidateStageFields.tsx:39 @@ -757,19 +853,38 @@ msgstr "" msgid "F1" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "failed" +msgstr "" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 #: src/components/biometrics/Calibration/ShadowRuns.tsx:12 msgid "Failed" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:265 +msgid "Fallback" +msgstr "" + #: src/components/biometrics/Calibration/CandidateStageFields.tsx:21 #: src/components/biometrics/Calibration/CandidateStageFields.tsx:21 msgid "Fallback stage" msgstr "" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:217 +msgid "Fell back to a compatible profile" +msgstr "" + +#. placeholder {0}: splitLabel[job.split] +#: src/components/biometrics/Calibration/CalibrationPage.tsx:235 +msgid "Finished a backtest on {0} nights" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:344 +msgid "first 500 readings" +msgstr "" + #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:125 msgid "First archive page" msgstr "" @@ -794,6 +909,10 @@ msgstr "" msgid "Flags" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:143 +msgid "Freeze a new dataset" +msgstr "" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:33 msgid "Freeze evaluation inputs" msgstr "" @@ -802,8 +921,42 @@ msgstr "" msgid "Freeze selected inputs" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:58 -msgid "Freshness unknown" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:85 +msgid "Freezing failed." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:79 +msgid "Freezing the nights…" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:380 +msgid "freshness unknown" +msgstr "" + +#. placeholder {0}: d.manifest.splitProtocol.assignments.length +#: src/components/biometrics/Calibration/CalibrationPage.tsx:239 +msgid "Froze a dataset of {0} nights" +msgstr "" + +#. placeholder {0}: formatDay(dataset.createdAtMs, timezone) +#. placeholder {1}: formatBytes(dataset.byteLength) +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:94 +msgid "frozen {0} · {1}" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:84 +msgid "Frozen and queued. The backtest replays every split; results appear as each finishes." +msgstr "" + +#. placeholder {0}: nights.length +#. placeholder {1}: sources.join(' + ') +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:71 +msgid "Frozen copy of {0} nights and {1} reference. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own." +msgstr "" + +#. placeholder {0}: nights.length +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:72 +msgid "Frozen copy of {0} nights. Later edits or deletions do not touch it. Whole nights are split before scoring; a split is not evidence on its own." msgstr "" #: src/components/biometrics/Calibration/DatasetLibrary.tsx:85 @@ -838,26 +991,32 @@ msgstr "" msgid "Frozen stage objective thresholds" msgstr "" -#. placeholder {0}: data.desired.binding.generation -#. placeholder {0}: event.generation -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:41 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:39 -msgid "Generation {0}" -msgstr "" - #: src/components/biometrics/Calibration/NumericReport.tsx:44 msgid "Groups use native reference timestamps and record types, independently of candidate output. Half-window groups exclude intervals crossing the midpoint. Groups overlap and are not independent experiments. These are not motion or artifact subgroups." msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:258 +msgid "H holdout" +msgstr "" + #: src/components/biometrics/Calibration/ReplayResources.tsx:49 msgid "Health was sampled at admission checks, normally five seconds apart. Unobserved intervals are not continuous health measurements." msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 #: src/components/biometrics/Calibration/ShadowReport.tsx:30 msgid "Heart rate" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:174 +msgid "HEART RATE" +msgstr "" + +#. placeholder {0}: label.weekday +#. placeholder {1}: label.day +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:321 +msgid "Heart rate · {0} {1}" +msgstr "" + #: src/components/biometrics/Calibration/NumericReport.tsx:27 #: src/components/biometrics/Calibration/NumericTrace.tsx:38 #: src/components/biometrics/Calibration/ShadowComparison.tsx:44 @@ -868,10 +1027,18 @@ msgstr "" msgid "Heart-rate spike threshold (standard deviations)" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:325 +msgid "Heart-rate trace" +msgstr "" + #: app/[lang]/health/HealthcheckDemo.tsx:13 msgid "Hello, tRPC test page!" msgstr "" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:401 +msgid "hide" +msgstr "" + #. placeholder {0}: summary.improvedAgreementNights #. placeholder {1}: summary.regressedAgreementNights #. placeholder {2}: summary.unchangedAgreementNights @@ -879,12 +1046,9 @@ msgstr "" msgid "Higher agreement: {0}; lower: {1}; unchanged: {2}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:46 -msgid "Historical sleep stages are recomputed with the current classifier. Original stored vitals remain separate from replay results." -msgstr "" - -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:30 -msgid "History" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:135 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "holdout" msgstr "" #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 @@ -900,6 +1064,26 @@ msgstr "" msgid "Holdout accesses recorded for this experiment: {0}" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:288 +msgid "Holdout is the number to trust; tuning nights were used to pick these values." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:186 +msgid "HR error · holdout" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:184 +msgid "HR error · tuning" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:185 +msgid "HR error · validation" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:204 +msgid "HR error per night · bpm vs reference" +msgstr "" + #: src/components/biometrics/BiometricsPage.tsx:532 msgid "HRV definition" msgstr "" @@ -908,8 +1092,12 @@ msgstr "" msgid "HRV definitions differ or are partly unknown. A combined HRV average is unavailable." msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:18 -msgid "HRV index" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:230 +msgid "HRV INDEX" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:188 +msgid "HRV index error" msgstr "" #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:60 @@ -928,6 +1116,10 @@ msgstr "" msgid "I intend to restore the exact previous profile without validation evidence." msgstr "" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:147 +msgid "Identical to the saved profile; restoring only re-requests it." +msgstr "" + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:53 #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:38 msgid "Immediate processing boundary" @@ -941,6 +1133,10 @@ msgstr "" msgid "Import preview" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:141 +msgid "Import reference" +msgstr "" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:53 msgid "Import reference measurements" msgstr "" @@ -972,6 +1168,11 @@ msgstr "" msgid "Inherited default" msgstr "" +#. placeholder {0}: stages.ruleCount +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:247 +msgid "Inherits the {0} rules and fallback from" +msgstr "" + #: src/components/biometrics/Calibration/DatasetLibrary.tsx:31 msgid "Inputs released; metadata retained" msgstr "" @@ -1063,6 +1264,14 @@ msgstr "" msgid "Keep retained inputs" msgstr "" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Keep saved profile" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:187 +msgid "Keep the last" +msgstr "" + #. placeholder {0}: display(operations.ingestion.maximumObservationGapMs, 1000) #: src/components/biometrics/Calibration/ReplayResources.tsx:47 msgid "Largest observation gap: {0} seconds" @@ -1095,6 +1304,14 @@ msgstr "" msgid "Limitations" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:77 +msgid "Linking Watch data already on the pod…" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:223 +msgid "Longest breathing interval (× median)" +msgstr "" + #: src/components/biometrics/Calibration/NumericReport.tsx:110 msgid "Lost readings" msgstr "" @@ -1144,8 +1361,8 @@ msgstr "" msgid "Measured Python processes: {0} of {1}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:53 -msgid "Measured sensor calibration" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:390 +msgid "Measured sensor calibration for this side:" msgstr "" #: src/components/biometrics/Calibration/NumericReport.tsx:174 @@ -1176,8 +1393,16 @@ msgstr "" msgid "Metrics: {0}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Migrate settings" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:217 +msgid "Migrated sleep-stage settings into the profile" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:201 +msgid "Minimum beat signal score" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:240 +msgid "Minimum IBI windows" msgstr "" #: src/components/biometrics/Calibration/ExperimentBuilder.tsx:97 @@ -1205,10 +1430,6 @@ msgstr "" msgid "New observation plan" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:62 -msgid "Newest" -msgstr "" - #: src/components/biometrics/Calibration/DatasetLibrary.tsx:41 msgid "Newest datasets" msgstr "" @@ -1280,6 +1501,10 @@ msgstr "" msgid "Night {0}" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:187 +msgid "Night covered" +msgstr "" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:74 msgid "Night progress is not available yet." msgstr "" @@ -1288,14 +1513,30 @@ msgstr "" msgid "No accepted time range." msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:30 -msgid "No activation history for this side." +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:158 +#~ msgid "No backtest has replayed the saved profile yet. Saving a change re-runs the latest experiment on its tuning and validation nights; nothing here changes the pod." +#~ msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:177 +msgid "No backtest has replayed the saved profile yet. Saving a change replays the frozen dataset on its tuning, validation and holdout nights; nothing here changes the pod." msgstr "" #: src/components/biometrics/Calibration/BacktestJobs.tsx:28 msgid "No backtest jobs for this side." msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:175 +msgid "No backtest yet. Freeze a new dataset below; it replays the newest referenced nights through the previous and saved profiles. Nothing here changes the pod." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:174 +msgid "No backtest yet. Import a reference for this side, then freeze nights that have it; the replay scores the previous and saved profiles against it. Nothing here changes the pod." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:176 +msgid "No backtest yet. This dataset has no waveform replay; freeze a new dataset below to set one up. Nothing here changes the pod." +msgstr "" + #: src/components/biometrics/Calibration/BacktestReport.tsx:38 msgid "No committed report is available yet." msgstr "" @@ -1312,6 +1553,14 @@ msgstr "" msgid "No coverage reduction detected" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:71 +#~ msgid "No dataset is frozen for this side yet. Freeze recorded nights with an imported reference to backtest a profile." +#~ msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:73 +msgid "No dataset is frozen for this side yet. Freezing takes the newest nights a reference covers, with their RAW archives, so a backtest can replay them." +msgstr "" + #: src/components/biometrics/Calibration/BacktestReport.tsx:62 #: src/components/biometrics/Calibration/NumericReport.tsx:28 msgid "No estimable variation" @@ -1366,10 +1615,18 @@ msgstr "" msgid "No recorded nights for this side." msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:116 +msgid "no reference" +msgstr "" + #: src/components/biometrics/Calibration/NumericReport.tsx:29 msgid "No reference" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:316 +msgid "no reference device · excluded from error" +msgstr "" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:91 #: src/components/biometrics/Calibration/ReferenceLibrary.tsx:35 msgid "No reference imports for this side." @@ -1404,10 +1661,6 @@ msgstr "" msgid "No saved experiments for this side." msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:55 -msgid "No sensor calibration has been reported for this side." -msgstr "" - #: src/components/biometrics/Calibration/StageTrace.tsx:84 msgid "No stage intervals were produced for this selection." msgstr "" @@ -1434,8 +1687,10 @@ msgstr "" msgid "Normalized reference measurements" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:26 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:386 +msgid "not reported" +msgstr "" + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Not reported" msgstr "" @@ -1445,6 +1700,10 @@ msgstr "" msgid "Not requested" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:200 +msgid "not run" +msgstr "" + #: src/components/biometrics/Calibration/BacktestReport.tsx:63 #: src/components/biometrics/Calibration/NumericReport.tsx:24 #: src/components/biometrics/Calibration/NumericReport.tsx:25 @@ -1453,6 +1712,19 @@ msgstr "" msgid "Not scored" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:123 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:125 +msgid "not tested" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:349 +msgid "Nothing changes until you move a control or restore." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:24 +msgid "Nothing has happened on this side yet." +msgstr "" + #: src/components/biometrics/Calibration/NumericTrace.tsx:158 msgid "Numeric trace configuration" msgstr "" @@ -1523,10 +1795,6 @@ msgstr "" msgid "Observing" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:63 -msgid "Older changes" -msgstr "" - #: src/components/biometrics/Calibration/DatasetLibrary.tsx:42 msgid "Older datasets" msgstr "" @@ -1558,6 +1826,10 @@ msgstr "" msgid "Only the current page is plotted. Dots represent individual readings; gaps and missing values are not interpolated. Original stored readings remain unchanged." msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:211 +msgid "or longer than" +msgstr "" + #: src/components/biometrics/Calibration/StageTrace.tsx:61 msgid "Original replay timestamps, without reference clock adjustment" msgstr "" @@ -1601,10 +1873,14 @@ msgstr "" msgid "Override intent can change even when the resolved value stays the same." msgstr "" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:26 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:27 msgid "Overview" msgstr "" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:349 +msgid "Overwrites the saved profile and requests it; the current one becomes previous." +msgstr "" + #: src/components/biometrics/Calibration/NumericReport.tsx:109 msgid "Own-support MAE difference" msgstr "" @@ -1630,10 +1906,6 @@ msgstr "" msgid "Partition timezone: {0}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:21 -msgid "Past acknowledgements do not establish which profile is running now." -msgstr "" - #: src/components/biometrics/Calibration/ShadowComparison.tsx:50 msgid "Paste JSON records or CSV with recordKey, metric, value, unit, and timestamp (or start and end). Use offset-bearing ISO times or Unix milliseconds. This comparison does not save a reference import." msgstr "" @@ -1654,8 +1926,8 @@ msgstr "" msgid "Permanently release report" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 -msgid "Personalized profile" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:268 +msgid "Pick a scored night to see its heart-rate trace." msgstr "" #: src/components/biometrics/Calibration/ProfileLibrary.tsx:38 @@ -1733,10 +2005,6 @@ msgstr "" msgid "Presence and pump context reflect host observations, not proof of the live processor’s cached state." msgstr "" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:29 -msgid "Preserved incompatible revision" -msgstr "" - #: src/components/biometrics/Calibration/CalibrationReferences.tsx:78 msgid "Preview import" msgstr "" @@ -1745,6 +2013,18 @@ msgstr "" msgid "Preview normalized records before saving them on the pod." msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:252 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:275 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:342 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:133 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:217 +msgid "previous" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:77 +msgid "Previous" +msgstr "" + #: src/components/biometrics/Calibration/NumericTrace.tsx:134 msgid "Previous numeric page" msgstr "" @@ -1770,19 +2050,33 @@ msgstr "" msgid "Processing run" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:27 -msgid "Processor health" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:19 +msgid "Processor" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:24 -msgid "Profile and processor" +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:218 +msgid "Processor acknowledged generation {0}" +msgstr "" + +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:219 +msgid "Processor rejected generation {0}" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:22 +msgid "Profile lifecycle" msgstr "" #: src/components/biometrics/Calibration/ProfileCandidateEditor.tsx:69 msgid "Profile resolution failed." msgstr "" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:28 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:84 +msgid "Profile revisions here are candidates. Applying one requests it like Save and request does; the overview shows when the processor acknowledges it." +msgstr "" + +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:29 msgid "Profiles" msgstr "" @@ -1804,6 +2098,10 @@ msgstr "" msgid "Protocol hash" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:317 +msgid "quality" +msgstr "" + #: src/components/biometrics/Calibration/NumericTrace.tsx:98 msgid "Quality" msgstr "" @@ -1812,10 +2110,28 @@ msgstr "" msgid "Queue backtest" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "queued" +msgstr "" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 msgid "Queued" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:83 +msgid "Queueing the backtest…" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:133 +msgid "range" +msgstr "" + +#. placeholder {0}: setup.done ?? 0 +#. placeholder {1}: setup.total ?? 0 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:80 +msgid "Reading RAW archives · {0} / {1}" +msgstr "" + #: src/components/biometrics/Calibration/StageReport.tsx:52 msgid "Recall" msgstr "" @@ -1854,6 +2170,11 @@ msgstr "" msgid "Recorded nights" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:343 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:143 +msgid "reference" +msgstr "" + #: src/components/biometrics/Calibration/StageReport.tsx:31 msgid "Reference / predicted" msgstr "" @@ -1974,7 +2295,7 @@ msgstr "" msgid "Reference-defined subgroup comparisons" msgstr "" -#: src/components/biometrics/Calibration/BiometricCalibration.tsx:27 +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:28 msgid "References" msgstr "" @@ -1986,6 +2307,10 @@ msgstr "" msgid "Regression under the evaluation policy" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:207 +msgid "Reject intervals shorter than" +msgstr "" + #: src/components/biometrics/Calibration/BacktestReport.tsx:162 msgid "Release detailed report" msgstr "" @@ -2002,6 +2327,10 @@ msgstr "" msgid "Release these retained inputs permanently? Existing reports and audit metadata remain. Active jobs must finish or be cancelled first." msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:94 +msgid "released" +msgstr "" + #: src/components/biometrics/Calibration/StageReport.tsx:12 #: src/components/biometrics/Calibration/StageTrace.tsx:30 msgid "REM" @@ -2059,6 +2388,14 @@ msgstr "" msgid "Report identity" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:197 +msgid "Report no heart rate when the beat signal scores below" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:233 +msgid "Report the index only after" +msgstr "" + #: src/components/biometrics/Calibration/ShadowRuns.tsx:51 msgid "Reports describe observed coverage and reference agreement; they do not authorize activation." msgstr "" @@ -2067,13 +2404,17 @@ msgstr "" msgid "Reproducible sampling seed" msgstr "" -#. placeholder {0}: event.requester -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:54 -msgid "Requested by {0}" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:18 +msgid "Requested" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:26 -msgid "Requested change" +#. placeholder {0}: event.generation +#: src/components/biometrics/Calibration/CalibrationPage.tsx:213 +msgid "Requested activation · generation {0}" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:135 +msgid "Requesting…" msgstr "" #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:117 @@ -2100,21 +2441,41 @@ msgstr "" msgid "Restart archive scan" msgstr "" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:17 -msgid "Restore a profile" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:135 +msgid "Restore and request" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:345 +msgid "Restore previous" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ProfileComparison.tsx:28 msgid "Restore previous profile" msgstr "" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:127 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ProfileComparison.tsx:28 msgid "Restore system defaults" msgstr "" +#: src/components/biometrics/Calibration/CalibrationControls.tsx:127 +msgid "Restore the previous profile" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:72 +msgid "Restore… ▾" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:216 +msgid "Restored system defaults and requested them" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:215 +msgid "Restored the previous profile and requested it" +msgstr "" + #: src/components/biometrics/Calibration/DatasetBuilder.tsx:33 msgid "Retain a copy of the selected nights and reference revisions. Future edits or source deletion do not alter this dataset." msgstr "" @@ -2144,6 +2505,10 @@ msgstr "" msgid "Retained waveform replay" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:81 +msgid "Retaining the archives…" +msgstr "" + #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:62 #: src/components/biometrics/Calibration/ValidatedProfileActivation.tsx:46 msgid "Retry exact activation request" @@ -2161,10 +2526,6 @@ msgstr "" msgid "Review candidate" msgstr "" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:17 -msgid "Review changes before requesting system defaults or the exact previous profile." -msgstr "" - #: src/components/biometrics/Calibration/ShadowRuns.tsx:88 msgid "Review observation report" msgstr "" @@ -2173,18 +2534,10 @@ msgstr "" msgid "Review prepared plan" msgstr "" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:21 -msgid "Review previous profile" -msgstr "" - #: src/components/biometrics/Calibration/BacktestJobs.tsx:81 msgid "Review report" msgstr "" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:20 -msgid "Review system defaults" -msgstr "" - #. placeholder {0}: profile.revision #. placeholder {1}: profile.origin #. placeholder {2}: profile.requester @@ -2209,6 +2562,15 @@ msgstr "" msgid "Row {0}: {1}" msgstr "" +#. placeholder {0}: o.rule +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:255 +msgid "Rule {0}" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:170 +msgid "running" +msgstr "" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:11 msgid "Running" msgstr "" @@ -2222,6 +2584,10 @@ msgstr "" msgid "Save a candidate for evaluation. This does not change live processing." msgstr "" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Save and request" +msgstr "" + #: src/components/biometrics/Calibration/ProfileCandidateEditor.tsx:111 msgid "Save candidate without applying" msgstr "" @@ -2230,11 +2596,21 @@ msgstr "" msgid "Save evaluation protocol" msgstr "" +#: src/components/biometrics/Calibration/CalibrationPage.tsx:189 +msgid "Save failed." +msgstr "" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:103 msgid "Save previewed import" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:256 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:276 +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:341 +msgid "saved" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:17 msgid "Saved" msgstr "" @@ -2251,8 +2627,8 @@ msgstr "" msgid "Saved observations: {0}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 -msgid "Saved profile" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:160 +msgid "Saved profile against previous" msgstr "" #: src/components/biometrics/Calibration/ProfileLibrary.tsx:19 @@ -2265,8 +2641,16 @@ msgstr "" msgid "Saved sample times: {0}. Planned observation intervals: {1}." msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:24 -msgid "Saved settings and processor reports are separate." +#: src/components/biometrics/Calibration/CalibrationPage.tsx:211 +msgid "Saved the profile" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationControls.tsx:141 +msgid "Saves it as the profile and requests it; the current saved profile becomes previous. The processor keeps running what it has until it acknowledges the request." +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:337 +msgid "Saving…" msgstr "" #: src/components/biometrics/Calibration/ShadowSchedule.tsx:129 @@ -2339,15 +2723,15 @@ msgstr "" msgid "Selected sessions: {0} / 60" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:53 -msgid "Sensor measurements are separate from algorithm personalization." -msgstr "" - #. placeholder {0}: cohort.sessionIds.join(', ') #: src/components/biometrics/Calibration/DatasetLibrary.tsx:78 msgid "Sessions: {0}" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:82 +msgid "Setting up the replay…" +msgstr "" + #: src/components/biometrics/Calibration/NumericReport.tsx:108 msgid "Shared MAE difference" msgstr "" @@ -2370,6 +2754,23 @@ msgstr "" msgid "Shared-timestamp comparisons. Differences are comparison minus baseline; negative error differences mean lower error on shared observations." msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:222 +msgid "Shortest breathing interval (× median)" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationPage.tsx:401 +msgid "show" +msgstr "" + +#. placeholder {0}: rows.length +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:42 +msgid "Show all {0}" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationActivity.tsx:42 +msgid "Show recent" +msgstr "" + #: src/components/biometrics/Calibration/ReplaySessionPicker.tsx:67 msgid "Signal replay unavailable: raw waveforms and processing context have not been retained by this service." msgstr "" @@ -2382,6 +2783,14 @@ msgstr "" msgid "Simulated demo: generated readings, no hardware execution or qualification." msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:249 +msgid "Sleep stages" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:243 +msgid "SLEEP STAGES" +msgstr "" + #: src/components/biometrics/Calibration/CalibrationReferences.tsx:59 msgid "Sleeper identifier" msgstr "" @@ -2464,10 +2873,6 @@ msgstr "" msgid "Stage trace for the displayed intervals; exact times and stages follow in the table." msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:15 -msgid "Stale" -msgstr "" - #: src/components/biometrics/Calibration/ReferenceOverlay.tsx:67 #: src/components/biometrics/Calibration/StageTrace.tsx:64 msgid "Start" @@ -2498,6 +2903,11 @@ msgstr "" msgid "Stop recorded" msgstr "" +#. placeholder {0}: i.summary.source +#: src/components/biometrics/Calibration/CalibrationPage.tsx:242 +msgid "Stored a {0} reference import" +msgstr "" + #. placeholder {0}: window.observationCount #: src/components/biometrics/Calibration/NumericReport.tsx:166 msgid "Stored observations: {0}" @@ -2515,7 +2925,8 @@ msgstr "" #: src/components/biometrics/Calibration/BacktestJobs.tsx:64 #: src/components/biometrics/Calibration/BacktestReport.tsx:60 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:25 +#: src/components/biometrics/Calibration/CalibrationControls.tsx:80 +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:171 #: src/components/biometrics/Calibration/NumericReport.tsx:26 #: src/components/biometrics/Calibration/NumericTrace.tsx:147 #: src/components/biometrics/Calibration/ProfileComparison.tsx:29 @@ -2525,6 +2936,10 @@ msgstr "" msgid "System defaults" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:122 +msgid "tested on save" +msgstr "" + #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:84 msgid "The archive directory is unavailable." msgstr "" @@ -2538,14 +2953,14 @@ msgstr "" msgid "The frozen offset of {clockOffsetMs} ms is added to recomputed timestamps in this overlay. Reference times stay unchanged. Point matching tolerance: {0} ms." msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:45 -msgid "The HRV index measures window IBI variability. It is not clinical beat-to-beat RMSSD, and incompatible reference metrics cannot validate it." -msgstr "" - #: src/components/biometrics/Calibration/WaveformDatasetBuilder.tsx:83 msgid "The last archive was unavailable. Continue to inspect the next archive." msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:215 +msgid "the median" +msgstr "" + #: src/components/biometrics/Calibration/ShadowReport.tsx:19 msgid "The observation gap exceeded the planned limit." msgstr "" @@ -2554,10 +2969,6 @@ msgstr "" msgid "The observation window was missed." msgstr "" -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:27 -msgid "The pinned profile is incompatible. Its revision will be preserved, but a valid before-and-after comparison is unavailable." -msgstr "" - #: src/components/biometrics/Calibration/ShadowSchedule.tsx:77 msgid "The plan could not be prepared. Check the selected profile, window and installed qualification." msgstr "" @@ -2579,12 +2990,15 @@ msgstr "" msgid "The retained inputs are unavailable for new evaluations." msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:252 +msgid "The rule list itself differs from the shipped rules." +msgstr "" + #: src/components/biometrics/Calibration/ProfileComparison.tsx:33 msgid "The saved profile changed. Compare again before reviewing these changes." msgstr "" #: src/components/biometrics/Calibration/ProfileComparison.tsx:33 -#: src/components/biometrics/Calibration/ProfileRecovery.tsx:34 msgid "The saved profile changed. Select a recovery action again to make a new request." msgstr "" @@ -2636,6 +3050,10 @@ msgstr "" msgid "These scores compare readings recomputed from retained waveforms. Shared-timestamp scores use observations available in every configuration. Differences describe agreement with the reference and do not establish validated improvement." msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:267 +msgid "This backtest compared stored readings, so there is no recomputed heart-rate trace to draw." +msgstr "" + #: src/components/biometrics/Calibration/StageTrace.tsx:41 msgid "This chart shows only the current page of replay intervals. Gaps are left empty; no stages are inferred between intervals." msgstr "" @@ -2709,7 +3127,6 @@ msgstr "" msgid "Times shown in {0}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:23 #: src/components/biometrics/Calibration/NumericReport.tsx:85 #: src/components/biometrics/Calibration/StageTrace.tsx:43 msgid "Times shown in {timezone}" @@ -2744,6 +3161,19 @@ msgstr "" msgid "Trace night" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:193 +msgid "Tracking history length" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:183 +msgid "Tracking tolerance (bpm)" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:127 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "tuning" +msgstr "" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 #: src/components/biometrics/Calibration/BacktestReport.tsx:61 #: src/components/biometrics/Calibration/BacktestSubmission.tsx:73 @@ -2763,6 +3193,10 @@ msgstr "" msgid "Unavailable or belongs to a different sleeper." msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:121 +msgid "unchanged" +msgstr "" + #. placeholder {0}: parameter.unit #. placeholder {1}: parameter.minimum #. placeholder {2}: parameter.maximum @@ -2790,10 +3224,6 @@ msgstr "" msgid "Use offset-bearing ISO timestamps or Unix milliseconds. Each record needs recordKey, metric, value, unit, and timestamp (or start and end). JSON uses an array; CSV uses these field names as headers. Imports are limited to 2 MiB and 2000 records." msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:17 -msgid "Use system defaults" -msgstr "" - #: src/components/biometrics/Calibration/ShadowSchedule.tsx:145 msgid "Use the same sleeper identity as the reference records you intend to compare." msgstr "" @@ -2802,6 +3232,11 @@ msgstr "" msgid "Validated activation" msgstr "" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:131 +#: src/components/biometrics/Calibration/CalibrationPage.tsx:221 +msgid "validation" +msgstr "" + #: src/components/biometrics/Calibration/BacktestJobs.tsx:63 #: src/components/biometrics/Calibration/BacktestReport.tsx:61 #: src/components/biometrics/Calibration/BacktestSubmission.tsx:74 @@ -2827,8 +3262,6 @@ msgstr "" msgid "Vitals with unknown original processing provenance: {0}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:17 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Waiting for a processing boundary" msgstr "" @@ -2837,8 +3270,6 @@ msgstr "" msgid "Waiting for a worker and resource admission. No execution is implied by a queued request." msgstr "" -#: src/components/biometrics/Calibration/CalibrationHistory.tsx:18 -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:16 #: src/components/biometrics/Calibration/ManualProfileActivation.tsx:73 msgid "Waiting for delivery" msgstr "" @@ -2856,8 +3287,12 @@ msgstr "" msgid "Waveform dataset retained: {published}" msgstr "" -#: src/components/biometrics/Calibration/CalibrationOverview.tsx:44 -msgid "What these metrics mean" +#: src/components/biometrics/Calibration/CalibrationEvidence.tsx:196 +msgid "What the saved profile did" +msgstr "" + +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:257 +msgid "when" msgstr "" #: src/components/biometrics/Calibration/DatasetBuilder.tsx:59 @@ -2875,6 +3310,10 @@ msgstr "" msgid "Window IBI variability index (ms)" msgstr "" +#: src/components/biometrics/Calibration/CalibrationProfileCard.tsx:239 +msgid "window IBI variability, not clinical RMSSD" +msgstr "" + #: src/components/biometrics/Calibration/ShadowReport.tsx:34 msgid "Within the observed limits." msgstr "" @@ -2883,6 +3322,10 @@ msgstr "" msgid "Work was deferred; your selection and retry key are preserved." msgstr "" +#: src/components/biometrics/Calibration/BiometricCalibration.tsx:41 +msgid "WORKBENCH" +msgstr "" + #: src/components/biometrics/Calibration/ShadowReport.tsx:18 msgid "Worker cleanup has not been confirmed." msgstr "" 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/podStorage.ts b/src/lib/podStorage.ts index 3a7b4c5ef..6b73879af 100644 --- a/src/lib/podStorage.ts +++ b/src/lib/podStorage.ts @@ -15,7 +15,7 @@ const execFileAsync = promisify(execFile) export const PERSISTENT_ROOT = '/persistent' export const PRUNER_TARGET_PERCENT = Number(process.env.SP_PRUNER_TARGET_PCT) || 80 -const ARCHIVE_DIR = `${PERSISTENT_ROOT}/biometrics-archive` +export const ARCHIVE_DIR = `${PERSISTENT_ROOT}/biometrics-archive` const SWAPFILE = `${PERSISTENT_ROOT}/swapfile` const INSTALL_DIR = process.env.SP_INSTALL_DIR ?? '/home/dac/sleepypod-core' const CLEANUP_BIN = '/usr/local/bin/sp-storage-cleanup' diff --git a/src/lib/recordingPolicy.ts b/src/lib/recordingPolicy.ts new file mode 100644 index 000000000..8e61654cf --- /dev/null +++ b/src/lib/recordingPolicy.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' + +export const recordingPolicySchema = z.object({ + loggingMode: z.enum(['quiet', 'off']).default('quiet'), + debugUntil: z.number().int().nonnegative().nullable().default(null), + healthHistory: z.boolean().default(true), + rawArchive: z.boolean().default(true), +}) +export type RecordingPolicy = z.infer +export const DEFAULT_RECORDING_POLICY: RecordingPolicy = recordingPolicySchema.parse({}) +export const recordingChangeSchema = z.object({ + loggingMode: z.enum(['quiet', 'off']).optional(), + debug: z.boolean().optional(), + healthHistory: z.boolean().optional(), + rawArchive: z.boolean().optional(), +}).strict() +export type RecordingChange = z.infer + +export function effectiveLoggingMode(policy: RecordingPolicy, now = Date.now()): 'quiet' | 'off' | 'debug' { + return policy.debugUntil != null && policy.debugUntil > now ? 'debug' : policy.loggingMode +} + +export function changeRecordingPolicy(policy: RecordingPolicy, change: RecordingChange, now = Date.now()): RecordingPolicy { + return { + loggingMode: change.loggingMode ?? policy.loggingMode, + debugUntil: change.debug === true + ? now + 15 * 60_000 + : change.debug === false || change.loggingMode != null ? null : policy.debugUntil, + healthHistory: change.healthHistory ?? policy.healthHistory, + rawArchive: change.rawArchive ?? policy.rawArchive, + } +} diff --git a/src/lib/recordingStore.ts b/src/lib/recordingStore.ts new file mode 100644 index 000000000..3d39f1d45 --- /dev/null +++ b/src/lib/recordingStore.ts @@ -0,0 +1,56 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { changeRecordingPolicy, DEFAULT_RECORDING_POLICY, recordingPolicySchema, type RecordingChange, type RecordingPolicy } from './recordingPolicy' + +/** Shared with Python and the archiver. Only explicit user changes write this file. */ +export function recordingPolicyPath(): string { + return process.env.SP_RECORDING_POLICY_FILE || path.join(path.dirname(process.env.DATABASE_URL?.replace(/^file:/, '') || './sleepypod.dev.db'), 'recording-policy.json') +} +const globalState = globalThis as typeof globalThis & { + __recordingPolicy?: { path: string, readAt: number, policy: RecordingPolicy } +} + +export function getRecordingPolicy(now = Date.now(), fresh = false): RecordingPolicy { + const file = recordingPolicyPath() + const cached = globalState.__recordingPolicy + if (!fresh && cached?.path === file && now >= cached.readAt && now - cached.readAt < 1000) return cached.policy + let policy: RecordingPolicy + try { + policy = recordingPolicySchema.parse(JSON.parse(readFileSync(/* turbopackIgnore: true */ file, 'utf8'))) + } + catch (error) { + // Missing on first install; malformed/unreadable settings must not silently + // re-enable recording after the user has disabled it. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + policy = { ...DEFAULT_RECORDING_POLICY } + } + globalState.__recordingPolicy = { path: file, readAt: now, policy } + return policy +} + +export function setRecordingPolicy(change: RecordingChange, now = Date.now()): RecordingPolicy { + // Synchronous read-modify-rename: concurrent mutations in this process cannot + // lose unrelated settings. Atomic rename keeps sidecar readers consistent. + const previous = getRecordingPolicy(now, true) + const next = changeRecordingPolicy(previous, change, now) + if (JSON.stringify(previous) === JSON.stringify(next)) return next + const file = recordingPolicyPath() + mkdirSync(/* turbopackIgnore: true */ path.dirname(file), { recursive: true }) + const temporary = `${file}.${randomUUID()}.tmp` + try { + writeFileSync(/* turbopackIgnore: true */ temporary, JSON.stringify(next, null, 2) + '\n', { mode: 0o644, flag: 'wx' }) + renameSync(/* turbopackIgnore: true */ temporary, file) + } + finally { + try { + unlinkSync(/* turbopackIgnore: true */ temporary) + } + catch { + // Rename already removed the temporary file on success. On failure, + // preserve the original write error rather than masking it with cleanup. + } + } + globalState.__recordingPolicy = { path: file, readAt: now, policy: next } + return next +} diff --git a/src/lib/scheduleLibrary.test.ts b/src/lib/scheduleLibrary.test.ts new file mode 100644 index 000000000..a768aaa7f --- /dev/null +++ b/src/lib/scheduleLibrary.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { + activeCurveOn, + daysOnSides, + hasDays, + type LibraryCurveClaim, + movedDays, + normalizeDays, + planClaims, + pointsToSetPoints, + setPointsToPoints, + uniqueCurveName, +} from './scheduleLibrary' + +const curve = (id: number, name: string, days: Partial, active = true): LibraryCurveClaim => ({ + id, name, days: normalizeDays(days), active, +}) + +describe('normalizeDays', () => { + it('orders days by week, drops duplicates and fills missing sides', () => { + expect(normalizeDays({ left: ['friday', 'monday', 'friday', 'sunday'] })).toEqual({ left: ['sunday', 'monday', 'friday'], right: [] }) + expect(normalizeDays(undefined)).toEqual({ left: [], right: [] }) + }) +}) + +describe('hasDays', () => { + it('is true when either side has a day', () => { + expect(hasDays({ left: [], right: [] })).toBe(false) + expect(hasDays({ left: ['monday'], right: [] })).toBe(true) + expect(hasDays({ left: [], right: ['monday'] })).toBe(true) + }) +}) + +describe('daysOnSides', () => { + it('puts the same days on the given sides only', () => { + expect(daysOnSides(['left'], ['tuesday', 'monday'])).toEqual({ left: ['monday', 'tuesday'], right: [] }) + expect(daysOnSides(['right'], ['monday'])).toEqual({ left: [], right: ['monday'] }) + expect(daysOnSides(['left', 'right'], ['monday'])).toEqual({ left: ['monday'], right: ['monday'] }) + }) +}) + +describe('activeCurveOn', () => { + it('finds the active curve claiming a side and day, ignoring inactive ones and the other side', () => { + const off = curve(1, 'Off', { left: ['monday'] }, false) + const on = curve(2, 'On', { left: ['monday'], right: ['tuesday'] }) + expect(activeCurveOn([off, on], 'left', 'monday')).toBe(on) + expect(activeCurveOn([off, on], 'right', 'monday')).toBeUndefined() + expect(activeCurveOn([off, on], 'right', 'tuesday')).toBe(on) + expect(activeCurveOn([off], 'left', 'monday')).toBeUndefined() + }) +}) + +describe('planClaims', () => { + const weeknight = curve(1, 'Weeknight', { left: ['monday', 'tuesday', 'wednesday'], right: ['monday'] }) + const weekend = curve(2, 'Weekend', { left: ['saturday'] }) + const parked = curve(3, 'Parked', { left: ['monday'] }, false) + + it('moves only the overlapping days on each side and keeps the rest active', () => { + const plan = planClaims([weeknight, weekend, parked], null, normalizeDays({ left: ['monday', 'tuesday'] }), true) + expect(plan.updates).toEqual([{ id: 1, days: { left: ['wednesday'], right: ['monday'] }, active: true }]) + expect(plan.moves).toEqual([{ id: 1, name: 'Weeknight', side: 'left', days: ['monday', 'tuesday'], deactivated: false }]) + }) + + it('reports moves per side and deactivates a curve left with no days', () => { + const plan = planClaims([weeknight], 9, normalizeDays({ + left: ['monday', 'tuesday', 'wednesday'], right: ['monday'], + }), true) + expect(plan.updates).toEqual([{ id: 1, days: { left: [], right: [] }, active: false }]) + expect(plan.moves).toEqual([ + { id: 1, name: 'Weeknight', side: 'left', days: ['monday', 'tuesday', 'wednesday'], deactivated: true }, + { id: 1, name: 'Weeknight', side: 'right', days: ['monday'], deactivated: true }, + ]) + }) + + it('does not take days from the other side', () => { + const plan = planClaims([weekend], null, normalizeDays({ right: ['saturday'] }), true) + expect(plan).toEqual({ updates: [], moves: [] }) + }) + + it('moves nothing when the target ends up inactive', () => { + expect(planClaims([weeknight], null, normalizeDays({ left: ['monday'] }), false)).toEqual({ updates: [], moves: [] }) + }) + + it('never takes days from the target itself', () => { + expect(planClaims([weeknight], 1, weeknight.days, true)).toEqual({ updates: [], moves: [] }) + }) + + it('ignores inactive curves that list the same days', () => { + expect(planClaims([parked], null, normalizeDays({ left: ['monday'] }), true)).toEqual({ updates: [], moves: [] }) + }) + + it('collects moves from several curves', () => { + const plan = planClaims([weeknight, weekend], null, normalizeDays({ left: ['monday', 'saturday'] }), true) + expect(plan.moves.map(m => [m.name, m.days, m.deactivated])).toEqual([ + ['Weeknight', ['monday'], false], + ['Weekend', ['saturday'], true], + ]) + }) +}) + +describe('movedDays', () => { + it('drops curve ids from moves', () => { + expect(movedDays([{ id: 4, name: 'X', side: 'right', days: ['friday'], deactivated: true }])) + .toEqual([{ name: 'X', side: 'right', days: ['friday'], deactivated: true }]) + }) +}) + +describe('points conversions', () => { + it('round-trips a point map and set point list', () => { + const points = { '22:00': 70, '06:30': 80 } + const list = pointsToSetPoints(points) + expect(list).toEqual([{ time: '22:00', temperature: 70 }, { time: '06:30', temperature: 80 }]) + expect(setPointsToPoints(list)).toEqual(points) + }) +}) + +describe('uniqueCurveName', () => { + it('numbers taken names and keeps them within 30 characters', () => { + expect(uniqueCurveName('Weekdays curve', new Set())).toBe('Weekdays curve') + expect(uniqueCurveName('Weekdays curve', new Set(['Weekdays curve', 'Weekdays curve 2']))).toBe('Weekdays curve 3') + const long = 'Mon, Tue, Wed, Thu, Fri, Sun curve' + expect(uniqueCurveName(long, new Set())).toBe('Mon, Tue, Wed, Thu, Fri, Sun c') + expect(uniqueCurveName(long, new Set(['Mon, Tue, Wed, Thu, Fri, Sun c']))).toBe('Mon, Tue, Wed, Thu, Fri, Sun 2') + }) +}) diff --git a/src/lib/scheduleLibrary.ts b/src/lib/scheduleLibrary.ts new file mode 100644 index 000000000..399133611 --- /dev/null +++ b/src/lib/scheduleLibrary.ts @@ -0,0 +1,115 @@ +/** + * Schedule library rules shared by the server, the Schedule screen and the + * MCP tools. Every curve is saved; an active curve runs on its days, and two + * active curves never claim the same side and day. Activating a curve moves + * any day it shares with another active curve to it; a curve left with no + * days stops being active. + */ +import { DAYS_OF_WEEK, type DayOfWeek } from '@/src/lib/scheduleTime' + +export type LibrarySide = 'left' | 'right' +export const LIBRARY_SIDES: readonly LibrarySide[] = ['left', 'right'] + +export interface CurveDays { + left: DayOfWeek[] + right: DayOfWeek[] +} + +export interface LibraryCurveClaim { + id: number + name: string + days: CurveDays + active: boolean +} + +/** Days one side of another curve gives up. */ +export interface DayMove { + id: number + name: string + side: LibrarySide + days: DayOfWeek[] + /** True when the curve has no days left and stops being active. */ + deactivated: boolean +} + +export interface ClaimPlan { + /** New days and active flag for every other curve that loses days. */ + updates: Array<{ id: number, days: CurveDays, active: boolean }> + moves: DayMove[] +} + +export const NO_DAYS: CurveDays = { left: [], right: [] } + +/** Week order, no duplicates, missing sides empty. */ +export function normalizeDays(days: Partial | undefined): CurveDays { + const pick = (list: DayOfWeek[] | undefined) => DAYS_OF_WEEK.filter(d => list?.includes(d)) + return { left: pick(days?.left), right: pick(days?.right) } +} + +export function hasDays(days: CurveDays): boolean { + return days.left.length > 0 || days.right.length > 0 +} + +/** The same days on each of `sides`, none on the others. */ +export function daysOnSides(sides: readonly LibrarySide[], days: DayOfWeek[]): CurveDays { + return normalizeDays({ left: sides.includes('left') ? days : [], right: sides.includes('right') ? days : [] }) +} + +/** The active curve running `side` on `day`, if any. */ +export function activeCurveOn(curves: C[], side: LibrarySide, day: DayOfWeek): C | undefined { + return curves.find(c => c.active && c.days[side].includes(day)) +} + +/** + * What activating curve `targetId` (null for a new curve) on `days` does to + * the other active curves. Nothing moves when the curve ends up inactive. + */ +export function planClaims(curves: LibraryCurveClaim[], targetId: number | null, days: CurveDays, active: boolean): ClaimPlan { + const plan: ClaimPlan = { updates: [], moves: [] } + if (!active) return plan + for (const other of curves) { + if (other.id === targetId || !other.active) continue + const next: CurveDays = { left: [...other.days.left], right: [...other.days.right] } + const taken: Array<[LibrarySide, DayOfWeek[]]> = [] + for (const side of LIBRARY_SIDES) { + const overlap = other.days[side].filter(d => days[side].includes(d)) + if (overlap.length === 0) continue + next[side] = other.days[side].filter(d => !days[side].includes(d)) + taken.push([side, overlap]) + } + if (taken.length === 0) continue + const stillActive = hasDays(next) + plan.updates.push({ id: other.id, days: next, active: stillActive }) + for (const [side, moved] of taken) { + plan.moves.push({ id: other.id, name: other.name, side, days: moved, deactivated: !stillActive }) + } + } + return plan +} + +export const CURVE_NAME_MAX = 30 + +/** `base`, or `base 2`, `base 3`… when taken; trimmed to fit the 30-character name limit with its number. */ +export function uniqueCurveName(base: string, taken: ReadonlySet): string { + let name = base.slice(0, CURVE_NAME_MAX).trim() + for (let n = 2; taken.has(name); n++) { + const suffix = ` ${n}` + name = base.slice(0, CURVE_NAME_MAX - suffix.length).trim() + suffix + } + return name +} + +/** Moves as the API reports them (curves by name). */ +export function movedDays(moves: DayMove[]): Array> { + return moves.map(({ name, side, days, deactivated }) => ({ name, side, days, deactivated })) +} + +/** Set points stored on a curve ({"HH:mm": °F}) as a list. */ +export function pointsToSetPoints(points: Record): Array<{ time: string, temperature: number }> { + return Object.entries(points).map(([time, temperature]) => ({ time, temperature })) +} + +/** A set point list as a curve stores it. */ +export function setPointsToPoints(setPoints: Array<{ time: string, temperature: number }>): Record { + return Object.fromEntries(setPoints.map(p => [p.time, p.temperature])) +} diff --git a/src/lib/tests/appLogging.test.ts b/src/lib/tests/appLogging.test.ts new file mode 100644 index 000000000..337ba3aeb --- /dev/null +++ b/src/lib/tests/appLogging.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment node +import { afterEach, expect, it, vi } from 'vitest' +const mock = vi.hoisted(() => ({ policy: vi.fn() })) +vi.mock('../recordingStore', () => ({ getRecordingPolicy: mock.policy })) +import { installAppLogging } from '../appLogging' +const methods = ['log', 'info', 'debug', 'warn', 'error', 'trace'] as const +const originals = Object.fromEntries(methods.map(method => [method, console[method]])) +afterEach(() => { + for (const method of methods) console[method] = originals[method] + delete (globalThis as typeof globalThis & { __recordingLoggingInstalled?: boolean }).__recordingLoggingInstalled + vi.useRealTimers() +}) +it('filters actual console output, switches modes, expires debug and installs only once', () => { + vi.useFakeTimers() + vi.setSystemTime(1000) + const output = Object.fromEntries(methods.map(method => [method, vi.fn()])) + for (const method of methods) console[method] = output[method] + mock.policy.mockReturnValue({ loggingMode: 'quiet', debugUntil: null }) + installAppLogging() + const wrapped = console.info + installAppLogging() + expect(console.info).toBe(wrapped) + console.info('routine') + console.warn('warning') + expect(output.info).not.toHaveBeenCalled() + expect(output.warn).toHaveBeenCalledWith('warning') + mock.policy.mockReturnValue({ loggingMode: 'off', debugUntil: 2000 }) + console.debug('detail') + expect(output.debug).toHaveBeenCalledWith('detail') + vi.setSystemTime(2000) + console.error('discarded') + expect(output.error).not.toHaveBeenCalled() +}) 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/flashHealth.test.ts b/src/lib/tests/flashHealth.test.ts new file mode 100644 index 000000000..70fadc786 --- /dev/null +++ b/src/lib/tests/flashHealth.test.ts @@ -0,0 +1,202 @@ +import path from 'node:path' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as schema from '@/src/db/biometrics-schema' +import { flashWriteSamples } from '@/src/db/biometrics-schema' +import type { FlashReading } from '../flashHealth' + +const files = vi.hoisted(() => new Map()) +vi.mock('node:fs/promises', () => { + const readFile = vi.fn(async (p: string) => { + const v = files.get(p) + if (v == null) throw new Error('ENOENT') + return v + }) + return { readFile, default: { readFile } } +}) + +const { + buildFlashReport, parseLifeTime, parsePreEol, parseSectorsWritten, readFlash, recordFlashSample, writeRate, +} = await import('../flashHealth') + +const HOUR = 3_600_000 +const DAY = 24 * HOUR +const T0 = Date.UTC(2026, 9, 17, 0, 0, 0) +/** sectors for n MiB */ +const mib = (n: number) => (n * 1024 * 1024) / 512 + +// Captured from a Pod 5 (FORESEE eMMC) — field 7 is sectors written. +const STAT = ' 43154 9400 2711852 39177 21804 18522 3248408 1139238 0 80564 1205189 5197 166 2954288 26227 665 545\n' + +function pod(overrides: Record = {}) { + files.clear() + const base = '/sys/block/mmcblk0' + const entries: Record = { + [`${base}/stat`]: STAT, + [`${base}/size`]: '61071360\n', + [`${base}/device/name`]: 'C9A551\n', + [`${base}/device/life_time`]: '0x04 0x00\n', + [`${base}/device/pre_eol_info`]: '0x01\n', + '/proc/sys/kernel/random/boot_id': 'boot-a\n', + '/proc/uptime': '2604.89 6984.84\n', + ...overrides, + } + for (const [k, v] of Object.entries(entries)) files.set(k, v) +} + +describe('parsers', () => { + it('maps JEDEC life_time codes to 10% bands, exceeded and undefined', () => { + expect(parseLifeTime('0x04 0x00\n')).toEqual([{ minPercent: 30, maxPercent: 40 }, null]) + expect(parseLifeTime('0x01 0x0A')).toEqual([{ minPercent: 0, maxPercent: 10 }, { minPercent: 90, maxPercent: 100 }]) + expect(parseLifeTime('0x0B 0x0c')).toEqual([{ minPercent: 100, maxPercent: null }, null]) + expect(parseLifeTime('')).toEqual([null, null]) + }) + + it('maps pre_eol_info', () => { + expect(parsePreEol('0x01\n')).toBe('normal') + expect(parsePreEol('0x02')).toBe('warning') + expect(parsePreEol('0x03')).toBe('urgent') + expect(parsePreEol('0x00')).toBeNull() + }) + + it('reads sectors written from field 7 of the block stat', () => { + expect(parseSectorsWritten(STAT)).toBe(3248408) + expect(parseSectorsWritten('1 2 3')).toBeNull() + }) +}) + +describe('readFlash', () => { + it('reads the chip and counters', async () => { + pod() + expect(await readFlash()).toEqual({ + name: 'C9A551', + capacityBytes: 61071360 * 512, + lifeTime: [{ minPercent: 30, maxPercent: 40 }, null], + preEol: 'normal', + sectorsWritten: 3248408, + bootId: 'boot-a', + uptimeSeconds: 2604.89, + }) + }) + + it('is null without the block device (off-pod)', async () => { + files.clear() + expect(await readFlash()).toBeNull() + }) + + it('reports a null boot id when it is unreadable', async () => { + pod() + files.delete('/proc/sys/kernel/random/boot_id') + expect((await readFlash())?.bootId).toBeNull() + }) + + it('still reports writes when the chip hides its health registers', async () => { + pod() + files.delete('/sys/block/mmcblk0/device/life_time') + files.delete('/sys/block/mmcblk0/device/pre_eol_info') + const r = await readFlash() + expect(r?.lifeTime).toEqual([null, null]) + expect(r?.preEol).toBeNull() + expect(r?.sectorsWritten).toBe(3248408) + }) +}) + +describe('writeRate', () => { + it('needs an hour of samples', () => { + expect(writeRate([{ at: T0, bootId: 'a', sectorsWritten: 0 }])).toBeNull() + expect(writeRate([ + { at: T0, bootId: 'a', sectorsWritten: 0 }, + { at: T0 + HOUR - 1, bootId: 'a', sectorsWritten: mib(100) }, + ])).toBeNull() + }) + + it('scales counter growth within a boot to a day', () => { + const r = writeRate([ + { at: T0, bootId: 'a', sectorsWritten: mib(1000) }, + { at: T0 + 2 * HOUR, bootId: 'a', sectorsWritten: mib(1100) }, + ]) + expect(r).toEqual({ bytesPerDay: 1200 * 1024 * 1024, coveredMs: 2 * HOUR }) + }) + + it('counts a new boot from zero instead of going negative', () => { + const r = writeRate([ + { at: T0, bootId: 'a', sectorsWritten: mib(5000) }, + { at: T0 + 12 * HOUR, bootId: 'b', sectorsWritten: mib(300) }, + { at: T0 + DAY, bootId: 'b', sectorsWritten: mib(700) }, + ]) + expect(r?.bytesPerDay).toBe(700 * 1024 * 1024) + }) + + it('treats a counter that went backwards as a reboot', () => { + const r = writeRate([ + { at: T0, bootId: 'a', sectorsWritten: mib(5000) }, + { at: T0 + DAY, bootId: 'a', sectorsWritten: mib(700) }, + ]) + expect(r?.bytesPerDay).toBe(700 * 1024 * 1024) + }) +}) + +describe('buildFlashReport', () => { + let raw: Database.Database + let db: ReturnType> + + beforeEach(() => { + raw = new Database(':memory:') + db = drizzle(raw, { schema }) + migrate(db, { migrationsFolder: path.resolve(process.cwd(), 'src/db/biometrics-migrations') }) + }) + + afterEach(() => raw.close()) + + const reading = (sectorsWritten: number, bootId = 'boot-a'): FlashReading => ({ + name: 'C9A551', + capacityBytes: 29 * 1024 ** 3, + lifeTime: [{ minPercent: 30, maxPercent: 40 }, null], + preEol: 'normal', + sectorsWritten, + bootId, + uptimeSeconds: 7200, + }) + + it('has no per-day rate before samples cover an hour', () => { + const report = buildFlashReport(db, reading(mib(50)), T0) + expect(report.perDay).toBeNull() + expect(report.sinceBoot).toEqual({ bytes: 50 * 1024 * 1024, uptimeSeconds: 7200 }) + }) + + it('records nothing and leaves the live reading out of the rate without a boot id', () => { + recordFlashSample(db, reading(mib(0)), T0) + recordFlashSample(db, { ...reading(mib(100)), bootId: null }, T0 + HOUR) + expect(db.select().from(flashWriteSamples).all()).toHaveLength(1) + const report = buildFlashReport(db, { ...reading(mib(400)), bootId: null }, T0 + 4 * HOUR) + expect(report.perDay).toBeNull() + expect(report.sinceBoot.bytes).toBe(400 * 1024 * 1024) + }) + + it('averages stored samples up to the live reading', () => { + recordFlashSample(db, reading(mib(0)), T0) + recordFlashSample(db, reading(mib(100)), T0 + HOUR) + const report = buildFlashReport(db, reading(mib(400)), T0 + 4 * HOUR) + expect(report.perDay).toEqual({ bytes: 2400 * 1024 * 1024, coveredHours: 4 }) + expect(report.device).toBe('mmcblk0') + }) + + it('builds insights from persisted counters and the live reading without writing rows', () => { + recordFlashSample(db, reading(mib(0)), T0) + recordFlashSample(db, reading(mib(100)), T0 + HOUR) + const report = buildFlashReport(db, reading(mib(200)), T0 + 2 * HOUR) + expect(report.insights.windows[0]).toEqual({ hours: 1, coveredHours: 1, bytes: 100 * 1024 ** 2, bytesPerDay: 2400 * 1024 ** 2 }) + expect(report.insights.windows[1].coveredHours).toBe(2) + expect(db.select().from(flashWriteSamples).all()).toHaveLength(2) + }) + + it('ignores samples older than a week', () => { + recordFlashSample(db, reading(0, 'old'), T0 - 8 * DAY) + recordFlashSample(db, reading(mib(100)), T0) + const report = buildFlashReport(db, reading(mib(200)), T0 + 2 * HOUR) + expect(report.perDay).toEqual({ bytes: 1200 * 1024 * 1024, coveredHours: 2 }) + expect(db.select().from(flashWriteSamples).all()).toHaveLength(2) + }) +}) diff --git a/src/lib/tests/flashInsights.test.ts b/src/lib/tests/flashInsights.test.ts new file mode 100644 index 000000000..aeb1f06a3 --- /dev/null +++ b/src/lib/tests/flashInsights.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { flashInsights } from '../flashInsights' + +const HOUR = 3_600_000 +const sample = (hour: number, sectors: number, bootId = 'a') => ({ at: hour * HOUR, sectorsWritten: sectors, bootId }) + +describe('flashInsights', () => { + it('projects bytes per day and exposes the measured total separately', () => { + const result = flashInsights([sample(0, 100), sample(1, 200), sample(2, 400)], 2 * HOUR) + expect(result.windows[0]).toEqual({ hours: 1, coveredHours: 1, bytes: 200 * 512, bytesPerDay: 200 * 512 * 24 }) + expect(result.windows[1]).toEqual({ hours: 24, coveredHours: 2, bytes: 300 * 512, bytesPerDay: 150 * 512 * 24 }) + expect(result.history).toHaveLength(24) + expect(result.history[0].bytesPerDay).toBeNull() + expect(result.history[23].bytesPerDay).toBe(200 * 512 * 24) + }) + + it('prorates boundaries without including writes outside the window', () => { + const result = flashInsights([sample(0, 0), sample(1, 100), sample(2, 300)], 2.5 * HOUR) + expect(result.windows[0]).toEqual({ hours: 1, coveredHours: 0.5, bytes: 100 * 512, bytesPerDay: null }) + const live = flashInsights([sample(0, 0), sample(1, 100), sample(2, 300), sample(2.5, 400)], 2.5 * HOUR) + expect(live.windows[0].bytesPerDay).toBe(200 * 512 * 24) + }) + + it('excludes reboots, counter resets and long gaps from both bytes and coverage', () => { + const result = flashInsights([ + sample(0, 0), sample(1, 100), sample(2, 500, 'b'), + sample(3, 600, 'b'), sample(4, 10, 'b'), sample(8, 1000, 'b'), + ], 8 * HOUR) + expect(result.windows[1].coveredHours).toBe(2) + expect(result.windows[1].bytes).toBe(200 * 512) + expect(result.windows[0].bytesPerDay).toBeNull() + }) + + it('distinguishes zero writes from missing measurements and waits an hour', () => { + expect(flashInsights([], HOUR).windows[0].bytesPerDay).toBeNull() + expect(flashInsights([sample(0, 0), sample(0.5, 0)], HOUR).windows[0].bytesPerDay).toBeNull() + expect(flashInsights([sample(0, 42), sample(1, 42)], HOUR).windows[0].bytesPerDay).toBe(0) + }) + + it('ignores future and invalid samples and orders valid samples', () => { + const result = flashInsights([sample(1, 100), sample(0, 0), sample(2, 999), sample(0.5, -1), sample(0.4, NaN)], HOUR) + expect(result.windows[0].bytesPerDay).toBe(100 * 512 * 24) + }) + + it('keeps each rate within its requested window', () => { + const samples = Array.from({ length: 171 }, (_, i) => sample(i, i * 100)) + expect(flashInsights(samples, 170 * HOUR).windows.map(w => w.coveredHours)).toEqual([1, 24, 168]) + }) +}) diff --git a/src/lib/tests/healthSampler.test.ts b/src/lib/tests/healthSampler.test.ts index cb71b02be..de4513569 100644 --- a/src/lib/tests/healthSampler.test.ts +++ b/src/lib/tests/healthSampler.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node import { afterEach, beforeEach, expect, it, vi } from 'vitest' -const mock = vi.hoisted(() => ({ get: vi.fn(), load: vi.fn(), record: vi.fn() })) +const mock = vi.hoisted(() => ({ get: vi.fn(), load: vi.fn(), record: vi.fn(), policy: vi.fn() })) +vi.mock('@/src/lib/recordingStore', () => ({ getRecordingPolicy: mock.policy })) vi.mock('@/src/db', () => ({ biometricsDb: {} })) vi.mock('@/src/lib/dataPathCollect', () => ({ getDataPath: mock.get })) vi.mock('@/src/lib/healthHistory', () => ({ loadOpenRuns: mock.load, recordSample: mock.record, SAMPLE_INTERVAL_MS: 60000 })) @@ -8,6 +9,7 @@ import { startHealthSampler, stopHealthSampler } from '../healthSampler' beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + mock.policy.mockReturnValue({ healthHistory: true }) mock.get.mockResolvedValue({ nodes: [{ id: 'dac', status: 'ok', detail: 'Connected' }] }) mock.load.mockReturnValue(new Map()) }) @@ -42,3 +44,27 @@ it('logs a failed sample and retries next minute', async () => { await vi.advanceTimersByTimeAsync(60000) expect(mock.record).toHaveBeenCalledOnce() }) + +it('stops history writes while disabled and starts a fresh run when recording resumes', async () => { + startHealthSampler(1) + await vi.advanceTimersByTimeAsync(1) + mock.policy.mockReturnValue({ healthHistory: false }) + await vi.advanceTimersByTimeAsync(60000) + expect(mock.record).toHaveBeenCalledTimes(1) + expect(mock.get).toHaveBeenCalledTimes(1) + mock.policy.mockReturnValue({ healthHistory: true }) + await vi.advanceTimersByTimeAsync(60000) + expect(mock.record).toHaveBeenCalledTimes(2) + expect(mock.load).toHaveBeenCalledTimes(1) + expect(mock.record.mock.calls[1][1]).not.toBe(mock.record.mock.calls[0][1]) +}) + +it('does not write if recording is disabled while a health probe is in flight', async () => { + mock.get.mockImplementationOnce(async () => { + mock.policy.mockReturnValue({ healthHistory: false }) + return { nodes: [] } + }) + startHealthSampler(1) + await vi.advanceTimersByTimeAsync(1) + expect(mock.record).not.toHaveBeenCalled() +}) 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/lib/tests/recordingPolicy.test.ts b/src/lib/tests/recordingPolicy.test.ts new file mode 100644 index 000000000..e880d4fa5 --- /dev/null +++ b/src/lib/tests/recordingPolicy.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { changeRecordingPolicy, DEFAULT_RECORDING_POLICY, effectiveLoggingMode } from '../recordingPolicy' +import { getRecordingPolicy, setRecordingPolicy } from '../recordingStore' +import { shouldLog } from '../appLogging' + +let root: string +let file: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'recording-policy-')) + file = join(root, 'recording-policy.json') + vi.stubEnv('SP_RECORDING_POLICY_FILE', file) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.unstubAllEnvs() +}) + +describe('recording policy', () => { + it('defaults to quiet and preserves history without writing merely to read', () => { + expect(getRecordingPolicy()).toEqual(DEFAULT_RECORDING_POLICY) + expect(existsSync(file)).toBe(false) + }) + + it('persists independent switches and restores them after cache expiry', () => { + setRecordingPolicy({ rawArchive: false }, 1000) + setRecordingPolicy({ healthHistory: false, loggingMode: 'off' }, 2000) + expect(getRecordingPolicy(4000)).toEqual({ loggingMode: 'off', healthHistory: false, rawArchive: false, debugUntil: null }) + expect(JSON.parse(readFileSync(file, 'utf8')).rawArchive).toBe(false) + }) + + it('expires debug without a timer or another disk write, returning to the prior mode', () => { + const off = changeRecordingPolicy(DEFAULT_RECORDING_POLICY, { loggingMode: 'off' }, 0) + const debug = changeRecordingPolicy(off, { debug: true }, 100) + expect(effectiveLoggingMode(debug, 100)).toBe('debug') + expect(effectiveLoggingMode(debug, 900099)).toBe('debug') + expect(effectiveLoggingMode(debug, 900100)).toBe('off') + expect(changeRecordingPolicy(debug, { debug: false }).loggingMode).toBe('off') + expect(changeRecordingPolicy(debug, { loggingMode: 'quiet' }).debugUntil).toBeNull() + expect(changeRecordingPolicy(debug, { rawArchive: false }).debugUntil).toBe(debug.debugUntil) + }) + + it('does not silently reset unreadable preferences or overwrite a malformed policy', () => { + writeFileSync(file, '{broken') + expect(() => getRecordingPolicy(0, true)).toThrow() + expect(() => setRecordingPolicy({ rawArchive: true })).toThrow() + expect(readFileSync(file, 'utf8')).toBe('{broken') + }) + + it('filters severity explicitly: off drops errors too; debug permits all levels', () => { + expect(shouldLog('warn', 'quiet')).toBe(true) + expect(shouldLog('error', 'quiet')).toBe(true) + expect(shouldLog('info', 'quiet')).toBe(false) + expect(shouldLog('log', 'quiet')).toBe(false) + expect(shouldLog('error', 'off')).toBe(false) + expect(shouldLog('debug', 'debug')).toBe(true) + }) +}) diff --git a/src/mcp/README.md b/src/mcp/README.md index 693545c57..a3d223de1 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -51,6 +51,10 @@ claude mcp add --transport http sleepypod http://192.168.1.88:3000/api/mcp | "How are my stages classified?" / "Do I have enough watch data to tune?" | `get_sleep_stage_rules` | `sleepStages.getClassifier`, `biometrics.getReferenceStages`, `getSleepRecords` | | "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 @@ -67,11 +71,11 @@ claude mcp add --transport http sleepypod http://192.168.1.88:3000/api/mcp | Intent | Tool | Procedures | |---|---|---| -| "What's my schedule?" / "Why didn't it turn on?" | `get_schedules` | `schedules.getAll`, `settings.getAll` | +| "What's my schedule?" / "Why didn't it turn on?" / "Which curve runs Tuesday?" | `get_schedules` | `schedules.getAll`, `getNotes`, `settings.getAll`, `curveTemplates.list` | | "Every weekday cool to 66 at 10pm" / "Wake me 7am with vibration" / "Delete Monday's alarm" | `manage_schedule` | `schedules.create/update/delete{Temperature,Power,Alarm}Schedule` | -| "Given my sleep data, build me a curve for weeknights" / "Put Late Warm on weekends" (preview, then apply) | [`apply_sleep_curve`](#sleep-curves) | `schedules.getAll`, `settings.getAll`, `schedules.batchUpdate`, `curveTemplates.list` | -| "What curves have I saved?" | `get_saved_curves` | `curveTemplates.list` | -| "Save that as Late Warm" / "Delete my Deep Cool curve" | [`manage_saved_curve`](#sleep-curves) | `curveTemplates.list`, `save`, `delete` | +| "Given my sleep data, build me a curve for weeknights" / "Put Late Warm on weekends" (preview, then apply) | [`apply_sleep_curve`](#sleep-curves) | `schedules.getAll`, `getNotes`, `settings.getAll`, `curveTemplates.list`, `put` | +| "What curves have I saved?" / "Which curves are on?" | `get_saved_curves` | `curveTemplates.list` | +| "Save that as Late Warm" / "Turn on Heatwave for weeknights" / "Pause Late Warm" / "Delete my Deep Cool curve" | [`manage_saved_curve`](#sleep-curves) | `curveTemplates.list`, `save`, `put`, `setActive`, `delete` | ### Settings @@ -141,6 +145,8 @@ app/api/mcp/route.ts Next.js route handler (stateless Streamable HTTP) | `import_biometric_reference` | Preview and commit normalized references using the exact preview hash | | `propose_biometric_profile` | Save an immutable agent-origin candidate; never activate | | `manage_biometric_experiment` | Freeze datasets, inspect archive cursors, retain selected waveform sources, and create/list/read experiments with pinned protocols | +| `export_calibration_dataset` | Verify a frozen dataset and its retained archives; return the hashed bundle index and the LAN tar download path (no waveform bytes in the MCP response) | +| `export_analysis_snapshot` | Count the RAW archives for a window (≤ 14 days) and return the LAN download path for a tar with a `biometrics.db` backup, those archives and a hashed `manifest.json` (no bytes in the MCP response) | | `backtest_biometrics` | Enqueue/status/list/cancel and compact report summaries | | `apply_biometric_profile` | Explicitly authorized validated/manual activation, reset or rollback; validated activation pins evaluation/report identity, manual activation requires unvalidated acknowledgement | | `release_biometric_artifacts` | Explicit reference deletion or dataset/report release; report release requires its content hash | @@ -205,9 +211,10 @@ source IDs against the parent dataset hash; do not pass paths or raw bytes. ### Apple Watch imports -`import_apple_watch_data` imports extracted Watch heart-rate `samples` and/or sleep-stage -`segments` for an explicit `side`. Timestamps are Unix seconds; each array accepts up to -2,000 records. It uses the existing reference-vitals and reference-stages procedures, +`import_apple_watch_data` imports extracted Watch heart-rate `samples`, respiratory-rate +`breathingRates` (breaths/min), HRV SDNN `hrvSdnn` (ms) and/or sleep-stage `segments` for an +explicit `side`. Timestamps are Unix seconds; each array accepts up to 2,000 records. It uses +the existing reference-vitals, reference-samples and reference-stages procedures, so repeats skip duplicates and never overwrite pod measurements. Check `stageError` for partial success before reporting completion. @@ -224,14 +231,16 @@ curl --fail-with-body -X POST \ The body is raw `export.zip` or `export.xml`, not multipart. `side` is required; `days` defaults to 30 and accepts 1–90. Uploads are limited to 512 MiB and ZIP XML -members to 1 GiB uncompressed. Only Apple Watch heart rate and sleep stages in the -chosen period are imported. A ZIP must contain `apple_health_export/export.xml` or +members to 1 GiB uncompressed. Only Apple Watch heart rate, respiratory rate, HRV +(SDNN) and sleep stages in the chosen period are imported; records the sleepypod iOS +app wrote back into Health (source `sleepypod`, `sleepypod design`) are always skipped. A ZIP must contain `apple_health_export/export.xml` or `export.xml`; unrelated members are never extracted. Files are staged on persistent storage and deleted on completion or failure. Only one upload/import runs at a time. -The response includes `vitals` and `stages` (new rows), `matchedVitals` and -`matchedStages` (eligible records), and `rejectedStageBatches` (conflicting stage -batches skipped while heart rate still imports). A disconnect or timeout can leave +The response includes `vitals`, `breathing`, `hrv` and `stages` (new rows), +`matchedVitals`, `matchedBreathing`, `matchedHrv` and `matchedStages` (eligible +records), and `rejectedStageBatches` (conflicting stage batches skipped while the +vitals still import). A disconnect or timeout can leave some batches imported; retrying the same export is safe. Upload has a 30-minute budget, followed by a separate 10-minute import budget. The browser allows 41 minutes for both phases and response cleanup. Like the existing API, this is LAN-only. @@ -264,24 +273,79 @@ 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 +read-only. For one `side` it takes sleep records of at least `minHours` (default 3) entered between +`startDate` and `endDate` (default the last 14 days, at most 31) and reports, per night and pooled +over every pair of all nights, how far the pod sits from imported Apple Watch data. Deviation is +pod − Watch. HR pairs each Watch sample with the nearest pod reading within ±60 s (the same +pairing as `heartRateAgreement`), breathing within ±150 s, and HRV compares Watch SDNN with the +mean pod HRV within ±5 min. Each metric has `n`, `watchSamples`, `coverage`, `mae`, `bias`, `p90` +(absolute error) and Pearson `r`, null without data. `stages` re-runs the side's current classifier +on the stored vitals and reports `epochs`, `accuracy`, `kappa`, `recall` and pod vs Watch minutes +per stage over the scored epochs. `hrvNote` explains that the pod's HRV is an IBI-variability index, +not SDNN, so HRV deviation is not like-for-like yet. + +Run it before and after a profile or stage ruleset change. Stages re-score immediately; stored HR, +breathing and HRV only change for nights recorded after a profile takes effect. `configChanges` lists +the side's profile generations that took effect in the window (`generation`, and `atMs`, the sample +time the processor acknowledged), so nights before and after a change can be told apart. + ### Sleep curves -`apply_sleep_curve` saves a whole curve (2-48 `setPoints`) for `side` `left`, `right` or `both` on a list of -`days`, through `planCurveSave` in `src/lib/sleepCurve/savePlan.ts`, the same planner the Schedule screen's -curve editor uses. Each target day's temperature and power rows are replaced in one `schedules.batchUpdate` -transaction; alarms are kept. Flat-hold set points are dropped, and the power window runs from the first to -the last set point (`endAction: "maintain"` holds the last temperature). `mode: "preview"` writes nothing and -returns the simplified curve, `powerWindow`, the rows it `replaces` per side and day, `keptAlarms` and -`warnings` (away mode, no on/off window); `mode: "apply"` with the same arguments saves exactly that. -Instead of `setPoints` it accepts `curveName`, a saved curve. Optional `notes` (up to 1,000 characters) are -written to every target day (`schedule_notes` table, `schedules.getNotes` / `batchUpdate.notes`) and shown on -the curve's card; they replace the days' previous notes, default to the saved curve's notes with `curveName`, -and appear per day in `get_schedules` and in the preview's `replaces`. - -Saved curves (`curve_templates` table, `curveTemplates` router) are named set point lists in °F with a -bedtime, wake and notes, not tied to a side or day. `manage_saved_curve` saves (replacing a curve of the -same name) or deletes one; `get_saved_curves` lists them. The Schedule screen lists them under "Saved curves" -(opening one starts a new curve with its set points and notes), the AI curve wizard saves into the same table, -and curves older versions kept in browser localStorage move to the pod on first load. +Every recurring curve lives in the schedule library (`curve_templates` table, `curveTemplates` router): a +named set point list in °F with a bedtime, wake, notes, `endAction`, its `days` per side and an `active` flag. +An active curve runs on its days; two active curves never share a side and day. Turning a curve on, or giving +it more days, moves shared days to it from the other active curves, and a curve left with no days turns off. +The temperature and power rows the scheduler reads are written from the library by +`src/server/scheduleLibrary.ts` through `planCurveSave` (`src/lib/sleepCurve/savePlan.ts`): each claimed day's +temperature and power rows are replaced, alarms are kept, flat-hold set points are dropped, and the power window +runs from the first to the last set point (`endAction: "maintain"` holds the last temperature). Editing a +temperature or power row one at a time (`manage_schedule`, `schedules.batchUpdate`) releases that day from its +curve, since the rows no longer match it; changing only `enabled` does not. + +`apply_sleep_curve` runs a curve (2-48 `setPoints`) on `side` `left`, `right` or `both` for a list of `days`. +With `setPoints` it saves a new library curve (named by `name`, or after its days, e.g. "Weekdays curve") or +replaces the set points of the curve called `name`; with `curveName` it puts an existing curve on more days. The +curve turns on and keeps its other days. `mode: "preview"` writes nothing and returns the simplified curve, +`powerWindow`, the target `curve` (`name`, `isNew`, resulting `days`), the rows it `replaces` per side and day, +`movesDaysFrom` (days other curves give up), `alsoRewrites` (other days an active curve is rewritten on when its +set points change), `keptAlarms` and `warnings` (away mode, no on/off window); `mode: "apply"` with the same +arguments saves exactly that through `curveTemplates.put`. Optional `notes` (up to 1,000 characters) are stored on +the curve and written to each of its days (`schedule_notes`, shown on the curve's card and per day in +`get_schedules`); a library curve keeps its notes when omitted. + +`get_saved_curves` lists the library with each curve's `active`, `days` and `endAction`. `manage_saved_curve` +`save`s set points under a name (replacing an existing curve's set points and keeping its days; a new curve is +saved off), `activate`s a curve on its saved days or, with `side` and `days`, on those days for that side, +`deactivate`s it (its days stop running; the curve and days are kept) or `delete`s it (its days stop running if it +was on). `get_schedules` reports per side the active `curves` and their days; a day no curve claims was written +one schedule at a time. + +The Schedule screen shows the same library under "Curves", each with an on/off switch, and asks before moving days. +The AI curve wizard saves into it (off, no days), and curves older versions kept in browser localStorage move to +the pod on first load. On upgrade, migration `0028_schedule_library` adds every existing schedule to the library +once (matching days become one active curve, both sides when they match) without changing what the Pod does. The `design_sleep_curve` prompt chains the data tools into a preview using `CURVE_DESIGN_GUIDANCE`, the sleep-science text the copy/paste AI curve wizard also sends. 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 40d7d94e5..6673bb66e 100644 --- a/src/mcp/tests/server.test.ts +++ b/src/mcp/tests/server.test.ts @@ -7,7 +7,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' import { TRPCError } from '@trpc/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const callerMock = vi.hoisted(() => { const fn = () => vi.fn() @@ -24,10 +24,13 @@ const callerMock = vi.hoisted(() => { }, biometrics: { getLatestSleep: fn(), getSleepRecord: fn(), getSleepRecords: fn(), getSleepStages: fn(), getVitalsSummary: fn(), - getMovementSummary: fn(), getVitalsBaseline: fn(), getOccupancy: fn(), importReferenceVitals: fn(), importReferenceStages: fn(), - getReferenceStages: fn(), getReferenceVitals: fn(), getVitals: fn(), + 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(), + listModels: fn(), previewModel: fn(), uploadModel: fn(), selectModel: fn(), setModelActive: fn(), }, - sleepStages: { getClassifier: fn(), previewClassifier: fn(), setClassifier: fn() }, environment: { getSummary: fn(), getAmbientLightSummary: fn(), getLatestBedTemp: fn(), getLatestFreezerTemp: fn(), getLatestAmbientLight: fn(), @@ -42,13 +45,13 @@ const callerMock = vi.hoisted(() => { delete: fn(), create: fn(), update: fn(), }, databases: { checkIntegrity: fn() }, - curveTemplates: { list: fn(), save: fn(), delete: fn() }, + curveTemplates: { list: fn(), save: fn(), put: fn(), setActive: fn(), delete: fn() }, biometricShadow: { list: fn(), get: fn(), stop: fn(), report: fn(), compare: fn(), create: fn(), templates: fn(), prepare: fn() }, biometricTuning: { getReplayAdmission: fn(), listRawSources: fn(), inspectRawSource: fn(), createWaveformDataset: fn(), getManifest: fn(), getProfileOverview: fn(), listSessions: fn(), getSessionCapabilities: fn(), listImports: fn(), getProfileRevision: fn(), listProfileRevisions: fn(), compareReadings: fn(), deleteImport: fn(), releaseDataset: fn(), releaseBacktestResult: fn(), getActivationStatus: fn(), listActivationHistory: fn(), previewImport: fn(), commitImport: fn(), - createProfileRevision: fn(), createDataset: fn(), getDataset: fn(), listDatasets: fn(), createExperiment: fn(), + createProfileRevision: fn(), createDataset: fn(), getDataset: fn(), describeDatasetExport: fn(), describeAnalysisExport: fn(), listDatasets: fn(), createExperiment: fn(), getExperiment: fn(), listExperiments: fn(), enqueueBacktest: fn(), getBacktest: fn(), cancelBacktest: fn(), listBacktests: fn(), getBacktestSummary: fn(), getBacktestTrace: fn(), getBacktestNumericTrace: fn(), getBacktestReferenceTrace: fn(), activateProfile: fn(), activateValidatedProfile: fn(), rollbackProfile: fn(), resetProfile: fn(), }, @@ -107,16 +110,21 @@ describe('sleepypod MCP server', () => { }) callerMock.device.getStatus.mockResolvedValue(STATUS) callerMock.schedules.getNotes.mockResolvedValue([]) + callerMock.curveTemplates.list.mockResolvedValue([]) }) it('exposes the curated tool, resource and prompt surface', async () => { 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', + 'compare_vitals_to_watch', 'diagnose_pod', + 'export_analysis_snapshot', + 'export_calibration_dataset', 'get_automations', 'get_bed_occupancy', 'get_environment', @@ -128,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', @@ -139,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', ]) @@ -389,6 +401,11 @@ describe('sleepypod MCP server', () => { { time: '23:30', temperature: 20 }, { time: '01:00', temperature: 20 }, ] + const NIGHT = [{ time: '22:00', temperature: 70 }, { time: '06:30', temperature: 80 }] + const libraryCurve = (over: Record = {}) => ({ + id: 4, name: 'Late Warm', bedtime: '00:45', wake: '10:30', points: { '10:30': 85, '00:45': 83, '02:30': 78 }, notes: 'Warm sleeper', + endAction: 'turn_off', days: { left: [], right: [] }, active: false, ...over, + }) beforeEach(() => { callerMock.schedules.getAll.mockImplementation(async ({ side }: { side: string }) => side === 'left' @@ -398,117 +415,170 @@ describe('sleepypod MCP server', () => { alarm: [{ id: 9, side: 'left', dayOfWeek: 'monday', time: '07:00' }], } : { temperature: [{ ...row(3, 'monday', '21:00', 18), side: 'right' }], power: [], alarm: [] }) - callerMock.schedules.batchUpdate.mockResolvedValue({ success: true }) + callerMock.curveTemplates.put.mockResolvedValue({ curve: {}, moved: [] }) }) - it('previews without writing: simplified curve, on/off window and replaced rows', async () => { + it('previews without writing: simplified curve, on/off window, replaced rows and the new library curve', async () => { const client = await connect() const result = await client.callTool({ name: 'apply_sleep_curve', arguments: { mode: 'preview', side: 'left', days: ['monday', 'monday'], setPoints: curve, unit: 'C' }, }) as CallToolResult expect(result.isError).toBeFalsy() + expect(callerMock.curveTemplates.put).not.toHaveBeenCalled() expect(callerMock.schedules.batchUpdate).not.toHaveBeenCalled() expect(callerMock.schedules.getAll).toHaveBeenCalledWith({ side: 'left', unit: 'C' }) const body = parse(result) as Record expect(body).toMatchObject({ dryRun: true, unit: 'C', + curve: { name: 'Mon curve', isNew: true, days: { left: ['monday'], right: [] } }, sides: ['left'], days: ['monday'], droppedFlatPoints: 1, powerWindow: { onTime: '22:00', offTime: '06:30', endAction: 'turn_off' }, replaces: { left: { monday: { setPoints: [{ time: '22:00', temperature: 20 }], power: [{ onTime: '22:00', offTime: '07:00' }] } } }, + movesDaysFrom: [], keptAlarms: 1, - rowsToDelete: 2, - rowsToCreate: 5, warnings: [], }) + expect(body).not.toHaveProperty('alsoRewrites') expect((body.setPoints as Array<{ time: string }>).map(p => p.time)).toEqual(['22:00', '23:00', '01:00', '06:30']) expect((body.replaces as Record).left).not.toHaveProperty('tuesday') }) - it('applies to both sides in one batch, replacing only the target days', async () => { + it('saves new set points as an active library curve on both sides', async () => { const client = await connect() const result = await client.callTool({ name: 'apply_sleep_curve', - arguments: { mode: 'apply', side: 'both', days: ['monday'], setPoints: [{ time: '22:00', temperature: 70 }, { time: '06:30', temperature: 80 }], endAction: 'maintain' }, + arguments: { mode: 'apply', side: 'both', days: ['monday'], setPoints: NIGHT, endAction: 'maintain', name: 'Cool Night' }, }) as CallToolResult expect(result.isError).toBeFalsy() - expect(callerMock.schedules.batchUpdate).toHaveBeenCalledTimes(1) - const batch = callerMock.schedules.batchUpdate.mock.calls[0][0] - expect(batch.deletes).toEqual({ temperature: [1, 3], power: [7] }) - expect(batch.creates.temperature).toHaveLength(4) - expect(batch.creates.power).toEqual([ - { side: 'left', dayOfWeek: 'monday', onTime: '22:00', offTime: '06:30', onTemperature: 70, endAction: 'maintain', enabled: true }, - { side: 'right', dayOfWeek: 'monday', onTime: '22:00', offTime: '06:30', onTemperature: 70, endAction: 'maintain', enabled: true }, - ]) - expect(parse(result)).toMatchObject({ saved: true, rowsDeleted: 3, rowsCreated: 6, warnings: ['Away mode is on for right: schedules will not fire until it is turned off.'] }) + expect(callerMock.schedules.batchUpdate).not.toHaveBeenCalled() + expect(callerMock.curveTemplates.put).toHaveBeenCalledWith({ + id: undefined, + name: 'Cool Night', + bedtime: '22:00', + wake: '06:30', + points: { '22:00': 70, '06:30': 80 }, + notes: '', + endAction: 'maintain', + days: { left: ['monday'], right: ['monday'] }, + active: true, + }) + expect(parse(result)).toMatchObject({ + saved: true, + curve: { name: 'Cool Night', isNew: true }, + powerWindow: { onTime: '22:00', offTime: '06:30', onTemperature: 70, endAction: 'maintain' }, + warnings: ['Away mode is on for right: schedules will not fire until it is turned off.'], + }) }) - it('fails the preview when the batch would exceed the 1,000-row limit', async () => { - callerMock.schedules.getAll.mockResolvedValue({ - temperature: Array.from({ length: 1001 }, (_, i) => row(i + 1, 'monday', '22:00', 20)), - power: [], - alarm: [], - }) + it('names a new curve after its days and avoids taken names', async () => { + callerMock.curveTemplates.list.mockResolvedValue([libraryCurve({ id: 1, name: 'Weekdays curve' })]) const client = await connect() const result = await client.callTool({ name: 'apply_sleep_curve', - arguments: { mode: 'preview', side: 'left', days: ['monday'], setPoints: [{ time: '22:00', temperature: 70 }, { time: '06:30', temperature: 80 }] }, + arguments: { mode: 'preview', side: 'left', days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], setPoints: NIGHT }, }) as CallToolResult - expect(result.isError).toBe(true) - expect(text(result)).toContain('1001 temperature deletes exceed the 1000-row batch limit') + expect(parse(result)).toMatchObject({ curve: { name: 'Weekdays curve 2', isNew: true } }) }) - it('applies a saved curve by name and requires exactly one curve source', async () => { - callerMock.curveTemplates.list.mockResolvedValue([{ name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: { '10:30': 85, '00:45': 83, '02:30': 78 } }]) + it('runs a library curve by name on more days, moving them from the curve running them', async () => { + callerMock.curveTemplates.list.mockResolvedValue([ + libraryCurve({ days: { left: ['saturday'], right: [] }, active: true }), + libraryCurve({ id: 5, name: 'Weeknight', points: { '22:00': 70, '06:00': 80 }, days: { left: ['thursday', 'friday'], right: [] }, active: true }), + ]) const client = await connect() - const result = await client.callTool({ + const preview = await client.callTool({ name: 'apply_sleep_curve', arguments: { mode: 'preview', side: 'left', days: ['friday'], curveName: ' Late Warm ' }, }) as CallToolResult - expect(parse(result)).toMatchObject({ - curveName: 'Late Warm', + expect(parse(preview)).toMatchObject({ + curve: { name: 'Late Warm', isNew: false, days: { left: ['friday', 'saturday'], right: [] } }, setPoints: [{ time: '00:45', temperature: 83 }, { time: '02:30', temperature: 78 }, { time: '10:30', temperature: 85 }], powerWindow: { onTime: '00:45', offTime: '10:30' }, + notes: 'Warm sleeper', + movesDaysFrom: [{ name: 'Weeknight', side: 'left', days: ['friday'], deactivated: false }], }) + await client.callTool({ name: 'apply_sleep_curve', arguments: { mode: 'apply', side: 'left', days: ['friday'], curveName: 'Late Warm' } }) + expect(callerMock.curveTemplates.put).toHaveBeenCalledWith({ + id: 4, + name: 'Late Warm', + bedtime: '00:45', + wake: '10:30', + points: { '00:45': 83, '02:30': 78, '10:30': 85 }, + notes: 'Warm sleeper', + endAction: 'turn_off', + days: { left: ['friday', 'saturday'], right: [] }, + active: true, + }) + + await client.callTool({ name: 'apply_sleep_curve', arguments: { mode: 'apply', side: 'left', days: ['friday'], curveName: 'Late Warm', notes: '' } }) + expect(callerMock.curveTemplates.put.mock.calls[1][0]).toMatchObject({ notes: '' }) + }) + + it('runs a curve that was off on just the given days, not its old saved days', async () => { + callerMock.curveTemplates.list.mockResolvedValue([libraryCurve({ days: { left: ['saturday', 'sunday'], right: ['monday'] }, active: false })]) + const client = await connect() + const result = await client.callTool({ + name: 'apply_sleep_curve', + arguments: { mode: 'preview', side: 'left', days: ['friday'], curveName: 'Late Warm' }, + }) as CallToolResult + expect(parse(result)).toMatchObject({ curve: { name: 'Late Warm', isNew: false, days: { left: ['friday'], right: [] } } }) + }) + + it('keeps generated names within the 30-character limit', async () => { + callerMock.curveTemplates.list.mockResolvedValue([libraryCurve({ name: 'Mon, Tue, Wed, Thu, Fri, Sun c' })]) + const client = await connect() + const result = await client.callTool({ + name: 'apply_sleep_curve', + arguments: { mode: 'preview', side: 'left', days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'sunday'], setPoints: NIGHT }, + }) as CallToolResult + expect(parse(result)).toMatchObject({ curve: { name: 'Mon, Tue, Wed, Thu, Fri, Sun 2', isNew: true } }) + }) + + it('reports the other days an active curve rewrites when its set points change', async () => { + callerMock.curveTemplates.list.mockResolvedValue([libraryCurve({ days: { left: ['saturday', 'sunday'], right: ['sunday'] }, active: true })]) + const client = await connect() + const result = await client.callTool({ + name: 'apply_sleep_curve', + arguments: { mode: 'preview', side: 'left', days: ['sunday'], setPoints: NIGHT, name: 'Late Warm' }, + }) as CallToolResult + expect(parse(result)).toMatchObject({ + curve: { name: 'Late Warm', isNew: false }, + alsoRewrites: { left: ['saturday'], right: ['sunday'] }, + notes: 'Warm sleeper', + }) + }) + + it('requires exactly one curve source and a known curve name', async () => { + callerMock.curveTemplates.list.mockResolvedValue([libraryCurve()]) + const client = await connect() const missing = await client.callTool({ name: 'apply_sleep_curve', arguments: { mode: 'preview', side: 'left', days: ['friday'], curveName: 'Nope' } }) as CallToolResult - expect(text(missing)).toContain('No saved curve named "Nope"') + expect(text(missing)).toContain('No curve named "Nope"') const both = await client.callTool({ name: 'apply_sleep_curve', - arguments: { mode: 'preview', side: 'left', days: ['friday'], curveName: 'Late Warm', setPoints: [{ time: '22:00', temperature: 70 }, { time: '06:00', temperature: 80 }] }, + arguments: { mode: 'preview', side: 'left', days: ['friday'], curveName: 'Late Warm', setPoints: NIGHT }, }) as CallToolResult expect(text(both)).toContain('exactly one of setPoints or curveName') - expect(callerMock.schedules.batchUpdate).not.toHaveBeenCalled() + const named = await client.callTool({ + name: 'apply_sleep_curve', + arguments: { mode: 'preview', side: 'left', days: ['friday'], curveName: 'Late Warm', name: 'Other' }, + }) as CallToolResult + expect(text(named)).toContain('name only goes with setPoints') + expect(callerMock.curveTemplates.put).not.toHaveBeenCalled() }) - it('writes notes for every target day, shows the notes it replaces, and defaults to a saved curve\'s notes', async () => { + it('shows the notes it replaces and trims the new ones', async () => { callerMock.schedules.getNotes.mockImplementation(async ({ side }: { side: string }) => side === 'left' ? [{ dayOfWeek: 'monday', notes: 'Old curve' }] : []) const client = await connect() const preview = await client.callTool({ name: 'apply_sleep_curve', - arguments: { mode: 'preview', side: 'left', days: ['monday'], setPoints: [{ time: '22:00', temperature: 70 }, { time: '06:30', temperature: 80 }], notes: ' From 14 nights ' }, + arguments: { mode: 'preview', side: 'left', days: ['monday'], setPoints: NIGHT, notes: ' From 14 nights ' }, }) as CallToolResult expect(parse(preview)).toMatchObject({ notes: 'From 14 nights', replaces: { left: { monday: { notes: 'Old curve' } } } }) - - await client.callTool({ - name: 'apply_sleep_curve', - arguments: { mode: 'apply', side: 'both', days: ['monday', 'tuesday'], setPoints: [{ time: '22:00', temperature: 70 }, { time: '06:30', temperature: 80 }], notes: 'From 14 nights' }, - }) - expect(callerMock.schedules.batchUpdate.mock.calls[0][0].notes).toEqual([ - { side: 'left', dayOfWeek: 'monday', notes: 'From 14 nights' }, - { side: 'left', dayOfWeek: 'tuesday', notes: 'From 14 nights' }, - { side: 'right', dayOfWeek: 'monday', notes: 'From 14 nights' }, - { side: 'right', dayOfWeek: 'tuesday', notes: 'From 14 nights' }, - ]) - - callerMock.curveTemplates.list.mockResolvedValue([{ name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: { '00:45': 83, '10:30': 85 }, notes: 'Warm sleeper' }]) - await client.callTool({ name: 'apply_sleep_curve', arguments: { mode: 'apply', side: 'left', days: ['friday'], curveName: 'Late Warm' } }) - expect(callerMock.schedules.batchUpdate.mock.calls[1][0].notes).toEqual([{ side: 'left', dayOfWeek: 'friday', notes: 'Warm sleeper' }]) - await client.callTool({ name: 'apply_sleep_curve', arguments: { mode: 'apply', side: 'left', days: ['friday'], curveName: 'Late Warm', notes: '' } }) - expect(callerMock.schedules.batchUpdate.mock.calls[2][0].notes).toEqual([{ side: 'left', dayOfWeek: 'friday', notes: null }]) }) it('rejects out-of-range and duplicate set points before reading schedules', async () => { @@ -526,13 +596,14 @@ describe('sleepypod MCP server', () => { expect(dup.isError).toBe(true) expect(text(dup)).toContain('Two set points at 22:00') expect(callerMock.schedules.getAll).not.toHaveBeenCalled() - expect(callerMock.schedules.batchUpdate).not.toHaveBeenCalled() + expect(callerMock.curveTemplates.put).not.toHaveBeenCalled() }) }) - it('get_saved_curves lists saved curves chronologically in the requested unit', async () => { + it('get_saved_curves lists the library chronologically in the requested unit', async () => { callerMock.curveTemplates.list.mockResolvedValue([{ id: 1, name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: { '10:30': 86, '23:30': 77 }, notes: 'r', + endAction: 'maintain', days: { left: ['friday'], right: [] }, active: true, createdAt: new Date('2026-10-13T00:00:00Z'), updatedAt: new Date('2026-10-13T01:00:00Z'), }]) const client = await connect() @@ -540,40 +611,103 @@ describe('sleepypod MCP server', () => { expect(parse(result)).toEqual({ unit: 'C', curves: [{ - name: 'Late Warm', bedtime: '01:30', wake: '10:30', notes: 'r', updatedAt: '2026-10-13T01:00:00.000Z', + name: 'Late Warm', active: true, days: { left: ['friday'], right: [] }, endAction: 'maintain', + bedtime: '01:30', wake: '10:30', notes: 'r', updatedAt: '2026-10-13T01:00:00.000Z', setPoints: [{ time: '23:30', temperature: 25 }, { time: '10:30', temperature: 30 }], }], }) }) - it('manage_saved_curve saves in °F, reports replacement and deletes by name', async () => { - callerMock.curveTemplates.list.mockResolvedValue([{ name: 'Late Warm' }]) - callerMock.curveTemplates.save.mockImplementation(async (input: Record) => ({ ...input, name: 'Late Warm' })) - const client = await connect() - const saved = await client.callTool({ - name: 'manage_saved_curve', - arguments: { action: 'save', name: 'Late Warm', bedtime: '01:30', wake: '10:30', setPoints: [{ time: '00:45', temperature: 28 }, { time: '10:30', temperature: 29.5 }], unit: 'C' }, - }) as CallToolResult - expect(callerMock.curveTemplates.save).toHaveBeenCalledWith({ - name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: { '00:45': 82, '10:30': 85 }, notes: '', + describe('manage_saved_curve', () => { + const LATE_WARM = { + id: 4, name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: { '00:45': 82, '10:30': 85 }, notes: 'n', + endAction: 'turn_off', days: { left: ['friday'], right: [] }, active: false, + } + + it('saves in °F with an end action, reports replacement, and saves a new curve off', async () => { + callerMock.curveTemplates.list.mockResolvedValue([LATE_WARM]) + callerMock.curveTemplates.save.mockImplementation(async (input: Record) => ({ ...input, days: LATE_WARM.days, active: false })) + const client = await connect() + const saved = await client.callTool({ + name: 'manage_saved_curve', + arguments: { action: 'save', name: ' Late Warm ', bedtime: '01:30', wake: '10:30', setPoints: [{ time: '00:45', temperature: 28 }, { time: '10:30', temperature: 29.5 }], endAction: 'maintain', unit: 'C' }, + }) as CallToolResult + expect(callerMock.curveTemplates.save).toHaveBeenCalledWith({ + name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: { '00:45': 82, '10:30': 85 }, notes: '', endAction: 'maintain', + }) + expect(parse(saved)).toEqual({ + saved: true, name: 'Late Warm', bedtime: '01:30', wake: '10:30', setPointCount: 2, replacedExisting: true, + active: false, days: { left: ['friday'], right: [] }, + }) + + const incomplete = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'save', name: 'X', bedtime: '01:30' } }) as CallToolResult + expect(text(incomplete)).toContain('save needs bedtime, wake and setPoints') }) - expect(parse(saved)).toEqual({ saved: true, name: 'Late Warm', bedtime: '01:30', wake: '10:30', setPointCount: 2, replacedExisting: true }) - const incomplete = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'save', name: 'X', bedtime: '01:30' } }) as CallToolResult - expect(text(incomplete)).toContain('save needs bedtime, wake and setPoints') + it('turns a curve on with its saved days or new ones, and off', async () => { + callerMock.curveTemplates.list.mockResolvedValue([LATE_WARM]) + const moved = [{ name: 'Weeknight', side: 'left', days: ['friday'], deactivated: false }] + callerMock.curveTemplates.setActive.mockImplementation(async ({ active }: { active: boolean }) => ({ curve: { ...LATE_WARM, active }, moved: active ? moved : [] })) + callerMock.curveTemplates.put.mockImplementation(async (input: Record) => ({ curve: { ...input }, moved: [] })) + const client = await connect() + + const on = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'activate', name: 'Late Warm' } }) as CallToolResult + expect(callerMock.curveTemplates.setActive).toHaveBeenCalledWith({ id: 4, active: true }) + expect(parse(on)).toEqual({ name: 'Late Warm', active: true, days: { left: ['friday'], right: [] }, movedDaysFrom: moved }) + + const off = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'deactivate', name: 'Late Warm' } }) as CallToolResult + expect(callerMock.curveTemplates.setActive).toHaveBeenLastCalledWith({ id: 4, active: false }) + expect(parse(off)).toMatchObject({ active: false, movedDaysFrom: [] }) + + const days = await client.callTool({ + name: 'manage_saved_curve', + arguments: { action: 'activate', name: 'Late Warm', side: 'both', days: ['saturday', 'sunday', 'saturday'] }, + }) as CallToolResult + expect(callerMock.curveTemplates.put).toHaveBeenCalledWith({ + id: 4, name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: LATE_WARM.points, notes: 'n', endAction: 'turn_off', + days: { left: ['sunday', 'saturday'], right: ['sunday', 'saturday'] }, + active: true, + }) + expect(parse(days)).toMatchObject({ name: 'Late Warm', active: true, movedDaysFrom: [] }) - callerMock.curveTemplates.delete.mockResolvedValueOnce({ deleted: true }).mockResolvedValueOnce({ deleted: false }) - const del = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'delete', name: 'Late Warm' } }) as CallToolResult - expect(text(del)).toBe('Deleted saved curve "Late Warm".') - const none = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'delete', name: 'Late Warm' } }) as CallToolResult - expect(text(none)).toBe('No saved curve named "Late Warm".') + const right = await client.callTool({ + name: 'manage_saved_curve', + arguments: { action: 'activate', name: 'Late Warm', side: 'right', days: ['monday'] }, + }) as CallToolResult + expect(right.isError).toBeFalsy() + expect(callerMock.curveTemplates.put.mock.calls[1][0].days).toEqual({ left: ['friday'], right: ['monday'] }) + + const half = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'activate', name: 'Late Warm', side: 'left' } }) as CallToolResult + expect(text(half)).toContain('Give side and days together') + const unknown = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'deactivate', name: 'Nope' } }) as CallToolResult + expect(text(unknown)).toContain('No curve named "Nope"') + }) + + it('deletes by name', async () => { + callerMock.curveTemplates.delete.mockResolvedValueOnce({ deleted: true }).mockResolvedValueOnce({ deleted: false }) + const client = await connect() + const del = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'delete', name: 'Late Warm' } }) as CallToolResult + expect(callerMock.curveTemplates.delete).toHaveBeenCalledWith({ name: 'Late Warm' }) + expect(text(del)).toBe('Deleted curve "Late Warm".') + const none = await client.callTool({ name: 'manage_saved_curve', arguments: { action: 'delete', name: 'Late Warm' } }) as CallToolResult + expect(text(none)).toBe('No curve named "Late Warm".') + }) }) - it('get_schedules reports both sides with away mode and always-on', async () => { + it('get_schedules reports both sides with away mode, always-on and the library curves running them', async () => { const client = await connect() callerMock.schedules.getAll.mockResolvedValue({ temperature: [], power: [], alarm: [] }) + callerMock.curveTemplates.list.mockResolvedValue([ + { name: 'Weeknight', active: true, days: { left: ['monday', 'tuesday'], right: [] } }, + { name: 'Off', active: false, days: { left: ['friday'], right: ['friday'] } }, + { name: 'Both', active: true, days: { left: ['saturday'], right: ['saturday'] } }, + ]) const result = await client.callTool({ name: 'get_schedules', arguments: {} }) as CallToolResult - expect(parse(result)).toMatchObject({ unit: 'F', left: { awayMode: false }, right: { awayMode: true, alarm: [] } }) + expect(parse(result)).toMatchObject({ + unit: 'F', + left: { awayMode: false, curves: [{ name: 'Weeknight', days: ['monday', 'tuesday'] }, { name: 'Both', days: ['saturday'] }] }, + right: { awayMode: true, alarm: [], curves: [{ name: 'Both', days: ['saturday'] }] }, + }) expect(callerMock.schedules.getAll).toHaveBeenCalledTimes(2) expect(callerMock.settings.getAll).toHaveBeenCalledTimes(1) }) @@ -1047,15 +1181,41 @@ it('imports stage-only Watch data and rejects empty import requests', async () = const result = await client.callTool({ name: 'import_apple_watch_data', arguments: { side: 'left', segments: [{ start: 1791700000, end: 1791700060, stage: 'deep' }], } }) as CallToolResult - expect(JSON.parse(text(result))).toEqual({ vitals: 0, stages: 1, stageError: null }) + expect(JSON.parse(text(result))).toEqual({ vitals: 0, breathing: 0, hrv: 0, stages: 1, stageError: null }) const empty = await client.callTool({ name: 'import_apple_watch_data', arguments: { side: 'left' } }) as CallToolResult expect(empty.isError).toBe(true) - expect(text(empty)).toContain('Provide samples or segments') + expect(text(empty)).toContain('Provide samples, breathingRates, hrvSdnn or segments') callerMock.biometrics.importReferenceVitals.mockResolvedValue({ written: 1 }) const vitalsOnly = await client.callTool({ name: 'import_apple_watch_data', arguments: { side: 'left', samples: [{ timestamp: 1791700000, heartRate: 60 }], } }) as CallToolResult - expect(JSON.parse(text(vitalsOnly))).toEqual({ vitals: 1, stages: 0, stageError: null }) + expect(JSON.parse(text(vitalsOnly))).toEqual({ vitals: 1, breathing: 0, hrv: 0, stages: 0, stageError: null }) + } + finally { await client.close() } +}) + +it('imports Watch breathing rate and HRV per metric through importReferenceSamples', async () => { + callerMock.biometrics.importReferenceSamples.mockReset() + callerMock.biometrics.importReferenceSamples.mockResolvedValueOnce({ written: 2 }).mockResolvedValueOnce({ written: 1 }) + const client = await connect() + try { + const result = await client.callTool({ name: 'import_apple_watch_data', arguments: { + side: 'left', + breathingRates: [{ timestamp: 1791700000, value: 14.5 }, { timestamp: 1791700300, value: 15 }], + hrvSdnn: [{ timestamp: 1791700000, value: 48 }], + } }) as CallToolResult + expect(JSON.parse(text(result))).toEqual({ vitals: 0, breathing: 2, hrv: 1, stages: 0, stageError: null }) + expect(callerMock.biometrics.importReferenceSamples).toHaveBeenNthCalledWith(1, { side: 'left', source: 'apple_watch', metric: 'breathing_rate', + samples: [{ timestamp: 1791700000, value: 14.5 }, { timestamp: 1791700300, value: 15 }] }) + expect(callerMock.biometrics.importReferenceSamples).toHaveBeenNthCalledWith(2, { side: 'left', source: 'apple_watch', metric: 'hrv_sdnn', + samples: [{ timestamp: 1791700000, value: 48 }] }) + const implausible = await client.callTool({ name: 'import_apple_watch_data', arguments: { + side: 'left', breathingRates: [{ timestamp: 1791700000, value: 90 }], + } }) as CallToolResult + expect(implausible.isError).toBe(true) + const empty = await client.callTool({ name: 'import_apple_watch_data', arguments: { side: 'left', samples: [], breathingRates: [], hrvSdnn: [] } }) as CallToolResult + expect(empty.isError).toBe(true) + expect(callerMock.biometrics.importReferenceSamples).toHaveBeenCalledTimes(2) } finally { await client.close() } }) @@ -1116,6 +1276,21 @@ it('routes every remaining inspection, experiment, backtest and release action t await manage({ action: 'list_experiments', input: { side: 'right' } }) expect(api.listExperiments).toHaveBeenCalledWith({ side: 'right' }) + const download = { method: 'GET', path: `/api/biometric-tuning/datasets/${id}/export`, contentType: 'application/x-tar', bytes: 4096 } + api.describeDatasetExport.mockResolvedValue({ index: { version: 'calibration-dataset-bundle-v1', files: [] }, download }) + expect(await call('export_calibration_dataset', { id })).toMatchObject({ download, next: expect.stringContaining('biometrics:local-fit') }) + expect(api.describeDatasetExport).toHaveBeenCalledWith({ id }) + expect((await client.listTools()).tools.find(tool => tool.name === 'export_calibration_dataset')?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }) + + const snapshot = { window: { startMs: 1791612000000, endMs: 1791640800000, includeRaw: true }, archiveCount: 3, rawBytes: 900, maxRawBytes: 2 * 1024 ** 3, estimatedBytes: 4096, + download: { method: 'GET', path: '/api/biometrics/analysis-export?start=1791612000000&end=1791640800000&raw=1', contentType: 'application/x-tar' } } + api.describeAnalysisExport.mockResolvedValue(snapshot) + expect(await call('export_analysis_snapshot', { start: '2026-10-10T06:00:00.000Z', end: '1791640800000' })).toEqual(snapshot) + expect(api.describeAnalysisExport).toHaveBeenCalledWith({ start: '2026-10-10T06:00:00.000Z', end: '1791640800000', includeRaw: true }) + await call('export_analysis_snapshot', { start: '1791612000000', end: '1791640800000', includeRaw: false }) + expect(api.describeAnalysisExport).toHaveBeenLastCalledWith({ start: '1791612000000', end: '1791640800000', includeRaw: false }) + expect((await client.listTools()).tools.find(tool => tool.name === 'export_analysis_snapshot')?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }) + const backtest = (request: Record) => call('backtest_biometrics', { request }) api.getBacktest.mockResolvedValue({ id, state: 'running' }) expect(await backtest({ action: 'status', input: { id } })).toEqual({ id, state: 'running' }) @@ -1216,6 +1391,10 @@ describe('sleep stage tuning tools', () => { beforeEach(() => { vi.clearAllMocks() + // Keep the October reference fixtures inside the rolling 14-day window. + // Leave transport timers real so MCP requests can complete normally. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-13T13:00:00Z')) callerMock.sleepStages.getClassifier.mockResolvedValue(classifierState()) callerMock.biometrics.getReferenceStages.mockResolvedValue([]) callerMock.biometrics.getSleepRecords.mockResolvedValue([]) @@ -1223,6 +1402,10 @@ describe('sleep stage tuning tools', () => { callerMock.biometrics.getVitals.mockResolvedValue([]) }) + afterEach(() => { + vi.useRealTimers() + }) + async function call(name: string, args: Record) { const client = await connect() try { @@ -1422,6 +1605,20 @@ describe('sleep stage tuning tools', () => { }) }) + it('get_sleep_stage_rules clips reference epochs to the rolling coverage window', async () => { + vi.setSystemTime(new Date('2026-10-26T09:00:00Z')) + const expired = { ...segment, id: 2, start: new Date('2026-10-11T06:00:00Z'), end: new Date('2026-10-11T12:00:00Z') } + callerMock.biometrics.getReferenceStages.mockResolvedValue([segment, expired]) + callerMock.biometrics.getSleepRecords.mockResolvedValue([record]) + + const result = parse(await call('get_sleep_stage_rules', { side: 'left' })) + expect(result).toMatchObject({ referenceCoverage: { epochs: 36 } }) + expect(callerMock.biometrics.getReferenceStages).toHaveBeenCalledWith(expect.objectContaining({ + startDate: new Date('2026-10-12T09:00:00Z'), + endDate: new Date('2026-10-26T09:00:00Z'), + })) + }) + it('get_sleep_stage_rules marks coverage as truncated when a query hits its row cap', async () => { callerMock.biometrics.getReferenceStages.mockResolvedValue([segment]) callerMock.biometrics.getSleepRecords.mockResolvedValue(Array.from({ length: 100 }, (_, i) => ({ ...record, id: i + 1 }))) @@ -1452,6 +1649,41 @@ describe('sleep stage tuning tools', () => { expect(good).toMatchObject({ referenceCoverage: { heartRateAgreement: { correlation: 1, meanAbsErrorBpm: 1 } }, hint: null }) }) + it('compare_vitals_to_watch passes the window through read-only and hints when there is nothing to compare', async () => { + const empty = { n: 0, watchSamples: 0, coverage: null, mae: null, bias: null, p90: null, r: null } + const hr = { n: 80, watchSamples: 85, coverage: 0.941, mae: 2.1, bias: 0.6, p90: 4, r: 0.63 } + const report = { + side: 'left', startDate: new Date('2026-10-01T00:00:00Z'), endDate: new Date('2026-10-15T00:00:00Z'), minHours: 4, + classifier: { isDefault: true, source: 'default', generation: null, stageHash: 'h' }, hrvNote: 'not SDNN', + nights: [{ sleepRecordId: 42, enteredBedAt, leftBedAt, hours: 8, heartRate: hr, breathingRate: empty, hrv: empty, stages: null }], + summary: { nights: 1, heartRate: hr, breathingRate: empty, hrv: empty, stages: null }, + } + callerMock.biometrics.compareToWatch.mockResolvedValue(report) + const result = parse(await call('compare_vitals_to_watch', { side: 'left', startDate: '2026-10-01', endDate: '2026-10-15', minHours: 4 })) + expect(result).toMatchObject({ side: 'left', hrvNote: 'not SDNN', summary: { heartRate: hr }, nights: [{ sleepRecordId: 42 }], hint: null }) + expect(callerMock.biometrics.compareToWatch).toHaveBeenCalledWith({ + side: 'left', minHours: 4, startDate: new Date('2026-10-01'), endDate: new Date('2026-10-15T23:59:59.999Z'), + }) + // A datetime endDate is taken as given; only a date-only one covers its whole day. + await call('compare_vitals_to_watch', { side: 'left', endDate: '2026-10-15T06:00:00Z' }) + expect(callerMock.biometrics.compareToWatch).toHaveBeenLastCalledWith({ side: 'left', minHours: 3, endDate: new Date('2026-10-15T06:00:00Z') }) + + callerMock.biometrics.compareToWatch.mockResolvedValue({ ...report, nights: [{ ...report.nights[0], heartRate: empty }], summary: { ...report.summary, heartRate: empty } }) + expect(parse(await call('compare_vitals_to_watch', { side: 'left' }))).toMatchObject({ hint: expect.stringContaining('import_apple_watch_data') }) + expect(callerMock.biometrics.compareToWatch).toHaveBeenLastCalledWith({ side: 'left', minHours: 3 }) + + callerMock.biometrics.compareToWatch.mockResolvedValue({ ...report, nights: [], summary: { ...report.summary, nights: 0, heartRate: empty } }) + expect(parse(await call('compare_vitals_to_watch', { side: 'right', minHours: 6 }))).toMatchObject({ hint: expect.stringContaining('at least 6 h') }) + + const client = await connect() + try { + const tool = (await client.listTools()).tools.find(t => t.name === 'compare_vitals_to_watch') + expect(tool?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }) + expect(tool?.description).toContain('set_sleep_stage_rules') + } + finally { await client.close() } + }) + it('get_sleep_stage_rules clips segment durations to the coverage window', async () => { const straddling = { ...segment, start: new Date(Date.now() - 20 * 24 * 3_600_000), end: new Date(Date.now() - 14 * 24 * 3_600_000 + 10 * 60_000) } callerMock.biometrics.getReferenceStages.mockResolvedValue([straddling]) @@ -1507,3 +1739,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/biometrics.ts b/src/mcp/tools/biometrics.ts index dde5e7d91..64c4989e2 100644 --- a/src/mcp/tools/biometrics.ts +++ b/src/mcp/tools/biometrics.ts @@ -16,6 +16,7 @@ import { traceRequestSchema } from '@/src/biometrics/backtest/trace-contracts' import { submitBacktestSchema } from '@/src/biometrics/backtest/submission' import { submitShadowSchema } from '@/src/biometrics/shadow/submission-contracts' import { prepareShadowRequestSchema } from '@/src/biometrics/shadow/preparation-contracts' +import { analysisExportRequestSchema } from '@/src/biometrics/analysis-export' const side = z.enum(['left', 'right']) const id = z.object({ id: z.string().uuid() }).strict() @@ -144,6 +145,19 @@ export function registerBiometricTools(server: McpServer) { } })) + server.registerTool('export_calibration_dataset', { + title: 'Export a frozen calibration dataset for off-pod fitting', + description: 'Verify one frozen dataset and every retained RAW archive it references, then return the bundle index (each file with its SHA-256) and the LAN download path that streams the tar. Waveform bytes are never placed in this response. Download only to a user-authorized machine: archives include both sides of the bed. Released datasets are not found. Off-pod results are exploratory; confirm candidates with backtest_biometrics on the pod.', + inputSchema: id.shape, annotations: read, + }, input => runTool(async () => jsonResult({ ...await getCaller().biometricTuning.describeDatasetExport(input), + next: 'pnpm biometrics:local-fit fit BUNDLE CANDIDATES OUTPUT_DIR (see scripts/biometrics-local-fit/README.md)' }))) + + server.registerTool('export_analysis_snapshot', { + title: 'Locate an analysis snapshot of the biometrics database and RAW archives', + description: 'Count the RAW archives for a window (start/end as ISO 8601 or epoch ms, at most 14 days) and return the LAN download path for a tar holding a consistent biometrics.db backup, those archives and a hashed manifest.json, with the archive count and estimated bytes. Bytes are never placed in this response. Windows over 2 GiB of RAW are rejected; narrow the window or set includeRaw false. The snapshot holds the full biometrics database for both sides: download only to a user-authorized machine.', + inputSchema: analysisExportRequestSchema.shape, annotations: read, + }, input => runTool(async () => jsonResult(await getCaller().biometricTuning.describeAnalysisExport(input)))) + server.registerTool('backtest_biometrics', { title: 'Start, inspect or cancel a biometric evaluation', description: 'Durable replay never applies a profile or changes hardware. Start pins the candidate and expected binding generation. Report returns compact evidence and a report resource; trace returns at most 500 exact stage intervals and numeric_trace returns at most 500 exact recomputed readings for the dataset side using a pinned report hash and cursor; reference_trace pages frozen source records without submitted payloads or annotations. Descriptive or insufficient evidence is not validated improvement.', diff --git a/src/mcp/tools/schedules.ts b/src/mcp/tools/schedules.ts index 3a51bf756..4e0b384b6 100644 --- a/src/mcp/tools/schedules.ts +++ b/src/mcp/tools/schedules.ts @@ -20,6 +20,8 @@ import { import { fromSetpointF, resolveUnit, toSetpointF } from '@/src/mcp/units' import { sortChronological } from '@/src/lib/scheduleGrouping' import { CURVE_NOTES_MAX, planCurveSave } from '@/src/lib/sleepCurve/savePlan' +import { LIBRARY_SIDES, movedDays, normalizeDays, planClaims, setPointsToPoints, uniqueCurveName } from '@/src/lib/scheduleLibrary' +import { formatDayRange } from '@/src/components/Schedule/scheduleFormat' import type { TempUnit } from '@/src/lib/tempUtils' const curveSetPoints = z.array(z.object({ time: timeOfDay, temperature })).min(2).max(48) @@ -36,16 +38,15 @@ function toSetPointsF(points: Array<{ time: string, temperature: number }>, unit }) } -const BATCH_ROW_LIMIT = 1000 - export function registerScheduleTools(server: McpServer) { server.registerTool('get_schedules', { title: 'List schedules', description: 'All recurring schedules for one or both sides, grouped as temperature (set a temp at a time), power ' + '(on/off window with a temp) and alarm (vibration wake-up with a temp), each with its id, day of week, ' - + 'time and enabled flag, plus curve notes per day. Also returns per-side away mode and always-on so you can explain why a ' - + 'schedule is not firing. Temperatures in the requested or device-default unit.', + + 'time and enabled flag, plus curve notes per day and the library curves running on that side (curves, by name ' + + 'and days; a day no curve claims was written one schedule at a time). Also returns per-side away mode and ' + + 'always-on so you can explain why a schedule is not firing. Temperatures in the requested or device-default unit.', inputSchema: { side: optionalSide, unit }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, async ({ side: s, unit: u }) => runTool(async () => { @@ -54,6 +55,7 @@ export function registerScheduleTools(server: McpServer) { const resolved = u ?? (settings.device.temperatureUnit === 'C' ? 'C' : 'F') const sides = s ? [s] : (['left', 'right'] as const) const result: Record = { unit: resolved } + const library = await caller.curveTemplates.list({}) for (const sd of sides) { const schedules = await caller.schedules.getAll({ side: sd, unit: resolved }) const sideSettings = settings.sides[sd] @@ -62,6 +64,7 @@ export function registerScheduleTools(server: McpServer) { alwaysOn: sideSettings.alwaysOn, ...schedules, notes: await caller.schedules.getNotes({ side: sd }), + curves: library.filter(c => c.active && c.days[sd].length > 0).map(c => ({ name: c.name, days: c.days[sd] })), } } return jsonResult(result) @@ -167,20 +170,22 @@ export function registerScheduleTools(server: McpServer) { server.registerTool('apply_sleep_curve', { title: 'Preview or save a recurring temperature curve', description: - 'Save a whole night\'s temperature curve as the recurring schedule for one or both sides on the given days, ' - + 'the same way the Schedule screen\'s curve editor does. Each day\'s temperature and power schedules are ' - + 'replaced; alarms are kept. The side turns on at the first set point and off at the last (overnight curves wrap ' - + 'past midnight); endAction "maintain" holds the last temperature instead of turning off. Set points inside a ' - + 'flat hold are dropped. Each row fires on its own weekday, so on "monday" a 01:00 point fires early Monday ' - + 'morning (the end of Sunday night). Call with mode "preview" first: it saves nothing and returns the simplified curve, the ' - + 'on/off window, the schedules it would replace and warnings. Show that to the user, then call again with mode ' - + '"apply" and the same arguments. Design guidance: 8-15 points from about 45 min before bedtime to 30 min after ' - + 'wake; warm-up, cooling ramp, coldest hold in the first deep-sleep hours, gradual pre-wake warming; water takes ' - + '15-20 min to settle; neutral is about 82.5°F. Base it on get_sleep_history, get_vitals_trend and ' - + 'get_schedules when the user asks for a curve from their data. Give setPoints, or curveName to apply a saved ' - + 'curve from get_saved_curves. Add notes saying what the curve is based on and what to watch (shown on the ' - + 'curve\'s card; replaces the days\' previous notes; a saved curve\'s notes are used when omitted). ' - + 'For tonight only, use run_once_curve instead. ' + 'Run a whole night\'s temperature curve on one or both sides on the given days. Every curve lives in the pod\'s ' + + 'schedule library (get_saved_curves): give setPoints to save a new curve (named by name, or after its days) or ' + + 'update the curve called name, or curveName to put an existing curve on more days. The curve turns on: one that ' + + 'was already on gains those days and keeps its others, one that was off runs on just those days. Two curves that are on never share a side and day, so those days move from ' + + 'any other curve running them, and a curve left with no days turns off (the preview lists these moves). Each ' + + 'day\'s temperature and power schedules are replaced; alarms are kept. The side turns on at the first set point ' + + 'and off at the last (overnight curves wrap past midnight); endAction "maintain" holds the last temperature ' + + 'instead of turning off. Set points inside a flat hold are dropped. Each row fires on its own weekday, so on ' + + '"monday" a 01:00 point fires early Monday morning (the end of Sunday night). Call with mode "preview" first: ' + + 'it saves nothing and returns the simplified curve, the on/off window, the schedules it would replace, the ' + + 'days it moves and warnings. Show that to the user, then call again with mode "apply" and the same arguments. ' + + 'Design guidance: 8-15 points from about 45 min before bedtime to 30 min after wake; warm-up, cooling ramp, ' + + 'coldest hold in the first deep-sleep hours, gradual pre-wake warming; water takes 15-20 min to settle; neutral ' + + 'is about 82.5°F. Base it on get_sleep_history, get_vitals_trend and get_schedules when the user asks for a ' + + 'curve from their data. Add notes saying what the curve is based on and what to watch (shown on the curve\'s ' + + 'card; a library curve keeps its notes when omitted). For tonight only, use run_once_curve instead. ' + 'Temperatures use the given unit or the device default.', inputSchema: { mode: z.enum(['preview', 'apply']).describe('"preview" is a dry run; "apply" saves.'), @@ -188,7 +193,9 @@ export function registerScheduleTools(server: McpServer) { days: z.array(dayOfWeek).min(1).max(7).describe('Days of week this curve runs on, e.g. ["monday","tuesday"].'), setPoints: curveSetPoints.optional() .describe('Set points in any order; each holds until the next one fires. Omit when giving curveName.'), - curveName: z.string().optional().describe('Apply this saved curve (see get_saved_curves) instead of setPoints.'), + name: z.string().trim().min(1).max(30).optional() + .describe('With setPoints: the library curve to save them as (replaces that curve\'s set points if it exists).'), + curveName: z.string().optional().describe('Run this library curve (see get_saved_curves) instead of setPoints.'), notes: z.string().max(CURVE_NOTES_MAX).optional().describe('Short note for the curve, e.g. what data it is based on.'), endAction: z.enum(['turn_off', 'maintain']).optional().describe('After the last set point: turn off (default) or keep holding it.'), unit, @@ -201,18 +208,20 @@ export function registerScheduleTools(server: McpServer) { const days = [...new Set(input.days)] if ((input.setPoints === undefined) === (input.curveName === undefined)) throw new Error('Give exactly one of setPoints or curveName') + if (input.curveName !== undefined && input.name !== undefined) throw new Error('name only goes with setPoints; curveName already names the curve') + const library = await caller.curveTemplates.list({}) + const curveName = (input.curveName ?? input.name ?? '').trim() + const existing = curveName ? library.find(t => t.name === curveName) : undefined let setPointsF: Array<{ time: string, temperature: number }> - let notes = input.notes if (input.setPoints) { setPointsF = toSetPointsF(input.setPoints, resolved) } else { - const name = (input.curveName ?? '').trim() - const saved = (await caller.curveTemplates.list({})).find(t => t.name === name) - if (!saved) throw new Error(`No saved curve named "${name}"; call get_saved_curves for the names`) - setPointsF = Object.entries(saved.points).map(([time, temp]) => ({ time, temperature: temp })) - notes ??= saved.notes + if (!existing) throw new Error(`No curve named "${curveName}"; call get_saved_curves for the names`) + setPointsF = Object.entries(existing.points).map(([time, temp]) => ({ time, temperature: temp })) } + const notes = input.notes ?? existing?.notes ?? '' + const endAction = input.endAction ?? existing?.endAction ?? 'turn_off' const settings = await caller.settings.getAll({}) const current = await Promise.all(sides.map(async sd => ({ @@ -224,18 +233,24 @@ export function registerScheduleTools(server: McpServer) { sides: [...sides], targetDays: days, setPoints: setPointsF, - endAction: input.endAction, + endAction, notes, existing: { temperature: current.flatMap(c => c.temperature), power: current.flatMap(c => c.power), }, }) - // schedules.batchUpdate caps each list at 1,000 rows; fail the preview rather than only the apply. - const lists = { 'temperature deletes': plan.deletes.temperature, 'power deletes': plan.deletes.power, 'temperature creates': plan.creates.temperature, 'power creates': plan.creates.power } - for (const [label, rows] of Object.entries(lists)) { - if (rows.length > BATCH_ROW_LIMIT) throw new Error(`${rows.length} ${label} exceed the ${BATCH_ROW_LIMIT}-row batch limit; apply the curve to fewer days at a time`) - } + + // An active curve gains these days; one that is off runs on just these. + const nextDays = normalizeDays(existing?.active ? existing.days : undefined) + for (const sd of sides) nextDays[sd] = [...new Set([...nextDays[sd], ...days])] + const { moves } = planClaims(library, existing?.id ?? null, normalizeDays(nextDays), true) + const taken = new Set(library.filter(t => t.id !== existing?.id).map(t => t.name)) + const name = existing?.name ?? (curveName || uniqueCurveName(`${formatDayRange(days, { named: true })} curve`, taken)) + // The curve was running other days already: they are rewritten too when its set points change. + const alsoRewrites = existing?.active && input.setPoints + ? Object.fromEntries(LIBRARY_SIDES.map(sd => [sd, existing.days[sd].filter(d => !(sides as readonly string[]).includes(sd) || !days.includes(d))]).filter(([, d]) => d.length > 0)) + : {} const daySet = new Set(days) const replaces: Record = {} @@ -265,7 +280,7 @@ export function registerScheduleTools(server: McpServer) { const summary = { unit: resolved, - ...(input.curveName !== undefined && { curveName: input.curveName.trim() }), + curve: { name, isNew: !existing, days: normalizeDays(nextDays) }, sides, days, setPoints: plan.setPoints.map(p => ({ time: p.time, temperature: fromSetpointF(p.temperature, resolved) })), @@ -273,6 +288,8 @@ export function registerScheduleTools(server: McpServer) { powerWindow: plan.power && { ...plan.power, onTemperature: fromSetpointF(plan.power.onTemperature, resolved) }, notes: plan.notes.find(n => n.notes)?.notes ?? null, replaces, + movesDaysFrom: movedDays(moves), + ...(Object.keys(alsoRewrites).length > 0 && { alsoRewrites }), keptAlarms, warnings, } @@ -281,28 +298,33 @@ export function registerScheduleTools(server: McpServer) { return jsonResult({ dryRun: true, ...summary, - rowsToDelete: plan.deletes.temperature.length + plan.deletes.power.length, - rowsToCreate: plan.creates.temperature.length + plan.creates.power.length, apply: 'Call apply_sleep_curve with mode "apply" and the same arguments to save exactly this.', }) } - await caller.schedules.batchUpdate({ deletes: plan.deletes, creates: plan.creates, notes: plan.notes }) - return jsonResult({ - saved: true, - ...summary, - rowsDeleted: plan.deletes.temperature.length + plan.deletes.power.length, - rowsCreated: plan.creates.temperature.length + plan.creates.power.length, + const sorted = plan.setPoints + await caller.curveTemplates.put({ + id: existing?.id, + name, + bedtime: existing && !input.setPoints ? existing.bedtime : sorted[0].time, + wake: existing && !input.setPoints ? existing.wake : sorted[sorted.length - 1].time, + points: setPointsToPoints(sorted), + notes, + endAction, + days: normalizeDays(nextDays), + active: true, }) + return jsonResult({ saved: true, ...summary }) })) server.registerTool('get_saved_curves', { - title: 'List saved curves', + title: 'List the schedule library', description: - 'Named curves saved on the pod (by the AI curve wizard or by manage_saved_curve), newest first. ' - + 'A saved curve is not tied to a side or day and does nothing until applied: pass its name to apply_sleep_curve as ' - + 'curveName, or open it from "Saved curves" on the Schedule screen. Set points are in chronological order from bedtime; temperatures in the ' - + 'requested or device-default unit.', + 'Every curve saved on the pod (by the Schedule screen, the AI curve wizard, apply_sleep_curve or ' + + 'manage_saved_curve), newest first. Each curve has its days per side and whether it is on: a curve that is on ' + + 'runs on its days, and two curves that are on never share a side and day. One that is off (or has no days) does ' + + 'nothing until turned on with manage_saved_curve "activate" or run with apply_sleep_curve curveName. Set points ' + + 'are in chronological order from bedtime; temperatures in the requested or device-default unit.', inputSchema: { unit }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, async ({ unit: u }) => runTool(async () => { @@ -312,6 +334,9 @@ export function registerScheduleTools(server: McpServer) { unit: resolved, curves: curves.map(c => ({ name: c.name, + active: c.active, + days: c.days, + endAction: c.endAction, bedtime: c.bedtime, wake: c.wake, setPoints: sortChronological(Object.entries(c.points).map(([time, temp]) => ({ time, temperature: temp }))) @@ -323,35 +348,67 @@ export function registerScheduleTools(server: McpServer) { })) server.registerTool('manage_saved_curve', { - title: 'Save or delete a named curve', + title: 'Save, turn on, turn off or delete a library curve', description: - 'Save a curve under a name (up to 30 characters) so it can be reused, without touching any schedule; saving an ' - + 'existing name replaces it. It then shows under "Saved curves" on the Schedule screen and can ' - + 'be applied with apply_sleep_curve curveName. "delete" removes the saved curve only; schedules already written ' - + 'from it stay. bedtime and wake label the curve and anchor the editor; set points usually run from about 45 ' - + 'min before bedtime to 30 min after wake. Temperatures use the given unit or the device default.', + 'Manage the schedule library by curve name (up to 30 characters). "save" stores set points under a name, ' + + 'replacing the set points of an existing curve and keeping its days; a curve that is on is rewritten straight ' + + 'away, a new one is saved off. "activate" turns a curve on: on its saved days, or with side and days on those ' + + 'days for that side (replacing its days there). Days another curve that is on runs move to it, and a curve left ' + + 'with no days turns off; the result lists them. "deactivate" turns it off: its days stop running but the curve ' + + 'and its days are kept. "delete" removes the curve, and its days stop running if it was on. bedtime and wake ' + + 'label the curve and anchor the editor; set points usually run from about 45 min before bedtime to 30 min after ' + + 'wake. Temperatures use the given unit or the device default.', inputSchema: { - action: z.enum(['save', 'delete']), - name: z.string().min(1).max(30).describe('Curve name, e.g. "Late Warm". Exact match for delete.'), + action: z.enum(['save', 'activate', 'deactivate', 'delete']), + name: z.string().min(1).max(30).describe('Curve name, e.g. "Late Warm". Exact match except for save.'), bedtime: timeOfDay.optional().describe('Required for save.'), wake: timeOfDay.optional().describe('Required for save.'), setPoints: curveSetPoints.optional().describe('Required for save. 2-48 points in any order.'), notes: z.string().max(CURVE_NOTES_MAX).optional().describe('Optional note: why the curve looks the way it does, what data it is based on.'), + endAction: z.enum(['turn_off', 'maintain']).optional().describe('save: after the last set point, turn off or keep holding it.'), + side: z.enum(['left', 'right', 'both']).optional().describe('activate: the side(s) to set days on; omit to use the saved days.'), + days: z.array(dayOfWeek).min(1).max(7).optional().describe('activate with side: the days it runs on there.'), unit, }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, }, async input => runTool(async () => { const caller = getCaller() + const name = input.name.trim() if (input.action === 'delete') { - const { deleted } = await caller.curveTemplates.delete({ name: input.name }) - return textResult(deleted ? `Deleted saved curve "${input.name.trim()}".` : `No saved curve named "${input.name.trim()}".`) + const { deleted } = await caller.curveTemplates.delete({ name }) + return textResult(deleted ? `Deleted curve "${name}".` : `No curve named "${name}".`) + } + if (input.action === 'activate' || input.action === 'deactivate') { + const curve = (await caller.curveTemplates.list({})).find(t => t.name === name) + if (!curve) throw new Error(`No curve named "${name}"; call get_saved_curves for the names`) + if ((input.side === undefined) !== (input.days === undefined)) throw new Error('Give side and days together, or neither to use the saved days') + const moved = (r: { moved: Array<{ name: string, side: string, days: string[], deactivated: boolean }> }) => r.moved + if (input.action === 'deactivate' || !input.side || !input.days) { + const result = await caller.curveTemplates.setActive({ id: curve.id, active: input.action === 'activate' }) + return jsonResult({ name, active: result.curve.active, days: result.curve.days, movedDaysFrom: moved(result) }) + } + const days = normalizeDays(curve.days) + const sides = input.side === 'both' ? LIBRARY_SIDES : [input.side] + for (const sd of sides) days[sd] = [...new Set(input.days)] + const result = await caller.curveTemplates.put({ + id: curve.id, + name: curve.name, + bedtime: curve.bedtime, + wake: curve.wake, + points: curve.points, + notes: curve.notes, + endAction: curve.endAction, + days: normalizeDays(days), + active: true, + }) + return jsonResult({ name, active: result.curve.active, days: result.curve.days, movedDaysFrom: moved(result) }) } if (!input.bedtime || !input.wake || !input.setPoints) throw new Error('save needs bedtime, wake and setPoints') const resolved = await resolveUnit(input.unit) const points = Object.fromEntries(toSetPointsF(input.setPoints, resolved).map(p => [p.time, p.temperature])) - const replacedExisting = (await caller.curveTemplates.list({})).some(t => t.name === input.name.trim()) + const before = (await caller.curveTemplates.list({})).find(t => t.name === name) const saved = await caller.curveTemplates.save({ - name: input.name, bedtime: input.bedtime, wake: input.wake, points, notes: input.notes ?? '', + name, bedtime: input.bedtime, wake: input.wake, points, notes: input.notes ?? '', endAction: input.endAction, }) return jsonResult({ saved: true, @@ -359,7 +416,9 @@ export function registerScheduleTools(server: McpServer) { bedtime: saved.bedtime, wake: saved.wake, setPointCount: Object.keys(saved.points).length, - replacedExisting, + replacedExisting: before !== undefined, + active: saved.active, + days: saved.days, }) })) } diff --git a/src/mcp/tools/sleep.ts b/src/mcp/tools/sleep.ts index c92abcc33..705e5c65d 100644 --- a/src/mcp/tools/sleep.ts +++ b/src/mcp/tools/sleep.ts @@ -8,6 +8,8 @@ import { getCaller, jsonResult, runTool, type Caller } from '@/src/mcp/caller' import { days, isoDate, optionalSide, side } from '@/src/mcp/schemas' import { STAGE_RULESET_PRESETS, stageRulesetSchema, type StageClassifier, type StageRule, type StageRuleset } from '@/src/lib/sleep-stage-rules' import { scoreAgainstReference, type SleepStage, type StageAgreement } from '@/src/lib/sleep-stages' +import { REFERENCE_SAMPLE_RANGE } from '@/src/server/routers/biometrics' +import { nearestWithin, WATCH_PAIRING_WINDOW_MS } from '@/src/biometrics/references/watch-deviation' const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } @@ -17,8 +19,6 @@ const COVERAGE_DAYS = 14 const SEGMENT_LIMIT = 20000 const RECORD_LIMIT = 100 const VITALS_LIMIT = 100000 -/** Pod and watch heart-rate readings this close in time are compared. */ -const HR_MATCH_S = 60 /** Below this pod-vs-watch heart-rate correlation, HR-ratio rules classify noise. */ const HR_WEAK_CORRELATION = 0.5 @@ -27,6 +27,12 @@ const PRESET_IDS = Object.keys(STAGE_RULESET_PRESETS) as [keyof typeof STAGE_RUL const IMPORT_HINT = `No sleep records overlapping reference stages in the last ${COVERAGE_DAYS} days. Run import_apple_watch_data (or POST /api/biometrics/watch-upload) first; nights without reference stages cannot be scored.` const UNCOVERED_HINT = 'Reference stages exist for these nights but cover none of the classified epochs, so agreement is null. Check the imported segment times.' +const unixSeconds = z.number().int().min(1_000_000_000).max(4_102_444_800) +const referenceSamples = (metric: keyof typeof REFERENCE_SAMPLE_RANGE) => z.array(z.object({ + timestamp: unixSeconds, + value: z.number().min(REFERENCE_SAMPLE_RANGE[metric][0]).max(REFERENCE_SAMPLE_RANGE[metric][1]), +})).max(2000).default([]) + const presetId = z.enum(PRESET_IDS).optional() .describe(`A built-in ruleset preset: ${PRESET_IDS.join(', ')}. Ids come from get_sleep_stage_rules.`) const ruleset = stageRulesetSchema.optional() @@ -40,23 +46,40 @@ const overrides = z.array(z.object({ })).min(1).max(32).optional() .describe('Threshold changes applied to the side\'s saved rules. Everything else in the ruleset stays as saved.') +/** A date-only "YYYY-MM-DD" end bound covers that whole UTC day; datetimes are taken as given. */ +function endOfDateOnly(value: string): Date { + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(Date.parse(value) + DAY_MS - 1) : new Date(value) +} + export function registerSleepTools(server: McpServer) { server.registerTool('import_apple_watch_data', { - title: 'Import Apple Watch heart rate and sleep stages', - description: 'Sync extracted Apple Health records to one sleeper. Send at most 2000 samples/segments per call; repeat calls are safe. ' + title: 'Import Apple Watch heart rate, breathing rate, HRV and sleep stages', + description: 'Sync extracted Apple Health records to one sleeper: heart rate (samples), respiratory rate (breathingRates, breaths/min), ' + + 'HRV SDNN (hrvSdnn, ms) and sleep stages (segments). Send at most 2000 records per array per call; repeat calls are safe. ' + + 'Only Apple Watch records belong here, never ones the sleepypod app wrote to Health (source "sleepypod"). ' + 'For a complete export.zip or export.xml, stream the file with POST /api/biometrics/watch-upload?side=left|right&days=30 ' - + '(application/octet-stream, 512 MiB maximum, days 1..90) instead of placing the export in model context. ' - + 'Stage conflicts are reported without blocking heart-rate import. Records remain separate from pod measurements.', + + '(application/octet-stream, 512 MiB maximum, days 1..90; imports heart rate, breathing rate, HRV and stages) instead of placing the export in model context. ' + + 'Stage conflicts are reported without blocking the other imports. Records remain separate from pod measurements.', inputSchema: { side, - samples: z.array(z.object({ timestamp: z.number().int().min(1_000_000_000).max(4_102_444_800), heartRate: z.number().min(20).max(250) })).max(2000).default([]), - segments: z.array(z.object({ start: z.number().int().min(1_000_000_000).max(4_102_444_800), end: z.number().int().min(1_000_000_000).max(4_102_444_800), stage: z.enum(['wake', 'light', 'deep', 'rem']) })).max(2000).default([]), + samples: z.array(z.object({ timestamp: unixSeconds, heartRate: z.number().min(20).max(250) })).max(2000).default([]), + breathingRates: referenceSamples('breathing_rate'), + hrvSdnn: referenceSamples('hrv_sdnn'), + segments: z.array(z.object({ start: unixSeconds, end: unixSeconds, stage: z.enum(['wake', 'light', 'deep', 'rem']) })).max(2000).default([]), }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, - }, async ({ side: sleeper, samples, segments }) => runTool(async () => { - if (!samples.length && !segments.length) throw new Error('Provide samples or segments. Timestamps are Unix seconds.') + }, async ({ side: sleeper, samples, breathingRates, hrvSdnn, segments }) => runTool(async () => { + if (!samples.length && !breathingRates.length && !hrvSdnn.length && !segments.length) { + throw new Error('Provide samples, breathingRates, hrvSdnn or segments. Timestamps are Unix seconds.') + } const caller = getCaller() const vitals = samples.length ? await caller.biometrics.importReferenceVitals({ side: sleeper, source: 'apple_watch', samples }) : { written: 0 } + const breathing = breathingRates.length + ? await caller.biometrics.importReferenceSamples({ side: sleeper, source: 'apple_watch', metric: 'breathing_rate', samples: breathingRates }) + : { written: 0 } + const hrv = hrvSdnn.length + ? await caller.biometrics.importReferenceSamples({ side: sleeper, source: 'apple_watch', metric: 'hrv_sdnn', samples: hrvSdnn }) + : { written: 0 } let stages = 0 let stageError: string | null = null if (segments.length) { @@ -69,7 +92,7 @@ export function registerSleepTools(server: McpServer) { : 'Stage import failed; stored stages were preserved. Check segment bounds/overlaps before retrying. See the vitals count for imported heart-rate samples.' } } - return jsonResult({ vitals: vitals.written, stages, stageError }) + return jsonResult({ vitals: vitals.written, breathing: breathing.written, hrv: hrv.written, stages, stageError }) })) server.registerTool('get_sleep_summary', { @@ -226,6 +249,49 @@ export function registerSleepTools(server: McpServer) { }) })) + server.registerTool('compare_vitals_to_watch', { + title: 'Pod vs Apple Watch deviation report', + description: + 'Read-only: how far the pod\'s heart rate, breathing rate, HRV and sleep stages sit from imported Apple Watch data, per night ' + + 'and as a summary pooled over every pair of all nights. Deviation is pod − Watch: per metric n, watchSamples, coverage ' + + '(paired / Watch samples), mae, bias (positive = pod reads high), p90 absolute error and Pearson r, all null without data. ' + + `Pairing: HR nearest pod reading within ±${WATCH_PAIRING_WINDOW_MS.heartRate / 1000} s (as get_sleep_stage_rules' heartRateAgreement), ` + + `breathing within ±${WATCH_PAIRING_WINDOW_MS.breathingRate / 1000} s, HRV = mean pod HRV within ±${WATCH_PAIRING_WINDOW_MS.hrv / 60_000} min vs Watch SDNN ` + + '(not like-for-like yet; see hrvNote). Stages re-run the side\'s current classifier on stored vitals and report epochs, accuracy, ' + + 'kappa, per-stage recall and pod vs Watch minutes. The goal is near-zero deviation: lower mae and |bias|, higher r, coverage and kappa. ' + + 'Use it before and after any biometric profile change (apply_biometric_profile) or stage ruleset change (set_sleep_stage_rules) ' + + 'to see whether deviation went down. Stages re-score immediately; stored HR, breathing and HRV only change for nights recorded ' + + 'after a profile takes effect, so compare those nights; configChanges lists the profile generations that took effect in the ' + + 'window (generation, atMs = the sample time the processor acknowledged) to tell which nights those are. ' + + 'Nights are picked by bed-entry time; a date-only startDate/endDate is ' + + 'a whole UTC calendar day, so endDate "2026-10-18" includes records entered any time that day. Default window is the last 14 days ' + + '(at most 31); only sleep records of at least minHours count.', + inputSchema: { + side, + startDate: isoDate, + endDate: isoDate, + minHours: z.number().min(0).max(24).default(3).describe('Shortest sleep record that counts as a night, in hours (default 3).'), + }, + annotations: READ_ONLY, + }, async ({ side: s, startDate, endDate, minHours }) => runTool(async () => { + const report = await getCaller().biometrics.compareToWatch({ + side: s, + minHours, + ...(startDate && { startDate: new Date(startDate) }), + ...(endDate && { endDate: endOfDateOnly(endDate) }), + }) + const { summary } = report + const hasWatch = summary.heartRate.watchSamples + summary.breathingRate.watchSamples + summary.hrv.watchSamples > 0 || summary.stages !== null + return jsonResult({ + ...report, + hint: report.nights.length === 0 + ? `No sleep records of at least ${minHours} h in this window. Widen startDate/endDate or lower minHours.` + : hasWatch + ? null + : 'No Apple Watch data overlaps these nights. Run import_apple_watch_data (or POST /api/biometrics/watch-upload) first.', + }) + })) + server.registerTool('set_sleep_stage_rules', { title: 'Save a sleep stage ruleset', description: @@ -373,21 +439,16 @@ async function referenceCoverage(sleeper: 'left' | 'right') { /** * How well the pod's heart-rate estimate tracks the watch: Pearson correlation - * and mean absolute error over readings within HR_MATCH_S of each other. Null + * and mean absolute error, pairing each watch sample with the nearest pod + * reading the way compare_vitals_to_watch does (WATCH_PAIRING_WINDOW_MS). Null * without enough pairs. Stage rules on hrRatio only work when this is high. */ function heartRateAgreement(pod: { timestamp: Date, heartRate: number | null }[], watch: { timestamp: Date, heartRate: number }[]) { - const podSorted = pod.filter((v): v is { timestamp: Date, heartRate: number } => v.heartRate != null).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + const podSeries = pod.map(v => ({ t: v.timestamp.getTime(), v: v.heartRate })).sort((a, b) => a.t - b.t) const pairs: [number, number][] = [] - let i = 0 for (const w of [...watch].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())) { - const t = w.timestamp.getTime() - while (i < podSorted.length && podSorted[i].timestamp.getTime() < t - HR_MATCH_S * 1000) i++ - let best: { timestamp: Date, heartRate: number } | null = null - for (let j = i; j < podSorted.length && podSorted[j].timestamp.getTime() <= t + HR_MATCH_S * 1000; j++) { - if (!best || Math.abs(podSorted[j].timestamp.getTime() - t) < Math.abs(best.timestamp.getTime() - t)) best = podSorted[j] - } - if (best) pairs.push([w.heartRate, best.heartRate]) + const best = nearestWithin(podSeries, w.timestamp.getTime(), WATCH_PAIRING_WINDOW_MS.heartRate) + if (best !== null) pairs.push([w.heartRate, best]) } if (pairs.length < 10) return null const n = pairs.length 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/scheduler/jobManager.ts b/src/scheduler/jobManager.ts index ef26122f2..4ad5cba32 100644 --- a/src/scheduler/jobManager.ts +++ b/src/scheduler/jobManager.ts @@ -720,6 +720,8 @@ export class JobManager { vibrationIntensity: sched.vibrationIntensity, vibrationPattern: sched.vibrationPattern, duration: sched.duration, + snoozeDuration: sched.snoozeDuration, + snoozeTaper: sched.snoozeTaper, } await client.setAlarm(sched.side, alarm) // Recorded, not just broadcast: a tap gesture reads it to decide diff --git a/src/scheduler/tests/jobManager.test.ts b/src/scheduler/tests/jobManager.test.ts index adc6fc2b1..581d73477 100644 --- a/src/scheduler/tests/jobManager.test.ts +++ b/src/scheduler/tests/jobManager.test.ts @@ -956,6 +956,8 @@ describe('JobManager incremental upsert/cancel', () => { duration: 120, alarmTemperature: 80, wakeWindow: 0, + snoozeDuration: 300, + snoozeTaper: 0, enabled: true, createdAt: new Date(0), updatedAt: new Date(0), @@ -1761,6 +1763,8 @@ describe('JobManager residual mutation contracts', () => { ...row, id: 25, wakeWindow: 0, + snoozeDuration: 300, + snoozeTaper: 0, side: 'right', dayOfWeek: 'monday', time: '06:30', @@ -2149,6 +2153,8 @@ describe('JobManager residual mutation contracts', () => { time: '06:30', alarmTemperature: 88, wakeWindow: 0, + snoozeDuration: 300, + snoozeTaper: 0, vibrationIntensity: 50, vibrationPattern: 'rise', duration: 30, @@ -2211,6 +2217,8 @@ describe('JobManager residual mutation contracts', () => { time: '06:30', alarmTemperature: 88, wakeWindow: 0, + snoozeDuration: 300, + snoozeTaper: 0, vibrationIntensity: 50, vibrationPattern: 'rise', duration: 30, @@ -2221,6 +2229,8 @@ describe('JobManager residual mutation contracts', () => { vibrationIntensity: 50, vibrationPattern: 'rise', duration: 30, + snoozeDuration: 300, + snoozeTaper: 0, }) expect(warn).toHaveBeenCalledWith( '[jobManager] skipped alarm temperature alarm-43: pump stall guard blocks right; firing vibration only', @@ -2240,11 +2250,13 @@ describe('JobManager residual mutation contracts', () => { time: '06:50', alarmTemperature: 80, wakeWindow: 0, + snoozeDuration: 300, + snoozeTaper: 0, vibrationIntensity: 100, vibrationPattern: 'rise', duration: 120, }) - expect(getActiveAlarmConfig('left')).toEqual({ vibrationIntensity: 100, vibrationPattern: 'rise', duration: 120 }) + expect(getActiveAlarmConfig('left')).toEqual({ vibrationIntensity: 100, vibrationPattern: 'rise', duration: 120, snoozeDuration: 300, snoozeTaper: 0 }) } finally { suspendActiveAlarms() diff --git a/src/scheduler/tests/jobOrdering.test.ts b/src/scheduler/tests/jobOrdering.test.ts index 4e0f3cdd8..2c9a50399 100644 --- a/src/scheduler/tests/jobOrdering.test.ts +++ b/src/scheduler/tests/jobOrdering.test.ts @@ -260,6 +260,8 @@ const alarmSched = (side: 'left' | 'right') => ({ ...alarmNow(), alarmTemperature: 88, wakeWindow: 0, + snoozeDuration: 300, + snoozeTaper: 0, vibrationIntensity: 100, vibrationPattern: 'rise' as const, duration: 10, @@ -354,6 +356,9 @@ describe('JobManager — job ordering and gating', () => { vibrationIntensity: 100, vibrationPattern: 'rise', duration: 10, + // Carried so a snooze of this alarm uses its length and taper. + snoozeDuration: 300, + snoozeTaper: 0, }) }) @@ -1854,6 +1859,8 @@ describe('JobManager — streaming + downstream side-effects', () => { ...alarmNow(), alarmTemperature: 88, wakeWindow: 0, + snoozeDuration: 300, + snoozeTaper: 0, vibrationIntensity: 90, vibrationPattern: 'rise' as const, duration: 30, @@ -1867,11 +1874,13 @@ describe('JobManager — streaming + downstream side-effects', () => { targetLevel: fahrenheitToLevel(88), }) expect(broadcastMutationStatus).toHaveBeenCalledWith('right', { isAlarmVibrating: true }) - // setAlarm forwarded the exact vibration parameters from the schedule + // setAlarm forwarded the exact vibration parameters (and snooze settings) from the schedule expect(setAlarm).toHaveBeenCalledWith('right', { vibrationIntensity: 90, vibrationPattern: 'rise', duration: 30, + snoozeDuration: 300, + snoozeTaper: 0, }) }) diff --git a/src/server/instrumentation-node.ts b/src/server/instrumentation-node.ts index 9bf4e1bf3..3d71f4e24 100644 --- a/src/server/instrumentation-node.ts +++ b/src/server/instrumentation-node.ts @@ -1,3 +1,4 @@ +import { installAppLogging } from '@/src/lib/appLogging' import { shutdownBaseController } from '@/src/hardware/base/instance' /** * Server startup and process lifecycle management. @@ -23,6 +24,7 @@ import { closeDatabase, closeBiometricsDatabase } from '@/src/db' import { startBiometricsRetention, stopBiometricsRetention } from '@/src/db/retention' import { startAutomationRunsRetention, stopAutomationRunsRetention } from '@/src/db/automationRunsRetention' import { startHealthSampler, stopHealthSampler } from '@/src/lib/healthSampler' +import { startFlashSampler, stopFlashSampler } from '@/src/lib/flashSampler' import { stopBacktestHost } from '@/src/biometrics/backtest/host' import { stopBiometricCoordinator } from '@/src/biometrics/backtest/coordinator-service' import { stopBiometricRuntime } from '@/src/biometrics/profiles/runtime-host' @@ -149,6 +151,7 @@ async function gracefulShutdown(signal: string): Promise { stopBiometricsRetention() stopAutomationRunsRetention() stopHealthSampler() + stopFlashSampler() stopBiometricRuntime() } catch (error) { @@ -358,6 +361,8 @@ async function initializeBackgroundServices(): Promise { // Started before the scheduler so a scheduler that fails to load still // leaves a history of the failure. startHealthSampler() + // Hourly eMMC write counter for System → Storage's writes-per-day. + startFlashSampler() const schedulerStartedAt = performance.now() console.log('Initializing job scheduler...') @@ -455,6 +460,7 @@ async function initializeBackgroundServices(): Promise { } export async function registerNodeInstrumentation(): Promise { + installAppLogging() // Register global handlers first (before any initialization that could fail) registerGlobalHandlers() startPerformanceMonitoring() diff --git a/src/server/routers/biometricTuning.ts b/src/server/routers/biometricTuning.ts index c7daad162..1a06b4f59 100644 --- a/src/server/routers/biometricTuning.ts +++ b/src/server/routers/biometricTuning.ts @@ -5,6 +5,7 @@ import { replayAdmission, replayAdmissionSchema, replayAdmitter } from '@/src/bi import { rawSourceDescriptorSchema, rawSourcePageSchema, rawSourcePageResultSchema } from '@/src/biometrics/backtest/raw-catalog' import { rawScanInputSchema, rawSourceNameSchema } from '@/src/biometrics/backtest/raw-inspection-child' import { createWaveformDatasetSchema } from '@/src/biometrics/backtest/waveform-selection' +import { locateRawArchives } from '@/src/biometrics/backtest/raw-locate' import { z } from 'zod' import { TRPCError } from '@trpc/server' import { publicProcedure, router } from '@/src/server/trpc' @@ -31,8 +32,11 @@ import { BacktestJobStore } from '@/src/biometrics/backtest/jobs' import { describeJob, jobDescriptorSchema, resultDescriptorSchema } from '@/src/biometrics/backtest/descriptors' import { experimentProtocolSchema } from '@/src/biometrics/backtest/contracts' import { datasetDescriptorSchema } from '@/src/biometrics/backtest/contracts' +import { datasetBundleIndexSchema, datasetBundleTarBytes } from '@/src/biometrics/backtest/dataset-export' +import { AnalysisExportTooLargeError, MAX_ANALYSIS_RAW_BYTES, analysisExportDownloadPath, analysisExportRequestSchema, analysisWindowSchema, liveDatabaseBytes, planAnalysisExport, tarBytes } from '@/src/biometrics/analysis-export' import { deviceSettings } from '@/src/db/schema' import { ReferenceInputError } from '@/src/biometrics/references/time' +import { countUnlinkedLegacyReferences, linkLegacyReferences } from '@/src/biometrics/references/legacy' /** UI and MCP call this shared service surface under the existing LAN trust model. */ async function references(action: (store: ReferenceStore) => T | Promise): Promise { @@ -248,6 +252,13 @@ export const biometricTuningRouter = router({ .output(rawSourcePageResultSchema) .query(({ input }) => references(() => coordinatorRequest(biometricsDb, { type: 'pageRawSources', input }))), + locateRawArchives: publicProcedure + .meta({ openapi: { method: 'GET', path: '/biometric-tuning/raw-sources/locate', protect: false, tags: ['Biometric tuning'] } }) + .input(z.object({ startMs: z.number().int().nonnegative(), endMs: z.number().int().nonnegative() }).strict() + .refine(input => input.startMs <= input.endMs && input.endMs - input.startMs <= 36 * 3600_000, { message: 'Use a window of at most 36 hours' })) + .output(z.array(z.object({ name: rawSourceNameSchema, afterName: rawSourceNameSchema.nullable() }))) + .query(({ input }) => locateRawArchives(input.startMs, input.endMs)), + createWaveformDataset: publicProcedure .meta({ openapi: { method: 'POST', path: '/biometric-tuning/waveform-datasets', protect: false, tags: ['Biometric tuning'] } }) .input(createWaveformDatasetSchema) @@ -278,6 +289,36 @@ export const biometricTuningRouter = router({ .output(datasetDescriptorSchema) .query(({ input }) => references(() => coordinatorRequest(biometricsDb, { type: 'datasetDescriptor', input }).then(result => datasetDescriptorSchema.parse(result)))), + describeDatasetExport: publicProcedure + .meta({ openapi: { method: 'GET', path: '/biometric-tuning/datasets/{id}/export-index', protect: false, tags: ['Biometric tuning'] } }) + .input(importIdSchema) + .output(z.object({ index: datasetBundleIndexSchema, download: z.object({ method: z.literal('GET'), path: z.string(), contentType: z.literal('application/x-tar'), bytes: z.number().int().nonnegative() }).strict() }).strict()) + .query(({ input }) => references(() => coordinatorRequest(biometricsDb, { type: 'exportDataset', input }).then(result => ({ index: result.index, + download: { method: 'GET' as const, path: `/api/biometric-tuning/datasets/${input.id}/export`, contentType: 'application/x-tar' as const, bytes: datasetBundleTarBytes(result.index) } })))), + + describeAnalysisExport: publicProcedure + .meta({ openapi: { method: 'GET', path: '/biometric-tuning/analysis-export-index', protect: false, tags: ['Biometric tuning'] } }) + .input(analysisExportRequestSchema) + .output(z.object({ window: z.object({ startMs: z.number(), endMs: z.number(), includeRaw: z.boolean() }).strict(), archiveCount: z.number().int().nonnegative(), + rawBytes: z.number().int().nonnegative(), maxRawBytes: z.number().int().positive(), estimatedBytes: z.number().int().nonnegative(), + download: z.object({ method: z.literal('GET'), path: z.string(), contentType: z.literal('application/x-tar') }).strict() }).strict()) + .query(async ({ input }) => { + const parsed = analysisWindowSchema.safeParse(input) + if (!parsed.success) throw new TRPCError({ code: 'BAD_REQUEST', message: parsed.error.issues.map(issue => issue.message).join('; ') }) + try { + const plan = await planAnalysisExport(parsed.data) + const databaseBytes = await liveDatabaseBytes(biometricsDb.$client.name) + return { window: plan.window, archiveCount: plan.archives.length, rawBytes: plan.rawBytes, maxRawBytes: MAX_ANALYSIS_RAW_BYTES, + // The manifest is a few hundred bytes per archive. + estimatedBytes: tarBytes([databaseBytes, ...plan.archives.map(archive => archive.bytes), 2048 + 300 * plan.archives.length]), + download: { method: 'GET' as const, path: analysisExportDownloadPath(plan.window), contentType: 'application/x-tar' as const } } + } + catch (error) { + if (error instanceof AnalysisExportTooLargeError) throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: error.message, cause: error }) + throw error + } + }), + listDatasets: publicProcedure .meta({ openapi: { method: 'GET', path: '/biometric-tuning/datasets', protect: false, tags: ['Biometric tuning'] } }) .input(z.object({ side: sideSchema, beforeId: z.string().uuid().optional(), beforeCreatedAtMs: z.number().int().nonnegative().optional() }).strict() @@ -373,6 +414,18 @@ export const biometricTuningRouter = router({ ? { createdAtMs: input.beforeCreatedAtMs, id: input.beforeId } : undefined))), + countLegacyReferences: publicProcedure + .meta({ openapi: { method: 'GET', path: '/biometric-tuning/references/legacy', protect: false, tags: ['Biometric tuning'] } }) + .input(z.object({ side: sideSchema }).strict()) + .output(z.object({ vitals: z.number().int(), stages: z.number().int() })) + .query(({ input }) => countUnlinkedLegacyReferences(biometricsDb, input.side)), + + linkLegacyReferences: publicProcedure + .meta({ openapi: { method: 'POST', path: '/biometric-tuning/references/legacy/link', protect: false, tags: ['Biometric tuning'] } }) + .input(z.object({ side: sideSchema }).strict()) + .output(z.object({ vitals: z.number().int(), stages: z.number().int(), imports: z.number().int() })) + .mutation(({ input }) => references(() => linkLegacyReferences(biometricsDb, input.side))), + getImport: publicProcedure .meta({ openapi: { method: 'GET', path: '/biometric-tuning/references/{id}', protect: false, tags: ['Biometric tuning'] } }) .input(importIdSchema) diff --git a/src/server/routers/biometrics.ts b/src/server/routers/biometrics.ts index e18e61933..602677b6f 100644 --- a/src/server/routers/biometrics.ts +++ b/src/server/routers/biometrics.ts @@ -4,13 +4,15 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { publicProcedure, router } from '@/src/server/trpc' import { biometricsDb, db } from '@/src/db' -import { sleepRecords, vitals, movement, referenceStages, referenceVitals, biometricProcessingRuns } from '@/src/db/biometrics-schema' +import { sleepRecords, vitals, movement, referenceStages, referenceVitals, referenceSamples, biometricProcessingRuns, biometricRuntimeReceipts } from '@/src/db/biometrics-schema' import { deviceSettings } from '@/src/db/schema' -import { eq, and, gt, gte, lt, lte, desc, asc, avg, min, max, count, sql, getTableColumns } from 'drizzle-orm' +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 { 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, @@ -24,6 +26,15 @@ import { pickMinBucketNonStillEpochs, } from '@/src/lib/movement' import { recordLegacyReferenceImport } from '@/src/biometrics/references/legacy' +import { + compareNightsToWatch, + deviationNightSchema, + deviationSummarySchema, + HRV_NOTE, + WATCH_PAIRING_WINDOW_MS, + type DeviationNightInput, +} from '@/src/biometrics/references/watch-deviation' +import type { StageRuleset } from '@/src/lib/sleep-stage-rules' import { getOccupancy } from '@/src/lib/occupancy' /** @@ -77,6 +88,100 @@ function batchOverlaps(segments: StageSegment[]): string[] { return conflicts } +/** Plausible Apple Watch values per reference_samples metric, in breaths/min and ms. */ +export const REFERENCE_SAMPLE_RANGE = { breathing_rate: [4, 60], hrv_sdnn: [1, 500] } as const + +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 + * pairing window so a Watch sample near bed entry or exit can still pair; the + * classifier and the Watch samples stay inside the record. + */ +async function loadDeviationNight(side: 'left' | 'right', record: typeof sleepRecords.$inferSelect, ruleset: StageRuleset): Promise { + const start = record.enteredBedAt + const end = record.leftBedAt + const pad = WATCH_PAIRING_WINDOW_MS.hrv + const { vitalsData, movementData, calibrationQuality } = await loadStageInputs(side, new Date(start.getTime() - pad), new Date(end.getTime() + pad)) + const reference = await loadReference(side, start, end) + const watchHeartRate = await biometricsDb + .select({ timestamp: referenceVitals.timestamp, value: referenceVitals.heartRate }) + .from(referenceVitals) + .where(and(eq(referenceVitals.side, side), gte(referenceVitals.timestamp, start), lte(referenceVitals.timestamp, end))) + .orderBy(asc(referenceVitals.timestamp)) + const watchSamples = await biometricsDb + .select({ metric: referenceSamples.metric, timestamp: referenceSamples.timestamp, value: referenceSamples.value }) + .from(referenceSamples) + .where(and(eq(referenceSamples.side, side), gte(referenceSamples.timestamp, start), lte(referenceSamples.timestamp, end))) + .orderBy(asc(referenceSamples.timestamp)) + + const inRecord = (rows: T[]) => rows.filter(r => r.timestamp >= start && r.timestamp <= end) + const series = (key: 'heartRate' | 'breathingRate' | 'hrv') => vitalsData.map(v => ({ t: v.timestamp.getTime(), v: v[key] })) + const watch = (rows: { timestamp: Date, value: number }[]) => rows.map(r => ({ t: r.timestamp.getTime(), v: r.value })) + return { + sleepRecordId: record.id, + enteredBedAt: start, + leftBedAt: end, + pod: { heartRate: series('heartRate'), breathingRate: series('breathingRate'), hrv: series('hrv') }, + watch: { + heartRate: watch(watchHeartRate), + breathingRate: watch(watchSamples.filter(s => s.metric === 'breathing_rate')), + hrvSdnn: watch(watchSamples.filter(s => s.metric === 'hrv_sdnn')), + }, + epochs: stageNight(inRecord(vitalsData), inRecord(movementData), calibrationQuality, ruleset, loadActiveStageModel(side), { start, end }).epochs, + reference, + } +} + +/** + * Profile generations (numeric settings and stage classifier) that took + * effect on this side in [start, end], at the sample time the processor + * acknowledged for its numeric components. A generation never acknowledged + * changed no stored reading, so it is left out. + */ +async function loadConfigChanges(side: 'left' | 'right', start: Date, end: Date) { + const rows = await biometricsDb + .select({ generation: biometricRuntimeReceipts.generation, atMs: max(biometricRuntimeReceipts.effectiveSampleTimeMs) }) + .from(biometricRuntimeReceipts) + .where(and( + eq(biometricRuntimeReceipts.side, side), + eq(biometricRuntimeReceipts.state, 'applied'), + inArray(biometricRuntimeReceipts.component, ['heart_rate', 'breathing_rate', 'hrv_index']) + )) + .groupBy(biometricRuntimeReceipts.generation) + return rows + .filter((r): r is { generation: number, atMs: number } => r.atMs !== null && r.atMs >= start.getTime() && r.atMs <= end.getTime()) + .sort((a, b) => a.atMs - b.atMs) +} + /** * Biometrics router - query sleep and health data collected by Pod sensors. * @@ -1159,12 +1264,83 @@ export const biometricsRouter = router({ } }), + /** + * Import externally measured breathing rate (breaths/min) or HRV SDNN (ms) + * samples, one metric per call (Apple Watch via a Health export). Stored in + * `reference_samples`, never in `vitals`, under the same first-write-wins + * rule as importReferenceVitals (unique on side, metric, timestamp, source). + */ + importReferenceSamples: publicProcedure + .meta({ openapi: { method: 'POST', path: '/biometrics/reference-samples', protect: false, tags: ['Biometrics'] } }) + .input( + z.object({ + side: sideSchema, + source: z.enum(['apple_watch']), + metric: z.enum(['breathing_rate', 'hrv_sdnn']), + samples: z.array( + z.object({ + // Unix seconds in 2001..2100, as importReferenceVitals. + timestamp: z.number().int().min(1_000_000_000).max(4_102_444_800), + value: z.number().finite(), + }).strict() + ).min(1).max(2000), + }).strict().superRefine((input, ctx) => { + const [lo, hi] = REFERENCE_SAMPLE_RANGE[input.metric] + const index = input.samples.findIndex(s => s.value < lo || s.value > hi) + if (index >= 0) ctx.addIssue({ code: 'custom', path: ['samples', index, 'value'], message: `${input.metric} must be within ${lo}..${hi}` }) + }) + ) + .output(z.object({ written: z.number() })) + .mutation(async ({ input }) => { + try { + const rows = input.samples.map(s => ({ + side: input.side, + source: input.source, + metric: input.metric, + timestamp: new Date(s.timestamp * 1000), + value: s.value, + })) + const inserted = biometricsDb.transaction((tx) => { + const accepted = tx.insert(referenceSamples).values(rows).onConflictDoNothing().returning().all() + recordLegacyReferenceImport(biometricsDb, 'samples', accepted) + return accepted + }) + return { written: inserted.length } + } + catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to import reference samples: ${error instanceof Error ? error.message : 'Unknown error'}`, + cause: error, + }) + } + }), + /** * Reference heart-rate samples for a side in [startDate, endDate] * (inclusive), oldest first. When the window holds more than `limit` * 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({ @@ -1217,6 +1393,102 @@ export const biometricsRouter = router({ } }), + /** + * How far the pod's vitals and sleep stages sit from the Apple Watch, per + * night and pooled over the window: HR (each Watch sample vs the nearest pod + * reading within ±60 s), breathing rate (±150 s) and HRV (Watch SDNN vs the + * mean pod HRV within ±5 min), as n, coverage, MAE, bias (pod − Watch), p90 + * absolute error and Pearson r; stages by re-running the side's effective + * classifier on the stored vitals and movement. Read-only. Nights are sleep + * records of at least `minHours` entered in [startDate, endDate], oldest + * first; the window defaults to the last 14 days and may span at most 31. + * `configChanges` lists the side's profile generations that took effect in + * the window, to mark where a night-to-night step may come from. + */ + compareToWatch: publicProcedure + .meta({ openapi: { method: 'GET', path: '/biometrics/watch-deviation', protect: false, tags: ['Biometrics'] } }) + .input( + z + .object({ + side: sideSchema, + startDate: z.date().optional(), + endDate: z.date().optional(), + minHours: z.number().min(0).max(24).default(3), + }) + .strict() + ) + .output(z.object({ + side: sideSchema, + startDate: z.date(), + endDate: z.date(), + minHours: z.number(), + /** The classifier the stages were re-run with. */ + classifier: z.object({ + isDefault: z.boolean(), + source: stageViewSchema.shape.provenance.shape.source, + generation: z.number().int().nullable(), + stageHash: z.string(), + }), + hrvNote: z.string(), + nights: z.array(deviationNightSchema), + summary: deviationSummarySchema, + /** Profile generations that took effect in the window, oldest first, at the acknowledged sample time. */ + configChanges: z.array(z.object({ generation: z.number().int(), atMs: z.number() })), + })) + .query(async ({ input }) => { + const endDate = input.endDate ?? new Date() + const startDate = input.startDate ?? new Date(endDate.getTime() - WATCH_DEVIATION_DEFAULT_DAYS * DAY_MS) + if (!validateDateRange(startDate, endDate)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) + } + if (endDate.getTime() - startDate.getTime() > WATCH_DEVIATION_MAX_DAYS * DAY_MS) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Select at most ${WATCH_DEVIATION_MAX_DAYS} days` }) + } + + try { + const view = loadStageView(input.side) + const records = (await biometricsDb + .select() + .from(sleepRecords) + .where( + and( + eq(sleepRecords.side, input.side), + gte(sleepRecords.enteredBedAt, startDate), + lte(sleepRecords.enteredBedAt, endDate) + ) + ) + .orderBy(asc(sleepRecords.enteredBedAt))) + .filter(r => r.leftBedAt.getTime() - r.enteredBedAt.getTime() >= input.minHours * 3_600_000) + + const nights: DeviationNightInput[] = [] + for (const record of records) { + nights.push(await loadDeviationNight(input.side, record, view.classifier.ruleset)) + } + return { + side: input.side, + startDate, + endDate, + minHours: input.minHours, + classifier: { + isDefault: view.isDefault, + source: view.provenance.source, + generation: view.provenance.generation, + stageHash: view.provenance.stageHash, + }, + hrvNote: HRV_NOTE, + ...compareNightsToWatch(nights), + configChanges: await loadConfigChanges(input.side, startDate, endDate), + } + } + catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to compare with the Watch: ${error instanceof Error ? error.message : 'Unknown error'}`, + cause: error, + }) + } + }), + /** * Returns the count of RAW biometrics files and total size. * RAW files are dual-channel (left + right interleaved), so left = right = total count. @@ -1394,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)) { @@ -1537,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 }, @@ -1561,6 +1837,7 @@ export const biometricsRouter = router({ return { stageView, + stagedBy, epochs, blocks, distribution, diff --git a/src/server/routers/curveTemplates.ts b/src/server/routers/curveTemplates.ts index 54c4b7467..5a3441c32 100644 --- a/src/server/routers/curveTemplates.ts +++ b/src/server/routers/curveTemplates.ts @@ -4,13 +4,24 @@ import { desc, eq } from 'drizzle-orm' import { publicProcedure, router } from '@/src/server/trpc' import { db } from '@/src/db' import { curveTemplates } from '@/src/db/schema' -import { temperatureSchema, timeStringSchema } from '@/src/server/validation-schemas' +import { dayOfWeekSchema, temperatureSchema, timeStringSchema } from '@/src/server/validation-schemas' import { CURVE_NOTES_MAX } from '@/src/lib/sleepCurve/savePlan' +import { hasDays, movedDays, normalizeDays, planClaims } from '@/src/lib/scheduleLibrary' +import { + applyClaimUpdates, + applySchedulerOps, + curveDays, + emptySchedulerOps, + syncCurveRows, + toClaim, +} from '@/src/server/scheduleLibrary' /** - * Saved curves: named set point lists (°F) that are not tied to a side or a - * day. The AI curve wizard and the MCP server save them here; applying one - * goes through the schedule editor or apply_sleep_curve like any other curve. + * The schedule library: named curves (set points in °F) that each run on + * their own days per side while active. Active curves never share a side and + * day; activating one moves shared days to it. The rows the scheduler reads + * are written from here (src/server/scheduleLibrary.ts). The Schedule screen, + * the AI curve wizard and the MCP server all save curves here. */ const nameSchema = z.string().trim().min(1).max(30) @@ -18,6 +29,13 @@ const nameSchema = z.string().trim().min(1).max(30) const pointsSchema = z.record(timeStringSchema, temperatureSchema) .refine(points => Object.keys(points).length >= 2 && Object.keys(points).length <= 48, 'A saved curve needs 2-48 set points') +const endActionSchema = z.enum(['turn_off', 'maintain']) + +const daysSchema = z.object({ + left: z.array(dayOfWeekSchema).max(7).default([]), + right: z.array(dayOfWeekSchema).max(7).default([]), +}).strict() + const curveTemplateOutput = z.object({ id: z.number(), name: z.string(), @@ -25,12 +43,36 @@ const curveTemplateOutput = z.object({ wake: z.string(), points: z.record(z.string(), z.number()), notes: z.string(), + endAction: endActionSchema, + days: z.object({ left: z.array(dayOfWeekSchema), right: z.array(dayOfWeekSchema) }), + active: z.boolean(), createdAt: z.date(), updatedAt: z.date(), }) +const changeOutput = z.object({ + curve: curveTemplateOutput, + // Days other active curves gave up to this one. + moved: z.array(z.object({ + name: z.string(), + side: z.enum(['left', 'right']), + days: z.array(dayOfWeekSchema), + deactivated: z.boolean(), + })), +}) + +type CurveRow = typeof curveTemplates.$inferSelect + +function toOutput(row: CurveRow) { + const { leftDays, rightDays, ...rest } = row + return { ...rest, days: curveDays({ leftDays, rightDays }) } +} + function failed(action: string, error: unknown): never { if (error instanceof TRPCError) throw error + if (error instanceof Error && /UNIQUE constraint failed: curve_templates\.name/.test(error.message)) { + throw new TRPCError({ code: 'CONFLICT', message: 'Another curve already has that name', cause: error }) + } throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to ${action}: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -39,21 +81,24 @@ function failed(action: string, error: unknown): never { } export const curveTemplatesRouter = router({ - /** Every saved curve, most recently saved first. */ + /** Every curve in the library, most recently saved first. */ list: publicProcedure .meta({ openapi: { method: 'GET', path: '/curve-templates', protect: false, tags: ['Schedules'] } }) .input(z.object({}).strict()) .output(z.array(curveTemplateOutput)) .query(() => { try { - return db.select().from(curveTemplates).orderBy(desc(curveTemplates.updatedAt), desc(curveTemplates.id)).all() + return db.select().from(curveTemplates).orderBy(desc(curveTemplates.updatedAt), desc(curveTemplates.id)).all().map(toOutput) } catch (error) { failed('list saved curves', error) } }), - /** Save a curve by name; saving an existing name replaces it. */ + /** + * Save a curve by name; saving an existing name replaces its set points and + * keeps its days. An active curve's schedule is rewritten straight away. + */ save: publicProcedure .meta({ openapi: { method: 'POST', path: '/curve-templates', protect: false, tags: ['Schedules'] } }) .input(z.object({ @@ -62,34 +107,145 @@ export const curveTemplatesRouter = router({ wake: timeStringSchema, points: pointsSchema, notes: z.string().max(CURVE_NOTES_MAX).default(''), + endAction: endActionSchema.optional(), }).strict()) .output(curveTemplateOutput) - .mutation(({ input }) => { + .mutation(async ({ input }) => { try { - const now = new Date() - return db.insert(curveTemplates) - .values({ ...input, createdAt: now, updatedAt: now }) - .onConflictDoUpdate({ - target: curveTemplates.name, - set: { bedtime: input.bedtime, wake: input.wake, points: input.points, notes: input.notes, updatedAt: now }, - }) - .returning() - .get() + const ops = emptySchedulerOps() + const saved = db.transaction((tx) => { + const now = new Date() + const before = tx.select().from(curveTemplates).where(eq(curveTemplates.name, input.name)).get() + const row = tx.insert(curveTemplates) + .values({ ...input, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ + target: curveTemplates.name, + set: { + bedtime: input.bedtime, + wake: input.wake, + points: input.points, + notes: input.notes, + ...(input.endAction ? { endAction: input.endAction } : {}), + updatedAt: now, + }, + }) + .returning() + .get() + if (before?.active) syncCurveRows(tx, row, { days: curveDays(before), active: true }, ops) + return row + }) + await applySchedulerOps(ops) + return toOutput(saved) } catch (error) { failed('save curve', error) } }), - /** Delete a saved curve by name. Schedules already written from it are untouched. */ + /** + * Create (no id) or update a curve with its days and active flag in one + * step. When active, the days it shares with other active curves move to + * it, and a curve left with no days stops being active. Active needs at + * least one day; a curve without days is saved inactive. + */ + put: publicProcedure + .meta({ openapi: { method: 'PUT', path: '/curve-templates', protect: false, tags: ['Schedules'] } }) + .input(z.object({ + id: z.number().int().positive().optional(), + name: nameSchema, + bedtime: timeStringSchema, + wake: timeStringSchema, + points: pointsSchema, + notes: z.string().max(CURVE_NOTES_MAX).default(''), + endAction: endActionSchema.default('turn_off'), + days: daysSchema, + active: z.boolean(), + }).strict()) + .output(changeOutput) + .mutation(async ({ input }) => { + try { + const ops = emptySchedulerOps() + const result = db.transaction((tx) => { + const all = tx.select().from(curveTemplates).all() + const before = input.id === undefined ? undefined : all.find(c => c.id === input.id) + if (input.id !== undefined && !before) throw new TRPCError({ code: 'NOT_FOUND', message: `No curve with id ${input.id}` }) + const days = normalizeDays(input.days) + const active = input.active && hasDays(days) + const plan = planClaims(all.map(toClaim), before?.id ?? null, days, active) + const now = new Date() + const fields = { + name: input.name, + bedtime: input.bedtime, + wake: input.wake, + points: input.points, + notes: input.notes, + endAction: input.endAction, + leftDays: days.left, + rightDays: days.right, + active, + updatedAt: now, + } + const row = before + ? tx.update(curveTemplates).set(fields).where(eq(curveTemplates.id, before.id)).returning().get() + : tx.insert(curveTemplates).values({ ...fields, createdAt: now }).returning().get() + applyClaimUpdates(tx, plan.updates) + syncCurveRows(tx, row, before ? { days: curveDays(before), active: before.active } : null, ops) + return { curve: toOutput(row), moved: movedDays(plan.moves) } + }) + await applySchedulerOps(ops) + return result + } + catch (error) { + failed('save curve', error) + } + }), + + /** Turn a curve on (on its saved days, moving shared days to it) or off (its days stop running). */ + setActive: publicProcedure + .meta({ openapi: { method: 'POST', path: '/curve-templates/active', protect: false, tags: ['Schedules'] } }) + .input(z.object({ id: z.number().int().positive(), active: z.boolean() }).strict()) + .output(changeOutput) + .mutation(async ({ input }) => { + try { + const ops = emptySchedulerOps() + const result = db.transaction((tx) => { + const all = tx.select().from(curveTemplates).all() + const before = all.find(c => c.id === input.id) + if (!before) throw new TRPCError({ code: 'NOT_FOUND', message: `No curve with id ${input.id}` }) + const days = curveDays(before) + if (input.active && !hasDays(days)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Pick days for ${before.name} before turning it on` }) + } + if (before.active === input.active) return { curve: toOutput(before), moved: [] } + const plan = planClaims(all.map(toClaim), before.id, days, input.active) + const row = tx.update(curveTemplates).set({ active: input.active }).where(eq(curveTemplates.id, before.id)).returning().get() + applyClaimUpdates(tx, plan.updates) + syncCurveRows(tx, row, { days, active: before.active }, ops) + return { curve: toOutput(row), moved: movedDays(plan.moves) } + }) + await applySchedulerOps(ops) + return result + } + catch (error) { + failed('change curve', error) + } + }), + + /** Delete a curve by name. An active curve's days stop running. */ delete: publicProcedure .meta({ openapi: { method: 'DELETE', path: '/curve-templates', protect: false, tags: ['Schedules'] } }) .input(z.object({ name: nameSchema }).strict()) .output(z.object({ deleted: z.boolean() })) - .mutation(({ input }) => { + .mutation(async ({ input }) => { try { - const rows = db.delete(curveTemplates).where(eq(curveTemplates.name, input.name)).returning().all() - return { deleted: rows.length > 0 } + const ops = emptySchedulerOps() + const deleted = db.transaction((tx) => { + const [row] = tx.delete(curveTemplates).where(eq(curveTemplates.name, input.name)).returning().all() + if (row?.active) syncCurveRows(tx, { ...row, active: false }, { days: curveDays(row), active: true }, ops) + return row !== undefined + }) + await applySchedulerOps(ops) + return { deleted } } catch (error) { failed('delete curve', error) diff --git a/src/server/routers/schedules.ts b/src/server/routers/schedules.ts index c00cde965..a59c3481d 100644 --- a/src/server/routers/schedules.ts +++ b/src/server/routers/schedules.ts @@ -19,6 +19,8 @@ import { vibrationPatternSchema, alarmDurationSchema, wakeWindowSchema, + snoozeDurationSchema, + snoozeTaperSchema, } from '@/src/server/validation-schemas' const temperatureScheduleOutput = z.object({ @@ -55,6 +57,8 @@ const alarmScheduleOutput = z.object({ duration: z.number(), alarmTemperature: z.number(), wakeWindow: z.number(), + snoozeDuration: z.number(), + snoozeTaper: z.number(), enabled: z.boolean(), createdAt: z.date(), updatedAt: z.date(), @@ -65,7 +69,7 @@ const schedulesCollectionOutput = z.object({ power: z.array(powerScheduleOutput), alarm: z.array(alarmScheduleOutput), }) -import { getJobManager } from '@/src/scheduler' +import { applyScheduler, releaseDays } from '@/src/server/scheduleLibrary' import { toC } from '@/src/lib/tempUtils' import { CURVE_NOTES_MAX } from '@/src/lib/sleepCurve/savePlan' @@ -73,20 +77,9 @@ type TemperatureRow = typeof temperatureSchedules.$inferSelect type PowerRow = typeof powerSchedules.$inferSelect type AlarmRow = typeof alarmSchedules.$inferSelect -/** - * Apply an incremental scheduler mutation. The helper resolves the job - * manager and invokes `fn`; failures are logged but never thrown out of the - * route handler — schedule writes already committed, and the heartbeat - * liveness loop will recover any drift. - */ -async function applyScheduler(fn: (jm: Awaited>) => void | Promise): Promise { - try { - const jobManager = await getJobManager() - await fn(jobManager) - } - catch (e) { - console.error('Scheduler update failed:', e) - } +/** True when a row update changes more than its enabled flag (pausing keeps the row's curve). */ +function editsRow(updates: Record): boolean { + return Object.entries(updates).some(([key, value]) => key !== 'enabled' && value !== undefined) } const unitSchema = z.enum(['F', 'C']).default('F') @@ -183,6 +176,7 @@ export const schedulesRouter = router({ }) } + releaseDays(tx, [result]) return result }) @@ -235,6 +229,7 @@ export const schedulesRouter = router({ }) } + if (editsRow(updates)) releaseDays(tx, [result]) return result }) @@ -280,6 +275,7 @@ export const schedulesRouter = router({ message: `Temperature schedule with ID ${input.id} not found`, }) } + releaseDays(tx, [deleted]) }) await applyScheduler(jm => jm.cancelTemperatureJob(input.id)) @@ -327,6 +323,7 @@ export const schedulesRouter = router({ }) } + releaseDays(tx, [result]) return result }) @@ -381,6 +378,7 @@ export const schedulesRouter = router({ }) } + if (editsRow(updates)) releaseDays(tx, [result]) return result }) @@ -426,6 +424,7 @@ export const schedulesRouter = router({ message: `Power schedule with ID ${input.id} not found`, }) } + releaseDays(tx, [deleted]) }) await applyScheduler(jm => jm.cancelPowerJob(input.id)) @@ -459,6 +458,8 @@ export const schedulesRouter = router({ duration: alarmDurationSchema, alarmTemperature: temperatureSchema, wakeWindow: wakeWindowSchema.default(0), + snoozeDuration: snoozeDurationSchema.default(300), + snoozeTaper: snoozeTaperSchema.default(0), enabled: z.boolean().default(true), }) .strict() @@ -508,6 +509,8 @@ export const schedulesRouter = router({ duration: alarmDurationSchema.optional(), alarmTemperature: temperatureSchema.optional(), wakeWindow: wakeWindowSchema.optional(), + snoozeDuration: snoozeDurationSchema.optional(), + snoozeTaper: snoozeTaperSchema.optional(), enabled: z.boolean().optional(), }) .strict() @@ -655,6 +658,8 @@ export const schedulesRouter = router({ duration: alarmDurationSchema, alarmTemperature: temperatureSchema, wakeWindow: wakeWindowSchema.default(0), + snoozeDuration: snoozeDurationSchema.default(300), + snoozeTaper: snoozeTaperSchema.default(0), enabled: z.boolean().default(true), })).max(1000).default([]), }).default({ temperature: [], power: [], alarm: [] }), @@ -681,6 +686,8 @@ export const schedulesRouter = router({ duration: alarmDurationSchema.optional(), alarmTemperature: temperatureSchema.optional(), wakeWindow: wakeWindowSchema.optional(), + snoozeDuration: snoozeDurationSchema.optional(), + snoozeTaper: snoozeTaperSchema.optional(), enabled: z.boolean().optional(), })).max(1000).default([]), }).default({ temperature: [], power: [], alarm: [] }), @@ -700,14 +707,18 @@ export const schedulesRouter = router({ const upsertAlarm: AlarmRow[] = [] db.transaction((tx) => { + // Sides and days whose temperature or power rows change outside the library. + const edited: Array<{ side: TemperatureRow['side'], dayOfWeek: TemperatureRow['dayOfWeek'] }> = [] // Deletes first for (const id of input.deletes.temperature) { const [deleted] = tx.delete(temperatureSchedules).where(eq(temperatureSchedules.id, id)).returning().all() if (!deleted) throw new TRPCError({ code: 'NOT_FOUND', message: `Temperature schedule with ID ${id} not found` }) + edited.push(deleted) } for (const id of input.deletes.power) { const [deleted] = tx.delete(powerSchedules).where(eq(powerSchedules.id, id)).returning().all() if (!deleted) throw new TRPCError({ code: 'NOT_FOUND', message: `Power schedule with ID ${id} not found` }) + edited.push(deleted) } for (const id of input.deletes.alarm) { const [deleted] = tx.delete(alarmSchedules).where(eq(alarmSchedules.id, id)).returning().all() @@ -720,11 +731,13 @@ export const schedulesRouter = router({ const [row] = tx.insert(temperatureSchedules).values(entry).returning().all() if (!row) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create temperature schedule - no record returned' }) upsertTemp.push(row) + edited.push(row) } for (const entry of input.creates.power) { const [row] = tx.insert(powerSchedules).values(entry).returning().all() if (!row) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create power schedule - no record returned' }) upsertPower.push(row) + edited.push(row) } for (const entry of input.creates.alarm) { const [row] = tx.insert(alarmSchedules).values(entry).returning().all() @@ -737,11 +750,13 @@ export const schedulesRouter = router({ const [updated] = tx.update(temperatureSchedules).set({ ...updates, updatedAt: new Date() }).where(eq(temperatureSchedules.id, id)).returning().all() if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: `Temperature schedule with ID ${id} not found` }) upsertTemp.push(updated) + if (editsRow(updates)) edited.push(updated) } for (const { id, ...updates } of input.updates.power) { const [updated] = tx.update(powerSchedules).set({ ...updates, updatedAt: new Date() }).where(eq(powerSchedules.id, id)).returning().all() if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: `Power schedule with ID ${id} not found` }) upsertPower.push(updated) + if (editsRow(updates)) edited.push(updated) } for (const { id, ...updates } of input.updates.alarm) { const [updated] = tx.update(alarmSchedules).set({ ...updates, updatedAt: new Date() }).where(eq(alarmSchedules.id, id)).returning().all() @@ -760,6 +775,7 @@ export const schedulesRouter = router({ tx.delete(scheduleNotes).where(and(eq(scheduleNotes.side, side), eq(scheduleNotes.dayOfWeek, dayOfWeek))).run() } } + releaseDays(tx, edited) }) await applyScheduler((jm) => { diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index b2f571540..4a3739e4e 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -725,6 +725,9 @@ export const settingsRouter = router({ actionType: z.literal('temperature'), temperatureChange: z.enum(['increment', 'decrement']), temperatureAmount: z.number().int().min(0).max(10), + // What the tap does while an alarm is ringing (any action type). + alarmBehavior: z.enum(['snooze', 'dismiss']).optional(), + alarmSnoozeDuration: z.number().int().min(60).max(600).optional(), }) .strict(), z @@ -740,6 +743,16 @@ export const settingsRouter = router({ ]) ) .mutation(async ({ input }) => { + // Write the whole row: a field the call leaves out is cleared, so a tap + // switched between action types keeps nothing from its old action. + const row = { + temperatureChange: null, + temperatureAmount: null, + alarmBehavior: null, + alarmSnoozeDuration: null, + alarmInactiveBehavior: null, + ...input, + } try { const result = db.transaction((tx) => { // Check if gesture already exists @@ -760,7 +773,7 @@ export const settingsRouter = router({ const [updated] = tx .update(tapGestures) .set({ - ...input, + ...row, updatedAt: new Date(), }) .where(eq(tapGestures.id, existing[0].id)) @@ -780,9 +793,7 @@ export const settingsRouter = router({ // Create new const [created] = tx .insert(tapGestures) - .values({ - ...input, - }) + .values(row) .returning() .all() diff --git a/src/server/routers/sleepStages.ts b/src/server/routers/sleepStages.ts index 2fcf3732e..ac683f20b 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) @@ -81,7 +112,7 @@ export async function loadStageInputs(side: Side, windowStart: Date, windowEnd: return { vitalsData, movementData, calibrationQuality: piezoCalibrationQuality(piezoProfile) } } -async function loadReference(side: Side, windowStart: Date, windowEnd: Date): Promise { +export async function loadReference(side: Side, windowStart: Date, windowEnd: Date): Promise { const rows = await biometricsDb .select() .from(referenceStages) @@ -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 + } + validateModel(model) + 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/system.ts b/src/server/routers/system.ts index 9e5b0f5c6..28fedd28d 100644 --- a/src/server/routers/system.ts +++ b/src/server/routers/system.ts @@ -1,3 +1,5 @@ +import { recordingChangeSchema, recordingPolicySchema } from '@/src/lib/recordingPolicy' +import { getRecordingPolicy, setRecordingPolicy } from '@/src/lib/recordingStore' import { z } from 'zod' import { branchNameSchema } from '@/src/server/validation-schemas' import { TRPCError } from '@trpc/server' @@ -7,6 +9,7 @@ import { accessSync, constants } from 'node:fs' import { readdir, readFile, stat } from 'node:fs/promises' import { promisify } from 'node:util' import { getStorageReport, runStorageCleanup } from '@/src/lib/podStorage' +import { getFlashReport } from '@/src/lib/flashSampler' import { classifyFirmware, expectedTransport, @@ -205,6 +208,7 @@ async function unblockWan(): Promise { * - Allow established connections * - Allow RFC 1918 LAN * - Allow NTP (port 123) + * - Allow mDNS (port 5353) * - Allow loopback * - DROP everything else */ @@ -231,6 +235,14 @@ async function blockWan(): Promise { await run(['-I', 'OUTPUT', '-p', 'udp', '--dport', '123', '-j', 'ACCEPT']) await run(['-I', 'INPUT', '-p', 'udp', '--sport', '123', '-j', 'ACCEPT']) + // Allow mDNS multicast (Bonjour discovery by the apps; 224.0.0.251 is + // outside the RFC 1918 ranges above, which already cover unicast mDNS). + // Limited to the group so no internet host is reachable on 5353; the + // startup check's `udp dpt:5353` / `udp spt:5353` still match these. + await run(['-I', 'OUTPUT', '-d', '224.0.0.251', '-p', 'udp', '--dport', '5353', '-j', 'ACCEPT']) + await run(['-I', 'INPUT', '-d', '224.0.0.251', '-p', 'udp', '--dport', '5353', '-j', 'ACCEPT']) + await run(['-I', 'OUTPUT', '-d', '224.0.0.251', '-p', 'udp', '--sport', '5353', '-j', 'ACCEPT']) + // Allow loopback await run(['-A', 'INPUT', '-i', 'lo', '-j', 'ACCEPT']) await run(['-A', 'OUTPUT', '-o', 'lo', '-j', 'ACCEPT']) @@ -259,7 +271,23 @@ async function blockWan(): Promise { * - `setInternetAccess` — toggle WAN block on/off * - `triggerUpdate` — kick off sp-update (or a deploy-driven install) */ +const flashWindow = z.object({ hours: z.number(), coveredHours: z.number(), bytes: z.number(), bytesPerDay: z.number().nullable() }) + +const wearRange = z.object({ minPercent: z.number(), maxPercent: z.number().nullable() }).nullable() + export const systemRouter = router({ + getRecordingPolicy: publicProcedure + .meta({ openapi: { method: 'GET', path: '/system/recording', protect: false, tags: ['System'] } }) + .input(z.object({})) + .output(recordingPolicySchema) + .query(() => getRecordingPolicy(Date.now(), true)), + + setRecordingPolicy: publicProcedure + .meta({ openapi: { method: 'POST', path: '/system/recording', protect: false, tags: ['System'] } }) + .input(recordingChangeSchema) + .output(recordingPolicySchema) + .mutation(({ input }) => setRecordingPolicy(input)), + /** * Returns whether WAN access is currently blocked by iptables. */ @@ -651,6 +679,28 @@ export const systemRouter = router({ })) .query(() => getStorageReport()), + /** + * eMMC endurance (System → Storage): the chip's own wear estimates and + * average writes per day for the whole device. Null off-pod. + */ + getFlashHealth: publicProcedure + .meta({ openapi: { method: 'GET', path: '/system/flash', protect: false, tags: ['System'] } }) + .input(z.object({})) + .output(z.object({ + device: z.string(), + name: z.string().nullable(), + capacityBytes: z.number().nullable(), + lifeTime: z.tuple([wearRange, wearRange]), + preEol: z.enum(['normal', 'warning', 'urgent']).nullable(), + insights: z.object({ + windows: z.array(flashWindow), + history: z.array(flashWindow.extend({ end: z.number() })), + }), + sinceBoot: z.object({ bytes: z.number(), uptimeSeconds: z.number() }), + perDay: z.object({ bytes: z.number(), coveredHours: z.number() }).nullable(), + }).nullable()) + .query(() => getFlashReport()), + /** * Delete sleepypod's own leftovers on /persistent (never the live install, * databases, raw archive, swap or HomeKit data). Old database backups only diff --git a/src/server/routers/tests/biometrics.compareToWatch.test.ts b/src/server/routers/tests/biometrics.compareToWatch.test.ts new file mode 100644 index 000000000..e098efa2f --- /dev/null +++ b/src/server/routers/tests/biometrics.compareToWatch.test.ts @@ -0,0 +1,197 @@ +/** + * compareToWatch against real in-memory sleepypod.db and biometrics.db with + * migrations applied: night selection, pairing across the record's edges, + * stage re-classification with the side's classifier, pooling and the + * window bounds. + */ + +import path from 'node:path' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { generateOpenApiDocument } from 'trpc-to-openapi' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as mainSchema from '@/src/db/schema' +import * as bioSchema from '@/src/db/biometrics-schema' +import { HRV_NOTE } from '@/src/biometrics/references/watch-deviation' +import { MOVEMENT_ONLY_STAGE_RULESET } from '@/src/lib/sleep-stage-rules' + +const dbHolder = vi.hoisted(() => ({ main: null as unknown, bio: null as unknown })) + +vi.mock('@/src/db', () => ({ + get db() { return dbHolder.main }, + get biometricsDb() { return dbHolder.bio }, +})) +vi.mock('@/src/server/routers/raw', () => ({ listRawFiles: vi.fn(async () => []) })) +vi.mock('@/src/lib/occupancy', () => ({ getOccupancy: vi.fn() })) + +const { biometricsRouter } = await import('@/src/server/routers/biometrics') +const caller = biometricsRouter.createCaller({}) + +const MIN = 60_000 +const HOUR = 60 * MIN +// 2026-10-10 23:00:00 UTC +const BED = new Date(Date.UTC(2026, 9, 10, 23)) +const at = (minutes: number, base = BED) => new Date(base.getTime() + minutes * MIN) +const WINDOW = { startDate: at(-60), endDate: at(3 * 24 * 60) } + +describe('biometrics.compareToWatch', () => { + let mainRaw: Database.Database + let bioRaw: Database.Database + let bio: ReturnType> + + beforeEach(() => { + mainRaw = new Database(':memory:') + const main = drizzle(mainRaw, { schema: mainSchema }) + migrate(main, { migrationsFolder: path.resolve(process.cwd(), 'src/db/migrations') }) + main.insert(mainSchema.sideSettings).values([{ side: 'left', name: 'Left' }, { side: 'right', name: 'Right' }]).run() + dbHolder.main = main + + bioRaw = new Database(':memory:') + bio = drizzle(bioRaw, { schema: bioSchema }) + migrate(bio, { migrationsFolder: path.resolve(process.cwd(), 'src/db/biometrics-migrations') }) + dbHolder.bio = bio + }) + + afterEach(() => { + mainRaw.close() + bioRaw.close() + }) + + /** A sleep record with pod vitals and movement every 5 minutes; HR 60, breathing 15, HRV 50. */ + function seedNight(base: Date, hours: number, side: 'left' | 'right' = 'left') { + const [record] = bio.insert(bioSchema.sleepRecords).values({ + side, enteredBedAt: base, leftBedAt: at(hours * 60, base), sleepDurationSeconds: hours * 3600, + timesExitedBed: 0, presentIntervals: [], notPresentIntervals: [], + }).returning().all() + const slots = Array.from({ length: hours * 12 }, (_, i) => at(i * 5, base)) + bio.insert(bioSchema.vitals).values(slots.map(timestamp => ({ side, timestamp, heartRate: 60, hrv: 50, breathingRate: 15 }))).run() + bio.insert(bioSchema.movement).values(slots.map(timestamp => ({ side, timestamp, totalMovement: 10 }))).run() + return record + } + + function seedWatchHr(side: 'left' | 'right', samples: Array<[Date, number]>) { + bio.insert(bioSchema.referenceVitals).values(samples.map(([timestamp, heartRate]) => ({ side, source: 'apple_watch' as const, timestamp, heartRate }))).run() + } + + function seedWatchSamples(metric: 'breathing_rate' | 'hrv_sdnn', samples: Array<[Date, number]>) { + bio.insert(bioSchema.referenceSamples).values(samples.map(([timestamp, value]) => ({ side: 'left' as const, source: 'apple_watch' as const, metric, timestamp, value }))).run() + } + + it('reports deviation per night with pod − Watch pairing and the default classifier\'s stages', async () => { + const record = seedNight(BED, 4) + // 30 s after a pod reading pairs (HR 60 vs 58); 2 min 30 s from the nearest reading does not. + seedWatchHr('left', [[at(0.5), 58], [at(10.5), 64], [at(17.5), 70]]) + seedWatchSamples('breathing_rate', [[at(2), 13], [at(60), 14]]) + seedWatchSamples('hrv_sdnn', [[at(120), 40]]) + bio.insert(bioSchema.referenceStages).values({ side: 'left', source: 'apple_watch', start: BED, end: at(4 * 60), stage: 'light' }).run() + + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + expect(out).toMatchObject({ side: 'left', minHours: 3, hrvNote: HRV_NOTE, classifier: { isDefault: true, source: 'default', generation: null } }) + expect(out.nights).toHaveLength(1) + const [night] = out.nights + expect(night).toMatchObject({ sleepRecordId: record.id, hours: 4, enteredBedAt: BED }) + // HR pairs: (60, 58) and (60, 64); 17.5 min is 2.5 min from the nearest pod reading. + expect(night.heartRate).toEqual({ n: 2, watchSamples: 3, coverage: 0.667, mae: 3, bias: -1, p90: 4, r: null }) + expect(night.breathingRate).toEqual({ n: 2, watchSamples: 2, coverage: 1, mae: 1.5, bias: 1.5, p90: 2, r: null }) + expect(night.hrv).toEqual({ n: 1, watchSamples: 1, coverage: 1, mae: 10, bias: 10, p90: 10, r: null }) + expect(night.stages).toMatchObject({ epochs: 48, recall: { light: expect.any(Number), wake: null, deep: null, rem: null } }) + expect(night.stages?.watchMinutes).toEqual({ wake: 0, light: 240, deep: 0, rem: 0 }) + const pod = night.stages?.podMinutes ?? { wake: 0, light: 0, deep: 0, rem: 0 } + expect(pod.wake + pod.light + pod.deep + pod.rem).toBe(240) + expect(out.summary).toMatchObject({ nights: 1, heartRate: night.heartRate, stages: night.stages }) + }) + + it('pairs a Watch sample at bed entry with a pod reading just before it, but only scores Watch samples inside the record', async () => { + seedNight(BED, 3) + bioRaw.prepare('DELETE FROM vitals WHERE timestamp = ?').run(BED.getTime() / 1000) + bio.insert(bioSchema.vitals).values({ side: 'left', timestamp: at(-20 / 60), heartRate: 70, hrv: 50, breathingRate: 15 }).run() + seedWatchHr('left', [[at(-0.25), 99], [at(0), 69]]) + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + // The bed-entry sample pairs with the pod reading 20 s before the record (the next one is 5 min later); + // the sample 15 s before bed entry is outside the record and not scored. + expect(out.nights[0].heartRate).toMatchObject({ n: 1, watchSamples: 1, mae: 1, bias: 1 }) + }) + + it('keeps only this side\'s records of at least minHours entered in the window, oldest first, and pools the summary', async () => { + const first = seedNight(BED, 3) + const second = seedNight(at(24 * 60), 4) + seedNight(at(48 * 60), 1) + seedNight(at(30 * 24 * 60), 5) + seedNight(BED, 5, 'right') + seedWatchHr('left', [[at(5), 70]]) + seedWatchHr('left', [[at(5, second.enteredBedAt), 62], [at(10, second.enteredBedAt), 62], [at(15, second.enteredBedAt), 62]]) + seedWatchHr('right', [[at(5), 99]]) + + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + expect(out.nights.map(n => n.sleepRecordId)).toEqual([first.id, second.id]) + expect(out.nights.map(n => n.heartRate.mae)).toEqual([10, 2]) + // Pooled over the 4 pairs: (10 + 2 + 2 + 2) / 4, not the mean of 10 and 2. + expect(out.summary).toMatchObject({ nights: 2, heartRate: { n: 4, watchSamples: 4, mae: 4, bias: -4 } }) + + const short = await caller.compareToWatch({ side: 'left', ...WINDOW, minHours: 1 }) + expect(short.nights).toHaveLength(3) + expect(short.nights[2]).toMatchObject({ hours: 1, heartRate: { watchSamples: 0, mae: null }, stages: null }) + }) + + it('re-runs the side\'s effective classifier and reports which one', async () => { + seedNight(BED, 3) + mainRaw.prepare('UPDATE side_settings SET stage_classifier = ? WHERE side = ?') + .run(JSON.stringify({ engine: 'rules', ruleset: MOVEMENT_ONLY_STAGE_RULESET }), 'left') + bio.insert(bioSchema.referenceStages).values({ side: 'left', source: 'apple_watch', start: BED, end: at(3 * 60), stage: 'light' }).run() + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + expect(out.classifier).toMatchObject({ isDefault: false, source: 'legacy' }) + expect(out.nights[0].stages).toMatchObject({ epochs: 36, accuracy: 1, podMinutes: { light: 180 } }) + }) + + it('lists profile generations acknowledged in the window at their latest numeric boundary, oldest first', async () => { + const receipt = (side: 'left' | 'right', component: string, generation: number, atMin: number | null, state: 'applied' | 'pending_boundary' = 'applied') => ({ + side, component, generation, appliedHash: `h${generation}`, effectiveSampleTimeMs: atMin === null ? null : at(atMin).getTime(), state, heartbeatAtMs: at(0).getTime(), + }) + bio.insert(bioSchema.biometricRuntimeReceipts).values([ + // Generation 3 takes effect once its last numeric component does; the stage consumer's receipt does not move it. + receipt('left', 'heart_rate', 3, 30 * 60), + receipt('left', 'breathing_rate', 3, 30 * 60 + 2), + receipt('left', 'hrv_index', 3, 30 * 60 + 1), + receipt('left', 'sleep_stages', 3, 40 * 60), + receipt('left', 'heart_rate', 2, 10 * 60), + receipt('left', 'heart_rate', 4, null, 'pending_boundary'), + receipt('left', 'heart_rate', 1, -2 * 60), + receipt('right', 'heart_rate', 5, 20 * 60), + ]).run() + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + expect(out.configChanges).toEqual([ + { generation: 2, atMs: at(10 * 60).getTime() }, + { generation: 3, atMs: at(30 * 60 + 2).getTime() }, + ]) + }) + + it('returns an empty report without nights and defaults to the last 14 days', async () => { + seedNight(at(-20 * 24 * 60, new Date()), 4) + const out = await caller.compareToWatch({ side: 'left' }) + expect(out.nights).toEqual([]) + expect(out.summary).toMatchObject({ nights: 0, heartRate: { n: 0, coverage: null, mae: null }, stages: null }) + expect(out.configChanges).toEqual([]) + expect(out.endDate.getTime() - out.startDate.getTime()).toBe(14 * 24 * HOUR) + }) + + it('rejects a reversed window, a window over 31 days and a negative minHours', async () => { + await expect(caller.compareToWatch({ side: 'left', startDate: at(60), endDate: BED })).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + await expect(caller.compareToWatch({ side: 'left', startDate: BED, endDate: at(32 * 24 * 60) })) + .rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Select at most 31 days' }) + await expect(caller.compareToWatch({ side: 'left', endDate: BED, startDate: at(-31 * 24 * 60) })).resolves.toMatchObject({ nights: [] }) + await expect(caller.compareToWatch({ side: 'left', minHours: -1 })).rejects.toThrow() + }) + + it('wraps database failures as INTERNAL_SERVER_ERROR', async () => { + bioRaw.exec('DROP TABLE reference_samples') + seedNight(BED, 3) + await expect(caller.compareToWatch({ side: 'left', ...WINDOW })) + .rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR', message: expect.stringContaining('Failed to compare with the Watch') }) + }) + + it('publishes a GET REST contract', () => { + const document = generateOpenApiDocument(biometricsRouter, { title: 'Biometrics', version: '1.0.0', baseUrl: 'http://localhost/api' }) + expect(document.paths?.['/biometrics/watch-deviation']?.get).toBeDefined() + }) +}) diff --git a/src/server/routers/tests/biometrics.referenceVitals.test.ts b/src/server/routers/tests/biometrics.referenceVitals.test.ts index 4884857a9..046f81acf 100644 --- a/src/server/routers/tests/biometrics.referenceVitals.test.ts +++ b/src/server/routers/tests/biometrics.referenceVitals.test.ts @@ -10,7 +10,8 @@ import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as schema from '@/src/db/biometrics-schema' -import { referenceVitals, vitals } from '@/src/db/biometrics-schema' +import { referenceSamples, referenceVitals, vitals } from '@/src/db/biometrics-schema' +import { ReferenceStore } from '@/src/biometrics/references/store' type Db = ReturnType> @@ -116,6 +117,87 @@ describe('biometrics reference vitals', () => { }) }) + describe('importReferenceSamples', () => { + const breathing = [{ timestamp: T0, value: 14.5 }, { timestamp: T0 + 5 * MIN, value: 15 }] + + it('stores breathing rate and HRV per metric with normalized provenance', async () => { + expect(await caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'breathing_rate', samples: breathing })).toEqual({ written: 2 }) + // Same second as a breathing sample: each metric keeps its own row. + expect(await caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'hrv_sdnn', samples: [{ timestamp: T0, value: 48 }] })).toEqual({ written: 1 }) + expect(raw.prepare('SELECT metric, timestamp, value FROM reference_samples ORDER BY metric, timestamp').all()).toEqual([ + { metric: 'breathing_rate', timestamp: T0, value: 14.5 }, + { metric: 'breathing_rate', timestamp: T0 + 5 * MIN, value: 15 }, + { metric: 'hrv_sdnn', timestamp: T0, value: 48 }, + ]) + expect(raw.prepare('SELECT metric, unit, value FROM reference_measurements ORDER BY metric, start_ms').all()).toEqual([ + { metric: 'breathing_rate', unit: 'breaths/min', value: 14.5 }, + { metric: 'breathing_rate', unit: 'breaths/min', value: 15 }, + { metric: 'hrv_sdnn', unit: 'ms', value: 48 }, + ]) + expect(raw.prepare('SELECT count(*) AS total FROM reference_samples WHERE import_id IS NULL').get()).toEqual({ total: 0 }) + expect(db.select().from(vitals).all()).toHaveLength(0) + }) + + it('is a no-op on repost and writes only new samples when a repost extends the night', async () => { + await caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'breathing_rate', samples: breathing }) + expect(await caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'breathing_rate', samples: breathing })).toEqual({ written: 0 }) + expect(await caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'breathing_rate', + samples: [...breathing, { timestamp: T0 + 10 * MIN, value: 16 }] })).toEqual({ written: 1 }) + expect(await caller.importReferenceSamples({ side: 'right', source: 'apple_watch', metric: 'breathing_rate', samples: breathing })).toEqual({ written: 2 }) + expect(db.select().from(referenceSamples).all()).toHaveLength(5) + expect(raw.prepare('SELECT count(*) AS total FROM reference_imports').get()).toEqual({ total: 3 }) + }) + + it('rejects implausible values per metric, unknown metrics and millisecond timestamps', async () => { + const post = (metric: 'breathing_rate' | 'hrv_sdnn', timestamp: number, value: number) => + caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric, samples: [{ timestamp, value }] }) + await expect(post('breathing_rate', T0, 2)).rejects.toThrow() + await expect(post('breathing_rate', T0, 80)).rejects.toThrow() + await expect(post('hrv_sdnn', T0, 0)).rejects.toThrow() + await expect(post('hrv_sdnn', T0, 600)).rejects.toThrow() + await expect(post('hrv_sdnn', T0 * 1000, 40)).rejects.toThrow() + await expect(post('heart_rate' as never, T0, 60)).rejects.toThrow() + expect(await post('hrv_sdnn', T0, 250)).toEqual({ written: 1 }) + }) + + it('deletes the samples with their reference import', async () => { + await caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'hrv_sdnn', samples: [{ timestamp: T0, value: 48 }] }) + const importId = db.select().from(referenceSamples).get()?.importId + expect(importId).toBeTruthy() + new ReferenceStore(db).delete(importId as string) + expect(db.select().from(referenceSamples).all()).toHaveLength(0) + }) + + it('surfaces a database failure as INTERNAL_SERVER_ERROR', async () => { + raw.exec('DROP TABLE reference_samples') + await expect(caller.importReferenceSamples({ side: 'left', source: 'apple_watch', metric: 'breathing_rate', samples: breathing })) + .rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR', message: /Failed to import reference samples/ }) + }) + }) + + 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/curveTemplates.test.ts b/src/server/routers/tests/curveTemplates.test.ts index 80afadcab..db2c2ad56 100644 --- a/src/server/routers/tests/curveTemplates.test.ts +++ b/src/server/routers/tests/curveTemplates.test.ts @@ -8,14 +8,32 @@ import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { eq } from 'drizzle-orm' import * as mainSchema from '@/src/db/schema' const dbHolder = vi.hoisted(() => ({ main: null as unknown })) +const jobs = vi.hoisted(() => ({ calls: [] as string[], committed: [] as boolean[], probe: null as null | (() => boolean) })) vi.mock('@/src/db', () => ({ get db() { return dbHolder.main }, })) +// Records every job change, and whether the schedule rows were already committed when it ran. +vi.mock('@/src/scheduler', () => ({ + getJobManager: async () => { + const record = (kind: string) => (arg: number | { id: number }) => { + jobs.calls.push(`${kind}:${typeof arg === 'number' ? arg : arg.id}`) + if (jobs.probe) jobs.committed.push(jobs.probe()) + } + return { + upsertTemperatureJob: record('upsertTemperature'), + cancelTemperatureJob: record('cancelTemperature'), + upsertPowerJob: record('upsertPower'), + cancelPowerJob: record('cancelPower'), + } + }, +})) + const { curveTemplatesRouter } = await import('@/src/server/routers/curveTemplates') const caller = curveTemplatesRouter.createCaller({}) @@ -24,11 +42,16 @@ const LATE_WARM = { name: 'Late Warm', bedtime: '01:30', wake: '10:30', points: describe('curveTemplates router', () => { let raw: Database.Database + let main: ReturnType> + beforeEach(() => { raw = new Database(':memory:') - const main = drizzle(raw, { schema: mainSchema }) + main = drizzle(raw, { schema: mainSchema }) migrate(main, { migrationsFolder: path.resolve(process.cwd(), 'src/db/migrations') }) dbHolder.main = main + jobs.calls = [] + jobs.committed = [] + jobs.probe = null }) afterEach(() => { @@ -36,6 +59,28 @@ describe('curveTemplates router', () => { vi.useRealTimers() }) + /** Each day's set points ("HH:mm@°F") on a side, plus its power window and note. */ + function sideRows(side: 'left' | 'right') { + const out: Record = {} + const day = (d: string) => (out[d] ??= { temps: [] }) + for (const t of main.select().from(mainSchema.temperatureSchedules).where(eq(mainSchema.temperatureSchedules.side, side)).all()) { + day(t.dayOfWeek).temps.push(`${t.time}@${t.temperature}`) + } + for (const p of main.select().from(mainSchema.powerSchedules).where(eq(mainSchema.powerSchedules.side, side)).all()) { + day(p.dayOfWeek).power = `${p.onTime}-${p.offTime}@${p.onTemperature}:${p.endAction}` + } + for (const n of main.select().from(mainSchema.scheduleNotes).where(eq(mainSchema.scheduleNotes.side, side)).all()) { + day(n.dayOfWeek).notes = n.notes + } + for (const v of Object.values(out)) v.temps.sort() + return out + } + + const NIGHT = { '22:00': 70, '02:00': 66, '06:30': 78 } + const NIGHT_ROWS = { temps: ['02:00@66', '06:30@78', '22:00@70'], power: '22:00-06:30@70:turn_off' } + const curve = (name: string, days: { left?: string[], right?: string[] }, active = true, extra: Record = {}) => + caller.put({ name, bedtime: '22:00', wake: '06:30', points: NIGHT, days: days as never, active, ...extra }) + it('saves, lists newest first and replaces by name', async () => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2026-10-13T20:00:00Z')) @@ -77,4 +122,132 @@ describe('curveTemplates router', () => { } await expect(caller.list({})).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to list saved curves: disk gone' }) }) + + it('lists days, active and end action, with new curves saved off and without days', async () => { + await caller.save(LATE_WARM) + const [t] = await caller.list({}) + expect(t).toMatchObject({ endAction: 'turn_off', days: { left: [], right: [] }, active: false }) + expect(t).not.toHaveProperty('leftDays') + expect(sideRows('left')).toEqual({}) + }) + + it('put creates an active curve and writes its rows, power window and note on each side and day', async () => { + const { curve: created, moved } = await curve('Weeknight', { left: ['tuesday', 'monday', 'monday'], right: ['monday'] }, true, { notes: 'Cool start' }) + expect(created).toMatchObject({ name: 'Weeknight', active: true, days: { left: ['monday', 'tuesday'], right: ['monday'] } }) + expect(moved).toEqual([]) + const day = { ...NIGHT_ROWS, notes: 'Cool start' } + expect(sideRows('left')).toEqual({ monday: day, tuesday: day }) + expect(sideRows('right')).toEqual({ monday: day }) + expect(jobs.calls.filter(c => c.startsWith('upsertTemperature'))).toHaveLength(9) + expect(jobs.calls.filter(c => c.startsWith('upsertPower'))).toHaveLength(3) + }) + + it('put with maintain writes a maintain power window', async () => { + await curve('Hold', { left: ['friday'] }, true, { endAction: 'maintain' }) + expect(sideRows('left').friday.power).toBe('22:00-06:30@70:maintain') + }) + + it('put saves an active curve with no days as off and writes nothing', async () => { + const { curve: c } = await curve('Nowhere', {}, true) + expect(c.active).toBe(false) + expect(sideRows('left')).toEqual({}) + expect(jobs.calls).toEqual([]) + }) + + it('put moves shared days per side from other active curves and turns off a curve left with none', async () => { + const a = (await curve('A', { left: ['monday', 'tuesday'], right: ['monday'] })).curve + const b = (await curve('B', { left: ['wednesday'] })).curve + await curve('Off', { left: ['monday'] }, false) + const { moved } = await caller.put({ name: 'C', bedtime: '23:00', wake: '07:00', points: { '23:00': 75, '07:00': 80 }, days: { left: ['monday', 'wednesday'], right: [] }, active: true }) + expect(moved).toEqual([ + { name: 'A', side: 'left', days: ['monday'], deactivated: false }, + { name: 'B', side: 'left', days: ['wednesday'], deactivated: true }, + ]) + const byName = Object.fromEntries((await caller.list({})).map(t => [t.name, t])) + expect(byName.A).toMatchObject({ id: a.id, active: true, days: { left: ['tuesday'], right: ['monday'] } }) + expect(byName.B).toMatchObject({ id: b.id, active: false, days: { left: [], right: [] } }) + // Inactive curves keep their days; they never run. + expect(byName.Off).toMatchObject({ active: false, days: { left: ['monday'], right: [] } }) + const left = sideRows('left') + expect(left.monday.temps).toEqual(['07:00@80', '23:00@75']) + expect(left.wednesday.temps).toEqual(['07:00@80', '23:00@75']) + expect(left.tuesday).toEqual(NIGHT_ROWS) + expect(sideRows('right').monday).toEqual(NIGHT_ROWS) + }) + + it('put updates by id: renames, clears days it gave up and keeps the other side', async () => { + const { curve: c } = await curve('Week', { left: ['monday', 'tuesday'], right: ['sunday'] }) + await caller.put({ id: c.id, name: 'Weekday', bedtime: '22:00', wake: '07:00', points: { '22:00': 72, '07:00': 80 }, days: { left: ['tuesday'], right: ['sunday'] }, active: true }) + const left = sideRows('left') + expect(left.monday).toBeUndefined() + expect(left.tuesday.temps).toEqual(['07:00@80', '22:00@72']) + expect(sideRows('right').sunday.temps).toEqual(['07:00@80', '22:00@72']) + expect((await caller.list({})).map(t => t.name)).toEqual(['Weekday']) + await expect(caller.put({ id: 9999, name: 'X', bedtime: '22:00', wake: '07:00', points: NIGHT, days: { left: [], right: [] }, active: false })) + .rejects.toMatchObject({ code: 'NOT_FOUND' }) + }) + + it('put turning a curve off clears its days and keeps alarms and other days', async () => { + const { curve: c } = await curve('Week', { left: ['monday'] }) + await curve('Other', { left: ['friday'] }) + main.insert(mainSchema.alarmSchedules).values({ side: 'left', dayOfWeek: 'monday', time: '07:00', vibrationIntensity: 50, duration: 60, alarmTemperature: 80 }).run() + const { curve: off } = await caller.put({ id: c.id, name: 'Week', bedtime: '22:00', wake: '06:30', points: NIGHT, days: { left: ['monday'], right: [] }, active: false }) + expect(off).toMatchObject({ active: false, days: { left: ['monday'], right: [] } }) + expect(Object.keys(sideRows('left'))).toEqual(['friday']) + expect(main.select().from(mainSchema.alarmSchedules).all()).toHaveLength(1) + }) + + it('rejects a duplicate name as CONFLICT', async () => { + await curve('Same', { left: ['monday'] }) + await expect(curve('Same', { left: ['tuesday'] })).rejects.toMatchObject({ code: 'CONFLICT', message: 'Another curve already has that name' }) + }) + + it('save replaces set points by name, keeps days and rewrites an active curve', async () => { + await curve('Week', { left: ['monday'] }) + const saved = await caller.save({ name: 'Week', bedtime: '21:00', wake: '06:00', points: { '21:00': 74, '06:00': 82 }, endAction: 'maintain' }) + expect(saved).toMatchObject({ active: true, days: { left: ['monday'], right: [] }, endAction: 'maintain' }) + expect(sideRows('left').monday).toEqual({ temps: ['06:00@82', '21:00@74'], power: '21:00-06:00@74:maintain' }) + + // Without endAction the saved one is kept; an inactive curve writes nothing. + await curve('Idle', { left: ['friday'] }, false, { endAction: 'maintain' }) + expect((await caller.save({ name: 'Idle', bedtime: '21:00', wake: '06:00', points: { '21:00': 74, '06:00': 82 } })).endAction).toBe('maintain') + expect(sideRows('left').friday).toBeUndefined() + }) + + it('setActive turns a curve off and back on, moving days back from others', async () => { + const { curve: a } = await curve('A', { left: ['monday', 'tuesday'] }) + expect(await caller.setActive({ id: a.id, active: true })).toMatchObject({ moved: [] }) + const off = await caller.setActive({ id: a.id, active: false }) + expect(off).toMatchObject({ curve: { active: false, days: { left: ['monday', 'tuesday'], right: [] } }, moved: [] }) + expect(sideRows('left')).toEqual({}) + + await curve('B', { left: ['tuesday'] }) + const on = await caller.setActive({ id: a.id, active: true }) + expect(on.moved).toEqual([{ name: 'B', side: 'left', days: ['tuesday'], deactivated: true }]) + expect(sideRows('left')).toEqual({ monday: NIGHT_ROWS, tuesday: NIGHT_ROWS }) + }) + + it('setActive refuses a curve without days and an unknown id', async () => { + const t = await caller.save(LATE_WARM) + await expect(caller.setActive({ id: t.id, active: true })).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Pick days for Late Warm before turning it on' }) + await expect(caller.setActive({ id: 9999, active: false })).rejects.toMatchObject({ code: 'NOT_FOUND' }) + expect((await caller.setActive({ id: t.id, active: false })).curve.active).toBe(false) + }) + + it('deleting an active curve clears its rows and notes', async () => { + await curve('Week', { left: ['monday'], right: ['monday'] }, true, { notes: 'n' }) + jobs.calls = [] + expect(await caller.delete({ name: 'Week' })).toEqual({ deleted: true }) + expect(sideRows('left')).toEqual({}) + expect(sideRows('right')).toEqual({}) + expect(jobs.calls.filter(c => c.startsWith('cancelTemperature'))).toHaveLength(6) + expect(jobs.calls.filter(c => c.startsWith('cancelPower'))).toHaveLength(2) + }) + + it('hands the scheduler its job changes only after the rows are committed', async () => { + jobs.probe = () => !raw.inTransaction && main.select().from(mainSchema.temperatureSchedules).all().length > 0 + await curve('Week', { left: ['monday'] }) + expect(jobs.committed.length).toBeGreaterThan(0) + expect(jobs.committed.every(Boolean)).toBe(true) + }) }) diff --git a/src/server/routers/tests/schedules.test.ts b/src/server/routers/tests/schedules.test.ts index b657e2e14..b733497c1 100644 --- a/src/server/routers/tests/schedules.test.ts +++ b/src/server/routers/tests/schedules.test.ts @@ -35,7 +35,9 @@ vi.mock('@/src/db', async () => { }) import { schedulesRouter } from '@/src/server/routers/schedules' -import { sqlite } from '@/src/db' +import { db, sqlite } from '@/src/db' +import { curveTemplates } from '@/src/db/schema' +import { eq } from 'drizzle-orm' const caller = schedulesRouter.createCaller({}) @@ -72,6 +74,8 @@ const SCHEMA_SQL = ` duration INTEGER NOT NULL, alarm_temperature REAL NOT NULL, wake_window INTEGER NOT NULL DEFAULT 0, + snooze_duration INTEGER NOT NULL DEFAULT 300, + snooze_taper INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) @@ -84,6 +88,21 @@ const SCHEMA_SQL = ` updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_schedule_notes_side_day ON schedule_notes (side, day_of_week); + CREATE TABLE IF NOT EXISTS curve_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + bedtime TEXT NOT NULL, + wake TEXT NOT NULL, + points TEXT NOT NULL, + reasoning TEXT NOT NULL DEFAULT '', + end_action TEXT NOT NULL DEFAULT 'turn_off', + left_days TEXT NOT NULL DEFAULT '[]', + right_days TEXT NOT NULL DEFAULT '[]', + active INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_curve_templates_name ON curve_templates (name); ` function createTables() { @@ -96,6 +115,7 @@ function clearTables() { DELETE FROM power_schedules; DELETE FROM alarm_schedules; DELETE FROM schedule_notes; + DELETE FROM curve_templates; `) } @@ -611,6 +631,34 @@ describe('schedules.alarm CRUD', () => { expect(updated.wakeWindow).toBe(30) }) + it('defaults the snooze to 5 minutes without a taper and stores chosen ones', async () => { + const plain = await caller.createAlarmSchedule({ + side: 'left', dayOfWeek: 'monday', time: '07:00', + vibrationIntensity: 50, duration: 120, alarmTemperature: 80, + } as any) + expect([plain.snoozeDuration, plain.snoozeTaper]).toEqual([300, 0]) + + const tapered = await caller.createAlarmSchedule({ + side: 'left', dayOfWeek: 'tuesday', time: '07:00', + vibrationIntensity: 50, duration: 120, alarmTemperature: 80, snoozeDuration: 540, snoozeTaper: 30, + }) + expect([tapered.snoozeDuration, tapered.snoozeTaper]).toEqual([540, 30]) + }) + + it.each([ + [{ snoozeTaper: 20 }], + [{ snoozeTaper: 70 }], + [{ snoozeDuration: 30 }], + [{ snoozeDuration: 1801 }], + ])('rejects snooze settings %j', async (snooze) => { + await expect( + caller.createAlarmSchedule({ + side: 'left', dayOfWeek: 'monday', time: '07:00', + vibrationIntensity: 50, duration: 120, alarmTemperature: 80, ...snooze, + } as any) + ).rejects.toThrow() + }) + it.each([5, 25, 45, -10, 12.5])('rejects a %s minute wake window', async (wakeWindow) => { await expect( caller.createAlarmSchedule({ @@ -1469,7 +1517,7 @@ describe('schedules openapi meta + input schema contract', () => { expect(Object.keys(power).sort()).toEqual( ['createdAt', 'dayOfWeek', 'enabled', 'endAction', 'id', 'offTime', 'onTemperature', 'onTime', 'side', 'updatedAt']) expect(Object.keys(alarm).sort()).toEqual( - ['alarmTemperature', 'createdAt', 'dayOfWeek', 'duration', 'enabled', 'id', 'side', 'time', 'updatedAt', 'vibrationIntensity', 'vibrationPattern', 'wakeWindow']) + ['alarmTemperature', 'createdAt', 'dayOfWeek', 'duration', 'enabled', 'id', 'side', 'snoozeDuration', 'snoozeTaper', 'time', 'updatedAt', 'vibrationIntensity', 'vibrationPattern', 'wakeWindow']) for (const collection of [await c.getAll({ side: 'left' }), await c.getByDay({ side: 'left', dayOfWeek: 'monday' })]) { expect(Object.keys(collection).sort()).toEqual(['alarm', 'power', 'temperature']) @@ -1515,3 +1563,83 @@ describe('schedule end actions', () => { expect((await caller.getAll({ side: 'right' })).power[0].endAction).toBe('maintain') }) }) + +describe('row edits release days from the schedule library', () => { + beforeEach(() => { + clearTables() + resetSchedulerMocks() + }) + + async function addCurve(name: string, leftDays: string[], rightDays: string[] = [], active = true) { + const now = new Date() + return db.insert(curveTemplates).values({ + name, bedtime: '22:00', wake: '07:00', points: { '22:00': 70, '07:00': 80 }, + leftDays: leftDays as any, rightDays: rightDays as any, active, createdAt: now, updatedAt: now, + }).returning().get() + } + + async function curve(name: string) { + const row = db.select().from(curveTemplates).where(eq(curveTemplates.name, name)).get() + return { left: row?.leftDays, right: row?.rightDays, active: row?.active } + } + + it('creating, editing or deleting a single temperature row releases that side and day', async () => { + await addCurve('Week', ['monday', 'tuesday', 'wednesday'], ['monday']) + const row = await caller.createTemperatureSchedule({ side: 'left', dayOfWeek: 'monday', time: '22:00', temperature: 70 }) + expect(await curve('Week')).toEqual({ left: ['tuesday', 'wednesday'], right: ['monday'], active: true }) + + const tue = await caller.createTemperatureSchedule({ side: 'right', dayOfWeek: 'tuesday', time: '22:00', temperature: 70 }) + expect(await curve('Week')).toEqual({ left: ['tuesday', 'wednesday'], right: ['monday'], active: true }) + + await caller.updateTemperatureSchedule({ id: row.id, temperature: 72 }) + await caller.deleteTemperatureSchedule({ id: tue.id }) + expect(await curve('Week')).toEqual({ left: ['tuesday', 'wednesday'], right: ['monday'], active: true }) + + await caller.batchUpdate({ creates: { temperature: [{ side: 'left', dayOfWeek: 'wednesday', time: '23:00', temperature: 70 }] } }) + expect(await curve('Week')).toEqual({ left: ['tuesday'], right: ['monday'], active: true }) + }) + + it('updating or deleting an existing row releases the day it is on', async () => { + const row = await caller.createTemperatureSchedule({ side: 'left', dayOfWeek: 'friday', time: '22:00', temperature: 70 }) + const power = await caller.createPowerSchedule({ side: 'left', dayOfWeek: 'saturday', onTime: '22:00', offTime: '07:00', onTemperature: 70 }) + await addCurve('Weekend', ['friday', 'saturday', 'sunday']) + + await caller.updateTemperatureSchedule({ id: row.id, time: '22:30' }) + expect(await curve('Weekend')).toMatchObject({ left: ['sunday', 'saturday'], active: true }) + + await caller.deletePowerSchedule({ id: power.id }) + expect(await curve('Weekend')).toMatchObject({ left: ['sunday'], active: true }) + }) + + it('a curve that loses its last day turns off', async () => { + const power = await caller.createPowerSchedule({ side: 'right', dayOfWeek: 'sunday', onTime: '22:00', offTime: '07:00', onTemperature: 70 }) + await addCurve('Sunday', [], ['sunday']) + await caller.updatePowerSchedule({ id: power.id, onTemperature: 72 }) + expect(await curve('Sunday')).toEqual({ left: [], right: [], active: false }) + }) + + it('pausing (enabled-only updates) keeps the days on their curve', async () => { + const temp = await caller.createTemperatureSchedule({ side: 'left', dayOfWeek: 'monday', time: '22:00', temperature: 70 }) + const power = await caller.createPowerSchedule({ side: 'left', dayOfWeek: 'monday', onTime: '22:00', offTime: '07:00', onTemperature: 70 }) + await addCurve('Mon', ['monday']) + await caller.updateTemperatureSchedule({ id: temp.id, enabled: false }) + await caller.updatePowerSchedule({ id: power.id, enabled: false }) + await caller.batchUpdate({ updates: { temperature: [{ id: temp.id, enabled: true }], power: [{ id: power.id, enabled: true }] } }) + expect(await curve('Mon')).toEqual({ left: ['monday'], right: [], active: true }) + }) + + it('batchUpdate releases deleted and edited rows, and leaves inactive curves alone', async () => { + const temp = await caller.createTemperatureSchedule({ side: 'left', dayOfWeek: 'monday', time: '22:00', temperature: 70 }) + const power = await caller.createPowerSchedule({ side: 'left', dayOfWeek: 'tuesday', onTime: '22:00', offTime: '07:00', onTemperature: 70 }) + const other = await caller.createPowerSchedule({ side: 'left', dayOfWeek: 'thursday', onTime: '22:00', offTime: '07:00', onTemperature: 70 }) + await addCurve('On', ['monday', 'tuesday', 'wednesday', 'thursday']) + await addCurve('Off', ['monday', 'tuesday'], [], false) + await caller.batchUpdate({ + deletes: { temperature: [temp.id] }, + updates: { power: [{ id: power.id, offTime: '08:00' }, { id: other.id, enabled: false }] }, + creates: { power: [{ side: 'left', dayOfWeek: 'sunday', onTime: '22:00', offTime: '07:00', onTemperature: 70 }] }, + }) + expect(await curve('On')).toEqual({ left: ['wednesday', 'thursday'], right: [], active: true }) + expect(await curve('Off')).toEqual({ left: ['monday', 'tuesday'], right: [], active: false }) + }) +}) diff --git a/src/server/routers/tests/settings.test.ts b/src/server/routers/tests/settings.test.ts index 85611b4f3..65217f4e6 100644 --- a/src/server/routers/tests/settings.test.ts +++ b/src/server/routers/tests/settings.test.ts @@ -587,6 +587,24 @@ describe('settings.setGesture / deleteGesture', () => { expect(out.id).toBe(1) }) + it('accepts a ringing action on a temperature gesture', async () => { + const created = { + id: 2, side: 'left', tapType: 'tripleTap', actionType: 'temperature', + temperatureChange: 'increment', temperatureAmount: 2, alarmBehavior: 'snooze', alarmSnoozeDuration: 420, + createdAt: new Date(0), updatedAt: new Date(0), + } + dbState.txRowsQueue.push([], [created]) + const out = await caller.setGesture({ + side: 'left', tapType: 'tripleTap', actionType: 'temperature', + temperatureChange: 'increment', temperatureAmount: 2, alarmBehavior: 'snooze', alarmSnoozeDuration: 420, + }) + expect(out).toMatchObject({ alarmBehavior: 'snooze', alarmSnoozeDuration: 420 }) + await expect(caller.setGesture({ + side: 'left', tapType: 'tripleTap', actionType: 'temperature', + temperatureChange: 'increment', temperatureAmount: 2, alarmBehavior: 'ring' as never, + })).rejects.toThrow() + }) + it('updates an alarm gesture when one already exists', async () => { const existing = { id: 5, side: 'left', tapType: 'doubleTap' } const updated = { @@ -1215,11 +1233,39 @@ describe('settings.setGesture — write payload and error surface', () => { side: 'left', tapType: 'doubleTap', actionType: 'alarm', + temperatureChange: null, + temperatureAmount: null, alarmBehavior: 'snooze', + alarmSnoozeDuration: null, + alarmInactiveBehavior: null, updatedAt: expect.any(Date), }]) }) + it('switching a tap from alarm to temperature keeps none of the old alarm settings', async () => { + // Ringing actions apply to temperature taps too, so a leftover would act. + dbState.txRowsQueue.push([{ id: 5, side: 'left', tapType: 'doubleTap' }], [{ id: 5 }]) + await caller.setGesture({ + side: 'left', tapType: 'doubleTap', actionType: 'temperature', + temperatureChange: 'decrement', temperatureAmount: 2, + }) + expect(dbState.txSetCalls).toEqual([expect.objectContaining({ + actionType: 'temperature', temperatureChange: 'decrement', temperatureAmount: 2, + alarmBehavior: null, alarmSnoozeDuration: null, alarmInactiveBehavior: null, + })]) + }) + + it('a temperature tap saved with a ringing action keeps it', async () => { + dbState.txRowsQueue.push([{ id: 5, side: 'left', tapType: 'doubleTap' }], [{ id: 5 }]) + await caller.setGesture({ + side: 'left', tapType: 'doubleTap', actionType: 'temperature', + temperatureChange: 'decrement', temperatureAmount: 2, alarmBehavior: 'snooze', alarmSnoozeDuration: 420, + }) + expect(dbState.txSetCalls).toEqual([expect.objectContaining({ + alarmBehavior: 'snooze', alarmSnoozeDuration: 420, alarmInactiveBehavior: null, + })]) + }) + it('rethrows the inner TRPCError unwrapped when the update returns no row', async () => { dbState.txRowsQueue.push([{ id: 9, side: 'left', tapType: 'doubleTap' }], []) await expect(caller.setGesture({ diff --git a/src/server/routers/tests/sleepStages.test.ts b/src/server/routers/tests/sleepStages.test.ts index 46d58428f..ff2c90950 100644 --- a/src/server/routers/tests/sleepStages.test.ts +++ b/src/server/routers/tests/sleepStages.test.ts @@ -409,4 +409,158 @@ 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') + // An unknown feature contract is bad input too, even when its own vectors pass. + const rename = (name: string) => `${name}_unknown` + const unknown = { + ...model, + featureContract: model.featureContract.map(rename), + testVectors: model.testVectors.map(v => ({ ...v, inputs: Object.fromEntries(Object.entries(v.inputs).map(([k, x]) => [rename(k), x])) })), + } + expect((await rejectionOf(caller.previewModel({ side: 'left', model: unknown }))).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') + }) + }) }) diff --git a/src/server/routers/tests/system.test.ts b/src/server/routers/tests/system.test.ts index 4ffb0966a..d0de096ae 100644 --- a/src/server/routers/tests/system.test.ts +++ b/src/server/routers/tests/system.test.ts @@ -10,6 +10,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +const recordingMock = vi.hoisted(() => ({ + get: vi.fn(() => ({ loggingMode: 'quiet', debugUntil: null, healthHistory: true, rawArchive: true })), + set: vi.fn(() => ({ loggingMode: 'off', debugUntil: null, healthHistory: true, rawArchive: true })), +})) +vi.mock('@/src/lib/recordingStore', () => ({ getRecordingPolicy: recordingMock.get, setRecordingPolicy: recordingMock.set })) + const execMock = vi.hoisted(() => ({ execFile: vi.fn((..._args: unknown[]) => { const cb = _args[_args.length - 1] as (e: unknown, o: { stdout: string }) => void @@ -682,6 +688,9 @@ describe('system.setInternetAccess — exact iptables sequences', () => { ['-A', 'OUTPUT', '-d', '192.168.0.0/16', '-j', 'ACCEPT'], ['-I', 'OUTPUT', '-p', 'udp', '--dport', '123', '-j', 'ACCEPT'], ['-I', 'INPUT', '-p', 'udp', '--sport', '123', '-j', 'ACCEPT'], + ['-I', 'OUTPUT', '-d', '224.0.0.251', '-p', 'udp', '--dport', '5353', '-j', 'ACCEPT'], + ['-I', 'INPUT', '-d', '224.0.0.251', '-p', 'udp', '--dport', '5353', '-j', 'ACCEPT'], + ['-I', 'OUTPUT', '-d', '224.0.0.251', '-p', 'udp', '--sport', '5353', '-j', 'ACCEPT'], ['-A', 'INPUT', '-i', 'lo', '-j', 'ACCEPT'], ['-A', 'OUTPUT', '-o', 'lo', '-j', 'ACCEPT'], ['-A', 'INPUT', '-j', 'DROP'], @@ -1055,3 +1064,16 @@ describe('system.getSensorSource', () => { expect(execMock.execFile).not.toHaveBeenCalled() }) }) + +describe('recording policy API', () => { + it('returns current policy and applies a validated independent setting', async () => { + expect(await caller.getRecordingPolicy({})).toMatchObject({ loggingMode: 'quiet' }) + expect(await caller.setRecordingPolicy({ loggingMode: 'off' })).toMatchObject({ loggingMode: 'off' }) + expect(recordingMock.set).toHaveBeenCalledWith({ loggingMode: 'off' }) + }) + + it('rejects arbitrary expiry values and invalid modes', async () => { + await expect(caller.setRecordingPolicy({ debugUntil: 9999999999999 } as never)).rejects.toThrow() + await expect(caller.setRecordingPolicy({ loggingMode: 'everything' } as never)).rejects.toThrow() + }) +}) diff --git a/src/server/scheduleLibrary.ts b/src/server/scheduleLibrary.ts new file mode 100644 index 000000000..91b54fdf7 --- /dev/null +++ b/src/server/scheduleLibrary.ts @@ -0,0 +1,248 @@ +/** + * Keeps the temperature and power schedule rows in step with the schedule + * library (curve_templates). The scheduler only ever reads the rows; an + * active curve's set points are written to every side and day it claims, + * and taking a day away from a curve clears or replaces that day's rows. + */ +import { and, eq, inArray } from 'drizzle-orm' +import type { db } from '@/src/db' +import { curveTemplates, powerSchedules, scheduleNotes, sideSettings, temperatureSchedules } from '@/src/db/schema' +import type { getJobManager } from '@/src/scheduler' +import { endActionForDay, sortChronological } from '@/src/lib/scheduleGrouping' +import { planCurveSave } from '@/src/lib/sleepCurve/savePlan' +import { DAYS_OF_WEEK, type DayOfWeek } from '@/src/lib/scheduleTime' +import { + activeCurveOn, + type CurveDays, + hasDays, + LIBRARY_SIDES, + type LibraryCurveClaim, + type LibrarySide, + normalizeDays, + pointsToSetPoints, + setPointsToPoints, + uniqueCurveName, +} from '@/src/lib/scheduleLibrary' +import { formatDayRange } from '@/src/components/Schedule/scheduleFormat' + +export type Tx = Parameters[0]>[0] +type CurveRow = typeof curveTemplates.$inferSelect +type TemperatureRow = typeof temperatureSchedules.$inferSelect +type PowerRow = typeof powerSchedules.$inferSelect + +/** Job changes to hand the scheduler once the transaction commits. */ +export interface SchedulerOps { + cancelTemperature: number[] + cancelPower: number[] + upsertTemperature: TemperatureRow[] + upsertPower: PowerRow[] +} + +export function emptySchedulerOps(): SchedulerOps { + return { cancelTemperature: [], cancelPower: [], upsertTemperature: [], upsertPower: [] } +} + +/** + * Apply an incremental scheduler mutation. The helper resolves the job + * manager and invokes `fn`; failures are logged but never thrown out of the + * route handler — schedule writes already committed, and the heartbeat + * liveness loop will recover any drift. + */ +export async function applyScheduler(fn: (jm: Awaited>) => void | Promise): Promise { + try { + // Loaded on use: adopting schedules during migrations must not start the scheduler's module graph. + const { getJobManager } = await import('@/src/scheduler') + const jobManager = await getJobManager() + await fn(jobManager) + } + catch (e) { + console.error('Scheduler update failed:', e) + } +} + +export async function applySchedulerOps(ops: SchedulerOps): Promise { + if (ops.cancelTemperature.length + ops.cancelPower.length + ops.upsertTemperature.length + ops.upsertPower.length === 0) return + await applyScheduler((jm) => { + for (const id of ops.cancelTemperature) jm.cancelTemperatureJob(id) + for (const id of ops.cancelPower) jm.cancelPowerJob(id) + for (const row of ops.upsertTemperature) jm.upsertTemperatureJob(row) + for (const row of ops.upsertPower) jm.upsertPowerJob(row) + }) +} + +export function curveDays(row: Pick): CurveDays { + return normalizeDays({ left: row.leftDays, right: row.rightDays }) +} + +export function toClaim(row: CurveRow): LibraryCurveClaim { + return { id: row.id, name: row.name, days: curveDays(row), active: row.active } +} + +/** Write `curve` on `side` for `targetDays` and clear `clearDays`; other days and alarms are untouched. */ +function writeSide(tx: Tx, curve: Pick, side: LibrarySide, targetDays: DayOfWeek[], clearDays: DayOfWeek[], ops: SchedulerOps) { + const existing = { + temperature: tx.select({ id: temperatureSchedules.id, dayOfWeek: temperatureSchedules.dayOfWeek }) + .from(temperatureSchedules).where(eq(temperatureSchedules.side, side)).all(), + power: tx.select({ id: powerSchedules.id, dayOfWeek: powerSchedules.dayOfWeek }) + .from(powerSchedules).where(eq(powerSchedules.side, side)).all(), + } + const plan = planCurveSave({ + sides: [side], + targetDays, + clearDays, + setPoints: pointsToSetPoints(curve.points), + endAction: curve.endAction, + notes: curve.notes, + existing, + }) + + if (plan.deletes.temperature.length > 0) { + tx.delete(temperatureSchedules).where(inArray(temperatureSchedules.id, plan.deletes.temperature)).run() + ops.cancelTemperature.push(...plan.deletes.temperature) + } + if (plan.deletes.power.length > 0) { + tx.delete(powerSchedules).where(inArray(powerSchedules.id, plan.deletes.power)).run() + ops.cancelPower.push(...plan.deletes.power) + } + for (const entry of plan.creates.temperature) { + ops.upsertTemperature.push(tx.insert(temperatureSchedules).values(entry).returning().get()) + } + for (const entry of plan.creates.power) { + ops.upsertPower.push(tx.insert(powerSchedules).values(entry).returning().get()) + } + for (const { dayOfWeek, notes } of plan.notes) { + if (notes) { + tx.insert(scheduleNotes).values({ side, dayOfWeek, notes, updatedAt: new Date() }) + .onConflictDoUpdate({ target: [scheduleNotes.side, scheduleNotes.dayOfWeek], set: { notes, updatedAt: new Date() } }) + .run() + } + else { + tx.delete(scheduleNotes).where(and(eq(scheduleNotes.side, side), eq(scheduleNotes.dayOfWeek, dayOfWeek))).run() + } + } +} + +/** + * Bring the rows in line with `curve` after it changed: an active curve is + * written to its days, and days it held before (`before`, null for a new + * curve) but no longer runs on are cleared. Days another curve just gave up + * are among the ones written, so their old rows are replaced here too. + */ +export function syncCurveRows(tx: Tx, curve: CurveRow, before: { days: CurveDays, active: boolean } | null, ops: SchedulerOps) { + const now = curveDays(curve) + for (const side of LIBRARY_SIDES) { + const target = curve.active ? now[side] : [] + const clear = before?.active ? before.days[side] : [] + if (target.length === 0 && clear.length === 0) continue + writeSide(tx, curve, side, target, clear, ops) + } +} + +/** Store new days and active flags on other curves (moving days does not reorder the library). */ +export function applyClaimUpdates(tx: Tx, updates: Array<{ id: number, days: CurveDays, active: boolean }>) { + for (const u of updates) { + tx.update(curveTemplates) + .set({ leftDays: u.days.left, rightDays: u.days.right, active: u.active }) + .where(eq(curveTemplates.id, u.id)) + .run() + } +} + +/** + * Rows on these sides and days were edited one by one (not through the + * library), so they no longer match the curve that claimed them: the curve + * gives those days up, and stops being active if it has none left. + */ +export function releaseDays(tx: Tx, pairs: Array<{ side: LibrarySide, dayOfWeek: DayOfWeek }>) { + if (pairs.length === 0) return + const active = tx.select().from(curveTemplates).where(eq(curveTemplates.active, true)).all() + for (const curve of active) { + const days = curveDays(curve) + let changed = false + for (const { side, dayOfWeek } of pairs) { + if (!days[side].includes(dayOfWeek)) continue + days[side] = days[side].filter(d => d !== dayOfWeek) + changed = true + } + if (changed) applyClaimUpdates(tx, [{ id: curve.id, days, active: hasDays(days) }]) + } +} + +function uniqueName(base: string, taken: Set): string { + const name = uniqueCurveName(base, taken) + taken.add(name) + return name +} + +/** + * Put every schedule that is not in the library yet into it: days on a side + * with the same set points and end action become one curve (one curve for + * both sides when they match), active on those days. Rows are not + * rewritten, so what the Pod does is unchanged. A saved curve with no days + * and the same set points is used instead of a new one. Returns the number + * of curves that now run schedules they did not before. + */ +export function adoptUnmanagedSchedules(tx: Tx): number { + const curves = tx.select().from(curveTemplates).all() + const claims = curves.map(toClaim) + const temps = tx.select().from(temperatureSchedules).all() + const power = tx.select().from(powerSchedules).all() + const notes = tx.select().from(scheduleNotes).all() + const sideNames = new Map(tx.select().from(sideSettings).all().map(s => [s.side, s.name] as const)) + + const groups = new Map, endAction: 'turn_off' | 'maintain', days: CurveDays, notes: string }>() + for (const side of LIBRARY_SIDES) { + const sidePower = power.filter(p => p.side === side) + for (const day of DAYS_OF_WEEK) { + if (activeCurveOn(claims, side, day)) continue + const rows = temps.filter(t => t.side === side && t.dayOfWeek === day) + if (rows.length === 0) continue + const points = setPointsToPoints(sortChronological(rows)) + const endAction = endActionForDay(sidePower, day) + const key = `${endAction}|${Object.entries(points).sort(([a], [b]) => a.localeCompare(b)).map(([t, v]) => `${t}@${v}`).join(',')}` + const group = groups.get(key) ?? { points, endAction, days: { left: [], right: [] }, notes: '' } + group.days[side].push(day) + group.notes ||= notes.find(n => n.side === side && n.dayOfWeek === day)?.notes ?? '' + groups.set(key, group) + } + } + if (groups.size === 0) return 0 + + const sameCurve = (a: Record, b: Record) => + Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([t, v]) => b[t] === v) + const taken = new Set(curves.map(c => c.name)) + const label = (days: CurveDays) => days.left.length > 0 && days.right.length > 0 + ? 'Both sides' + : sideNames.get(days.left.length > 0 ? 'left' : 'right') ?? (days.left.length > 0 ? 'Left' : 'Right') + const perLabel = new Map() + for (const g of groups.values()) perLabel.set(label(g.days), (perLabel.get(label(g.days)) ?? 0) + 1) + + for (const g of groups.values()) { + const sorted = sortChronological(pointsToSetPoints(g.points)) + const days = normalizeDays(g.days) + const reuse = curves.find(c => !c.active && !hasDays(curveDays(c)) && c.endAction === g.endAction && sameCurve(c.points, g.points)) + if (reuse) { + tx.update(curveTemplates).set({ leftDays: days.left, rightDays: days.right, active: true }).where(eq(curveTemplates.id, reuse.id)).run() + reuse.active = true + continue + } + const base = label(days) + const allDays = [...new Set([...days.left, ...days.right])] + const name = uniqueName((perLabel.get(base) ?? 0) > 1 ? `${base} ${formatDayRange(allDays)}` : `${base} schedule`, taken) + const now = new Date() + tx.insert(curveTemplates).values({ + name, + bedtime: sorted[0].time, + wake: sorted[sorted.length - 1].time, + points: g.points, + notes: g.notes, + endAction: g.endAction, + leftDays: days.left, + rightDays: days.right, + active: true, + createdAt: now, + updatedAt: now, + }).run() + } + return groups.size +} diff --git a/src/server/tests/scheduleLibrary.test.ts b/src/server/tests/scheduleLibrary.test.ts new file mode 100644 index 000000000..6ff6261bd --- /dev/null +++ b/src/server/tests/scheduleLibrary.test.ts @@ -0,0 +1,135 @@ +/** + * adoptUnmanagedSchedules against a real in-memory sleepypod.db with + * migrations applied. + */ + +import path from 'node:path' +import Database from 'better-sqlite3' +import { eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as schema from '@/src/db/schema' +import type { DayOfWeek } from '@/src/lib/scheduleTime' + +vi.mock('@/src/scheduler', () => ({ getJobManager: vi.fn() })) + +const { adoptUnmanagedSchedules } = await import('@/src/server/scheduleLibrary') + +type Side = 'left' | 'right' + +describe('adoptUnmanagedSchedules', () => { + let raw: Database.Database + let db: ReturnType> + + beforeEach(() => { + raw = new Database(':memory:') + db = drizzle(raw, { schema }) + migrate(db, { migrationsFolder: path.resolve(process.cwd(), 'src/db/migrations') }) + db.insert(schema.sideSettings).values([{ side: 'left', name: 'Jo' }, { side: 'right', name: 'Sam' }]).run() + }) + + afterEach(() => raw.close()) + + const adopt = () => db.transaction(tx => adoptUnmanagedSchedules(tx)) + + function night(side: Side, days: DayOfWeek[], points: Record, endAction: 'turn_off' | 'maintain' = 'turn_off', enabled = true) { + for (const dayOfWeek of days) { + for (const [time, temperature] of Object.entries(points)) { + db.insert(schema.temperatureSchedules).values({ side, dayOfWeek, time, temperature, enabled }).run() + } + const times = Object.keys(points) + db.insert(schema.powerSchedules).values({ side, dayOfWeek, onTime: times[0], offTime: times[times.length - 1], onTemperature: 70, endAction, enabled }).run() + } + } + + const curves = () => db.select().from(schema.curveTemplates).all() + .map(c => ({ name: c.name, active: c.active, left: c.leftDays, right: c.rightDays, points: c.points, endAction: c.endAction, notes: c.notes, bedtime: c.bedtime, wake: c.wake })) + const rowCounts = () => ({ + temperature: db.select().from(schema.temperatureSchedules).all().length, + power: db.select().from(schema.powerSchedules).all().length, + }) + + const WEEK = { '22:00': 70, '02:00': 66, '06:30': 78 } + const WEEKEND = { '23:00': 72, '08:00': 80 } + + it('returns 0 and adds nothing without schedules', () => { + expect(adopt()).toBe(0) + expect(curves()).toEqual([]) + }) + + it('makes one active curve per side for days with the same set points and end action, without touching rows', () => { + night('left', ['monday', 'tuesday', 'wednesday'], WEEK) + night('left', ['saturday', 'sunday'], WEEKEND) + night('left', ['friday'], WEEKEND, 'maintain') + db.insert(schema.scheduleNotes).values({ side: 'left', dayOfWeek: 'tuesday', notes: 'Cool start' }).run() + const before = rowCounts() + + expect(adopt()).toBe(3) + expect(rowCounts()).toEqual(before) + const list = curves() + expect(list).toHaveLength(3) + expect(list.find(c => c.left.includes('monday'))).toEqual({ + name: 'Jo Mon–Wed', active: true, left: ['monday', 'tuesday', 'wednesday'], right: [], + points: WEEK, endAction: 'turn_off', notes: 'Cool start', bedtime: '22:00', wake: '06:30', + }) + expect(list.find(c => c.left.includes('sunday'))).toMatchObject({ name: 'Jo Sat, Sun', left: ['sunday', 'saturday'], endAction: 'turn_off', notes: '' }) + expect(list.find(c => c.left.includes('friday'))).toMatchObject({ name: 'Jo Fri', endAction: 'maintain' }) + }) + + it('merges matching sides into one curve and names a lone side curve after the sleeper', () => { + night('left', ['monday', 'tuesday'], WEEK) + night('right', ['monday', 'friday'], WEEK) + night('right', ['sunday'], WEEKEND, 'turn_off', false) + + expect(adopt()).toBe(2) + expect(curves()).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'Both sides schedule', active: true, left: ['monday', 'tuesday'], right: ['monday', 'friday'] }), + // Paused rows are adopted too; the curve is on and the rows keep their enabled flag. + expect.objectContaining({ name: 'Sam schedule', active: true, left: [], right: ['sunday'] }), + ])) + }) + + it('reuses a saved curve with no days and the same set points and end action', () => { + const at = new Date() + db.insert(schema.curveTemplates).values([ + { name: 'Late Warm', bedtime: '22:00', wake: '06:30', points: WEEK, endAction: 'maintain', createdAt: at, updatedAt: at }, + { name: 'Weeknight', bedtime: '22:00', wake: '06:30', points: WEEK, createdAt: at, updatedAt: at }, + ]).run() + night('left', ['monday'], WEEK) + + expect(adopt()).toBe(1) + const list = curves() + expect(list).toHaveLength(2) + expect(list.find(c => c.name === 'Weeknight')).toMatchObject({ active: true, left: ['monday'], right: [] }) + expect(list.find(c => c.name === 'Late Warm')).toMatchObject({ active: false, left: [], right: [] }) + }) + + it('skips days an active curve already runs, but not days of a curve that is off', () => { + const at = new Date() + db.insert(schema.curveTemplates).values([ + { name: 'On', bedtime: '22:00', wake: '06:30', points: WEEK, leftDays: ['monday'], active: true, createdAt: at, updatedAt: at }, + { name: 'Off', bedtime: '22:00', wake: '06:30', points: WEEKEND, leftDays: ['tuesday'], active: false, createdAt: at, updatedAt: at }, + ]).run() + night('left', ['monday', 'tuesday'], WEEK) + + expect(adopt()).toBe(1) + expect(curves().find(c => c.name === 'Jo schedule')).toMatchObject({ left: ['tuesday'], active: true }) + // Running it again finds nothing left to adopt. + expect(adopt()).toBe(0) + }) + + it('keeps names unique and within 30 characters', () => { + db.update(schema.sideSettings).set({ name: 'Bartholomew-Alexandria Smith' }).where(eq(schema.sideSettings.side, 'left')).run() + const at = new Date() + db.insert(schema.curveTemplates).values({ name: 'Bartholomew-Alexandria Smith s', bedtime: '22:00', wake: '06:30', points: WEEKEND, leftDays: ['friday'], active: false, createdAt: at, updatedAt: at }).run() + night('left', ['monday'], WEEK) + night('right', ['tuesday'], WEEKEND) + + expect(adopt()).toBe(2) + const names = curves().map(c => c.name) + expect(names.every(n => n.length <= 30)).toBe(true) + expect(new Set(names).size).toBe(names.length) + expect(names.sort()).toEqual(['Bartholomew-Alexandria Smith 2', 'Bartholomew-Alexandria Smith s', 'Sam schedule']) + }) +}) diff --git a/src/server/validation-schemas.ts b/src/server/validation-schemas.ts index aa06c7d17..c8cd98d31 100644 --- a/src/server/validation-schemas.ts +++ b/src/server/validation-schemas.ts @@ -79,6 +79,26 @@ export const wakeWindowSchema = z { message: `Wake window must be one of ${WAKE_WINDOW_MINUTES.join(', ')} minutes` }, ) +/** How long a snooze lasts, in seconds (1-30 minutes). */ +export const snoozeDurationSchema = z + .number() + .int() + .min(60, 'Snooze must be at least 1 minute') + .max(1800, 'Snooze must not exceed 30 minutes') + +/** + * Snooze taper: percent by which each later snooze of the same alarm is + * shorter than the one before. 0 turns it off. + */ +export const SNOOZE_TAPER_PERCENTS = [0, 30, 40, 50, 60] as const +export const snoozeTaperSchema = z + .number() + .int() + .refine( + (v): v is typeof SNOOZE_TAPER_PERCENTS[number] => (SNOOZE_TAPER_PERCENTS as readonly number[]).includes(v), + { message: `Snooze taper must be one of ${SNOOZE_TAPER_PERCENTS.join(', ')} percent` }, + ) + /** * Vibration pattern enum */ diff --git a/src/streaming/piezoStream.ts b/src/streaming/piezoStream.ts index 6682febea..ab4eed8d0 100644 --- a/src/streaming/piezoStream.ts +++ b/src/streaming/piezoStream.ts @@ -404,6 +404,7 @@ const ALL_SENSOR_TYPES = [ 'piezo-dual', 'capSense', 'capSense2', 'bedTemp', 'bedTemp2', 'frzTemp', 'frzTherm', 'frzHealth', 'log', 'deviceStatus', 'gesture', 'lps', + 'frzWater', 'sensHealth', ] as const /** diff --git a/src/streaming/tests/piezoStream.test.ts b/src/streaming/tests/piezoStream.test.ts index 0567672ed..11e624b24 100644 --- a/src/streaming/tests/piezoStream.test.ts +++ b/src/streaming/tests/piezoStream.test.ts @@ -782,6 +782,41 @@ describe('piezoStream — server lifecycle and protocol', () => { } }) + it('supports frzWater and sensHealth subscriptions without unknown frame warnings', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + __test__.warnedUnknownTypes.delete('frzWater') + __test__.warnedUnknownTypes.delete('sensHealth') + const client = await connectClient(startAndPort()) + try { + client.ws.send(JSON.stringify({ type: 'subscribe', sensors: ['frzWater', 'sensHealth'] })) + const ack = await client.waitFor(m => m.type === 'subscribed') + expect(ack.sensors).toEqual(['frzWater', 'sensHealth']) + + const waterFrame = { + type: 'frzWater', ts: 12345, + isPriming: false, hasWater: true, capValid: true, capLevel: 5, needsPriming: false, + } + const healthFrame = { + type: 'sensHealth', ts: 12346, + left: { connected: true }, right: { connected: true }, version: 1, + } + + __test__.dispatchSensorFrame(waterFrame) + __test__.dispatchSensorFrame(healthFrame) + + const receivedWater = await client.waitFor(m => m.type === 'frzWater') + expect(receivedWater).toEqual(waterFrame) + + const receivedHealth = await client.waitFor(m => m.type === 'sensHealth') + expect(receivedHealth).toEqual(healthFrame) + + expect(warn.mock.calls.filter(args => args[1] === 'frzWater' || args[1] === 'sensHealth')).toEqual([]) + } + finally { + await client.close() + } + }) + it('subscribe with an empty sensors array → subscribes to all types', async () => { const logSpy = vi.spyOn(console, 'log') const port = startAndPort() diff --git a/tests/biometricsExecutionLock.contract.test.ts b/tests/biometricsExecutionLock.contract.test.ts new file mode 100644 index 000000000..d52f8cd9b --- /dev/null +++ b/tests/biometricsExecutionLock.contract.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const installScript = readFileSync(join(process.cwd(), 'scripts/install'), 'utf8') +const updateScript = readFileSync(join(process.cwd(), 'scripts/bin/sp-update'), 'utf8') +const lockModule = readFileSync(join(process.cwd(), 'modules/common/biometrics/execution_lock.py'), 'utf8') +const lockPath = lockModule.match(/^EXECUTION_LOCK_PATH = '([^']+)'/m)?.[1] +const step = installScript.split('\n').find(line => line.startsWith('ExecStartPre=+') && lockPath !== undefined && line.includes(lockPath)) + +let dir: string | undefined +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + dir = undefined +}) + +describe('biometric execution lock deployment contract', () => { + it('prepares the workers’ lock for the unprivileged service user as root on every start', () => { + expect(lockPath).toBe('/run/sleepypod-shadow.lock') + expect(installScript).toMatch(/^User=sleepypod$/m) + expect(step).toBeDefined() + expect(step).toContain(`chown sleepypod:sleepypod ${lockPath}`) + expect(step).toContain(`chmod 600 ${lockPath}`) + }) + + it('gives pods upgraded over the air the same step as a drop-in, before the service starts', () => { + const dropIn = updateScript.split('\n').find(line => line.startsWith('ExecStartPre=+') && lockPath !== undefined && line.includes(lockPath)) + expect(dropIn).toBe(step) + expect(updateScript).toContain('/etc/systemd/system/sleepypod.service.d/biometrics-execution-lock.conf') + expect(updateScript.indexOf('EXECUTION_LOCK_DROPIN=')).toBeLessThan(updateScript.indexOf('systemctl start sleepypod.service\n', updateScript.indexOf('echo "Starting service..."'))) + }) + + it('adopts an existing lock without truncating or replacing the inode a worker may hold', () => { + dir = mkdtempSync(join(tmpdir(), 'exec-lock-')) + const target = join(dir, 'lock') + const command = step?.match(/sh -c '(.*)'$/)?.[1] + if (!command || !lockPath) throw new Error('Lock preparation step not found') + // Exercise the redirect only; ownership changes need root. + const create = command.split(' && ')[0].replaceAll(lockPath, target) + execFileSync('/bin/sh', ['-c', create]) + expect(statSync(target).size).toBe(0) + writeFileSync(target, '') + const inode = statSync(target).ino + execFileSync('/bin/sh', ['-c', create]) + expect(statSync(target).ino).toBe(inode) + expect(statSync(target).size).toBe(0) + }) +}) diff --git a/tests/browser/workflow.e2e.ts b/tests/browser/workflow.e2e.ts index 0ab87ce80..cc64692bd 100644 --- a/tests/browser/workflow.e2e.ts +++ b/tests/browser/workflow.e2e.ts @@ -78,14 +78,15 @@ test('import, freeze, replay, review, manually apply, acknowledge, and roll back expect(configuration()).toContainEqual({ side: 'left', desired_revision_id: candidateId, generation: 1 }) await view(page, 'Overview') - await page.getByRole('button', { name: 'Review previous profile', exact: true }).click() - await page.getByRole('button', { name: 'Compare with saved profile', exact: true }).click() - await page.getByLabel('I intend to restore the exact previous profile without validation evidence.', { exact: true }).check() - await page.getByRole('button', { name: 'Restore previous profile', exact: true }).click() - const recovery = page.getByRole('region', { name: 'Manual activation' }) - await expect(recovery.getByText('Waiting for delivery', { exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Restore previous', exact: true }).click() + const review = page.getByRole('dialog', { name: 'Restore the previous profile' }) + await expect(review).toContainText('heart_rate.trackingToleranceBpm') + await review.getByRole('button', { name: 'Restore and request', exact: true }).click() + const recovery = page.getByRole('group', { name: 'Profile lifecycle' }) + await expect(recovery.locator('[data-segment="requested"]')).toContainText('waiting') await fixture('adopt') - await expect(recovery.getByText('Applied', { exact: true })).toBeVisible({ timeout: 15_000 }) + await expect(recovery.locator('[data-segment="requested"]')).toContainText('acknowledged', { timeout: 15_000 }) + await expect(recovery.locator('[data-segment="processor"]')).toContainText('running saved') expect(configuration()).toContainEqual({ side: 'left', desired_revision_id: null, generation: 2 }) expect(configuration().filter(row => row.side === 'right')).toEqual(baseline.filter(row => row.side === 'right')) await recovery.screenshot({ path: test.info().outputPath('rollback-receipt.png') }) diff --git a/tests/installNodeVersion.contract.test.ts b/tests/installNodeVersion.contract.test.ts new file mode 100644 index 000000000..5657cf070 --- /dev/null +++ b/tests/installNodeVersion.contract.test.ts @@ -0,0 +1,35 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, test } from 'vitest' + +const installerPath = resolve(process.cwd(), 'scripts/install') +const installer = readFileSync(installerPath, 'utf8') +const wanted = /^NODE_WANTED=(\d+)$/m.exec(installer)?.[1] +const check = installer.slice( + installer.indexOf('node_version_is_supported()'), + installer.indexOf('CURRENT_NODE_VERSION='), +) + +describe('installer Node.js version check', () => { + test('installer is valid Bash', () => { + execFileSync('bash', ['-n', installerPath]) + }) + + test.each([ + ['', false], + ['v22.23.4', false], + ['v24.16.0', false], + ['v24.22.0', false], + ['v24.22.1', true], + ['v24.22.2', true], + ['v24.23.0', true], + ['v24.22.1-rc.1', false], + ['v26.12.0', false], + ])('version %j accepted=%s', (version, accepted) => { + const result = spawnSync('bash', [ + '-c', `NODE_WANTED=${wanted}\n${check}\nnode_version_is_supported "$1"`, 'bash', version, + ]) + expect(result.status).toBe(accepted ? 0 : 1) + }) +}) diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index 21f4f3ec5..8e14f0818 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -1,4 +1,5 @@ // @vitest-environment node +import { tryToParsePath } from 'next/dist/lib/try-to-parse-path' import { NextRequest } from 'next/server' import { afterEach, describe, expect, it, vi } from 'vitest' import { config, proxy } from '../proxy' @@ -63,15 +64,14 @@ describe('proxy host check', () => { }) describe('proxy matcher', () => { - const matches = (path: string) => config.matcher.some((pattern) => { - if (pattern === '/api/:path*') return path === '/api' || path.startsWith('/api/') - return new RegExp(`^${pattern}$`).test(path) - }) + // Compile each source with the path-to-regexp parser next build uses, so the test sees what the server sees. + const compiled = config.matcher.map(source => new RegExp(tryToParsePath(source).regexStr ?? '$^')) + const matches = (path: string) => compiled.some(regexp => regexp.test(path)) it.each([ '/api/trpc/device.getStatus', '/api/mcp', - '/api/biometrics/watch-upload', + '/api/biometrics/reference-stages', '/api/raw/SEQNO.RAW', '/api/export/archive', '/api/db-backup', @@ -85,7 +85,11 @@ describe('proxy matcher', () => { expect(matches(path)).toBe(true) }) + // A matched request's body is buffered and cut off at 10 MB before the route + // reads it, which broke Health export uploads (the route checks Host itself). it.each([ + '/api/biometrics/watch-upload', + '/api/biometrics/watch-upload/', '/_next/static/chunks/main.js', '/_next/image', '/favicon.ico',