From cfec831d6b079fb93f3d8582f27a0912b09c9e2a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 11:16:49 +0530 Subject: [PATCH 1/4] chore: make the repo cloneable, add CI, drop the dead TypeScript port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all of which made this repo look and behave worse in public than the code in it deserves. 1. A fresh clone could not `pub get` at all. pubspec.yaml pinned openstrap_protocol to `path: ../openstrap-protocol-dart`, a directory name that no longer exists anywhere; it only ever resolved on one machine, via a gitignored pubspec_overrides.yaml. Anyone who cloned this repo hit a version-solve failure on the first command in the README. Now pinned to a protocol commit SHA over git, matching the convention edge already uses (SHAs, never floating branch refs). The package description was also still the pre-rewrite one, describing "analytics over minute rollups" — the backend-cron architecture that was replaced by the 1 Hz-native rewrite. 2. No CI. 290 tests and nothing ran them on a PR. Adds dart analyze + dart test on push and PR. Fixed one pre-existing unused import that would have made the first run red. 3. Removed src/ — 35 TypeScript files, ~286KB, last touched before the 1 Hz rewrite. GitHub counted it as 28% of a repo whose first line says "pure Dart". Its package.json also declared `"license": "ISC"` while LICENSE is MIT, which is a licensing contradiction sitting in public. It is the dead pre-1Hz backend port, it is not the parity oracle (that lives in protocol/ts), nothing references it, and git history keeps it. Also adds CONTRIBUTING.md, SECURITY.md, FUNDING.yml and README badges. Test count in the README corrected 282 -> 290. --- .github/FUNDING.yml | 3 + .github/workflows/test.yml | 33 + .gitignore | 4 + CONTRIBUTING.md | 92 ++ README.md | 22 +- SECURITY.md | 52 + package-lock.json | 567 --------- package.json | 17 - pubspec.yaml | 17 +- src/__tests__/_harness.ts | 46 - src/__tests__/analytics.test.ts | 1086 ----------------- src/activity.ts | 11 - src/advanced.ts | 6 - src/arousal.ts | 72 -- src/baselines.ts | 119 -- src/calories.ts | 77 -- src/circadian.ts | 389 ------ src/coach.ts | 277 ----- src/cycle.ts | 100 -- src/cycles.ts | 108 -- src/fitness.ts | 86 -- src/har.ts | 325 ----- src/hrv.ts | 296 ----- src/illness.ts | 169 --- src/index.ts | 129 -- src/nocturnal.ts | 98 -- src/notify.ts | 155 --- src/readiness.ts | 100 -- src/readiness_index.ts | 74 -- src/recovery.ts | 128 -- src/regularity.ts | 95 -- src/resting.ts | 116 -- src/restlessness.ts | 76 -- src/sessions.ts | 183 --- src/sleep.ts | 595 --------- src/spo2.ts | 120 -- src/steps.ts | 92 -- src/strain.ts | 54 - src/stress.ts | 69 -- src/trends.ts | 134 -- src/types.ts | 344 ------ src/util.ts | 114 -- src/wake.ts | 326 ----- src/zones.ts | 54 - test/onehz/real_night_cardio_stager_test.dart | 1 - tsconfig.json | 17 - 46 files changed, 220 insertions(+), 6828 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/test.yml create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 src/__tests__/_harness.ts delete mode 100644 src/__tests__/analytics.test.ts delete mode 100644 src/activity.ts delete mode 100644 src/advanced.ts delete mode 100644 src/arousal.ts delete mode 100644 src/baselines.ts delete mode 100644 src/calories.ts delete mode 100644 src/circadian.ts delete mode 100644 src/coach.ts delete mode 100644 src/cycle.ts delete mode 100644 src/cycles.ts delete mode 100644 src/fitness.ts delete mode 100644 src/har.ts delete mode 100644 src/hrv.ts delete mode 100644 src/illness.ts delete mode 100644 src/index.ts delete mode 100644 src/nocturnal.ts delete mode 100644 src/notify.ts delete mode 100644 src/readiness.ts delete mode 100644 src/readiness_index.ts delete mode 100644 src/recovery.ts delete mode 100644 src/regularity.ts delete mode 100644 src/resting.ts delete mode 100644 src/restlessness.ts delete mode 100644 src/sessions.ts delete mode 100644 src/sleep.ts delete mode 100644 src/spo2.ts delete mode 100644 src/steps.ts delete mode 100644 src/strain.ts delete mode 100644 src/stress.ts delete mode 100644 src/trends.ts delete mode 100644 src/types.ts delete mode 100644 src/util.ts delete mode 100644 src/wake.ts delete mode 100644 src/zones.ts delete mode 100644 tsconfig.json diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2365365 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# GitHub funding config only accepts URLs, not raw wallet addresses. +custom: + - https://github.com/OpenStrap/edge/blob/main/DONATE.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d0ea97b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed lib test + continue-on-error: true + + - name: Analyze + run: dart analyze --fatal-infos + + # The golden capture (whoop_hist.jsonl) is a real band recording kept + # beside the repo rather than committed to it. The handful of tests that + # replay it SKIP here (they call markTestSkipped) and run locally; every + # synthetic-input and property test runs on each PR. + - name: Test + run: dart test --reporter=expanded diff --git a/.gitignore b/.gitignore index d58cb7a..8c920c1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ build/ .DS_Store # legacy ts/ tooling scratch, dont want another node_modules commit node_modules/ + +# Local dev override redirecting sibling packages to in-tree checkouts. +# Never committed — pubspec.yaml pins real SHAs so CI and fresh clones work. +pubspec_overrides.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3361933 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# Contributing + +This package is the math: 1 Hz records in, metrics out. **Pure Dart, zero runtime +dependencies, no I/O, no randomness, no clock.** Same input, same output, every +time. It runs on-device, inside an isolate, on a phone — so it also has to be +cheap. + +## What belongs here (and what doesn't) + +| Your change | Repo | +|---|---| +| A metric, or how an existing number is computed | **here** | +| A record type, opcode, event, anything byte-level | [protocol](https://github.com/OpenStrap/protocol) | +| Storage, sync, Bluetooth, UI, when things get computed | [edge](https://github.com/OpenStrap/edge) | + +## The two rules that matter + +**1. Cite the method.** Everything in here implements a published, +peer-reviewed algorithm, with the citation in a comment next to the code and a +row in [`ALGORITHMS.md`](ALGORITHMS.md). The point is that you can go read the +paper and decide for yourself whether to trust the number. + +If nothing in the literature fits, that's allowed — mark it `ESTIMATE`, give it +low confidence, and say so plainly. What's not allowed is inventing constants +and presenting them as science, or reverse-engineering WHOOP's scores by +fitting to their output. + +**2. Never fabricate a value.** If an input isn't there, return a `Metric` with +a `null` value. Not a default, not a last-known-good, not an interpolation +that'll look plausible on a chart. Cold start returns +`need_baseline:have=H,need=N` and that's the correct behaviour, not a bug to +paper over. + +```dart +class Metric { + final T? value; // null when the inputs weren't there + final double confidence; // 0..1 + final String tier; // AUTH | HIGH | ESTIMATE | RELATIVE + ... +} +``` + +## Honest ceilings — please don't "fix" these + +These are properties of what a WHOOP 4.0 actually hands over, not defects: + +- **HRV is PRV**, derived from 1 Hz beat timing. It is not ECG-grade. +- **Deep sleep is a low-confidence HR-flatness overlay.** The band gives no + signal that distinguishes NREM stages properly. +- **SpO₂ and skin temperature are relative ADC values.** There is no calibration + to absolute units and there won't be. `absolute_spo2:false` is deliberate. +- **Cole–Kripke coefficients on 1 Hz data** are a documented bounded exception, + used for the wake spine only and corrected downstream. Read the comment in + `cardio_stager.dart` before touching it. + +Making any of these *look* more confident than the underlying signal supports is +the one change guaranteed to be rejected. + +## Structure + +Each family is a subdirectory under `lib/src/onehz/` with its own sub-barrel: +`foundations`, `clinical`, `sleep`, `respiration`, `motion`, `workout`, +`wellness`, `human`. Put new work with its siblings and export it through the +barrel. + +There is **one** sleep source (`segmentSleep`) and **one** headline readiness +(`readinessComposite`). Don't add a second of either — extend the existing one. + +## Tests + +```bash +dart pub get +dart analyze +dart test +``` + +Everything runs on synthetic and property-based inputs in CI. A handful of tests +replay `whoop_hist.jsonl`, a real band capture kept *beside* the repo rather +than committed to it; those skip automatically when it's absent. + +Any change to a metric needs a test that pins the behaviour, and — because +stored results are versioned and immutable — a note in the PR saying so, since +it means `kAlgoVersion` has to be bumped over in edge. If that bump doesn't +happen, devices keep serving the old numbers. + +## Pull requests + +- Branch off `main`, one logical change per PR. +- Name the paper you implemented, with enough detail to find it. +- Say whether the output of any existing metric changes. This is the single most + important line in the PR. +- No `Co-Authored-By` trailers. diff --git a/README.md b/README.md index d7c2efa..1037175 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # OpenStrap analytics +[![test](https://github.com/OpenStrap/analytics/actions/workflows/test.yml/badge.svg)](https://github.com/OpenStrap/analytics/actions/workflows/test.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + This is the math. Given the always-on 1 Hz substrate a WHOOP 4.0 actually hands over — beat-to-beat RR intervals, 1 Hz heart rate, 1 Hz tri-axial accel, and a few relative ADC channels (skin temp, SpO2, ambient light) — it works out the things you care about: how @@ -105,7 +108,7 @@ not a feature, no matter how tempting the plausible-looking headline is. dart test # run from the repo root — some fixtures resolve paths relative to it ``` -282 tests, nothing mocked — pure functions, fixture in, assertion out. +290 tests, nothing mocked — pure functions, fixture in, assertion out. ## If you want to add a metric @@ -118,3 +121,20 @@ Derive confidence from real coverage, and return absent (`Metric.absent(...)`, n fabricated fallback) when the inputs genuinely aren't there. And if your idea needs a cause it can't actually tell apart from three other explanations, report the state, not the cause. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) — which repo a change belongs in, how to run the +tests, and the rules that keep this package honest. Security issues go through +[SECURITY.md](SECURITY.md), not a public issue. + +## Support the work + +Free, MIT, no company behind it. If OpenStrap gave your band a second life, +[DONATE.md](https://github.com/OpenStrap/edge/blob/main/DONATE.md) has BTC and EVM +addresses. Protocol findings and bug reports are worth more than money, though. + +--- + +Not affiliated with, endorsed by, or connected to WHOOP. "WHOOP" is their trademark, used +only to say which device this talks to. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..378a50d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,52 @@ +# Security Policy + +## Reporting a vulnerability + +Please **don't** open a public issue for a security problem. + +Use GitHub's private reporting instead: +[**Report a vulnerability →**](https://github.com/OpenStrap/analytics/security/advisories/new) + +That goes straight to the maintainer and stays private until there's a fix. + +Rough expectations, set honestly — this is a one-maintainer project, not a +company with an on-call rota: + +- Acknowledgement within about a week. +- An assessment, and a fix or a clear "won't fix and here's why", within 30 days + for anything that puts user data at risk. +- Credit in the release notes if you want it. + +## What's in scope + +- Anything that discloses a user's health data off their device. +- Anything that lets a third party read, write to, or hijack the Bluetooth + session with someone's band. +- Local data-at-rest problems: the database, exports, the App Group container, + widget snapshots. +- The optional companion worker in + [backend](https://github.com/OpenStrap/backend): auth, the import endpoints, + the opt-in telemetry and health-upload paths. +- Anything that causes the app to send data anywhere the user did not agree to. + +## What's out of scope + +- The band's own firmware. We don't ship it, can't patch it, and won't publish + attacks against it. +- WHOOP's own apps and services. Please report those to WHOOP. +- The fact that sideloaded builds are unsigned, or that a rooted/jailbroken + device can read app storage. Both are known properties of the distribution + model, documented in the README. +- Metric accuracy. Wrong numbers are bugs — open a normal issue. + +## Where your data actually is + +Worth knowing before you go looking: OpenStrap computes and stores everything +on-device. There's no account and no server holding your health data. The +optional companion worker exists for legacy import, an update pointer, and two +opt-in features that are off by default and compiled out of store builds +entirely. See [edge's PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md). + +That means the realistic attack surface is the phone, the Bluetooth link, and +the local database — not a cloud backend. Reports focused there are the most +useful. diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index e4476e6..0000000 --- a/package-lock.json +++ /dev/null @@ -1,567 +0,0 @@ -{ - "name": "openstrap-analytics", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "openstrap-analytics", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "@types/node": "^25.9.2", - "tsx": "^4.22.4", - "typescript": "^6.0.3" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/node": { - "version": "25.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", - "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index d698936..0000000 --- a/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "openstrap-analytics", - "version": "1.0.0", - "description": "Published-algorithm analytics (sleep/strain/recovery/load/readiness/stress) over minute rollups. Stress is an HR+motion arousal ESTIMATE (not HRV). HRV itself is computed in the backend from raw RR intervals, not here.", - "type": "commonjs", - "main": "src/index.ts", - "license": "ISC", - "scripts": { - "typecheck": "tsc --noEmit", - "test": "tsx src/__tests__/analytics.test.ts" - }, - "devDependencies": { - "@types/node": "^25.9.2", - "tsx": "^4.22.4", - "typescript": "^6.0.3" - } -} diff --git a/pubspec.yaml b/pubspec.yaml index b945fcf..d083864 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,26 @@ name: openstrap_analytics -description: Pure-Dart 1:1 port of openstrap-analytics (published-algorithm analytics over minute rollups). +description: >- + Recovery, strain, sleep and more, computed from a WHOOP 4.0's 1 Hz sensor + stream using published, cited methods only. Pure Dart, zero runtime + dependencies, deterministic, on-device. version: 1.0.0 publish_to: none environment: sdk: ^3.5.0 +# NOTE: no runtime dependencies, by design. openstrap_protocol is a DEV-only +# dependency — the tests replay real records through it, but nothing under lib/ +# imports it. That is what keeps this package pure Dart with zero runtime deps. dev_dependencies: test: ^1.25.0 lints: ^4.0.0 + # Pinned to a commit SHA, not a floating branch ref, so a given analytics + # commit always tests against exactly one protocol revision. (This used to be + # `path: ../openstrap-protocol-dart`, which only resolved on the author's + # machine — a fresh clone could not `pub get` at all.) Local development still + # builds against ../protocol via a gitignored pubspec_overrides.yaml. openstrap_protocol: - path: ../openstrap-protocol-dart + git: + url: https://github.com/OpenStrap/protocol.git + ref: 02fc8e5f2310ded690a522cd9884bf51b4cdc7e1 # tip of protocol main diff --git a/src/__tests__/_harness.ts b/src/__tests__/_harness.ts deleted file mode 100644 index 98d1acc..0000000 --- a/src/__tests__/_harness.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Tiny assertion harness for tsx-runnable tests. Tracks pass/fail counts. -let passed = 0; -let failed = 0; - -export function assert(cond: boolean, msg: string): void { - if (cond) { - passed++; - console.log(` ✅ ${msg}`); - } else { - failed++; - console.error(` ❌ ${msg}`); - } -} - -export function approx(actual: number, expected: number, eps: number, msg: string): void { - assert(Math.abs(actual - expected) <= eps, `${msg} (got ${actual}, want ${expected}±${eps})`); -} - -export function summary(label: string): void { - console.log(`\n[${label}] ${passed} passed, ${failed} failed`); - if (failed > 0) process.exitCode = 1; -} - -export function counts(): { passed: number; failed: number } { - return { passed, failed }; -} - -/** Build a Minute quickly. */ -export function min( - ts: number, - hr: number, - activity = 0, - opts: Partial<{ steps: number; wrist_on: boolean; hr_max: number; act_class: import('../types').ActivityClass }> = {} -) { - return { - ts, - hr_avg: hr, - hr_min: hr, - hr_max: opts.hr_max ?? hr, - hr_n: hr > 0 ? 60 : 0, - activity, - steps: opts.steps ?? 0, - wrist_on: opts.wrist_on ?? hr > 0, - ...(opts.act_class ? { act_class: opts.act_class } : {}), - }; -} diff --git a/src/__tests__/analytics.test.ts b/src/__tests__/analytics.test.ts deleted file mode 100644 index c1bd994..0000000 --- a/src/__tests__/analytics.test.ts +++ /dev/null @@ -1,1086 +0,0 @@ -// Unit tests — one block per analytics function, asserting numeric expectations -// against fixed in-code fixtures. Run with: npx tsx src/__tests__/analytics.test.ts -import { assert, approx, summary, min } from './_harness'; -import type { Baseline, Minute, Profile } from '../types'; - -import { calcRestingHR } from '../resting'; -import { calcStrain } from '../strain'; -import { calcHrZones } from '../zones'; -import { calcCalories } from '../calories'; -import { calcSleep, calcSleepPeriods } from '../sleep'; -import { calcSleepRegularity } from '../regularity'; -import { detectSessions } from '../sessions'; -import { calcHrRecovery, calcRecovery } from '../recovery'; -import { calcLoad, calcFitnessTrend } from '../trends'; -import { calcAnomaly } from '../readiness'; -import { calcBaselines } from '../baselines'; -import { calcStress } from '../stress'; -import { calcSpo2Index } from '../spo2'; -import { calcSleepStress } from '../arousal'; -import { calcNocturnalHeart } from '../nocturnal'; -import { calcIllness } from '../illness'; -import { timeDomainHrv, freqDomainHrv, baevskyStressIndex, cleanRr } from '../hrv'; -import { pedometer, calcSteps, STEP_PARAMS } from '../steps'; -import { resolveMaxHr } from '../util'; -import { calcCircadian, stageSleep } from '../circadian'; -import { detectSleepCycles } from '../cycles'; -import { detectWakeState, peekRecentState } from '../wake'; -import { calcCycle } from '../cycle'; -import { extractHarFeatures, classifyActivityWindow, segmentWorkout, DB10_LO, dwtDetailEnergies } from '../har'; -import type { ClassVote } from '../har'; -import { calcRestlessness } from '../restlessness'; -import { calcDaytimeHrv } from '../hrv'; -import { calcDesaturation } from '../spo2'; - -const baseline: Baseline = { - resting_hr: 50, - max_hr: 190, - sleep_need_min: 480, - skin_temp: 34.0, - chronic_strain: 10, -}; - -// ── §1 calcRestingHR ───────────────────────────────────────────────────────── -console.log('--- §1 calcRestingHR ---'); -{ - // sleep window 0..5min, HRs [40,42,44,46,48,50] → 5th pctile = 40.5 - const mins: Minute[] = [40, 42, 44, 46, 48, 50].map((h, i) => min(i * 60, h)); - const r = calcRestingHR(mins, { onset_ts: 0, wake_ts: 5 * 60 }); - approx(r.resting_hr ?? -1, 40.5, 0.01, 'RHR = 5th pctile of sleep-window HR'); - assert(r.tier === 'HIGH', 'RHR tier HIGH'); - approx(r.confidence, 6 / 240, 0.001, 'RHR confidence = worn_min/240'); - - // no window → fallback path, confidence ≤ 0.5 - const r2 = calcRestingHR(mins, undefined); - assert(r2.resting_hr !== null, 'fallback produces an RHR'); - assert(r2.confidence <= 0.5, 'fallback confidence capped at 0.5'); - - // off-wrist excluded - const off = calcRestingHR([], { onset_ts: 0, wake_ts: 60 }); - assert(off.resting_hr === null && off.confidence === 0, 'no data → null + conf 0'); -} - -// ── §2 calcStrain ────────────────────────────────────────────────────────────── -console.log('--- §2 calcStrain ---'); -{ - // flat rest at RHR → 0 strain - const rest: Minute[] = Array.from({ length: 30 }, (_, i) => min(i * 60, 50)); - const rs = calcStrain(rest, baseline); - approx(rs.trimp, 0, 1e-9, 'rest at RHR → 0 TRIMP'); - approx(rs.score, 0, 1e-9, 'rest at RHR → 0 strain'); - - // 30 min @150bpm → known trimp 54.0477, score 9.89 - const hard: Minute[] = Array.from({ length: 30 }, (_, i) => min(i * 60, 150)); - const hs = calcStrain(hard, baseline); - approx(hs.trimp, 54.0477, 0.01, '30min@150 → TRIMP ≈ 54.05'); - approx(hs.score, 9.89, 0.01, '30min@150 → score ≈ 9.89'); - approx(hs.confidence, 1, 1e-9, '≥30 worn min → confidence 1'); - assert(hs.max_hr_source === 'measured', 'baseline max → measured source'); - - // off-wrist gaps do not contribute - const gapped: Minute[] = [ - ...Array.from({ length: 15 }, (_, i) => min(i * 60, 150)), - ...Array.from({ length: 10 }, (_, i) => min((i + 15) * 60, 0, 0, { wrist_on: false })), - ...Array.from({ length: 15 }, (_, i) => min((i + 25) * 60, 150)), - ]; - const cont: Minute[] = Array.from({ length: 30 }, (_, i) => min(i * 60, 150)); - approx(calcStrain(gapped, baseline).trimp, calcStrain(cont, baseline).trimp, 1e-9, - 'off-wrist minutes excluded from strain'); - - // score bounded ≤ 21 - const insane: Minute[] = Array.from({ length: 1000 }, (_, i) => min(i * 60, 190)); - assert(calcStrain(insane, baseline).score <= 21, 'strain bounded at 21'); -} - -// ── §3 calcHrZones ─────────────────────────────────────────────────────────── -console.log('--- §3 calcHrZones ---'); -{ - // maxHR 190 (%HRmax): 100bpm=52.6%→z1, 120=63%→z2, 150=78.9%→z3, 160=84%→z4, 175=92%→z5 - const mins: Minute[] = [100, 120, 150, 160, 175].map((h, i) => min(i * 60, h)); - const z = calcHrZones(mins, baseline); - assert(z.zone1_min === 1, '100bpm → z1'); - assert(z.zone2_min === 1, '120bpm → z2'); - assert(z.zone3_min === 1, '150bpm → z3'); - assert(z.zone4_min === 1, '160bpm → z4'); - assert(z.zone5_min === 1, '175bpm → z5'); - assert(z.max_hr_source === 'measured', 'measured maxHR'); - - // age-defaulted → lower base confidence (0.6 vs 0.85) - const profileLess: Profile = {}; - const noMaxBaseline: Baseline = { ...baseline, max_hr: 0 }; - const z2 = calcHrZones(mins, noMaxBaseline, { age: 40 }); - // no measured max on minutes>0? minutes DO carry hr_max so source = measured. - // Force age path: all hr=0 minutes can't bucket; use explicit age fallback test below. - const ageOnly = calcHrZones( - [min(0, 0, 0, { wrist_on: false })], - noMaxBaseline, - { age: 40 } - ); - assert(ageOnly.max_hr_source === 'age' && ageOnly.max_hr_used === 180, - 'age fallback maxHR = Tanaka 208−0.7·age (=180 at age 40) when no measured HR'); -} - -// ── §4 calcCalories ────────────────────────────────────────────────────────── -console.log('--- §4 calcCalories ---'); -{ - // ACTIVE calories = burn ABOVE resting. One min @120 with RHR 60, sex-avg: - // delta/min = avg((0.6309+0.4472)/2)*(120−60)/4.184 = 0.53905*60/4.184 ≈ 7.73 kcal. - const one: Minute[] = [min(0, 120)]; - const c = calcCalories(one, { age: 30, weight_kg: 70 }, 60); - approx(c.kcal, 7.73, 0.2, 'one min @120 over RHR60 sex-avg ≈ 7.7 active kcal'); - assert(c.tier === 'ESTIMATE' && c.label.includes('est.'), 'calories ESTIMATE + est. label'); - // a minute AT resting HR contributes ≈0 active calories (the key fix). - const atRest = calcCalories([min(0, 60)], { age: 30, weight_kg: 70 }, 60); - assert(atRest.kcal < 0.01, 'minutes at resting HR → ~0 active calories (no BMR over-count)'); - // below resting clamped at 0. - const low = calcCalories([min(0, 40)], { age: 30, weight_kg: 70 }, 60); - assert(low.kcal >= 0, 'below-resting calories never negative'); - // sex specified changes the active value (HR coefficient differs). - const cm = calcCalories(one, { age: 30, weight_kg: 70, sex: 'm' }, 60); - assert(cm.kcal !== c.kcal, 'male coeff differs from sex-avg'); - // a full day spent AT resting HR must contribute ≈0 active kcal — the bug fix - // (pre-fix this summed Keytel's BMR constant → ~5884 "active" kcal on a full day). - const fullRestDay: Minute[] = Array.from({ length: 1440 }, (_, i) => min(i * 60, 58)); - const fr = calcCalories(fullRestDay, { age: 29, weight_kg: 75, sex: 'm' }, 58); - assert(fr.kcal < 20, `full resting-HR day → ~0 active kcal (got ${fr.kcal}, was ~5884 pre-fix)`); -} - -// ── §5 calcSleep ───────────────────────────────────────────────────────────── -console.log('--- §5 calcSleep ---'); -{ - // 200 min of low activity + dipped HR → asleep; bracketed by awake activity. - const mins: Minute[] = []; - for (let i = 0; i < 5; i++) mins.push(min(i * 60, 70, 2000)); // awake before - for (let i = 5; i < 205; i++) mins.push(min(i * 60, 45, 50)); // asleep (HR dip + low act) - for (let i = 205; i < 210; i++) mins.push(min(i * 60, 72, 2000)); // awake after - const s = calcSleep(mins, baseline); - assert(s.duration_min >= 195, `main sleep ≈ 200 min (got ${s.duration_min})`); - // Clean block with no interior awakenings → efficiency ≈ 1.0 (no interior awake - // epochs between first- and last-asleep). - assert(s.efficiency > 0.99, 'efficiency ≈ 1.0 for a clean (un-fragmented) block'); - assert(s.onset_ts === 5 * 60, 'onset at first asleep minute'); - assert(s.stages !== null && s.stages_beta === true, 'stages present + flagged beta'); - assert(s.tier === 'HIGH', 'sleep tier HIGH'); - assert(s.inputs_used.includes('baseline.skin_temp'), 'temp counted as input when baseline has it'); - - // Interior awakenings: in-bed span is first-asleep → last-asleep, and the - // awake minutes in the middle count AGAINST efficiency (no longer 1.0). - const frag: Minute[] = []; - for (let i = 0; i < 5; i++) frag.push(min(i * 60, 70, 2000)); // awake before - for (let i = 5; i < 100; i++) frag.push(min(i * 60, 45, 50)); // asleep - for (let i = 100; i < 110; i++) frag.push(min(i * 60, 72, 1800)); // interior awakening (10 min) - for (let i = 110; i < 205; i++) frag.push(min(i * 60, 45, 50)); // asleep again - for (let i = 205; i < 210; i++) frag.push(min(i * 60, 72, 2000)); // awake after - const f = calcSleep(frag, baseline); - assert(f.onset_ts === 5 * 60, 'fragmented: onset at first asleep minute'); - assert(f.wake_ts === 204 * 60, 'fragmented: wake at last asleep minute (span spans the awakening)'); - assert(f.in_bed_min === 200, `fragmented: in-bed span = first→last asleep inclusive (got ${f.in_bed_min})`); - assert(f.duration_min === 190, `fragmented: asleep minutes exclude the 10-min awakening (got ${f.duration_min})`); - assert(f.efficiency > 0.9 && f.efficiency < 1, `fragmented: efficiency 0.9–1.0 (got ${f.efficiency})`); - - // empty input - const e = calcSleep([], baseline); - assert(e.duration_min === 0 && e.confidence === 0, 'no data → 0 sleep + conf 0'); - - // Regression: a LONG awake gap (> MAX_GAP_MIN) must NOT bridge two low-activity - // blocks into one giant "night". Here a 250-min night is followed by a 60-min - // awake daytime stretch (elevated HR), then a 90-min low-activity-but-awake - // afternoon block. The consolidated main sleep = only the 250-min night; the - // afternoon must not be absorbed (this is the in-bed-span regression that made - // some nights read 17h). - const split: Minute[] = []; - for (let i = 0; i < 5; i++) split.push(min(i * 60, 70, 2000)); // awake before - for (let i = 5; i < 255; i++) split.push(min(i * 60, 45, 50)); // 250-min night (asleep) - for (let i = 255; i < 315; i++) split.push(min(i * 60, 75, 2500)); // 60-min awake daytime (long gap) - // afternoon: low activity but HR clearly elevated (> 1.15*RHR) → awake, not sleep - for (let i = 315; i < 405; i++) split.push(min(i * 60, 72, 60)); // 90-min sedentary-but-awake - const sp = calcSleep(split, baseline); - assert(sp.duration_min >= 245 && sp.duration_min <= 255, - `long-gap: main sleep = the 250-min night only (got ${sp.duration_min})`); - assert(sp.in_bed_min <= 260, - `long-gap: in-bed span not stretched across the day (got ${sp.in_bed_min})`); - assert((sp.wake_ts ?? 0) <= 255 * 60, - `long-gap: wake at end of night, not afternoon (wake_ts ${sp.wake_ts})`); - - // Regression: off-wrist epochs (wrist_on=false, no HR) carry NO sleep signal - // and must read awake — a long off-wrist daytime stretch must break the night, - // not bridge across it. Night, then 40 min off-wrist (band on charger), then a - // short low-activity worn block; main sleep stays the night only. - const offwrist: Minute[] = []; - for (let i = 0; i < 5; i++) offwrist.push(min(i * 60, 70, 2000)); // awake before - for (let i = 5; i < 205; i++) offwrist.push(min(i * 60, 45, 50)); // 200-min night (asleep) - for (let i = 205; i < 245; i++) offwrist.push(min(i * 60, 0, 0, { wrist_on: false })); // 40-min off-wrist - for (let i = 245; i < 285; i++) offwrist.push(min(i * 60, 70, 60)); // worn-but-awake daytime - const ow = calcSleep(offwrist, baseline); - assert(ow.duration_min >= 195 && ow.duration_min <= 205, - `off-wrist: main sleep = the 200-min night only (got ${ow.duration_min})`); - assert(ow.in_bed_min <= 210, - `off-wrist: off-wrist stretch not counted as in-bed (got ${ow.in_bed_min})`); - - // Regression (v3): the band's flash record (R24 — the entire overnight) carries NO - // actigraphy, so `activity` is ~0 for every sleep minute and Cole-Kripke is inert; the - // call is HR-driven. A night's HR legitimately runs ABOVE the 5th-pctile RHR floor, so - // the old fixed `1.15*rhr` awake-override flagged the whole night awake → 1-min "sleep" - // (observed in prod). The window-relative reference must detect it. rhr=50 floor, night - // HR ~62 (well above 1.15*50=57.5), activity 0, bracketed by waking HR ~95. - const aboveFloor: Minute[] = []; - for (let i = 0; i < 30; i++) aboveFloor.push(min(i * 60, 95, 0)); // waking evening - for (let i = 30; i < 430; i++) aboveFloor.push(min(i * 60, 62, 0)); // ~6.7h night, HR > RHR floor - for (let i = 430; i < 470; i++) aboveFloor.push(min(i * 60, 95, 0)); // waking morning - const af = calcSleep(aboveFloor, baseline); - assert(af.duration_min >= 380, - `above-floor night (HR>RHR, activity inert) is detected, not clipped to ~1min (got ${af.duration_min})`); - - // Regression (v3): the OTHER failure mode — with activity inert, a flat + ELEVATED - // sedentary-awake window must NOT be manufactured into a full night. The absolute - // backstop (HR > 1.5*RHR → awake) clips it. Flat 92 bpm for 5h, rhr 50 → 92 > 75. - const flatAwake: Minute[] = []; - for (let i = 0; i < 300; i++) flatAwake.push(min(i * 60, 92, 0)); - const fa = calcSleep(flatAwake, baseline); - assert(fa.duration_min <= 30, - `flat elevated sedentary-awake window is not mis-read as sleep (got ${fa.duration_min})`); - - // Plausibility guard: even if (pathologically) almost the whole 18h window - // reads low-activity, a single main-sleep period must never exceed ~14h. - const giant: Minute[] = []; - for (let i = 0; i < 1080; i++) giant.push(min(i * 60, 45, 50)); // 18h of "asleep-looking" epochs - const g = calcSleep(giant, baseline); - assert(g.in_bed_min <= 14 * 60, - `plausibility: main-sleep period capped at ~14h (got ${g.in_bed_min})`); -} - -// ── §5b calcSleepPeriods (multi-period; naps = shorter sleeps) ─────────────── -console.log('--- §5b calcSleepPeriods ---'); -{ - // A main night (200 min) + a separate afternoon nap (40 min), split by a long - // awake daytime stretch. v2 must return TWO periods, longest flagged is_main. - const day: Minute[] = []; - for (let i = 0; i < 5; i++) day.push(min(i * 60, 70, 2000)); // awake before - for (let i = 5; i < 205; i++) day.push(min(i * 60, 45, 50)); // 200-min night (asleep) - for (let i = 205; i < 305; i++) day.push(min(i * 60, 75, 2500)); // 100-min awake daytime (long gap) - for (let i = 305; i < 345; i++) day.push(min(i * 60, 48, 50)); // 40-min afternoon nap (asleep) - for (let i = 345; i < 350; i++) day.push(min(i * 60, 72, 2000)); // awake after - const v2 = calcSleepPeriods(day, baseline); - assert(v2.periods.length === 2, `two sleep periods detected (got ${v2.periods.length})`); - const mainP = v2.periods[v2.main_idx ?? -1]; - assert(mainP != null && mainP.is_main === true, 'main period flagged is_main'); - assert(mainP.duration_min >= 195, `main period ≈ 200 min (got ${mainP?.duration_min})`); - const napP = v2.periods.find((p) => !p.is_main); - assert(napP != null && napP.duration_min >= 30 && napP.duration_min <= 45, - `nap treated as a shorter sleep ≈ 40 min, edge-trimmed (got ${napP?.duration_min})`); - assert(v2.total_asleep_min >= 228, `total asleep sums both periods (got ${v2.total_asleep_min})`); - assert(v2.periods.every((p) => p.confidence >= 0 && p.confidence <= 1), 'per-period confidence in [0,1]'); - - // Single night → exactly one period (backward-consistent with calcSleep). - const oneNight: Minute[] = []; - for (let i = 0; i < 5; i++) oneNight.push(min(i * 60, 70, 2000)); - for (let i = 5; i < 205; i++) oneNight.push(min(i * 60, 45, 50)); - for (let i = 205; i < 210; i++) oneNight.push(min(i * 60, 72, 2000)); - const one = calcSleepPeriods(oneNight, baseline); - assert(one.periods.length === 1 && one.periods[0].is_main, 'single night → one main period'); - - // Micro-doze (< 15 min) is discarded, not surfaced as a period. - const micro: Minute[] = []; - for (let i = 0; i < 5; i++) micro.push(min(i * 60, 70, 2000)); - for (let i = 5; i < 13; i++) micro.push(min(i * 60, 45, 50)); // 8-min doze - for (let i = 13; i < 20; i++) micro.push(min(i * 60, 72, 2000)); - const m2 = calcSleepPeriods(micro, baseline); - assert(m2.periods.length === 0 && m2.confidence === 0, 'micro-doze (<15 min) discarded'); - - // Empty input → no periods, conf 0. - const ep = calcSleepPeriods([], baseline); - assert(ep.periods.length === 0 && ep.confidence === 0, 'no data → no periods + conf 0'); -} - -// ── §6 calcSleepRegularity ─────────────────────────────────────────────────── -console.log('--- §6 calcSleepRegularity ---'); -{ - const DAY = 86400; - // identical schedule 3 nights → SRI 100 - const same = [0, 1, 2].map((d) => ({ - onset_ts: d * DAY + 23 * 3600, // 23:00 - wake_ts: d * DAY + 7 * 3600, // 07:00 (next-day modulo) - })); - const r = calcSleepRegularity(same); - approx(r.sri, 100, 0.01, 'identical schedule → SRI 100'); - assert(r.confidence === 0.7, 'SRI confidence 0.7 with ≥3 nights'); - - // <3 nights → confidence 0 - assert(calcSleepRegularity(same.slice(0, 2)).confidence === 0, '<3 nights → conf 0'); - - // jittered onset → SRI < 100 - const jit = [0, 1, 2].map((d, i) => ({ - onset_ts: d * DAY + 23 * 3600 + i * 1800, - wake_ts: d * DAY + 7 * 3600, - })); - assert(calcSleepRegularity(jit).sri < 100, 'jittered onset → SRI < 100'); -} - -// ── §7 detectSessions ──────────────────────────────────────────────────────── -console.log('--- §7 detectSessions ---'); -{ - // threshold = 50 + 0.4*140 = 106 bpm. Build a 10-min high-HR + high-activity bout - // surrounded by rest. - const mins: Minute[] = []; - for (let i = 0; i < 10; i++) mins.push(min(i * 60, 55, 1)); // rest, low act - for (let i = 10; i < 20; i++) mins.push(min(i * 60, 150, 100, { hr_max: 165 })); // workout - for (let i = 20; i < 30; i++) mins.push(min(i * 60, 55, 1)); // rest - const sessions = detectSessions(mins, baseline); - assert(sessions.length === 1, `exactly one session detected (got ${sessions.length})`); - const ses = sessions[0]; - assert(ses.start_ts === 10 * 60, 'session starts at minute 10'); - assert(ses.duration_min >= 9, 'session ~10 min'); - assert(ses.confidence === 0.8 && ses.type_confidence === 0.4, 'event 0.8 / type 0.4'); - assert(ses.type === 'run/cardio', 'high act + high HR → run/cardio'); - assert(ses.strain > 0 && ses.kcal > 0, 'session carries strain + calories'); - - // a 2-min sustained bout now qualifies (threshold lowered 3/5 → 2 min). - const short: Minute[] = []; - for (let i = 0; i < 10; i++) short.push(min(i * 60, 55, 1)); - for (let i = 10; i < 12; i++) short.push(min(i * 60, 150, 100)); // 2 min - for (let i = 12; i < 20; i++) short.push(min(i * 60, 55, 1)); - assert(detectSessions(short, baseline).length === 1, '2-min bout now detected'); - - // a 1-min blip is still discarded. - const tiny: Minute[] = []; - for (let i = 0; i < 10; i++) tiny.push(min(i * 60, 55, 1)); - tiny.push(min(10 * 60, 150, 100)); // 1 min only - for (let i = 11; i < 20; i++) tiny.push(min(i * 60, 55, 1)); - assert(detectSessions(tiny, baseline).length === 0, '1-min blip discarded'); - - // Per-minute HAR class → motion-based workout type + confidence (not the HR heuristic). - const cyc: Minute[] = []; - for (let i = 0; i < 10; i++) cyc.push(min(i * 60, 55, 1)); - for (let i = 10; i < 20; i++) cyc.push(min(i * 60, 140, 100, { hr_max: 150, act_class: 'cycle' })); - for (let i = 20; i < 30; i++) cyc.push(min(i * 60, 55, 1)); - const cs = detectSessions(cyc, baseline)[0]; - assert(cs.type === 'cycle' && cs.type_confidence > 0.4, `motion class → cycle (got ${cs.type}/${cs.type_confidence})`); - assert(cs.detected_type === 'cycle', 'detected_type recorded for calibration ledger'); -} - -// ── §8 calcHrRecovery ──────────────────────────────────────────────────────── -console.log('--- §8 calcHrRecovery ---'); -{ - // peak hr_max 170 at minute 5, then drop to 130 one minute later → HRR60 = 40 - const mins: Minute[] = []; - for (let i = 0; i < 5; i++) mins.push(min(i * 60, 150, 50, { hr_max: 155 })); - mins.push(min(5 * 60, 165, 50, { hr_max: 170 })); // peak - mins.push(min(6 * 60, 130, 10, { hr_max: 135 })); // +60s - const hrr = calcHrRecovery(mins, baseline); - approx(hrr.hrr60 ?? -1, 40, 0.01, 'HRR60 = peak 170 − 130 = 40'); - approx(hrr.peak_hr ?? -1, 170, 0.01, 'peak_hr = 170'); - assert(hrr.confidence === 0.7, 'HRR confidence 0.7 with elevated peak'); - - // no elevated peak → null - const flat: Minute[] = Array.from({ length: 10 }, (_, i) => min(i * 60, 55, 1)); - const nf = calcHrRecovery(flat, baseline); - assert(nf.hrr60 === null && nf.confidence === 0, 'no elevated peak → null HRR'); -} - -// ── §9 calcLoad + calcFitnessTrend ─────────────────────────────────────────── -console.log('--- §9 calcLoad / calcFitnessTrend ---'); -{ - // 28 days, all strain 10 → acwr 1.0 optimal - const steady = Array.from({ length: 28 }, (_, i) => ({ ts: i * 86400, strain: 10 })); - const load = calcLoad(steady); - approx(load.acwr ?? -1, 1.0, 1e-9, 'steady strain → ACWR 1.0'); - assert(load.band === 'optimal', 'ACWR 1.0 → optimal band'); - - // acute spike (EWMA, Williams 2017): prior 21 days at 10, last 7 at 20. - // EWMA acute (λ=0.25) rises to ~18.7, chronic (λ≈0.069) to ~13.9 → acwr ~1.34 - // (caution). EWMA is deliberately smoother than the rolling-average ratio. - const spike = Array.from({ length: 28 }, (_, i) => ({ - ts: i * 86400, - strain: i >= 21 ? 20 : 10, - })); - const sl = calcLoad(spike); - assert(sl.band === 'caution' && (sl.acwr ?? 0) > 1.3, - `acute spike → caution (acwr ${sl.acwr})`); - - // <7 days → unknown - assert(calcLoad(steady.slice(0, 5)).band === 'unknown', '<7 days → unknown band'); - - // fitness improving: RHR declining, HRR rising over 28 days - const daily = Array.from({ length: 28 }, (_, i) => ({ - resting_hr: 60 - i * 0.2, // declining - hrr60: 30 + i * 0.3, // rising - })); - const ft = calcFitnessTrend(daily); - assert(ft.direction === 'improving', `RHR↓ + HRR↑ → improving (got ${ft.direction})`); - assert(ft.rhr_slope < 0 && ft.hrr_slope > 0, 'slopes have expected signs'); - // never emits a VO2max number — only direction + slopes (type guarantees this) - assert(!('vo2max' in (ft as unknown as Record)), 'no VO2max field emitted'); -} - -// ── §10 calcRecovery (HRV) + calcAnomaly + calcIllness ─────────────────────── -console.log('--- §10 calcRecovery / calcAnomaly / calcIllness ---'); -{ - // Plews lnRMSSD z-score. Baseline ~75ms; tonight at baseline → ~50. - const base = [72, 75, 78, 74, 76, 73, 77, 75, 74, 76]; - const atBase = calcRecovery(75, base, { date: '2026-06-13' }); - approx(atBase.score!, 50, 8, 'RMSSD at baseline → recovery ≈ 50'); - assert(atBase.tier === 'HIGH' && atBase.note === 'HRV-based', 'recovery HIGH, HRV-based'); - assert(atBase.drivers!.length >= 1 && atBase.drivers![0].ref!.metric === 'hrv', 'recovery driver links to hrv'); - const high = calcRecovery(100, base); - const low = calcRecovery(50, base); - assert(high.score! > atBase.score! && low.score! < atBase.score!, 'higher RMSSD → higher recovery'); - // insufficient baseline → null (honest, no heuristic fallback) - assert(calcRecovery(75, [70, 72]).score === null && calcRecovery(75, [70, 72]).confidence === 0, - '<5 baseline nights → recovery null'); - assert(calcRecovery(null, base).score === null, 'no RMSSD tonight → null'); - - // anomaly: RHR ≥ baseline+7% for ≥2 consecutive days - const an = calcAnomaly({ recent_rhr: [50, 51, 55, 56] }, baseline); - assert(an.signal === true && an.triggers.includes('rhr_elevated_2d'), 'two elevated RHR days → signal'); - assert(an.note === 'signal, not a diagnosis', 'anomaly non-diagnostic note'); - // §4 cycle gate: luteal phase suppresses the pure-RHR-elevation rule (expected rise). - const anLuteal = calcAnomaly({ recent_rhr: [50, 51, 55, 56] }, baseline, { cyclePhase: 'luteal' }); - assert(anLuteal.signal === false && /cycle/i.test(anLuteal.note), - 'luteal phase suppresses pure-RHR anomaly with a cycle note'); - - // illness (Mahalanobis): RHR↑ + RMSSD↓ + temp↑ vs baseline → signal. - const hist = { - resting_hr: Array.from({ length: 20 }, (_, i) => 55 + (i % 3)), - rmssd: Array.from({ length: 20 }, (_, i) => 74 + (i % 5)), - skin_temp: Array.from({ length: 20 }, (_, i) => 34 + (i % 2) * 0.1), - }; - const sick = calcIllness({ resting_hr: 68, rmssd: 45, skin_temp: 35.2 }, hist); - assert(sick.signal === true && sick.triggers.length >= 2, 'illness fires on multivariate deviation'); - assert(sick.note === 'a signal, not a diagnosis', 'illness non-diagnostic note'); - const well = calcIllness({ resting_hr: 56, rmssd: 76, skin_temp: 34.05 }, hist); - assert(well.signal === false, 'normal vector → no illness signal'); - - // §5 respiratory rate as a 4th Mahalanobis feature: RMSSD↓ + resp↑ fires + lists 'resp'. - const histR = { ...hist, resp_rate: Array.from({ length: 20 }, (_, i) => 14 + (i % 2)) }; - const sickResp = calcIllness({ resting_hr: 56, rmssd: 45, skin_temp: 34.05, resp_rate: 19 }, histR); - assert(sickResp.signal === true && sickResp.triggers.includes('resp'), - 'elevated respiratory rate drives the illness signal'); - assert(sickResp.inputs_used.includes('resp_rate'), 'resp_rate listed in inputs_used'); - - // §4 cycle gating: temp+RHR rise ALONE is phase-expected → suppressed in luteal, - // but still fires when no cycle context is supplied. - const cycIn = { resting_hr: 64, rmssd: 76, skin_temp: 35.0 }; - const noCyc = calcIllness(cycIn, hist); - assert(noCyc.signal === true && noCyc.triggers.includes('rhr') && noCyc.triggers.includes('temp'), - 'temp+RHR rise → illness signal with no cycle context'); - const luteal = calcIllness(cycIn, hist, { cyclePhase: 'luteal' }); - assert(luteal.signal === false, 'luteal phase suppresses temp/RHR-only illness signal'); - assert(/cycle/i.test(luteal.note), 'suppressed signal explains the cycle phase'); - // …but HRV/resp deviations are NOT explained by the cycle → still fires. - const lutealReal = calcIllness({ resting_hr: 64, rmssd: 45, skin_temp: 35.0, resp_rate: 19 }, histR, { cyclePhase: 'luteal' }); - assert(lutealReal.signal === true, 'HRV/resp shift still fires even in luteal phase'); -} - -// ── §11 calcBaselines ──────────────────────────────────────────────────────── -console.log('--- §11 calcBaselines ---'); -{ - const hist = Array.from({ length: 30 }, (_, i) => ({ - resting_hr: 50 + (i % 3), // 50,51,52,... - sleep_duration_min: 470 + (i % 5), - skin_temp: 34 + (i % 2) * 0.1, - daily_strain: 10 + (i % 4), - session_hr_max: 180 + (i % 10), - zone_min: [10, 20, 15, 5, 2] as [number, number, number, number, number], - })); - // Observed daily peak (189) CLEARS the age-predicted floor (age 40 → Tanaka 180), - // so it's a genuine effort → trusted as the measured max. - const bl = calcBaselines(hist, { age: 40 }); - assert(bl.resting_hr !== null && bl.resting_hr! >= 50 && bl.resting_hr! <= 52, 'RHR median in range'); - assert(bl.max_hr === 189 && bl.max_hr_source === 'measured', 'observed peak above age floor → measured'); - assert(bl.chronic_strain !== null, 'chronic strain computed'); - assert(bl.zone_min !== null && bl.zone_min![0] === 10, 'per-zone medians present'); - approx(bl.confidence, 1, 1e-9, '30 days → confidence 1'); - - // Guard: a quiet daily peak (≤ age floor) must NOT be promoted to a measured max — - // it would under-state HRmax and inflate zones/strain. Age floor wins instead. - const quiet = hist.map((d) => ({ ...d, session_hr_max: 150 })); - const blQuiet = calcBaselines(quiet, { age: 30 }); // Tanaka 187 > 150 - assert(blQuiet.max_hr === 187 && blQuiet.max_hr_source === 'age', 'quiet daily peak → age floor, not measured'); - - // age fallback for maxHR when no sessions - const noSess = hist.map((d) => ({ ...d, session_hr_max: undefined })); - const bl2 = calcBaselines(noSess, { age: 30 }); - assert(bl2.max_hr === 187 && bl2.max_hr_source === 'age', 'no sessions + age → Tanaka 208−0.7·age maxHR'); - - // seed period (3 days) → low confidence - const seed = calcBaselines(hist.slice(0, 3)); - approx(seed.confidence, 3 / 30, 1e-9, 'seed period → wide (low) confidence'); -} - -// (activity rollup metric — steps + active/sedentary — REMOVED in v0.) - -// ── determinism ────────────────────────────────────────────────────────────── -console.log('--- determinism ---'); -{ - const mins: Minute[] = Array.from({ length: 30 }, (_, i) => min(i * 60, 150, 10)); - const a = JSON.stringify(calcStrain(mins, baseline)); - const b = JSON.stringify(calcStrain(mins, baseline)); - assert(a === b, 'same input → identical output'); -} - -// ── coaching engine ─────────────────────────────────────────────────────────── -console.log('--- buildCoach ---'); -{ - const { buildCoach } = require('../coach'); - // Low recovery + high load → "ease off", strain target capped low. - const lo = buildCoach({ - readiness: 35, readiness_components: { rhr: 0.5, sleep_debt: 0.4, sleep_quality: 0.6 }, - resting_hr: 70, baseline_rhr: 60, rhr_recent: [60, 61, 70], - strain_today: 5, acwr: 1.5, sleep_last_min: 300, sleep_need_min: 480, - sleep_debt_min: 200, sleep_efficiency: 0.7, sri: 60, fitness_direction: 'flat', anomaly: null, - }); - assert(lo.strain_target!.value <= 10, 'high ACWR caps strain target ≤10'); - assert(lo.plan.some((s: any) => s.category === 'load' || s.category === 'recovery'), 'low recovery/high load → a load/recovery suggestion'); - assert(lo.plan.some((s: any) => s.id === 'sleep.debt'), 'big sleep debt → debt suggestion'); - assert(lo.readiness_contributors.length === 3, 'three readiness contributors'); - assert(lo.summary.length > 0, 'has narrative'); - // Fresh + light load → "room to push", higher target. - const hi = buildCoach({ - readiness: 85, readiness_components: { rhr: 1, sleep_debt: 1, sleep_quality: 0.9 }, - resting_hr: 55, baseline_rhr: 58, rhr_recent: [58, 57, 55], - strain_today: 2, acwr: 0.6, sleep_last_min: 480, sleep_need_min: 480, - sleep_debt_min: 0, sleep_efficiency: 0.92, sri: 90, fitness_direction: 'rising', anomaly: null, - }); - assert(hi.strain_target!.value >= 14, 'high recovery → high strain target'); - assert(hi.plan.some((s: any) => s.id === 'load.low' || s.id === 'recovery.high'), 'fresh → push suggestion'); - // Determinism. - assert(JSON.stringify(buildCoach({ - readiness: 50, resting_hr: 60, baseline_rhr: 60, rhr_recent: [60], - strain_today: 8, acwr: 1.0, sleep_last_min: 400, sleep_need_min: 480, - sleep_debt_min: 0, sleep_efficiency: 0.85, sri: 80, fitness_direction: 'flat', anomaly: null, - })) === JSON.stringify(buildCoach({ - readiness: 50, resting_hr: 60, baseline_rhr: 60, rhr_recent: [60], - strain_today: 8, acwr: 1.0, sleep_last_min: 400, sleep_need_min: 480, - sleep_debt_min: 0, sleep_efficiency: 0.85, sri: 80, fitness_direction: 'flat', anomaly: null, - })), 'coach is deterministic'); -} - -// ── §HRV math (RMSSD/SDNN/pNN50, Lomb–Scargle, Baevsky) ─────────────────────── -console.log('--- §HRV time/freq/SI ---'); -{ - // Exact RMSSD: alternating 800/820 → successive diff 20 → RMSSD = 20. - const alt = Array.from({ length: 60 }, (_, i) => (i % 2 ? 820 : 800)); - const td = timeDomainHrv(alt); - approx(td.rmssd!, 20, 0.01, 'alternating 800/820 → RMSSD = 20'); - approx(td.mean_rr!, 810, 0.1, 'mean RR = 810'); - approx(td.mean_hr!, 60000 / 810, 0.1, 'mean HR from RR'); - // cleanRr drops out-of-physiological + ectopic jumps. - assert(cleanRr([900, 250, 905, 2500, 910]).length === 3, 'cleanRr drops non-physiological'); - // Respiratory peak: RR modulated at 0.25 Hz (15 brpm) → resp_rate ≈ 15. - const t: number[] = []; let acc = 0; - const resp: number[] = []; - for (let i = 0; i < 320; i++) { // ≥250 s span so LF (and LF/HF) are valid per Task Force 1996 - const rr = 900 + 60 * Math.sin(2 * Math.PI * 0.25 * (acc / 1000)); - resp.push(Math.round(rr)); acc += rr; - } - const fd = freqDomainHrv(resp); - assert(fd.resp_rate !== null && Math.abs(fd.resp_rate - 15) < 3, `RSA resp rate ≈ 15 brpm (got ${fd.resp_rate})`); - assert(fd.hf! > 0 && fd.lf_hf !== null, 'LF/HF computed'); - // Baevsky SI: tighter RR distribution → higher SI than a spread one. - const tight = Array.from({ length: 100 }, (_, i) => 900 + (i % 3)); // narrow - const spread = Array.from({ length: 100 }, (_, i) => 700 + (i * 4) % 400); // wide - const siT = baevskyStressIndex(tight).si!, siS = baevskyStressIndex(spread).si!; - assert(siT > siS, `tighter RR ⇒ higher Baevsky SI (${siT} > ${siS})`); -} - -// ── §12 calcStress (HRV-based, personal-relative) ───────────────────────────── -console.log('--- §12 calcStress (HRV) ---'); -{ - const rr = Array.from({ length: 120 }, (_, i) => 850 + (i % 5) * 8); - const si = baevskyStressIndex(rr).si!; - // No baseline → indices only, no fabricated score. - const noBase = calcStress(rr, []); - assert(noBase.score === null && noBase.si !== null, 'no baseline → SI reported, score null'); - assert(noBase.tier === 'ESTIMATE', 'stress ESTIMATE'); - // With a baseline SI distribution → personal-relative score + level. - const baseSI = [si * 0.8, si * 0.9, si, si * 1.1, si * 1.2, si * 0.95, si * 1.05]; - const withBase = calcStress(rr, baseSI); - assert(withBase.score !== null && withBase.level !== null, 'baseline present → score + level'); - assert(withBase.drivers!.some((d) => d.label.includes('Baevsky')), 'stress driver = Baevsky SI'); - // Higher SI than baseline → higher stress score. - const tightRr = Array.from({ length: 120 }, (_, i) => 850 + (i % 2)); // very tight → high SI - const hi = calcStress(tightRr, baseSI); - assert((hi.score ?? 0) >= (withBase.score ?? 0), 'higher SI vs baseline → higher stress'); - // determinism - assert(JSON.stringify(calcStress(rr, baseSI)) === JSON.stringify(calcStress(rr, baseSI)), 'stress deterministic'); -} - -// ── §SpO₂ relative index (red/IR ratio) ─────────────────────────────────────── -console.log('--- §calcSpo2Index ---'); -{ - // Too few minutes → null, conf 0. - assert(calcSpo2Index([0.85, 0.86, 0.85], 0.85).index === null, 'spo2: <30 min → null'); - assert(calcSpo2Index([0.85, 0.86], 0.85).confidence === 0, 'spo2: too few → conf 0'); - // No baseline yet → seed night_ratio, null index. - const stable = Array.from({ length: 200 }, () => 0.850); - const seed = calcSpo2Index(stable, null); - assert(seed.index === null, 'spo2: no baseline → null index'); - approx(seed.night_ratio!, 0.85, 0.001, 'spo2: no baseline → seed night_ratio'); - // Stable clean night vs baseline → high confidence; lower ratio than baseline → positive index. - const better = calcSpo2Index(Array.from({ length: 200 }, () => 0.840), 0.850); - assert(better.index !== null && better.index > 0, 'spo2: lower ratio than baseline → positive index'); - assert(better.confidence > 0.8, 'spo2: stable + plenty of samples → high confidence'); - // Noisy night (high intra-night CV) → low confidence even with a baseline. - const noisy = calcSpo2Index(Array.from({ length: 200 }, (_, i) => 0.85 + (i % 2 ? 0.08 : -0.08)), 0.850); - assert(noisy.confidence < 0.3, 'spo2: high intra-night CV → low confidence'); - // Plausibility gate drops garbage ratios. - assert(calcSpo2Index(Array.from({ length: 200 }, () => 3.0), 0.85).index === null, 'spo2: implausible ratios → null'); -} - -// ── §sleep-stress / nocturnal arousal ───────────────────────────────────────── -console.log('--- §calcSleepStress ---'); -{ - // Calm night: flat low HR, no motion → no arousals, low score. - const calm: Minute[] = Array.from({ length: 240 }, (_, i) => min(i * 60, 50, 5)); - const cs = calcSleepStress(calm, baseline); - assert(cs.arousal_events === 0, 'calm night → no arousal events'); - assert(cs.score !== null && cs.score < 10, 'calm night → low sleep-stress'); - // Restless night: periodic HR surges + motion → arousal events detected. - const restless: Minute[] = Array.from({ length: 240 }, (_, i) => { - const surge = i % 40 === 0; // a surge every 40 min - return min(i * 60, surge ? 80 : 50, surge ? 3000 : 5); - }); - const rs = calcSleepStress(restless, baseline); - assert(rs.arousal_events >= 4, `restless night → arousal events detected (got ${rs.arousal_events})`); - assert(rs.score! > cs.score!, 'restless night scores higher than calm'); - assert(rs.events.length > 0 && rs.events.some((e) => e.kind === 'arousal'), 'arousal events listed for overlay'); -} - -// ── §13 calcNocturnalHeart ───────────────────────────────────────────────────── -console.log('--- §13 calcNocturnalHeart ---'); -{ - const sleep: Minute[] = [48, 46, 44, 46, 48, 50, 47, 45].map((h, i) => min(i * 60, h)); - const day: Minute[] = Array.from({ length: 20 }, (_, i) => min((100 + i) * 60, 70)); - const n = calcNocturnalHeart(sleep, day, { ...baseline, sleeping_hr: 50 }); - assert(n.sleeping_hr_avg !== null && Math.abs(n.sleeping_hr_avg - 47) <= 1, 'sleeping HR avg ≈ 47'); - assert(n.sleeping_hr_min !== null && n.sleeping_hr_min <= n.sleeping_hr_avg!, 'nadir ≤ avg'); - assert(n.day_hr_avg === 70, 'day HR avg = 70'); - assert(n.dip_pct !== null && n.dip_pct > 0.25, 'nocturnal dip computed (>25%)'); - assert(n.elevated === false, 'sleeping HR below baseline → not elevated'); - // elevated vs a low baseline - const hi = calcNocturnalHeart(sleep, day, { ...baseline, sleeping_hr: 42 }); - assert(hi.elevated === true, 'sleeping HR ≥ baseline+4 and +5% → elevated flag'); - // no HR → empty - const none = calcNocturnalHeart([], day, { ...baseline, sleeping_hr: 50 }); - assert(none.sleeping_hr_avg === null && none.confidence === 0, 'no sleep HR → null + conf 0'); -} - -// ── §14 buildNotifications ───────────────────────────────────────────────────── -console.log('--- §14 buildNotifications ---'); -{ - const { buildNotifications } = require('../notify'); - const base = { - date: '2026-06-11', readiness: 72, coach_summary: 'Solid day.', - coach_top: { title: 'Anchor sleep timing', body: 'Aim for a steady bedtime.' }, - body_alert: null, stress_score: 40, nocturnal_elevated: false, - sleep_debt_min: 0, acwr: 1.0, strain_today: 8, strain_target_low: 6, strain_target_high: 10, - }; - const n = buildNotifications(base); - assert(n.some((x: any) => x.kind === 'morning_readiness'), 'fires morning readiness'); - assert(n.every((x: any) => x.id === `2026-06-11:${x.kind}`), 'ids are date:kind (idempotent)'); - // health alert outranks everything. - const alert = buildNotifications({ ...base, body_alert: { kind: 'overtraining', note: 'High load.' } }); - assert(alert[0].kind === 'body_alert' && alert[0].priority === 3, 'body alert ranks first, priority 3'); - // sleep debt + high stress fire. - const heavy = buildNotifications({ ...base, sleep_debt_min: 200, stress_score: 80 }); - assert(heavy.some((x: any) => x.kind === 'sleep_debt'), 'big debt → sleep_debt notification'); - assert(heavy.some((x: any) => x.kind === 'high_stress'), 'high stress → high_stress notification'); - // streak milestone + new record. - const milestone = buildNotifications({ ...base, streaks: { wear: 7 }, new_records: ['Lowest resting HR'] }); - assert(milestone.some((x: any) => x.kind === 'streak_wear'), '7-day wear streak → milestone'); - assert(milestone.some((x: any) => x.kind.startsWith('record_')), 'new PR → record notification'); - // cap + determinism. - assert(n.length <= 6, 'capped at 6'); - assert(JSON.stringify(buildNotifications(base)) === JSON.stringify(buildNotifications(base)), - 'notifications deterministic'); -} - -// ── regression: SRI circular statistics (midnight-wrap bug) ────────────────── -console.log('--- regression: SRI circular (midnight wrap) ---'); -{ - const DAY = 86400; - // A TIGHT schedule that straddles midnight: onsets 23:50 / 00:05 / 23:55, - // wakes ~07:20–07:30. These are within ~15 min of each other, so a regular - // sleeper — but a LINEAR minute-of-day std treats 23:50 (1430) and 00:05 (5) - // as ~1425 min apart and floors SRI to 0. Circular std must score it HIGH. - const straddle = [ - { onset_ts: 0 * DAY + 1430 * 60, wake_ts: 0 * DAY + 440 * 60 }, - { onset_ts: 1 * DAY + 5 * 60, wake_ts: 1 * DAY + 450 * 60 }, - { onset_ts: 2 * DAY + 1435 * 60, wake_ts: 2 * DAY + 435 * 60 }, - ]; - const r = calcSleepRegularity(straddle); - assert(r.sri > 80, `midnight-straddle tight schedule → SRI high, not floored (got ${r.sri})`); - // Genuinely scattered onsets (spread across the clock) → low SRI. - const scattered = [ - { onset_ts: 0 * DAY + 1320 * 60, wake_ts: 0 * DAY + 360 * 60 }, // 22:00 - { onset_ts: 1 * DAY + 120 * 60, wake_ts: 1 * DAY + 600 * 60 }, // 02:00 - { onset_ts: 2 * DAY + 1200 * 60, wake_ts: 2 * DAY + 480 * 60 }, // 20:00 - ]; - assert(calcSleepRegularity(scattered).sri < r.sri, - 'scattered schedule scores lower than the tight straddle schedule'); -} - -// ── regression: sleep stages are physiologically plausible (not REM-dominated) ─ -console.log('--- regression: sleep stage proportions ---'); -{ - // A realistic night: low activity throughout, sleeping HR varying within a - // narrow band (deep = lowest HR, REM = highest). The OLD estimator routed the - // top ~38% of HR plus any >2 bpm jump into REM → 60–70% REM. The fix must keep - // REM a minority and produce a light-dominant night with some deep. - const night: Minute[] = []; - for (let i = 0; i < 5; i++) night.push(min(i * 60, 70, 2000)); // awake before - for (let i = 5; i < 365; i++) { - // sleeping HR oscillates 44..58 with minute-to-minute wobble (REM-like noise). - const base = 44 + 7 * (1 + Math.sin(i / 25)); - const wobble = (i % 3) - 1; // -1,0,1 every minute - night.push(min(i * 60, Math.round(base + wobble), 40)); - } - for (let i = 365; i < 370; i++) night.push(min(i * 60, 72, 2000)); // awake after - const s = calcSleep(night, baseline); - const st = s.stages!; - const tot = st.light_min + st.deep_min + st.rem_min; - assert(tot > 0, 'stages computed'); - assert(st.rem_min / tot < 0.40, `REM is a minority, not dominant (got ${(100*st.rem_min/tot).toFixed(0)}%)`); - assert(st.deep_min / tot > 0.05, `some deep sleep detected (got ${(100*st.deep_min/tot).toFixed(0)}%)`); - assert(st.light_min >= st.rem_min, `light ≥ REM (light ${st.light_min} vs REM ${st.rem_min})`); -} - -// ── regression: stageSleep detects REM from RR variability on a flat-HR night ── -console.log('--- regression: RR-driven REM staging ---'); -{ - // A calm night where HR is nearly flat (≈60 bpm) so HR LEVEL alone CANNOT separate - // REM from light (the real bug: REM read 0%). REM is encoded the physiological way — - // parasympathetic withdrawal → REDUCED beat-to-beat variability (low RMSSD), with HR - // only mildly above light. Deep = high RMSSD + lowest HR. stageSleep must use the RR - // autonomic axis to recover a physiological REM share (15–25%), not 0. - const ONSET = 0, WAKE = 280 * 60; - // Build a minute's RR stream with a target RMSSD: alternate ±d around the mean RR so - // successive |Δ| ≈ 2d ⇒ RMSSD ≈ 2d (kept < 200 ms so cleanRr doesn't drop beats). - const rrFor = (hr: number, rmssdTarget: number): number[] => { - const meanRr = Math.round(60000 / hr); - const d = Math.min(95, Math.round(rmssdTarget / 2)); - const out: number[] = []; - for (let j = 0; j < 48; j++) out.push(meanRr + (j % 2 === 0 ? d : -d)); - return out; - }; - type SM = { ts: number; hr_avg: number; rr?: number[] }; - const night: SM[] = []; - const push = (a: number, b: number, hr: number, rmssd: number) => { - for (let i = a; i < b; i++) night.push({ ts: i * 60, hr_avg: hr, rr: rrFor(hr, rmssd) }); - }; - push(0, 30, 60, 50); // light (medium variability) - push(30, 95, 56, 90); // deep (high RMSSD, lowest HR) - push(95, 150, 60, 50); // light - push(150, 215, 64, 16); // REM (low RMSSD, mildly elevated HR) - push(215, 280, 60, 50); // light - const ss = stageSleep(night, ONSET, WAKE, /*mesor*/ 90); - const tot = ss.light_min + ss.deep_min + ss.rem_min; - assert(tot > 0, 'RR-staged night produced stages'); - const remPct = (100 * ss.rem_min) / tot, deepPct = (100 * ss.deep_min) / tot; - assert(remPct >= 12 && remPct <= 35, `REM recovered from RR, physiological share (got ${remPct.toFixed(0)}%)`); - assert(deepPct >= 8, `deep detected from high-RMSSD block (got ${deepPct.toFixed(0)}%)`); - assert(ss.light_min >= ss.rem_min, 'light remains dominant'); - // 0 short(<20 min) awake flaps in the hypnogram. - let flaps = 0; - for (let i = 0; i < ss.hypnogram.length;) { - let j = i; while (j < ss.hypnogram.length && ss.hypnogram[j].stage === ss.hypnogram[i].stage) j++; - if (ss.hypnogram[i].stage === 'awake' && (j - i) < 20) flaps++; - i = j; - } - assert(flaps === 0, `no short awake flaps (got ${flaps})`); - // Without RR, the SAME flat-HR night cannot resolve REM → graceful HR-only fallback - // (must not throw, must not fabricate a REM-dominated night). - const noRr = night.map((m) => ({ ts: m.ts, hr_avg: m.hr_avg })); - const fb = stageSleep(noRr, ONSET, WAKE, 90); - assert((fb.light_min + fb.deep_min + fb.rem_min) > 0, 'HR-only fallback still stages'); -} - -// ── §Sleep cycles (fractal-cycle method on HRV) ─────────────────────────────── -console.log('--- §detectSleepCycles ---'); -{ - // RMSSD oscillating with a ~80-min ultradian period over a 320-min night → the - // findpeaks(20min, 0.9z) detector should recover ~4 peaks ⇒ ~3 cycles near 80 min. - // RR is built so each minute's RMSSD ≈ target: alternate ±d ⇒ RMSSD ≈ 2d. - const rrFor = (rmssd: number): number[] => { - const d = Math.max(2, Math.round(rmssd / 2)); - return Array.from({ length: 40 }, (_, j) => 900 + (j % 2 ? d : -d)); - }; - const mins: { ts: number; rr: number[] }[] = []; - for (let i = 0; i < 320; i++) { - const rmssd = 50 + 30 * Math.sin((2 * Math.PI * i) / 80); // ~80-min cycle - mins.push({ ts: i * 60, rr: rrFor(rmssd) }); - } - const c = detectSleepCycles(mins, 0, 319 * 60); - assert(c.n >= 2 && c.n <= 6, `cycles: ~3-4 ultradian cycles found (got ${c.n})`); - assert(c.mean_duration_min != null && c.mean_duration_min >= 55 && c.mean_duration_min <= 110, - `cycles: mean duration near the ~80-min period (got ${c.mean_duration_min})`); - assert(c.series.length > 0, 'cycles: emits a z-series for plotting'); - // No RR → abstain cleanly (no fabricated cycles). - const noRr = detectSleepCycles(Array.from({ length: 200 }, (_, i) => ({ ts: i * 60 })), 0, 199 * 60); - assert(noRr.n === 0 && noRr.cycles.length === 0, 'cycles: no RR → abstains'); -} - -// ── regression: resolveMaxHr doesn't promote a quiet within-day peak ────────── -console.log('--- regression: resolveMaxHr source ---'); -{ - // No baseline max, age present, day peaks only at 110 bpm (a quiet day). Must - // use the age-predicted max (Tanaka: 208−0.7·29 ≈ 188) as the denominator, - // NOT call 110 "measured". - const quiet: Minute[] = []; - for (let i = 0; i < 60; i++) quiet.push(min(i * 60, 95 + (i % 5), 100, { hr_max: 110 })); - const r1 = resolveMaxHr(quiet, { max_hr: 0 }, { age: 29 }); - assert(r1.source === 'age' && r1.maxHr === 188, - `quiet-day peak not promoted to measured (got ${r1.maxHr}/${r1.source})`); - // A genuine hard effort above age-max IS taken as measured. - const effort: Minute[] = []; - for (let i = 0; i < 60; i++) effort.push(min(i * 60, 150, 5000, { hr_max: i === 30 ? 198 : 150 })); - const r2 = resolveMaxHr(effort, { max_hr: 0 }, { age: 29 }); - assert(r2.source === 'measured' && r2.maxHr === 198, - `real above-age effort taken as measured (got ${r2.maxHr}/${r2.source})`); - // Baseline max always wins (stable session max). - const r3 = resolveMaxHr(quiet, { max_hr: 185 }, { age: 29 }); - assert(r3.source === 'measured' && r3.maxHr === 185, 'baseline max_hr wins'); -} - -// ── §Steps pedometer (AN-2554) ─────────────────────────────────────────────── -console.log('--- §Steps pedometer ---'); -{ - // Rest: flat 1g magnitude → the CONFIRM gate must read exactly 0. - const rest: number[] = new Array(3000).fill(1.0); - assert(pedometer(rest) === 0, 'rest signal → 0 steps (CONFIRM gate rejects non-gait)'); - - // Walk: clean ~1.8 Hz cadence over 30 s @100 Hz ≈ 54 gait cycles. Magnitude - // swings well outside the ±SENS dead-zone, so each cycle is a step. - const walk: number[] = []; - for (let i = 0; i < 3000; i++) walk.push(1.0 + 0.3 * Math.sin(2 * Math.PI * 1.8 * (i / 100))); - const raw = pedometer(walk); - assert(raw >= 45 && raw <= 60, `walk ~54 cycles → plausible step count (got ${raw})`); - - // calcSteps groups per-minute signals, sums, applies the calibration gain. - assert(calcSteps([rest]) === 0, 'calcSteps all-rest → 0'); - approx(calcSteps([walk]), Math.round(raw * STEP_PARAMS.GAIN), 0.001, - 'calcSteps applies the ×GAIN calibration to the summed raw count'); - assert(STEP_PARAMS.GAIN === 1.11, 'locked calibration gain = 1.11'); -} - -// ── §Circadian — CircaCP cosinor + bounded change-point ────────────────────── -console.log('--- §Circadian calcCircadian ---'); -{ - // 2 days of 1-min HR: asleep (hr≈55) hours [1,8), awake (hr≈80) otherwise. - // Onset ≈ 01:00, wake ≈ 08:00 each day; sharp transitions. - const mins: Minute[] = []; - for (let i = 0; i < 2 * 1440; i++) { - const ts = i * 60; - const hod = Math.floor(ts / 3600) % 24; - const asleep = hod >= 1 && hod < 8; - const hr = (asleep ? 55 : 80) + (i % 5) - 2; // tiny deterministic jitter - mins.push(min(ts, hr)); - } - const c = calcCircadian(mins); - assert(c.amplitude !== null && c.amplitude > 5, `circadian amplitude detected (got ${c.amplitude})`); - // day-2 onset ≈ 90000s (25:00 → 01:00 day 2), wake ≈ 115200s (32:00 → 08:00 day 2) - assert(c.onset_ts !== null && Math.abs(c.onset_ts - 90000) <= 1800, `onset ≈ 01:00 day2 (got ${c.onset_ts})`); - assert(c.wake_ts !== null && Math.abs(c.wake_ts - 115200) <= 1800, `wake ≈ 08:00 day2 (got ${c.wake_ts})`); - assert(c.settled === true, 'completed night marked settled'); - assert(c.confidence > 0.5, `confidence high on clean rhythm (got ${c.confidence})`); - - // flat HR (no rhythm) → abstain - const flat: Minute[] = []; - for (let i = 0; i < 2 * 1440; i++) flat.push(min(i * 60, 70)); - const cf = calcCircadian(flat); - assert(cf.onset_ts === null && cf.confidence < 0.3, 'flat HR → abstains (no fabricated boundary)'); -} - -// ── detectWakeState (sleep/wake ensemble) ───────────────────────────────────── -{ - const bl: Baseline = { resting_hr: 50, max_hr: 190, sleep_need_min: 480 }; - // 8h sleep (low HR, still) then N min awake (elevated HR, moving + steps). - const build = (awakeMin: number): Minute[] => { - const out: Minute[] = []; - let t = 0; - for (let i = 0; i < 480; i++, t += 60) out.push(min(t, 50, 0.01, { wrist_on: true })); - for (let i = 0; i < awakeMin; i++, t += 60) out.push(min(t, 72, 0.4, { steps: 20, wrist_on: true })); - return out; - }; - - const woke = detectWakeState({ minutes: build(15), baseline: bl }); - assert(woke.state === 'awake', `ensemble: state awake after waking (got ${woke.state})`); - assert(woke.wake_ts != null && Math.abs(woke.wake_ts - 480 * 60) <= 180, `ensemble: wake_ts ≈ sleep→wake boundary ±3min (got ${woke.wake_ts})`); - assert(woke.awake_min >= 12 && woke.awake_min <= 19, `ensemble: sustained awake ~15 min ±detector fuzz (got ${woke.awake_min})`); - assert(woke.asleep_min >= 90, `ensemble: main sleep ≥90 min (got ${woke.asleep_min})`); - - const tooSoon = detectWakeState({ minutes: build(5), baseline: bl }); - assert(tooSoon.wake_ts === null, 'ensemble: <10 min awake → no premature wake fire'); - - const stillAsleep = detectWakeState({ minutes: build(0), baseline: bl }); - assert(stillAsleep.state === 'asleep' && stillAsleep.wake_ts === null, 'ensemble: mid-sleep → asleep, no wake_ts'); - - const movingTail = [min(0, 72, 0.4, { steps: 20, wrist_on: true }), min(60, 73, 0.5, { steps: 25, wrist_on: true }), min(120, 71, 0.3, { steps: 10, wrist_on: true })]; - assert(peekRecentState(movingTail, bl) === 'awake', 'peek: moving + HR up → awake'); -} - -// ── regression: QUIET sedentary wake (HR up, NO motion, RR present) must fire ── -// The real-world bug: a user awake but still (on the phone in bed) has elevated HR -// and RR but ~zero motion. The OLD flat 2-of-3 majority let the two motion voters -// (blind to quiet wake) outvote cardiac → "asleep" → close never fired → no recovery. -// The ≥2 consensus must let the autonomic pair (cardiac + hrvArousal) carry the wake. -{ - const bl: Baseline = { resting_hr: 55, max_hr: 190, sleep_need_min: 480 }; - const minutes: Minute[] = []; - const rrByMin = new Map(); - let t = 0; - const rr = (meanMs: number, sd: number, n = 40) => Array.from({ length: n }, (_, j) => meanMs + (j % 2 ? sd : -sd)); - for (let i = 0; i < 480; i++, t += 60) { minutes.push(min(t, 52, 0.01, { wrist_on: true })); rrByMin.set(t, rr(1150, 12)); } // sleep: low HR, still, low RR-SD - for (let i = 0; i < 30; i++, t += 60) { minutes.push(min(t, 74, 0.01, { wrist_on: true })); rrByMin.set(t, rr(810, 60)); } // QUIET wake: HR up, NO motion, high RR-SD - - const ws = detectWakeState({ minutes, baseline: bl, rrByMin }); - assert(ws.state === 'awake', `quiet sedentary wake detected without motion (got ${ws.state})`); - assert(ws.wake_ts != null && Math.abs(ws.wake_ts - 480 * 60) <= 180, `quiet wake_ts at the boundary (got ${ws.wake_ts})`); - assert(ws.asleep_min >= 90, `main sleep preserved (got ${ws.asleep_min})`); - assert(ws.votes.cardiac === 'awake' && ws.votes.hrvArousal === 'awake', 'autonomic pair both vote awake at wake'); - - // Honest degradation: same still-but-awake tail with NO RR → only 1 signal (cardiac) - // → below the ≥2 bar → stays asleep rather than guess. - const noRr = detectWakeState({ minutes, baseline: bl }); - assert(noRr.wake_ts === null, `no-RR + no-motion quiet wake cannot be confirmed (honest) (got ${noRr.wake_ts})`); -} - -// ── menstrual cycle (log-anchored calendar method) ─────────────────────────── -{ - // No logs → empty/abstain. - const none = calcCycle([], '2026-06-20'); - assert(none.confidence === 0 && none.phase === 'unknown' && none.predicted_next === null, - 'cycle: no logs → abstain'); - - // Three regular 28-day starts → median 28, prediction = last + 28. - const starts = ['2026-04-04', '2026-05-02', '2026-05-30']; - const c = calcCycle(starts, '2026-06-06'); // day 8 of the cycle that began 05-30 - assert(c.mean_length === 28, `cycle: median length 28 (got ${c.mean_length})`); - assert(c.length_history.length === 2, `cycle: 2 observed lengths (got ${c.length_history.length})`); - assert(c.cycle_day === 8, `cycle: cycle day 8 (got ${c.cycle_day})`); - assert(c.predicted_next === '2026-06-27', `cycle: next period 06-27 (got ${c.predicted_next})`); - assert(c.ovulation_est === '2026-06-13', `cycle: ovulation = next−14 (got ${c.ovulation_est})`); - assert(c.fertile_start === '2026-06-08' && c.fertile_end === '2026-06-14', - `cycle: fertile window ov−5..ov+1 (got ${c.fertile_start}..${c.fertile_end})`); - assert(c.phase === 'follicular', `cycle: day 8 pre-ovulation → follicular (got ${c.phase})`); - assert(c.confidence > 0.5, `cycle: confidence grows with cycles (got ${c.confidence})`); - - // Menstruation window (day ≤ 5). - const m = calcCycle(starts, '2026-05-31'); // day 2 - assert(m.phase === 'menstruation', `cycle: day 2 → menstruation (got ${m.phase})`); - - // Luteal: after the fertile window, before next period. - const l = calcCycle(starts, '2026-06-20'); // day 22 - assert(l.phase === 'luteal', `cycle: day 22 → luteal (got ${l.phase})`); - - // Very overdue → prediction unreliable, abstain on phase + low confidence. - const od = calcCycle(starts, '2026-07-25'); // ~56 days since last start - assert(od.phase === 'unknown' && od.confidence <= 0.2, `cycle: very overdue → unknown/low conf (got ${od.phase}/${od.confidence})`); - - // Single log → 28-day default, low-but-nonzero confidence. - const one = calcCycle(['2026-06-10'], '2026-06-15'); - assert(one.mean_length === null && one.predicted_next === '2026-07-08' && one.confidence > 0, - `cycle: single log uses 28d default (got ${one.predicted_next}/${one.confidence})`); -} - -// ── §HAR — activity recognition (Mannini features + classifier + segmentation) ── -console.log('--- §HAR activity recognition ---'); -{ - // db10 orthonormality invariants — catch any coefficient transcription error. - const sumLo = DB10_LO.reduce((s, v) => s + v, 0); - const sumSq = DB10_LO.reduce((s, v) => s + v * v, 0); - assert(DB10_LO.length === 20, 'db10 has 20 taps'); - approx(sumLo, Math.SQRT2, 1e-6, 'db10 Σh = √2'); - approx(sumSq, 1, 1e-6, 'db10 Σh² = 1'); - - // Synthetic tri-axial window: gravity on Z + a sinusoidal swing at f0 on X. - const fs = 100, secs = 4, n = fs * secs; - // Oscillate the magnitude (gravity axis) at f0 so SMV ≈ 1 + amp·sin(2π f0 t) — this - // matches how the accel-vector magnitude actually varies with gait (avoids the sin² - // frequency-doubling artifact you get from a single off-axis sinusoid). - const mk = (f0: number, amp: number, noise = 0.004) => { - const x: number[] = [], y: number[] = [], z: number[] = []; - for (let i = 0; i < n; i++) { - const t = i / fs; - z.push(1 + amp * Math.sin(2 * Math.PI * f0 * t) + (((i * 7919) % 991) / 991 - 0.5) * noise); - x.push((((i * 1103515245 + 12345) % 1000) / 1000 - 0.5) * noise); - y.push((((i * 1103) % 997) / 997 - 0.5) * noise); - } - return { x, y, z }; - }; - - // Frequency detection: a 2.0 Hz swing → dom1_freq ≈ 2.0 (within bin resolution). - const w2 = mk(2.0, 0.5); - const f2 = extractHarFeatures(w2.x, w2.y, w2.z, fs); - approx(f2.dom1_freq, 2.0, 0.3, `HAR dom1_freq ≈ 2.0 (got ${f2.dom1_freq.toFixed(2)})`); - assert(f2.dom1_ratio > 0.25, 'HAR strong sine → periodic (high dom1_ratio)'); - - // Classification: flat (gravity only, tiny noise) → sedentary. - const flat = mk(1.0, 0.0); - assert(classifyActivityWindow(extractHarFeatures(flat.x, flat.y, flat.z, fs)).cls === 'sedentary', - 'HAR flat signal → sedentary'); - - // 2 Hz strong swing → a locomotion class (walk), not sedentary/other. - const cw = classifyActivityWindow(f2); - assert(cw.cls === 'walk', `HAR 2 Hz → walk (got ${cw.cls})`); - - // 2.8 Hz strong swing → run. - const w3 = mk(2.8, 0.6); - assert(classifyActivityWindow(extractHarFeatures(w3.x, w3.y, w3.z, fs)).cls === 'run', - 'HAR 2.8 Hz → run'); - - // wavelet detail energies present (6 levels), non-negative. - const we = dwtDetailEnergies(w2.x, 6); - assert(we.length === 6 && we.every((e) => e >= 0), 'db10 detail energies: 6 levels, ≥0'); - - // Segmentation: 5 min walk → 5 min run (one continuous bout) → two phases, primary = either. - const votes: ClassVote[] = []; - for (let t = 0; t < 300; t += 4) votes.push({ ts: 1000 + t, cls: 'walk', conf: 0.7 }); - for (let t = 300; t < 600; t += 4) votes.push({ ts: 1000 + t, cls: 'run', conf: 0.7 }); - const seg = segmentWorkout(votes); - assert(seg.segments.length === 2, `HAR segment: walk→run → 2 phases (got ${seg.segments.length})`); - assert(seg.segments[0].type === 'walk' && seg.segments[1].type === 'run', 'HAR phases ordered walk then run'); - - // A single-window blip inside a long run is smoothed away (no spurious phase). - const blip: ClassVote[] = []; - for (let t = 0; t < 600; t += 4) blip.push({ ts: 2000 + t, cls: t === 300 ? 'cycle' : 'run', conf: 0.7 }); - assert(segmentWorkout(blip).segments.length === 1, 'HAR single-window blip smoothed → one phase'); -} - -// ── §Restlessness / §Daytime HRV / §Desaturation ──────────────────────────── -console.log('--- §restlessness / daytime HRV / desaturation ---'); -{ - // Restlessness: a still night with a movement spike every 30 min → bouts detected. - const sleepMin: Minute[] = []; - for (let i = 0; i < 240; i++) { - const moving = i % 30 === 0; - sleepMin.push({ ts: 1000 + i * 60, hr_avg: 55, hr_min: 54, hr_max: 56, hr_n: 60, activity: moving ? 0.5 : 0.01, steps: 0, wrist_on: true }); - } - const rest = calcRestlessness(sleepMin); - assert(rest.score !== null && rest.movement_bouts >= 5, `restlessness: detects bouts (got ${rest.movement_bouts})`); - assert(rest.longest_still_min > 0 && rest.mobility_pct !== null, 'restlessness: still stretch + mobility'); - assert(calcRestlessness(sleepMin.slice(0, 5)).score === null, 'restlessness: <20 min → null'); - - // Daytime HRV: 60 min of RR bucketed into 5-min windows → per-window RMSSD series. - const byMin: { ts: number; rr: number[] }[] = []; - for (let i = 0; i < 60; i++) { - const rr: number[] = []; - for (let k = 0; k < 12; k++) rr.push(850 + ((i + k) % 5) * 15); - byMin.push({ ts: 1000 + i * 60, rr }); - } - const dh = calcDaytimeHrv(byMin, 300); - assert(dh.rmssd_median !== null && dh.n_windows >= 10, `daytime HRV: windows (got ${dh.n_windows})`); - assert(dh.series.length === dh.n_windows && dh.lowest_ts !== null, 'daytime HRV: series + lowest window'); - assert(calcDaytimeHrv([], 300).rmssd_median === null, 'daytime HRV: no RR → null'); - - // Desaturation: a 2-min dip (R↑ above baseline) every 20 min → events counted. - const ratios: number[] = []; - for (let i = 0; i < 120; i++) ratios.push(i % 20 < 2 ? 0.86 : 0.79); - const des = calcDesaturation(ratios, 0.80); - assert(des.events >= 4 && des.odi !== null, `desaturation: counts dips (got ${des.events})`); - assert(des.deepest_pct !== null && des.deepest_pct > 0, 'desaturation: reports deepest dip'); - const desNoBase = calcDesaturation(ratios, null); - assert(desNoBase.events === 0 && desNoBase.confidence === 0, 'desaturation: no baseline → abstain'); -} - -summary('analytics'); diff --git a/src/activity.ts b/src/activity.ts deleted file mode 100644 index b611715..0000000 --- a/src/activity.ts +++ /dev/null @@ -1,11 +0,0 @@ -// REMOVED in v0. Steps + active/sedentary classification are gone: -// - `steps` had no source field on this firmware (always 0) → misleading. -// - active/sedentary used a relative (median-split) threshold → tautological -// (~50% "active" by construction), not a real classifier. -// Activity-type detection (walk/run/cycle) was intentionally NOT added: too -// uncertain to ship in v0. -// -// The per-minute `activity` motion signal still exists in the Minute rollup and -// is consumed by sleep (Cole-Kripke) and session detection — only the daily -// activity METRIC is removed. Nothing is exported here. -export {}; diff --git a/src/advanced.ts b/src/advanced.ts deleted file mode 100644 index 14c2301..0000000 --- a/src/advanced.ts +++ /dev/null @@ -1,6 +0,0 @@ -// DEPRECATED module. Its former contents are now split across spec-aligned files: -// - HR recovery (HRR60) → src/recovery.ts (calcHrRecovery) -// - Sleep regularity (SRI) → src/regularity.ts (calcSleepRegularity) -// - Stress → BANNED (see docs/CONFIDENCE.md); removed entirely. -// Nothing is exported here. Kept as an empty module to avoid stale imports. -export {}; diff --git a/src/arousal.ts b/src/arousal.ts deleted file mode 100644 index 0ca4272..0000000 --- a/src/arousal.ts +++ /dev/null @@ -1,72 +0,0 @@ -// §Sleep stress / nocturnal arousal — "what your body did while you slept". -// Detects sympathetic ACTIVATION during sleep: heart-rate surges co-occurring -// with movement (the autonomic signature of arousals / restless / nightmare-like -// events), plus overall restlessness (motion during the sleep window). Built from -// minute HR + actigraphy over the main sleep period — no new hardware. -// -// HONESTY: we label spikes as "possible arousal events", NEVER "nightmares" — we -// can't know dream content. Tier ESTIMATE. -import type { Minute, Baseline, Metric, SleepStressValue, Driver } from './types'; -import { isHrUsable, mean, stddev, round } from './util'; - -/** - * calcSleepStress(sleepMinutes, baseline) - * sleepMinutes: worn minutes within the main sleep period (onset..wake). - * An "arousal event" = a minute whose HR jumps ≥ max(8 bpm, mean+1.5·sd of sleeping - * HR) AND carries movement (activity above the night's sleep-mean). Consecutive - * surge minutes collapse into one event. "restless" minutes = movement above the - * sleeping-activity mean. Score scales with event density + restless fraction. - */ -export function calcSleepStress(sleepMinutes: Minute[], _baseline: Baseline): Metric { - const worn = sleepMinutes.filter(isHrUsable).sort((a, b) => a.ts - b.ts); - const empty = (): Metric => ({ - score: null, arousal_events: 0, restless_min: 0, mean_sleeping_hr: null, events: [], - confidence: 0, tier: 'ESTIMATE', inputs_used: [], - }); - if (worn.length < 20) return empty(); - - const hrs = worn.map((m) => m.hr_avg); - const meanHr = mean(hrs); - const sdHr = stddev(hrs); - const acts = worn.map((m) => m.activity); - const meanAct = mean(acts); - const surgeThresh = meanHr + Math.max(8, 1.5 * sdHr); - - let arousalEvents = 0; - let restless = 0; - const events: { ts: number; kind: 'arousal' | 'restless' }[] = []; - let inSurge = false; - for (const m of worn) { - const moving = m.activity > meanAct && m.activity > 0; - if (moving) restless++; - const surge = m.hr_avg >= surgeThresh && moving; - if (surge && !inSurge) { - arousalEvents++; - events.push({ ts: m.ts, kind: 'arousal' }); - inSurge = true; - } else if (!surge) { - inSurge = false; - // record a few representative restless markers (not every minute) for overlay - if (moving && m.activity > meanAct * 2 && events.length < 60) events.push({ ts: m.ts, kind: 'restless' }); - } - } - - // Score: arousal density (events per hour) + restless fraction, mapped 0..100. - const hours = Math.max(0.5, worn.length / 60); - const eventsPerHour = arousalEvents / hours; - const restlessFrac = restless / worn.length; - const score = Math.max(0, Math.min(100, Math.round(eventsPerHour * 12 + restlessFrac * 100 * 0.5))); - - const drivers: Driver[] = [ - { label: 'Arousal events', contribution: arousalEvents, detail: `${arousalEvents} HR-surge+motion events`, ref: { metric: 'hr', scale: 'day' } }, - { label: 'Restlessness', contribution: round(restlessFrac * 100, 1), detail: `${restless} restless min`, ref: { metric: 'activity', scale: 'day' } }, - ]; - - const confidence = Math.min(1, worn.length / 240); - return { - score, arousal_events: arousalEvents, restless_min: restless, - mean_sleeping_hr: round(meanHr, 0), events, - confidence: round(confidence, 4), tier: 'ESTIMATE', - inputs_used: ['hr_avg', 'activity'], drivers, - }; -} diff --git a/src/baselines.ts b/src/baselines.ts deleted file mode 100644 index 7542016..0000000 --- a/src/baselines.ts +++ /dev/null @@ -1,119 +0,0 @@ -// §11 Baselines — rolling 30-day medians feeding everything. -import type { DayHistory, Profile, Metric, BaselinesValue } from './types'; -import { median, round } from './util'; - -/** - * calcBaselines(history, profile?) - * - * Rolling 30-day medians of: RHR, sleep duration (→ sleep_need_min), skin_temp - * (RELATIVE), per-zone time, daily strain (chronic, for ACWR). - * maxHR = max observed session hr_max (else 220−age). - * Seed from first ~3 days with wide confidence bands. - * - * Confidence formula: clamp(days_used/30, 0, 1) — a full 30-day window → 1.0; - * the first few seed days yield low confidence (wide bands). - */ -export function calcBaselines( - history: DayHistory[], - profile?: Profile -): Metric { - // Use the most-recent 30 days. - const window = history.slice(-30); - const days = window.length; - - const rhrs = window.map((d) => d.resting_hr).filter((x): x is number => x != null); - const sleeps = window - .map((d) => d.sleep_duration_min) - .filter((x): x is number => x != null); - const temps = window.map((d) => d.skin_temp).filter((x): x is number => x != null); - const strains = window - .map((d) => d.daily_strain) - .filter((x): x is number => x != null); - - const rhr = median(rhrs); - // Sleep need = median of REAL nights only. 0-duration nights (off-wrist / no - // sleep detected) would drag the median to garbage (e.g. median([299,0,0,1])≈0 - // → "0.0h need"). Require ≥2h to count, and reject an implausible result - // (<4h) as "no baseline yet" → null, so callers fall back to the 8h default. - const realNights = sleeps.filter((s) => s >= 120); - const sleepNeedRaw = median(realNights); - // Personalize only with ≥3 real nights AND a plausible (≥4h) result; else - // null → callers fall back to the 8h default (avoids 1-sample noise / garbage). - const sleepNeed = - realNights.length >= 3 && sleepNeedRaw != null && sleepNeedRaw >= 240 - ? sleepNeedRaw - : null; - const temp = median(temps); - const chronic = strains.length ? mean(strains) : null; - - // Per-zone medians. - const zoneCols: number[][] = [[], [], [], [], []]; - for (const d of window) { - if (d.zone_min) for (let z = 0; z < 5; z++) zoneCols[z].push(d.zone_min[z]); - } - const zoneMed = zoneCols.every((c) => c.length > 0) - ? (zoneCols.map((c) => median(c) ?? 0) as [number, number, number, number, number]) - : null; - - // maxHR: the highest per-day peak observed across the window. BUT a daily peak - // on a sedentary day is just a quiet high (e.g. 120 bpm), NOT a true HRmax — - // trusting it as the zone/strain denominator under-states the scale and inflates - // those metrics. So we only treat the observed peak as a real 'measured' max when - // it EXCEEDS the age-predicted Tanaka floor (208−0.7·age, JACC 2001 — a genuine - // hard effort); otherwise the age floor wins. Mirrors resolveMaxHr (util.ts) so - // the baseline and the per-day denominator agree. - const observedMax = window - .map((d) => d.session_hr_max) - .filter((x): x is number => x != null); - const observedPeak = observedMax.length > 0 ? Math.max(...observedMax) : 0; - const ageMax = - profile?.age && profile.age > 0 ? Math.round(208 - 0.7 * profile.age) : null; - let maxHr: number | null; - let maxHrSource: 'measured' | 'age'; - if (ageMax != null) { - if (observedPeak > ageMax) { - maxHr = observedPeak; // genuine above-age effort → trust it - maxHrSource = 'measured'; - } else { - maxHr = ageMax; // a quiet daily peak can't shrink the scale - maxHrSource = 'age'; - } - } else if (observedPeak > 0) { - // No age to floor against: the observed peak is the best available, but flag it - // 'age' (an unverified within-window peak, not a true measured HRmax) so callers - // down-weight confidence. - maxHr = observedPeak; - maxHrSource = 'age'; - } else { - maxHr = null; // honest: no measured max, no age → cannot derive - maxHrSource = 'age'; - } - - const confidence = Math.min(1, days / 30); - - const inputs_used: string[] = []; - if (rhrs.length) inputs_used.push('resting_hr'); - if (sleeps.length) inputs_used.push('sleep_duration_min'); - if (temps.length) inputs_used.push('skin_temp'); - if (strains.length) inputs_used.push('daily_strain'); - if (maxHrSource === 'measured') inputs_used.push('session_hr_max'); - else if (profile?.age) inputs_used.push('profile.age'); - - return { - resting_hr: rhr == null ? null : round(rhr, 1), - sleep_need_min: sleepNeed == null ? null : round(sleepNeed, 0), - skin_temp: temp == null ? null : round(temp, 2), - max_hr: maxHr == null ? null : round(maxHr, 0), - max_hr_source: maxHrSource, - chronic_strain: chronic == null ? null : round(chronic, 3), - zone_min: zoneMed, - days_used: days, - confidence: round(days === 0 ? 0 : confidence, 4), - tier: 'HIGH', - inputs_used, - }; -} - -function mean(xs: number[]): number { - return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0; -} diff --git a/src/calories.ts b/src/calories.ts deleted file mode 100644 index b4ff5a7..0000000 --- a/src/calories.ts +++ /dev/null @@ -1,77 +0,0 @@ -// §4 Active Calories — Keytel kcal/min ABOVE resting, sex-averaged when unknown. -// Tier ESTIMATE. This is ACTIVE energy expenditure (the HR-driven burn over and -// above the resting metabolic baseline) — NOT total daily expenditure. We subtract -// the resting-HR Keytel value per minute so quiet/sleep minutes contribute ≈0; -// without this subtraction Keytel's large weight/age constant stays positive even -// at rest and balloons a full-wear day to thousands of "active" kcal. -import type { Minute, Profile, Metric, CaloriesValue } from './types'; -import { isHrUsable, percentile, round } from './util'; - -/** - * calcCalories(minutes, profile, restingHr?) - * - * Keytel et al. (2005) HR→energy, kcal/min: - * male = (−55.0969 + 0.6309*hr + 0.1988*w + 0.2017*age)/4.184 - * female = (−20.4022 + 0.4472*hr − 0.1263*w + 0.0740*age)/4.184 - * Use sex if present, else the mean of male & female. ACTIVE kcal/min = - * max(0, perMin(hr) − perMin(restingHr)); summed over worn minutes (per-minute - * rollups → ×1). `restingHr` should be the user's baseline RHR; if absent we use - * the worn-HR 5th percentile as the resting floor. ALWAYS labeled "(est.)". - * - * Confidence formula: 0.5 base, scaled by coverage (worn_min/30 clamped). - */ -export function calcCalories( - minutes: Minute[], - profile: Profile, - restingHr?: number, - maxHr?: number -): Metric { - const worn = minutes.filter(isHrUsable); - const age = profile.age ?? 30; // population default ONLY for the formula term - const w = profile.weight_kg ?? 70; // population default weight - - const perMin = (hr: number): number => { - const male = (-55.0969 + 0.6309 * hr + 0.1988 * w + 0.2017 * age) / 4.184; - const female = (-20.4022 + 0.4472 * hr - 0.1263 * w + 0.074 * age) / 4.184; - if (profile.sex === 'm') return male; - if (profile.sex === 'f') return female; - return (male + female) / 2; - }; - - // Resting reference: the user's RHR (preferred) or the worn-HR 5th percentile. - const restRef = (restingHr != null && restingHr > 0) - ? restingHr - : (percentile(worn.map((m) => m.hr_avg), 5) ?? 50); - const restPerMin = perMin(restRef); - - // Activity gate: Keytel's HR→kcal slope is calibrated for EXERCISE; applied to - // all-day low-grade elevation (sitting at 75 bpm) it over-counts badly. Only - // accrue active calories once HR is genuinely in an active zone — Zone 1 onset - // = 50% of max HR (matches calcHrZones). Below that, the burn is sedentary/NEAT, - // not "active". When maxHr is unknown we don't gate (caller should pass it). - const activeFloor = (maxHr != null && maxHr > restRef) ? 0.5 * maxHr : restRef; - - let kcal = 0; - for (const m of worn) { - if (m.hr_avg < activeFloor) continue; // sedentary minute → not active calories - kcal += Math.max(0, perMin(m.hr_avg) - restPerMin); // active = above resting - } - - const inputs_used = ['hr_avg']; - if (restingHr != null && restingHr > 0) inputs_used.push('baseline.resting_hr'); - if (profile.age != null) inputs_used.push('profile.age'); - if (profile.weight_kg != null) inputs_used.push('profile.weight_kg'); - if (profile.sex != null) inputs_used.push('profile.sex'); - - // Spec fixes base at 0.5. Scale by coverage so a near-empty day isn't 0.5. - const coverage = Math.min(1, worn.length / 30); - const confidence = 0.5 * coverage; - - return { - kcal: round(kcal, 1), - label: '≈ active kcal (est.)', - confidence: round(confidence, 4), - tier: 'ESTIMATE', - inputs_used, - }; -} diff --git a/src/circadian.ts b/src/circadian.ts deleted file mode 100644 index 80d9255..0000000 --- a/src/circadian.ts +++ /dev/null @@ -1,389 +0,0 @@ -// §Circadian — CircaCP (Chen & Sun 2021, arXiv:2111.14960): robust cosinor model -// of the circadian rhythm + a single bounded change-point per cycle for sleep -// onset / wake. Validated on the dev account: wake within ~2 min, onset within -// ~2 min of the Cole-Kripke detector on real WHOOP 1 Hz HR. -// -// WHY HR (not actigraphy): the paper uses minute actigraphy, but the method is -// signal-agnostic and HR carries a strong, clean circadian rhythm (MESOR≈79, -// amplitude≈13 bpm on the dev account) with a sharp wake transition (HR rises -// ~25 bpm on waking) — a better wake discriminator than our near-zero activity -// rollup. Published method, no training, no fabrication; pure & deterministic. -// -// Division of labour: this returns the circadian phase + the MAIN-sleep -// onset/wake boundary of the most-recent completed cycle. In-sleep staging, -// efficiency and naps stay with calcSleep / calcSleepPeriods over that window. -import type { Minute, Metric, CircadianValue } from './types'; -import { isHrUsable, clamp, round, percentile } from './util'; -import { cleanRr } from './hrv'; - -const DAY = 86400; -const W = (2 * Math.PI) / DAY; // circadian angular frequency (rad/sec) - -interface Pt { t: number; y: number } - -/** - * Robust cosinor: y ≈ M + b1·cos(ωt) + b2·sin(ωt), fit by IRLS with a Tukey - * biweight so the active-phase HR spikes (exercise) don't drag the rhythm. - * Returns MESOR M, and (b1,b2) → amplitude = hypot, phase φ = atan2(b2,b1) - * with b1·cos+b2·sin = amp·cos(ωt − φ). - */ -function fitCosinor(pts: Pt[]): { mesor: number; b1: number; b2: number; amp: number; phi: number } | null { - const n = pts.length; - if (n < 120) return null; // need at least ~2h of points for a stable fit - const rows = pts.map((p) => ({ c: Math.cos(W * p.t), s: Math.sin(W * p.t), y: p.y })); - let w = new Array(n).fill(1); - let M = 0, b1 = 0, b2 = 0; - for (let iter = 0; iter < 8; iter++) { - // weighted normal equations for design [1, cos, sin] (3×3) - const A = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; - const bv = [0, 0, 0]; - for (let i = 0; i < n; i++) { - const x = [1, rows[i].c, rows[i].s]; - const wi = w[i]; - for (let r = 0; r < 3; r++) { - bv[r] += wi * x[r] * rows[i].y; - for (let cc = 0; cc < 3; cc++) A[r][cc] += wi * x[r] * x[cc]; - } - } - const sol = solve3(A, bv); - if (!sol) return null; - [M, b1, b2] = sol; - // update biweight weights from residuals (c = 4.685·1.4826·MAD) - const res = rows.map((r) => r.y - (M + b1 * r.c + b2 * r.s)); - const absr = res.map(Math.abs).sort((a, b) => a - b); - const mad = absr[absr.length >> 1] || 1; - const cc = 4.685 * 1.4826 * mad; - w = res.map((e) => (Math.abs(e) < cc ? (1 - (e / cc) ** 2) ** 2 : 0)); - } - return { mesor: M, b1, b2, amp: Math.hypot(b1, b2), phi: Math.atan2(b2, b1) }; -} - -/** Solve 3×3 A·x = b by Gaussian elimination with partial pivot. null if singular. */ -function solve3(A: number[][], b: number[]): [number, number, number] | null { - const m = A.map((row, i) => [...row, b[i]]); - for (let col = 0; col < 3; col++) { - let piv = col; - for (let r = col + 1; r < 3; r++) if (Math.abs(m[r][col]) > Math.abs(m[piv][col])) piv = r; - if (Math.abs(m[piv][col]) < 1e-12) return null; - [m[col], m[piv]] = [m[piv], m[col]]; - const pv = m[col][col]; - for (let k = col; k < 4; k++) m[col][k] /= pv; - for (let r = 0; r < 3; r++) { - if (r === col) continue; - const f = m[r][col]; - for (let k = col; k < 4; k++) m[r][k] -= f * m[col][k]; - } - } - return [m[0][3], m[1][3], m[2][3]]; -} - -/** Per-minute RMSSD (ms) from a (raw) RR stream; null if too few beats to be - * meaningful. Cleans physiological + ectopic artifacts first (shared `cleanRr`). */ -function minuteRmssd(rr: number[] | undefined): number | null { - if (!rr || rr.length < 12) return null; - const c = cleanRr(rr); - if (c.length < 10) return null; - let s = 0; - for (let i = 1; i < c.length; i++) { const d = c[i] - c[i - 1]; s += d * d; } - return Math.sqrt(s / (c.length - 1)); -} - -/** Median of finite values; null if none. */ -function medOf(xs: (number | null)[]): number | null { - const a = xs.filter((x): x is number => x != null && Number.isFinite(x)).sort((p, q) => p - q); - return a.length ? a[a.length >> 1] : null; -} - -/** Median filter (±k) to denoise HR before change-point search. */ -function smooth(ys: number[], k: number): number[] { - const n = ys.length; - const out = new Array(n); - for (let i = 0; i < n; i++) { - const lo = Math.max(0, i - k), hi = Math.min(n, i + k + 1); - const seg = ys.slice(lo, hi).sort((a, b) => a - b); - out[i] = seg[seg.length >> 1]; - } - return out; -} - -/** - * Single mean-shift change-point in a window, constrained to a direction ('drop' = - * onset, HR falls). Maximises the SSE reduction of a 2-segment split — i.e. the - * STRONGEST drop, which for the pre-bathyphase window is the evening→sleep onset - * (bigger than any quiet-evening dip). null if the window is too thin. - */ -function changePoint(pts: Pt[], want: 'drop' | 'rise'): number | null { - const MIN = 15; - if (pts.length < 2 * MIN) return null; - const ys = smooth(pts.map((p) => p.y), 5); - const n = ys.length; - const pre = new Array(n + 1).fill(0); - const pre2 = new Array(n + 1).fill(0); - for (let i = 0; i < n; i++) { pre[i + 1] = pre[i] + ys[i]; pre2[i + 1] = pre2[i] + ys[i] * ys[i]; } - const sse = (a: number, b: number): number => { - const cnt = b - a; if (cnt <= 0) return 0; - const sum = pre[b] - pre[a]; - return (pre2[b] - pre2[a]) - (sum * sum) / cnt; - }; - const total = sse(0, n); - let best: { gain: number; tau: number } | null = null; - for (let tau = MIN; tau < n - MIN; tau++) { - const d = (pre[n] - pre[tau]) / (n - tau) - pre[tau] / tau; // +ve = rise - if (want === 'rise' && d <= 0) continue; - if (want === 'drop' && d >= 0) continue; - const gain = total - (sse(0, tau) + sse(tau, n)); - if (!best || gain > best.gain) best = { gain, tau }; - } - return best ? pts[best.tau].t : null; -} - -/** - * Main sleep period. ONSET = the strongest HR drop in [bath−8h, bath] (the - * evening→sleep fall; robust to quiet sedentary evenings that a below-mesor run - * would wrongly absorb). WAKE = the end of the consolidated below-MESOR run - * extending forward from the bathyphase, bridging interior >mesor bumps ≤ BRIDGE - * min (REM/arousal) and ending at the morning rise that persists (daytime). Mesor - * is the cosinor midline — sleep below, wake above — so it doesn't cut through - * high-sleeping-HR users' light sleep. Returns onset/wake, or null. - */ -function mainSleepPeriod(pts: Pt[], bath: number, mesor: number): { onset: number; wake: number } | null { - const n = pts.length; - if (n < 30) return null; - const ys = smooth(pts.map((p) => p.y), 5); - const asleep = ys.map((v) => v < mesor); - // anchor = index nearest the bathyphase - let a = 0; - for (let i = 1; i < n; i++) if (Math.abs(pts[i].t - bath) < Math.abs(pts[a].t - bath)) a = i; - const BRIDGE = 60 * 60; - // wake: walk forward from anchor, bridging >mesor gaps ≤ BRIDGE; stop at a long one. - let end = a; - for (let i = a + 1; i < n;) { - if (asleep[i]) { end = i; i++; continue; } - let k = i; while (k < n && !asleep[k]) k++; - if (pts[(k < n ? k : n) - 1].t - pts[i].t > BRIDGE) break; - i = k; - } - // onset: strongest evening→sleep drop; fall back to the below-mesor run start. - let start = a; - for (let i = a - 1; i >= 0;) { - if (asleep[i]) { start = i; i--; continue; } - let k = i; while (k >= 0 && !asleep[k]) k--; - if (pts[i].t - pts[k + 1].t > BRIDGE) break; - i = k; - } - const onsetCp = changePoint(pts.filter((p) => p.t >= bath - 8 * 3600 && p.t <= bath), 'drop'); - const onset = onsetCp != null && onsetCp >= pts[start].t ? onsetCp : pts[start].t; - return { onset, wake: pts[end].t }; -} - -export interface SleepStaging { - in_bed_min: number; asleep_min: number; efficiency: number; - awake_min: number; light_min: number; deep_min: number; rem_min: number; - hypnogram: { t: number; stage: 'awake' | 'light' | 'deep' | 'rem' }[]; -} - -/** - * The ONE sleep stager (ESTIMATE/beta). Classifies every minute in [onset, wake] - * into awake / light / deep / rem, then returns BOTH the per-minute hypnogram AND the - * reconciled totals — so the graph and the stage breakdown can never disagree (the - * previous bug: two different classifiers). - * - * TWO AXES: - * • HR LEVEL — sets WAKE (sustained ≥20 min elevation, or off-wrist) and the - * overall depth ordering. - * • RR/HRV AUTONOMIC (when per-minute `rr` is present) — separates DEEP from REM, - * which HR level CANNOT do on calm nights (REM-HR ≈ light-HR). Deep/SWS = high - * parasympathetic tone → HIGH beat-to-beat variability (RMSSD) + lowest HR; REM = - * parasympathetic withdrawal → REDUCED RMSSD + mildly elevated HR (verified on real - * nights: corr(HR,RMSSD) ≈ −0.4, and a pure-HR stager read REM ≈ 0%). Thresholds are - * RATIOS to the night's asleep-RMSSD median, so they self-calibrate per user: - * deep = RMSSD ≥ 1.15·median AND HR ≤ asleep-HR-median - * rem = RMSSD ≤ 0.88·median (asleep, below wake) - * light = everything else (the broad majority). - * • Without usable RR we fall back to the legacy HR-only bands (graceful, honest — - * such nights just can't resolve REM, rather than fabricate it). - * asleep = light+deep+rem; efficiency = asleep / in-bed. - */ -export function stageSleep( - minutes: { ts: number; hr_avg: number; rr?: number[] }[], onset: number, wake: number, mesor: number, -): SleepStaging { - const inBed = Math.max(1, Math.round((wake - onset) / 60)); - const win = minutes.filter((m) => m.ts >= onset && m.ts <= wake).sort((a, b) => a.ts - b.ts); - const empty: SleepStaging = { - in_bed_min: inBed, asleep_min: 0, efficiency: 0, awake_min: inBed, - light_min: 0, deep_min: 0, rem_min: 0, hypnogram: [], - }; - const worn = win.filter((m) => m.hr_avg > 0); - if (worn.length < 5) return empty; - const hrs = worn.map((m) => m.hr_avg); - const floor = percentile(hrs, 10) ?? Math.min(...hrs); - const span = Math.max(1, mesor - floor); - // Light-dominant bands (HR-only stages_beta): deep = only the lowest HR, rem = a - // narrow elevated band just below wake, light = the broad middle (most of sleep). - const tAwake = Math.max(floor + 10, floor + 0.70 * span); - // REM = the broad "elevated-but-asleep" band below wake. Anchored at 0.40·span (was - // 0.60, which sat just under tAwake → a 2-3 bpm sliver that almost never triggered, - // so REM read ~0 on calm nights). REM's HR is only modestly above light, not 60% of - // the way to the daytime mesor — 0.40 puts the band where REM actually lives. - const tRem = floor + 0.40 * span; - const tDeep = floor + 0.12 * span; - - // smoothed HR aligned to `win` (off-wrist minutes carry hr 0 → forced awake) - const ys = smooth(win.map((m) => (m.hr_avg > 0 ? m.hr_avg : tAwake + 50)), 5); - - // ── Autonomic axis: per-minute RMSSD (smoothed ±2), and the night's asleep - // RMSSD/HR medians as self-calibrating references. Only used if a real fraction of - // the asleep minutes carry enough RR — else deep/rem stay on the HR-only fallback. ── - const rmRaw = win.map((m) => minuteRmssd(m.rr)); - const rmS = rmRaw.map((_, i) => { - const seg: (number | null)[] = []; - for (let j = Math.max(0, i - 2); j < Math.min(win.length, i + 3); j++) seg.push(rmRaw[j]); - return medOf(seg); - }); - const asleepI = win.map((_, i) => i).filter((i) => win[i].hr_avg > 0 && ys[i] < tAwake); - const rmRef = medOf(asleepI.map((i) => rmS[i])); - const hrRef = medOf(asleepI.map((i) => ys[i])); - const rrUsable = rmRef != null && hrRef != null - && asleepI.filter((i) => rmS[i] != null).length >= Math.max(20, Math.floor(asleepI.length * 0.4)); - const DEEP_R = 1.15, REM_R = 0.88; // RMSSD ratios vs asleep median (tuned on real RR: REM≈21%, deep≈13%) - - const stage: ('awake' | 'light' | 'deep' | 'rem')[] = new Array(win.length).fill('light'); - // pass 1: provisional per-minute classes - for (let k = 0; k < win.length; k++) { - if (win[k].hr_avg <= 0) { stage[k] = 'awake'; continue; } - const v = ys[k]; - if (v >= tAwake) { stage[k] = 'awake'; continue; } - if (rrUsable && rmS[k] != null) { - // RR present → autonomic deep/rem split (the REM-detecting path). - const rm = rmS[k]!; - stage[k] = (rm >= DEEP_R * rmRef! && v <= hrRef!) ? 'deep' - : (rm <= REM_R * rmRef!) ? 'rem' - : 'light'; - } else { - // No usable RR this minute/night → legacy HR-level bands. - stage[k] = v < tDeep ? 'deep' : v >= tRem ? 'rem' : 'light'; - } - } - // pass 2: an "awake" minute only counts as awake if part of a SUSTAINED (≥20 min) - // run; otherwise it's a brief REM/arousal bump → reclassify as rem. - let k = 0; - while (k < win.length) { - if (stage[k] === 'awake' && win[k].hr_avg > 0) { - let j = k; while (j < win.length && stage[j] === 'awake' && win[j].hr_avg > 0) j++; - if ((win[j - 1].ts - win[k].ts) / 60 < 20) for (let x = k; x < j; x++) stage[x] = 'rem'; - k = j; - } else k++; - } - // pass 3: BOUT-SMOOTHING — the per-minute classes flicker when HR hovers near a - // threshold (sawtooth hypnogram). Merge any run shorter than MIN_BOUT minutes into - // its longer neighbour, repeatedly, so the hypnogram reads as stable sleep bouts - // (real stages last many minutes). Awake keeps a higher floor so a genuine short - // awakening survives; sub-floor awakes are noise → absorbed into surrounding sleep. - const MIN_BOUT = 6, MIN_AWAKE_BOUT = 10; - for (let iter = 0; iter < 4; iter++) { - const runs: { s: number; e: number }[] = []; - for (let i = 0; i < win.length;) { let j = i; while (j < win.length && stage[j] === stage[i]) j++; runs.push({ s: i, e: j - 1 }); i = j; } - if (runs.length <= 1) break; - let changed = false; - for (let r = 0; r < runs.length; r++) { - const { s, e } = runs[r]; - const lenMin = e - s + 1; - const floorMin = stage[s] === 'awake' ? MIN_AWAKE_BOUT : MIN_BOUT; - if (lenMin >= floorMin) continue; - const prev = r > 0 ? runs[r - 1] : null; - const next = r < runs.length - 1 ? runs[r + 1] : null; - let target: typeof stage[number] | null = null; - if (prev && next) target = (prev.e - prev.s) >= (next.e - next.s) ? stage[prev.s] : stage[next.s]; - else if (prev) target = stage[prev.s]; - else if (next) target = stage[next.s]; - if (target) { for (let x = s; x <= e; x++) stage[x] = target; changed = true; } - } - if (!changed) break; - } - let light = 0, deep = 0, rem = 0, awake = 0; - for (const s of stage) { if (s === 'awake') awake++; else if (s === 'deep') deep++; else if (s === 'rem') rem++; else light++; } - const asleep = light + deep + rem; - return { - in_bed_min: inBed, asleep_min: asleep, efficiency: clamp(asleep / inBed, 0, 1), - awake_min: awake, light_min: light, deep_min: deep, rem_min: rem, - hypnogram: win.map((m, idx) => ({ t: m.ts, stage: stage[idx] })), - }; -} - -export interface CircadianOpts { - now?: number; // unix s "now" (default = latest minute ts); for determinism - settleSec?: number; // a wake older than this = night complete (default 600s) - anchorTs?: number; // target a specific cycle's bathyphase nearest this ts -} - -/** - * calcCircadian(minutes, opts) - * - * Fits the circadian cosinor over the HR series, then for the most-recent - * completed cycle (bathyphase = HR trough) finds main-sleep onset (HR drop in - * the 8h before the trough) and wake (HR rise in the 8h after). Returns the - * circadian phase + that boundary. - * - * Confidence = rhythm strength (amplitude) × whether a clean onset+wake pair was - * found. Abstains (nulls, confidence 0) when the rhythm is too weak or data too - * thin — never fabricates a boundary. - */ -export function calcCircadian(minutes: Minute[], opts: CircadianOpts = {}): Metric { - const usable = minutes.filter(isHrUsable).map((m) => ({ t: m.ts, y: m.hr_avg })).sort((a, b) => a.t - b.t); - const settle = opts.settleSec ?? 600; - - const empty = (): Metric => ({ - mesor: null, amplitude: null, acrophase_ts: null, bathyphase_ts: null, - onset_ts: null, wake_ts: null, in_bed_min: 0, settled: false, - confidence: 0, tier: 'HIGH', inputs_used: [], - }); - if (usable.length < 120) return empty(); - - const now = opts.now ?? usable[usable.length - 1].t; - const fit = fitCosinor(usable); - if (!fit) return empty(); - // No detectable circadian rhythm (flat HR) → abstain, never fabricate a boundary. - if (fit.amp < 3) return empty(); - - // bathyphase (HR trough): amp·cos(ωt − φ) minimal → ωt − φ = π. - const bathBase = (fit.phi + Math.PI) / W; - // acrophase (HR peak): ωt − φ = 0. - const acroBase = fit.phi / W; - const nearest = (base: number, ref: number) => base + Math.round((ref - base) / DAY) * DAY; - - // Pick the cycle: the most-recent bathyphase IN THE PAST. The wake is found within - // [bath, bath+8h] clipped to available data, and `settled` (wake older than the - // settle window) decides whether the night is complete. We deliberately do NOT - // require bath+8h to be in the past — that wrongly stepped back a whole night for - // an early riser checking soon after waking (the "previous night / short sleep" bug). - let bath = nearest(bathBase, opts.anchorTs ?? now); - if (bath > now - 3600) bath -= DAY; // bathyphase must be ~in the past - const acro = nearest(acroBase, now); - - const inWin = (lo: number, hi: number) => usable.filter((p) => p.t >= lo && p.t <= hi); - // Consolidated main-sleep period around the bathyphase (handles REM bumps + daytime). - const period = mainSleepPeriod(inWin(bath - 8 * 3600, bath + 10 * 3600), bath, fit.mesor); - const onset_ts = period ? period.onset : null; - const wake_ts = period ? period.wake : null; - const in_bed_min = onset_ts != null && wake_ts != null ? Math.round((wake_ts - onset_ts) / 60) : 0; - const settled = wake_ts != null && wake_ts <= now - settle; - - // confidence: rhythm strength (amp 2→0 … 10→1) gated on a clean onset+wake pair. - const rhythm = clamp((fit.amp - 2) / 8, 0, 1); - const paired = onset_ts != null && wake_ts != null ? 1 : 0.3; - const confidence = round(rhythm * paired, 2); - - return { - mesor: round(fit.mesor, 1), - amplitude: round(fit.amp, 1), - acrophase_ts: acro, - bathyphase_ts: bath, - onset_ts, - wake_ts, - in_bed_min, - settled, - confidence, - tier: 'HIGH', - inputs_used: ['hr'], - }; -} diff --git a/src/coach.ts b/src/coach.ts deleted file mode 100644 index 4b50a4f..0000000 --- a/src/coach.ts +++ /dev/null @@ -1,277 +0,0 @@ -// coach.ts — deterministic coaching engine. NO AI: pure math + rules. -// Turns a day's physiological state into a ranked, EXPLAINABLE "what to do" -// plan, a recommended strain target, a readiness-contributor decomposition, -// and a one-line narrative. Every suggestion carries the math that fired it -// (`why`) so the UI can show *why*, honouring the project's honesty contract. - -import { clamp, round } from './util'; - -export interface CoachInputs { - readiness: number | null; // 0..100 (est.) - readiness_components?: { rhr: number; sleep_debt: number; sleep_quality: number } | null; // 0..1 each - resting_hr: number | null; // today - baseline_rhr: number | null; - rhr_recent: number[]; // recent daily RHRs, most-recent LAST (for z-score) - strain_today: number | null; // 0..21 - acwr: number | null; // acute:chronic load ratio - sleep_last_min: number | null; // last night asleep minutes - sleep_need_min: number; // baseline need (already plausibility-floored upstream) - sleep_debt_min: number; // cumulative debt over recent real nights (≥0) - sleep_efficiency: number | null; // 0..1 - sri: number | null; // sleep regularity 0..100 - fitness_direction: string | null; // 'rising' | 'flat' | 'declining' | ... - anomaly: { signal: boolean; kind?: string; note?: string } | null; -} - -export interface Why { label: string; value: string; detail?: string } -export interface Suggestion { - id: string; - category: 'recovery' | 'sleep' | 'load' | 'health' | 'activity'; - title: string; - body: string; - severity: number; // 0 info … 3 urgent — drives ordering + colour - why: Why[]; - target?: string; -} -export interface Contributor { - key: string; - label: string; - value: number | null; - baseline: number | null; - impact: number; // signed points contributed to the 0..100 readiness score - note: string; -} -export interface CoachOutput { - strain_target: { value: number; low: number; high: number; rationale: string } | null; - plan: Suggestion[]; - readiness_contributors: Contributor[]; - summary: string; -} - -const mean = (xs: number[]) => xs.reduce((s, v) => s + v, 0) / xs.length; -const std = (xs: number[]) => { - if (xs.length < 2) return 0; - const m = mean(xs); - return Math.sqrt(xs.reduce((s, v) => s + (v - m) ** 2, 0) / xs.length); -}; -const hm = (min: number) => `${Math.floor(min / 60)}h ${Math.round(min % 60)}m`; - -/** Recommended strain target from readiness, capped by load + health flags. */ -function strainTarget(i: CoachInputs): CoachOutput['strain_target'] { - if (i.readiness == null) return null; - let base = 6 + (clamp(i.readiness, 0, 100) / 100) * 12; // readiness 0→6, 100→18 - const reasons: string[] = [`recovery ${Math.round(i.readiness)}`]; - if (i.acwr != null && i.acwr > 1.3) { - base = Math.min(base, 10); - reasons.push(`load high (ACWR ${i.acwr.toFixed(2)})`); - } - if (i.anomaly?.signal) { - base = Math.min(base, 8); - reasons.push('body-strain signal'); - } - const v = round(base, 1); - return { - value: v, - low: round(Math.max(0, v - 2), 1), - high: round(Math.min(21, v + 2), 1), - rationale: reasons.join(' · '), - }; -} - -/** Decompose the readiness score into signed point contributions. */ -function contributors(i: CoachInputs): Contributor[] { - const c = i.readiness_components; - if (!c) return []; - // Renormalize the published weights across present components. - const W = { rhr: 0.5, sleep_debt: 0.3, sleep_quality: 0.2 }; - const wSum = W.rhr + W.sleep_debt + W.sleep_quality; - // Each component's "lost points" = weight*100*(1 - component). Impact is the - // signed deviation from a neutral (all-perfect) contribution. - const pts = (w: number, comp: number) => round(-((w / wSum) * 100 * (1 - comp)), 1); - const note = (comp: number, good: string, bad: string) => - comp >= 0.85 ? good : bad; - return [ - { - key: 'rhr', - label: 'Resting HR', - value: i.resting_hr, - baseline: i.baseline_rhr, - impact: pts(W.rhr, c.rhr), - note: note(c.rhr, 'at/below baseline — supporting recovery', - 'elevated vs baseline — dragging recovery down'), - }, - { - key: 'sleep_debt', - label: 'Sleep duration', - value: i.sleep_last_min, - baseline: i.sleep_need_min, - impact: pts(W.sleep_debt, c.sleep_debt), - note: note(c.sleep_debt, 'met your sleep need', - 'short vs your need — costing recovery'), - }, - { - key: 'sleep_quality', - label: 'Sleep quality', - value: i.sleep_efficiency == null ? null : round(i.sleep_efficiency * 100, 0), - baseline: null, - impact: pts(W.sleep_quality, c.sleep_quality), - note: note(c.sleep_quality, 'efficient + consistent', - 'fragmented or irregular'), - }, - ]; -} - -/** The rule library. Each returns a Suggestion or null. Order-independent. */ -function rules(i: CoachInputs): Suggestion[] { - const out: (Suggestion | null)[] = []; - const tgt = strainTarget(i); - const recovery = i.readiness; - const acwrHigh = i.acwr != null && i.acwr > 1.3; - const acwrLow = i.acwr != null && i.acwr < 0.8; - - // RHR z-score (today vs recent). - let rhrZ: number | null = null; - if (i.rhr_recent.length >= 3 && i.resting_hr != null) { - const prior = i.rhr_recent.slice(0, -1); - const s = std(prior); - if (s > 0) rhrZ = (i.resting_hr - mean(prior)) / s; - } - - // ── HEALTH ── - if (i.anomaly?.signal) { - out.push({ - id: 'health.anomaly', - category: 'health', - title: i.anomaly.kind === 'overtraining' ? 'Back off — high load' : 'Recovery flag', - body: i.anomaly.note || - 'Your body is showing strain signals. Prioritise rest, hydration and easy movement today. A signal, not a diagnosis.', - severity: 3, - why: [ - ...(i.resting_hr != null && i.baseline_rhr != null - ? [{ label: 'Resting HR', value: `${Math.round(i.resting_hr)} bpm`, detail: `baseline ${Math.round(i.baseline_rhr)}` }] : []), - ...(i.acwr != null ? [{ label: 'Load (ACWR)', value: i.acwr.toFixed(2) }] : []), - ], - target: tgt ? `Keep strain ≤ ${tgt.high}` : undefined, - }); - } - - // ── LOAD ── - if (acwrHigh && !i.anomaly?.signal) { - out.push({ - id: 'load.high', - category: 'load', - title: 'Ease off the gas', - body: `Your acute training load is well above your 28-day baseline. Stack an easy or rest day to let it settle before pushing again.`, - severity: 2, - why: [{ label: 'Load (ACWR)', value: i.acwr!.toFixed(2), detail: 'optimal 0.8–1.3' }], - target: tgt ? `Target strain ${tgt.low}–${tgt.value}` : undefined, - }); - } - if (acwrLow && (recovery == null || recovery >= 55)) { - out.push({ - id: 'load.low', - category: 'activity', - title: 'Room to push', - body: `You're fresh and your recent load is light. A solid session today moves your fitness forward without overreaching.`, - severity: 1, - why: [{ label: 'Load (ACWR)', value: i.acwr!.toFixed(2), detail: '< 0.8 = detraining zone' }], - target: tgt ? `Aim for strain ${tgt.value}–${tgt.high}` : undefined, - }); - } - - // ── RECOVERY ── - if (recovery != null && recovery < 40 && !i.anomaly?.signal) { - out.push({ - id: 'recovery.low', - category: 'recovery', - title: 'Take it easy today', - body: `Recovery is low. Favour light movement, mobility or a walk over hard training, and protect tonight's sleep.`, - severity: 2, - why: [{ label: 'Recovery', value: `${Math.round(recovery)}`, detail: '(est.) — not HRV-based' }], - target: tgt ? `Keep strain ≤ ${tgt.value}` : undefined, - }); - } - if (recovery != null && recovery >= 70 && !acwrHigh && !i.anomaly?.signal) { - out.push({ - id: 'recovery.high', - category: 'activity', - title: 'Green light', - body: `Recovery is strong — your body's ready for a harder effort if you want it.`, - severity: 0, - why: [{ label: 'Recovery', value: `${Math.round(recovery)}` }], - target: tgt ? `You can target strain up to ${tgt.high}` : undefined, - }); - } - if (rhrZ != null && rhrZ > 1.5 && !i.anomaly?.signal) { - out.push({ - id: 'recovery.rhr_spike', - category: 'recovery', - title: 'Resting HR is up', - body: `Your resting HR is notably above your recent norm — often a sign of incomplete recovery, stress, alcohol or oncoming illness. Keep today gentle.`, - severity: 2, - why: [{ label: 'Resting HR', value: `${Math.round(i.resting_hr!)} bpm`, detail: `+${rhrZ.toFixed(1)}σ vs recent` }], - }); - } - - // ── SLEEP ── - if (i.sleep_debt_min >= 90) { - const earlier = Math.min(90, Math.round(i.sleep_debt_min / 3 / 5) * 5); - out.push({ - id: 'sleep.debt', - category: 'sleep', - title: 'Pay down sleep debt', - body: `You're carrying about ${hm(i.sleep_debt_min)} of sleep debt. Going to bed ~${earlier} min earlier tonight will start closing the gap.`, - severity: 2, - why: [{ label: 'Sleep debt', value: hm(i.sleep_debt_min), detail: `need ${hm(i.sleep_need_min)}/night` }], - }); - } - if (i.sri != null && i.sri < 70) { - out.push({ - id: 'sleep.consistency', - category: 'sleep', - title: 'Anchor your sleep timing', - body: `Your sleep schedule is inconsistent. Going to bed and waking within the same ~30-min window — even on weekends — is one of the biggest levers on recovery.`, - severity: 1, - why: [{ label: 'Sleep regularity', value: `${Math.round(i.sri)}/100`, detail: 'higher = steadier' }], - }); - } - if (i.sleep_last_min != null && i.sleep_efficiency != null && i.sleep_efficiency < 0.8 && i.sleep_last_min > 120) { - out.push({ - id: 'sleep.efficiency', - category: 'sleep', - title: 'Restless night', - body: `You spent a good chunk of last night awake in bed. A cooler, darker room and no screens before bed usually lift efficiency.`, - severity: 1, - why: [{ label: 'Sleep efficiency', value: `${Math.round(i.sleep_efficiency * 100)}%`, detail: 'target ≥ 85%' }], - }); - } - - return out.filter((s): s is Suggestion => s != null); -} - -/** Deterministic one-line narrative built from the dominant signals. */ -function narrative(i: CoachInputs): string { - const parts: string[] = []; - if (i.readiness != null) { - const w = i.readiness >= 70 ? 'Strong' : i.readiness >= 40 ? 'Moderate' : 'Low'; - parts.push(`${w} recovery`); - } - if (i.sleep_last_min != null && i.sleep_last_min > 0) parts.push(`slept ${hm(i.sleep_last_min)}`); - if (i.acwr != null) { - const w = i.acwr > 1.3 ? 'high load' : i.acwr < 0.8 ? 'light load' : 'balanced load'; - parts.push(w); - } - return parts.length ? parts.join(' · ') : 'Wear your strap and sync to see your daily read.'; -} - -export function buildCoach(i: CoachInputs): CoachOutput { - const plan = rules(i) - .sort((a, b) => b.severity - a.severity) - .slice(0, 5); - return { - strain_target: strainTarget(i), - plan, - readiness_contributors: contributors(i), - summary: narrative(i), - }; -} diff --git a/src/cycle.ts b/src/cycle.ts deleted file mode 100644 index 83bd122..0000000 --- a/src/cycle.ts +++ /dev/null @@ -1,100 +0,0 @@ -// cycle.ts — menstrual cycle estimation. -// -// LOG-ANCHORED + calendar method: the user logs period-start dates; prediction is -// driven by those, never inferred from biometrics alone (we don't claim to detect -// ovulation from temperature — that would be fabrication). The luteal phase is -// physiologically stable at ~14 days (Wilcox 2000), so ovulation ≈ next-period − 14 -// and the fertile window is the 5 days before ovulation + ovulation day. -// -// Biometric overlay (skin-temp / RHR / HRV shifts across the cycle) is rendered by -// the caller from stored `daily` values — it ENRICHES the view but is descriptive, -// never the basis of the prediction. Honest: an ESTIMATE, not medical or -// contraceptive guidance. - -import type { Metric } from './types' -import { median } from './util' - -const DAY_MS = 86400000 -const toMs = (d: string) => Date.parse(d + 'T00:00:00Z') -const toDate = (ms: number) => new Date(ms).toISOString().slice(0, 10) -const daysBetween = (a: string, b: string) => Math.round((toMs(b) - toMs(a)) / DAY_MS) - -export type CyclePhase = 'menstruation' | 'follicular' | 'ovulation' | 'luteal' | 'unknown' - -export interface CycleValue { - cycle_day: number | null // 1-based day within the current cycle (day 1 = start) - phase: CyclePhase - mean_length: number | null // median observed cycle length (days); null if <2 starts - length_history: number[] // observed consecutive-start gaps (days) - last_start: string | null // most recent logged period start (YYYY-MM-DD) - predicted_next: string | null // predicted next period start - days_until_next: number | null - ovulation_est: string | null // predicted_next − 14d - fertile_start: string | null // ovulation − 5d - fertile_end: string | null // ovulation + 1d - note: string -} - -const DEFAULT_LEN = 28 // population default until the user has ≥2 logged periods -const LUTEAL = 14 // stable luteal length → ovulation = next_period − LUTEAL -const MENSES = 5 // assumed menses length when no explicit end is logged - -/** Estimate the current cycle position + next-period / fertile-window prediction - * from a list of logged period-START dates. `today` is supplied (pure, no clock). */ -export function calcCycle(startsRaw: string[], today: string): Metric { - const empty = (note: string): Metric => ({ - cycle_day: null, phase: 'unknown', mean_length: null, length_history: [], - last_start: null, predicted_next: null, days_until_next: null, - ovulation_est: null, fertile_start: null, fertile_end: null, note, - confidence: 0, tier: 'ESTIMATE', inputs_used: ['period_log'], - }) - - const starts = Array.from(new Set(startsRaw)) - .filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d) && toMs(d) <= toMs(today)) - .sort() - if (starts.length === 0) return empty('Log a period to start tracking your cycle.') - - // Observed cycle lengths between consecutive starts (keep physiological 15–60d). - const lengths: number[] = [] - for (let i = 1; i < starts.length; i++) { - const len = daysBetween(starts[i - 1], starts[i]) - if (len >= 15 && len <= 60) lengths.push(len) - } - const med = lengths.length ? median(lengths) : null - const meanLen = med == null ? null : Math.round(med) - const useLen = meanLen ?? DEFAULT_LEN - - const last = starts[starts.length - 1] - const cycleDay = daysBetween(last, today) + 1 // day 1 = the start date itself - - const nextMs = toMs(last) + useLen * DAY_MS - const predictedNext = toDate(nextMs) - const daysUntil = daysBetween(today, predictedNext) - const ovMs = nextMs - LUTEAL * DAY_MS - const ovulation = toDate(ovMs) - const fertileStart = toDate(ovMs - 5 * DAY_MS) - const fertileEnd = toDate(ovMs + 1 * DAY_MS) - - // Phase by calendar method. - const todayMs = toMs(today) - let phase: CyclePhase - if (cycleDay <= MENSES) phase = 'menstruation' - else if (todayMs >= toMs(fertileStart) && todayMs <= toMs(fertileEnd)) phase = 'ovulation' - else if (todayMs < ovMs) phase = 'follicular' - else phase = 'luteal' - - // Confidence grows with the number of observed cycles; collapses if very overdue - // (a missed/late period makes the calendar prediction unreliable — say so). - let conf = lengths.length === 0 ? 0.3 : Math.min(0.9, 0.4 + 0.15 * lengths.length) - if (cycleDay > useLen * 1.6) { phase = 'unknown'; conf = Math.min(conf, 0.2) } - - return { - cycle_day: cycleDay, phase, mean_length: meanLen, length_history: lengths, - last_start: last, predicted_next: predictedNext, days_until_next: daysUntil, - ovulation_est: ovulation, fertile_start: fertileStart, fertile_end: fertileEnd, - note: lengths.length === 0 - ? 'Based on one logged period and a 28-day default — accuracy improves as you log more.' - : `Based on ${lengths.length + 1} logged periods (median ${useLen}-day cycle).`, - confidence: conf, tier: 'ESTIMATE', inputs_used: ['period_log'], - } -} diff --git a/src/cycles.ts b/src/cycles.ts deleted file mode 100644 index 042d5ad..0000000 --- a/src/cycles.ts +++ /dev/null @@ -1,108 +0,0 @@ -// §Sleep cycles — ultradian NREM↔REM cycle detection, adapted from Rosenblum et al. -// 2024 (eLife 13:RP96784, "Fractal cycles of sleep"). -// -// The paper detects sleep cycles as the interval between successive PEAKS of the -// smoothed, z-normalized EEG *aperiodic (fractal) slope* time series — MATLAB -// findpeaks(MinPeakDistance = 20 min, MinPeakProminence = 0.9 z). Peaks coincide with -// REM, troughs with non-REM; ~4–6 cycles/night, ~90 min each. It's a continuous, -// parameter-light definition with NO arbitrary per-night knob — exactly the property -// we want. -// -// We have no EEG. But heart-rate VARIABILITY carries the SAME ultradian rhythm — RMSSD -// is high in deep non-REM and low in REM — so we run the IDENTICAL peak algorithm on -// the smoothed z-normalized per-minute RMSSD series. Cycle boundaries anchor on the -// deep-sleep RMSSD peaks (the paper anchors on REM peaks: a cosmetic half-cycle phase -// shift — the cycle COUNT and DURATION, the actual outputs, are unchanged). -// -// Pure & deterministic. Tier ESTIMATE (it's a wrist-HRV proxy for an EEG method). -import { cleanRr } from './hrv'; - -export interface SleepCycle { start_ts: number; end_ts: number; duration_min: number } -export interface SleepCyclesValue { - cycles: SleepCycle[]; - mean_duration_min: number | null; - n: number; - series: { t: number; z: number }[]; // smoothed z-RMSSD, for plotting under the hypnogram -} - -const SMOOTH_MIN = 10; // ±10-min moving average → exposes the ~90-min envelope -const MIN_PEAK_DIST = 20; // min minutes between cycle boundaries (paper: 20 min / 40×30s) -const MIN_PROMINENCE = 0.9; // z — paper's MinPeakProminence (the |0.9| z descent gate) - -/** Per-minute RMSSD (ms) from a raw RR stream; null if too few clean beats. */ -function minuteRmssd(rr: number[] | undefined): number | null { - if (!rr || rr.length < 12) return null; - const c = cleanRr(rr); - if (c.length < 10) return null; - let s = 0; - for (let i = 1; i < c.length; i++) { const d = c[i] - c[i - 1]; s += d * d; } - return Math.sqrt(s / (c.length - 1)); -} - -/** Local maxima with topographic prominence ≥ minProm, then enforce a minimum spacing - * (keep the highest in each cluster) — a faithful port of MATLAB findpeaks' two gates. */ -function findPeaks(y: (number | null)[], minDist: number, minProm: number): number[] { - const n = y.length; - const cand: { i: number; v: number }[] = []; - for (let i = 1; i < n - 1; i++) { - const yi = y[i]; if (yi == null) continue; - const a = y[i - 1] ?? -Infinity, b = y[i + 1] ?? -Infinity; - if (!(yi >= a && yi > b)) continue; - // prominence: walk out each side until a higher sample; the higher of the two - // in-between minima is the reference. prom = peak − that reference. - let l = i; while (l > 0 && (y[l - 1] ?? -Infinity) < yi) l--; - let r = i; while (r < n - 1 && (y[r + 1] ?? -Infinity) < yi) r++; - let lmin = yi, rmin = yi; - for (let k = l; k <= i; k++) { const v = y[k]; if (v != null && v < lmin) lmin = v; } - for (let k = i; k <= r; k++) { const v = y[k]; if (v != null && v < rmin) rmin = v; } - if (yi - Math.max(lmin, rmin) >= minProm) cand.push({ i, v: yi }); - } - cand.sort((p, q) => q.v - p.v); // tallest first - const kept: number[] = []; - for (const c of cand) if (kept.every((k) => Math.abs(c.i - k) >= minDist)) kept.push(c.i); - return kept.sort((a, b) => a - b); -} - -/** - * detectSleepCycles(minutes, onset, wake) - * minutes: per-minute records over (at least) the sleep window, each with optional `rr`. - * Returns the ultradian cycles (peak-to-peak), their mean duration, and the smoothed - * z-RMSSD series. Abstains (empty) when there isn't enough clean RR to resolve cycles. - */ -export function detectSleepCycles( - minutes: { ts: number; rr?: number[] }[], onset: number, wake: number, -): SleepCyclesValue { - const none: SleepCyclesValue = { cycles: [], mean_duration_min: null, n: 0, series: [] }; - const win = minutes.filter((m) => m.ts >= onset && m.ts <= wake).sort((a, b) => a.ts - b.ts); - if (win.length < 60) return none; // need ~1 h to resolve even one cycle - - const raw = win.map((m) => minuteRmssd(m.rr)); - // ±SMOOTH_MIN moving average (ignoring gaps). - const sm = raw.map((_, i) => { - let s = 0, c = 0; - for (let j = Math.max(0, i - SMOOTH_MIN); j <= Math.min(raw.length - 1, i + SMOOTH_MIN); j++) { - const v = raw[j]; if (v != null) { s += v; c++; } - } - return c ? s / c : null; - }); - const vals = sm.filter((x): x is number => x != null); - if (vals.length < 60) return none; - const mean = vals.reduce((a, b) => a + b, 0) / vals.length; - const sd = Math.sqrt(vals.reduce((a, b) => a + (b - mean) ** 2, 0) / vals.length) || 1; - const z = sm.map((x) => (x == null ? null : (x - mean) / sd)); - - const peaks = findPeaks(z, MIN_PEAK_DIST, MIN_PROMINENCE); - const cycles: SleepCycle[] = []; - for (let i = 0; i + 1 < peaks.length; i++) { - const start_ts = win[peaks[i]].ts, end_ts = win[peaks[i + 1]].ts; - cycles.push({ start_ts, end_ts, duration_min: Math.round((end_ts - start_ts) / 60) }); - } - const mean_duration_min = cycles.length - ? Math.round(cycles.reduce((s, c) => s + c.duration_min, 0) / cycles.length) : null; - const series = win - .map((m, i) => ({ t: m.ts, z: z[i] })) - .filter((p): p is { t: number; z: number } => p.z != null) - .map((p) => ({ t: p.t, z: Math.round(p.z * 1000) / 1000 })); - - return { cycles, mean_duration_min, n: cycles.length, series }; -} diff --git a/src/fitness.ts b/src/fitness.ts deleted file mode 100644 index c83bf84..0000000 --- a/src/fitness.ts +++ /dev/null @@ -1,86 +0,0 @@ -// §Fitness — cardiorespiratory + training-load modeling. All from inputs we -// already have (max HR, resting HR, daily strain history). Published methods. -import type { - Metric, Vo2MaxValue, FitnessModelValue, MonotonyValue, DailyStrain, -} from './types'; -import { mean, stddev, round } from './util'; - -/** - * calcVo2Max(maxHr, restingHr) — Uth–Sørensen (2004): - * VO2max ≈ 15.3 × (HRmax / HRrest) [ml·kg⁻¹·min⁻¹] - * A whole-population HR-ratio estimate; ESTIMATE tier. Needs a measured maxHR and - * a resting HR with maxHR > restingHR, else abstains. - */ -export function calcVo2Max(maxHr: number | null, restingHr: number | null): Metric { - if (maxHr == null || restingHr == null || restingHr <= 0 || maxHr <= restingHr) { - return { vo2max: null, method: 'Uth–Sørensen', confidence: 0, tier: 'ESTIMATE', inputs_used: [] }; - } - return { - vo2max: round(15.3 * (maxHr / restingHr), 1), - method: 'Uth–Sørensen', - confidence: 0.5, - tier: 'ESTIMATE', - inputs_used: ['baseline.max_hr', 'baseline.resting_hr'], - }; -} - -/** - * calcFitnessModel(dailyStrain[]) — Banister impulse-response (1975/1991): - * Fitness (CTL) = EWMA of daily strain, τ ≈ 42 d (slow, "fitness") - * Fatigue (ATL) = EWMA of daily strain, τ ≈ 7 d (fast, "fatigue") - * Form (TSB) = Fitness − Fatigue measured BEFORE today's strain (freshness) - * Confidence ramps with days available (full at ~42 d). ESTIMATE tier. - */ -export function calcFitnessModel(dailyStrain: DailyStrain[]): Metric { - const sorted = [...dailyStrain].sort((a, b) => a.ts - b.ts); - const days = sorted.length; - if (days < 7) { - return { - fitness: null, fatigue: null, form: null, - confidence: round(Math.min(1, days / 42), 4), tier: 'ESTIMATE', inputs_used: ['daily_strain'], - }; - } - const aCtl = 2 / (42 + 1), aAtl = 2 / (7 + 1); - let ctl = sorted[0].strain, atl = sorted[0].strain; - let prevCtl = ctl, prevAtl = atl; - for (const d of sorted) { - prevCtl = ctl; prevAtl = atl; - ctl = ctl + aCtl * (d.strain - ctl); - atl = atl + aAtl * (d.strain - atl); - } - return { - fitness: round(ctl, 2), - fatigue: round(atl, 2), - form: round(prevCtl - prevAtl, 2), // freshness coming INTO today - confidence: round(Math.min(1, days / 42), 4), - tier: 'ESTIMATE', - inputs_used: ['daily_strain'], - }; -} - -/** - * calcMonotony(dailyStrain[]) — Foster (1998) training monotony & strain: - * monotony = mean(7d strain) / SD(7d strain) (>2 = risky sameness) - * training_strain = weekly_load × monotony - * HIGH tier (deterministic from strain). Needs ≥4 of the last 7 days. - */ -export function calcMonotony(dailyStrain: DailyStrain[]): Metric { - const last7 = [...dailyStrain].sort((a, b) => a.ts - b.ts).slice(-7).map((d) => d.strain); - const weekly = round(last7.reduce((a, b) => a + b, 0), 1); - if (last7.length < 4) { - return { - monotony: null, training_strain: null, weekly_load: weekly, - confidence: round(last7.length / 7, 4), tier: 'HIGH', inputs_used: ['daily_strain'], - }; - } - const m = mean(last7), sd = stddev(last7); - const monotony = sd > 0 ? m / sd : null; - return { - monotony: monotony == null ? null : round(monotony, 2), - training_strain: monotony == null ? null : round(weekly * monotony, 1), - weekly_load: weekly, - confidence: round(Math.min(1, last7.length / 7), 4), - tier: 'HIGH', - inputs_used: ['daily_strain'], - }; -} diff --git a/src/har.ts b/src/har.ts deleted file mode 100644 index 642527a..0000000 --- a/src/har.ts +++ /dev/null @@ -1,325 +0,0 @@ -// §HAR — Human Activity Recognition from wrist accelerometer windows. -// Method: Mannini et al., Med Sci Sports Exerc 2013 ("Activity recognition using -// a single accelerometer placed at the wrist or ankle"), wrist configuration. -// Per ~4 s window we extract Mannini's feature set (time-domain + frequency-domain -// + db10 wavelet energies) and classify with TRANSPARENT cadence/power thresholds -// (ESTIMATE tier) — no trained model yet (we have no labelled data). The feature -// vector is the same one a future SVM would consume, so the upgrade path is clean. -// -// HONEST SCOPE: this only ever runs on LIVE high-rate stream data (R10/0x33, -// ~100 Hz) — flash-drained history is 1 Hz and CANNOT be motion-classified. -// Pure + synchronous (no I/O, no wasm): a db10 DWT on a 512-sample window is -// microseconds in plain TS; a wasm dependency would only puncture the pure model. - -import { mean, stddev } from './util'; -import type { ActivityClass } from './types'; - -export type { ActivityClass }; - -export interface HarFeatures { - smv_mean: number; - smv_std: number; - smv_min: number; - smv_max: number; - total_power: number; // spectral power in 0.3–15 Hz - dom1_freq: number; // strongest spectral peak in 0.3–15 Hz - dom1_pow: number; - dom2_freq: number; // second peak - dom2_pow: number; - cad_freq: number; // dominant peak inside the locomotion band 0.6–2.5 Hz - cad_pow: number; - dom1_ratio: number; // dom1_pow / total_power (spectral peakiness → periodicity) - freq_ratio_prev: number; // dom1_freq / previous window's dom1_freq (transition cue) - wav_e5: number; // db10 detail energy at level 5 - wav_e6: number; // db10 detail energy at level 6 -} - -// ── db10 decomposition low-pass coefficients (PyWavelets `pywt.Wavelet('db10').dec_lo`). -// Validated in tests via Σh=√2 and Σh²=1. Source: wavelets.pybytes.com / PyWavelets. -export const DB10_LO: number[] = [ - 2.667005790055555358661744877130858277192498290851289932779975e-02, - 1.881768000776914890208929736790939942702546758640393484348595e-01, - 5.272011889317255864817448279595081924981402680840223445318549e-01, - 6.884590394536035657418717825492358539771364042407339537279681e-01, - 2.811723436605774607487269984455892876243888859026150413831543e-01, - -2.498464243273153794161018979207791000564669737132073715013121e-01, - -1.959462743773770435042992543190981318766776476382778474396781e-01, - 1.273693403357932600826772332014009770786177480422245995563097e-01, - 9.305736460357235116035228983545273226942917998946925868063974e-02, - -7.139414716639708714533609307605064767292611983702150917523756e-02, - -2.945753682187581285828323760141839199388200516064948779769654e-02, - 3.321267405934100173976365318215912897978337413267096043323351e-02, - 3.606553566956169655423291417133403299517350518618994762730612e-03, - -1.073317548333057504431811410651364448111548781143923213370333e-02, - 1.395351747052901165789318447957707567660542855688552426721117e-03, - 1.992405295185056117158742242640643211762555365514105280067936e-03, - -6.858566949597116265613709819265714196625043336786920516211903e-04, - -1.164668551292854509514809710258991891527461854347597362819235e-04, - 9.358867032006959133405013034222854399688456215297276443521873e-05, - -1.326420289452124481243667531226683305749240960605829756400674e-05, -]; - -/** Quadrature-mirror high-pass: g[k] = (−1)^k · h[N−1−k]. */ -function db10Hi(): number[] { - const N = DB10_LO.length; - return DB10_LO.map((_, k) => (k % 2 === 0 ? 1 : -1) * DB10_LO[N - 1 - k]); -} -const DB10_HI = db10Hi(); - -/** One DWT level with periodic ('wrap') boundary → {approx, detail} (downsampled by 2). */ -function dwtStep(sig: number[], lo: number[], hi: number[]): { a: number[]; d: number[] } { - const n = sig.length, L = lo.length; - const half = Math.floor(n / 2); - const a = new Array(half).fill(0); - const d = new Array(half).fill(0); - for (let i = 0; i < half; i++) { - let sa = 0, sd = 0; - for (let k = 0; k < L; k++) { - const idx = (2 * i + k) % n; // periodic extension - sa += lo[k] * sig[idx]; - sd += hi[k] * sig[idx]; - } - a[i] = sa; d[i] = sd; - } - return { a, d }; -} - -/** Detail-coefficient energy (Σ d²) at each level 1..levels via db10 DWT. */ -export function dwtDetailEnergies(signal: number[], levels: number): number[] { - let a = signal.slice(); - const out: number[] = []; - for (let lvl = 1; lvl <= levels; lvl++) { - if (a.length < 2) { out.push(0); continue; } - const { a: na, d } = dwtStep(a, DB10_LO, DB10_HI); - out.push(d.reduce((s, v) => s + v * v, 0)); - a = na; - } - return out; -} - -// ── 4th-order Butterworth low-pass = two RBJ biquad sections (Butterworth Qs). ── -function biquadLP(sig: number[], fs: number, fc: number, q: number): number[] { - const w0 = (2 * Math.PI * fc) / fs; - const cosw = Math.cos(w0), sinw = Math.sin(w0); - const alpha = sinw / (2 * q); - const a0 = 1 + alpha; - const b0 = ((1 - cosw) / 2) / a0, b1 = (1 - cosw) / a0, b2 = ((1 - cosw) / 2) / a0; - const a1 = (-2 * cosw) / a0, a2 = (1 - alpha) / a0; - const out = new Array(sig.length); - // Initialise to steady state at the first sample so the ~1 g DC gravity offset - // doesn't ring up a 0→1 startup transient (which would inflate power/variance). - const s0 = sig.length ? sig[0] : 0; - let x1 = s0, x2 = s0, y1 = s0, y2 = s0; - for (let i = 0; i < sig.length; i++) { - const x0 = sig[i]; - const y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; - x2 = x1; x1 = x0; y2 = y1; y1 = y0; out[i] = y0; - } - return out; -} -/** 15 Hz 4th-order Butterworth low-pass (Mannini preprocessing). */ -function butterLP4(sig: number[], fs: number, fc = 15): number[] { - return biquadLP(biquadLP(sig, fs, fc, 0.54119610), fs, fc, 1.30656296); -} - -function nextPow2(n: number): number { let p = 1; while (p < n) p <<= 1; return p; } - -/** Iterative radix-2 FFT power spectrum (length N/2+1), N = next pow2 of input. */ -function powerSpectrum(sig: number[]): number[] { - const N = nextPow2(sig.length); - const re = new Float64Array(N), im = new Float64Array(N); - for (let i = 0; i < sig.length; i++) re[i] = sig[i]; - // bit-reversal permutation - for (let i = 1, j = 0; i < N; i++) { - let bit = N >> 1; - for (; j & bit; bit >>= 1) j ^= bit; - j ^= bit; - if (i < j) { const tr = re[i]; re[i] = re[j]; re[j] = tr; const ti = im[i]; im[i] = im[j]; im[j] = ti; } - } - for (let len = 2; len <= N; len <<= 1) { - const ang = (-2 * Math.PI) / len; - const wr = Math.cos(ang), wi = Math.sin(ang); - for (let i = 0; i < N; i += len) { - let cr = 1, ci = 0; - for (let k = 0; k < len / 2; k++) { - const ur = re[i + k], ui = im[i + k]; - const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci; - const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr; - re[i + k] = ur + vr; im[i + k] = ui + vi; - re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi; - const ncr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = ncr; - } - } - } - const half = N / 2; - const pow = new Array(half + 1); - for (let i = 0; i <= half; i++) pow[i] = (re[i] * re[i] + im[i] * im[i]) / N; - return pow; -} - -/** - * extractHarFeaturesFromSmv(smvRaw, fs, prevDomFreq?) — Mannini feature set from the - * accel-vector MAGNITUDE signal (= SMV). This is the primary entry point: the band's - * decoders give us per-sample |accel| (frameAccel `mags`), which IS the SMV, so the - * ingest path feeds it directly. The 15 Hz Butterworth LP is applied to the magnitude. - */ -export function extractHarFeaturesFromSmv(smvRaw: number[], fs: number, prevDomFreq = 0): HarFeatures { - const smv = butterLP4(smvRaw, fs); // 15 Hz LP on the magnitude - const smv_mean = mean(smv), smv_std = stddev(smv); - const smv_min = Math.min(...smv), smv_max = Math.max(...smv); - - // Spectrum of the DC-removed SMV. - const ac = smv.map((v) => v - smv_mean); - const pow = powerSpectrum(ac); - const N = nextPow2(ac.length); - const binHz = fs / N; - const idxOf = (f: number) => Math.round(f / binHz); - const loBin = Math.max(1, idxOf(0.3)), hiBin = Math.min(pow.length - 1, idxOf(15)); - let total = 0; - for (let i = loBin; i <= hiBin; i++) total += pow[i]; - - // top-2 peaks in 0.3–15 Hz - let d1i = loBin, d2i = loBin; - for (let i = loBin; i <= hiBin; i++) if (pow[i] > pow[d1i]) d1i = i; - for (let i = loBin; i <= hiBin; i++) if (i !== d1i && pow[i] > pow[d2i]) d2i = i; - // dominant peak in the locomotion band 0.6–2.5 Hz - const cLo = Math.max(1, idxOf(0.6)), cHi = Math.min(pow.length - 1, idxOf(2.5)); - let ci = cLo; - for (let i = cLo; i <= cHi; i++) if (pow[i] > pow[ci]) ci = i; - - const dom1_freq = d1i * binHz, dom1_pow = pow[d1i]; - const wav = dwtDetailEnergies(smv, 6); - - return { - smv_mean, smv_std, smv_min, smv_max, - total_power: total, - dom1_freq, dom1_pow, - dom2_freq: d2i * binHz, dom2_pow: pow[d2i], - cad_freq: ci * binHz, cad_pow: pow[ci], - dom1_ratio: total > 0 ? dom1_pow / total : 0, - freq_ratio_prev: prevDomFreq > 0 ? dom1_freq / prevDomFreq : 1, - wav_e5: wav[4] ?? 0, - wav_e6: wav[5] ?? 0, - }; -} - -/** - * extractHarFeatures(x, y, z, fs, prevDomFreq?) — convenience wrapper for tri-axial - * input (mainly tests/synthetic data): computes the SMV magnitude and delegates. - */ -export function extractHarFeatures( - x: number[], y: number[], z: number[], fs: number, prevDomFreq = 0, -): HarFeatures { - const n = Math.min(x.length, y.length, z.length); - const smvRaw = new Array(n); - for (let i = 0; i < n; i++) smvRaw[i] = Math.sqrt(x[i] * x[i] + y[i] * y[i] + z[i] * z[i]); - return extractHarFeaturesFromSmv(smvRaw, fs, prevDomFreq); -} - -// Tunable thresholds (documented, ESTIMATE tier — replace with trained model later). -// We classify off dom1_freq (strongest 0.3–15 Hz peak = locomotion fundamental); the -// Mannini cad_freq feature (0.6–2.5 Hz band, capped) is kept for the future SVM. -const SED_POWER = 0.02; // below this spectral power → not moving rhythmically -const SED_STD = 0.04; // g -const PERIODIC = 0.25; // dom1_ratio above this → clearly periodic (walk/run/cycle) -const RUN_HZ = 2.4; // dominant ≥2.4 Hz → running cadence -const WALK_HZ = 1.3; // 1.3–2.4 Hz → walking cadence -const CYCLE_HZ_LO = 0.6; // 0.6–1.3 Hz with smooth motion → cycling pedal cadence - -/** - * classifyActivityWindow(f) — transparent threshold classifier over Mannini features. - * Returns the class + a confidence from spectral peakiness & band power. ESTIMATE tier. - */ -export function classifyActivityWindow(f: HarFeatures): { cls: ActivityClass; confidence: number } { - // Sedentary: low spectral power AND low motion variance. - if (f.total_power < SED_POWER && f.smv_std < SED_STD) { - return { cls: 'sedentary', confidence: 0.6 }; - } - const periodic = f.dom1_ratio >= PERIODIC; - const peakConf = Math.min(0.9, 0.4 + f.dom1_ratio); // peakier spectrum → higher confidence - const cad = f.dom1_freq; - - if (periodic) { - if (cad >= RUN_HZ) return { cls: 'run', confidence: peakConf }; - if (cad >= WALK_HZ) return { cls: 'walk', confidence: peakConf }; - if (cad >= CYCLE_HZ_LO && f.smv_std < SED_STD * 4) - return { cls: 'cycle', confidence: peakConf * 0.9 }; // smooth low-cadence motion - } - // Elevated but non-periodic motion (irregular, no clean cadence) → resistance/lifting. - if (f.smv_std >= SED_STD && !periodic) return { cls: 'lift', confidence: 0.45 }; - return { cls: 'other', confidence: 0.4 }; -} - -// ── Segmentation: per-window votes → smoothed phases (graceful activity switches) ── -export interface ClassVote { ts: number; cls: ActivityClass; conf: number } -export interface WorkoutSegment { start_ts: number; end_ts: number; type: ActivityClass; confidence: number } -export interface SegmentResult { primary: ActivityClass; segments: WorkoutSegment[]; type_confidence: number } - -/** Mode of a class array (ties → highest mean confidence handled by caller). */ -function modeClass(window: ClassVote[]): ActivityClass { - const c: Record = {}; - for (const v of window) c[v.cls] = (c[v.cls] ?? 0) + 1; - let best: ActivityClass = window[0].cls, bestN = -1; - for (const k of Object.keys(c)) if (c[k] > bestN) { bestN = c[k]; best = k as ActivityClass; } - return best; -} - -/** - * segmentWorkout(votes, opts) — smooth the per-window class timeline (median filter to - * kill blips) and run-length-encode into phases ≥ minPhaseSec, so a run→cycle or - * lift→cardio switch becomes ONE workout with labelled phases (hysteresis, not flip-flop). - * primary = longest phase; 'mixed' when no phase dominates (circuit/brick). - */ -export function segmentWorkout( - votes: ClassVote[], - opts: { smoothWin?: number; minPhaseSec?: number } = {}, -): SegmentResult { - const smoothWin = opts.smoothWin ?? 7; // ~window count for median smoothing - const minPhaseSec = opts.minPhaseSec ?? 180; // a phase must persist ≥3 min - if (votes.length === 0) return { primary: 'other', segments: [], type_confidence: 0 }; - const sorted = [...votes].sort((a, b) => a.ts - b.ts); - - // Median-class smoothing over a sliding window (hysteresis against momentary blips). - const smoothed: ClassVote[] = sorted.map((v, i) => { - const half = Math.floor(smoothWin / 2); - const win = sorted.slice(Math.max(0, i - half), Math.min(sorted.length, i + half + 1)); - return { ts: v.ts, cls: modeClass(win), conf: v.conf }; - }); - - // Run-length encode into raw phases. - const raw: WorkoutSegment[] = []; - for (const v of smoothed) { - const last = raw[raw.length - 1]; - if (last && last.type === v.cls) { - last.end_ts = v.ts; - last.confidence = (last.confidence + v.conf) / 2; - } else { - raw.push({ start_ts: v.ts, end_ts: v.ts, type: v.cls, confidence: v.conf }); - } - } - - // Merge phases shorter than minPhaseSec into the neighbour (drop noise blips). - const phases: WorkoutSegment[] = []; - for (const seg of raw) { - const dur = seg.end_ts - seg.start_ts; - if (dur < minPhaseSec && phases.length > 0) { - phases[phases.length - 1].end_ts = seg.end_ts; // absorb blip into prior phase - } else if (dur < minPhaseSec && phases.length === 0) { - phases.push({ ...seg }); // keep until a real phase appears - } else { - if (phases.length && phases[phases.length - 1].type === seg.type) - phases[phases.length - 1].end_ts = seg.end_ts; - else phases.push({ ...seg }); - } - } - - // Primary = longest-duration phase; 'mixed' if the top phase is <50% of total. - const totalDur = phases.reduce((s, p) => s + (p.end_ts - p.start_ts), 0) || 1; - let top = phases[0]; - for (const p of phases) if ((p.end_ts - p.start_ts) > (top.end_ts - top.start_ts)) top = p; - const topShare = (top.end_ts - top.start_ts) / totalDur; - const primary: ActivityClass = topShare >= 0.5 ? top.type : 'other'; - const type_confidence = Math.round(top.confidence * topShare * 100) / 100; - - return { primary, segments: phases, type_confidence }; -} diff --git a/src/hrv.ts b/src/hrv.ts deleted file mode 100644 index 3d3e04d..0000000 --- a/src/hrv.ts +++ /dev/null @@ -1,296 +0,0 @@ -// §HRV — heart-rate variability from beat-to-beat RR intervals. All published, -// standard algorithms (no heuristics): -// • time-domain: RMSSD, SDNN, pNN50 — Task Force of ESC/NASPE, Circulation 1996 -// • frequency-domain: LF/HF via the LOMB–SCARGLE periodogram (RR is unevenly -// sampled, so an FFT is wrong) — Laguna, Moody & Mark, IEEE TBME 1998 -// • Baevsky Stress Index (SI) from the RR histogram — Baevsky & Berseneva 2008 -// • respiratory rate from respiratory sinus arrhythmia (HF peak) — Charlton -// et al., Physiol Meas 2016 -// -// Inputs are a TIME-ORDERED RR-interval stream in milliseconds. We artifact-filter -// inside (physiological 300–2000 ms; successive |Δ| ≤ 200 ms drops ectopics/misses) -// so callers can pass the raw decoded RR. -// -// RR is decoded from type-24 records (parse_r24 rr_intervals_ms) and validated on -// real hardware (99.7% physiological; p50≈860 ms ≈ 70 bpm). This is NOT the parked -// R17/R11 live-optical path — it's the historical RR that ships in every record. -import { round, mean, stddev, median } from './util'; -import type { Metric, HrvStabilityValue, IrregularValue } from './types'; - -/** Standard frequency bands (Hz) per the HRV Task Force (1996). */ -export const VLF_BAND: [number, number] = [0.0033, 0.04]; -export const LF_BAND: [number, number] = [0.04, 0.15]; -export const HF_BAND: [number, number] = [0.15, 0.4]; - -export interface TimeDomainHrv { - rmssd: number | null; // ms — short-term parasympathetic index (primary) - sdnn: number | null; // ms — overall variability - pnn50: number | null; // % of successive diffs > 50 ms - mean_rr: number | null; - mean_hr: number | null; - n_beats: number; -} - -export interface FreqDomainHrv { - lf: number | null; // ms² absolute power in LF band - hf: number | null; // ms² absolute power in HF band - lf_hf: number | null; // sympatho-vagal balance - total_power: number | null; - resp_rate: number | null; // breaths/min from the HF peak (RSA) - resp_conf: number; // 0..1 — HF peak prominence -} - -/** Filter an RR stream to physiological intervals with successive-difference - * artifact rejection. Returns the cleaned, still time-ordered RR (ms). */ -export function cleanRr(rr: number[]): number[] { - const physio = rr.filter((x) => x >= 300 && x <= 2000); - if (physio.length < 2) return physio; - const out: number[] = [physio[0]]; - for (let i = 1; i < physio.length; i++) { - // keep a beat only if it's within 200 ms of the previous kept beat (drops - // ectopics / missed-beat doubles that would corrupt RMSSD). - if (Math.abs(physio[i] - out[out.length - 1]) <= 200) out.push(physio[i]); - } - return out; -} - -/** Time-domain HRV (RMSSD/SDNN/pNN50). Needs ≥ ~20 beats to be meaningful. */ -export function timeDomainHrv(rrRaw: number[]): TimeDomainHrv { - const rr = cleanRr(rrRaw); - const n = rr.length; - if (n < 20) return { rmssd: null, sdnn: null, pnn50: null, mean_rr: null, mean_hr: null, n_beats: n }; - - const meanRr = rr.reduce((a, b) => a + b, 0) / n; - const varNn = rr.reduce((a, b) => a + (b - meanRr) * (b - meanRr), 0) / (n - 1); - const sdnn = Math.sqrt(varNn); - let sumSq = 0, nn50 = 0; - for (let i = 1; i < n; i++) { - const d = rr[i] - rr[i - 1]; - sumSq += d * d; - if (Math.abs(d) > 50) nn50++; - } - const rmssd = Math.sqrt(sumSq / (n - 1)); - const pnn50 = (nn50 / (n - 1)) * 100; - - return { - rmssd: round(rmssd, 1), - sdnn: round(sdnn, 1), - pnn50: round(pnn50, 1), - mean_rr: round(meanRr, 1), - mean_hr: round(60000 / meanRr, 1), - n_beats: n, - }; -} - -/** - * Lomb–Scargle periodogram power in [fLo,fHi) for an unevenly-sampled series - * (t in seconds, x detrended). Classic estimator (Lomb 1976 / Scargle 1982): - * P(ω) = 1/2 [ (Σ x_i cos ω(t_i−τ))² / Σ cos²ω(t_i−τ) - * + (Σ x_i sin ω(t_i−τ))² / Σ sin²ω(t_i−τ) ] - * with tan(2ωτ) = Σ sin 2ωt_i / Σ cos 2ωt_i. We sum P·Δf over the band to get - * absolute band power (ms²), and also return the peak (freq, power) in-band. - */ -function lombScargleBand( - t: number[], x: number[], fLo: number, fHi: number, df: number, -): { power: number; peakFreq: number; peakPower: number } { - let power = 0, peakPower = -1, peakFreq = 0; - for (let f = fLo; f < fHi; f += df) { - const w = 2 * Math.PI * f; - let s2 = 0, c2 = 0; - for (const ti of t) { s2 += Math.sin(2 * w * ti); c2 += Math.cos(2 * w * ti); } - const tau = Math.atan2(s2, c2) / (2 * w); - let xc = 0, xs = 0, cc = 0, ss = 0; - for (let i = 0; i < t.length; i++) { - const arg = w * (t[i] - tau); - const cosv = Math.cos(arg), sinv = Math.sin(arg); - xc += x[i] * cosv; xs += x[i] * sinv; - cc += cosv * cosv; ss += sinv * sinv; - } - const p = 0.5 * ((cc > 0 ? (xc * xc) / cc : 0) + (ss > 0 ? (xs * xs) / ss : 0)); - power += p * df; - if (p > peakPower) { peakPower = p; peakFreq = f; } - } - return { power, peakFreq, peakPower }; -} - -/** Frequency-domain HRV + respiratory rate (RSA) via Lomb–Scargle. */ -export function freqDomainHrv(rrRaw: number[]): FreqDomainHrv { - const rr = cleanRr(rrRaw); - const none: FreqDomainHrv = { lf: null, hf: null, lf_hf: null, total_power: null, resp_rate: null, resp_conf: 0 }; - if (rr.length < 30) return none; - - // Build the RR tachogram: cumulative beat time (s) vs detrended RR (ms). - const t: number[] = []; - let acc = 0; - for (const r of rr) { acc += r / 1000; t.push(acc); } - const mean = rr.reduce((a, b) => a + b, 0) / rr.length; - const x = rr.map((r) => r - mean); - const span = t[t.length - 1] - t[0]; - // Task Force 1996 rule: a band needs ≥10× the wavelength of its lower bound. - // HF lower bound 0.15 Hz → ≥~67 s; LF lower bound 0.04 Hz → ≥250 s (~4 min). - // So HF/resp are valid from ~1 min, but LF (and therefore LF/HF) are NOT - // trustworthy below ~250 s — report them null rather than ship spectral noise. - if (span < 60) return none; - const HF_MIN_SPAN = 60; - const LF_MIN_SPAN = 250; - const df = 0.005; // 5 mHz grid - - const hfBand = lombScargleBand(t, x, HF_BAND[0], HF_BAND[1], df); - const lfValid = span >= LF_MIN_SPAN; - const lf = lfValid ? lombScargleBand(t, x, LF_BAND[0], LF_BAND[1], df).power : null; - const vlf = lfValid ? lombScargleBand(t, x, VLF_BAND[0], VLF_BAND[1], df).power : null; - const total = lf != null && vlf != null ? vlf + lf + hfBand.power : null; - - // Respiratory rate = HF peak frequency × 60 (breaths/min). Confidence = how - // dominant that peak is vs the mean HF power (prominence). Valid from ~1 min. - const hfValid = span >= HF_MIN_SPAN; - const meanHf = hfBand.power / ((HF_BAND[1] - HF_BAND[0]) / df); - const prominence = meanHf > 0 ? hfBand.peakPower / meanHf : 0; - const respConf = hfValid ? Math.max(0, Math.min(1, (prominence - 1) / 4)) : 0; // ~1×→0, ~5×→1 - const respRate = hfBand.peakFreq * 60; - - return { - lf: lf == null ? null : round(lf, 1), - hf: round(hfBand.power, 1), - lf_hf: lf != null && hfBand.power > 0 ? round(lf / hfBand.power, 3) : null, - total_power: total == null ? null : round(total, 1), - resp_rate: respConf >= 0.3 ? round(respRate, 1) : null, - resp_conf: round(respConf, 3), - }; -} - -/** - * Baevsky Stress Index (SI) from the RR histogram (Baevsky & Berseneva 2008): - * SI = AMo / (2 · Mo · MxDMn) - * Mo = mode (most frequent RR), in SECONDS (50 ms bins) - * AMo = amplitude of mode = % of RR in the modal bin - * MxDMn= variation range (max − min RR), in SECONDS - * Higher SI ⇒ greater sympathetic activation. We report SI and its square root - * (the commonly-reported, more linear form). Pure RR; not a heuristic. - */ -export function baevskyStressIndex(rrRaw: number[]): { si: number | null; sqrt_si: number | null; n_beats: number } { - const rr = cleanRr(rrRaw); - if (rr.length < 30) return { si: null, sqrt_si: null, n_beats: rr.length }; - const BIN = 50; // ms - const bins = new Map(); - let max = -Infinity, min = Infinity; - for (const r of rr) { - const b = Math.round(r / BIN) * BIN; - bins.set(b, (bins.get(b) ?? 0) + 1); - if (r > max) max = r; - if (r < min) min = r; - } - let modeBin = 0, modeCount = 0; - for (const [b, c] of bins) if (c > modeCount) { modeCount = c; modeBin = b; } - const Mo = modeBin / 1000; // s - const AMo = (modeCount / rr.length) * 100; // % - const MxDMn = (max - min) / 1000; // s - if (Mo <= 0 || MxDMn <= 0) return { si: null, sqrt_si: null, n_beats: rr.length }; - const si = AMo / (2 * Mo * MxDMn); - return { si: round(si, 1), sqrt_si: round(Math.sqrt(si), 2), n_beats: rr.length }; -} - -/** - * calcHrvStability(rmssdSeries) — coefficient of variation of nocturnal RMSSD over - * a window (SD/mean × 100). A low, stable CV tracks consistent autonomic balance; - * a rising CV flags instability. Needs ≥5 nights. Tier HIGH. - */ -export function calcHrvStability(rmssdSeries: number[]): Metric { - const xs = rmssdSeries.filter((x) => x != null && x > 0); - if (xs.length < 5) { - return { cv: null, mean_rmssd: null, n: xs.length, confidence: round(xs.length / 7, 3), tier: 'HIGH', inputs_used: ['hrv_rmssd'] }; - } - const m = mean(xs), sd = stddev(xs); - return { - cv: m > 0 ? round((sd / m) * 100, 1) : null, - mean_rmssd: round(m, 1), - n: xs.length, - confidence: round(Math.min(1, xs.length / 14), 3), - tier: 'HIGH', - inputs_used: ['hrv_rmssd'], - }; -} - -/** - * calcIrregular(rrRaw) — irregular-rhythm SCREEN (NOT a diagnosis). From nocturnal - * RR we compute the Poincaré descriptors (SD1 = RMSSD/√2, SD2 = √(2·SDNN² − ½·RMSSD²)) - * and the fraction of beats the artifact filter rejects as ectopic/irregular. A - * sustained high ectopic fraction together with very high short-term variability - * (pNN50) is the AF-like pattern. Deliberately conservative; surfaced like the - * illness watch ("a screen, not a diagnosis — see a clinician"). - */ -export function calcIrregular(rrRaw: number[]): Metric { - const NOTE = 'a screen, not a diagnosis'; - const physio = rrRaw.filter((x) => x >= 300 && x <= 2000); - const cleaned = cleanRr(rrRaw); - const td = timeDomainHrv(rrRaw); - if (physio.length < 100 || td.rmssd == null || td.sdnn == null || td.pnn50 == null) { - return { flag: false, sd1: null, sd2: null, ratio: null, pnn50: td.pnn50, ectopic_frac: null, note: NOTE, confidence: 0, tier: 'ESTIMATE', inputs_used: [] }; - } - const sd1 = td.rmssd / Math.SQRT2; - const sd2 = Math.sqrt(Math.max(0, 2 * td.sdnn * td.sdnn - 0.5 * td.rmssd * td.rmssd)); - const ratio = sd2 > 0 ? sd1 / sd2 : null; - const ectopicFrac = physio.length > 0 ? 1 - cleaned.length / physio.length : 0; - // Conservative AF-like pattern: a fifth+ of beats irregular AND very high pNN50. - const flag = ectopicFrac > 0.20 && td.pnn50 > 30 && sd1 > 60; - return { - flag, - sd1: round(sd1, 1), sd2: round(sd2, 1), - ratio: ratio == null ? null : round(ratio, 2), - pnn50: td.pnn50, - ectopic_frac: round(ectopicFrac, 3), - note: NOTE, - confidence: round(Math.min(1, physio.length / 300), 3), - tier: 'ESTIMATE', - inputs_used: ['rr_intervals'], - }; -} - -/** §Daytime HRV — waking-hours RMSSD timeline (ultradian autonomic rhythm). */ -export interface DaytimeHrvValue { - rmssd_median: number | null; // median of per-window RMSSD across the day - series: { ts: number; rmssd: number }[]; // per-window RMSSD (ultradian rhythm / stress timeline) - lowest_ts: number | null; // window with the lowest RMSSD (most-stressed point) - n_windows: number; -} - -/** - * calcDaytimeHrv(byMinute, bucketSec=300) — HRV across the WAKING day (not just sleep). - * `byMinute` is per-minute RR arrays (ms) over the daytime window. We bucket RR into - * fixed windows (default 5 min) and run the standard time-domain RMSSD per window with - * enough beats → an ultradian HRV / daytime-stress timeline. Tier HIGH (published - * RMSSD); abstains (null) when too few windows have usable RR — no fabrication. - */ -export function calcDaytimeHrv( - byMinute: { ts: number; rr: number[] }[], - bucketSec = 300, -): Metric { - const buckets = new Map(); - for (const m of byMinute) { - if (!m.rr || m.rr.length === 0) continue; - const key = Math.floor(m.ts / bucketSec); - const b = buckets.get(key) ?? { ts: key * bucketSec, rr: [] }; - for (const v of m.rr) b.rr.push(v); - buckets.set(key, b); - } - const series: { ts: number; rmssd: number }[] = []; - for (const b of [...buckets.values()].sort((a, c) => a.ts - c.ts)) { - const td = timeDomainHrv(b.rr); - if (td.rmssd != null) series.push({ ts: b.ts, rmssd: td.rmssd }); - } - if (series.length < 3) { - return { rmssd_median: null, series, lowest_ts: null, n_windows: series.length, confidence: 0, tier: 'HIGH', inputs_used: ['rr_intervals'] }; - } - const vals = series.map((s) => s.rmssd); - let lowest = series[0]; - for (const s of series) if (s.rmssd < lowest.rmssd) lowest = s; - return { - rmssd_median: round(median(vals) ?? 0, 1), - series, - lowest_ts: lowest.ts, - n_windows: series.length, - confidence: round(Math.min(1, series.length / 24), 3), // ~2 h of windows → full - tier: 'HIGH', - inputs_used: ['rr_intervals'], - }; -} diff --git a/src/illness.ts b/src/illness.ts deleted file mode 100644 index e429fe3..0000000 --- a/src/illness.ts +++ /dev/null @@ -1,169 +0,0 @@ -// §Illness — multivariate under-recovery / illness signal. Combines the signals -// that co-move when the body is fighting something: resting HR ↑, nocturnal HRV -// (RMSSD) ↓, skin temperature ↑, and respiratory rate ↑. We compute the -// MAHALANOBIS distance (Mahalanobis 1936) of today's vector from the user's -// personal baseline mean + covariance — one scalar that accounts for how these -// normally co-vary, rather than independent flags. Validated approach for -// wearable illness detection (Mishra et al., Nat Biomed Eng 2020; Smarr et al., -// Sci Rep 2020); elevated nocturnal respiratory rate is among the earliest -// infection signals (Mishra 2020; Natarajan, Lancet Digit Health 2020). -// A SIGNAL, NOT A DIAGNOSIS — and only fires when deviations are in the illness -// direction (not just "unusual"). -// -// Cycle-aware (honest, no fabrication): for users tracking their menstrual cycle, -// a rise in skin temperature and resting HR through the luteal phase is normal -// physiology (Wilcox 2000), not illness. When the only deviations are those two -// phase-expected signals, we do NOT flag illness — we still fire if HRV or -// respiratory rate also shift (signals not explained by the cycle). -import type { Metric, IllnessValue, Driver } from './types'; -import type { CyclePhase } from './cycle'; -import { mean, stddev, round } from './util'; - -export interface IllnessToday { - resting_hr: number | null; - rmssd: number | null; // nocturnal RMSSD (ms) - skin_temp: number | null; // RELATIVE temp index - resp_rate?: number | null; // nocturnal respiratory rate (breaths/min) -} -export interface IllnessHistory { - resting_hr: number[]; - rmssd: number[]; - skin_temp: number[]; - resp_rate?: number[]; -} -export interface IllnessOpts { - /** Current menstrual-cycle phase, if the user tracks it — gates phase-expected - * RHR/temp rises so they don't masquerade as illness. */ - cyclePhase?: CyclePhase | null; -} - -/** Invert a symmetric N×N matrix via Gauss-Jordan with partial pivoting; null if - * singular. Handles the 2-, 3- and 4-feature cases uniformly. */ -function invMatrix(m: number[][]): number[][] | null { - const n = m.length; - const a = m.map((row, i) => [...row, ...Array.from({ length: n }, (_, j) => (i === j ? 1 : 0))]); - for (let col = 0; col < n; col++) { - let piv = col; - for (let r = col + 1; r < n; r++) if (Math.abs(a[r][col]) > Math.abs(a[piv][col])) piv = r; - if (Math.abs(a[piv][col]) < 1e-12) return null; - [a[col], a[piv]] = [a[piv], a[col]]; - const d = a[col][col]; - for (let j = 0; j < 2 * n; j++) a[col][j] /= d; - for (let r = 0; r < n; r++) { - if (r === col) continue; - const f = a[r][col]; - for (let j = 0; j < 2 * n; j++) a[r][j] -= f * a[col][j]; - } - } - return a.map((row) => row.slice(n)); -} - -// Signals whose elevation is expected through the luteal/menstrual phase — not illness. -const CYCLE_EXPECTED = new Set(['rhr', 'temp']); - -/** - * calcIllness(today, history, opts?). Needs ≥7 days of baseline per feature and - * ≥2 present features. Distance threshold 2.5 (≈ χ² 95th pct). Tier ESTIMATE; - * "a signal, not a diagnosis." When opts.cyclePhase is luteal/menstruation and the - * only deviating signals are RHR/temp, the signal is suppressed (phase-expected). - */ -export function calcIllness( - today: IllnessToday, - history: IllnessHistory, - opts?: IllnessOpts, -): Metric { - const NOTE = 'a signal, not a diagnosis'; - // Build aligned feature set from whatever is present today + has ≥7 baseline. - type Feat = { key: string; label: string; today: number; hist: number[]; dir: 1 | -1 }; - const cand: Feat[] = []; - if (today.resting_hr != null && history.resting_hr.length >= 7) - cand.push({ key: 'rhr', label: 'Resting HR', today: today.resting_hr, hist: history.resting_hr, dir: 1 }); - if (today.rmssd != null && history.rmssd.length >= 7) - cand.push({ key: 'rmssd', label: 'HRV (RMSSD)', today: today.rmssd, hist: history.rmssd, dir: -1 }); - if (today.skin_temp != null && history.skin_temp.length >= 7) - cand.push({ key: 'temp', label: 'Skin temperature', today: today.skin_temp, hist: history.skin_temp, dir: 1 }); - if (today.resp_rate != null && (history.resp_rate?.length ?? 0) >= 7) - cand.push({ key: 'resp', label: 'Respiratory rate', today: today.resp_rate, hist: history.resp_rate!, dir: 1 }); - - const none = (): Metric => ({ - signal: false, distance: null, triggers: [], note: NOTE, - confidence: 0, tier: 'ESTIMATE', inputs_used: [], - }); - if (cand.length < 2) return none(); - - // Per-feature z (in the illness direction: dir·(x−μ)/σ; positive = toward illness). - const z = cand.map((f) => { - const mu = mean(f.hist), sd = stddev(f.hist); - return sd > 0 ? f.dir * (f.today - mu) / sd : 0; - }); - - const lens = cand.map((f) => f.hist.length); - const minLen = Math.min(...lens); - let distance: number; - const drivers: Driver[] = []; - const dim = cand.length; - const dvec = z; // standardized deviation vector - if (dim >= 2 && minLen >= 7) { - // Correlation matrix over the overlapping tail (standardized → correlation). - const tail = cand.map((f) => f.hist.slice(-minLen)); - const stds = tail.map((h) => ({ mu: mean(h), sd: stddev(h) || 1 })); - const Z = tail.map((h, k) => h.map((v) => (v - stds[k].mu) / stds[k].sd)); - const corr: number[][] = []; - for (let a = 0; a < dim; a++) { - corr[a] = []; - for (let b = 0; b < dim; b++) { - let s = 0; for (let t = 0; t < minLen; t++) s += Z[a][t] * Z[b][t]; - corr[a][b] = s / (minLen - 1); - } - } - // Mahalanobis² = dvecᵀ · C⁻¹ · dvec (using correlation since dvec is already z). - const inv = invMatrix(corr); - if (inv) { - let d2 = 0; - for (let a = 0; a < dim; a++) for (let b = 0; b < dim; b++) d2 += dvec[a] * inv[a][b] * dvec[b]; - distance = Math.sqrt(Math.max(0, d2)); - } else { - distance = Math.sqrt(dvec.reduce((s, v) => s + v * v, 0)); // diag fallback - } - } else { - distance = Math.sqrt(dvec.reduce((s, v) => s + v * v, 0)); - } - - const metricFor = (key: string): string => - key === 'rmssd' ? 'hrv' : key === 'rhr' ? 'rhr' : key === 'resp' ? 'resp' : 'temp'; - const inputName = (key: string): string => - key === 'rmssd' ? 'hrv_rmssd' : key === 'rhr' ? 'resting_hr' : key === 'resp' ? 'resp_rate' : 'skin_temp'; - const triggers: string[] = []; - cand.forEach((f, k) => { - if (z[k] > 0.75) { - triggers.push(f.key); - drivers.push({ - label: f.label, contribution: round(z[k], 2), detail: `${round(z[k], 1)}σ toward illness`, - ref: { metric: metricFor(f.key), scale: 'day' }, - }); - } - }); - - // Signal: distance past threshold AND ≥2 features deviating toward illness. - let signal = distance > 2.5 && triggers.length >= 2; - let note = NOTE; - // Cycle-aware gate: through the luteal/menstrual phase, a temp & RHR rise is - // expected. If those are the ONLY deviations, don't flag illness — but still - // fire when HRV or respiration also shift (not explained by the cycle). - const inCyclePhase = opts?.cyclePhase === 'luteal' || opts?.cyclePhase === 'menstruation'; - if (signal && inCyclePhase) { - const corroborating = triggers.filter((t) => !CYCLE_EXPECTED.has(t)); - if (corroborating.length === 0) { - signal = false; - note = `${NOTE} (a rise in temperature & resting HR can be expected in this phase of your cycle)`; - } - } - const confidence = Math.min(0.6, (minLen / 30) * (cand.length / 4)); - - return { - signal, distance: round(distance, 2), triggers, note, - confidence: round(confidence, 4), tier: 'ESTIMATE', - inputs_used: cand.map((f) => inputName(f.key)), - drivers, - }; -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 4c3954b..0000000 --- a/src/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -// openstrap-analytics — public surface. -// Pure, deterministic, spec-aligned. Every metric is a published algorithm. -// HRV is derived from real type-24 RR intervals (validated on hardware). - -export * from './types'; - -// utilities (handy for callers building their own pipelines) -export { resolveMaxHr, percentile, median, clamp, mean, stddev, linregSlope } from './util'; - -// §1 Resting HR -export { calcRestingHR } from './resting'; -export type { SleepWindow } from './resting'; - -// §2 Strain -export { calcStrain } from './strain'; - -// §3 HR zones -export { calcHrZones } from './zones'; - -// §4 Calories -export { calcCalories } from './calories'; - -// §5 Sleep -export { calcSleep } from './sleep'; -// §5b Sleep v2 — multi-period (naps = shorter sleeps). Additive; calcSleep unchanged. -export { calcSleepPeriods } from './sleep'; -// Per-minute asleep/awake mask (calcSleep's boundary) — reconciles the hypnogram's awake. -export { sleepAwakeMask } from './sleep'; -// v1-method per-minute hypnogram (Cole-Kripke + HR-percentile bands) — single source. -export { stageHypnogram } from './sleep'; -export type { NightHypnogram } from './sleep'; -// §Sleep cycles — ultradian NREM↔REM cycles (Rosenblum 2024 fractal-cycle method, HRV-adapted). -export { detectSleepCycles } from './cycles'; -export type { SleepCycle, SleepCyclesValue } from './cycles'; - -// §Menstrual cycle — log-anchored calendar method + fertile window (Wilcox 2000). -export { calcCycle } from './cycle'; -export type { CycleValue, CyclePhase } from './cycle'; - -// §6 Sleep regularity (SRI) -export { calcSleepRegularity } from './regularity'; - -// §7 Auto-workout detection -export { detectSessions } from './sessions'; - -// §8 HR recovery (HRR60) + HRV-based recovery (Plews lnRMSSD z-score) -export { calcHrRecovery, calcRecovery } from './recovery'; - -// §HRV — RMSSD/SDNN/pNN50, Lomb–Scargle LF/HF, Baevsky SI, RSA respiratory rate -export { - timeDomainHrv, freqDomainHrv, baevskyStressIndex, cleanRr, - calcHrvStability, calcIrregular, calcDaytimeHrv, - VLF_BAND, LF_BAND, HF_BAND, -} from './hrv'; -export type { TimeDomainHrv, FreqDomainHrv, DaytimeHrvValue } from './hrv'; - -// §9 Training load / fitness trend -export { calcLoad, calcFitnessTrend } from './trends'; - -// §Fitness — VO₂max (Uth–Sørensen), Banister fitness/fatigue/form, Foster monotony -export { calcVo2Max, calcFitnessModel, calcMonotony } from './fitness'; - -// §Steps — AN-2554 wrist pedometer (pure math; backend re-decodes the IMU + runs it) -export { calcSteps, pedometer, STEP_PARAMS } from './steps'; - -// §HAR — wrist activity recognition (Mannini 2013): per-window features + classifier -// + workout segmentation. LIVE high-rate stream only (flash is 1 Hz). -export { - extractHarFeatures, extractHarFeaturesFromSmv, classifyActivityWindow, segmentWorkout, - dwtDetailEnergies, DB10_LO, -} from './har'; -export type { - HarFeatures, ClassVote, WorkoutSegment, SegmentResult, -} from './har'; - -// §Circadian — CircaCP cosinor + bounded change-point (physiological-day anchor) -export { calcCircadian, stageSleep } from './circadian'; -export type { CircadianOpts, SleepStaging } from './circadian'; - -// §Sleep/wake ENSEMBLE — pluggable voters (Cole-Kripke + cardiac/CPD + van Hees); -// drives the demand-driven day-close trigger. detectWakeState + cheap peekRecentState. -export { detectWakeState, peekRecentState, coleKripke, cardiac, inactivity, DEFAULT_VOTERS } from './wake'; -export type { WakeContext, WakeState, WakeLabel, Voter } from './wake'; - -// §Composite Readiness — weighted HRV + sleep blend (abstains without HRV) -export { calcReadinessIndex } from './readiness_index'; -export type { ReadinessInputs } from './readiness_index'; - -// §10 Anomaly + illness (Mahalanobis). calcReadiness REMOVED (heuristic) — use -// calcRecovery (HRV) instead. -export { calcAnomaly } from './readiness'; -export type { AnomalyInputs, AnomalyOpts } from './readiness'; -export { calcIllness } from './illness'; -export type { IllnessToday, IllnessHistory, IllnessOpts } from './illness'; - -// §11 Baselines -export { calcBaselines } from './baselines'; - -// Activity metric (steps / active-sedentary) REMOVED in v0 — see activity.ts. - -// Coaching engine (deterministic, no AI) — signals → ranked plan + strain target. -export { buildCoach } from './coach'; -export type { - CoachInputs, CoachOutput, Suggestion, Contributor, Why, -} from './coach'; - -// §12 Stress — HRV-based (Baevsky Stress Index + LF/HF, personal-relative). -export { calcStress } from './stress'; - -// §SpO₂ — RELATIVE blood-oxygen index + overnight desaturation screen. -export { calcSpo2Index, calcDesaturation } from './spo2'; -export type { Spo2Value, DesaturationValue } from './spo2'; - -// §Sleep stress / nocturnal arousal (HR surge + motion during sleep). -export { calcSleepStress } from './arousal'; - -// §Restlessness — nocturnal movement fragmentation from per-minute actigraphy. -export { calcRestlessness } from './restlessness'; -export type { RestlessnessValue } from './restlessness'; - -// §13 Nocturnal Heart (sleeping-HR dynamics + dip + elevated-overnight flag). -export { calcNocturnalHeart } from './nocturnal'; -export type { NocturnalValue } from './nocturnal'; - -// §14 Notification engine (deterministic per-user nudges from existing signals). -export { buildNotifications } from './notify'; -export type { - NotifyInputs, AppNotification, NotifyCategory, NotifyWindow, -} from './notify'; diff --git a/src/nocturnal.ts b/src/nocturnal.ts deleted file mode 100644 index e7af0af..0000000 --- a/src/nocturnal.ts +++ /dev/null @@ -1,98 +0,0 @@ -// §13 Nocturnal Heart — what your heart does while you sleep. The honest, -// data-grounded counterpart to "respiratory rate" (which needs PPG we usually -// don't have overnight). All from minute HR over the sleep window: -// • sleeping HR average + nadir (lowest sustained HR) and when it happened -// • nocturnal dip = how far sleeping HR falls below your waking HR (autonomic -// recovery; a bigger dip is generally healthier) -// • elevated-overnight-HR flag vs your own baseline (illness / under-recovery -// early-warning — a signal, not a diagnosis) -// Tier HIGH for the measured numbers (HR is authoritative); the interpretation is -// labelled. NOT a clinical metric. - -import type { Minute, Baseline, Metric } from './types'; -import { clamp, mean } from './util'; - -export interface NocturnalValue { - sleeping_hr_avg: number | null; // mean HR over worn sleep minutes - sleeping_hr_min: number | null; // nadir = lowest 5-min rolling mean HR - nadir_ts: number | null; // when the nadir occurred - day_hr_avg: number | null; // mean waking HR (context for the dip) - dip_pct: number | null; // (day - sleep)/day, 0..1 (autonomic recovery) - vs_baseline_bpm: number | null; // sleeping_hr_avg − baseline.sleeping_hr - elevated: boolean; // sleeping HR notably above baseline -} - -/** - * calcNocturnalHeart(sleepMinutes, dayMinutes, baseline) - * - * sleepMinutes: worn minutes within the main sleep period (onset..wake). - * dayMinutes: worn minutes of the WAKING day (for the dip denominator). - * baseline.sleeping_hr: rolling baseline sleeping HR (null until established). - * - * Elevated = sleeping_hr_avg ≥ baseline + 4 bpm (and ≥ +5%). Confidence = coverage - * of sleep minutes that carry HR (clamp(n/180)). - */ -export function calcNocturnalHeart( - sleepMinutes: Minute[], - dayMinutes: Minute[], - baseline: Baseline & { sleeping_hr?: number | null }, -): Metric { - const sleepHrs = sleepMinutes - .filter((m) => m.wrist_on && m.hr_avg > 0) - .sort((a, b) => a.ts - b.ts); - - const empty = (): Metric => ({ - sleeping_hr_avg: null, sleeping_hr_min: null, nadir_ts: null, - day_hr_avg: null, dip_pct: null, vs_baseline_bpm: null, elevated: false, - confidence: 0, tier: 'HIGH', inputs_used: [], - }); - if (sleepHrs.length === 0) return empty(); - - const hrVals = sleepHrs.map((m) => m.hr_avg); - const sleepingHrAvg = Math.round(mean(hrVals)); - - // Nadir = lowest 5-min rolling mean (avoids a single artefactual low beat). - let nadir: { ts: number; v: number } | null = null; - const W = 5; - if (sleepHrs.length >= W) { - for (let i = 0; i + W <= sleepHrs.length; i++) { - const win = sleepHrs.slice(i, i + W); - const m = mean(win.map((x) => x.hr_avg)); - if (!nadir || m < nadir.v) { - nadir = { ts: sleepHrs[i + Math.floor(W / 2)].ts, v: m }; - } - } - } else { - const lo = sleepHrs.reduce((p, c) => (c.hr_avg < p.hr_avg ? c : p)); - nadir = { ts: lo.ts, v: lo.hr_avg }; - } - - const dayHr = dayMinutes.filter((m) => m.wrist_on && m.hr_avg > 0).map((m) => m.hr_avg); - const dayHrAvg = dayHr.length ? Math.round(mean(dayHr)) : null; - const dipPct = (dayHrAvg && dayHrAvg > 0) - ? Math.round(clamp((dayHrAvg - sleepingHrAvg) / dayHrAvg, 0, 1) * 1000) / 1000 - : null; - - const baseSleepHr = (baseline.sleeping_hr && baseline.sleeping_hr > 0) - ? baseline.sleeping_hr : null; - const vsBaseline = baseSleepHr != null ? Math.round((sleepingHrAvg - baseSleepHr) * 10) / 10 : null; - const elevated = baseSleepHr != null - && sleepingHrAvg >= baseSleepHr + 4 - && sleepingHrAvg >= baseSleepHr * 1.05; - - const coverage = clamp(sleepHrs.length / 180, 0, 1); - - return { - sleeping_hr_avg: sleepingHrAvg, - sleeping_hr_min: nadir ? Math.round(nadir.v) : null, - nadir_ts: nadir ? nadir.ts : null, - day_hr_avg: dayHrAvg, - dip_pct: dipPct, - vs_baseline_bpm: vsBaseline, - elevated, - confidence: Math.round(coverage * 1000) / 1000, - tier: 'HIGH', - inputs_used: ['hr_avg', 'sleep.onset_ts', 'sleep.wake_ts', - ...(baseSleepHr != null ? ['baseline.sleeping_hr'] : [])], - }; -} diff --git a/src/notify.ts b/src/notify.ts deleted file mode 100644 index baaa88c..0000000 --- a/src/notify.ts +++ /dev/null @@ -1,155 +0,0 @@ -// §14 Notification engine — deterministic (NO AI) per-user nudges derived from -// the SAME signals the coach/alerts already compute. The server generates them; -// the client pulls + presents them (local notifications + in-app inbox). This -// keeps ALL intelligence server-side and avoids any closed push dependency. -// -// Each notification is idempotent by id = `${date}:${kind}` so regenerating a day -// converges (no duplicates). The engine is honest — it only fires on real signals -// and inherits the "signal, not a diagnosis" framing for health cues. - -export interface NotifyInputs { - date: string; // YYYY-MM-DD - readiness: number | null; - coach_summary: string; - coach_top: { title: string; body: string } | null; - body_alert: { kind: string; note: string } | null; - stress_score: number | null; - nocturnal_elevated: boolean; - sleep_debt_min: number; - acwr: number | null; - strain_today: number | null; - strain_target_low: number | null; - strain_target_high: number | null; - // Optional engagement inputs (from records/streaks); empty when not supplied. - streaks?: { wear?: number; strain_target?: number; sleep?: number }; - new_records?: string[]; // human labels of PRs set on this date -} - -export type NotifyCategory = 'recovery' | 'sleep' | 'load' | 'health' | 'activity' | 'milestone'; -export type NotifyWindow = 'morning' | 'midday' | 'evening' | 'any'; - -export interface AppNotification { - id: string; // `${date}:${kind}` — stable, idempotent - kind: string; - category: NotifyCategory; - priority: number; // 0 (fyi) .. 3 (urgent) - title: string; - body: string; - window: NotifyWindow; // preferred delivery window - quiet_ok: boolean; // safe to deliver during quiet hours (e.g. overnight) -} - -const MILESTONES = new Set([3, 7, 14, 21, 30, 50, 75, 100, 150, 200, 365]); -const MAX_NOTIFICATIONS = 6; - -const hm = (min: number): string => { - const m = Math.max(0, Math.round(min)); - const h = Math.floor(m / 60), r = m % 60; - if (h === 0) return `${r}m`; - if (r === 0) return `${h}h`; - return `${h}h ${r}m`; -}; - -/** - * buildNotifications(inputs) — deterministic ranked list (≤6) for one day. - * Pure: same inputs → identical output. Ordered by priority desc, then a fixed - * kind order so ties are stable. - */ -export function buildNotifications(i: NotifyInputs): AppNotification[] { - const out: AppNotification[] = []; - const push = (n: Omit) => - out.push({ id: `${i.date}:${n.kind}`, ...n }); - - // 1. Health signal (illness / overtraining / elevated overnight HR) — highest. - if (i.body_alert) { - const k = i.body_alert.kind; - const title = k === 'overtraining' ? 'High training load' - : k === 'both' ? 'Recovery + load signal' - : 'Recovery signal'; - push({ - kind: 'body_alert', category: 'health', priority: 3, window: 'morning', - quiet_ok: false, title, body: i.body_alert.note, - }); - } else if (i.nocturnal_elevated) { - // Standalone overnight-HR cue (when not already folded into body_alert). - push({ - kind: 'overnight_hr', category: 'health', priority: 3, window: 'morning', - quiet_ok: false, title: 'Overnight heart rate was high', - body: 'Your sleeping heart rate ran above your baseline — often an early cue of ' - + 'under-recovery or fighting something off. Consider an easier day. A signal, not a diagnosis.', - }); - } - - // 2. New personal records (celebrate) — high engagement value. - for (const label of i.new_records ?? []) { - push({ - kind: `record_${label.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`, - category: 'milestone', priority: 2, window: 'any', quiet_ok: false, - title: 'New personal record 🎉', body: `${label} — a new best. Nice work.`, - }); - } - - // 3. Morning readiness + today's plan. - if (i.readiness != null) { - const r = Math.round(i.readiness); - const tip = i.coach_top - ? `${i.coach_top.title}: ${i.coach_top.body}` - : (i.coach_summary || 'Carry on with your day.'); - push({ - kind: 'morning_readiness', category: 'recovery', priority: 1, window: 'morning', - quiet_ok: false, title: `Recovery ${r}/100`, body: tip, - }); - } - - // 4. Sleep debt nudge (evening). - if (i.sleep_debt_min >= 120) { - push({ - kind: 'sleep_debt', category: 'sleep', priority: 2, window: 'evening', - quiet_ok: false, - title: `You're carrying ${hm(i.sleep_debt_min)} of sleep debt`, - body: 'An earlier night would help you pay it down. Aim to wind down soon.', - }); - } - - // 5. High-arousal day (evening wind-down). - if (i.stress_score != null && i.stress_score >= 70) { - push({ - kind: 'high_stress', category: 'health', priority: 1, window: 'evening', - quiet_ok: false, title: 'A high-arousal day', - body: `Stress read ${Math.round(i.stress_score)}/100 — some downtime or slow breathing tonight could help you settle.`, - }); - } - - // 6. Strain target progress (midday) — only when we have room to push or are close. - if (i.strain_target_low != null && i.strain_today != null) { - if (i.strain_today < i.strain_target_low - 1) { - push({ - kind: 'strain_room', category: 'activity', priority: 0, window: 'midday', - quiet_ok: false, title: 'Room to move today', - body: `You're at ${i.strain_today.toFixed(1)} — your target is around ${i.strain_target_low.toFixed(0)}–${(i.strain_target_high ?? i.strain_target_low).toFixed(0)}.`, - }); - } - } - - // 7. Streak milestones (celebrate at meaningful counts). - const s = i.streaks ?? {}; - if (s.wear && MILESTONES.has(s.wear)) { - push({ - kind: 'streak_wear', category: 'milestone', priority: 1, window: 'any', - quiet_ok: false, title: `${s.wear}-day wear streak 🔥`, - body: `You've worn your strap ${s.wear} days running. Consistency is the whole game.`, - }); - } - if (s.strain_target && MILESTONES.has(s.strain_target)) { - push({ - kind: 'streak_strain', category: 'milestone', priority: 1, window: 'any', - quiet_ok: false, title: `${s.strain_target} days on target 🔥`, - body: `You've hit your strain target ${s.strain_target} days in a row.`, - }); - } - - // Rank: priority desc, then a stable kind order (insertion order as tiebreak). - const order = new Map(out.map((n, idx) => [n.id, idx])); - out.sort((a, b) => b.priority - a.priority || order.get(a.id)! - order.get(b.id)!); - return out.slice(0, MAX_NOTIFICATIONS); -} diff --git a/src/readiness.ts b/src/readiness.ts deleted file mode 100644 index e41d40a..0000000 --- a/src/readiness.ts +++ /dev/null @@ -1,100 +0,0 @@ -// §10 Anomaly signal (RHR-elevation rule). The old weighted "readiness" composite -// was REMOVED — recovery is now HRV-based (see calcRecovery in recovery.ts). This -// keeps only the simple, rule-based RHR anomaly (Radin et al., Lancet Digit Health -// 2020); the richer multivariate illness signal lives in illness.ts (Mahalanobis). -import type { Baseline, Metric, AnomalyValue } from './types'; -import type { CyclePhase } from './cycle'; -import { round } from './util'; - -export interface AnomalyInputs { - /** recent RHR series, most-recent LAST, used for the ≥2 consecutive-day rule */ - recent_rhr: number[]; - skin_temp?: number | null; // today RELATIVE - sleep_efficiency?: number | null; // today - baseline_sleep_efficiency?: number | null; // prior typical efficiency -} -export interface AnomalyOpts { - /** menstrual-cycle phase if tracked — gates phase-expected RHR rises */ - cyclePhase?: CyclePhase | null; -} - -/** - * calcAnomaly(inputs, baseline) - * - * Fires when RHR ≥ baseline+7% for ≥2 consecutive days, OR (RHR↑ AND temp - * Δ>+0.5 vs baseline AND sleep efficiency↓). Output boolean signal + which - * inputs triggered + "signal, not a diagnosis". Never alarmist. confidence ≤0.5. - * - * Confidence formula: ESTIMATE, capped at 0.5; scaled by how many distinct - * trigger conditions are evaluable (more corroborating signals → up to 0.5). - */ -export function calcAnomaly( - inputs: AnomalyInputs, - baseline: Baseline, - opts?: AnomalyOpts -): Metric { - let NOTE = 'signal, not a diagnosis'; - const triggers: string[] = []; - const used: string[] = []; - - const rhrThreshold = baseline.resting_hr * 1.07; - - // Rule A: RHR ≥ baseline+7% for ≥2 consecutive (trailing) days. - let consecutive = 0; - if (inputs.recent_rhr.length > 0) { - used.push('recent_rhr', 'baseline.resting_hr'); - for (let i = inputs.recent_rhr.length - 1; i >= 0; i--) { - if (inputs.recent_rhr[i] >= rhrThreshold) consecutive++; - else break; - } - } - const ruleA = consecutive >= 2; - if (ruleA) triggers.push('rhr_elevated_2d'); - - // Rule B: RHR↑ AND temp Δ>+0.5 AND sleep efficiency↓. - const latestRhr = inputs.recent_rhr.length - ? inputs.recent_rhr[inputs.recent_rhr.length - 1] - : null; - const rhrUp = latestRhr != null && latestRhr >= rhrThreshold; - let tempUp = false; - if (inputs.skin_temp != null && baseline.skin_temp != null) { - used.push('skin_temp', 'baseline.skin_temp'); - tempUp = inputs.skin_temp - baseline.skin_temp > 0.5; - } - let effDown = false; - if ( - inputs.sleep_efficiency != null && - inputs.baseline_sleep_efficiency != null - ) { - used.push('sleep_efficiency', 'baseline_sleep_efficiency'); - effDown = inputs.sleep_efficiency < inputs.baseline_sleep_efficiency; - } - const ruleB = rhrUp && tempUp && effDown; - if (ruleB) triggers.push('rhr_temp_efficiency'); - - // Cycle-aware gate: through the luteal/menstrual phase a RHR (and temp) rise is - // normal physiology. Suppress the pure-RHR rule A then; rule B keeps firing because - // its sleep-efficiency drop is NOT explained by the cycle. (Wilcox 2000.) - const inCyclePhase = opts?.cyclePhase === 'luteal' || opts?.cyclePhase === 'menstruation'; - const signal = (ruleA && !inCyclePhase) || ruleB; - if (ruleA && inCyclePhase && !ruleB) { - NOTE = 'signal, not a diagnosis (an elevated resting HR can be expected in this phase of your cycle)'; - } - - // Confidence scales with how many corroborating inputs were evaluable. - const evaluable = [ - inputs.recent_rhr.length >= 2, - inputs.skin_temp != null && baseline.skin_temp != null, - inputs.sleep_efficiency != null && inputs.baseline_sleep_efficiency != null, - ].filter(Boolean).length; - const confidence = Math.min(0.5, (evaluable / 3) * 0.5); - - return { - signal, - triggers, - note: NOTE, - confidence: round(confidence, 4), - tier: 'ESTIMATE', - inputs_used: Array.from(new Set(used)), - }; -} diff --git a/src/readiness_index.ts b/src/readiness_index.ts deleted file mode 100644 index 4272d3b..0000000 --- a/src/readiness_index.ts +++ /dev/null @@ -1,74 +0,0 @@ -// §Composite Readiness — a transparent, weighted blend of the autonomic + sleep -// signals we already compute. NOT a black box and NOT claiming to be WHOOP's score: -// it's an honest aggregate that abstains until HRV-recovery exists, and ships its -// component breakdown as drivers so the user sees exactly what moved it. -import type { Metric, ReadinessIndexValue, Driver } from './types'; -import { clamp, round } from './util'; - -export interface ReadinessInputs { - recovery: number | null; // 0..100 — Plews HRV recovery (the anchor) - sleepDurationMin: number | null; - sleepNeedMin: number | null; - dipPct: number | null; // nocturnal HR dip, fraction (0.10 = 10%) - sleepStress: number | null; // 0..100 nocturnal arousal load (higher = worse) -} - -// Weights (sum 1) over whatever components are present; recovery anchors it. -const W = { recovery: 0.5, sleep: 0.2, dip: 0.15, arousal: 0.15 }; - -/** - * calcReadinessIndex — weighted mean of the available components, renormalized - * over present weights. Abstains (score=null) if HRV-recovery is absent, because - * without the autonomic anchor the rest is just sleep accounting. - */ -export function calcReadinessIndex(inp: ReadinessInputs): Metric { - const components = { - recovery: inp.recovery, - sleep: (inp.sleepDurationMin != null && inp.sleepNeedMin && inp.sleepNeedMin > 0) - ? round(clamp((inp.sleepDurationMin / inp.sleepNeedMin) * 100, 0, 100), 0) : null, - // A nocturnal dip of ~10%+ is healthy → map 0..0.10 onto 0..100. - dip: inp.dipPct != null ? round(clamp((inp.dipPct / 0.10) * 100, 0, 100), 0) : null, - // Arousal is "worse when higher" → invert to a 0..100 calmness score. - arousal: inp.sleepStress != null ? round(clamp(100 - inp.sleepStress, 0, 100), 0) : null, - }; - - if (components.recovery == null) { - return { - score: null, components, - note: 'Building baseline — needs nocturnal HRV', - confidence: 0, tier: 'ESTIMATE', inputs_used: [], - }; - } - - let wsum = 0, acc = 0; - const used: string[] = []; - const drivers: Driver[] = []; - const add = (key: keyof typeof components, w: number, label: string) => { - const v = components[key]; - if (v == null) return; - wsum += w; acc += w * v; used.push(key); - drivers.push({ - label, - contribution: round((w * (v - 50)) / 50, 3), // signed vs neutral 50 - detail: `${v}/100`, - ref: { metric: key === 'recovery' ? 'recovery' : key === 'sleep' ? 'sleep' : 'hrv', scale: 'day' }, - }); - }; - add('recovery', W.recovery, 'HRV recovery'); - add('sleep', W.sleep, 'Sleep vs need'); - add('dip', W.dip, 'Nocturnal HR dip'); - add('arousal', W.arousal, 'Sleep calmness'); - - const score = wsum > 0 ? Math.round(acc / wsum) : null; - drivers.sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution)); - - return { - score, - components, - note: 'Composite (HRV + sleep) — a guide, not a diagnosis', - confidence: round(clamp(wsum, 0, 1), 3), - tier: 'ESTIMATE', - inputs_used: used, - drivers, - }; -} diff --git a/src/recovery.ts b/src/recovery.ts deleted file mode 100644 index fa65c2b..0000000 --- a/src/recovery.ts +++ /dev/null @@ -1,128 +0,0 @@ -// §8 HR recovery (HRR60). Tier HIGH. -// §Recovery — nocturnal-HRV recovery score (Plews et al., Sports Med 2013): -// compare tonight's ln(RMSSD) to a rolling personal baseline (mean ± sd of -// ln RMSSD). This is the validated, non-heuristic replacement for the old -// weighted "readiness" composite. No HRV → null (honest), never a fallback blend. -import type { Minute, Baseline, Metric, HrRecoveryValue, RecoveryValue, Driver, MetricRef } from './types'; -import { isHrUsable, resolveMaxHr, round, mean, stddev } from './util'; - -/** - * calcRecovery(rmssdToday, baselineRmssd[], opts?) - * - * Plews lnRMSSD method: z = (ln RMSSD_today − mean ln RMSSD_baseline) / sd. - * score = clamp(50 + 25·z, 0, 100) — each baseline SD ≈ 25 points; above your - * own baseline → green, below → compromised. Needs ≥5 baseline nights, else null. - * Tier HIGH (HRV is the gold-standard autonomic recovery signal). - */ -export function calcRecovery( - rmssdToday: number | null, - baselineRmssd: number[], - opts: { date?: string } = {}, -): Metric { - const NOTE = 'HRV-based'; - const usableBaseline = baselineRmssd.filter((x) => x > 0); - const none = (): Metric => ({ - score: null, rmssd: rmssdToday, baseline_rmssd: null, z: null, note: NOTE, - confidence: 0, tier: 'HIGH', inputs_used: ['hrv_rmssd'], - }); - if (rmssdToday == null || rmssdToday <= 0 || usableBaseline.length < 5) return none(); - - const lnBase = usableBaseline.map((x) => Math.log(x)); - const m = mean(lnBase); - const sd = stddev(lnBase); - const baseRmssd = Math.exp(m); - // Degenerate baseline (no spread) → can't z-score; report value, low confidence. - if (sd <= 0) { - return { - score: null, rmssd: round(rmssdToday, 1), baseline_rmssd: round(baseRmssd, 1), - z: null, note: NOTE, confidence: 0.2, tier: 'HIGH', inputs_used: ['hrv_rmssd'], - }; - } - const z = (Math.log(rmssdToday) - m) / sd; - const score = Math.max(0, Math.min(100, Math.round(50 + 25 * z))); - const ref: MetricRef = { metric: 'hrv', date: opts.date, scale: 'day' }; - const drivers: Driver[] = [{ - label: 'Nocturnal HRV (RMSSD)', - contribution: round(25 * z, 1), - detail: `${round(rmssdToday, 0)} ms vs baseline ${round(baseRmssd, 0)} ms`, - ref, - }]; - // confidence rises with baseline length (≥21 nights → full). - const confidence = Math.min(1, usableBaseline.length / 21); - return { - score, rmssd: round(rmssdToday, 1), baseline_rmssd: round(baseRmssd, 1), - z: round(z, 2), note: NOTE, - confidence: round(confidence, 4), tier: 'HIGH', inputs_used: ['hrv_rmssd', 'baseline.hrv_rmssd'], - drivers, - }; -} - -/** - * calcHrRecovery(sessionMinutes, baseline, profile?) - * - * Find the session's peak hr_max. HRR60 = peak − hr_avg of the minute ~1 min - * after the peak (minute resolution). Bigger drop = fitter. - * confidence = 0.7 if a clear elevated peak (≥ RHR + 40% reserve) exists, else - * the result is null with confidence 0. - * - * Confidence formula: 0.7 when an elevated peak (≥ RHR+0.4*reserve) and a valid - * post-peak minute both exist; otherwise 0 (insufficient signal). - */ -export function calcHrRecovery( - sessionMinutes: Minute[], - baseline: Baseline, - profile?: { age?: number } -): Metric { - const sorted = [...sessionMinutes].sort((a, b) => a.ts - b.ts); - const worn = sorted.filter(isHrUsable); - - const none = (): Metric => ({ - hrr60: null, - peak_hr: null, - confidence: 0, - tier: 'HIGH', - inputs_used: ['hr_max', 'hr_avg'], - }); - if (worn.length === 0) return none(); - - const { maxHr } = resolveMaxHr(sorted, baseline, profile); - const rhr = baseline.resting_hr; - const threshold = rhr + 0.4 * (maxHr - rhr); - - // Peak = the worn minute with the highest hr_max. - let peakIdxInSorted = -1; - let peakVal = -Infinity; - for (let i = 0; i < sorted.length; i++) { - if (!isHrUsable(sorted[i])) continue; - if (sorted[i].hr_max > peakVal) { - peakVal = sorted[i].hr_max; - peakIdxInSorted = i; - } - } - - if (peakIdxInSorted < 0 || peakVal < threshold) return none(); - - // Minute ~1 min after the peak: prefer the immediate next worn minute whose - // ts is ~60s after the peak (tolerant of gaps). - const peakTs = sorted[peakIdxInSorted].ts; - let after: Minute | null = null; - for (let i = peakIdxInSorted + 1; i < sorted.length; i++) { - if (!isHrUsable(sorted[i])) continue; - const dt = sorted[i].ts - peakTs; - if (dt >= 45 && dt <= 90) { - after = sorted[i]; - break; - } - if (dt > 90) break; - } - if (!after) return none(); - - const hrr60 = peakVal - after.hr_avg; - return { - hrr60: round(hrr60, 1), - peak_hr: round(peakVal, 1), - confidence: 0.7, - tier: 'HIGH', - inputs_used: ['hr_max', 'hr_avg', 'baseline.resting_hr'], - }; -} diff --git a/src/regularity.ts b/src/regularity.ts deleted file mode 100644 index 7dedcfc..0000000 --- a/src/regularity.ts +++ /dev/null @@ -1,95 +0,0 @@ -// §6 Sleep timing regularity. Tier HIGH (≥3 nights). -// -// HONEST SCOPE: this is the circular variability of sleep-ONSET and WAKE clock-times -// (how much your bed/wake times wander night to night), scaled 0–100. It is a -// legitimate, useful regularity measure — but it is NOT the Phillips et al. 2017 -// Sleep Regularity Index (Sci Rep 7:3216), which is an epoch-by-epoch probability -// that two time points 24 h apart are in the same sleep/wake state. We don't plumb -// minute-level sleep/wake state across consecutive days, so we don't claim the SRI -// name. If that state is ever wired in, implement the real SRI and relabel. -import type { NightSummary, Metric, SleepRegularityValue } from './types'; -import { round } from './util'; - -const DAY_MIN = 24 * 60; - -/** minute-of-day (0..1439) from a unix-seconds timestamp, UTC-based & deterministic. */ -function minuteOfDay(ts: number): number { - const totalMin = Math.floor(ts / 60); - return ((totalMin % DAY_MIN) + DAY_MIN) % DAY_MIN; -} - -/** - * Circular standard deviation (in MINUTES) of a set of minute-of-day values. - * - * Clock time is cyclic: 23:50 and 00:10 are 20 min apart, not ~1430. A LINEAR - * stddev of minute-of-day blows up for any bedtime straddling midnight and floors - * SRI to 0 for people who simply sleep around midnight. We instead map each time - * to an angle θ = 2π·m/1440, take the mean resultant length R, and derive the - * circular std σ = √(−2·ln R) (Mardia), converted back to minutes. Identical - * times → R=1 → σ=0; uniformly scattered → R→0 → σ→∞ (clamped by the caller). - */ -function circularStdMin(minutesOfDay: number[]): number { - if (minutesOfDay.length < 2) return 0; - let sumCos = 0; - let sumSin = 0; - for (const m of minutesOfDay) { - const theta = (2 * Math.PI * m) / DAY_MIN; - sumCos += Math.cos(theta); - sumSin += Math.sin(theta); - } - const n = minutesOfDay.length; - const r = Math.sqrt(sumCos * sumCos + sumSin * sumSin) / n; - // R∈(0,1]; guard R→0 (fully scattered) so ln stays finite. - const rClamped = Math.max(1e-9, Math.min(1, r)); - const sigmaRad = Math.sqrt(-2 * Math.log(rClamped)); - return sigmaRad * (DAY_MIN / (2 * Math.PI)); -} - -/** - * calcSleepRegularity(nights[]) - * - * For each night compute onset/wake minute-of-day. - * score = max(0, 100 − (avg(circ_std_onset, circ_std_wake)/120)*100). - * Uses CIRCULAR std (see circularStdMin) so midnight-straddling bedtimes don't - * spuriously read as irregular. confidence = 0 if <3 nights, else 0.7. - * - * NOTE: the returned `sri` field is this onset/wake timing-regularity score, NOT - * the Phillips epoch-agreement Sleep Regularity Index — see the file header. - * - * Confidence formula: pinned 0.7 once ≥3 nights with both onset & wake present; - * 0 below that threshold (input incompleteness). - */ -export function calcSleepRegularity( - nights: NightSummary[] -): Metric { - const valid = nights.filter((nn) => nn.onset_ts != null && nn.wake_ts != null); - const onsets = valid.map((nn) => minuteOfDay(nn.onset_ts as number)); - const wakes = valid.map((nn) => minuteOfDay(nn.wake_ts as number)); - - if (valid.length < 3) { - return { - sri: 0, - onset_std_min: 0, - wake_std_min: 0, - nights_used: valid.length, - confidence: 0, - tier: 'HIGH', - inputs_used: ['nights.onset_ts', 'nights.wake_ts'], - }; - } - - const onsetStd = circularStdMin(onsets); - const wakeStd = circularStdMin(wakes); - const avgStd = (onsetStd + wakeStd) / 2; - const sri = Math.max(0, 100 - (avgStd / 120) * 100); - - return { - sri: round(sri, 2), - onset_std_min: round(onsetStd, 2), - wake_std_min: round(wakeStd, 2), - nights_used: valid.length, - confidence: 0.7, - tier: 'HIGH', - inputs_used: ['nights.onset_ts', 'nights.wake_ts'], - }; -} diff --git a/src/resting.ts b/src/resting.ts deleted file mode 100644 index 403e797..0000000 --- a/src/resting.ts +++ /dev/null @@ -1,116 +0,0 @@ -// §1 Resting HR — 5th percentile of HR in the sleep window. Tier HIGH. -import type { Minute, Metric, RestingHrValue } from './types'; -import { isHrUsable, percentile, clamp, round } from './util'; - -export interface SleepWindow { - onset_ts: number | null; - wake_ts: number | null; -} - -/** - * calcRestingHR(minutes, sleepWindow?) - * - * Restrict to the night's sleep window; take worn minutes with hr_avg>0; - * RHR = 5th percentile of hr_avg (robust low, not the noisy absolute min). - * confidence = clamp(worn_sleep_minutes / 240, 0, 1). - * - * Fallback when no sleep window: 5th pctile of the lowest contiguous 30-min worn - * stretch of the day, with confidence capped at 0.5. - * - * Confidence formula: coverage = worn_sleep_min/240 (full night ≈ 4h worn = 1.0); - * fallback path multiplies by the 0.5 cap (lower input completeness). - */ -export function calcRestingHR( - minutes: Minute[], - sleepWindow?: SleepWindow -): Metric { - const hasWindow = - !!sleepWindow && sleepWindow.onset_ts != null && sleepWindow.wake_ts != null; - - if (hasWindow) { - const onset = sleepWindow!.onset_ts as number; - const wake = sleepWindow!.wake_ts as number; - const inWindow = minutes.filter( - (m) => m.ts >= onset && m.ts <= wake && isHrUsable(m) - ); - const hrs = inWindow.map((m) => m.hr_avg); - const rhr = percentile(hrs, 5); - const confidence = clamp(inWindow.length / 240, 0, 1); - return { - resting_hr: rhr == null ? null : round(rhr, 1), - confidence: round(rhr == null ? 0 : confidence, 4), - tier: 'HIGH', - inputs_used: ['hr_avg', 'sleep_window'], - }; - } - - // Fallback: lowest contiguous 30-min worn stretch of the day. - const best = lowestContiguousStretch(minutes, 30); - if (!best) { - return { - resting_hr: null, - confidence: 0, - tier: 'HIGH', - inputs_used: ['hr_avg'], - }; - } - const rhr = percentile(best.hrs, 5); - // confidence ≤ 0.5: scale coverage of the 30-min stretch, then cap. - const confidence = Math.min(0.5, clamp(best.hrs.length / 30, 0, 1) * 0.5); - return { - resting_hr: rhr == null ? null : round(rhr, 1), - confidence: round(rhr == null ? 0 : confidence, 4), - tier: 'HIGH', - inputs_used: ['hr_avg', 'fallback_30min'], - }; -} - -/** - * Lowest-mean stretch of `windowMin` worn minutes that are actually ADJACENT IN - * TIME (≤90s apart). Filtering off-wrist minutes out first and then taking - * index-adjacent slices would let a "30-min stretch" span hours of fragmented - * wear; a resting-HR window must be a genuine continuous quiet block, so we only - * slide the window within a run of time-contiguous worn minutes. - */ -function lowestContiguousStretch( - minutes: Minute[], - windowMin: number -): { hrs: number[] } | null { - const worn = minutes - .filter(isHrUsable) - .sort((a, b) => a.ts - b.ts); - if (worn.length === 0) return null; - - const MAX_GAP = 90; // seconds; > this breaks time-contiguity - let bestMean = Infinity; - let bestHrs: number[] | null = null; - - // Walk maximal time-contiguous runs; slide a windowMin window inside each. - let runStart = 0; - for (let i = 1; i <= worn.length; i++) { - const broken = i === worn.length || worn[i].ts - worn[i - 1].ts > MAX_GAP; - if (!broken) continue; - const run = worn.slice(runStart, i); - runStart = i; - if (run.length < windowMin) continue; - let windowSum = run.slice(0, windowMin).reduce((s, x) => s + x.hr_avg, 0); - for (let j = 0; j + windowMin <= run.length; j++) { - if (j > 0) windowSum += run[j + windowMin - 1].hr_avg - run[j - 1].hr_avg; - const m = windowSum / windowMin; - if (m < bestMean) { - bestMean = m; - bestHrs = run.slice(j, j + windowMin).map((s) => s.hr_avg); - } - } - } - - // No run long enough for a full window → fall back to the quietest worn minutes - // we have (better than nothing; confidence is already capped at ≤0.5 upstream). - if (!bestHrs) { - const lowest = [...worn].sort((a, b) => a.hr_avg - b.hr_avg) - .slice(0, Math.min(windowMin, worn.length)) - .map((m) => m.hr_avg); - return { hrs: lowest }; - } - return { hrs: bestHrs }; -} diff --git a/src/restlessness.ts b/src/restlessness.ts deleted file mode 100644 index eb9a0b9..0000000 --- a/src/restlessness.ts +++ /dev/null @@ -1,76 +0,0 @@ -// §Restlessness — nocturnal MOVEMENT fragmentation from per-minute actigraphy. -// -// Distinct from calcSleepStress (which detects sympathetic HR-surge arousals): this -// is purely about physical movement during the sleep window — how often you shifted, -// how fragmented the night was, your longest still stretch. Built ONLY from the -// per-minute `activity` aggregate we already store (works on 1 Hz flash R24 — no -// high-rate stream, no new storage). Tier ESTIMATE. -// -// "Movement" is defined relative to the night's own dynamic range (a robust floor + -// a fraction of the spread), so it adapts per-user and per-night rather than using a -// fabricated absolute threshold. -import type { Minute, Metric, Driver } from './types'; -import { percentile, round } from './util'; - -export interface RestlessnessValue { - score: number | null; // 0..100 (higher = more restless) - restless_min: number; // minutes with movement above the night floor - movement_bouts: number; // transitions still→moving (tosses/turns) - mobility_pct: number | null; // fraction of the window spent moving (0..1) - longest_still_min: number; // longest contiguous still stretch (sleep continuity) -} - -/** - * calcRestlessness(sleepMinutes) — sleepMinutes are the worn minutes within the main - * sleep period (onset..wake). A minute is "movement" when its activity exceeds a - * per-night threshold = p10 + 0.4·(p90 − p10). Bouts = still→move transitions. - */ -export function calcRestlessness(sleepMinutes: Minute[]): Metric { - const m = sleepMinutes - .filter((x) => x.wrist_on !== false && x.activity != null) - .sort((a, b) => a.ts - b.ts); - const empty = (): Metric => ({ - score: null, restless_min: 0, movement_bouts: 0, mobility_pct: null, longest_still_min: 0, - confidence: 0, tier: 'ESTIMATE', inputs_used: [], - }); - if (m.length < 20) return empty(); - - const acts = m.map((x) => x.activity); - const p10 = percentile(acts, 10) ?? 0, p90 = percentile(acts, 90) ?? 0; - const thresh = p10 + 0.4 * (p90 - p10); - - let restless = 0, bouts = 0, longestStill = 0, curStill = 0; - let moving = false; - for (const x of m) { - const isMove = x.activity > thresh && x.activity > 0; - if (isMove) { - restless++; - if (!moving) bouts++; // entering a movement bout (a toss/turn) - moving = true; - if (curStill > longestStill) longestStill = curStill; - curStill = 0; - } else { - moving = false; - curStill++; - } - } - if (curStill > longestStill) longestStill = curStill; - - const total = m.length; - const mobility = restless / total; - const hours = Math.max(0.5, total / 60); - const boutsPerHour = bouts / hours; - // Score: movement-bout density + mobility fraction, mapped to 0..100. - const score = Math.max(0, Math.min(100, Math.round(boutsPerHour * 6 + mobility * 100 * 0.5))); - - const drivers: Driver[] = [ - { label: 'Movement bouts', contribution: bouts, detail: `${bouts} shifts (${round(boutsPerHour, 1)}/h)`, ref: { metric: 'activity', scale: 'day' } }, - { label: 'Mobility', contribution: round(mobility * 100, 1), detail: `${restless}/${total} min moving`, ref: { metric: 'activity', scale: 'day' } }, - ]; - return { - score, restless_min: restless, movement_bouts: bouts, - mobility_pct: round(mobility, 4), longest_still_min: longestStill, - confidence: round(Math.min(1, total / 240), 4), tier: 'ESTIMATE', - inputs_used: ['activity'], drivers, - }; -} diff --git a/src/sessions.ts b/src/sessions.ts deleted file mode 100644 index c2e47e4..0000000 --- a/src/sessions.ts +++ /dev/null @@ -1,183 +0,0 @@ -// §7 Auto-workout detection. Tier HIGH (event) / ESTIMATE (type). -import type { Minute, Baseline, Profile, Metric, SessionValue, ActivityClass } from './types'; -import { isHrUsable, resolveMaxHr, median, mean, round } from './util'; -import { calcStrain } from './strain'; -import { calcCalories } from './calories'; -import { calcHrZones } from './zones'; -import { calcHrRecovery } from './recovery'; -import { segmentWorkout } from './har'; -import type { ClassVote } from './har'; - -/** - * detectSessions(minutes, baseline, profile?) - * - * A session starts when hr_avg ≥ RHR + 0.4*(maxHR−RHR) (≈40% HR reserve) - * sustained ≥3 min AND mean activity over those minutes > daily median activity. - * It ends when HR drops below the threshold for ≥3 consecutive minutes. - * Merge sessions <5 min apart; discard sessions <5 min total. - * Per session: start/end ts, duration, avg/max HR, strain, calories, zones, - * HRR60, mean+peak activity, type (ESTIMATE). - * - * Each session is a Metric: event confidence 0.8 (HIGH), type_confidence 0.4. - * Confidence formula: event detection pinned 0.8 (published threshold method on - * authoritative HR); type heuristic pinned 0.4 (crude buckets). - */ -export function detectSessions( - minutes: Minute[], - baseline: Baseline, - profile?: Profile -): Metric[] { - const sorted = [...minutes].sort((a, b) => a.ts - b.ts); - const worn = sorted.filter(isHrUsable); - if (worn.length === 0) return []; - - const { maxHr } = resolveMaxHr(sorted, baseline, profile); - const rhr = baseline.resting_hr; - const threshold = rhr + 0.4 * (maxHr - rhr); - const dailyMedianAct = median(sorted.map((m) => m.activity)) ?? 0; - - const above = (m: Minute) => isHrUsable(m) && m.hr_avg >= threshold; - - // 1. Find candidate raw segments: contiguous runs above threshold, allowing - // short (<3 consecutive) below-threshold dips inside. - type Seg = { startIdx: number; endIdx: number }; - const segs: Seg[] = []; - let i = 0; - while (i < worn.length) { - if (!above(worn[i])) { - i++; - continue; - } - // start a candidate - let j = i; - let belowRun = 0; - let lastAboveIdx = i; - while (j < worn.length) { - if (above(worn[j])) { - belowRun = 0; - lastAboveIdx = j; - } else { - belowRun++; - if (belowRun >= 3) break; // sustained drop ends the session - } - j++; - } - segs.push({ startIdx: i, endIdx: lastAboveIdx }); - i = lastAboveIdx + 1; - } - - // 2. Require ≥2 min sustained start AND mean activity > daily median. - const qualified = segs.filter((s) => { - const slice = worn.slice(s.startIdx, s.endIdx + 1); - if (slice.length < 2) return false; - const meanAct = mean(slice.map((m) => m.activity)); - return meanAct > dailyMedianAct; - }); - - // 3. Merge sessions <5 min apart (by ts gap between consecutive segments). - const merged: Seg[] = []; - for (const s of qualified) { - if (merged.length === 0) { - merged.push({ ...s }); - continue; - } - const prev = merged[merged.length - 1]; - const gapMin = (worn[s.startIdx].ts - worn[prev.endIdx].ts) / 60; - if (gapMin < 5) { - prev.endIdx = s.endIdx; - } else { - merged.push({ ...s }); - } - } - - // 4. Discard sessions <2 min total; build outputs. - const out: Metric[] = []; - for (const s of merged) { - const slice = worn.slice(s.startIdx, s.endIdx + 1); - const durationMin = (slice[slice.length - 1].ts - slice[0].ts) / 60 + 1; - if (durationMin < 2) continue; - - const hrs = slice.map((m) => m.hr_avg); - const avgHr = mean(hrs); - const maxHrSeen = Math.max(...slice.map((m) => m.hr_max)); - const acts = slice.map((m) => m.activity); - const meanAct = mean(acts); - const peakAct = Math.max(...acts); - - const strain = calcStrain(slice, baseline, profile); - const cals = calcCalories(slice, profile ?? {}, baseline.resting_hr, maxHr); - const zones = calcHrZones(slice, baseline, profile); - const hrr = calcHrRecovery(slice, baseline, profile); - - // Type: prefer the motion-based HAR classes carried per-minute from ingest (live - // high-rate stream → Mannini classifier). Run segmentWorkout over the bout's - // per-minute act_class to get the primary type + graceful phase breakdown. If the - // bout has no classified minutes (flash-drained, 1 Hz, no motion texture), fall - // back to the crude HR/activity heuristic — honestly low-confidence. - const votes: ClassVote[] = slice - .filter((m) => m.act_class) - .map((m) => ({ ts: m.ts, cls: m.act_class as ActivityClass, conf: 1 })); - let type: string; - let typeConf: number; - let segments: SessionValue['segments']; - if (votes.length >= 2) { - const seg = segmentWorkout(votes, { minPhaseSec: 120 }); - type = seg.primary; - // Cap at 0.75: motion-classified is far better than the HR heuristic but still - // ESTIMATE (threshold classifier, no trained model yet). - typeConf = Math.min(0.75, Math.max(0.4, seg.type_confidence)); - segments = seg.segments.length > 1 ? seg.segments : undefined; - } else { - type = classifyType(meanAct, dailyMedianAct, avgHr, rhr, maxHr); - typeConf = 0.4; - } - - out.push({ - start_ts: slice[0].ts, - end_ts: slice[slice.length - 1].ts, - duration_min: round(durationMin, 0), - avg_hr: round(avgHr, 1), - max_hr: round(maxHrSeen, 1), - strain: strain.score, - trimp: strain.trimp, - kcal: cals.kcal, - zones: { - zone1_min: zones.zone1_min, - zone2_min: zones.zone2_min, - zone3_min: zones.zone3_min, - zone4_min: zones.zone4_min, - zone5_min: zones.zone5_min, - max_hr_used: zones.max_hr_used, - max_hr_source: zones.max_hr_source, - }, - hrr60: hrr.hrr60, - mean_activity: round(meanAct, 4), - peak_activity: round(peakAct, 4), - type, - type_confidence: round(typeConf, 2), - segments, - detected_type: type, - confidence: 0.8, // event detection, HIGH - tier: 'HIGH', - inputs_used: ['hr_avg', 'hr_max', 'activity', 'baseline.resting_hr'], - }); - } - - return out; -} - -/** Crude ESTIMATE type buckets from activity + HR reserve. */ -function classifyType( - meanAct: number, - dailyMedianAct: number, - avgHr: number, - rhr: number, - maxHr: number -): SessionValue['type'] { - const reserve = maxHr - rhr; - const hrReservePct = reserve > 0 ? (avgHr - rhr) / reserve : 0; - const highActivity = meanAct > dailyMedianAct * 2; - if (highActivity && hrReservePct >= 0.6) return 'run/cardio'; - if (!highActivity && hrReservePct >= 0.6) return 'strength/other'; - return 'walk'; // low activity / HR < 60% reserve -} diff --git a/src/sleep.ts b/src/sleep.ts deleted file mode 100644 index 5b45673..0000000 --- a/src/sleep.ts +++ /dev/null @@ -1,595 +0,0 @@ -// §5 Sleep — Cole-Kripke actigraphy + HR-dip fusion. Tier HIGH (duration/eff), -// ESTIMATE (stages). One-minute epochs. -import type { - Minute, Baseline, Metric, SleepValue, SleepStages, - SleepPeriod, SleepPeriodsValue, -} from './types'; -import { isHrUsable, mean, round } from './util'; -import { cleanRr } from './hrv'; - -// Cole-Kripke weights over window [-4..+2]. -const CK_W = [1.06, 0.54, 0.58, 0.76, 2.3, 0.74, 0.67]; - -/** Per-minute RMSSD (ms) from a raw RR stream; null if too few clean beats. */ -function minuteRmssd(rr: number[] | undefined): number | null { - if (!rr || rr.length < 12) return null; - const c = cleanRr(rr); - if (c.length < 10) return null; - let s = 0; - for (let i = 1; i < c.length; i++) { const d = c[i] - c[i - 1]; s += d * d; } - return Math.sqrt(s / (c.length - 1)); -} -function medOfNullable(xs: (number | null)[]): number | null { - const a = xs.filter((x): x is number => x != null && Number.isFinite(x)).sort((p, q) => p - q); - return a.length ? a[a.length >> 1] : null; -} -const REM_RMSSD_FACTOR = 0.90; // high-HR minute with smoothed RMSSD < 0.90×asleep-median = REM, not wake - -/** - * calcSleep(minutes, baseline) - * - * Per 1-min epoch: S = 0.001 * Σ W_i * A_i over window [−4..+2] with - * W = [1.06,0.54,0.58,0.76,2.30,0.74,0.67]; asleep = S < 1. - * HR-dip fusion: hr_avg < 0.95*RHR nudges toward asleep; clearly elevated HR - * (> 1.15*RHR) nudges awake. RHR is the 5th-PERCENTILE sleep HR (a floor), and - * normal REM HR legitimately runs >10% above that floor, so the awake-override - * margin must clear REM — 1.10 would mis-flag REM epochs as awake and fragment - * the night. - * Main sleep = the longest CONSOLIDATED period (asleep epochs joined only across - * short interior awake gaps ≤20 min; a longer awake stretch ends the period). - * NOT first-asleep→last-asleep across the whole night window — that would let - * evening/morning low-activity minutes bridge into a giant span. A 14h - * plausibility cap re-segments with a tighter gap if the period is too long. - * duration = asleep minutes; efficiency = asleep / in-bed span of that period. - * Stages (BETA): deep = very low activity + lowest HR; REM = low activity + HR - * variability up; else light. - * - * Confidence formula: (#inputs present among {hr,activity,temp}/3) × coverage, - * where coverage = clamp(in_bed_min/240, 0, 1) so a full ~4h needs to clear >0.5. - */ -export function calcSleep(minutes: Minute[], baseline: Baseline): Metric { - const sorted = [...minutes].sort((a, b) => a.ts - b.ts); - const n = sorted.length; - - const empty = (): Metric => ({ - onset_ts: null, - wake_ts: null, - duration_min: 0, - in_bed_min: 0, - efficiency: 0, - stages: null, - stages_beta: true, - confidence: 0, - tier: 'HIGH', - inputs_used: [], - }); - if (n === 0) return empty(); - - const rhr = baseline.resting_hr; - // Cole–Kripke + HR-dip fusion is anchored on resting HR; without a real - // baseline (null/0 for a brand-new user) the dip comparisons degrade to - // garbage (everything reads "awake"). Abstain until we have one. - if (rhr == null || rhr <= 0) return empty(); - - // Per-window sleep-HR reference for the HR-dip awake override. - // - // The band's flash record (R24, the entire overnight) carries NO usable - // actigraphy — its accel is a single 1 Hz gravity sample, so per-minute - // `activity` reads ~0 and the Cole-Kripke score never clears its count-scaled - // S<1 threshold. The awake/asleep call is therefore HR-driven in practice. - // Anchoring "elevated → awake" to `rhr` is WRONG, because `rhr` is the - // 5th-PERCENTILE sleep HR (a floor): normal/REM sleep HR legitimately runs - // 20–40% above that floor, so a fixed 1.15*rhr cutoff flags almost every real - // sleep minute awake (observed: 167/168 worn minutes on a real night, RHR 58 → - // 67 bpm cutoff vs a 66–94 bpm sleeping band → 1-minute "sleep"). Instead anchor - // the cutoff to THIS window's own depressed-HR level (a low percentile of worn - // HR), with `rhr` as a floor so a window that is genuinely all-awake can't - // manufacture a low reference. Percentiles are robust to the high-HR - // evening/morning tail that shares the search window. - const wornHr = sorted - .filter((m) => m.wrist_on && m.hr_avg > 0) - .map((m) => m.hr_avg) - .sort((a, b) => a - b); - const pctl = (p: number) => - wornHr.length ? wornHr[Math.min(wornHr.length - 1, Math.floor(p * wornHr.length))] : rhr; - const sleepHr = Math.max(rhr, pctl(0.10)); // the quiet sleep-HR level for this window - const ASLEEP_HI = 1.05; // ≤ this × sleepHr → strong dip, nudge asleep - const AWAKE_HI = 1.20; // > this × sleepHr → clearly elevated, nudge awake (clears REM) - // Absolute backstop: HR at/above this × the RHR floor reads awake REGARDLESS of the - // window. Without it, a flat + motion-inert window anchors sleepHr to its own level and - // every minute falls under ASLEEP_HI*sleepHr → a sedentary-awake stretch (TV, desk) - // would be mis-read as a full night. Real sleep, including REM, rarely SUSTAINS >1.5× - // the 5th-percentile RHR floor, so this clips the false-positive without clipping sleep. - // (The proper tie-breaker is actigraphy; R24 carries none today — see decode note.) - const ABS_WAKE = 1.5; - - // 1. Cole-Kripke score + HR-dip fusion → boolean asleep per epoch. - const asleep: boolean[] = new Array(n).fill(false); - for (let i = 0; i < n; i++) { - let s = 0; - // window offsets -4..+2 align with weights index 0..6 - for (let k = 0; k < CK_W.length; k++) { - const off = k - 4; // -4..+2 - const idx = i + off; - if (idx >= 0 && idx < n) s += CK_W[k] * sorted[idx].activity; - } - s *= 0.001; - const m = sorted[i]; - // Off-wrist epochs carry NO sleep signal (activity reads 0, no HR). They must - // NOT default to asleep just because Cole-Kripke on activity=0 scores < 1 — - // otherwise a long daytime off-wrist stretch (band on charger / removed) - // bridges the gap-tolerant period across hours of non-sleep. Treat unknown as - // awake so it acts as a period boundary; real interior brief off-wrist blips - // are still absorbed by the ≤MAX_GAP_MIN bridging in step 2. - if (!m.wrist_on) { asleep[i] = false; continue; } - let isAsleep = s < 1; - // HR-dip fusion (only when we have a usable HR reading). - if (m.hr_avg > 0) { - if (m.hr_avg > ABS_WAKE * rhr) isAsleep = false; // absolute backstop → awake regardless - else if (m.hr_avg <= ASLEEP_HI * sleepHr) isAsleep = true; // at/below the sleep level → asleep - else if (m.hr_avg > AWAKE_HI * sleepHr) isAsleep = false; // clearly elevated → awake - } - asleep[i] = isAsleep; - } - - // 2. Main consolidated sleep period. We DON'T take first-asleep→last-asleep - // across the whole local-night window — that lets evening wind-down and - // morning-in-bed minutes (which can read as low-activity) bridge into one - // giant block spanning most of the ~18h window. Instead we segment the - // night into CONSOLIDATED periods: a period grows over asleep epochs and - // over SHORT interior awake gaps (≤ MAX_GAP_MIN, real awakenings are brief), - // but a longer contiguous awake stretch ENDS the period (out-of-bed / - // separate nap / wind-down). The main sleep = the longest such consolidated - // period, trimmed to its first/last asleep epoch so a trailing gap isn't - // counted as in-bed. Interior short awakenings stay inside and count - // against efficiency. - const MAX_GAP_MIN = 20; // an awake gap longer than this ends the period - - let bestStart = -1; - let bestEnd = -1; - let bestAsleep = 0; - // current consolidated period, tracked by its first/last ASLEEP epoch. - let periodFirst = -1; // first asleep epoch of the current period - let periodLast = -1; // last asleep epoch seen in the current period - let periodAsleep = 0; // asleep-epoch count in the current period - let gap = 0; // consecutive awake epochs since the last asleep epoch - - const closePeriod = () => { - // score by asleep-epoch count (the consolidated block's actual sleep). - if (periodFirst >= 0 && periodAsleep > bestAsleep) { - bestAsleep = periodAsleep; - bestStart = periodFirst; - bestEnd = periodLast; - } - periodFirst = -1; - periodLast = -1; - periodAsleep = 0; - gap = 0; - }; - - for (let i = 0; i < n; i++) { - if (asleep[i]) { - if (periodFirst < 0) periodFirst = i; - periodLast = i; - periodAsleep++; - gap = 0; - } else if (periodFirst >= 0) { - // inside a period — tolerate a short awake gap, else close it. - if (++gap > MAX_GAP_MIN) closePeriod(); - } - } - closePeriod(); - - if (bestStart < 0 || bestAsleep === 0) return empty(); - - let startIdx = bestStart; - let endIdx = bestEnd; - - // 2a. Plausibility guard. A single main-sleep period spanning more than - // MAX_SLEEP_MIN is implausible — it means low-activity non-sleep time - // (daytime sedentary / off-wrist) is being merged in. Tighten the gap - // bound and re-segment; the shorter bound severs the spurious bridges so - // the true night survives as the longest consolidated period. - const MAX_SLEEP_MIN = 14 * 60; // 14h hard ceiling on one main-sleep period - if (endIdx - startIdx + 1 > MAX_SLEEP_MIN) { - for (const tighter of [10, 5, 2]) { - let bs = -1, be = -1, ba = 0; - let pf = -1, pl = -1, pa = 0, g = 0; - const close = () => { - if (pf >= 0 && pa > ba) { ba = pa; bs = pf; be = pl; } - pf = -1; pl = -1; pa = 0; g = 0; - }; - for (let i = 0; i < n; i++) { - if (asleep[i]) { if (pf < 0) pf = i; pl = i; pa++; g = 0; } - else if (pf >= 0) { if (++g > tighter) close(); } - } - close(); - if (bs >= 0 && be - bs + 1 <= MAX_SLEEP_MIN) { startIdx = bs; endIdx = be; break; } - // keep the tightest attempt even if still over, so we never fall back to - // the giant span. - if (bs >= 0) { startIdx = bs; endIdx = be; } - } - // Last resort: if even the tightest re-segmentation can't get under the - // ceiling (a genuinely uninterrupted block with no awake epoch to split on — - // implausible for real sleep), hard-clamp to MAX_SLEEP_MIN from the onset so - // we never report a >14h "night". - if (endIdx - startIdx + 1 > MAX_SLEEP_MIN) endIdx = startIdx + MAX_SLEEP_MIN - 1; - } - - const onset_ts = sorted[startIdx].ts; - const wake_ts = sorted[endIdx].ts; - - // in-bed span = first-asleep → last-asleep epoch inclusive (epoch count). - const inBedEpochs = sorted.slice(startIdx, endIdx + 1); - const in_bed_min = inBedEpochs.length; - // asleep minutes within the span; interior awake epochs reduce efficiency. - let duration_min = 0; - for (let i = startIdx; i <= endIdx; i++) if (asleep[i]) duration_min++; - const efficiency = in_bed_min > 0 ? duration_min / in_bed_min : 0; - - // 3. Stages (BETA/ESTIMATE) over the asleep epochs within main sleep. - const sleepEpochs = inBedEpochs.filter((_, i) => asleep[startIdx + i]); - const stages = estimateStages(sleepEpochs, rhr); - - // 4. Confidence: input completeness × coverage. - const hasHr = inBedEpochs.some((m) => m.wrist_on && m.hr_avg > 0); - const hasActivity = inBedEpochs.some((m) => m.activity > 0); - const hasTemp = baseline.skin_temp != null; - const present = [hasHr, hasActivity, hasTemp].filter(Boolean).length; - const inputCompleteness = present / 3; - const coverage = Math.min(1, in_bed_min / 240); - const confidence = inputCompleteness * coverage; - - const inputs_used: string[] = ['activity']; - if (hasHr) inputs_used.push('hr_avg'); - if (hasTemp) inputs_used.push('baseline.skin_temp'); - - return { - onset_ts, - wake_ts, - duration_min, - in_bed_min, - efficiency: round(efficiency, 4), - stages, - stages_beta: true, - confidence: round(confidence, 4), - tier: 'HIGH', - inputs_used, - }; -} - -/** - * calcSleepPeriods(minutes, baseline) — Sleep v2 (multi-period) - * - * Same epoch scorer + consolidation as calcSleep, but instead of keeping only the - * single longest period we return EVERY consolidated sleep period in the window. - * A nap is not a special case — it's just a shorter sleep. Slept once → one - * period; napped twice → three periods total; the UI renders one card each. - * - * Each period carries its own full breakdown (onset/wake/duration/efficiency/ - * stages) and its own confidence. The longest period is flagged `is_main` purely - * as a UI hint; the data treats all periods identically. - * - * Per-period confidence = input_completeness × clamp(in_bed_min/90, 0, 1) — a ~90 - * min block reaches full coverage, so a genuine 40-min nap isn't unfairly crushed - * the way the 240-min night-coverage of calcSleep would crush it. - * - * Periods with fewer than MIN_PERIOD_MIN asleep minutes are discarded (micro-dozes - * aren't sleep). The top-level Metric.confidence mirrors the main period. - */ -export function calcSleepPeriods(minutes: Minute[], baseline: Baseline): Metric { - const sorted = [...minutes].sort((a, b) => a.ts - b.ts); - const n = sorted.length; - const rhr = baseline.resting_hr; - - const empty = (): Metric => ({ - periods: [], - total_asleep_min: 0, - main_idx: null, - stages_beta: true, - confidence: 0, - tier: 'HIGH', - inputs_used: [], - }); - if (n === 0) return empty(); - if (rhr == null || rhr <= 0) return empty(); // need a resting-HR baseline (see calcSleep) - - // 1. Per-epoch asleep — identical scorer to calcSleep (duplicated here on - // purpose so v1 stays byte-for-byte untouched). - const asleep: boolean[] = new Array(n).fill(false); - for (let i = 0; i < n; i++) { - let s = 0; - for (let k = 0; k < CK_W.length; k++) { - const off = k - 4; - const idx = i + off; - if (idx >= 0 && idx < n) s += CK_W[k] * sorted[idx].activity; - } - s *= 0.001; - const m = sorted[i]; - if (!m.wrist_on) { asleep[i] = false; continue; } - let isAsleep = s < 1; - if (m.hr_avg > 0) { - if (m.hr_avg < 0.95 * rhr) isAsleep = true; - else if (m.hr_avg > 1.15 * rhr) isAsleep = false; - } - asleep[i] = isAsleep; - } - - // 2. Collect ALL consolidated periods (same ≤20-min interior-gap rule as the - // main-sleep detector). Each is trimmed to its first/last asleep epoch. - const MAX_GAP_MIN = 20; - const MAX_SLEEP_MIN = 14 * 60; // same plausibility ceiling, applied per period - const MIN_PERIOD_MIN = 15; // shorter than this isn't a sleep period - - const raw: { start: number; end: number; asleepN: number }[] = []; - let pf = -1, pl = -1, pa = 0, gap = 0; - const close = () => { - if (pf >= 0 && pa > 0) raw.push({ start: pf, end: pl, asleepN: pa }); - pf = -1; pl = -1; pa = 0; gap = 0; - }; - for (let i = 0; i < n; i++) { - if (asleep[i]) { if (pf < 0) pf = i; pl = i; pa++; gap = 0; } - else if (pf >= 0) { if (++gap > MAX_GAP_MIN) close(); } - } - close(); - - const periods: SleepPeriod[] = []; - for (const p of raw) { - let startIdx = p.start; - let endIdx = p.end; - if (endIdx - startIdx + 1 > MAX_SLEEP_MIN) endIdx = startIdx + MAX_SLEEP_MIN - 1; - - const span = sorted.slice(startIdx, endIdx + 1); - const in_bed_min = span.length; - let duration_min = 0; - for (let i = startIdx; i <= endIdx; i++) if (asleep[i]) duration_min++; - if (duration_min < MIN_PERIOD_MIN) continue; - - const efficiency = in_bed_min > 0 ? duration_min / in_bed_min : 0; - const sleepEpochs = span.filter((_, i) => asleep[startIdx + i]); - const stages = estimateStages(sleepEpochs, rhr); - - const hasHr = span.some((m) => m.wrist_on && m.hr_avg > 0); - const hasActivity = span.some((m) => m.activity > 0); - const hasTemp = baseline.skin_temp != null; - const inputCompleteness = [hasHr, hasActivity, hasTemp].filter(Boolean).length / 3; - const coverage = Math.min(1, in_bed_min / 90); - - periods.push({ - onset_ts: sorted[startIdx].ts, - wake_ts: sorted[endIdx].ts, - duration_min, - in_bed_min, - efficiency: round(efficiency, 4), - stages, - is_main: false, - confidence: round(inputCompleteness * coverage, 4), - }); - } - - if (periods.length === 0) return empty(); - - // 3. Flag the longest period as the main one (UI hint only). - let main_idx = 0; - for (let i = 1; i < periods.length; i++) { - if (periods[i].duration_min > periods[main_idx].duration_min) main_idx = i; - } - periods[main_idx].is_main = true; - - const total_asleep_min = periods.reduce((a, p) => a + p.duration_min, 0); - - const inputs_used: string[] = ['activity']; - if (sorted.some((m) => m.wrist_on && m.hr_avg > 0)) inputs_used.push('hr_avg'); - if (baseline.skin_temp != null) inputs_used.push('baseline.skin_temp'); - - return { - periods, - total_asleep_min, - main_idx, - stages_beta: true, - confidence: periods[main_idx].confidence, - tier: 'HIGH', - inputs_used, - }; -} - -/** - * sleepAwakeMask(minutes, baseline, rrByMin?) → Map per minute. - * Cole-Kripke actigraphy + HR-dip — the authoritative asleep/awake boundary — PLUS an - * RR tiebreaker for the override's blind spot: - * - * The HR-dip rule "HR > 1.15·rhr ⇒ awake" is right for genuine wake but it ALSO catches - * REM (REM HR legitimately runs ~15% above the sleeping floor) — and on a calm wrist with - * a near-dead activity signal there's no movement to tell them apart, so REM gets called - * "awake". Fix: for those high-HR minutes, look at beat-to-beat RR. REM = parasympathetic - * withdrawal ⇒ LOW RMSSD; so a high-HR minute whose smoothed RMSSD is below 0.90× the - * night's asleep-RMSSD median is REM → stays ASLEEP, not awake. (rrByMin optional; without - * it the legacy HR-only override stands.) - * - * The day-detail uses this to drive the hypnogram + breakdown from one source. Empty map - * when there's no resting-HR baseline. calcSleep/calcSleepPeriods scorers stay untouched. - */ -export function sleepAwakeMask( - minutes: Minute[], baseline: Baseline, rrByMin?: Map, -): Map { - const out = new Map(); - const rhr = baseline.resting_hr; - if (rhr == null || rhr <= 0) return out; - const sorted = [...minutes].sort((a, b) => a.ts - b.ts); - const n = sorted.length; - - // Per-minute smoothed RMSSD + the asleep-RMSSD median → the REM cut for the tiebreaker. - let rms: (number | null)[] = []; - let remCut: number | null = null; - if (rrByMin && rrByMin.size) { - const raw = sorted.map((m) => minuteRmssd(rrByMin.get(m.ts))); - rms = raw.map((_, i) => medOfNullable(raw.slice(Math.max(0, i - 2), Math.min(n, i + 3)))); - const asleepRms = sorted.map((m, i) => (m.hr_avg > 0 ? rms[i] : null)); - const med = medOfNullable(asleepRms); - if (med != null) remCut = REM_RMSSD_FACTOR * med; - } - - for (let i = 0; i < n; i++) { - let s = 0; - for (let k = 0; k < CK_W.length; k++) { - const idx = i + (k - 4); - if (idx >= 0 && idx < n) s += CK_W[k] * sorted[idx].activity; - } - s *= 0.001; - const m = sorted[i]; - if (!m.wrist_on) { out.set(m.ts, false); continue; } // off-wrist = not asleep (boundary) - let isAsleep = s < 1; - if (m.hr_avg > 0) { - if (m.hr_avg < 0.95 * rhr) isAsleep = true; - else if (m.hr_avg > 1.15 * rhr) { - // RR tiebreaker: high HR + low RMSSD = REM (asleep); else genuine wake. - const remLike = remCut != null && rms[i] != null && rms[i]! < remCut; - isAsleep = remLike; - } - } - out.set(m.ts, isAsleep); - } - return out; -} - -/** Merge per-minute stage runs shorter than the floor into the larger neighbour - * (awake keeps a higher floor) — consolidates the per-minute classifier's flicker - * into stable bouts so the hypnogram doesn't sawtooth. */ -function boutSmoothStage(labels: string[], minRun = 5, minAwakeRun = 7, passes = 6): string[] { - const s = [...labels]; - for (let p = 0; p < passes; p++) { - const runs: { a: number; b: number }[] = []; - for (let i = 0; i < s.length;) { let j = i; while (j < s.length && s[j] === s[i]) j++; runs.push({ a: i, b: j - 1 }); i = j; } - if (runs.length <= 1) break; - let changed = false; - for (let r = 0; r < runs.length; r++) { - const { a, b } = runs[r]; - const floor = s[a] === 'awake' ? minAwakeRun : minRun; - if (b - a + 1 >= floor) continue; - const prev = r > 0 ? runs[r - 1] : null; - const next = r < runs.length - 1 ? runs[r + 1] : null; - let tgt: string | null = null; - if (prev && next) tgt = (prev.b - prev.a) >= (next.b - next.a) ? s[prev.a] : s[next.a]; - else if (prev) tgt = s[prev.a]; - else if (next) tgt = s[next.a]; - if (tgt) { for (let x = a; x <= b; x++) s[x] = tgt; changed = true; } - } - if (!changed) break; - } - return s; -} - -export interface NightHypnogram { - hypnogram: { t: number; stage: 'awake' | 'light' | 'deep' | 'rem' }[]; - light_min: number; deep_min: number; rem_min: number; awake_min: number; asleep_min: number; -} - -/** - * stageHypnogram(minutes, onset, wake, baseline) — the v1 staging method, made - * per-minute and consistent. ONE source for the whole hypnogram + breakdown: - * • asleep/awake from calcSleep's Cole-Kripke + HR-dip mask (the proven detector), - * • deep/light/rem within the asleep minutes from the SAME HR-percentile bands as - * estimateStages (deep = bottom ~22% of sleeping HR, REM = top ~21%, else light), - * • bout-smoothed so it reads as stable bouts, not minute flicker. - * No RR, no circadian, no second stager — exactly what worked in v1, now driving both - * the graph and the totals (so they can never disagree). null if no resting-HR baseline. - */ -export function stageHypnogram( - minutes: Minute[], onset: number, wake: number, baseline: Baseline, rrByMin?: Map, -): NightHypnogram | null { - const rhr = baseline.resting_hr; - if (rhr == null || rhr <= 0) return null; - const mask = sleepAwakeMask(minutes, baseline, rrByMin); // ts → asleep (REM tiebreaker via RR) - const win = minutes.filter((m) => m.ts >= onset && m.ts <= wake).sort((a, b) => a.ts - b.ts); - if (win.length < 5) return null; - - // HR-percentile bands over the night's OWN sleeping HR (same as estimateStages). - const sleepHr = win.filter((m) => mask.get(m.ts) !== false && m.hr_avg > 0).map((m) => m.hr_avg); - const hrs = sleepHr.length ? sleepHr : win.filter((m) => m.hr_avg > 0).map((m) => m.hr_avg); - const sortedHr = [...hrs].sort((a, b) => a - b); - const meanHr = hrs.length ? hrs.reduce((a, b) => a + b, 0) / hrs.length : rhr; - const q = (p: number): number => sortedHr.length ? sortedHr[Math.min(sortedHr.length - 1, Math.floor(p * sortedHr.length))] : meanHr; - const deepEdge = q(0.22), remEdge = q(0.79); - const bigJump = Math.max(6, (hrs.length ? Math.max(1, q(0.9) - q(0.1)) : 1) * 0.6); - const acts = win.map((m) => m.activity); - const meanAct = acts.reduce((a, b) => a + b, 0) / (acts.length || 1); - - const raw: string[] = win.map((m, i) => { - if (mask.get(m.ts) === false || m.hr_avg <= 0) return 'awake'; - const hr = m.hr_avg; - const prev = i > 0 && win[i - 1].hr_avg > 0 ? win[i - 1].hr_avg : hr; - const next = i + 1 < win.length && win[i + 1].hr_avg > 0 ? win[i + 1].hr_avg : hr; - const hrJump = Math.max(Math.abs(hr - prev), Math.abs(hr - next)); - const lowAct = m.activity <= meanAct; - if (lowAct && hr <= deepEdge) return 'deep'; - if (lowAct && hr >= remEdge) return 'rem'; - if (lowAct && hrJump > bigJump) return 'rem'; - return 'light'; - }); - const sm = boutSmoothStage(raw); - let light = 0, deep = 0, rem = 0, awake = 0; - for (const s of sm) { if (s === 'awake') awake++; else if (s === 'deep') deep++; else if (s === 'rem') rem++; else light++; } - return { - hypnogram: win.map((m, i) => ({ t: m.ts, stage: sm[i] as 'awake' | 'light' | 'deep' | 'rem' })), - light_min: light, deep_min: deep, rem_min: rem, awake_min: awake, asleep_min: light + deep + rem, - }; -} - -/** - * BETA stage estimator. Splits asleep epochs into deep/REM/light using activity - * + HR relative to that night's own distribution. Honest heuristic, not clinical. - */ -function estimateStages(sleepEpochs: Minute[], rhr: number): SleepStages | null { - if (sleepEpochs.length === 0) return null; - const hrs = sleepEpochs.filter((m) => m.hr_avg > 0).map((m) => m.hr_avg); - const meanHr = hrs.length ? mean(hrs) : rhr; - const acts = sleepEpochs.map((m) => m.activity); - const meanAct = mean(acts); - - // Band the night's OWN asleep-HR distribution (robust, scale-free). Physiology: - // sleeping HR is LOWEST in deep/slow-wave sleep and HIGHER + more variable in - // REM and light. With no EEG/PPG this relative-HR-depth proxy is the honest - // signal — so we band by HR percentile to land a PHYSIOLOGICALLY plausible split - // (deep ~20%, REM ~25%, light ~55%): - // • bottom ~22% of sleeping HR + quiet → deep - // • top ~26% of sleeping HR + quiet → REM - // • everything else → light - // We deliberately DON'T gate on minute-to-minute HR variability: the earlier - // estimator did (REM = high-band OR jump>2 bpm), and on minute-AVERAGED HR that - // mis-fired catastrophically — almost every minute jumps >2 bpm, so 60–70% of - // the night read as REM. Variability is too noisy at minute resolution to be a - // reliable REM cue, so HR depth alone drives the split. A small variability - // NUDGE only rescues a mid-band epoch that is unusually erratic → REM-leaning. - const sortedHr = [...hrs].sort((a, b) => a - b); - const q = (p: number): number => - sortedHr.length ? sortedHr[Math.min(sortedHr.length - 1, Math.floor(p * sortedHr.length))] : meanHr; - const deepEdge = q(0.22); // bottom ~22% of sleeping HR → deep - const remEdge = q(0.79); // top ~21% of sleeping HR → REM - const hrSpread = hrs.length ? Math.max(1, q(0.9) - q(0.1)) : 1; - const bigJump = Math.max(6, hrSpread * 0.6); // a clearly erratic minute - - let light = 0; - let deep = 0; - let rem = 0; - for (let i = 0; i < sleepEpochs.length; i++) { - const m = sleepEpochs[i]; - // Activity at or below the night's own mean = "quiet"; deep/REM need quiet - // epochs (sustained movement → light/arousal). - const lowAct = m.activity <= meanAct; - const hr = m.hr_avg > 0 ? m.hr_avg : meanHr; - const prev = i > 0 && sleepEpochs[i - 1].hr_avg > 0 ? sleepEpochs[i - 1].hr_avg : hr; - const next = i + 1 < sleepEpochs.length && sleepEpochs[i + 1].hr_avg > 0 - ? sleepEpochs[i + 1].hr_avg : hr; - const hrJump = Math.max(Math.abs(hr - prev), Math.abs(hr - next)); - if (lowAct && hr <= deepEdge) { - deep++; // quietest + HR in the night's lowest band → deep - } else if (lowAct && hr >= remEdge) { - rem++; // quiet + HR in the night's upper band → REM - } else if (lowAct && hrJump > bigJump) { - rem++; // mid-band but a clearly erratic minute → REM-leaning - } else { - light++; - } - } - return { light_min: light, deep_min: deep, rem_min: rem }; -} diff --git a/src/spo2.ts b/src/spo2.ts deleted file mode 100644 index 5876070..0000000 --- a/src/spo2.ts +++ /dev/null @@ -1,120 +0,0 @@ -// §SpO₂ — RELATIVE blood-oxygen index from the red/IR reflectance ratio. -// -// Pulse oximetry is grounded in the ratio R = red/IR: dividing the two LED -// channels cancels common-mode (perfusion, skin tone, contact, motion) that a -// single channel conflates. We do NOT have factory LED/photodiode calibration, -// and the 1 Hz historical record can't resolve the cardiac AC for a true -// ratio-of-ratios — so this is a RELATIVE index (tonight's median R vs the -// user's own baseline R), never an absolute %. Validated on 4 user datasets: -// the ratio is more stable than red-alone on clean signal (CV 0.79–4.81% vs -// 0.99–5.53%); on a noisy band it isn't, which is why the confidence below -// is gated on intra-night ratio stability + sample count — a noisy night -// yields low confidence (or null), never a misleading number. -import type { Metric, Driver, MetricRef } from './types'; -import { median, mean, stddev, round, clamp } from './util'; - -export interface Spo2Value { - /** Relative deviation vs personal baseline, in % of baseline ratio. - * Signed so that POSITIVE = better-oxygenated than your baseline (lower R). */ - index: number | null; - /** Tonight's median red/IR ratio (raw) — kept so the caller can roll the baseline. */ - night_ratio: number | null; -} - -const MIN_MINUTES = 30; // need ≥30 one-minute ratios for a defensible night -const PLAUSIBLE = (r: number) => r > 0.4 && r < 1.5; // reflectance-ratio sanity band -const CV_FLOOR = 0.08; // intra-night CV at/above which confidence → 0 - -/** - * calcSpo2Index(ratios, baselineRatio) - * - * ratios: per-minute red/IR ratios over the sleep window (one per usable minute). - * baselineRatio: the user's rolling baseline ratio, or null if not yet established. - * - * Returns a RELATIVE index + a computed confidence. With no baseline yet it reports - * night_ratio only (to seed the baseline) and a null index — honest, not guessed. - */ -export function calcSpo2Index(ratios: number[], baselineRatio: number | null): Metric { - const r = ratios.filter(PLAUSIBLE); - const none = (conf = 0): Metric => ({ - index: null, night_ratio: null, confidence: conf, tier: 'RELATIVE', inputs_used: [], - }); - if (r.length < MIN_MINUTES) return none(); - - const med = median(r); - if (med == null) return none(); - const nightR = round(med, 4); - const m = mean(r); - const cv = m > 0 ? stddev(r) / m : 1; - // confidence: more minutes (cap at 3 h) AND a stable within-night ratio. - const conf = round(clamp(Math.min(r.length / 180, 1) * Math.max(0, 1 - cv / CV_FLOOR), 0, 1), 3); - const inputs_used = ['spo2_red_raw', 'spo2_ir_raw']; - const ref: MetricRef = { metric: 'spo2', scale: 'day' }; - - // No personal baseline yet → seed value only, no fabricated index. - if (baselineRatio == null || !(baselineRatio > 0)) { - return { index: null, night_ratio: nightR, confidence: round(conf * 0.5, 3), tier: 'RELATIVE', inputs_used }; - } - - // + = lower ratio than baseline = more oxygenated than your norm. - const index = round(((baselineRatio - nightR) / baselineRatio) * 100, 2); - const drivers: Driver[] = [ - { label: 'Blood-oxygen vs baseline', contribution: index, detail: `R ${nightR} vs baseline ${round(baselineRatio, 4)}`, ref }, - ]; - return { index, night_ratio: nightR, confidence: conf, tier: 'RELATIVE', inputs_used, drivers }; -} - -export interface DesaturationValue { - /** count of relative-desaturation dips overnight (R rising ≥ DESAT_REL above baseline) */ - events: number; - /** desaturation index: events per hour of usable signal (apnea-screening ODI analogue) */ - odi: number | null; - /** deepest relative dip seen, in % of baseline ratio */ - deepest_pct: number | null; - note: string; -} - -const DESAT_REL = 0.04; // R rising ≥4% above baseline ≈ a relative desaturation dip -const DESAT_MINUTES = 1; // a dip must persist ≥1 min to count (collapse consecutive) - -/** - * calcDesaturation(ratios, baselineRatio) — nocturnal RELATIVE desaturation screen. - * - * A higher red/IR ratio than baseline = relatively LESS oxygenated. We count clustered - * dips where R rises ≥ DESAT_REL above the personal baseline and report an ODI-style - * events/hour. RELATIVE + SCREENING ONLY — no absolute %SpO₂, no diagnosis (we lack - * factory calibration and 1 Hz can't resolve true ratio-of-ratios). Confidence-gated: - * a noisy night or no baseline → low confidence / null, never a misleading number. - */ -export function calcDesaturation(ratios: number[], baselineRatio: number | null): Metric { - const NOTE = 'a screen, not a diagnosis'; - const r = ratios.filter(PLAUSIBLE); - const none = (conf = 0): Metric => ({ - events: 0, odi: null, deepest_pct: null, note: NOTE, confidence: conf, tier: 'RELATIVE', inputs_used: [], - }); - if (r.length < MIN_MINUTES || baselineRatio == null || !(baselineRatio > 0)) return none(); - - const thresh = baselineRatio * (1 + DESAT_REL); - let events = 0, run = 0, deepest = 0; - for (const v of r) { - if (v >= thresh) { - run++; - const dipPct = ((v - baselineRatio) / baselineRatio) * 100; - if (dipPct > deepest) deepest = dipPct; - if (run === DESAT_MINUTES) events++; // count once per sustained dip - } else { - run = 0; - } - } - const hours = Math.max(0.5, r.length / 60); - const m = mean(r); - const cv = m > 0 ? stddev(r) / m : 1; - const conf = round(clamp(Math.min(r.length / 180, 1) * Math.max(0, 1 - cv / CV_FLOOR), 0, 1), 3); - const drivers: Driver[] = [ - { label: 'Desaturation dips', contribution: events, detail: `${events} dips (${round(events / hours, 1)}/h)`, ref: { metric: 'spo2', scale: 'day' } }, - ]; - return { - events, odi: round(events / hours, 1), deepest_pct: round(deepest, 1), note: NOTE, - confidence: conf, tier: 'RELATIVE', inputs_used: ['spo2_red_raw', 'spo2_ir_raw'], drivers, - }; -} diff --git a/src/steps.ts b/src/steps.ts deleted file mode 100644 index e62011b..0000000 --- a/src/steps.ts +++ /dev/null @@ -1,92 +0,0 @@ -// §Steps — wrist pedometer (AN-2554, Analog Devices ADXL367 reference). PURE math -// over an already-decoded accelerometer-magnitude signal. The I/O around it — re- -// decoding the IMU frames from R2, ordering + per-minute grouping, persistence — -// lives in the backend runner (steps_imu.ts), exactly like the HRV/resp runners. -// -// Pipeline (per contiguous signal): -// sum-of-abs accel → low-pass moving average → centered-window max/min peak -// detection → dynamic threshold ± sensitivity/2 → CONFIRM consecutive "possible -// steps" to confirm (the regularity gate that rejects waving/typing/handling — -// validated to read 0 at rest). Params scaled to our ~100 Hz IMU; a calibration -// gain corrects the normal ~10% wrist undercount (locked vs a 100-step ground- -// truth walk: raw 90 → ×1.11 ≈ 100). - -// ── locked AN-2554 parameters (calibrated on a 100-step ground-truth walk) ── -const FS = 100 // assembled IMU sample rate (Hz) -const FILTER = 8 // low-pass moving-average taps -const WINDOW = 33 // centered peak window (~0.33 s @100 Hz) -const SENS = 0.10 // g — dead-zone around the dynamic threshold -const THR_ORDER = 4 // dynamic-threshold smoothing buffer -const CONFIRM = 8 // consecutive possible steps before counting (rejects non-gait) -const MAXMIN_TIMEOUT = 120 // samples to find a min after a max (~1.2 s) -const GAIN = 1.11 // calibration: raw 90 → ~100 on the ground-truth walk - -/** The locked parameters, exposed for documentation/tests. */ -export const STEP_PARAMS = { - FS, FILTER, WINDOW, SENS, THR_ORDER, CONFIRM, MAXMIN_TIMEOUT, GAIN, -} as const - -/** - * pedometer(sig) — AN-2554 time-domain step count over ONE contiguous - * accelerometer-magnitude signal (g, ~100 Hz). Raw count, no calibration gain. - */ -export function pedometer(sig: number[]): number { - const n = sig.length - if (n < WINDOW) return 0 - // low-pass: trailing moving average - const lp = new Array(n) - let acc = 0 - for (let i = 0; i < n; i++) { - acc += sig[i] - if (i >= FILTER) acc -= sig[i - FILTER] - lp[i] = acc / Math.min(i + 1, FILTER) - } - const half = WINDOW >> 1 - // centered-window extrema candidates - const cand: { i: number; max: boolean; v: number }[] = [] - for (let i = half; i < n - half; i++) { - let isMax = true, isMin = true - const v = lp[i] - for (let j = i - half; j <= i + half; j++) { - if (lp[j] > v) isMax = false - if (lp[j] < v) isMin = false - if (!isMax && !isMin) break - } - if (isMax) cand.push({ i, max: true, v }) - else if (isMin) cand.push({ i, max: false, v }) - } - // dynamic threshold + CONFIRM-step regularity - const dyn: number[] = [] - let dynVal = sig.reduce((s, v) => s + v, 0) / n - let steps = 0, poss = 0, regulation = false - let state: 'max' | 'min' = 'max' - let curMax = 0, curMaxIdx = -1 - for (const c of cand) { - if (state === 'max') { - if (c.max) { curMax = c.v; curMaxIdx = c.i; state = 'min' } - } else { - if (c.max) { if (c.v > curMax) { curMax = c.v; curMaxIdx = c.i } continue } - if (c.i - curMaxIdx > MAXMIN_TIMEOUT) { state = 'max'; poss = 0; regulation = false; continue } - const mx = curMax, mn = c.v - if (mx > dynVal + SENS / 2 && mn < dynVal - SENS / 2) { - if (mx - mn > SENS) { dyn.push((mx + mn) / 2); if (dyn.length > THR_ORDER) dyn.shift(); dynVal = dyn.reduce((s, v) => s + v, 0) / dyn.length } - poss++ - if (regulation) steps++ - else if (poss >= CONFIRM) { steps += poss; regulation = true } - } else { poss = 0; regulation = false } - state = 'max' - } - } - return steps -} - -/** - * calcSteps(minuteSignals) — run the pedometer over each per-minute contiguous - * signal, sum, and apply the calibration gain. `minuteSignals[m]` is the ordered - * (ts, sub-frame) magnitude samples for minute m. Returns the calibrated daily total. - */ -export function calcSteps(minuteSignals: number[][]): number { - let total = 0 - for (const sig of minuteSignals) total += pedometer(sig) - return Math.round(total * GAIN) -} diff --git a/src/strain.ts b/src/strain.ts deleted file mode 100644 index 59f3372..0000000 --- a/src/strain.ts +++ /dev/null @@ -1,54 +0,0 @@ -// §2 Strain — Banister TRIMP over HR reserve, log-scaled to 0..21. Tier HIGH. -import type { Minute, Baseline, Profile, Metric, StrainValue } from './types'; -import { isHrUsable, resolveMaxHr, clamp, round } from './util'; - -/** - * calcStrain(minutes, baseline, profile?) - * - * Per worn minute with hr_avg>0: - * ratio = clamp((hr_avg − RHR)/(maxHR − RHR), 0, 1) - * trimp += ratio * k * e^(b*ratio) - * where (k,b) are Banister's sex-specific weights: men (0.64, 1.92), women - * (0.86, 1.67). Sex unknown → men's weights (the classic default; keeps prior - * behaviour for sex-less profiles). - * score = min(21, log(trimp+1)/log(1.5)), rounded to 0.01. - * maxHR = measured session max if available else 220−age (see resolveMaxHr). - * - * Confidence formula: clamp(worn_min / 30, 0, 1) - * (coverage proxy: 1.0 once ≥30 worn minutes are present; degrades linearly). - */ -export function calcStrain( - minutes: Minute[], - baseline: Baseline, - profile?: Profile -): Metric { - const { maxHr, source } = resolveMaxHr(minutes, baseline, profile); - const rhr = baseline.resting_hr; - const worn = minutes.filter(isHrUsable); - - // Banister TRIMP weights: women (0.86, 1.67), men / unknown (0.64, 1.92). - const [k, b] = profile?.sex === 'f' ? [0.86, 1.67] : [0.64, 1.92]; - let trimp = 0; - const denom = maxHr - rhr; - for (const m of worn) { - if (denom <= 0) continue; - const ratio = clamp((m.hr_avg - rhr) / denom, 0, 1); - trimp += ratio * k * Math.exp(b * ratio); - } - - const score = Math.min(21, Math.log(trimp + 1) / Math.log(1.5)); - const confidence = clamp(worn.length / 30, 0, 1); - - const inputs_used = ['hr_avg', 'baseline.resting_hr']; - inputs_used.push(source === 'measured' ? 'baseline.max_hr' : 'profile.age'); - - return { - score: round(score, 2), - trimp: round(trimp, 4), - max_hr_used: maxHr, - max_hr_source: source, - confidence: round(confidence, 4), - tier: 'HIGH', - inputs_used, - }; -} diff --git a/src/stress.ts b/src/stress.ts deleted file mode 100644 index 474a116..0000000 --- a/src/stress.ts +++ /dev/null @@ -1,69 +0,0 @@ -// §Stress — HRV-based, replacing the old "HR-above-rest while sedentary" heuristic. -// Stress = sympathetic activation read from beat-to-beat RR: the Baevsky Stress -// Index (SI) and LF/HF balance (Task Force 1996). Scored PERSONAL-RELATIVE: today's -// ln(SI) as a z against the user's own rolling baseline SI distribution — so a -// "stress level" means "high for you", not vs a population constant we'd have to -// invent. No baseline yet → report the raw indices, no score (honest, not guessed). -import type { Metric, StressValue, Driver, MetricRef } from './types'; -import { mean, stddev, round } from './util'; -import { timeDomainHrv, freqDomainHrv, baevskyStressIndex } from './hrv'; - -/** - * calcStress(rr, baselineSI[], opts?) - * - * rr: time-ordered RR-interval stream (ms) over the window (a day, or a sleep - * window for nocturnal stress). baselineSI: prior windows' SI for the z-score. - * Tier ESTIMATE (HRV stress is a validated index, but absolute interpretation is - * personal and noisy — so ESTIMATE with computed confidence). - */ -export function calcStress( - rr: number[], - baselineSI: number[], - opts: { date?: string } = {}, -): Metric { - const si = baevskyStressIndex(rr); - const td = timeDomainHrv(rr); - const fd = freqDomainHrv(rr); - - const none = (): Metric => ({ - score: null, si: si.si, lf_hf: fd.lf_hf, rmssd: td.rmssd, level: null, - confidence: 0, tier: 'ESTIMATE', inputs_used: [], - }); - if (si.si == null) return none(); - - const usableBase = baselineSI.filter((x) => x > 0); - const ref: MetricRef = { metric: 'hrv', date: opts.date, scale: 'day' }; - const drivers: Driver[] = [ - { label: 'Baevsky Stress Index', contribution: round(si.si, 1), detail: `SI ${si.si}`, ref }, - ]; - if (fd.lf_hf != null) drivers.push({ label: 'Sympatho-vagal balance (LF/HF)', contribution: round(fd.lf_hf, 2), detail: `LF/HF ${fd.lf_hf}`, ref }); - if (td.rmssd != null) drivers.push({ label: 'HRV (RMSSD)', contribution: round(-(td.rmssd), 1), detail: `${td.rmssd} ms`, ref }); - - // No personal baseline yet → indices only, no fabricated score. - if (usableBase.length < 5) { - return { - score: null, si: si.si, lf_hf: fd.lf_hf, rmssd: td.rmssd, level: null, - confidence: round(Math.min(0.4, si.n_beats / 300), 4), tier: 'ESTIMATE', - inputs_used: ['hrv_si', 'hrv_lf_hf'], drivers, - }; - } - - const lnBase = usableBase.map((x) => Math.log(x)); - const m = mean(lnBase); - const sd = stddev(lnBase); - let score: number | null = null; - let z: number | null = null; - if (sd > 0) { - z = (Math.log(si.si) - m) / sd; // higher SI ⇒ more stress - score = Math.max(0, Math.min(100, Math.round(50 + 25 * z))); - } - const level: StressValue['level'] = - score == null ? null : score < 40 ? 'low' : score <= 70 ? 'moderate' : 'elevated'; - const confidence = Math.min(1, usableBase.length / 21) * Math.min(1, si.n_beats / 300); - - return { - score, si: si.si, lf_hf: fd.lf_hf, rmssd: td.rmssd, level, - confidence: round(confidence, 4), tier: 'ESTIMATE', - inputs_used: ['hrv_si', 'hrv_lf_hf', 'baseline.hrv_si'], drivers, - }; -} diff --git a/src/trends.ts b/src/trends.ts deleted file mode 100644 index a4cd33f..0000000 --- a/src/trends.ts +++ /dev/null @@ -1,134 +0,0 @@ -// §9 Training load (calcLoad, Tier HIGH) + fitness trend (calcFitnessTrend, Tier -// ESTIMATE — directional only, never a VO2max number). -import type { - DailyStrain, - DayHistory, - Metric, - LoadValue, - FitnessTrendValue, -} from './types'; -import { mean, linregSlope, round } from './util'; - -/** - * calcLoad(dailyStrain[]) - * - * ACWR = acute/chronic. acute = mean daily strain last 7d; chronic = mean last 28d. - * Bands: <0.8 detraining, 0.8–1.3 optimal, 1.3–1.5 caution, >1.5 high-risk. - * - * Confidence formula: clamp(days_available/28, 0, 1) — chronic needs ~28d to be - * meaningful; fewer days → lower confidence. Needs ≥7d for any acwr. - */ -export function calcLoad(dailyStrain: DailyStrain[]): Metric { - const sorted = [...dailyStrain].sort((a, b) => a.ts - b.ts); - const days = sorted.length; - - if (days < 7) { - return { - acwr: null, - acute: 0, - chronic: 0, - band: 'unknown', - confidence: round(Math.min(1, days / 28), 4), - tier: 'HIGH', - inputs_used: ['daily_strain'], - }; - } - - // EWMA acute/chronic (Williams et al. 2017, BJSM 51:209) — exponentially- - // weighted, λ = 2/(N+1) with N=7 (acute) / N=28 (chronic). Decays older load - // smoothly instead of the rolling-average "cliff", and is more sensitive to - // when load actually occurred. Seed both at the first day's strain. - const LAMBDA_ACUTE = 2 / (7 + 1); // 0.25 - const LAMBDA_CHRONIC = 2 / (28 + 1); // ≈0.069 - let acute = sorted[0].strain; - let chronic = sorted[0].strain; - for (let i = 1; i < sorted.length; i++) { - acute = sorted[i].strain * LAMBDA_ACUTE + acute * (1 - LAMBDA_ACUTE); - chronic = sorted[i].strain * LAMBDA_CHRONIC + chronic * (1 - LAMBDA_CHRONIC); - } - const acwr = chronic > 0 ? acute / chronic : null; - - let band: LoadValue['band'] = 'unknown'; - if (acwr != null) { - if (acwr < 0.8) band = 'detraining'; - else if (acwr <= 1.3) band = 'optimal'; - else if (acwr <= 1.5) band = 'caution'; - else band = 'high-risk'; - } - - return { - acwr: acwr == null ? null : round(acwr, 3), - acute: round(acute, 3), - chronic: round(chronic, 3), - band, - confidence: round(Math.min(1, days / 28), 4), - tier: 'HIGH', - inputs_used: ['daily_strain'], - }; -} - -/** - * calcFitnessTrend(daily[]) - * - * Rolling 7d RHR and rolling 7d session-HRR60 over the history. Fitness improving - * when RHR slope < 0 AND HRR slope > 0 over ~4 weeks. Output direction + the two - * slopes. NEVER emits an absolute VO2max number. Tier ESTIMATE — it's a noisy - * directional read from two proxy slopes, not a measured fitness score. - * - * Confidence formula: min(0.8, (days/21)*0.8) — spec pins ≥21 days → 0.8; below - * that it ramps linearly. 'unknown' until ≥7 days & ≥3 RHR points. - */ -export function calcFitnessTrend(daily: DayHistory[]): Metric { - const rhrSeries: number[] = []; - const hrrSeries: number[] = []; - for (const d of daily) { - if (d.resting_hr != null) rhrSeries.push(d.resting_hr); - if (d.hrr60 != null) hrrSeries.push(d.hrr60); - } - - const days = daily.length; - if (days < 7 || rhrSeries.length < 3) { - return { - direction: 'unknown', - rhr_slope: 0, - hrr_slope: 0, - days_used: days, - confidence: round(Math.min(0.8, (days / 21) * 0.8), 4), - tier: 'ESTIMATE', - inputs_used: ['resting_hr', 'hrr60'], - }; - } - - const rhrRoll = rollingMean(rhrSeries, 7); - const hrrRoll = hrrSeries.length >= 3 ? rollingMean(hrrSeries, 7) : []; - - const rhrSlope = linregSlope(rhrRoll); - const hrrSlope = hrrRoll.length >= 2 ? linregSlope(hrrRoll) : 0; - - let direction: FitnessTrendValue['direction']; - if (rhrSlope < 0 && hrrSlope > 0) direction = 'improving'; - else if (rhrSlope > 0 && (hrrSlope < 0 || hrrRoll.length < 2)) direction = 'declining'; - else direction = 'flat'; - - const confidence = Math.min(0.8, (days / 21) * 0.8); - - return { - direction, - rhr_slope: round(rhrSlope, 5), - hrr_slope: round(hrrSlope, 5), - days_used: days, - confidence: round(confidence, 4), - tier: 'ESTIMATE', - inputs_used: ['resting_hr', 'hrr60'], - }; -} - -/** Trailing rolling mean of window w; output length = input length (ramps in). */ -function rollingMean(values: number[], w: number): number[] { - const out: number[] = []; - for (let i = 0; i < values.length; i++) { - const start = Math.max(0, i - w + 1); - out.push(mean(values.slice(start, i + 1))); - } - return out; -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index a58e9ca..0000000 --- a/src/types.ts +++ /dev/null @@ -1,344 +0,0 @@ -// Shared input/output types for openstrap-analytics. -// These match docs/ANALYTICS_SPEC.md and docs/CONFIDENCE.md exactly. -// -// All analytics consume arrays of MINUTE ROLLUPS (not 1Hz samples). Each fn is a -// pure, deterministic function returning a Metric with a COMPUTED confidence. - -/** One minute rollup. `activity` is the actigraphy signal (stddev of |accel(g)|). */ -export interface Minute { - ts: number; // unix seconds at the start of the minute - hr_avg: number; // mean HR over the minute (0 = off-wrist / no reading) - hr_min: number; - hr_max: number; - hr_n: number; // number of HR samples that contributed - activity: number; // motion magnitude (actigraphy count) - steps: number; // detected steps this minute (accel peak-count; ESTIMATE) - wrist_on: boolean; - // Dominant HAR activity class for this minute, classified at ingest from the live - // high-rate accel (see har.ts). Present ONLY for live-streamed minutes (flash is - // 1 Hz → no motion texture). Drives workout typing + segmentation in detectSessions. - act_class?: ActivityClass; -} - -/** Wrist activity-recognition class (Mannini 2013). Canonical home for the shared type. */ -export type ActivityClass = 'sedentary' | 'walk' | 'run' | 'cycle' | 'lift' | 'other'; - -/** One labelled phase of a workout (graceful activity switches). */ -export interface SessionSegment { start_ts: number; end_ts: number; type: string; confidence: number } - -/** User profile. Fields are frequently absent — algorithms must degrade honestly. */ -export interface Profile { - age?: number; - weight_kg?: number; - height_cm?: number; - sex?: 'm' | 'f'; -} - -/** Rolling baselines (see calcBaselines). */ -export interface Baseline { - resting_hr: number; - max_hr: number; - sleep_need_min: number; - skin_temp?: number; // RELATIVE only; deviation, never absolute health truth - chronic_strain?: number; // 28d mean daily strain (for ACWR) -} - -export type Tier = 'AUTH' | 'HIGH' | 'ESTIMATE' | 'RELATIVE'; - -/** A pointer the UI can navigate to: another metric (optionally at a date/scale). - * This is the edge of the cross-metric "driver graph" — every contributor links - * to its own deep-dive. */ -export interface MetricRef { - metric: string; // 'hr' | 'hrv' | 'rhr' | 'sleep' | 'activity' | 'strain' | ... - date?: string; // YYYY-MM-DD - scale?: 'day' | 'week' | 'month' | 'quarter'; -} - -/** One ranked contributor to a metric's value — "what affected this number". - * `contribution` is signed (positive = pushed the value up). `ref` makes it - * tappable in the UI (deep-dive into the cause). */ -export interface Driver { - label: string; // human label, e.g. "Elevated heart rate" - contribution: number; // signed magnitude (units are metric-specific / normalized) - detail?: string; // e.g. "82 bpm vs your 61 resting" - ref?: MetricRef; // where tapping this driver navigates -} - -/** Every metric returns its value plus computed confidence + provenance, and - * (optionally) the ranked drivers that explain it. */ -export type Metric = T & { - confidence: number; // 0..1, COMPUTED (coverage × input_completeness) - tier: Tier; - inputs_used: string[]; - drivers?: Driver[]; -}; - -// ── HRV-derived value shapes (see hrv.ts / recovery.ts / stress.ts) ────────── - -/** Recovery from nocturnal HRV (Plews et al. 2013 — ln RMSSD vs rolling baseline). */ -export interface RecoveryValue { - score: number | null; // 0..100, null when no usable HRV - rmssd: number | null; // tonight's nocturnal RMSSD (ms) - baseline_rmssd: number | null; // rolling mean RMSSD (ms) - z: number | null; // ln-RMSSD z vs baseline (sd units) - note: string; // "HRV-based" -} - -/** Stress from HRV (Baevsky Stress Index + LF/HF). Personal-relative when a - * baseline SI distribution is available. */ -export interface StressValue { - score: number | null; // 0..100 (percentile/z vs personal baseline SI) - si: number | null; // Baevsky Stress Index - lf_hf: number | null; // sympatho-vagal balance - rmssd: number | null; // ms (context) - level: 'low' | 'moderate' | 'elevated' | null; -} - -/** Multivariate illness / under-recovery signal (Mahalanobis distance). */ -export interface IllnessValue { - signal: boolean; - distance: number | null; // Mahalanobis distance from personal baseline - triggers: string[]; // which features deviated (rhr/rmssd/temp) - note: string; // "a signal, not a diagnosis" -} - -/** Nocturnal arousal / sleep-stress (HR surges + RMSSD dips + motion in sleep). */ -export interface SleepStressValue { - score: number | null; // 0..100 nocturnal arousal load - arousal_events: number; // count of HR-surge + motion events - restless_min: number; // minutes with elevated motion during sleep - mean_sleeping_hr: number | null; - events: { ts: number; kind: 'arousal' | 'restless' }[]; // for the hypnogram overlay -} - -// ── value shapes (wrapped by Metric<>) ────────────────────────────────────── - -export interface RestingHrValue { - resting_hr: number | null; // bpm, null when no usable data -} - -export interface StrainValue { - score: number; // 0..21 - trimp: number; - max_hr_used: number; - max_hr_source: 'measured' | 'age'; -} - -export interface HrZonesValue { - zone1_min: number; // 50-60% HRmax - zone2_min: number; // 60-70% - zone3_min: number; // 70-80% - zone4_min: number; // 80-90% - zone5_min: number; // 90-100% - max_hr_used: number; - max_hr_source: 'measured' | 'age'; -} - -export interface CaloriesValue { - kcal: number; - label: string; // always carries "(est.)" -} - -export interface SleepStages { - light_min: number; - deep_min: number; - rem_min: number; -} - -export interface SleepValue { - onset_ts: number | null; - wake_ts: number | null; - duration_min: number; // asleep minutes - in_bed_min: number; - efficiency: number; // 0..1 - stages: SleepStages | null; // BETA/ESTIMATE - stages_beta: boolean; -} - -// ── Sleep v2 (multi-period) — naps are just shorter sleeps ─────────────────── -/** One consolidated sleep period. Same breakdown as SleepValue, per-period. */ -export interface SleepPeriod { - onset_ts: number; - wake_ts: number; - duration_min: number; // asleep minutes - in_bed_min: number; - efficiency: number; // 0..1 - stages: SleepStages | null; // BETA/ESTIMATE - is_main: boolean; // longest period of the day (UI hint only; data is uniform) - confidence: number; // per-period detection confidence (0..1) -} - -/** All sleep periods detected in a window (one card each in the UI). */ -export interface SleepPeriodsValue { - periods: SleepPeriod[]; // chronological - total_asleep_min: number; // sum across periods - main_idx: number | null; // index of the main (longest) period, or null - stages_beta: boolean; -} - -export interface SleepRegularityValue { - sri: number; // 0..100 - onset_std_min: number; - wake_std_min: number; - nights_used: number; -} - -export interface SessionValue { - start_ts: number; - end_ts: number; - duration_min: number; - avg_hr: number; - max_hr: number; - strain: number; - trimp: number; - kcal: number; - zones: HrZonesValue; - hrr60: number | null; - mean_activity: number; - peak_activity: number; - type: string; // ActivityClass when motion-classified, else legacy 'walk'|'run/cardio'|'strength/other' - type_confidence: number; // ESTIMATE - segments?: SessionSegment[]; // labelled phases (multi-activity workouts) - detected_type?: string; // the model's call at detection (for the calibration ledger) -} - -export interface HrRecoveryValue { - hrr60: number | null; // bpm dropped ~60s after peak - peak_hr: number | null; -} - -export interface LoadValue { - acwr: number | null; - acute: number; // mean daily strain last 7d - chronic: number; // mean daily strain last 28d - band: 'detraining' | 'optimal' | 'caution' | 'high-risk' | 'unknown'; -} - -export interface FitnessTrendValue { - direction: 'improving' | 'flat' | 'declining' | 'unknown'; - rhr_slope: number; // per-day slope of rolling 7d RHR - hrr_slope: number; // per-day slope of session HRR60 - days_used: number; -} - -// VO₂max — Uth–Sørensen (2004), HR-ratio estimate. Tier ESTIMATE. -export interface Vo2MaxValue { - vo2max: number | null; // ml/kg/min - method: string; -} - -// Banister impulse-response: Fitness (CTL, slow EWMA of strain), Fatigue (ATL, -// fast EWMA), Form/TSB = Fitness − Fatigue. Tier ESTIMATE. -export interface FitnessModelValue { - fitness: number | null; // chronic training load (slow) - fatigue: number | null; // acute training load (fast) - form: number | null; // fitness − fatigue (freshness) -} - -// Foster training monotony + strain (7-day mean/SD of daily strain). -export interface MonotonyValue { - monotony: number | null; // mean/SD of last-7-day strain - training_strain: number | null; // weekly_load × monotony - weekly_load: number; -} - -// HRV stability — coefficient of variation of nocturnal RMSSD over a window. -export interface HrvStabilityValue { - cv: number | null; // % (SD/mean × 100) - mean_rmssd: number | null; - n: number; -} - -// Irregular-rhythm SCREEN (not a diagnosis): Poincaré SD1/SD2 + high ectopic/ -// successive-difference fraction from nocturnal RR. -export interface IrregularValue { - flag: boolean; - sd1: number | null; - sd2: number | null; - ratio: number | null; // sd1/sd2 - pnn50: number | null; - ectopic_frac: number | null; // share of beats rejected as ectopic/irregular - note: string; -} - -// Composite Readiness — transparent weighted blend (recovery + sleep + dip + -// arousal). Abstains (null) until HRV-recovery exists. Tier ESTIMATE. -export interface ReadinessIndexValue { - score: number | null; // 0..100 - components: { - recovery: number | null; - sleep: number | null; - dip: number | null; - arousal: number | null; - }; - note: string; -} - -export interface ReadinessComponents { - rhr: number; // 0..1 - sleep_debt: number; // 0..1 - sleep_quality: number; // 0..1 - temp_adjust: number; // multiplicative factor applied (1 if none) -} - -export interface ReadinessValue { - score: number; // 0..100 - components: ReadinessComponents; - note: string; // ALWAYS "(est.) — not HRV-based" -} - -export interface AnomalyValue { - signal: boolean; - triggers: string[]; // which inputs fired - note: string; // "signal, not a diagnosis" -} - -export interface BaselinesValue { - resting_hr: number | null; - sleep_need_min: number | null; - skin_temp: number | null; // RELATIVE - max_hr: number | null; - max_hr_source: 'measured' | 'age'; - chronic_strain: number | null; - zone_min: [number, number, number, number, number] | null; // median per-zone minutes - days_used: number; -} - -// CircaCP circadian rhythm + main-sleep boundary (see circadian.ts). Cosinor -// phase is timezone-free (the physiological day anchor); onset/wake are the -// main-sleep boundary of the most-recent completed cycle. -export interface CircadianValue { - mesor: number | null; // bpm, rhythm-adjusted mean HR - amplitude: number | null; // bpm, cosinor amplitude (half peak-to-trough) - acrophase_ts: number | null; // unix s, HR peak (active-phase center) near window end - bathyphase_ts: number | null; // unix s, HR trough (rest-phase center) of the detected cycle - onset_ts: number | null; // main-sleep onset (HR drop), or null - wake_ts: number | null; // main-sleep wake (HR rise), or null - in_bed_min: number; // wake − onset, minutes - settled: boolean; // wake older than the settle window → night complete -} - -// ── history shapes for trend/baseline fns ─────────────────────────────────── - -/** One night's sleep summary (for SRI). */ -export interface NightSummary { - onset_ts: number | null; - wake_ts: number | null; -} - -/** One day of aggregate history (for baselines / load / fitness). */ -export interface DayHistory { - resting_hr?: number; - sleep_duration_min?: number; - skin_temp?: number; - daily_strain?: number; - session_hr_max?: number; // max session peak that day (for measured maxHR) - hrr60?: number; // representative session HRR60 that day - zone_min?: [number, number, number, number, number]; -} - -/** Per-day strain entry for ACWR / fitness. */ -export interface DailyStrain { - ts: number; // unix seconds (day) - strain: number; -} diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index e4260d4..0000000 --- a/src/util.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Shared pure helpers. No I/O. -import type { Minute, Profile, Baseline } from './types'; - -/** A worn minute usable for HR math: wrist on AND a real HR reading (>0). */ -export function isHrUsable(m: Minute): boolean { - return m.wrist_on && m.hr_avg > 0; -} - -/** Linear-interpolated percentile (p in [0,100]) of a numeric array. */ -export function percentile(values: number[], p: number): number | null { - if (values.length === 0) return null; - const sorted = [...values].sort((a, b) => a - b); - if (sorted.length === 1) return sorted[0]; - const rank = (p / 100) * (sorted.length - 1); - const lo = Math.floor(rank); - const hi = Math.ceil(rank); - if (lo === hi) return sorted[lo]; - const frac = rank - lo; - return sorted[lo] + (sorted[hi] - sorted[lo]) * frac; -} - -export function median(values: number[]): number | null { - return percentile(values, 50); -} - -export function clamp(x: number, lo: number, hi: number): number { - return Math.max(lo, Math.min(hi, x)); -} - -export function mean(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((a, b) => a + b, 0) / values.length; -} - -export function stddev(values: number[]): number { - if (values.length < 2) return 0; - const m = mean(values); - const v = values.reduce((a, b) => a + (b - m) * (b - m), 0) / values.length; - return Math.sqrt(v); -} - -/** Least-squares slope of y vs x (x = 0..n-1 if omitted). Returns 0 if <2 points. */ -export function linregSlope(y: number[], x?: number[]): number { - const n = y.length; - if (n < 2) return 0; - const xs = x ?? y.map((_, i) => i); - const mx = mean(xs); - const my = mean(y); - let num = 0; - let den = 0; - for (let i = 0; i < n; i++) { - num += (xs[i] - mx) * (y[i] - my); - den += (xs[i] - mx) * (xs[i] - mx); - } - return den === 0 ? 0 : num / den; -} - -export function round(x: number, decimals: number): number { - const f = Math.pow(10, decimals); - return Math.round(x * f) / f; -} - -/** - * Resolve max HR per spec: measured session max if available, else 220 − age. - * There is NO biometric "age detection" — we never fabricate an age. If age is - * absent and no measured max exists, we cannot compute an age-based max, so we - * fall back to whatever measured max the minutes themselves expose. - * - * @returns { maxHr, source } where source is 'measured' or 'age'. - */ -export function resolveMaxHr( - minutes: Minute[], - baseline: Pick, - profile?: Profile -): { maxHr: number; source: 'measured' | 'age' } { - // 1. Prefer a measured max carried on the baseline (rolling observed SESSION - // max — a real peak effort, accumulated across days). This is the stable, - // trustworthy denominator. - if (baseline.max_hr && baseline.max_hr > 0) { - return { maxHr: baseline.max_hr, source: 'measured' }; - } - - // The highest HR seen in THIS window. NOTE: on a low-activity day this is just - // the day's quiet peak (e.g. 110 bpm) — NOT a true HRmax. Using it directly as - // the zone/strain denominator over-states zone occupancy and inflates strain on - // sedentary days, and varies wildly day-to-day. So we only treat it as a - // 'measured' max when it actually EXCEEDS the age-predicted max (a genuine hard - // effort); otherwise the age floor wins so a quiet peak can't shrink the scale. - const observed = minutes - .filter(isHrUsable) - .reduce((mx, m) => Math.max(mx, m.hr_max, m.hr_avg), 0); - - // 2. Age-predicted max (floor), taking a genuine above-age effort if present. - if (profile?.age && profile.age > 0) { - // Tanaka, Monahan & Seals 2001 (JACC 37:153) — validated on 18,712 subjects, - // more accurate than 220−age (which over-estimates in the young, under- - // estimates in the old; they converge near age 40). - const ageMax = Math.round(208 - 0.7 * profile.age); - if (observed > ageMax) return { maxHr: observed, source: 'measured' }; - return { maxHr: ageMax, source: 'age' }; - } - - // 3. No age, but we have an observed peak: best available, but flag it 'age' - // (not 'measured') so callers down-weight confidence — it's an unverified - // within-window peak, not a true measured HRmax. - if (observed > 0) { - return { maxHr: observed, source: 'age' }; - } - - // 4. No data at all: honest neutral fallback (population HRmax ~190) used ONLY - // as a denominator guard so HR-zone math doesn't divide by zero — callers - // should down-weight confidence. - return { maxHr: 190, source: 'age' }; -} diff --git a/src/wake.ts b/src/wake.ts deleted file mode 100644 index af05d13..0000000 --- a/src/wake.ts +++ /dev/null @@ -1,326 +0,0 @@ -// wake.ts — sleep/wake state ENSEMBLE for the demand-driven day-close trigger. -// -// Purpose: from an assembled window of minute rollups, answer "is the user asleep -// or awake right now, and if they just woke, when?" — cheaply, deterministically, -// and HONESTLY (abstain to 'unknown' rather than fabricate). This is the gate that -// fires the once-per-day Tier-3/4 derivation at the user's REAL wake, not a clock. -// -// Method: a PLUGGABLE registry of per-minute voters, each emitting asleep|awake| -// unknown for every minute. We majority-vote per minute, consolidate into bouts, -// then report the current state + the most-recent sleep→wake boundary. Voters are -// swappable; the initial set is grounded in published, NON-TRAINED methods that fit -// our exact per-minute signals (hr_avg, activity, and optional RR): -// -// • coleKripke — Cole-Kripke 1992 actigraphy weighted-sum (benchmark-competitive -// among non-DL methods; ActiGraph/Newcastle PSG eval 2023). -// • cardiac — CPD's core finding (Cakmak 2020, Sleep): cardiac change-points -// carry the WAKE signal actigraphy misses. Uses HR level + change -// vs the night's own trough, and RR-SD (HRV) when RR is supplied. -// • inactivity — van Hees-style sustained-low-movement = sleep (non-trained). -// -// Thresholds are SELF-CALIBRATING off the window's own distribution (our `activity` -// is stddev-of-|accel|, not actigraph counts, so absolute published thresholds don't -// transfer — we use the published SHAPE with adaptive scaling). Tier = ESTIMATE; we -// do NOT claim the papers' trained-model accuracy. Validate against ground truth. - -import type { Minute, Baseline } from './types'; -import { isHrUsable } from './util'; - -export type WakeLabel = 'asleep' | 'awake' | 'unknown'; - -export interface WakeContext { - minutes: Minute[]; // time-ordered window (should span the night) - baseline: Baseline; // resting_hr anchor - rrByMin?: Map; // optional per-minute RR (ms) — enables the HRV arm of the cardiac voter - now?: number; // evaluation instant (default = last minute ts) -} - -/** A voter labels EVERY minute in the window. Pure; no I/O. */ -export type Voter = (ctx: WakeContext) => WakeLabel[]; - -export interface WakeState { - state: WakeLabel; // current state at `now` - wake_ts: number | null; // start of the most-recent sustained wake bout (the day boundary) - onset_ts: number | null; // start of the main sleep bout preceding that wake - awake_min: number; // sustained awake minutes ending at `now` - asleep_min: number; // minutes asleep in the main bout - votes: Record; // each voter's verdict at `now` (transparency) - confidence: number; // 0..1 (voter agreement × data coverage) -} - -// ── helpers ────────────────────────────────────────────────────────────────── -const MIN = 60; -const MIN_MAIN_SLEEP_MIN = 90; // a real night, not a catnap, must precede a "wake" -const SUSTAINED_WAKE_MIN = 10; // your rule: awake must hold ≥10 min before we fire - -function median(xs: number[]): number { - if (!xs.length) return 0; - const s = [...xs].sort((a, b) => a - b); - const m = s.length >> 1; - return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; -} - -// ── Voter 1: Cole-Kripke (actigraphy weighted-sum, published shape) ─────────── -// D = P·Σ wᵢ·Aᵢ over [−4..+2] minutes; sleep if D < 1. Our `activity` is rescaled -// to the window so the unit-dependent constants behave (self-calibrating). -const CK_W = [106, 54, 58, 76, 230, 74, 67]; // weights for A[-4..+2] -const CK_P = 0.001; -export const coleKripke: Voter = ({ minutes }) => { - const act = minutes.map((m) => (m.wrist_on ? m.activity : 0)); - // normalize activity to a counts-like scale: median wake-ish activity ≈ unit. - const nz = act.filter((a) => a > 0); - const scale = median(nz) || 1; // counts ≈ activity/scale - const n = minutes.length; - return minutes.map((_, i) => { - if (!isHrUsable(minutes[i]) && minutes[i].activity === 0) return 'unknown'; - let d = 0; - for (let k = -4; k <= 2; k++) { - const j = i + k; - if (j < 0 || j >= n) continue; - d += CK_W[k + 4] * (act[j] / scale); - } - d *= CK_P; - return d < 1 ? 'asleep' : 'awake'; - }); -}; - -// ── Voter 2: Cardiac (CPD core — the wake signal actigraphy misses) ─────────── -// Awake when SMOOTHED HR sits above the night's cardiac trough by a margin. This is -// the band's single most reliable wake signal (quiet wake has elevated HR but no -// motion — exactly where the actigraphy voters go blind). Smoothing (±2 min) means a -// single REM/arousal HR spike can't flip a minute to 'awake'; only a sustained rise -// does. The RR/HRV arm now lives in its OWN voter (hrvArousal) so it isn't -// double-counted. Trough is self-calibrating off the window, floored by RHR. -export const cardiac: Voter = ({ minutes, baseline }) => { - const usable = minutes.filter(isHrUsable).map((m) => m.hr_avg); - const sorted = [...usable].sort((a, b) => a - b); - const p10 = sorted.length ? sorted[Math.floor(sorted.length * 0.1)] : baseline.resting_hr; - const trough = Math.max(baseline.resting_hr || 0, p10 || 0) || (sorted[0] ?? 0); - const wakeMargin = 8; // bpm above the sleeping trough → cardiac wake (separates wake from REM) - // ±2-min median-smoothed HR (NaN where unusable) so spikes don't fragment the vote. - const hr = minutes.map((m) => (isHrUsable(m) ? m.hr_avg : NaN)); - const hs = hr.map((_, i) => { - const seg = hr.slice(Math.max(0, i - 2), Math.min(hr.length, i + 3)) - .filter((v) => Number.isFinite(v)).sort((a, b) => a - b); - return seg.length ? seg[seg.length >> 1] : NaN; - }); - return minutes.map((_, i) => { - if (!Number.isFinite(hs[i])) return 'unknown'; - return hs[i] > trough + wakeMargin ? 'awake' : 'asleep'; - }); -}; - -// ── Voter 3: van Hees-style sustained inactivity ────────────────────────────── -// Sleep = movement held below an adaptive low threshold across a rolling window. -export const inactivity: Voter = ({ minutes }) => { - const act = minutes.map((m) => (m.wrist_on ? m.activity : NaN)); - const worn = act.filter((a) => Number.isFinite(a)) as number[]; - const s = [...worn].sort((a, b) => a - b); - const pct = (p: number) => (s.length ? s[Math.min(s.length - 1, Math.floor(s.length * p))] : 0); - const p10 = pct(0.1), p90 = pct(0.9); - // "Still" = at/below the window's low (sleep) floor + a margin. Anchoring on the - // floor (not the median) is robust when the window is sleep-dominated, where the - // median itself sits inside the sleep mode. ABS_MOVE (~0.05 g RMS) is the still→move - // boundary in our `activity` units; the range term adapts when the window has daytime. - const ABS_MOVE = 0.05; - const thr = p10 + Math.max(ABS_MOVE, 0.3 * (p90 - p10)); - const W = 5; // minutes of sustained stillness - const n = minutes.length; - return minutes.map((_, i) => { - if (!Number.isFinite(act[i])) return 'unknown'; - let still = 0, seen = 0; - for (let j = Math.max(0, i - W); j <= Math.min(n - 1, i + W); j++) { - if (!Number.isFinite(act[j])) continue; - seen++; - if (act[j] <= thr) still++; - } - if (seen === 0) return 'unknown'; - return still / seen >= 0.7 ? 'asleep' : 'awake'; - }); -}; - -// ── Voter 4: HRV / RR autonomic arousal ─────────────────────────────────────── -// Beat-to-beat RR spread (SD) climbs with autonomic arousal toward and at wake. A -// minute is 'awake' when its short-window RR-SD is elevated AND HR sits above the -// sleeping trough. Returns 'unknown' when the minute has no RR — so a night without -// RR simply drops this voter rather than biasing it. This is the SECOND autonomic -// signal: paired with `cardiac` it lets the ≥2 consensus fire on quiet wake (HR up, -// no motion) without the two blind motion voters being able to veto it. -export const hrvArousal: Voter = ({ minutes, baseline, rrByMin }) => { - const usable = minutes.filter(isHrUsable).map((m) => m.hr_avg); - const sorted = [...usable].sort((a, b) => a - b); - const p10 = sorted.length ? sorted[Math.floor(sorted.length * 0.1)] : baseline.resting_hr; - const trough = Math.max(baseline.resting_hr || 0, p10 || 0) || (sorted[0] ?? 0); - const RR_SD_WAKE = 45; // ms — elevated beat-to-beat spread (validated on real RR) - // Per-minute RR-SD (NaN when <4 beats)… - const sdRaw = minutes.map((m) => { - const rr = rrByMin?.get(Math.floor(m.ts / MIN) * MIN); - if (!rr || rr.length < 4) return NaN; - const mean = rr.reduce((s, v) => s + v, 0) / rr.length; - return Math.sqrt(rr.reduce((s, v) => s + (v - mean) ** 2, 0) / rr.length); - }); - // …then ±2-min median-smoothed. Raw minute RR-SD bounces across the threshold, which - // fragments the morning wake so cardiac+hrv only intermittently reach the ≥2 bar; - // smoothing makes the autonomic signal continuous (mirrors the cardiac HR smoothing). - const sd = sdRaw.map((_, i) => { - const seg = sdRaw.slice(Math.max(0, i - 2), Math.min(sdRaw.length, i + 3)) - .filter((v) => Number.isFinite(v)).sort((a, b) => a - b); - return seg.length ? seg[seg.length >> 1] : NaN; - }); - return minutes.map((m, i) => { - if (!Number.isFinite(sd[i]) || !isHrUsable(m)) return 'unknown'; - return sd[i] > RR_SD_WAKE && m.hr_avg > trough ? 'awake' : 'asleep'; - }); -}; - -// Pluggable registry — swap/extend these as better algorithms are validated. -// FOUR voters, two families: motion (coleKripke, inactivity) + autonomic (cardiac, -// hrvArousal). The consensus rule (≥2) + bout-smoothing is HR-led by construction — -// motion is blind to quiet wake, so the autonomic pair must be able to carry a wake. -export const DEFAULT_VOTERS: { name: string; fn: Voter }[] = [ - { name: 'coleKripke', fn: coleKripke }, - { name: 'cardiac', fn: cardiac }, - { name: 'inactivity', fn: inactivity }, - { name: 'hrvArousal', fn: hrvArousal }, -]; - -/** - * Consensus label per minute: AWAKE if ≥`minAwake` voters say awake. NOT a majority — - * a 2–2 split counts as AWAKE on purpose. Two of the four voters are motion-based and - * physically blind to quiet wakefulness (lying still, HR up); a flat majority lets them - * veto a correct cardiac+HRV wake (and ties → 'unknown' → the close never fires). The - * ≥2 rule means the autonomic pair (cardiac, hrvArousal) can carry a wake on their own, - * while still requiring corroboration (one voter alone never flips it). Else asleep if - * any voter had a known read; else unknown. - */ -function consensusPerMinute(labels: WakeLabel[][], n: number, minAwake = 2): WakeLabel[] { - const out: WakeLabel[] = []; - for (let i = 0; i < n; i++) { - let awake = 0, known = 0; - for (const arr of labels) { - const l = arr[i]; - if (l === 'awake') { awake++; known++; } - else if (l === 'asleep') known++; - } - out.push(awake >= minAwake ? 'awake' : known ? 'asleep' : 'unknown'); - } - return out; -} - -/** - * Merge per-minute label runs shorter than `minRun` minutes into their larger - * neighbour (repeated to a fixed point), so intermittent voter agreement reads as one - * stable bout instead of a sawtooth. The HRV voter flickers in/out, which fragments a - * real continuous wake into sub-threshold pieces; smoothing bridges them. Same - * technique the sleep stager uses on its hypnogram. - */ -function boutSmooth(labels: WakeLabel[], minRun = 10, passes = 4): WakeLabel[] { - const s = [...labels]; - for (let p = 0; p < passes; p++) { - const runs: { a: number; b: number }[] = []; - for (let i = 0; i < s.length;) { let j = i; while (j < s.length && s[j] === s[i]) j++; runs.push({ a: i, b: j - 1 }); i = j; } - if (runs.length <= 1) break; - let changed = false; - for (let r = 0; r < runs.length; r++) { - const { a, b } = runs[r]; - if (b - a + 1 >= minRun) continue; - const prev = r > 0 ? runs[r - 1] : null; - const next = r < runs.length - 1 ? runs[r + 1] : null; - let tgt: WakeLabel | null = null; - if (prev && next) tgt = (prev.b - prev.a) >= (next.b - next.a) ? s[prev.a] : s[next.a]; - else if (prev) tgt = s[prev.a]; - else if (next) tgt = s[next.a]; - if (tgt) { for (let x = a; x <= b; x++) s[x] = tgt; changed = true; } - } - if (!changed) break; - } - return s; -} - -/** - * Run the ensemble over the window. Returns the current state + the most-recent - * sustained sleep→wake boundary (the physiological-day anchor). Requires ≥`minVote` - * agreement (default majority of the registry) AND a main sleep bout ≥90 min AND a - * sustained-awake stretch ≥10 min before reporting state='awake' with a wake_ts. - */ -export function detectWakeState( - ctx: WakeContext, - voters = DEFAULT_VOTERS, -): WakeState { - const minutes = ctx.minutes; - const n = minutes.length; - const now = ctx.now ?? (n ? minutes[n - 1].ts : 0); - const empty: WakeState = { state: 'unknown', wake_ts: null, onset_ts: null, awake_min: 0, asleep_min: 0, votes: {}, confidence: 0 }; - if (n < SUSTAINED_WAKE_MIN) return empty; - - const perVoter = voters.map((v) => v.fn(ctx)); - // HR-led consensus (≥2 awake, ties→awake) then bout-smoothed into stable bouts. - const labels = boutSmooth(consensusPerMinute(perVoter, n)); - - // votes at `now` (last minute) for transparency. - const votes: Record = {}; - voters.forEach((v, k) => { votes[v.name] = perVoter[k][n - 1] ?? 'unknown'; }); - - // Find the most-recent sleep bout and the wake that ends it. - // Walk backward: the current trailing run is "awake" if we just woke. - let i = n - 1; - // trailing awake run - let awakeRunStart = n; - while (i >= 0 && labels[i] === 'awake') { awakeRunStart = i; i--; } - // skip any unknown gap between wake and the sleep bout - while (i >= 0 && labels[i] === 'unknown') i--; - // the sleep bout - let sleepEnd = i; - while (i >= 0 && labels[i] !== 'awake') i--; // include asleep + interior unknowns - let sleepStart = i + 1; - - const sleepBoutMin = sleepEnd >= sleepStart && sleepStart >= 0 - ? Math.round((minutes[sleepEnd].ts - minutes[sleepStart].ts) / MIN) + 1 : 0; - const awakeMin = awakeRunStart < n - ? Math.round((minutes[n - 1].ts - minutes[awakeRunStart].ts) / MIN) + 1 : 0; - - // coverage = fraction of the window with a known label. - const known = labels.filter((l) => l !== 'unknown').length; - const coverage = n ? known / n : 0; - const agree = (() => { - // mean per-minute voter agreement over known minutes (for confidence). - let acc = 0, c = 0; - for (let k = 0; k < n; k++) { - let a = 0, w = 0; - for (const arr of perVoter) { if (arr[k] === 'asleep') a++; else if (arr[k] === 'awake') w++; } - const tot = a + w; if (!tot) continue; - acc += Math.max(a, w) / tot; c++; - } - return c ? acc / c : 0; - })(); - const confidence = Math.round(coverage * agree * 100) / 100; - - // Current state. - const current: WakeLabel = labels[n - 1]; - const justWoke = current === 'awake' - && awakeMin >= SUSTAINED_WAKE_MIN - && sleepBoutMin >= MIN_MAIN_SLEEP_MIN; - - return { - state: current, - wake_ts: justWoke ? minutes[awakeRunStart].ts : null, - onset_ts: justWoke && sleepStart >= 0 && sleepStart <= sleepEnd ? minutes[sleepStart].ts : null, - awake_min: awakeMin, - asleep_min: sleepBoutMin, - votes, - confidence, - }; -} - -/** - * Cheap recent-window check for the cron's per-tick ladder (ladder-step 2): is the - * user plausibly awake in the last few minutes? Liberal (high-recall) on purpose — - * a 'maybe' only triggers the full `detectWakeState`; it never suppresses a wake. - */ -export function peekRecentState(recent: Minute[], baseline: Baseline): WakeLabel { - const worn = recent.filter((m) => m.wrist_on); - if (worn.length < 3) return 'unknown'; - const hrUp = worn.filter(isHrUsable).some((m) => m.hr_avg > (baseline.resting_hr || 0) + 6); - const moving = worn.some((m) => m.activity > 0 && m.steps > 0); - return hrUp || moving ? 'awake' : 'asleep'; -} diff --git a/src/zones.ts b/src/zones.ts deleted file mode 100644 index dabcce7..0000000 --- a/src/zones.ts +++ /dev/null @@ -1,54 +0,0 @@ -// §3 HR zones — minutes per %HRmax band. Tier HIGH. -import type { Minute, Baseline, Profile, Metric, HrZonesValue } from './types'; -import { isHrUsable, resolveMaxHr, round } from './util'; - -/** - * calcHrZones(minutes, baseline, profile?) - * - * For each worn minute, pct = hr_avg/maxHR, bucket into: - * z1 50-60, z2 60-70, z3 70-80, z4 80-90, z5 90-100 (%). - * maxHR = measured session max if available else 220−age. - * - * Confidence formula (per spec): 0.85 if age/maxHR known ('measured' or age - * present), 0.6 if defaulted from age. We scale by coverage (worn_min/30) so a - * sparse day isn't over-confident: confidence = base × clamp(worn_min/30,0,1). - */ -export function calcHrZones( - minutes: Minute[], - baseline: Baseline, - profile?: Profile -): Metric { - const { maxHr, source } = resolveMaxHr(minutes, baseline, profile); - const worn = minutes.filter(isHrUsable); - - // %HRmax zones (standard, familiar). NOTE: Karvonen %HRR is more individualized - // in principle, but it requires a TRUSTWORTHY measured max — here maxHR is - // usually age-predicted, so %HRR adds no real accuracy and makes light days - // read as "no zones." Validated on real data; %HRmax kept deliberately. - const z = [0, 0, 0, 0, 0]; - for (const m of worn) { - const pct = (m.hr_avg / maxHr) * 100; - if (pct >= 50 && pct < 60) z[0]++; - else if (pct >= 60 && pct < 70) z[1]++; - else if (pct >= 70 && pct < 80) z[2]++; - else if (pct >= 80 && pct < 90) z[3]++; - else if (pct >= 90) z[4]++; // 90-100+ all map to z5 - } - - const base = source === 'measured' ? 0.85 : 0.6; - const coverage = Math.min(1, worn.length / 30); - const confidence = base * coverage; - - return { - zone1_min: z[0], - zone2_min: z[1], - zone3_min: z[2], - zone4_min: z[3], - zone5_min: z[4], - max_hr_used: maxHr, - max_hr_source: source, - confidence: round(confidence, 4), - tier: 'HIGH', - inputs_used: source === 'measured' ? ['hr_avg', 'baseline.max_hr'] : ['hr_avg', 'profile.age'], - }; -} diff --git a/test/onehz/real_night_cardio_stager_test.dart b/test/onehz/real_night_cardio_stager_test.dart index e7e2ff6..52c0f04 100644 --- a/test/onehz/real_night_cardio_stager_test.dart +++ b/test/onehz/real_night_cardio_stager_test.dart @@ -41,7 +41,6 @@ // counter/device fields). import 'dart:io'; -import 'dart:math' as math; import 'package:test/test.dart'; import 'package:openstrap_analytics/onehz.dart'; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 4dba679..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "preserve", - "moduleResolution": "bundler", - "lib": ["ES2020"], - "types": ["node"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "isolatedModules": true, - "verbatimModuleSyntax": false - }, - "include": ["src/**/*.ts"] -} From bca815a4c6230eb36e6153f9098f58878cd91bd0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 12:28:15 +0530 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20CI=20hardening,=20honest=20privacy=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same three changes as the protocol PR. Workflow hardening (zizmor `artipacked`): `permissions: contents: read` plus `persist-credentials: false`. This job runs `dart analyze`/`dart test` on PR-authored code and never pushes back, so it has no business holding a writable token in .git/config. Dropped the "Verify formatting" step. It had `continue-on-error: true`, so it could never fail — a check that cannot fail is worse than no check, because the green tick implies coverage it doesn't have. Enforcing it isn't currently possible either: `dart format` would rewrite 51 of 79 files. That's a worthwhile change but it's a large mechanical diff that would churn every open PR, so it belongs on its own. SECURITY.md overstated the privacy boundary — "computes and stores everything on-device" reads as absolute, and Firebase diagnostics are on by default in GitHub release builds (never health data, absent from store builds), with health-data contribution uploading the local DB when explicitly enabled. Both were already in PRIVACY.md; the security policy now matches it. Not applied — pinning actions to commit SHAs. Sound in principle, but with no Dependabot config to keep them current, pinned SHAs rot into known-vulnerable versions that nobody notices, which is a worse failure mode than tracking a maintained major tag. Worth revisiting together with a Dependabot config. --- .github/workflows/test.yml | 13 +++++++++---- SECURITY.md | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0ea97b..78a85bd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,11 +6,20 @@ on: pull_request: workflow_dispatch: +# Read-only: this job only builds and tests. Nothing here needs write access, +# and it executes code from pull requests. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # Don't leave GITHUB_TOKEN in .git/config where PR-authored test code + # could read it. Nothing in this job pushes back to the repo. + persist-credentials: false - uses: dart-lang/setup-dart@v1 with: sdk: stable @@ -18,10 +27,6 @@ jobs: - name: Install dependencies run: dart pub get - - name: Verify formatting - run: dart format --output=none --set-exit-if-changed lib test - continue-on-error: true - - name: Analyze run: dart analyze --fatal-infos diff --git a/SECURITY.md b/SECURITY.md index 378a50d..b12ac35 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,11 +41,18 @@ company with an on-call rota: ## Where your data actually is -Worth knowing before you go looking: OpenStrap computes and stores everything -on-device. There's no account and no server holding your health data. The -optional companion worker exists for legacy import, an update pointer, and two -opt-in features that are off by default and compiled out of store builds -entirely. See [edge's PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md). +Worth knowing before you go looking: OpenStrap computes and stores your health +data on-device, and there's no account or server holding it. Two qualifications, +so the boundary is exact: + +- **Anonymous diagnostics** (Firebase crash/performance, never health data) are + **on by default in GitHub release builds** and absent from App Store / Play + Store builds. Switchable off in-app. +- **Health-data contribution** uploads the local database, but is opt-in, off by + default, and compiled out of store builds entirely. + +Everything else the companion worker does — legacy import, an update pointer — +is optional and carries no health data. See [edge's PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md). That means the realistic attack surface is the phone, the Bluetooth link, and the local database — not a cloud backend. Reports focused there are the most From 49604f9640c9a39cc69b30f371376d9d55550725 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 12:42:15 +0530 Subject: [PATCH 3/4] ci: actually test the declared minimum SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pubspec declares `sdk: ^3.5.0`, which is a promise this package makes to anyone depending on it, but CI only ever ran whatever `stable` happened to be that week. So the lower bound was an untested guess. (CodeRabbit nitpick, and a fair one.) Adds a matrix over ['3.5.0', 'stable'] with fail-fast: false, so a break on the floor is distinguishable from a break on current. If 3.5.0 turns out not to work, the honest fix is to raise the constraint to what does — but either way it should be measured rather than assumed. --- .github/workflows/test.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78a85bd..34b4fda 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,14 @@ permissions: jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # pubspec declares `sdk: ^3.5.0`, so 3.5.0 is a claim this package makes + # to anyone depending on it. Test it, rather than only testing whatever + # `stable` happens to be — otherwise the lower bound is a guess. + sdk: ['3.5.0', 'stable'] + name: test (Dart ${{ matrix.sdk }}) steps: - uses: actions/checkout@v4 with: @@ -22,7 +30,7 @@ jobs: persist-credentials: false - uses: dart-lang/setup-dart@v1 with: - sdk: stable + sdk: ${{ matrix.sdk }} - name: Install dependencies run: dart pub get From 308a75b2f5a728c6c09a1440a6127d4422517d34 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 12:47:17 +0530 Subject: [PATCH 4/4] test: suppress deliberate deprecation notices so 3.5.0 passes The SDK matrix added in the previous commit did its job immediately: this package does NOT pass on the Dart 3.5.0 it claims to support, while protocol (same declared constraint) does. The cause isn't a language incompatibility, which is the interesting part. Dart 3.5.0's analyzer reports `deprecated_member_use_from_same_package` where current stable doesn't, and CI runs `--fatal-infos`, so six intentional usages became six build failures. The usages are correct: sleep_test covers `autonomicStager` and human_test covers `glassBoxReadiness`, both deprecated but still EXPORTED, and dropping coverage of shipped API because it's deprecated would be backwards. So the notices are suppressed at the six sites rather than the tests changed. sleep_test uses a file-level ignore because the usage there is inside a multi-line `show` clause, where an inline ignore anchors unreliably across analyzer versions; human_test uses three inline ignores. Both carry a comment saying the deprecation is deliberate. `^3.5.0` is now a tested claim rather than an assumed one. --- test/onehz/human_test.dart | 3 +++ test/onehz/sleep_test.dart | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/test/onehz/human_test.dart b/test/onehz/human_test.dart index 71daa47..ebf91ea 100644 --- a/test/onehz/human_test.dart +++ b/test/onehz/human_test.dart @@ -265,6 +265,7 @@ void main() { label: 'temp', value: 0.04, history: tempHist, weight: wTemp, lowerIsBetter: true), ]; + // ignore: deprecated_member_use_from_same_package final m = glassBoxReadiness(inputs); expect(m.present, isTrue); final v = m.value!; @@ -288,6 +289,7 @@ void main() { label: 'rhr', value: 50.2, history: hist, weight: wRhr, lowerIsBetter: true), ]; + // ignore: deprecated_member_use_from_same_package final m = glassBoxReadiness(inputs); expect(m.value!.drivers, isEmpty); expect(m.value!.narrative.toLowerCase(), contains('noise')); @@ -300,6 +302,7 @@ void main() { // temp has no history => dropped + reweighted, not zeroed. GlassBoxInput(label: 'temp', value: 0.0, history: const [], weight: wTemp), ]; + // ignore: deprecated_member_use_from_same_package final m = glassBoxReadiness(inputs); expect(m.present, isTrue); expect(m.value!.inputsUsed, 1); diff --git a/test/onehz/sleep_test.dart b/test/onehz/sleep_test.dart index a4e6ac7..1bcb42a 100644 --- a/test/onehz/sleep_test.dart +++ b/test/onehz/sleep_test.dart @@ -1,3 +1,9 @@ +// This file deliberately exercises `autonomicStager`, which is deprecated but +// still exported for back-compat. Testing shipped-but-deprecated API is the +// point, so the deprecation notice is suppressed for the whole file. Older Dart +// analyzers report `deprecated_member_use_from_same_package` where newer ones +// don't, and CI runs `--fatal-infos` on both, so this has to be file-level. +// ignore_for_file: deprecated_member_use_from_same_package // SLEEP & CIRCADIAN family — synthetic known-answer + real-capture plausibility. // // No TS oracle exists for the 1 Hz sleep/circadian methods, so every method is