diff --git a/app/api/biometrics/watch-motion/[id]/route.ts b/app/api/biometrics/watch-motion/[id]/route.ts new file mode 100644 index 000000000..7a8f0e8af --- /dev/null +++ b/app/api/biometrics/watch-motion/[id]/route.ts @@ -0,0 +1,14 @@ +import { biometricsDb } from '@/src/db' +import { getWatchMotionFile } from '@/src/biometrics/references/watch-motion' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** The stored, trimmed canonical file: byte-for-byte what training imports. */ +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params + if (!/^[1-9]\d{0,15}$/.test(id)) return Response.json({ error: 'Invalid id' }, { status: 400 }) + const file = getWatchMotionFile(biometricsDb, Number(id)) + if (file === null) return Response.json({ error: 'Not found' }, { status: 404 }) + return new Response(file, { headers: { 'Content-Type': 'application/json' } }) +} diff --git a/app/api/biometrics/watch-motion/route.test.ts b/app/api/biometrics/watch-motion/route.test.ts new file mode 100644 index 000000000..02acc67fe --- /dev/null +++ b/app/api/biometrics/watch-motion/route.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment node +import { createHash } from 'node:crypto' +import { beforeEach, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ db: null as unknown, raw: null as { exec: (sql: string) => void } | null })) +vi.mock('@/src/db', async () => { + const Database = (await import('better-sqlite3')).default + const { drizzle } = await import('drizzle-orm/better-sqlite3') + const { migrate } = await import('drizzle-orm/better-sqlite3/migrator') + const schema = await import('@/src/db/biometrics-schema') + const raw = new Database(':memory:') + const db = drizzle(raw, { schema }) + migrate(db, { migrationsFolder: 'src/db/biometrics-migrations' }) + state.db = db + state.raw = raw + return { biometricsDb: db } +}) + +const { POST, GET } = await import('./route') +const { GET: GET_ONE } = await import('./[id]/route') + +const FROM = 1_793_618_344_598 +const TO = FROM + 8 * 3600_000 +const FIRST_BIN = Math.floor(FROM / 2000) * 2000 // overlaps the start edge, so it is kept + +function file(version: 'watch-motion-v1' | 'watch-motion-v2', starts: number[], extra: Record = {}) { + const axes = version === 'watch-motion-v2' ? { meanX: starts.map(() => 0.1), meanY: starts.map(() => -0.2), meanZ: starts.map(() => 0.97) } : {} + return { + version, recordedFromMs: FROM, recordedToMs: TO, timeZone: 'America/Los_Angeles', utcOffsetSeconds: -28800, + sampleRateHz: 50, binSeconds: 2, jerkThresholdGPerSecond: 1, device: 'Apple Watch · watchOS 26.6', + bins: { startMs: starts, samples: starts.map(() => 100), meanMagnitude: starts.map(() => 1), magnitudeVariance: starts.map((_, i) => i * 1e-6), + maxJerk: starts.map((_, i) => i % 7), movingSamples: starts.map((_, i) => i % 3), ...axes }, + ...extra, + } +} +const night = Array.from({ length: 90 }, (_, i) => FIRST_BIN + i * 2000) +/** The same file with a bin prepended to every column, as the Watch recorder's leftovers. */ +function withStray(f: ReturnType, startMs: number) { + return { ...f, bins: Object.fromEntries(Object.entries(f.bins).map(([column, values]) => [column, [column === 'startMs' ? startMs : 1, ...values]])) } +} + +const post = (body: unknown, side = 'left', headers: Record = {}) => POST(new Request(`http://pod/api/biometrics/watch-motion?side=${side}`, { + method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body), +})) + +beforeEach(() => state.raw?.exec('DELETE FROM watch_motion_captures')) + +it('accepts a v1 file and serves the stored canonical file back byte-for-byte', async () => { + const response = await post(file('watch-motion-v1', night)) + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ id: expect.any(Number), side: 'left', version: 'watch-motion-v1', recordedFromMs: FROM, recordedToMs: TO, bins: 90, trimmedBins: 0, status: 'created' }) + const stored = await GET_ONE(new Request('http://pod'), { params: Promise.resolve({ id: String(body.id) }) }) + expect(stored.headers.get('content-type')).toBe('application/json') + const text = await stored.text() + expect(JSON.parse(text)).toEqual(file('watch-motion-v1', night)) + // Keys sorted at every level, like the iOS encoder. + expect(text.indexOf('"binSeconds"')).toBeLessThan(text.indexOf('"bins"')) + expect(text.indexOf('"magnitudeVariance"')).toBeLessThan(text.indexOf('"startMs"')) + const [listed] = await (await GET(new Request('http://pod/api/biometrics/watch-motion?side=left'))).json() + expect(listed).toMatchObject({ id: body.id, sha256: createHash('sha256').update(text).digest('hex'), bins: 90, trimmedBins: 0 }) +}) + +it('accepts a v2 file with axis means', async () => { + const response = await post(file('watch-motion-v2', night), 'right') + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toMatchObject({ side: 'right', version: 'watch-motion-v2', bins: 90, status: 'created' }) + const stored = JSON.parse(await (await GET_ONE(new Request('http://pod'), { params: Promise.resolve({ id: String(body.id) }) })).text()) + expect(stored.bins.meanZ).toHaveLength(90) +}) + +it('trims a stray bin from an earlier recording and counts it', async () => { + const stray = FROM - 20 * 3600_000 - (FROM - 20 * 3600_000) % 2000 + const after = Math.ceil(TO / 2000) * 2000 + const response = await post(file('watch-motion-v2', [stray, stray + 2000, ...night, after])) + expect(await response.json()).toMatchObject({ bins: 90, trimmedBins: 3, status: 'created' }) + const [listed] = await (await GET(new Request(`http://pod/api/biometrics/watch-motion?startDate=${FROM}&endDate=${new Date(TO).toISOString()}`))).json() + expect(listed).toMatchObject({ bins: 90, trimmedBins: 3, recordedFromMs: FROM }) + const stored = JSON.parse(await (await GET_ONE(new Request('http://pod'), { params: Promise.resolve({ id: String(listed.id) }) })).text()) + expect(stored.bins.startMs[0]).toBe(FIRST_BIN) + expect(stored.bins.meanX).toHaveLength(90) + // Each kept bin keeps its own values. + expect(stored.bins.maxJerk).toEqual(night.map((_, i) => (i + 2) % 7)) +}) + +it('reports unchanged for identical content and replaced for new content of the same night', async () => { + const first = await (await post(file('watch-motion-v1', night))).json() + // A fixed app resending without the stray bin stores the same trimmed file. + const again = await (await post(withStray(file('watch-motion-v1', night), FIRST_BIN - 40 * 3600_000))).json() + expect(again).toMatchObject({ id: first.id, status: 'unchanged', trimmedBins: 1 }) + const replaced = await (await post(file('watch-motion-v2', night))).json() + expect(replaced).toMatchObject({ id: first.id, status: 'replaced', version: 'watch-motion-v2' }) + expect(await (await GET(new Request('http://pod/api/biometrics/watch-motion'))).json()).toHaveLength(1) + // The other side is a separate capture. + expect(await (await post(file('watch-motion-v1', night), 'right')).json()).toMatchObject({ status: 'created' }) +}) + +it('rejects raw readings, oversize bodies and invalid files', async () => { + const rawResponse = await post(file('watch-motion-v1', night, { raw: [[FROM, 0, 0, 1]] })) + expect(rawResponse.status).toBe(400) + expect(await rawResponse.json()).toEqual({ error: 'raw readings are not accepted' }) + + expect((await post('x', 'left', { 'content-length': String(8 * 1024 * 1024 + 1) })).status).toBe(413) + const chunk = new Uint8Array(1) + Object.defineProperty(chunk, 'byteLength', { value: 8 * 1024 * 1024 + 1 }) + const stream = new ReadableStream({ start(controller) { + controller.enqueue(chunk) + controller.close() + } }) + const streamed = await POST(new Request('http://pod/api/biometrics/watch-motion?side=left', { method: 'POST', body: stream, duplex: 'half' } as RequestInit)) + expect(streamed.status).toBe(413) + + const mismatched = file('watch-motion-v1', night) + mismatched.bins.maxJerk.pop() + const cases: Array<[unknown, RegExp]> = [ + [mismatched, /bins\.maxJerk differ/], + [file('watch-motion-v1', night, { version: 'watch-motion-v3' }), /version/], + [file('watch-motion-v1', night, { binSeconds: 1 }), /binSeconds/], + [file('watch-motion-v2', night, { bins: { ...file('watch-motion-v1', night).bins } }), /v2 requires/], + [file('watch-motion-v1', night, { bins: file('watch-motion-v2', night).bins }), /v1 must not/], + [file('watch-motion-v1', [FIRST_BIN, FIRST_BIN + 1000]), /multiple of 2000/], + [file('watch-motion-v1', [FIRST_BIN + 2000, FIRST_BIN]), /strictly increasing/], + [file('watch-motion-v1', [FIRST_BIN, FIRST_BIN + 2000], { bins: { ...file('watch-motion-v1', [FIRST_BIN, FIRST_BIN + 2000]).bins, movingSamples: [1, -1] } }), /movingSamples/], + [file('watch-motion-v1', [FIRST_BIN, FIRST_BIN + 2000], { bins: { ...file('watch-motion-v1', [FIRST_BIN, FIRST_BIN + 2000]).bins, samples: [1.5, 2] } }), /samples/], + [file('watch-motion-v1', [FIRST_BIN - 3600_000]), /No bins fall inside/], + [file('watch-motion-v1', night, { recordedToMs: FROM }), /recordedToMs must be after/], + [file('watch-motion-v1', night, { extra: 1 }), /extra/], + ] + for (const [body, message] of cases) { + const response = await post(body) + expect(response.status, String(message)).toBe(400) + expect((await response.json()).error).toMatch(message) + } + // JSON.parse turns 1e999 into Infinity. + const infinite = JSON.stringify(file('watch-motion-v1', [FIRST_BIN])).replace('"maxJerk":[0]', '"maxJerk":[1e999]') + expect((await post(infinite)).status).toBe(400) + expect((await post('{not json')).status).toBe(400) + expect((await post('')).status).toBe(400) + expect((await POST(new Request('http://pod/api/biometrics/watch-motion', { method: 'POST', body: '{}' }))).status).toBe(400) + expect(await (await GET(new Request('http://pod/api/biometrics/watch-motion'))).json()).toEqual([]) +}) + +it('rejects cross-origin and DNS-rebinding uploads but accepts the same-origin web app', async () => { + for (const origin of ['http://evil', 'http://192.168.1.88:8080', 'null']) { + const response = await post(file('watch-motion-v1', night), 'left', { origin, host: '192.168.1.88:3000' }) + expect(response.status).toBe(403) + } + const rebound = await post(file('watch-motion-v1', night), 'left', { origin: 'http://attacker.example.com:3000', host: 'attacker.example.com:3000' }) + expect(rebound.status).toBe(403) + expect(await (await GET(new Request('http://pod/api/biometrics/watch-motion'))).json()).toEqual([]) + const same = await post(file('watch-motion-v1', night), 'left', { origin: 'http://192.168.1.88:3000', host: '192.168.1.88:3000' }) + expect(same.status).toBe(200) +}) + +it('lists by overlap and validates list and fetch parameters', async () => { + await post(file('watch-motion-v1', night)) + const list = (query: string) => GET(new Request(`http://pod/api/biometrics/watch-motion?${query}`)) + expect(await (await list(`startDate=${TO}`)).json()).toEqual([]) + expect(await (await list(`endDate=${FROM - 1}`)).json()).toEqual([]) + expect(await (await list(`startDate=${TO - 1}&endDate=${TO + 1}&side=left`)).json()).toHaveLength(1) + expect(await (await list('side=right')).json()).toEqual([]) + expect((await list('side=middle')).status).toBe(400) + expect((await list('startDate=yesterday')).status).toBe(400) + expect((await list(`startDate=${TO}&endDate=${FROM}`)).status).toBe(400) + expect((await GET_ONE(new Request('http://pod'), { params: Promise.resolve({ id: '999' }) })).status).toBe(404) + expect((await GET_ONE(new Request('http://pod'), { params: Promise.resolve({ id: '../x' }) })).status).toBe(400) +}) diff --git a/app/api/biometrics/watch-motion/route.ts b/app/api/biometrics/watch-motion/route.ts new file mode 100644 index 000000000..4c7a155cb --- /dev/null +++ b/app/api/biometrics/watch-motion/route.ts @@ -0,0 +1,103 @@ +import { z } from 'zod' +import { biometricsDb } from '@/src/db' +import { rejectUnknownHost } from '@/src/lib/hostAllowlist' +import { + listWatchMotion, parseWatchMotion, saveWatchMotion, WATCH_MOTION_MAX_BYTES, WatchMotionError, +} from '@/src/biometrics/references/watch-motion' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const sideQuery = z.object({ side: z.enum(['left', 'right']) }) +/** ISO 8601 text or Unix milliseconds. */ +const dateParam = z.string().transform((value, ctx) => { + const date = new Date(/^\d+$/.test(value) ? Number(value) : value) + if (Number.isNaN(date.getTime())) { + ctx.addIssue({ code: 'custom', message: 'must be ISO 8601 or Unix milliseconds' }) + return z.NEVER + } + return date +}) +const listQuery = z.object({ side: z.enum(['left', 'right']).optional(), startDate: dateParam.optional(), endDate: dateParam.optional() }).strict() + +/** Same check as watch-upload: Next reports the 0.0.0.0 bind address in request.url, so compare Origin with Host. */ +function isSameOrigin(request: Request): boolean { + const origin = request.headers.get('origin') + if (origin === null) return true + try { + return new URL(origin).host === request.headers.get('host') + } + catch { + return false + } +} + +class TooLarge extends Error {} + +/** The body as text, refusing to buffer more than the limit even without a Content-Length. */ +async function readBody(request: Request): Promise { + if (Number(request.headers.get('content-length')) > WATCH_MOTION_MAX_BYTES) throw new TooLarge() + if (!request.body) return '' + const chunks: Uint8Array[] = [] + let bytes = 0 + const reader = request.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + bytes += value.byteLength + if (bytes > WATCH_MOTION_MAX_BYTES) { + await reader.cancel() + throw new TooLarge() + } + chunks.push(value) + } + return Buffer.concat(chunks).toString('utf8') +} + +/** + * One night's watch-motion-v1/v2 file from the iOS app, as-is. Stored as a + * movement label only (see docs/biometrics-reference-imports.md). + */ +export async function POST(request: Request): Promise { + const rejectedHost = rejectUnknownHost(request) + if (rejectedHost) return rejectedHost + if (!isSameOrigin(request)) return Response.json({ error: 'Cross-origin upload is not allowed' }, { status: 403 }) + const query = sideQuery.safeParse(Object.fromEntries(new URL(request.url).searchParams)) + if (!query.success) return Response.json({ error: 'Specify side=left|right' }, { status: 400 }) + let body: string + try { + body = await readBody(request) + } + catch (error) { + if (error instanceof TooLarge) return Response.json({ error: 'Upload exceeds 8 MiB' }, { status: 413 }) + return Response.json({ error: 'Upload interrupted' }, { status: 400 }) + } + if (!body) return Response.json({ error: 'Send the watch-motion JSON file as the request body' }, { status: 400 }) + try { + const parsed = parseWatchMotion(body) + const { capture, status } = saveWatchMotion(biometricsDb, query.data.side, parsed) + return Response.json({ + id: capture.id, + side: capture.side, + version: capture.version, + recordedFromMs: capture.recordedFromMs, + recordedToMs: capture.recordedToMs, + bins: capture.bins, + trimmedBins: parsed.trimmedBins, + status, + }) + } + catch (error) { + if (error instanceof WatchMotionError) return Response.json({ error: error.message }, { status: error.status }) + return Response.json({ error: 'Could not store the recording' }, { status: 500 }) + } +} + +/** Capture metadata overlapping [startDate, endDate], oldest first. */ +export async function GET(request: Request): Promise { + const query = listQuery.safeParse(Object.fromEntries(new URL(request.url).searchParams)) + if (!query.success) return Response.json({ error: 'Use side=left|right and startDate/endDate as ISO 8601 or Unix milliseconds' }, { status: 400 }) + const { side, startDate, endDate } = query.data + if (startDate && endDate && startDate > endDate) return Response.json({ error: 'startDate must be before or equal to endDate' }, { status: 400 }) + return Response.json(listWatchMotion(biometricsDb, { side, start: startDate, end: endDate })) +} diff --git a/docs/biometrics-reference-imports.md b/docs/biometrics-reference-imports.md index 49ead2afa..6db08be44 100644 --- a/docs/biometrics-reference-imports.md +++ b/docs/biometrics-reference-imports.md @@ -273,3 +273,32 @@ RMSSD read failure is reported while the other readings are sent. A newer app and core are needed for RMSSD sync. Neither deployment nor local training imports a Health export automatically; the user must initiate/authorize an import. No Watch is required for Pod-only charts or model inference. + +### Apple Watch wrist movement + +`POST /api/biometrics/watch-motion?side=left|right` stores one night's +`watch-motion-v1` or `watch-motion-v2` file from the iOS app, sent as-is +(format: sleepypod-ios `docs/watch-motion-format.md`). It is a movement label +only: it never feeds sleep staging, movement scoring or hardware control. +Bodies over 8 MiB are rejected (413), and so are files carrying `raw` +readings (400). `binSeconds` must be 2, every `bins` column must match +`startMs` in length, `startMs` must lie on the 2 s Unix grid and strictly +increase, counts must be nonnegative integers, and v2 requires (v1 forbids) +`meanX`/`meanY`/`meanZ`. Bins outside [`recordedFromMs`, `recordedToMs`) are +leftovers of an earlier recording and are trimmed, not rejected; a bin +overlapping the start edge stays. The response reports `trimmedBins`. + +One capture is kept per side and `recordedFromMs`. Resending identical +trimmed content returns `status: "unchanged"`; different content for the same +night replaces the stored file in place (`"replaced"`, same `id`). The stored +file is the trimmed file as sorted-key compact JSON; `sha256` is its hash. +`GET /api/biometrics/watch-motion?side=&startDate=&endDate=` lists capture +metadata overlapping the window (dates as ISO 8601 or Unix ms), and +`GET /api/biometrics/watch-motion/{id}` returns the stored file byte for byte +for the training workspace. `biometrics.getWatchMotionSeries` +(`GET /api/biometrics/watch-motion-series`) sums the bins per minute +(`movingSamples`, max `maxJerk`, mean `magnitudeVariance`, `samples`) for at +most 31 days; minutes without bins are absent rather than zero. The night +chart draws it over the pod's movement, scaled to each night. Unlike the other +reference tables, captures (~0.5 MB a night) are pruned with the biometrics +retention window by `recordedToMs`; the iPhone keeps the original files. diff --git a/scripts/biometrics-archive/README.md b/scripts/biometrics-archive/README.md index 30c6e3c26..3755f4c51 100644 --- a/scripts/biometrics-archive/README.md +++ b/scripts/biometrics-archive/README.md @@ -119,6 +119,7 @@ Rules (`TABLES` in `merge.ts`): | `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 | +| `watch_motion_captures` | side, recorded_from | state: a resent night replaces the file | | `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 | diff --git a/scripts/biometrics-archive/merge.ts b/scripts/biometrics-archive/merge.ts index 8170d6416..21f1ca26e 100644 --- a/scripts/biometrics-archive/merge.ts +++ b/scripts/biometrics-archive/merge.ts @@ -56,6 +56,7 @@ export const TABLES: TableSpec[] = [ { 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: 'watch_motion_captures', key: ['side', 'recorded_from'], 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' }, diff --git a/src/biometrics/references/tests/watch-motion.test.ts b/src/biometrics/references/tests/watch-motion.test.ts new file mode 100644 index 000000000..0efe99c3c --- /dev/null +++ b/src/biometrics/references/tests/watch-motion.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node +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 * as schema from '@/src/db/biometrics-schema' +import { canonicalJson, parseWatchMotion, saveWatchMotion, watchMotionMinutes, watchMotionSeries, type WatchMotionFile } from '../watch-motion' + +const M = 60_000 +const T = Date.UTC(2026, 10, 2, 6) // minute-aligned + +function file(starts: number[], moving: number[], from = starts[0], to = starts[starts.length - 1] + 2000): WatchMotionFile { + return { + version: 'watch-motion-v1', recordedFromMs: from, recordedToMs: to, timeZone: 'UTC', utcOffsetSeconds: 0, sampleRateHz: 50, + binSeconds: 2, jerkThresholdGPerSecond: 1, device: 'test', + bins: { startMs: starts, samples: starts.map(() => 100), meanMagnitude: starts.map(() => 1), + magnitudeVariance: starts.map((_, i) => (i + 1) * 0.001), maxJerk: starts.map((_, i) => i), movingSamples: moving }, + } +} + +let sqlite: Database.Database +let db: ReturnType> +beforeEach(() => { + sqlite = new Database(':memory:') + db = drizzle(sqlite, { schema }) + migrate(db, { migrationsFolder: 'src/db/biometrics-migrations' }) +}) +afterEach(() => sqlite.close()) + +it('sums bins per minute and leaves minutes without bins absent', () => { + const starts = [T, T + 2000, T + 58_000, T + 3 * M] + const rows = watchMotionMinutes([file(starts, [1, 2, 3, 4])], T, T + 10 * M) + expect(rows).toEqual([ + { timestamp: new Date(T), movingSamples: 6, maxJerk: 2, meanVariance: 0.002, samples: 300 }, + { timestamp: new Date(T + 3 * M), movingSamples: 4, maxJerk: 3, meanVariance: 0.004, samples: 100 }, + ]) +}) + +it('keeps only bins starting inside the window and counts overlapping captures once', () => { + const a = file([T, T + 2000, T + M], [1, 1, 1]) + const b = file([T + M, T + 2 * M], [5, 5]) + expect(watchMotionMinutes([b, a], T + 2000, T + 2 * M).map(r => [r.timestamp.getTime(), r.movingSamples])) + .toEqual([[T, 1], [T + M, 1], [T + 2 * M, 5]]) +}) + +it('canonicalizes with sorted keys and hashes the trimmed file', () => { + expect(canonicalJson({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: 3 } })).toBe('{"a":{"c":3,"d":[2,{"y":2,"z":1}]},"b":1}') + const original = file([T - 24 * 3600_000, T, T + 2000], [9, 1, 2], T + 500, T + M) + const parsed = parseWatchMotion(JSON.stringify(original)) + expect(parsed.trimmedBins).toBe(1) + expect(parsed.file.bins.startMs).toEqual([T, T + 2000]) + expect(parsed.file.bins.movingSamples).toEqual([1, 2]) + expect(parseWatchMotion(parsed.canonical).sha256).toBe(parsed.sha256) +}) + +it('serves the series for one side from overlapping captures only', () => { + const night = file([T, T + M, T + 5 * M], [3, 4, 5], T, T + 6 * M) + saveWatchMotion(db, 'left', parseWatchMotion(JSON.stringify(night))) + saveWatchMotion(db, 'right', parseWatchMotion(JSON.stringify(file([T], [99], T, T + M)))) + expect(watchMotionSeries(db, 'left', new Date(T + M), new Date(T + 10 * M)).map(r => [r.timestamp.getTime(), r.movingSamples])) + .toEqual([[T + M, 4], [T + 5 * M, 5]]) + expect(watchMotionSeries(db, 'left', new Date(T + 7 * M), new Date(T + 10 * M))).toEqual([]) + expect(watchMotionSeries(db, 'right', new Date(T), new Date(T + M)).map(r => r.movingSamples)).toEqual([99]) +}) diff --git a/src/biometrics/references/watch-motion.ts b/src/biometrics/references/watch-motion.ts new file mode 100644 index 000000000..e2b301922 --- /dev/null +++ b/src/biometrics/references/watch-motion.ts @@ -0,0 +1,275 @@ +import { createHash } from 'node:crypto' +import { and, asc, eq, gt, lte } from 'drizzle-orm' +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' +import { z } from 'zod' +import type * as schema from '@/src/db/biometrics-schema' +import { watchMotionCaptures } from '@/src/db/biometrics-schema' + +/** + * Apple Watch wrist-movement recordings (sleepypod-ios docs/watch-motion-format.md). + * Stored as a movement label for comparison and off-pod training only; nothing + * here feeds sleep staging, movement scoring or hardware control. + */ + +export const WATCH_MOTION_VERSIONS = ['watch-motion-v1', 'watch-motion-v2'] as const +export const WATCH_MOTION_MAX_BYTES = 8 * 1024 * 1024 +/** Series reads parse every overlapping file, so keep the window to a month. */ +export const WATCH_MOTION_MAX_SERIES_DAYS = 31 + +const BIN_SECONDS = 2 +const BIN_MS = BIN_SECONDS * 1000 +const MINUTE_MS = 60_000 +// The iOS app records at most 12 h; anything far beyond a night is not a Watch recording. +const MAX_SPAN_MS = 24 * 60 * MINUTE_MS +// Unix ms between 2001 and 2100, as the reference import contract. +const unixMs = z.number().int().min(978_307_200_000).max(4_102_444_800_000) +// zod 4 numbers reject Infinity, which JSON.parse makes of overflowing literals like 1e999. +const finite = z.number() +const count = z.number().int().nonnegative() + +const binsSchema = z.object({ + startMs: z.array(z.number().int()), + samples: z.array(count), + meanMagnitude: z.array(finite), + magnitudeVariance: z.array(finite), + maxJerk: z.array(finite), + movingSamples: z.array(count), + meanX: z.array(finite).optional(), + meanY: z.array(finite).optional(), + meanZ: z.array(finite).optional(), +}).strict() + +const fileSchema = z.object({ + version: z.enum(WATCH_MOTION_VERSIONS), + recordedFromMs: unixMs, + recordedToMs: unixMs, + timeZone: z.string().min(1).max(64), + utcOffsetSeconds: z.number().int().min(-18 * 3600).max(18 * 3600), + sampleRateHz: z.number().positive().max(1000), + binSeconds: z.literal(BIN_SECONDS), + jerkThresholdGPerSecond: z.number().nonnegative(), + device: z.string().max(200), + bins: binsSchema, +}).strict() + +export type WatchMotionFile = z.infer +type Bins = WatchMotionFile['bins'] +type Column = Exclude + +const BASE_COLUMNS = ['samples', 'meanMagnitude', 'magnitudeVariance', 'maxJerk', 'movingSamples'] as const satisfies readonly Column[] +const AXIS_COLUMNS = ['meanX', 'meanY', 'meanZ'] as const satisfies readonly Column[] + +export class WatchMotionError extends Error { + constructor(message: string, readonly status: 400 | 413 = 400) { + super(message) + this.name = 'WatchMotionError' + } +} + +export interface ParsedWatchMotion { + file: WatchMotionFile + /** Sorted-key compact JSON of the trimmed file: what is stored and served. */ + canonical: string + sha256: string + trimmedBins: number +} + +function describeIssues(error: z.ZodError): string { + const issues = error.issues.slice(0, 5).map(issue => `${issue.path.join('.') || 'file'}: ${issue.message}`) + const more = error.issues.length > 5 ? `; and ${error.issues.length - 5} more` : '' + return `Invalid watch-motion file: ${issues.join('; ')}${more}` +} + +/** JSON with object keys sorted at every level, matching the iOS encoder's `.sortedKeys`. */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(value, (_key, v: unknown) => v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : v) +} + +/** + * Validates a watch-motion file and trims bins outside [recordedFromMs, + * recordedToMs). A bin overlapping the start edge stays: bins are Unix-aligned, + * so the first one can begin up to 2 s before the requested span. Bins wholly + * outside are leftovers the Watch recorder handed back from an earlier + * recording; files already on phones contain them, so they are dropped, not + * rejected. + */ +export function parseWatchMotion(text: string): ParsedWatchMotion { + let raw: unknown + try { + raw = JSON.parse(text) + } + catch { + throw new WatchMotionError('Body is not valid JSON') + } + if (raw && typeof raw === 'object' && !Array.isArray(raw) && Object.hasOwn(raw, 'raw')) { + throw new WatchMotionError('raw readings are not accepted') + } + const parsed = fileSchema.safeParse(raw) + if (!parsed.success) throw new WatchMotionError(describeIssues(parsed.error)) + const file = parsed.data + const { bins } = file + + if (file.recordedToMs <= file.recordedFromMs) throw new WatchMotionError('recordedToMs must be after recordedFromMs') + if (file.recordedToMs - file.recordedFromMs > MAX_SPAN_MS) throw new WatchMotionError('A recording may span at most 24 hours') + + const hasAxes = AXIS_COLUMNS.map(column => bins[column] !== undefined) + if (file.version === 'watch-motion-v2' && !hasAxes.every(Boolean)) { + throw new WatchMotionError('watch-motion-v2 requires bins.meanX, bins.meanY and bins.meanZ') + } + if (file.version === 'watch-motion-v1' && hasAxes.some(Boolean)) { + throw new WatchMotionError('watch-motion-v1 must not include bins.meanX, bins.meanY or bins.meanZ') + } + const columns: Column[] = file.version === 'watch-motion-v2' ? [...BASE_COLUMNS, ...AXIS_COLUMNS] : [...BASE_COLUMNS] + const n = bins.startMs.length + const mismatched = columns.filter(column => bins[column]?.length !== n) + if (mismatched.length) throw new WatchMotionError(`bins columns must all have ${n} entries like bins.startMs; ${mismatched.map(c => `bins.${c}`).join(', ')} differ`) + + for (let i = 0; i < n; i++) { + const start = bins.startMs[i] + if (start % BIN_MS !== 0) throw new WatchMotionError(`bins.startMs.${i}: must be a multiple of ${BIN_MS} ms`) + if (i > 0 && start <= bins.startMs[i - 1]) throw new WatchMotionError(`bins.startMs.${i}: must be strictly increasing`) + } + + const keep: number[] = [] + for (let i = 0; i < n; i++) { + const start = bins.startMs[i] + if (start + BIN_MS > file.recordedFromMs && start < file.recordedToMs) keep.push(i) + } + if (keep.length === 0) throw new WatchMotionError('No bins fall inside recordedFromMs..recordedToMs') + + const trimmed = { ...file, bins: Object.fromEntries((['startMs', ...columns] as const).map(column => [column, keep.map(i => (bins[column] as number[])[i])])) as Bins } + const canonical = canonicalJson(trimmed) + return { file: trimmed, canonical, sha256: createHash('sha256').update(canonical).digest('hex'), trimmedBins: n - keep.length } +} + +type BioDB = BetterSQLite3Database +export type WatchMotionSide = 'left' | 'right' +export type WatchMotionStatus = 'created' | 'unchanged' | 'replaced' + +export const watchMotionCaptureSchema = z.object({ + id: z.number().int(), + side: z.enum(['left', 'right']), + version: z.enum(WATCH_MOTION_VERSIONS), + recordedFromMs: z.number().int(), + recordedToMs: z.number().int(), + bins: z.number().int(), + trimmedBins: z.number().int(), + sha256: z.string(), + createdAt: z.date(), +}) +export type WatchMotionCapture = z.infer + +const metadataColumns = { + id: watchMotionCaptures.id, + side: watchMotionCaptures.side, + version: watchMotionCaptures.version, + recordedFrom: watchMotionCaptures.recordedFrom, + recordedTo: watchMotionCaptures.recordedTo, + bins: watchMotionCaptures.bins, + trimmedBins: watchMotionCaptures.trimmedBins, + sha256: watchMotionCaptures.sha256, + createdAt: watchMotionCaptures.createdAt, +} + +function toCapture({ recordedFrom, recordedTo, ...row }: { [K in keyof typeof metadataColumns]: (typeof watchMotionCaptures.$inferSelect)[K] }): WatchMotionCapture { + return { ...row, recordedFromMs: recordedFrom.getTime(), recordedToMs: recordedTo.getTime() } +} + +/** + * One capture per (side, recordedFromMs). Identical content is a no-op; new + * content for the same night (a fixed app resending) replaces the stored file + * and keeps the id. + */ +export function saveWatchMotion(db: BioDB, side: WatchMotionSide, parsed: ParsedWatchMotion): { capture: WatchMotionCapture, status: WatchMotionStatus } { + const { file } = parsed + const values = { + side, + version: file.version, + recordedFrom: new Date(file.recordedFromMs), + recordedTo: new Date(file.recordedToMs), + bins: file.bins.startMs.length, + trimmedBins: parsed.trimmedBins, + sha256: parsed.sha256, + file: parsed.canonical, + createdAt: new Date(), + } + return db.transaction((tx) => { + const existing = tx.select(metadataColumns).from(watchMotionCaptures) + .where(and(eq(watchMotionCaptures.side, side), eq(watchMotionCaptures.recordedFrom, values.recordedFrom))).get() + if (existing?.sha256 === parsed.sha256) return { capture: toCapture(existing), status: 'unchanged' as const } + const row = existing + ? tx.update(watchMotionCaptures).set(values).where(eq(watchMotionCaptures.id, existing.id)).returning(metadataColumns).get() + : tx.insert(watchMotionCaptures).values(values).returning(metadataColumns).get() + return { capture: toCapture(row), status: existing ? 'replaced' as const : 'created' as const } + }) +} + +/** Captures overlapping [start, end], oldest first. Both bounds optional. */ +export function listWatchMotion(db: BioDB, query: { side?: WatchMotionSide, start?: Date, end?: Date }): WatchMotionCapture[] { + const conditions = [] + if (query.side) conditions.push(eq(watchMotionCaptures.side, query.side)) + if (query.start) conditions.push(gt(watchMotionCaptures.recordedTo, query.start)) + if (query.end) conditions.push(lte(watchMotionCaptures.recordedFrom, query.end)) + return db.select(metadataColumns).from(watchMotionCaptures) + .where(conditions.length ? and(...conditions) : undefined) + .orderBy(asc(watchMotionCaptures.recordedFrom), asc(watchMotionCaptures.side)).all() + .map(toCapture) +} + +/** The stored canonical file, or null. */ +export function getWatchMotionFile(db: BioDB, id: number): string | null { + return db.select({ file: watchMotionCaptures.file }).from(watchMotionCaptures).where(eq(watchMotionCaptures.id, id)).get()?.file ?? null +} + +export interface WatchMotionMinute { + /** Minute start. */ + timestamp: Date + movingSamples: number + maxJerk: number + meanVariance: number + samples: number +} + +/** + * Per-minute summary of the bins starting in [startMs, endMs]. Minutes without + * bins are absent: no data is not stillness. Overlapping captures count each + * 2 s bin once (the earliest capture's copy). + */ +export function watchMotionMinutes(files: WatchMotionFile[], startMs: number, endMs: number): WatchMotionMinute[] { + const seen = new Set() + const minutes = new Map() + for (const file of [...files].sort((a, b) => a.recordedFromMs - b.recordedFromMs)) { + const { bins } = file + for (let i = 0; i < bins.startMs.length; i++) { + const start = bins.startMs[i] + if (start < startMs || start > endMs || seen.has(start)) continue + seen.add(start) + const key = Math.floor(start / MINUTE_MS) * MINUTE_MS + const minute = minutes.get(key) ?? { moving: 0, jerk: 0, variance: 0, bins: 0, samples: 0 } + minute.moving += bins.movingSamples[i] + minute.jerk = Math.max(minute.jerk, bins.maxJerk[i]) + minute.variance += bins.magnitudeVariance[i] + minute.samples += bins.samples[i] + minute.bins++ + minutes.set(key, minute) + } + } + return [...minutes.entries()].sort(([a], [b]) => a - b).map(([t, m]) => ({ + timestamp: new Date(t), + movingSamples: m.moving, + maxJerk: m.jerk, + meanVariance: m.variance / m.bins, + samples: m.samples, + })) +} + +/** Per-minute Watch movement for one side, read from every capture overlapping the window. */ +export function watchMotionSeries(db: BioDB, side: WatchMotionSide, start: Date, end: Date): WatchMotionMinute[] { + const files = db.select({ file: watchMotionCaptures.file }).from(watchMotionCaptures) + .where(and(eq(watchMotionCaptures.side, side), gt(watchMotionCaptures.recordedTo, new Date(start.getTime() - BIN_MS)), + lte(watchMotionCaptures.recordedFrom, end))).all() + .map(row => JSON.parse(row.file) as WatchMotionFile) + return watchMotionMinutes(files, start.getTime(), end.getTime()) +} diff --git a/src/components/MovementChart/MovementChart.tsx b/src/components/MovementChart/MovementChart.tsx index a56ed8c1c..4a7168ca7 100644 --- a/src/components/MovementChart/MovementChart.tsx +++ b/src/components/MovementChart/MovementChart.tsx @@ -12,6 +12,8 @@ import { useMemo } from 'react' import { BarChart, Bar, + ComposedChart, + Line, XAxis, YAxis, CartesianGrid, @@ -19,6 +21,7 @@ import { Tooltip, } from 'recharts' import { pickMovementBucketSeconds } from '@/src/lib/movement' +import { WATCH_MAX_SCALE, WATCH_MOTION_MAX_SERIES_MS, watchBuckets, type WatchMinute } from '@/src/lib/watchMovement' interface BucketRecord { side: 'left' | 'right' @@ -37,7 +40,12 @@ interface SummaryRecord { interface ChartDataPoint { time: string timestamp: number - movement: number + /** Pod events/hr; null where only the Watch has a bucket, or at a line break. */ + movement: number | null + /** Watch movement as a fraction of its night's peak; null breaks the line. */ + watch?: number | null + /** Watch moving readings summed over the bucket. */ + watchMoving?: number } /** @@ -99,6 +107,37 @@ function toChartData(buckets: BucketRecord[], bucketSeconds: number): ChartDataP .sort((a, b) => a.timestamp - b.timestamp) } +/** + * Add Apple Watch movement to the pod's bucket points. Watch minutes count + * only inside a sleep record (the nights shown); each night is scaled to its + * own peak. Buckets only the Watch has are added without a bar, and a break + * point is inserted wherever consecutive Watch buckets are not adjacent, so the + * line never bridges a gap. Without Watch data the points come back unchanged. + */ +export function withWatchMovement(points: ChartDataPoint[], minutes: WatchMinute[], nights: Array<{ start: number, end: number }>, bucketSeconds: number): ChartDataPoint[] { + const bucketMs = bucketSeconds * 1000 + const watch = watchBuckets(minutes, nights, bucketMs) + if (watch.size === 0) return points + const byTime = new Map(points.map(p => [p.timestamp, { ...p, watch: null as number | null }])) + for (const [t, w] of watch) { + const point = byTime.get(t) ?? { time: new Date(t).toISOString(), timestamp: t, movement: null, watch: null } + byTime.set(t, { ...point, watch: w.scaled, watchMoving: w.moving }) + } + const merged: ChartDataPoint[] = [] + let lastWatch: number | null = null + for (const point of [...byTime.values()].sort((a, b) => a.timestamp - b.timestamp)) { + if (point.watch != null) { + if (lastWatch != null && point.timestamp - lastWatch > bucketMs) { + const t = lastWatch + bucketMs + if (merged.at(-1)?.watch != null) merged.push({ time: new Date(t).toISOString(), timestamp: t, movement: null, watch: null }) + } + lastWatch = point.timestamp + } + merged.push(point) + } + return merged +} + /** * For week view, return one tick key per calendar day (the first bucket * of that day) so the axis shows Mon/Tue/Wed/... cleanly. Day view @@ -141,6 +180,7 @@ function MovementTooltip({ active, payload, bucketSeconds }: MovementTooltipProp const timeFormat = useTimeFormat() if (!active || !payload?.[0]) return null const data = payload[0].payload as ChartDataPoint + if (data.movement == null && data.watchMoving == null) return null // Tooltip shows the full bucket timestamp regardless of whether the // axis hides labels for non-day-boundary buckets. const tooltipLabel = formatClock(new Date(data.timestamp), timeFormat, { @@ -152,9 +192,16 @@ function MovementTooltip({ active, payload, bucketSeconds }: MovementTooltipProp return (

{tooltipLabel}

-

- {`${data.movement} /hr`} -

+ {data.movement != null && ( +

+ {`${data.movement} /hr`} +

+ )} + {data.watchMoving != null && ( +

+ {`Watch ${data.watchMoving} moving`} +

+ )}
) } @@ -193,6 +240,11 @@ export function MovementChart() { { refetchOnWindowFocus: false }, ) + const watchQuery = trpc.biometrics.getWatchMotionSeries.useQuery( + { side, startDate: weekStart, endDate: weekEnd }, + { refetchOnWindowFocus: false, enabled: weekEnd.getTime() - weekStart.getTime() <= WATCH_MOTION_MAX_SERIES_MS }, + ) + const buckets = useMemo(() => (bucketsQuery.data ?? []) as BucketRecord[], [bucketsQuery.data]) const totalSleepSeconds = useMemo( @@ -207,7 +259,22 @@ export function MovementChart() { [summaryQuery.data, totalSleepSeconds, nights], ) - const chartData = useMemo(() => toChartData(buckets, bucketSeconds), [buckets, bucketSeconds]) + const nightWindows = useMemo( + () => Array.isArray(sleepData) + ? sleepData.map(r => ({ start: new Date(r.enteredBedAt).getTime(), end: r.leftBedAt ? new Date(r.leftBedAt).getTime() : weekEnd.getTime() })) + : [], + [sleepData, weekEnd], + ) + const watchMinutes = useMemo( + () => (watchQuery.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), moving: r.movingSamples })), + [watchQuery.data], + ) + const chartData = useMemo( + () => withWatchMovement(toChartData(buckets, bucketSeconds), watchMinutes, nightWindows, bucketSeconds), + [buckets, bucketSeconds, watchMinutes, nightWindows], + ) + const hasWatch = chartData.some(p => p.watch != null) + const Chart = hasWatch ? ComposedChart : BarChart const isMultiDay = bucketSeconds >= 30 * 60 const tickKeys = useMemo(() => pickTickKeys(chartData, isMultiDay), [chartData, isMultiDay]) @@ -244,7 +311,7 @@ export function MovementChart() { : (
- + - + {hasWatch && } + {hasWatch && } +
)} + {hasWatch && ( +
+ + + Pod + + + + Apple Watch · scaled per night + +
+ )} ) } diff --git a/src/components/MovementChart/tests/charts.test.tsx b/src/components/MovementChart/tests/charts.test.tsx index f8e8b23d3..1f706faf6 100644 --- a/src/components/MovementChart/tests/charts.test.tsx +++ b/src/components/MovementChart/tests/charts.test.tsx @@ -1,14 +1,15 @@ import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, expect, it, vi } from 'vitest' -import { MovementChart } from '../MovementChart' +import { MovementChart, withWatchMovement } from '../MovementChart' import { HumidityChart } from '@/src/components/Environment/HumidityChart' -const mock = vi.hoisted(() => ({ buckets: [] as unknown[], summary: undefined as unknown, sleep: undefined as unknown, days: 1, loading: false, error: null as Error | null, chart: vi.fn() })) +const mock = vi.hoisted(() => ({ buckets: [] as unknown[], summary: undefined as unknown, sleep: undefined as unknown, watch: [] as unknown[], days: 1, loading: false, error: null as Error | null, chart: vi.fn() })) vi.mock('@/src/hooks/useBiometricsSide', () => ({ useBiometricsSide: () => ({ side: 'left' }) })) vi.mock('@/src/hooks/useWeekNavigator', () => ({ useWeekNavigator: () => ({ weekStart: new Date(0), weekEnd: new Date(mock.days * 86400000) }) })) vi.mock('@/src/utils/trpc', () => ({ trpc: { biometrics: { getMovementBuckets: { useQuery: () => ({ data: mock.buckets, isLoading: mock.loading, error: mock.error }) }, getMovementSummary: { useQuery: () => ({ data: mock.summary }) }, getSleepRecords: { useQuery: () => ({ data: mock.sleep }) }, + getWatchMotionSeries: { useQuery: () => ({ data: mock.watch }) }, } } })) vi.mock('recharts', () => { const container = ({ children }: { children: ReactNode }) =>
{children}
@@ -17,7 +18,7 @@ vi.mock('recharts', () => { return
{children}
} return { - ResponsiveContainer: container, BarChart: chart, AreaChart: chart, Bar: () => null, Area: () => null, CartesianGrid: () => null, YAxis: () => null, + ResponsiveContainer: container, BarChart: chart, ComposedChart: chart, AreaChart: chart, Bar: () => null, Area: () => null, Line: () => , CartesianGrid: () => null, YAxis: () => null, XAxis: ({ tickFormatter }: { tickFormatter?: (v: string) => string }) => {tickFormatter?.('2026-09-28T22:00:00Z')}, Tooltip: ({ content, formatter, labelFormatter }: { content?: ReactNode, formatter?: (v: number) => unknown, labelFormatter?: (v: number) => unknown }) => (
@@ -39,7 +40,7 @@ vi.mock('recharts', () => { } }) beforeEach(() => { - Object.assign(mock, { buckets: [], summary: undefined, sleep: undefined, days: 1, loading: false, error: null }) + Object.assign(mock, { buckets: [], summary: undefined, sleep: undefined, watch: [], days: 1, loading: false, error: null }) vi.clearAllMocks() }) it('renders movement loading, empty and error states', () => { @@ -65,6 +66,33 @@ it.each([1, 7])('normalizes movement buckets and summarizes %s days of sleep', ( expect(points[0].timestamp).toBeLessThan(points.at(-1).timestamp) expect(points[0].movement).toBeGreaterThan(0) }) +it('overlays Watch movement inside the nights only, with a legend', () => { + const M = 60_000 + mock.buckets = [{ side: 'left', bucketStart: new Date(10 * M), eventCount: 2, totalMovement: 10, sampleCount: 1 }] + mock.sleep = [{ sleepDurationSeconds: 3600, enteredBedAt: new Date(5 * M), leftBedAt: new Date(60 * M) }] + mock.watch = [{ timestamp: new Date(2 * M), movingSamples: 99 }, { timestamp: new Date(11 * M), movingSamples: 4 }] + render() + expect(screen.getByTestId('movement-watch-legend').textContent).toContain('Apple Watch · scaled per night') + expect(screen.getByTestId('watch-line')).toBeTruthy() + const points = mock.chart.mock.lastCall?.[0] + expect(points.map((p: { watchMoving?: number }) => p.watchMoving)).toEqual([4]) +}) +it('leaves the chart untouched without Watch data in a night', () => { + mock.buckets = [{ side: 'left', bucketStart: new Date(0), eventCount: 2, totalMovement: 10, sampleCount: 1 }] + mock.watch = [{ timestamp: new Date(0), movingSamples: 3 }] + render() + expect(screen.queryByTestId('movement-watch-legend')).toBeNull() + expect(screen.queryByTestId('watch-line')).toBeNull() + expect(mock.chart.mock.lastCall?.[0]).toEqual([{ time: new Date(0).toISOString(), timestamp: 0, movement: 24 }]) +}) +it('adds Watch-only buckets and breaks the line between non-adjacent buckets', () => { + const B = 300_000 + const pod = [0, 4 * B].map(t => ({ time: new Date(t).toISOString(), timestamp: t, movement: 5 })) + const minutes = [0, B, 4 * B].map(t => ({ t, moving: 10 })) + const merged = withWatchMovement(pod, minutes, [{ start: 0, end: 10 * B }], 300) + expect(merged.map(p => [p.timestamp / B, p.movement, p.watch])).toEqual([[0, 5, 1], [1, null, 1], [2, null, null], [4, 5, 1]]) + expect(withWatchMovement(pod, [], [{ start: 0, end: 10 * B }], 300)).toBe(pod) +}) it('derives humidity statistics, samples dense data and formats the tooltip', () => { const { rerender } = render() expect(screen.getByText('No humidity data available')).toBeTruthy() diff --git a/src/components/Sleep/NightVitals.tsx b/src/components/Sleep/NightVitals.tsx index 514379ba7..da7c50dc1 100644 --- a/src/components/Sleep/NightVitals.tsx +++ b/src/components/Sleep/NightVitals.tsx @@ -5,6 +5,7 @@ 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, readingsInSessions, splitSessions } from '@/src/components/biometrics/biometricsLogic' +import { WATCH_MOTION_MAX_SERIES_MS, watchInWindows } from '@/src/lib/watchMovement' /** 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 }) { @@ -16,6 +17,7 @@ export function NightVitals({ side, start, end }: { side: 'left' | 'right', star const br = trpc.biometrics.getReferenceSamples.useQuery({ ...range, metric: 'breathing_rate', limit: 20000 }) const oxygen = trpc.biometrics.getReferenceSamples.useQuery({ ...range, metric: 'blood_oxygen', limit: 20000 }) const movement = trpc.biometrics.getMovementBuckets.useQuery({ ...range, bucketSeconds: 300, limit: 10000 }) + const watch = trpc.biometrics.getWatchMotionSeries.useQuery(range, { enabled: end - start <= WATCH_MOTION_MAX_SERIES_MS }) 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) @@ -23,6 +25,7 @@ export function NightVitals({ side, start, end }: { side: 'left' | 'right', star const referenceRmssd = (rmssd.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 referenceOxygen = (oxygen.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) + const watchMovement = (watch.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), moving: r.movingSamples })) const sessionPoints = points.length ? points : [...new Set([...reference, ...referenceHrv, ...referenceRmssd, ...referenceBr, ...referenceOxygen].map(r => r.t))] @@ -36,9 +39,9 @@ export function NightVitals({ side, start, end }: { side: 'left' | 'right', star {vitals.isLoading || hr.isLoading || hrv.isLoading || rmssd.isLoading || br.isLoading || oxygen.isLoading ? : sessionPoints.length > 0 - ? + ? :

No recorded vitals for this night.

} - 0} referenceRmssd={referenceRmssd.length > 0} referenceHrv={referenceHrv.length > 0} referenceBr={referenceBr.length > 0} referenceOxygen={readingsInSessions(referenceOxygen, sessions).length > 0} /> + 0} referenceRmssd={referenceRmssd.length > 0} referenceHrv={referenceHrv.length > 0} referenceBr={referenceBr.length > 0} referenceOxygen={readingsInSessions(referenceOxygen, sessions).length > 0} watchMovement={watchInWindows(watchMovement, sessions).length > 0} /> {(referenceHrv.length > 0 || referenceRmssd.length > 0) &&

Watch SDNN, Watch RMSSD 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/tests/NightVitals.test.tsx b/src/components/Sleep/tests/NightVitals.test.tsx index ec1f406cd..9fda88123 100644 --- a/src/components/Sleep/tests/NightVitals.test.tsx +++ b/src/components/Sleep/tests/NightVitals.test.tsx @@ -3,10 +3,11 @@ 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() })) +const mock = vi.hoisted(() => ({ query: vi.fn(), watch: vi.fn(() => ({ data: [] as unknown[] })), 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 }, + getWatchMotionSeries: { useQuery: mock.watch }, } } })) vi.mock('@/src/components/biometrics/VitalsExplorer', () => ({ VitalsExplorer: (props: unknown) => { mock.chart(props) @@ -54,6 +55,20 @@ it('waits for blood oxygen before drawing the night', () => { expect(mock.chart).not.toHaveBeenCalled() expect(screen.queryByText('No recorded vitals for this night.')).toBeNull() }) +it('passes the night’s Watch movement to the chart and legend, and draws nothing extra without it', () => { + mock.query.mockImplementation(input => ({ data: input.limit === 100000 + ? [{ timestamp: new Date(60_000), heartRate: 60, hrv: 40, breathingRate: 15 }, { timestamp: new Date(120_000), heartRate: 61, hrv: 40, breathingRate: 15 }] + : [] })) + render() + expect(mock.watch).toHaveBeenCalledWith({ side: 'left', startDate: new Date(0), endDate: new Date(600_000) }, expect.objectContaining({ enabled: true })) + expect(mock.chart).toHaveBeenLastCalledWith(expect.objectContaining({ watchMovement: [] })) + expect(screen.queryByTestId('legend-watch-movement')).toBeNull() + cleanup() + mock.watch.mockReturnValue({ data: [{ timestamp: new Date(60_000), movingSamples: 7 }] }) + render() + expect(mock.chart).toHaveBeenLastCalledWith(expect.objectContaining({ watchMovement: [{ t: 60_000, moving: 7 }] })) + expect(screen.getByTestId('legend-watch-movement')).toBeTruthy() +}) it('reports a failed recorded-vitals request', () => { mock.query.mockReturnValue({ error: new Error('offline') }) render() diff --git a/src/components/Sleep/tests/SleepScreen.test.tsx b/src/components/Sleep/tests/SleepScreen.test.tsx index 46206fb02..cdb3a5305 100644 --- a/src/components/Sleep/tests/SleepScreen.test.tsx +++ b/src/components/Sleep/tests/SleepScreen.test.tsx @@ -36,6 +36,7 @@ vi.mock('@/src/utils/trpc', () => ({ getReferenceStages: { useQuery: state.reference }, getReferenceVitals: { useQuery: () => ({ data: [] }) }, getReferenceSamples: { useQuery: () => ({ data: [] }) }, + getWatchMotionSeries: { useQuery: () => ({ data: [] }) }, getSleepRecords: { useQuery: state.sleep }, getSleepStages: { useQuery: state.stages }, getVitals: { useQuery: state.vitals }, diff --git a/src/components/biometrics/BiometricsPage.tsx b/src/components/biometrics/BiometricsPage.tsx index 1ee98b16f..91020fbd6 100644 --- a/src/components/biometrics/BiometricsPage.tsx +++ b/src/components/biometrics/BiometricsPage.tsx @@ -28,6 +28,7 @@ import { useSideNames } from '@/src/hooks/useSideNames' import { OccupancyCheck } from '@/src/components/diagnostics/OccupancyCheck' import { RawDataButton } from './RawDataButton' import { referenceInSession, type ReferencePoint, VitalsLegend } from './VitalsChart' +import { WATCH_MOTION_MAX_SERIES_MS, watchInWindows } from '@/src/lib/watchMovement' import { WatchUpload } from './WatchUpload' import { VitalsExplorer } from './VitalsExplorer' import { @@ -89,6 +90,9 @@ function BiometricsBody({ sectionSwitch, now }: { sectionSwitch?: ReactNode, now const referenceHrv = (watchHrvQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) const referenceRmssd = (watchRmssdQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) const referenceOxygen = (watchOxygenQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), value: r.value })) + const watchMovementQ = trpc.biometrics.getWatchMotionSeries.useQuery({ side, startDate, endDate }, + { enabled: win.end - win.start <= WATCH_MOTION_MAX_SERIES_MS, refetchInterval: 5 * 60_000 }) + const watchMovement = (watchMovementQ.data ?? []).map(r => ({ t: new Date(r.timestamp).getTime(), moving: r.movingSamples })) 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, @@ -282,10 +286,10 @@ function BiometricsBody({ sectionSwitch, now }: { sectionSwitch?: ReactNode, now : sessions.length === 0 && !stall ?
No vitals in this range
: ( - + )} {(referenceHrv.length > 0 || referenceRmssd.length > 0) &&

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

} - 0} referenceBr={referenceBr.length > 0} referenceRmssd={referenceRmssd.length > 0} referenceHrv={referenceHrv.length > 0} bandLabel={bandLabel} stalled={!!stall} reference={reference.length > 0} /> + 0} referenceBr={referenceBr.length > 0} referenceRmssd={referenceRmssd.length > 0} referenceHrv={referenceHrv.length > 0} bandLabel={bandLabel} stalled={!!stall} reference={reference.length > 0} watchMovement={watchInWindows(watchMovement, sessions).length > 0} />
diff --git a/src/components/biometrics/VitalsChart.tsx b/src/components/biometrics/VitalsChart.tsx index ee03b4b30..a8ddddd27 100644 --- a/src/components/biometrics/VitalsChart.tsx +++ b/src/components/biometrics/VitalsChart.tsx @@ -11,6 +11,9 @@ * the heart-rate row so the two sensors can be compared night by night. * Watch blood oxygen has no pod counterpart, so it gets a fourth row of its own, * drawn only when a reading falls inside a session. + * Apple Watch wrist movement (`watchMovement`) is drawn as a line over the + * movement bars in the same 5-minute buckets, scaled to each night's own peak + * and broken at gaps. */ 'use client' @@ -21,6 +24,7 @@ import { axisTicks, timeAtX, clampTo, clock, fmtDuration, fmtGap, layoutAxis, METRICS, nearestIndex, OXYGEN_ROW, readingsInSessions, rollingMean, sessionHeader, type MetricKey, type RangeWindow, type Session, } from './biometricsLogic' +import { splitRuns, WATCH_MAX_SCALE, watchBuckets, type WatchMinute } from '@/src/lib/watchMovement' const LABEL_W = 130 const GUTTER = 34 @@ -100,8 +104,12 @@ export function referenceRuns(points: T[], gapMs = REFE const REFERENCE_TOL_MS = 5 * 60_000 const REFERENCE_STROKE = 'var(--text-1)' +const WATCH_MOVEMENT_STROKE = 'var(--accent-cool)' +const MOVEMENT_H = 48 +/** The pod's movement bars span this long; the Watch line uses the same buckets so the two compare like for like. */ +const MOVEMENT_BUCKET_MS = 300_000 -export function VitalsChart({ sessions, stall, bands, reference = [], referenceHrv = [], referenceRmssd = [], referenceBr = [], referenceOxygen = [], movement = [], onSelectWindow }: { +export function VitalsChart({ sessions, stall, bands, reference = [], referenceHrv = [], referenceRmssd = [], referenceBr = [], referenceOxygen = [], movement = [], watchMovement = [], onSelectWindow }: { sessions: Session[] /** In bed with no vitals: from the last reading to now. */ stall: { start: number, end: number } | null @@ -113,6 +121,8 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH referenceBr?: Array<{ t: number, value: number }> referenceOxygen?: Array<{ t: number, value: number }> movement?: Array<{ bucketStart: Date, eventCount: number }> + /** Apple Watch moving readings per minute, by minute start. */ + watchMovement?: WatchMinute[] onSelectWindow?: (window: RangeWindow) => void }) { const rows = readingsInSessions(referenceOxygen, sessions).length > 0 ? ROWS : METRICS @@ -136,6 +146,8 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH const ticks = axisTicks(axis, timeFormat) const multiNight = sessions.length > 1 && (sessions.at(-1)?.end ?? 0) - sessions[0].start > 24 * 60 * 60_000 const maxMovement = Math.max(1, ...movement.map(b => b.eventCount)) + // Only minutes inside a session shown are drawn or count toward a night's peak. + const watchScaled = useMemo(() => [...watchBuckets(watchMovement, sessions, MOVEMENT_BUCKET_MS)].map(([t, bucket]) => ({ t, ...bucket })).sort((a, b) => a.t - b.t), [watchMovement, sessions]) const onMove = (e: PointerEvent) => { const box = e.currentTarget.getBoundingClientRect() @@ -311,6 +323,14 @@ export function VitalsChart({ sessions, stall, bands, reference = [], referenceH const h = b.eventCount / maxMovement * 48 return {`${clock(t, timeFormat)} · ${b.eventCount} position changes`} }))} + {axis.segments.filter(s => s.kind === 'session').flatMap(seg => splitRuns(watchScaled.filter(m => m.t + MOVEMENT_BUCKET_MS > seg.start && m.t <= seg.end), MOVEMENT_BUCKET_MS).map((run) => { + const xs = run.map(m => axis.xOf(seg, clampTo(m.t + MOVEMENT_BUCKET_MS / 2, [seg.start, seg.end]))) + const ys = run.map(m => HEAD_H + PLOT_H - m.scaled / WATCH_MAX_SCALE * MOVEMENT_H) + const label = `Apple Watch movement · ${clock(run[0].t, timeFormat)}–${clock(run[run.length - 1].t + MOVEMENT_BUCKET_MS, timeFormat)}` + return run.length > 1 + ? {label} + : {`${label} · ${run[0].moving} moving`} + }))} {/* Globally spaced date labels for multiple nights, clock labels for one night. */} {ticks.map(tick => ( @@ -426,7 +446,7 @@ function LegendGroup({ device, children }: { device: string, children: ReactNode ) } -export function VitalsLegend({ bandLabel, stalled, reference = false, referenceHrv = false, referenceRmssd = false, referenceBr = false, referenceOxygen = false }: { bandLabel: string, stalled: boolean, reference?: boolean, referenceHrv?: boolean, referenceRmssd?: boolean, referenceBr?: boolean, referenceOxygen?: boolean }) { +export function VitalsLegend({ bandLabel, stalled, reference = false, referenceHrv = false, referenceRmssd = false, referenceBr = false, referenceOxygen = false, watchMovement = false }: { bandLabel: string, stalled: boolean, reference?: boolean, referenceHrv?: boolean, referenceRmssd?: boolean, referenceBr?: boolean, referenceOxygen?: boolean, watchMovement?: boolean }) { return (
@@ -449,7 +469,7 @@ export function VitalsLegend({ bandLabel, stalled, reference = false, referenceH )} - {(reference || referenceHrv || referenceRmssd || referenceBr || referenceOxygen) && ( + {(reference || referenceHrv || referenceRmssd || referenceBr || referenceOxygen || watchMovement) && ( {reference && ( @@ -481,6 +501,12 @@ export function VitalsLegend({ bandLabel, stalled, reference = false, referenceH blood oxygen )} + {watchMovement && ( + + + movement · scaled per night + + )} )}
diff --git a/src/components/biometrics/tests/BiometricsPage.test.tsx b/src/components/biometrics/tests/BiometricsPage.test.tsx index 87c0182ca..5220ae906 100644 --- a/src/components/biometrics/tests/BiometricsPage.test.tsx +++ b/src/components/biometrics/tests/BiometricsPage.test.tsx @@ -59,6 +59,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 : input.metric === 'hrv_rmssd' ? state.referenceRmssd : input.metric === 'blood_oxygen' ? state.referenceOxygen : [] }) }, + getWatchMotionSeries: { useQuery: () => ({ data: [] }) }, getReferenceVitals: { useQuery: (_input: unknown, opts: Record) => { state.referenceOpts = opts diff --git a/src/components/biometrics/tests/DataPage.test.tsx b/src/components/biometrics/tests/DataPage.test.tsx index 9fcd12692..aa6ac40f1 100644 --- a/src/components/biometrics/tests/DataPage.test.tsx +++ b/src/components/biometrics/tests/DataPage.test.tsx @@ -31,6 +31,7 @@ vi.mock('@/src/utils/trpc', () => ({ getReferenceVitals: { useQuery: () => ({ data: [] }) }, getMovementBuckets: { useQuery: () => ({ data: [] }) }, getReferenceSamples: { useQuery: () => ({ data: [] }) }, + getWatchMotionSeries: { 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 3676a668a..80b7a006d 100644 --- a/src/components/biometrics/tests/VitalsChart.test.tsx +++ b/src/components/biometrics/tests/VitalsChart.test.tsx @@ -386,3 +386,45 @@ describe('blood oxygen row', () => { expect(screen.getByText('Apple Watch').nextElementSibling?.textContent).toContain('blood oxygen') }) }) + +describe('VitalsChart Apple Watch movement overlay', () => { + it('draws nothing extra without Watch movement', () => { + withWidth() + const { container: plain } = render() + const { container: empty } = render() + // Same markup apart from the per-instance hatch pattern id. + const strip = (html: string) => html.replace(/_r_\w+_|«\w+»|:r\w+:/g, '') + expect(strip(empty.innerHTML)).toBe(strip(plain.innerHTML)) + expect(screen.queryAllByTestId('watch-movement-line')).toHaveLength(0) + }) + + it('sums into the pod\'s 5-minute buckets, breaks the line at missing buckets, dots isolated ones and ignores minutes outside sessions', () => { + withWidth() + const watchMovement = [ + // Before the first session: outside the night, never drawn or counted. + { t: START - 30 * MIN, moving: 500 }, + { t: START, moving: 2 }, { t: START + MIN, moving: 4 }, { t: START + 6 * MIN, moving: 1 }, { t: START + 11 * MIN, moving: 20 }, + // A bucket without bins: a new run, and this one stands alone. + { t: START + 20 * MIN, moving: 5 }, + { t: START + 30 * MIN, moving: 3 }, { t: START + 35 * MIN, moving: 2 }, + // Between sessions. + { t: START + 4 * 60 * MIN, moving: 50 }, + ] + render() + const lines = screen.getAllByTestId('watch-movement-line') + expect(lines).toHaveLength(2) + expect(lines[0].getAttribute('d')?.match(/[ML]/g)).toEqual(['M', 'L', 'L']) + expect(lines[0].getAttribute('stroke')).toBe('var(--accent-cool)') + expect(screen.getAllByTestId('watch-movement-dot')).toHaveLength(1) + // Scaled to the in-session peak, so the out-of-session 500 does not flatten the night. + const ys = (lines[0].getAttribute('d') ?? '').split(/[ML]/).filter(Boolean).map(p => Number(p.split(' ')[1])) + expect(Math.max(...ys) - Math.min(...ys)).toBeGreaterThan(20) + }) + + it('lists Watch movement in the legend only when shown', () => { + const { rerender } = render() + expect(screen.queryByTestId('legend-watch-movement')).toBeNull() + rerender() + expect(screen.getByTestId('legend-watch-movement').textContent).toBe('movement · scaled per night') + }) +}) diff --git a/src/db/biometrics-migrations/0035_watch_motion_captures.sql b/src/db/biometrics-migrations/0035_watch_motion_captures.sql new file mode 100644 index 000000000..78f5c8ed9 --- /dev/null +++ b/src/db/biometrics-migrations/0035_watch_motion_captures.sql @@ -0,0 +1,14 @@ +CREATE TABLE `watch_motion_captures` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `side` text NOT NULL, + `version` text NOT NULL, + `recorded_from` integer NOT NULL, + `recorded_to` integer NOT NULL, + `bins` integer NOT NULL, + `trimmed_bins` integer NOT NULL, + `sha256` text NOT NULL, + `file` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uq_watch_motion_captures_side_recorded_from` ON `watch_motion_captures` (`side`,`recorded_from`); \ No newline at end of file diff --git a/src/db/biometrics-migrations/meta/0035_snapshot.json b/src/db/biometrics-migrations/meta/0035_snapshot.json new file mode 100644 index 000000000..56eade6b7 --- /dev/null +++ b/src/db/biometrics-migrations/meta/0035_snapshot.json @@ -0,0 +1,3254 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3bf7f651-7787-4c90-983c-0e5f9a1eca96", + "prevId": "59d256c2-02fe-4f8c-9e57-85b2c3e148dc", + "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 + }, + "end_timestamp": { + "name": "end_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "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": {} + }, + "watch_motion_captures": { + "name": "watch_motion_captures", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recorded_from": { + "name": "recorded_from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recorded_to": { + "name": "recorded_to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bins": { + "name": "bins", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trimmed_bins": { + "name": "trimmed_bins", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_watch_motion_captures_side_recorded_from": { + "name": "uq_watch_motion_captures_side_recorded_from", + "columns": [ + "side", + "recorded_from" + ], + "isUnique": true + } + }, + "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 e17ae1738..7de19298b 100644 --- a/src/db/biometrics-migrations/meta/_journal.json +++ b/src/db/biometrics-migrations/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1793841463347, "tag": "0034_reference_sample_device", "breakpoints": true + }, + { + "idx": 35, + "version": "6", + "when": 1793998512298, + "tag": "0035_watch_motion_captures", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/biometrics-schema.ts b/src/db/biometrics-schema.ts index 1bf760064..fa80dea02 100644 --- a/src/db/biometrics-schema.ts +++ b/src/db/biometrics-schema.ts @@ -335,6 +335,28 @@ export const referenceSamples = sqliteTable('reference_samples', { uniqueIndex('uq_reference_samples_side_metric_timestamp_source').on(t.side, t.metric, t.timestamp, t.source), ]) +/** + * Apple Watch wrist-movement recordings (watch-motion-v1/v2 files sent by the + * iOS app), one row per night holding the trimmed canonical file. A movement + * label for comparison and off-pod training only: never read by the stage + * classifier, movement scoring or any hardware control. A resend of the same + * night (side, recorded_from) replaces the stored file in place. + */ +export const watchMotionCaptures = sqliteTable('watch_motion_captures', { + id: integer('id').primaryKey({ autoIncrement: true }), + side: text('side', { enum: ['left', 'right'] }).notNull(), + version: text('version', { enum: ['watch-motion-v1', 'watch-motion-v2'] }).notNull(), + recordedFrom: integer('recorded_from', { mode: 'timestamp_ms' }).notNull(), + recordedTo: integer('recorded_to', { mode: 'timestamp_ms' }).notNull(), + bins: integer('bins').notNull(), + trimmedBins: integer('trimmed_bins').notNull(), + sha256: text('sha256').notNull(), + file: text('file').notNull(), // canonical JSON (sorted keys), served byte-for-byte + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), +}, t => [ + uniqueIndex('uq_watch_motion_captures_side_recorded_from').on(t.side, t.recordedFrom), +]) + // ── Calibration tables ── export const calibrationProfiles = sqliteTable('calibration_profiles', { diff --git a/src/db/retention.ts b/src/db/retention.ts index a37fdb01d..8f33e8933 100644 --- a/src/db/retention.ts +++ b/src/db/retention.ts @@ -15,6 +15,7 @@ import { vitals, vitalsQuality, waterLevelReadings, + watchMotionCaptures, } from './biometrics-schema' /** @@ -30,6 +31,9 @@ import { * ambient_light, water_level_readings, pump_alerts, vitals_quality, * thermal_state, health_runs, flash_write_samples, * heartbeats (~150 KB per side per night of beat times) + * watch_motion_captures (Apple Watch movement labels, ~0.5 MB per night + * as canonical JSON — far heavier than the other reference tables, so + * pruned by recorded_to; the iPhone keeps the original files) * * vitals_quality shares vitals' timestamp cutoff so each quality row dies * with its paired vitals row (vitals_id is a logical, unenforced reference — @@ -58,6 +62,7 @@ const RETENTION_TABLES = [ { 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' }, + { table: watchMotionCaptures, column: watchMotionCaptures.recordedTo, name: 'watch_motion_captures' }, ] as const /** Tables the daily retention pass prunes, for System → Databases. */ diff --git a/src/db/tests/__snapshots__/schema.test.ts.snap b/src/db/tests/__snapshots__/schema.test.ts.snap index b84d2d0c4..241b9cf1b 100644 --- a/src/db/tests/__snapshots__/schema.test.ts.snap +++ b/src/db/tests/__snapshots__/schema.test.ts.snap @@ -784,6 +784,29 @@ 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", + "version | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:watch-motion-v1,watch-motion-v2", + "recorded_from | sql:integer | logical:date/SQLiteTimestamp | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "recorded_to | sql:integer | logical:date/SQLiteTimestamp | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "bins | sql:integer | logical:number/SQLiteInteger | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "trimmed_bins | sql:integer | logical:number/SQLiteInteger | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "sha256 | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", + "file | sql:text | logical:string/SQLiteText | 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", + ], + "exportName": "watchMotionCaptures", + "foreignKeys": [], + "indexes": [ + "uq_watch_motion_captures_side_recorded_from | unique | columns:side,recorded_from | where:none", + ], + "name": "watch_motion_captures", + "primaryKeys": [], + "uniqueConstraints": [], + }, { "checks": 0, "columns": [ diff --git a/src/db/tests/retention.test.ts b/src/db/tests/retention.test.ts index 16a11cbae..36fc76001 100644 --- a/src/db/tests/retention.test.ts +++ b/src/db/tests/retention.test.ts @@ -151,9 +151,16 @@ describe('pruneOldBiometrics', () => { { timestamp: fresh, bootId: 'a', sectorsWritten: 2 }, ]).run() + // Pruned by when the recording ended. + const capture = { version: 'watch-motion-v1' as const, bins: 1, trimmedBins: 0, sha256: 'x', file: '{}' } + db.insert(schema.watchMotionCaptures).values([ + { ...capture, side: 'left', recordedFrom: new Date(old.getTime() - 3_600_000), recordedTo: old }, + { ...capture, side: 'left', recordedFrom: old, recordedTo: fresh }, + ]).run() + const result = pruneOldBiometrics(cutoff, db) - expect(result.rowsDeleted).toBe(13) + expect(result.rowsDeleted).toBe(14) expect(result.perTable).toEqual({ vitals: 1, vitals_quality: 1, @@ -168,7 +175,9 @@ describe('pruneOldBiometrics', () => { thermal_state: 1, health_runs: 1, flash_write_samples: 1, + watch_motion_captures: 1, }) + expect(db.select().from(schema.watchMotionCaptures).all().map(r => r.recordedTo)).toEqual([fresh]) // Verify fresh rows remain expect(db.select().from(vitals).all()).toHaveLength(1) @@ -278,6 +287,7 @@ describe('pruneOldBiometrics', () => { thermal_state: 0, health_runs: 0, flash_write_samples: 0, + watch_motion_captures: 0, }) }) diff --git a/src/demo/handlers/biometrics.ts b/src/demo/handlers/biometrics.ts index f3f7d5b0a..87e86a284 100644 --- a/src/demo/handlers/biometrics.ts +++ b/src/demo/handlers/biometrics.ts @@ -234,6 +234,21 @@ export const biometrics: DemoHandlers<'biometrics'> = { .slice(-(input.limit ?? 5000)) }, + // Apple Watch wrist movement per minute, loosely following the pod's own + // movement score, so the night chart shows the Watch overlay. + getWatchMotionSeries: (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 demoMovement(input.side, start, end).map(m => ({ + timestamp: new Date(Math.floor(m.t / MINUTE) * MINUTE), + movingSamples: Math.round(m.score / 12), + maxJerk: Math.round(m.score / 40 * 100) / 100, + meanVariance: m.score * 1e-6, + samples: 3000, + })) + }, + getReferenceStages: (input) => { const start = input.startDate.getTime() const end = input.endDate.getTime() diff --git a/src/demo/tests/contract.test.ts b/src/demo/tests/contract.test.ts index 49b1f03f6..c215dec96 100644 --- a/src/demo/tests/contract.test.ts +++ b/src/demo/tests/contract.test.ts @@ -79,6 +79,7 @@ describe('demo handlers honour the real router contracts', () => { await call('biometrics.getVitalsSummary', { side }) await call('biometrics.getMovementBuckets', { side, bucketSeconds: 300 }) await call('biometrics.getMovementSummary', { side }) + await call('biometrics.getWatchMotionSeries', { side, startDate: new Date(now - day), endDate: new Date(now) }) await call('biometrics.getSleepStages', { side }) await call('calibration.getStatus', { side }) await call('calibration.getHistory', { side }) diff --git a/src/lib/tests/watchMovement.test.ts b/src/lib/tests/watchMovement.test.ts new file mode 100644 index 000000000..049163a23 --- /dev/null +++ b/src/lib/tests/watchMovement.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest' +import { scaleWatchByNight, splitRuns, watchInWindows, watchPeak } from '../watchMovement' + +const M = 60_000 + +it('takes the 95th percentile as the peak, never below 1', () => { + expect(watchPeak([])).toBe(1) + expect(watchPeak([0, 0, 0.5])).toBe(1) + expect(watchPeak([...Array.from({ length: 99 }, (_, i) => i + 1), 1000])).toBe(96) +}) + +it('splits runs at gaps over two minutes', () => { + const points = [0, M, 3 * M, 6 * M].map(t => ({ t })) + expect(splitRuns(points).map(r => r.map(p => p.t / M))).toEqual([[0, 1, 3], [6]]) +}) + +it('keeps minutes inside the windows only, sorted', () => { + const minutes = [{ t: 5 * M, moving: 1 }, { t: M, moving: 2 }, { t: 50 * M, moving: 3 }] + expect(watchInWindows(minutes, [{ start: 0, end: 10 * M }]).map(m => m.t)).toEqual([M, 5 * M]) +}) + +it('scales each night to its own peak and clips at 1.2', () => { + // One thrash in 40 minutes sits above the 95th percentile, so it clips instead of flattening the night. + const night1 = Array.from({ length: 40 }, (_, i) => ({ t: i * M, moving: i === 39 ? 100 : 10 })) + const night2 = Array.from({ length: 20 }, (_, i) => ({ t: 20 * 3600_000 + i * M, moving: 2 })) + const scaled = scaleWatchByNight([...night1, ...night2]) + expect(scaled[0].scaled).toBe(1) + expect(scaled[39].scaled).toBe(1.2) + expect(scaled.slice(40).every(m => m.scaled === 1)).toBe(true) +}) diff --git a/src/lib/watchMovement.ts b/src/lib/watchMovement.ts new file mode 100644 index 000000000..d6f9752aa --- /dev/null +++ b/src/lib/watchMovement.ts @@ -0,0 +1,80 @@ +/** + * Chart helpers for Apple Watch wrist movement (moving readings per minute) + * drawn over the pod's movement. Watch motion is a comparison label only. + * + * Rules learned from the iOS chart: a missing minute is no data, never + * stillness, so lines never bridge gaps; only minutes inside the sleep session + * shown are drawn and scaled; each night is scaled to its own peak. + */ + +export interface WatchMinute { t: number, moving: number } + +/** The longest window biometrics.getWatchMotionSeries serves (31 days). */ +export const WATCH_MOTION_MAX_SERIES_MS = 31 * 24 * 60 * 60_000 +/** Consecutive minutes further apart than this are not joined. */ +export const WATCH_GAP_MS = 2 * 60_000 +/** Watch minutes further apart than this belong to different nights for scaling. */ +export const WATCH_NIGHT_GAP_MS = 4 * 60 * 60_000 +/** A normalized value may overshoot the night's peak by this much before it is clipped. */ +export const WATCH_MAX_SCALE = 1.2 + +/** The 95th percentile (at least 1), so one thrash does not flatten the rest of the night. Matches iOS. */ +export function watchPeak(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b) + if (sorted.length === 0) return 1 + return Math.max(sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))], 1) +} + +/** Minutes inside any of the windows, time-sorted. */ +export function watchInWindows(minutes: WatchMinute[], windows: Array<{ start: number, end: number }>): WatchMinute[] { + return minutes.filter(m => windows.some(w => m.t >= w.start && m.t <= w.end)).sort((a, b) => a.t - b.t) +} + +/** + * Each minute's movement as a fraction of its night's peak, clipped at + * WATCH_MAX_SCALE. Pass only minutes already restricted to the sessions shown. + */ +export function scaleWatchByNight(minutes: WatchMinute[], nightGapMs = WATCH_NIGHT_GAP_MS): Array { + const out: Array = [] + for (const night of splitRuns(minutes, nightGapMs)) { + const peak = watchPeak(night.map(m => m.moving)) + for (const m of night) out.push({ ...m, scaled: Math.min(m.moving / peak, WATCH_MAX_SCALE) }) + } + return out +} + +/** + * Watch movement summed into chart buckets (aligned like the pod's + * getMovementBuckets) for each night window, each night scaled to the 95th + * percentile of its own buckets. Minutes outside every night are ignored. + */ +export function watchBuckets(minutes: WatchMinute[], nights: Array<{ start: number, end: number }>, bucketMs: number): Map { + const out = new Map() + const used = new Set() + for (const night of nights) { + const sums = new Map() + for (const m of minutes) { + if (m.t < night.start || m.t > night.end || used.has(m.t)) continue + used.add(m.t) + const key = Math.floor(m.t / bucketMs) * bucketMs + sums.set(key, (sums.get(key) ?? 0) + m.moving) + } + const peak = watchPeak([...sums.values()]) + for (const [key, moving] of sums) { + const prior = out.get(key)?.moving ?? 0 + out.set(key, { moving: prior + moving, scaled: Math.min((prior + moving) / peak, WATCH_MAX_SCALE) }) + } + } + return out +} + +/** Time-sorted points split wherever the next is more than `gapMs` later. */ +export function splitRuns(points: T[], gapMs = WATCH_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) + else runs.push([p]) + } + return runs +} diff --git a/src/server/routers/biometrics.ts b/src/server/routers/biometrics.ts index 5dd2c0214..32eca35fa 100644 --- a/src/server/routers/biometrics.ts +++ b/src/server/routers/biometrics.ts @@ -27,6 +27,7 @@ import { } from '@/src/lib/movement' import { recordLegacyReferenceImport } from '@/src/biometrics/references/legacy' import { REFERENCE_SAMPLE_METRICS, referenceAlgorithmSchema, referenceCapabilities, referenceDeviceSchema, referenceSampleMetricSchema } from '@/src/biometrics/references/contracts' +import { WATCH_MOTION_MAX_SERIES_DAYS, watchMotionSeries } from '@/src/biometrics/references/watch-motion' import { compareNightsToWatch, deviationNightSchema, @@ -1367,6 +1368,26 @@ export const biometricsRouter = router({ })) .query(() => referenceCapabilities()), + /** + * Apple Watch wrist movement per minute for a side in [startDate, endDate] + * (at most 31 days), oldest first, from the watch-motion captures the iOS + * app posted. Minutes without Watch bins are absent: no data is not + * stillness. A comparison label only, never an input to staging or scoring. + */ + getWatchMotionSeries: publicProcedure + .meta({ openapi: { method: 'GET', path: '/biometrics/watch-motion-series', protect: false, tags: ['Biometrics'] } }) + .input(z.object({ side: sideSchema, startDate: z.date(), endDate: z.date() }).strict()) + .output(z.array(z.object({ timestamp: z.date(), movingSamples: z.number(), maxJerk: z.number(), meanVariance: z.number(), samples: z.number() }))) + .query(({ input }) => { + if (!validateDateRange(input.startDate, input.endDate)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) + } + if (input.endDate.getTime() - input.startDate.getTime() > WATCH_MOTION_MAX_SERIES_DAYS * DAY_MS) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Select at most ${WATCH_MOTION_MAX_SERIES_DAYS} days` }) + } + return watchMotionSeries(biometricsDb, input.side, input.startDate, input.endDate) + }), + getReferenceVitals: publicProcedure .meta({ openapi: { method: 'GET', path: '/biometrics/reference-vitals', protect: false, tags: ['Biometrics'] } }) .output(z.array(z.object({ diff --git a/src/server/routers/tests/biometrics.watchMotion.test.ts b/src/server/routers/tests/biometrics.watchMotion.test.ts new file mode 100644 index 000000000..b65f50c74 --- /dev/null +++ b/src/server/routers/tests/biometrics.watchMotion.test.ts @@ -0,0 +1,56 @@ +/** + * getWatchMotionSeries against a real in-memory biometrics.db, fed through the + * same parse/save path as POST /api/biometrics/watch-motion. + */ + +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, expect, it, vi } from 'vitest' +import * as schema from '@/src/db/biometrics-schema' +import { parseWatchMotion, saveWatchMotion } from '@/src/biometrics/references/watch-motion' + +const dbHolder = vi.hoisted(() => ({ current: null as unknown })) +vi.mock('@/src/db', () => ({ + db: {}, + get biometricsDb() { return dbHolder.current }, +})) +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 T = Date.UTC(2026, 10, 2, 6) +const M = 60_000 +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') }) + dbHolder.current = db +}) +afterEach(() => raw.close()) + +it('returns per-minute Watch movement for the side, with gaps left out', async () => { + const starts = [T, T + 2000, T + 10 * M] + saveWatchMotion(db, 'left', parseWatchMotion(JSON.stringify({ + version: 'watch-motion-v1', recordedFromMs: T, recordedToMs: T + 11 * M, timeZone: 'UTC', utcOffsetSeconds: 0, sampleRateHz: 50, + binSeconds: 2, jerkThresholdGPerSecond: 1, device: 'test', + bins: { startMs: starts, samples: [100, 100, 50], meanMagnitude: [1, 1, 1], magnitudeVariance: [0.002, 0.004, 0.01], maxJerk: [0.5, 2, 1], movingSamples: [1, 3, 0] }, + }))) + const rows = await caller.getWatchMotionSeries({ side: 'left', startDate: new Date(T - M), endDate: new Date(T + 20 * M) }) + expect(rows).toEqual([ + { timestamp: new Date(T), movingSamples: 4, maxJerk: 2, meanVariance: 0.003, samples: 200 }, + { timestamp: new Date(T + 10 * M), movingSamples: 0, maxJerk: 1, meanVariance: 0.01, samples: 50 }, + ]) + expect(await caller.getWatchMotionSeries({ side: 'right', startDate: new Date(T), endDate: new Date(T + 20 * M) })).toEqual([]) +}) + +it('rejects inverted and over-long windows', async () => { + await expect(caller.getWatchMotionSeries({ side: 'left', startDate: new Date(T), endDate: new Date(T - 1) })).rejects.toThrow(/startDate/) + await expect(caller.getWatchMotionSeries({ side: 'left', startDate: new Date(T), endDate: new Date(T + 32 * 24 * 60 * M) })).rejects.toThrow(/31 days/) +})