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..34b4fda --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: test + +on: + push: + branches: [main] + 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 + 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: + # 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: ${{ matrix.sdk }} + + - name: Install dependencies + run: dart pub get + + - 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..b12ac35 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,59 @@ +# 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 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 +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/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/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/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 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"] -}