diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..ee49509b55 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ +## What this PR does + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor / cleanup +- [ ] Documentation +- [ ] CI / tooling + +## How it was tested + + + +## Checklist + +- [ ] Swift package tests pass for any package I touched (`swift test` in `Packages/`) +- [ ] Android unit tests pass if I touched `android/` (`./gradlew testFullDebugUnitTest`) +- [ ] No new build warnings introduced +- [ ] UI changes use only `StrandDesign` tokens — no hardcoded colors, fonts, or spacing +- [ ] No hardcoded hex frame bytes; protocol facts live in the schema / decoders +- [ ] Follows the conventions in [`docs/CONTRIBUTING.md`](../docs/CONTRIBUTING.md) +- [ ] I did not commit generated output (`Strand.xcodeproj/`) or any secrets/keystores + +## Related issues + + diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml new file mode 100644 index 0000000000..71024e25d9 --- /dev/null +++ b/.github/workflows/app-build.yml @@ -0,0 +1,64 @@ +# Compile-only verification for the macOS and iOS app targets. +# +# Why this exists: the iOS app is a build-from-source community port (no App Store — +# NOOP stays anonymous), so it isn't exercised on a device by the maintainer. This job +# compiles both app targets on every relevant change so a shared-code change can't +# silently break iOS (or regress macOS). It is anonymity-safe: CODE_SIGNING_ALLOWED=NO, +# no secrets, no signing identity, no artifacts uploaded — it only checks that it builds. +# (The shipped macOS .app is still produced and anonymized by hand; this is a compile gate.) +name: App build (macOS + iOS) + +on: + pull_request: + branches: [main] + paths: + - 'Strand/**' + - 'StrandiOS/**' + - 'StrandiOSShared/**' + - 'StrandiOSWidgets/**' + - 'Packages/**' + - 'project.yml' + - '.github/workflows/app-build.yml' + push: + branches: [main] + paths: + - 'Strand/**' + - 'StrandiOS/**' + - 'StrandiOSShared/**' + - 'StrandiOSWidgets/**' + - 'Packages/**' + - 'project.yml' + - '.github/workflows/app-build.yml' + workflow_dispatch: + +concurrency: + group: app-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + include: + - scheme: Strand + destination: 'platform=macOS' + - scheme: NOOPiOS + destination: 'generic/platform=iOS Simulator' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Build ${{ matrix.scheme }} + run: >- + xcodebuild -scheme '${{ matrix.scheme }}' -configuration Debug + -destination '${{ matrix.destination }}' + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO + build diff --git a/.github/workflows/swift-packages.yml b/.github/workflows/swift-packages.yml new file mode 100644 index 0000000000..8c517d4d41 --- /dev/null +++ b/.github/workflows/swift-packages.yml @@ -0,0 +1,50 @@ +# Compile-and-test verification for the reusable Swift packages. +# +# This job intentionally does NOT touch the macOS app target: that build needs +# XcodeGen to generate Strand.xcodeproj and a wired-up test scheme, and the app +# release is produced and anonymized by hand. The packages, by contrast, build +# and test on their own with plain SwiftPM — no Xcode project, no signing, no +# secrets — which makes them a safe, self-contained CI check that leaks nothing. +name: Swift Packages CI + +on: + pull_request: + branches: [main] + paths: + - 'Packages/**' + - '.github/workflows/swift-packages.yml' + push: + branches: [main] + paths: + - 'Packages/**' + - '.github/workflows/swift-packages.yml' + workflow_dispatch: + +concurrency: + group: swift-packages-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + package: + - WhoopProtocol + - WhoopStore + - StrandAnalytics + - StrandImport + - StrandDesign + - NoopLocalAccess + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build ${{ matrix.package }} + run: swift build + working-directory: Packages/${{ matrix.package }} + + - name: Test ${{ matrix.package }} + run: swift test + working-directory: Packages/${{ matrix.package }} diff --git a/.gitignore b/.gitignore index a92f47b82e..a4c70bd1d4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,21 @@ Icon? *.xcodeproj/ *.xcworkspace/ !*.xcworkspace/contents.xcworkspacedata +# …EXCEPT the xcodeproj's SPM lockfile. The shipped app builds from this Package.resolved, +# so it MUST be tracked — otherwise a clean checkout resolves a floating, uncommitted lockfile +# that could drift to a newer (potentially compromised) upstream release. Supply-chain pin. +# Git won't descend into an excluded directory, so re-include each ancestor on the path, then +# re-exclude its contents with a wildcard so ONLY Package.resolved gets tracked (not the +# generated project.pbxproj / contents.xcworkspacedata / xcuserdata cruft). +!Strand.xcodeproj/ +Strand.xcodeproj/* +!Strand.xcodeproj/project.xcworkspace/ +Strand.xcodeproj/project.xcworkspace/* +!Strand.xcodeproj/project.xcworkspace/xcshareddata/ +Strand.xcodeproj/project.xcworkspace/xcshareddata/* +!Strand.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ +Strand.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/* +!Strand.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved build/ DerivedData/ .build/ @@ -54,6 +69,10 @@ android/app/release/ # ── Misc / secrets (never commit) ─────────────────────────────── *.log .env +# Deployment credentials (PAT / signing / endpoints) — never commit. `.env` above +# won't match `deploy.env`, so list it (and any `deploy.env.`) explicitly. +deploy.env +deploy.env.* secrets.* android/local.properties android/keystore.properties @@ -68,3 +87,11 @@ export.xml export.zip *whoop_export*.zip *apple_health*.zip + +# Python bytecode +__pycache__/ +*.pyc + +# Local release artifacts (zips/ipas built for upload; never tracked) +dist/ +err.txt diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dd93b0c154..937207dfdf 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -1,26 +1,27 @@ # Attribution -Strand is an independent, unofficial, local-first macOS app. It is not affiliated +NOOP is an independent, unofficial, local-first app for macOS and iOS. It is not affiliated with, endorsed by, or connected to WHOOP, Inc. "WHOOP" is used nominatively only to identify the hardware the app interoperates with. -Strand builds on prior open-source reverse-engineering and interoperability work: +NOOP builds on prior community reverse-engineering and interoperability work: ## WHOOP 4.0 protocol + Swift packages - **`johnmiddleton12/my-whoop`** — the `WhoopProtocol` and `WhoopStore` Swift packages (vendored under `Packages/`), the WHOOP 4.0 BLE framing/command/decode work, and the - iOS collection logic that Strand's `WhoopBLE`/`Collect` layers are adapted from. - See `DISCLAIMER.md` (carried over from that project). + iOS collection logic that NOOP's `WhoopBLE`/`Collect` layers are adapted from. ## WHOOP 5.0 / MG protocol - **`b-nnett/goose`** — the WHOOP 5.0 BLE reverse-engineering (service UUID family `fd4b0001-…`, CRC16-Modbus header, CLIENT_HELLO, and the "puffin" packet types) - that Strand's `DeviceFamily` Whoop-5 path and `whoop5_protocol.json` are ported from. + that NOOP's `DeviceFamily` Whoop-5 path and `whoop5_protocol.json` are ported from. ## Other - **GRDB.swift** (`groue/GRDB.swift`) — SQLite persistence (via Swift Package Manager). +- **MarkdownUI** (`gonzalezreal/swift-markdown-ui`) — renders the AI Coach's Markdown + replies (via Swift Package Manager). -Strand contains no WHOOP proprietary code, binaries, firmware, logos, or assets, and +NOOP contains no WHOOP proprietary code, binaries, firmware, logos, or assets, and performs no DRM circumvention. It operates only with the user's own device and data. -Strand is **not a medical device**; all metrics (HR, HRV, recovery, strain, sleep, +NOOP is **not a medical device**; all metrics (HR, HRV, recovery, strain, sleep, SpO₂, temperature) are approximations and not clinically validated. diff --git a/DISCLAIMER.md b/DISCLAIMER.md deleted file mode 100644 index 4c72d86369..0000000000 --- a/DISCLAIMER.md +++ /dev/null @@ -1,76 +0,0 @@ -# Disclaimer, Trademark & Good-Faith Notice - -## 1. Independent & unofficial - -This is an independent, unofficial, non-commercial project by an individual hobbyist. It is -**not affiliated with, endorsed by, sponsored by, or connected to WHOOP, Inc.** in any way. All -references to "WHOOP" describe the third-party hardware this software interoperates with and are -**nominative fair use** of the mark — used only to identify that hardware, never to imply origin, -sponsorship, or endorsement, and never as the name of this project's own product or brand. - -"WHOOP" and any related marks are the property of WHOOP, Inc. All other trademarks belong to -their respective owners. - -## 2. No proprietary material is contained or redistributed - -This repository contains **only original work** authored by the project's contributors, plus -factual protocol observations. Some protocol facts were confirmed by examining the official app -**for the sole purpose of interoperability** — an activity expressly permitted by -**17 U.S.C. § 1201(f)** and analogous interoperability provisions (see §3). **No** such material -is reproduced, redistributed, or included here. Specifically, this repository does **NOT** -contain, bundle, mirror, or link to any of the following: - -- WHOOP application binaries, APKs, IPAs, or installers; -- WHOOP firmware, firmware images, or extracted firmware; -- decompiled, disassembled, or reverse-compiled WHOOP source code; -- WHOOP source code, headers, or build artifacts of any kind; -- WHOOP logos, icons, artwork, fonts, screenshots, or other copyrighted/branded assets; -- any WHOOP account credentials, API secrets, or server endpoints. - -Application icons, color choices, and UI in this project are **original creations**. Any -similarity to a generic "dark fitness app" aesthetic is coincidental and not copied from any -protected work. Protocol facts (frame layout, command identifiers, field offsets) are -**uncopyrightable factual information** about how bytes appear on a wire, documented through the -author's own observation of traffic to and from a device the author owns. - -## 3. Nature of the work: interoperability & security research - -The purpose of this project is to allow a person who **owns a WHOOP 4.0 device** to read **their -own biometric data** from **their own device** in an interoperable way, and to study the device -for educational and security-research purposes. - -- It operates only with the **user's own device** and the **user's own data**. -- It does **not** circumvent any technological protection measure protecting a copyrighted work, - and does not bypass any subscription, paywall, login, or account control. -- Reverse engineering undertaken solely to achieve **interoperability** of an independently - created program with other programs is a protected activity under, among others, - **17 U.S.C. § 1201(f)** (U.S.) and comparable interoperability and research provisions in - other jurisdictions. -- Nothing here is intended to compete with, devalue, or harm WHOOP's products, services, or - business. Users are encouraged to maintain an active relationship with the official product. - -## 4. Personal & educational use only — no warranty - -This software is provided **for personal and educational use only**, **as-is**, with **no -warranty of any kind**, express or implied. You use it entirely **at your own risk**, including -any risk to your device, data, or warranty status. The authors accept no liability for any -damage, loss, or consequence arising from its use. Review your own agreements and local laws -before use; you are responsible for your own compliance. - -## 5. Not a medical device - -Outputs such as heart rate, HRV, recovery, strain, sleep stages, SpO₂, respiratory rate, and skin -temperature are **approximations** computed from published methods. They are **not** clinically -validated, are **not** a medical device, and are **not** medical advice. Do not use them to -diagnose, treat, or make health decisions. Consult a qualified professional. - -## 6. Good-faith takedown contact - -This project is shared in good faith and the author wants to respect others' rights. If you are -WHOOP, Inc. (or another rights holder) and believe anything in this repository infringes your -rights, **please contact the author directly via a GitHub issue or the email on the author's -GitHub profile before filing a formal complaint.** The author will review promptly and, where a -concern is well-founded, will cooperate — including editing or removing the material in question. - -The author's intent is interoperability and research, not infringement; most concerns can be -resolved quickly and amicably through direct contact. diff --git a/NOOPWatch/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png b/NOOPWatch/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png new file mode 100644 index 0000000000..5a550ed61c Binary files /dev/null and b/NOOPWatch/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png differ diff --git a/NOOPWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/NOOPWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..422fc9b91f --- /dev/null +++ b/NOOPWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon1024.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NOOPWatch/Assets.xcassets/Contents.json b/NOOPWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/NOOPWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NOOPWatch/Info.plist b/NOOPWatch/Info.plist new file mode 100644 index 0000000000..e8b3e6d265 --- /dev/null +++ b/NOOPWatch/Info.plist @@ -0,0 +1,34 @@ + + + + + AppGroupIdentifier + $(APP_GROUP_ID) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NOOP + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + NOOP + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSHealthShareUsageDescription + NOOP reads your heart rate from the Watch sensor to show a live readout on your wrist. It stays on your device. + NSHealthUpdateUsageDescription + NOOP records a basic workout session on your Watch for a higher-fidelity heart rate. The session stays on your device. + WKApplication + + WKCompanionAppBundleIdentifier + com.bbdw.noop + + diff --git a/NOOPWatch/Localizable.xcstrings b/NOOPWatch/Localizable.xcstrings new file mode 100644 index 0000000000..53bf629545 --- /dev/null +++ b/NOOPWatch/Localizable.xcstrings @@ -0,0 +1,1910 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "–" : { + + }, + "/ %lld" : { + + }, + "%@ · %@s in / %@s out" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@s in / %@s out" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@s inhala / %@s exhala" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@s dentro / %@s fuori" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · 吸气 %@ 秒 / 呼气 %@ 秒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · 吸 %@ 秒 / 呼 %@ 秒" + } + } + } + }, + "%@ · %lld kcal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld kcal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld kcal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld kcal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld kcal" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld kcal" + } + } + } + }, + "%@ br/min" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ br/min" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ resp/min" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ resp/min" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 次/分" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 次/分" + } + } + } + }, + "%@ pace" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ pace" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritmo %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritmo %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配速 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "配速 %@" + } + } + } + }, + "%lld" : { + + }, + "%lld breaths · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld breaths · %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld respiraciones · %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld respiri · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 次呼吸 · %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 次呼吸 · %@" + } + } + } + }, + "%lld seconds remaining in %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld seconds remaining in %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld segundos restantes en %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld secondi rimanenti in %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ 还剩 %1$lld 秒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ 還剩 %1$lld 秒" + } + } + } + }, + "✓" : { + + }, + "Allow access" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow access" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir acceso" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consenti accesso" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许访问" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "允許存取" + } + } + } + }, + "as of %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "as of %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "a fecha de %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "al %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "截至 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "截至 %@" + } + } + } + }, + "Box" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Box" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuadrada" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Box" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "箱式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "箱式" + } + } + } + }, + "bpm" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "bpm" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bpm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ppm" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bpm" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "bpm" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "уд/мин" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "bpm" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "bpm" + } + } + } + }, + "Breathe" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breathe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Respira" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Respirer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Respira" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дыхание" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "呼吸" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "呼吸" + } + } + } + }, + "Breathe in" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breathe in" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inhala" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inspira" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "吸气" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "吸氣" + } + } + } + }, + "Breathe in for %lld seconds" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breathe in for %lld seconds" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inhala durante %lld segundos" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inspira per %lld secondi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "吸气 %lld 秒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "吸氣 %lld 秒" + } + } + } + }, + "Breathe out" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breathe out" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exhala" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espira" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "呼气" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "吐氣" + } + } + } + }, + "Breathe out for %lld seconds" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breathe out for %lld seconds" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exhala durante %lld segundos" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espira per %lld secondi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "呼气 %lld 秒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "吐氣 %lld 秒" + } + } + } + }, + "cal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cal" + } + } + } + }, + "Charge" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carga" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carica" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заряд" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量" + } + } + } + }, + "Coherence" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coherence" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coherencia" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coerenza" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "协调度" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "諧振" + } + } + } + }, + "Done" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listo" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fatto" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + } + }, + "DONE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DONE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "LISTO" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "FATTO" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + } + }, + "Effort" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anstrengung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effort" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esfuerzo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effort" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sforzo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усилие" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "消耗" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "消耗" + } + } + } + }, + "End" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finalizar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fine" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "结束" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結束" + } + } + } + }, + "End workout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End workout" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finalizar entrenamiento" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termina allenamento" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "结束锻炼" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結束鍛鍊" + } + } + } + }, + "Functional strength" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Functional strength" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fuerza funcional" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forza funzionale" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能性力量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能性肌力" + } + } + } + }, + "Grant Health access" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grant Health access" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conceder acceso a Salud" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Concedi l'accesso a Salute" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授予健康数据访问权限" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "授予「健康」存取權" + } + } + } + }, + "HEART RATE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HERZFREQUENZ" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HEART RATE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "FRECUENCIA CARDÍACA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "FRÉQUENCE CARDIAQUE" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "FREQUENZA CARDIACA" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ПУЛЬС" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "心率" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "心率" + } + } + } + }, + "HR unavailable" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HR unavailable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "FC no disponible" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "FC non disponibile" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "心率不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法取得心率" + } + } + } + }, + "Live heart rate and energy, recorded on your wrist. Stays on device." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live heart rate and energy, recorded on your wrist. Stays on device." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frecuencia cardíaca y energía en vivo, registradas en tu muñeca. Se queda en el dispositivo." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frequenza cardiaca ed energia in diretta, registrate al polso. Restano sul dispositivo." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时心率和能量,在你手腕上记录。数据留在设备上。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "即時心率和能量消耗,記錄在你的手腕上。留在裝置上。" + } + } + } + }, + "Open NOOP on your iPhone to sync" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open NOOP on your iPhone to sync" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre NOOP en tu iPhone para sincronizar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri NOOP sul tuo iPhone per sincronizzare" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 iPhone 上打开 NOOP 以同步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在你的 iPhone 上打開 NOOP 以同步" + } + } + } + }, + "Pause" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pausar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pausa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пауза" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂停" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫停" + } + } + } + }, + "PAUSED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAUSED" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "EN PAUSA" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "IN PAUSA" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已暂停" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已暫停" + } + } + } + }, + "Ready to breathe" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready to breathe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listo para respirar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pronto a respirare" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "准备开始呼吸" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "準備好呼吸" + } + } + } + }, + "RECORDING" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RECORDING" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GRABANDO" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "REGISTRAZIONE" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "记录中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "記錄中" + } + } + } + }, + "Relax" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relax" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relájate" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rilassati" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "放松" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "放鬆" + } + } + } + }, + "Reset" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reimposta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重設" + } + } + } + }, + "Rest" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruhe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rest" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descanso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repos" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riposo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отдых" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "休息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "休息" + } + } + } + }, + "REST" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "REST" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "DESCANSO" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "RIPOSO" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "休息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "休息" + } + } + } + }, + "Restart" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neu starten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restart" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redémarrer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riavvia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапустить" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新启动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新啟動" + } + } + } + }, + "Resume" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reanudar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riprendi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "繼續" + } + } + } + }, + "Round %lld of %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Round %lld of %lld" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ronda %lld de %lld" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Round %lld di %lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %1$lld / %2$lld 轮" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 輪,共 %lld 輪" + } + } + } + }, + "SEC" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SEC" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SEG" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "SEC" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "秒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "秒" + } + } + } + }, + "Session done" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session done" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesión terminada" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessione completata" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话结束" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作階段結束" + } + } + } + }, + "stale · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "stale · %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desactualizado · %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "obsoleto · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已过期 · %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "過時 · %@" + } + } + } + }, + "Start" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Старт" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始" + } + } + } + }, + "Start session" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitzung starten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start session" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar sesión" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrer la séance" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvia sessione" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать сессию" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始工作階段" + } + } + } + }, + "Stop" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detener" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ferma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止" + } + } + } + }, + "Stop session" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitzung beenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop session" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detener sesión" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrêter la séance" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termina sessione" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить сессию" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止工作階段" + } + } + } + }, + "WORK" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WORK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "TRABAJO" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "LAVORO" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動" + } + } + } + }, + "Workout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Workout" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrenamiento" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allenamento" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锻炼" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "鍛鍊" + } + } + } + }, + "Workout saved" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Workout saved" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrenamiento guardado" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allenamento salvato" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "训练已保存" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動已保存" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/NOOPWatch/NOOPWatch.entitlements b/NOOPWatch/NOOPWatch.entitlements new file mode 100644 index 0000000000..02a9f4db45 --- /dev/null +++ b/NOOPWatch/NOOPWatch.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + + com.apple.security.application-groups + + $(APP_GROUP_ID) + + + diff --git a/NOOPWatch/NOOPWatchApp.swift b/NOOPWatch/NOOPWatchApp.swift new file mode 100644 index 0000000000..7dbb40f4d3 --- /dev/null +++ b/NOOPWatch/NOOPWatchApp.swift @@ -0,0 +1,75 @@ +import SwiftUI +import StrandDesign + +// MARK: - NOOPWatch — the watchOS glance app +// +// The iPhone is the brain. M1 already computes Charge / Effort / Rest with confidence and provenance; +// this watch app ONLY displays the latest snapshot the phone pushes over WatchConnectivity. It never +// recomputes a score. The one thing the watch measures locally is its OWN heart rate (HealthKit), shown +// as a live readout alongside the synced scores. +// +// Two long-lived objects own the data: +// - WatchScoreStore receives the phone's snapshot, persists it to the shared App Group, drives the +// complication reload, and publishes it to the glance. +// - WatchLiveHR streams the watch's own heart rate (guarded behind HealthKit authorization). +// +// Both are created once here and handed to the glance as environment objects so the view stays pure. + +@main +struct NOOPWatchApp: App { + // Created once for the app's lifetime. The store activates WCSession on init so a snapshot the + // phone sent while the app was backgrounded is delivered as soon as we come up. + @StateObject private var store = WatchScoreStore() + @StateObject private var liveHR = WatchLiveHR() + + init() { + #if DEBUG + Self.seedDemoSnapshotIfNeeded() + #endif + } + + var body: some Scene { + WindowGroup { + rootView + .environmentObject(store) + .environmentObject(liveHR) + // The watch app is dark-only to match the Apple-Fitness-x-WHOOP look. StrandPalette + // tokens resolve their dark values here, so the rings read on the near-black canvas. + .preferredColorScheme(.dark) + } + } + + // Normally the glance. In DEBUG only, a NOOP_DEMO_SCREEN env var can root the app directly at one of + // the active features so each can be screenshotted on the simulator (which can't tap to navigate). + // Compiled out of release builds. + @ViewBuilder private var rootView: some View { + #if DEBUG + switch ProcessInfo.processInfo.environment["NOOP_DEMO_SCREEN"] { + case "breathe": WatchBreatheView() + case "workout": WatchWorkoutView() + case "intervals": WatchIntervalView() + case "glance": WatchGlanceView() + default: WatchRootView() + } + #else + WatchRootView() + #endif + } + + #if DEBUG + /// DEBUG-ONLY screenshot aid. On a fresh sim there is no paired phone to push scores, so the glance + /// would sit on its empty "open NOOP on your iPhone" state and the rings never render. When nothing + /// has ever synced we write ONE believable sample snapshot into the shared app group so the rings + /// draw for screenshots. Guarded so it never overwrites a real synced snapshot, and the whole thing + /// is compiled out of release builds, so it can never ship. + static func seedDemoSnapshotIfNeeded() { + guard WatchScoreSnapshot.load() == nil else { return } + let demo = WatchScoreSnapshot(charge: 72, chargeCalibrating: false, + effort: 61, effortCalibrating: false, + rest: 84, restCalibrating: false, + hr: 58, sleepSummary: "7h 12m", + asOf: Date()) + demo.save() + } + #endif +} diff --git a/NOOPWatch/NOOPWatchRelease.entitlements b/NOOPWatch/NOOPWatchRelease.entitlements new file mode 100644 index 0000000000..674f121fc1 --- /dev/null +++ b/NOOPWatch/NOOPWatchRelease.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + com.apple.security.application-groups + + $(APP_GROUP_ID) + + + diff --git a/NOOPWatch/WatchBreatheView.swift b/NOOPWatch/WatchBreatheView.swift new file mode 100644 index 0000000000..a2a8c18629 --- /dev/null +++ b/NOOPWatch/WatchBreatheView.swift @@ -0,0 +1,379 @@ +import SwiftUI +import StrandDesign + +// MARK: - WatchBreatheView — a wrist-native guided breathing session +// +// The phone is the brain for SCORES, but this runs entirely ON the watch: its own clock paces the breath, +// the Taptic engine carries the cue so it works with the wrist down and eyes closed, and a concentric guide +// ring swells on the inhale and settles on the exhale to match the iOS Breathe orb scaled to the wrist. +// +// The phase pattern + durations are reimplemented from Strand/Screens/BreathingView.swift (the fixed-pace +// "Breathe" trainer): three presets, inhale-then-exhale phases, one buzz on the inhale start and two on the +// exhale start. We do NOT link the iOS view (it depends on AppModel / LiveState / the strap haptic path that +// the watch doesn't have); this is a standalone WatchKit reimplementation that uses StrandHaptic for the +// wrist buzz instead of the strap motor. +// +// Self-contained, zero-arg init. The nav lane wires it in by name. Respects Reduce Motion (the ring parks at +// its mid radius and the phase word + haptic carry the pace instead of the swell). +struct WatchBreatheView: View { + + // MARK: Pace presets (mirrors the iOS BreathingView.Pace inhale/exhale seconds) + + private enum Pace: CaseIterable, Hashable { + case relax // 4s inhale / 6s exhale — long exhale, downshift to rest + case coherence // 5.5s / 5.5s — equal breath, ~5.5 br/min + case box // 4s / 4s — square breath, steady focus + + var label: String { + switch self { + case .relax: return String(localized: "Relax") + case .coherence: return String(localized: "Coherence") + case .box: return String(localized: "Box") + } + } + + /// Inhale seconds — same values the iOS fixed-pace trainer uses. + var inhale: Double { + switch self { + case .relax: return 4.0 + case .coherence: return 5.5 + case .box: return 4.0 + } + } + + /// Exhale seconds — same values the iOS fixed-pace trainer uses. + var exhale: Double { + switch self { + case .relax: return 6.0 + case .coherence: return 5.5 + case .box: return 4.0 + } + } + + var cycle: Double { inhale + exhale } + var bpm: Double { 60.0 / cycle } + } + + private enum Phase { case inhale, exhale } + + // MARK: State + + /// When Reduce Motion is on the swelling ring is suppressed — the breath is cued by the phase word + + /// haptics instead, so the screen stays still. (watchOS a11y) + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var pace: Pace = .coherence + @State private var running = false + + /// 0 = fully contracted, 1 = fully expanded. Drives the guide ring's radius, exactly like the iOS orb. + @State private var ringProgress: CGFloat = 0 + @State private var phase: Phase = .inhale + + /// When the current phase ends (wall-clock). The 0.05s ticker compares against this so the pace stays + /// true even if a frame is dropped, rather than counting ticks. + @State private var phaseDeadline: Date = .distantFuture + /// When the current phase began — used to drive the on-ring countdown. + @State private var phaseStart: Date = Date() + /// Seconds left in the current phase, recomputed each tick for the centre countdown. + @State private var phaseRemaining: Int = 0 + + @State private var breathCount = 0 + @State private var sessionSeconds = 0 + + // A 0.05s clock advances the phases; a 1s clock counts the session length. Both gate on `running`. + private let phaseTimer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect() + private let secondTimer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() + + /// Parked radius under Reduce Motion — the ring sits mid-way rather than pulsing. + private let reducedSteadyRing: CGFloat = 0.5 + + var body: some View { + // One-screen fit: no ScrollView. A fixed compact header (the pace line) and a fixed control stack + // (the 3 pace pills + Start/Stop) bracket the hero ring, and the ring is sized to whatever vertical + // space is left over. That way every control stays on screen on any watch, from 41mm up, without + // scrolling. The pace line + ring fold the session readout and the breath-cue caption into themselves + // so we don't need the old footer row. + GeometryReader { geo in + let totalH = geo.size.height + let totalW = geo.size.width + let vSpacing: CGFloat = 6 + + // Reserve room for the two fixed rows. These are deliberate floors that match the rendered + // heights of paceLine, the pill row and the Start/Stop button so the ring can claim the rest. + let headerH: CGFloat = 16 // the compact pace line + let pillsH: CGFloat = 30 // the 3 pace pills + let controlH: CGFloat = 38 // the Start/Stop button + let reserved = headerH + pillsH + controlH + vSpacing * 3 + + // Whatever's left is the ring's. Clamp so it never collapses or overflows the width. + let remaining = max(totalH - reserved, 40) + let ringSide = min(min(totalW, remaining), 150) + + VStack(spacing: vSpacing) { + paceLine + .frame(height: headerH) + ring(side: ringSide) + Spacer(minLength: 0) + pacePicker + .frame(height: pillsH) + control + .frame(height: controlH) + } + .frame(width: totalW, height: totalH) + .padding(.horizontal, 6) + } + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .onReceive(phaseTimer) { now in + guard running else { return } + advance(now: now) + updateCountdown(now: now) + } + .onReceive(secondTimer) { _ in + guard running else { return } + sessionSeconds += 1 + } + .onChange(of: pace) { _ in + // Re-arm from the inhale at the new pace without an extra buzz (the user just tapped a pill). + if running { armPhase(.inhale, from: Date(), buzz: false) } + } + .onDisappear { stop() } + } + + // MARK: - The breathing ring + + /// A concentric guide ring on a near-black card: a faint resting track plus a brighter travelling ring + /// that grows toward the track on the inhale and collapses on the exhale. The phase word + a per-phase + /// countdown sit in the centre. Matches the iOS orb's behaviour, scaled to the wrist. + private func ring(side: CGFloat) -> some View { + let maxDiameter = side + let minScale: CGFloat = 0.46 + let scale = minScale + (1.0 - minScale) * ringProgress + let guideDiameter = maxDiameter * scale + + return ZStack { + // The resting track the breath expands toward. Crisp 1px stroke, no glow. + Circle() + .strokeBorder(StrandPalette.restColor.opacity(0.26), lineWidth: 1) + .frame(width: maxDiameter, height: maxDiameter) + + // A soft radial-shaded disc that swells with the breath (shading, not a bloom halo) — the + // same cue as the iOS orb. Held steady under Reduce Motion. + Circle() + .fill( + RadialGradient( + colors: [StrandPalette.restBright.opacity(0.85), + StrandPalette.restColor.opacity(0.55), + StrandPalette.restDeep.opacity(0.80)], + center: .init(x: 0.4, y: 0.35), + startRadius: 1, + endRadius: guideDiameter * 0.62 + ) + ) + .frame(width: guideDiameter, height: guideDiameter) + + // The travelling guide ring — a brighter 2px stroke riding the breath out and back, the + // crisp pace line on top of the soft swell. + Circle() + .strokeBorder(StrandPalette.restBright.opacity(running ? 0.70 : 0.40), lineWidth: 2) + .frame(width: guideDiameter, height: guideDiameter) + + centerLabel + } + .frame(width: maxDiameter, height: maxDiameter) + .frame(maxWidth: .infinity) + } + + /// The phase word and, while running, the per-phase countdown. Idle it invites the user to begin. + @ViewBuilder + private var centerLabel: some View { + VStack(spacing: 2) { + if running { + Text(phaseWord) + .font(StrandFont.rounded(15, weight: .semibold)) + .foregroundStyle(StrandPalette.restBright) + .animation(.easeInOut(duration: 0.2), value: phase) + Text("\(max(phaseRemaining, 0))") + .font(StrandFont.number(28)) + .foregroundStyle(StrandPalette.textPrimary) + .monospacedDigit() + .contentTransition(.numericText()) + } else { + Text("Breathe") + .font(StrandFont.rounded(16, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + Text("\(String(format: "%.1f", pace.bpm)) br/min") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + // Keep every centre word on ONE line — it sits inside the orb, which shrinks on the smallest + // watch, so let the text scale down rather than wrap (no "Breath / e"). + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.horizontal, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(running ? phaseAccessibilityLabel : String(localized: "Ready to breathe")) + } + + private var phaseWord: String { + switch phase { + case .inhale: return String(localized: "Breathe in") + case .exhale: return String(localized: "Breathe out") + } + } + + /// Whole-phrase per phase (never a localized word stitched into a template) so each reads + /// naturally in every language. + private var phaseAccessibilityLabel: String { + let secs = max(phaseRemaining, 0) + switch phase { + case .inhale: return String(localized: "Breathe in for \(secs) seconds") + case .exhale: return String(localized: "Breathe out for \(secs) seconds") + } + } + + // MARK: - Pace line + picker + + private var paceLine: some View { + // Compact one-liner. Running: the live session readout. Idle: the selected pace + a quick nod to the + // wrist cue (folded in from the old footer so the "one tap in, two out" guidance still has a home). + Text(running ? String(localized: "\(breathCount) breaths · \(timeString(sessionSeconds))") + : String(localized: "\(pace.label) · \(String(format: "%.0f", pace.inhale))s in / \(String(format: "%.0f", pace.exhale))s out")) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity) + } + + /// Three preset pills. Disabled mid-session would feel abrupt; instead picking a new pace re-arms the + /// breath cleanly (handled in onChange), so the user can switch on the fly. + private var pacePicker: some View { + HStack(spacing: 6) { + ForEach(Pace.allCases, id: \.self) { p in + Button { + StrandHaptic.selection.play() + pace = p + } label: { + Text(p.label) + .font(StrandFont.caption) + .foregroundStyle(p == pace ? StrandPalette.textPrimary : StrandPalette.textTertiary) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(p == pace ? StrandPalette.restColor.opacity(0.22) : StrandPalette.surfaceRaised) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(p == pace ? StrandPalette.restBright.opacity(0.6) : Color.clear, + lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(p.label) pace") + .accessibilityAddTraits(p == pace ? [.isSelected] : []) + } + } + } + + // MARK: - Start / stop + + private var control: some View { + Button { + running ? stop() : start() + } label: { + HStack(spacing: 6) { + Image(systemName: running ? "stop.fill" : "play.fill") + .font(.system(size: 14, weight: .semibold)) + Text(running ? String(localized: "Stop") : String(localized: "Start")) + .font(StrandFont.rounded(15, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(running ? StrandPalette.statusCritical.opacity(0.22) + : StrandPalette.restColor.opacity(0.28)) + ) + .foregroundStyle(running ? StrandPalette.statusCritical : StrandPalette.restBright) + } + .buttonStyle(.plain) + .accessibilityLabel(running ? String(localized: "Stop session") : String(localized: "Start session")) + } + + // MARK: - Session control + + private func start() { + running = true + sessionSeconds = 0 + breathCount = 0 + StrandHaptic.success.play() + armPhase(.inhale, from: Date(), buzz: true) + } + + private func stop() { + guard running else { return } + running = false + phaseDeadline = .distantFuture + StrandHaptic.commit.play() + if reduceMotion { + ringProgress = 0 + } else { + withAnimation(.easeInOut(duration: 0.7)) { ringProgress = 0 } + } + } + + /// Arm a new phase: set its deadline, animate the ring toward the target radius over the phase duration, + /// and (when `buzz`) fire the wrist haptic — one tap on the inhale start, two on the exhale start, so the + /// pace is felt without looking. Mirrors the iOS armPhase, swapping the strap buzz for StrandHaptic. + private func armPhase(_ newPhase: Phase, from now: Date, buzz: Bool) { + phase = newPhase + let duration = (newPhase == .inhale) ? pace.inhale : pace.exhale + phaseStart = now + phaseDeadline = now.addingTimeInterval(duration) + phaseRemaining = Int(duration.rounded(.up)) + + if reduceMotion { + ringProgress = reducedSteadyRing + } else { + withAnimation(.easeInOut(duration: duration)) { + ringProgress = (newPhase == .inhale) ? 1.0 : 0.0 + } + } + + if buzz { + // One tap leading the inhale, a double tap leading the exhale — the iOS 1-buzz / 2-buzz cue, + // reproduced on the wrist. The second exhale tap is nudged slightly so they read as a pair. + StrandHaptic.light.play() + if newPhase == .exhale { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + StrandHaptic.light.play() + } + } + } + } + + private func advance(now: Date) { + guard now >= phaseDeadline else { return } + switch phase { + case .inhale: + armPhase(.exhale, from: now, buzz: true) + case .exhale: + breathCount += 1 + armPhase(.inhale, from: now, buzz: true) + } + } + + /// Recompute the centre countdown from the wall clock so it ticks down 1-by-1 in step with the phase. + private func updateCountdown(now: Date) { + let left = phaseDeadline.timeIntervalSince(now) + phaseRemaining = max(0, Int(left.rounded(.up))) + } + + // MARK: - Formatting + + private func timeString(_ total: Int) -> String { + String(format: "%d:%02d", total / 60, total % 60) + } +} diff --git a/NOOPWatch/WatchGlanceView.swift b/NOOPWatch/WatchGlanceView.swift new file mode 100644 index 0000000000..944e8e60d1 --- /dev/null +++ b/NOOPWatch/WatchGlanceView.swift @@ -0,0 +1,200 @@ +import SwiftUI +import StrandDesign + +// MARK: - WatchGlanceView — the watch app's single primary screen +// +// The Apple-Fitness-x-WHOOP look scaled to the wrist: the three NOOP rings (Charge / Effort / Rest) with +// their numbers in SF-Rounded, each honouring confidence (a calibrating score shows a dash plus a small +// "cal" marker, NEVER a fabricated number), a live heart-rate readout from the watch's own sensor, and a +// one-line sleep summary. When nothing has synced yet we show a friendly "open NOOP on your iPhone" state, +// and we always label the scores with the snapshot's age ("as of 2h ago") rather than implying they are live. +struct WatchGlanceView: View { + @EnvironmentObject private var store: WatchScoreStore + @EnvironmentObject private var liveHR: WatchLiveHR + + var body: some View { + // The glance is page 1 of the watch app's swipeable page deck (WatchRootView): just the synced + // scores, sized to ONE screen with no scrolling. Breathe / Workout / Intervals are their OWN pages + // a swipe away, so the glance no longer pushes or links anywhere. The phone is the brain for the + // SCORES here; the active features run on the watch's own sensors + haptics on their pages. + Group { + if let snap = store.snapshot { + glance(snap) + } else { + emptyState + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .onAppear { liveHR.start() } + .onDisappear { liveHR.stop() } + } + + // MARK: Synced state + + @ViewBuilder + private func glance(_ snap: WatchScoreSnapshot) -> some View { + // One staleness decision for the whole glance: when the snapshot has aged out (per the shared + // contract) we force every ring into its empty-track + dash branch so an arbitrarily old + // snapshot never shows live-looking numbers. The honest recency line below says how old it is. + let stale = snap.isStale() + VStack(spacing: 12) { + // The three score rings. Each renders a number only when the phone earned one AND it is + // still current; a calibrating OR stale score is a dash with a small "cal" marker so we + // never show a value we did not compute or one that is no longer current. + HStack(spacing: 8) { + // The labels ride a plain String property into ScoreRing, so they must be wrapped HERE; + // a bare literal would bypass the string catalog entirely. + ScoreRing(label: String(localized: "Charge"), value: snap.charge, + calibrating: snap.chargeCalibrating || stale, + color: StrandPalette.chargeColor) + ScoreRing(label: String(localized: "Effort"), value: snap.effort, + calibrating: snap.effortCalibrating || stale, + color: StrandPalette.effortColor) + ScoreRing(label: String(localized: "Rest"), value: snap.rest, + calibrating: snap.restCalibrating || stale, + color: StrandPalette.restColor) + } + .frame(maxWidth: .infinity) + + heartRate + // A stale snapshot's sleep line is also out of date, so drop it rather than imply it is today's. + if !stale { sleepLine(snap.sleepSummary) } + asOf(snap) + } + .padding(.horizontal, 4) + .padding(.vertical, 8) + } + + /// Live heart rate from the watch's own sensor. Honest about denial: "HR unavailable" when HealthKit + /// access was refused, a dash until the first sample lands, then the live BPM. + private var heartRate: some View { + HStack(spacing: 6) { + Image(systemName: "heart.fill") + .font(.system(size: 13)) + .foregroundStyle(StrandPalette.statusCritical) + if liveHR.denied { + Text("HR unavailable") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } else { + Text(liveHR.bpm.map(String.init) ?? "–") + .font(StrandFont.rounded(20, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .monospacedDigit() + Text("bpm") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 12)) + } + + /// One-line sleep summary straight from the phone (e.g. "7h 12m · 81% Rest"). Empty string = skip it. + @ViewBuilder + private func sleepLine(_ summary: String) -> some View { + if !summary.isEmpty { + HStack(spacing: 6) { + Image(systemName: "bed.double.fill") + .font(.system(size: 11)) + .foregroundStyle(StrandPalette.restColor) + Text(summary) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity) + } + } + + /// The honesty line: how recent the synced scores are, straight from the shared contract so the + /// glance and the complication phrase it identically ("Today" / "Yesterday" / "2h ago"). When the + /// snapshot is stale the rings above are already dashes, and this line carries the recency. + private func asOf(_ snap: WatchScoreSnapshot) -> some View { + let fresh = snap.freshnessText() + return Text(snap.isStale() ? String(localized: "stale · \(fresh)") : String(localized: "as of \(fresh)")) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .frame(maxWidth: .infinity) + } + + // MARK: Empty state + + private var emptyState: some View { + VStack(spacing: 10) { + Image(systemName: "iphone.gen3") + .font(.system(size: 28)) + .foregroundStyle(StrandPalette.textTertiary) + Text("Open NOOP on your iPhone to sync") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, minHeight: 140) + .padding(.horizontal, 12) + } + + // Snapshot recency now comes from the shared contract (`freshnessText` / `isStale` on + // WatchScoreSnapshot) so the glance and the complication never drift apart. The old local + // ageString helper was retired with that move. +} + +// MARK: - ScoreRing — one clean NOOP ring scaled for the wrist +// +// Wraps the shared GlowRing (the flat, crisp Apple-Fitness-x-WHOOP arc) so the watch matches the phone's +// rings exactly. A calibrating score draws an EMPTY track with a dash centre and a small "cal" marker +// underneath, never a fabricated fill or number. Reduce-motion is respected inside GlowRing itself. +private struct ScoreRing: View { + let label: String + let value: Double? + let calibrating: Bool + let color: Color + + private let diameter: CGFloat = 52 + private let lineWidth: CGFloat = 6 + + var body: some View { + VStack(spacing: 4) { + ring + Text(label) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var ring: some View { + if let value, !calibrating { + // A real, earned score: the clean filled arc with its SF-Rounded number in the centre. + GlowRing(fraction: value / 100, + value: value, + format: { "\(Int($0.rounded()))" }, + color: color, + diameter: diameter, + lineWidth: lineWidth) + } else { + // Calibrating / no number yet: an empty track with a dash and a small "cal" marker. We render + // "needs more data" as a dash, NEVER a number we did not earn. + ZStack { + Circle() + .stroke(StrandPalette.textPrimary.opacity(0.10), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + VStack(spacing: 1) { + Text("–") + .font(GlowRing.centerFont(diameter: diameter)) + .foregroundStyle(StrandPalette.textTertiary) + Text("cal") + .font(StrandFont.overlineScaled(8)) + .tracking(0.5) + .foregroundStyle(color) + } + } + .frame(width: diameter, height: diameter) + } + } +} + diff --git a/NOOPWatch/WatchIntervalView.swift b/NOOPWatch/WatchIntervalView.swift new file mode 100644 index 0000000000..cef509ee96 --- /dev/null +++ b/NOOPWatch/WatchIntervalView.swift @@ -0,0 +1,320 @@ +import SwiftUI +import StrandDesign + +// MARK: - WatchIntervalView — silent haptic HIIT, on the wrist +// +// The watch-native sibling of the phone's Interval Timer (Strand/Screens/IntervalTimerView.swift). Same +// model: a WORK / REST state machine over a number of rounds with the session total derived from +// work*rounds + rest*(rounds-1). The difference is where the buzz lands. On the phone the strap (or the +// phone's own Taptic engine) cues the transitions; here the watch IS on your wrist, so we fire WatchKit +// haptics through StrandHaptic at every WORK<->REST flip and round change. Train hands-free and let the +// wrist tell you when to switch, never looking at the face. +// +// Defaults match the phone: 30s work / 15s rest / 8 rounds. Scaled for the watch: one big countdown ring +// is the whole screen (flat track + solid phase-tinted arc, SF-Rounded number in the centre, WHOOP-grey +// card), with the WORK/REST chip and ROUND x/N above it and compact Start/Pause + Reset below. No config +// steppers up here on the small face — the wrist is for running the session, the phone owns setup. +struct WatchIntervalView: View { + + // Cross-lane contract: a no-arg init, fully self-contained. + init() {} + + // MARK: Config (the phone's defaults — fixed on the watch, run-only surface) + + private let workSeconds = 30 + private let restSeconds = 15 + private let rounds = 8 + + // MARK: Run state + + private enum Phase { + case work, rest, done + var label: String { + switch self { + case .work: return String(localized: "WORK") + case .rest: return String(localized: "REST") + case .done: return String(localized: "DONE") + } + } + } + + @State private var phase: Phase = .work + @State private var currentRound = 1 + @State private var remaining = 30 // seconds left in the current phase + @State private var running = false + @State private var elapsed = 0 // total elapsed seconds across the session + + // 1Hz tick, same cadence as the phone. + private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + // MARK: Derived + + private var phaseDuration: Int { + switch phase { + case .work: return max(1, workSeconds) + case .rest: return max(1, restSeconds) + case .done: return 1 + } + } + + /// 0...1 progress through the current interval. + private var intervalProgress: Double { + guard phaseDuration > 0 else { return 0 } + let done = Double(phaseDuration - remaining) + return min(1, max(0, done / Double(phaseDuration))) + } + + /// The active phase's reset token: WORK uses the Effort blue, REST the Rest blue-grey, DONE the + /// positive green. Tints the flat ring arc + the phase chip only (no glow), matching the phone. + private var phaseColor: Color { + switch phase { + case .work: return StrandPalette.effortColor + case .rest: return StrandPalette.restColor + case .done: return StrandPalette.statusPositive + } + } + + private var isFinished: Bool { phase == .done } + + // MARK: Body + + var body: some View { + // One-screen fit: no ScrollView. A fixed compact header + a fixed control row top-and-tail the + // face, and the countdown ring takes exactly the space left between them. Sizing the hero to the + // remaining height means Start/Pause + Reset are always on screen, on a 41mm right up to an Ultra, + // with nothing ever falling below the fold. + GeometryReader { geo in + let spacing: CGFloat = 6 + // Measured constants for the two fixed rows so we can hand the ring whatever's left over. + let headerHeight: CGFloat = 26 + let controlsHeight: CGFloat = 34 + let available = geo.size.height + - headerHeight - controlsHeight + - spacing * 2 // the two gaps between the three rows + // Clamp the ring to the smaller of the width and the leftover height, with a sane floor so it + // never collapses to nothing on the tightest faces. + let ringSpace = min(geo.size.width, max(available, 64)) + let diameter = max(64, min(ringSpace, 150)) + + VStack(spacing: spacing) { + header + .frame(height: headerHeight) + heroRing(diameter: diameter) + .frame(maxWidth: .infinity, maxHeight: .infinity) + controls + .frame(height: controlsHeight) + } + .frame(width: geo.size.width, height: geo.size.height) + } + .padding(.horizontal, 4) + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .onReceive(ticker) { _ in tick() } + .onAppear { if remaining == 0 { resetToStart() } } + } + + // MARK: Header — phase chip + round chip + + private var header: some View { + HStack { + phaseChip + Spacer(minLength: 6) + roundChip + } + .frame(maxWidth: .infinity) + } + + /// Tinted phase pill (WORK / REST / DONE). + private var phaseChip: some View { + Text(phase.label) + .font(StrandFont.rounded(13, weight: .heavy)) + .tracking(1.5) + .foregroundStyle(phaseColor) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(phaseColor.opacity(0.16), in: Capsule(style: .continuous)) + .overlay(Capsule(style: .continuous).strokeBorder(phaseColor.opacity(0.35), lineWidth: 1)) + } + + /// "ROUND n / N" chip. + private var roundChip: some View { + HStack(spacing: 3) { + Text("\(min(currentRound, rounds))") + .font(StrandFont.number(15)) + .foregroundStyle(StrandPalette.textPrimary) + Text("/ \(rounds)") + .font(StrandFont.number(15)) + .foregroundStyle(StrandPalette.textTertiary) + } + .monospacedDigit() + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(StrandPalette.surfaceInset, in: Capsule(style: .continuous)) + .overlay(Capsule(style: .continuous).strokeBorder(StrandPalette.hairline, lineWidth: 1)) + .accessibilityLabel("Round \(min(currentRound, rounds)) of \(rounds)") + } + + // MARK: Hero ring — the countdown + + /// Flat phase-progress ring (visible track + solid reset-token arc, no glow) with the countdown number + /// + caption centred, scaled down to the wrist. Same look as the phone's heroRing, just smaller. The + /// diameter comes from the body's GeometryReader so the ring soaks up whatever vertical space is left + /// after the fixed header + controls, keeping every control on one screen. + private func heroRing(diameter: CGFloat) -> some View { + // Stroke scales with the ring so it stays proportional from a 41mm right up to an Ultra. + let lineWidth: CGFloat = max(7, min(11, diameter * 0.085)) + let fraction = isFinished ? 1 : intervalProgress + return ZStack { + // Visible full-circle track so the arc reads as a fraction of a circle (WHOOP-style). + Circle() + .stroke(StrandPalette.textPrimary.opacity(0.10), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + // Flat, crisp solid arc — no glow. + Circle() + .trim(from: 0, to: max(0.0001, CGFloat(min(max(fraction, 0), 1)))) + .rotation(.degrees(-90)) + .stroke(phaseColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .animation(.snappy, value: fraction) + // Centred countdown number + caption. + VStack(spacing: 2) { + Text(isFinished ? "✓" : "\(remaining)") + .font(GlowRing.centerFont(diameter: diameter)) + .foregroundStyle(StrandPalette.textPrimary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.5) + .contentTransition(.numericText()) + Text(isFinished ? String(localized: "DONE") : String(localized: "SEC")) + .font(StrandFont.overlineScaled(9)) + .tracking(1.5) + .foregroundStyle(StrandPalette.textTertiary) + } + .padding(.horizontal, lineWidth + 3) + } + .frame(width: diameter, height: diameter) + .animation(.snappy, value: remaining) + .accessibilityElement(children: .ignore) + .accessibilityLabel(isFinished ? String(localized: "Session done") + : String(localized: "\(remaining) seconds remaining in \(phase.label)")) + } + + // MARK: Controls — Start/Pause + Reset + + private var controls: some View { + HStack(spacing: 6) { + Button { + if isFinished { resetToStart() } + toggleRunning() + } label: { + Label(running ? String(localized: "Pause") + : (isFinished ? String(localized: "Restart") : String(localized: "Start")), + systemImage: running ? "pause.fill" : "play.fill") + .font(StrandFont.rounded(14, weight: .semibold)) + .frame(maxWidth: .infinity) + } + .tint(phaseColor) + + Button { + stopAndReset() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + } + .tint(StrandPalette.surfaceRaised) + .accessibilityLabel("Reset") + .disabled(isCleanStart) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + /// True when nothing has run yet — disables Reset so it never looks active on a fresh session. + private var isCleanStart: Bool { + !running && phase == .work && currentRound == 1 + && remaining == max(1, workSeconds) && elapsed == 0 + } + + // MARK: Timer logic (reimplemented to match the phone's parameters) + + private func tick() { + guard running, !isFinished else { return } + + // 3-2-1 countdown tick on the last seconds of the current phase — a light wrist tap. + if remaining <= 3 && remaining >= 1 { + StrandHaptic.selection.play() + } + + if remaining > 1 { + remaining -= 1 + elapsed += 1 + return + } + + // remaining hits 0 — advance to the next phase/round. + elapsed += 1 + advancePhase() + } + + private func advancePhase() { + switch phase { + case .work: + if currentRound >= rounds { + // Last work block finished → session complete. + finishSession() + } else { + // Into rest — a soft single cue. + phase = .rest + remaining = max(1, restSeconds) + StrandHaptic.light.play() + } + case .rest: + // Rest done → next round's work — a strong cue so you feel it without looking. + currentRound += 1 + phase = .work + remaining = max(1, workSeconds) + StrandHaptic.commit.play() + case .done: + break + } + } + + private func finishSession() { + withAnimation(.snappy) { + phase = .done + remaining = 0 + running = false + } + StrandHaptic.success.play() // long completion cue + } + + private func toggleRunning() { + if isFinished { return } + if running { + running = false + } else { + // Starting fresh from a clean reset → fire the opening WORK cue, like the phone does. + let startingFresh = isCleanStart + running = true + if startingFresh { StrandHaptic.commit.play() } + } + } + + private func stopAndReset() { + running = false + resetToStart() + } + + /// Reset run state back to round 1 / start of work, using current config. + private func resetToStart() { + phase = .work + currentRound = 1 + remaining = max(1, workSeconds) + elapsed = 0 + } +} + +#if DEBUG +#Preview("Watch Interval") { + WatchIntervalView() +} +#endif diff --git a/NOOPWatch/WatchLiveHR.swift b/NOOPWatch/WatchLiveHR.swift new file mode 100644 index 0000000000..c7e659c8a5 --- /dev/null +++ b/NOOPWatch/WatchLiveHR.swift @@ -0,0 +1,93 @@ +import Foundation +import Combine +#if canImport(HealthKit) +import HealthKit +#endif + +// MARK: - WatchLiveHR — the watch's own live heart rate +// +// This is the one number the watch measures itself rather than receiving from the phone: the wrist's +// current heart rate, read from HealthKit via a streaming HKAnchoredObjectQuery. It is GUARDED at every +// step. If HealthKit is unavailable or the user denied heart-rate read access, `bpm` stays nil and +// `denied` flips true so the glance can honestly show "HR unavailable" instead of a fake number. +// +// We deliberately keep this lightweight: an anchored query that delivers the newest samples while the app +// is foregrounded, no HKWorkoutSession. A full session (and the higher-fidelity in-workout stream) is M4. +final class WatchLiveHR: ObservableObject { + + /// The most recent heart rate in whole BPM, or nil if we have no reading yet. + @Published private(set) var bpm: Int? + /// True once we know HealthKit is unavailable or read access was denied. Drives "HR unavailable". + @Published private(set) var denied: Bool = false + + #if canImport(HealthKit) + private let store = HKHealthStore() + private let hrType = HKQuantityType.quantityType(forIdentifier: .heartRate) + private var query: HKAnchoredObjectQuery? + private let bpmUnit = HKUnit.count().unitDivided(by: .minute()) + #endif + + /// Ask for permission (idempotent) and start streaming. Call when the glance appears. + func start() { + #if canImport(HealthKit) + guard HKHealthStore.isHealthDataAvailable(), let hrType else { + denied = true + return + } + // Read-only — we never write HR from the watch. If the user declines, the streaming query simply + // returns no samples and we surface "HR unavailable". + store.requestAuthorization(toShare: [], read: [hrType]) { [weak self] granted, _ in + guard let self else { return } + DispatchQueue.main.async { + if granted { + self.beginStreaming() + } else { + self.denied = true + } + } + } + #else + denied = true + #endif + } + + /// Tear the query down when the glance disappears so we are not streaming HR in the background. + func stop() { + #if canImport(HealthKit) + if let query { store.stop(query) } + query = nil + #endif + } + + #if canImport(HealthKit) + private func beginStreaming() { + guard let hrType, query == nil else { return } + // Anchored query: an initial results handler plus an updateHandler that fires as new samples land, + // so the readout tracks the wrist live while the screen is on. + let q = HKAnchoredObjectQuery(type: hrType, + predicate: nil, + anchor: nil, + limit: HKObjectQueryNoLimit) { [weak self] _, samples, _, _, _ in + self?.handle(samples) + } + q.updateHandler = { [weak self] _, samples, _, _, _ in + self?.handle(samples) + } + query = q + store.execute(q) + } + + /// Pull the newest sample out of a batch and publish its BPM. Reads can arrive on a background queue, + /// so publish on the main actor. + private func handle(_ samples: [HKSample]?) { + guard let latest = (samples as? [HKQuantitySample])? + .max(by: { $0.endDate < $1.endDate }) else { return } + let value = latest.quantity.doubleValue(for: bpmUnit) + let rounded = Int(value.rounded()) + DispatchQueue.main.async { + self.bpm = rounded + self.denied = false + } + } + #endif +} diff --git a/NOOPWatch/WatchRootView.swift b/NOOPWatch/WatchRootView.swift new file mode 100644 index 0000000000..4a692b024c --- /dev/null +++ b/NOOPWatch/WatchRootView.swift @@ -0,0 +1,24 @@ +import SwiftUI +import StrandDesign + +// MARK: - WatchRootView — the swipeable page deck +// +// The watch app is a deck of full-screen pages you swipe (or turn the Digital Crown) between, each sized to +// exactly ONE screen so nothing ever needs scrolling: the glance (today's synced scores) first, then the +// three on-watch active features. A page-style TabView with the dots showing replaces the old push-nav, so +// every screen is one swipe away and the page indicator makes that obvious. The phone stays the brain for +// the SCORES on the glance; Breathe / Workout / Intervals run on the watch's own sensors + haptics. +struct WatchRootView: View { + var body: some View { + TabView { + WatchGlanceView() + WatchBreatheView() + WatchWorkoutView() + WatchIntervalView() + } + // watchOS page TabView shows the page-indicator dots by default; the iOS background-display-mode + // customisation is unavailable here, so the plain page style is the right call. + .tabViewStyle(.page) + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + } +} diff --git a/NOOPWatch/WatchScoreStore.swift b/NOOPWatch/WatchScoreStore.swift new file mode 100644 index 0000000000..3e033eea6a --- /dev/null +++ b/NOOPWatch/WatchScoreStore.swift @@ -0,0 +1,112 @@ +import Foundation +import Combine +import WatchConnectivity +import WidgetKit +import StrandDesign + +// MARK: - WatchScoreStore — the watch side of the phone->watch bridge +// +// Activates WCSession on the watch, receives the latest score snapshot the phone pushed via +// `updateApplicationContext` (latest-state semantics, no queue buildup), persists it into the shared +// App Group so the complication can read the same bytes, and reloads the complication timelines so the +// watch face matches the glance. The phone is the brain; this object never computes a score, it only +// carries the one the phone already earned. +// +// The published `snapshot` is what the glance binds to. It starts from whatever was last persisted to the +// App Group (so a relaunch shows the last-known scores immediately, with an honest "as of" age) and is +// nil only on a truly fresh install, which the glance renders as the "open NOOP on your iPhone" state. +final class WatchScoreStore: NSObject, ObservableObject, WCSessionDelegate { + + /// The latest snapshot the watch knows about. nil = nothing has ever synced (fresh install). + @Published private(set) var snapshot: WatchScoreSnapshot? + + /// The shared App Group suite the watch app + its complication both read/write. The watch reads its + /// own bundle's AppGroupIdentifier Info.plist key (injected from $(APP_GROUP_ID) in project.yml) so + /// the value is never hard-coded in Swift, then falls back to the canonical group defined ONCE in + /// the shared contract (StrandDesign) so the writer and readers can't desync on it. + static let suiteName: String = { + Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String + ?? WatchScoreSnapshot.appGroupId + }() + + /// The key the complication also reads. The single source of truth lives in the shared contract. + static let storageKey = WatchScoreSnapshot.storageKey + + override init() { + super.init() + // Show the last-known snapshot straight away (honest about its age via the glance's "as of"). + snapshot = Self.loadPersisted() + activate() + } + + /// Bring up the WCSession so the phone can reach us. Guarded because the simulator / an unpaired + /// state can report the session unsupported, in which case we simply run on the last persisted snapshot. + private func activate() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self + session.activate() + } + + // MARK: Persistence (shared with the complication) + + /// Read the last snapshot the phone delivered, if any. The complication uses the same key. + static func loadPersisted() -> WatchScoreSnapshot? { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: storageKey), + let snap = try? JSONDecoder().decode(WatchScoreSnapshot.self, from: data) else { return nil } + return snap + } + + /// Persist a snapshot into the shared group so the complication reads the SAME bytes the glance shows. + /// They can never disagree because there is one source of truth. + private func persist(_ snap: WatchScoreSnapshot) { + guard let defaults = UserDefaults(suiteName: Self.suiteName), + let data = try? JSONEncoder().encode(snap) else { return } + defaults.set(data, forKey: Self.storageKey) + } + + /// Apply a freshly received snapshot: store it, publish to the glance, refresh the complication. + /// Hops to the main actor because it touches @Published state and WidgetCenter. + private func apply(_ snap: WatchScoreSnapshot) { + persist(snap) + DispatchQueue.main.async { + self.snapshot = snap + // The phone just pushed new scores, so pull the complication timelines forward now rather + // than waiting for WidgetKit's own cadence. + WidgetCenter.shared.reloadAllTimelines() + } + } + + /// Decode a WatchScoreSnapshot out of a WatchConnectivity payload. The phone encodes the Codable + /// snapshot to Data under "snapshot"; we tolerate a missing/garbled payload by simply ignoring it. + private func decode(from payload: [String: Any]) -> WatchScoreSnapshot? { + guard let data = payload["snapshot"] as? Data else { return nil } + return try? JSONDecoder().decode(WatchScoreSnapshot.self, from: data) + } + + // MARK: WCSessionDelegate + + func session(_ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error?) { + // On activation the system hands us the most recent application context the phone set, even if it + // was set while we were not running. Pick it up so a relaunch immediately reflects the latest scores. + if let snap = decode(from: session.receivedApplicationContext) { + apply(snap) + } + } + + /// The phone calls `updateApplicationContext` whenever its dashboard refreshes. Latest-state only, so + /// we always have the freshest scores without a backlog of stale messages. + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + if let snap = decode(from: applicationContext) { + apply(snap) + } + } + + // Required by the protocol on watchOS even though they are phone-side concerns. No-ops here. + #if os(watchOS) + func sessionReachabilityDidChange(_ session: WCSession) {} + #endif +} diff --git a/NOOPWatch/WatchWorkoutView.swift b/NOOPWatch/WatchWorkoutView.swift new file mode 100644 index 0000000000..ead1a39964 --- /dev/null +++ b/NOOPWatch/WatchWorkoutView.swift @@ -0,0 +1,561 @@ +import SwiftUI +import StrandDesign +#if canImport(HealthKit) +import HealthKit +#endif + +// MARK: - WatchWorkoutView — record a workout ON the wrist (M3) +// +// This is the one ACTIVE feature where the watch is the brain, not the phone. The phone owns SCORES; this +// screen owns a real HKWorkoutSession + HKLiveWorkoutBuilder running on the watch's own sensors, so the +// heart rate here is the higher-fidelity in-workout stream (not the foregrounded anchored-query readout the +// glance uses), and the energy is the watch's own activeEnergyBurned. On End we save the finished workout +// to HealthKit so it shows up in Activity / Fitness like any other. +// +// We deliberately reimplement the phone's LiveWorkoutView rather than link it: that screen reads the strap +// feed and the shared scorers off AppModel, which don't exist on the watch. The framing is kept though — +// a generic "functional" workout (functionalStrengthTraining), a big live HR hero in SF-Rounded, elapsed +// time, and the building Effort idea expressed honestly here as the live calorie burn from the wrist. +// +// Everything is GUARDED. If HealthKit is unavailable or workout authorization is denied, we show a calm +// "Grant Health access" state instead of a dead Start button. StrandHaptic (real WatchKit path now) marks +// the start / pause / resume / end landings so the wrist confirms each state change without looking. +struct WatchWorkoutView: View { + @StateObject private var workout = WatchWorkoutSession() + + var body: some View { + // One screen, no scrolling. A GeometryReader hands each state the real space it has to live in so + // the controls never fall below the fold on any watch size. The recording state in particular sizes + // its HR hero to whatever height is left after the fixed header and the fixed control row. + GeometryReader { geo in + Group { + switch workout.phase { + case .unavailable, .denied: + grantAccess + case .idle: + idle + case .requesting: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .active, .paused, .ending: + recording(in: geo.size) + case .saved: + saved + } + } + .frame(width: geo.size.width, height: geo.size.height) + .padding(.horizontal, 4) + } + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + } + + // MARK: Pre-flight states + + /// HealthKit unavailable or workout write denied. Honest about it, with a retry that re-asks (or sends + /// the user to Settings if the system has already remembered a hard "no"). + private var grantAccess: some View { + VStack(spacing: 8) { + Image(systemName: "heart.text.square") + .font(.system(size: 24)) + .foregroundStyle(StrandPalette.textTertiary) + Text("Grant Health access") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + // Condensed so the whole panel clears the fold on a 41mm. + Text("Live heart rate and energy, recorded on your wrist. Stays on device.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.8) + Button("Allow access") { workout.requestAuthorization() } + .font(StrandFont.subhead) + .tint(StrandPalette.effortColor) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 8) + } + + /// Ready to record. A single big Effort-tinted Start. + private var idle: some View { + VStack(spacing: 14) { + Image(systemName: "figure.strengthtraining.functional") + .font(.system(size: 30)) + .foregroundStyle(StrandPalette.effortColor) + Text("Workout") + .font(StrandFont.rounded(22, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + Text("Functional strength") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + Button { + workout.start() + } label: { + Label("Start", systemImage: "play.fill") + .font(StrandFont.subhead) + .frame(maxWidth: .infinity) + } + .tint(StrandPalette.effortColor) + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: Recording + + /// The whole recording layout, sized to fit ONE screen. We reserve fixed heights for the compact header, + /// the stats row, and the side-by-side control row, then hand whatever is left to the HR hero so End is + /// always on screen. The hero gets the remaining height (floored so it never collapses), which keeps the + /// big SF-Rounded BPM the visual anchor on a 41mm and lets it breathe on the bigger watches. + private func recording(in size: CGSize) -> some View { + let spacing: CGFloat = 6 + let headerH: CGFloat = 22 + let statsH: CGFloat = 50 + let controlsH: CGFloat = 40 + let reserved = headerH + statsH + controlsH + spacing * 4 // 3 gaps + a little breathing room + let heroH = max(56, size.height - reserved) + + return VStack(spacing: spacing) { + header + .frame(height: headerH) + heroHeartRate + .frame(maxHeight: heroH) + statsRow + .frame(height: statsH) + controls + .frame(height: controlsH) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var header: some View { + HStack { + Circle() + .fill(workout.phase == .paused ? StrandPalette.statusWarning : StrandPalette.statusCritical) + .frame(width: 7, height: 7) + Text(workout.phase == .paused ? String(localized: "PAUSED") : String(localized: "RECORDING")) + .font(StrandFont.overline) + .tracking(StrandFont.overlineTracking) + .foregroundStyle(workout.phase == .paused ? StrandPalette.statusWarning : StrandPalette.metricRose) + Spacer() + // Elapsed time ticks itself off the session start via a TimelineView, so we never run a manual + // Timer. While paused we freeze the readout at the accumulated duration the session reports. + elapsed + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var elapsed: some View { + if workout.phase == .paused { + Text(Self.clock(workout.elapsed)) + .font(StrandFont.rounded(18, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(StrandPalette.textPrimary) + } else { + TimelineView(.periodic(from: .now, by: 1)) { _ in + Text(Self.clock(workout.elapsed)) + .font(StrandFont.rounded(18, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(StrandPalette.textPrimary) + } + } + } + + /// The big live wrist heart rate, SF-Rounded, on a near-black Effort-tinted card. A dash until the + /// first in-session sample lands. + private var heroHeartRate: some View { + VStack(spacing: 1) { + Text("HEART RATE") + .font(StrandFont.overline) + .tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "heart.fill") + .font(.system(size: 13)) + .foregroundStyle(StrandPalette.statusCritical) + Text(workout.bpm.map(String.init) ?? "–") + .font(StrandFont.rounded(40, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(StrandPalette.textPrimary) + .minimumScaleFactor(0.7) + .lineLimit(1) + } + Text("bpm") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 14)) + } + + /// Active energy from the watch's own builder, the watch-native stand-in for the phone's building + /// Effort. Whole kcal, SF-Rounded, never a fabricated number (a dash until the builder reports any). + private var statsRow: some View { + HStack(spacing: 6) { + stat("ENERGY", workout.activeKcal.map { "\($0)" } ?? "–", unit: "kcal", + tint: StrandPalette.effortColor) + stat("AVG HR", workout.avgBpm.map(String.init) ?? "–", unit: "bpm", + tint: StrandPalette.metricRose) + } + } + + private func stat(_ title: String, _ value: String, unit: String, tint: Color) -> some View { + VStack(spacing: 2) { + Text(title) + .font(StrandFont.overlineScaled(9)) + .tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textTertiary) + Text(value) + .font(StrandFont.rounded(24, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(unit) + .font(StrandFont.overlineScaled(8)) + .foregroundStyle(StrandPalette.textTertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 12)) + } + + /// Pause/Resume and End sit SIDE BY SIDE on one row so both are always on screen without scrolling. + /// Icon-only buttons keep them compact on a 41mm; the role/tint still reads at a glance (Effort-tinted + /// pause/resume, critical-red End). + private var controls: some View { + HStack(spacing: 8) { + if workout.phase == .paused { + Button { + workout.resume() + } label: { + Label("Resume", systemImage: "play.fill") + .labelStyle(.iconOnly) + .font(StrandFont.subhead) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .accessibilityLabel("Resume") + .tint(StrandPalette.effortColor) + .buttonStyle(.borderedProminent) + } else { + Button { + workout.pause() + } label: { + Label("Pause", systemImage: "pause.fill") + .labelStyle(.iconOnly) + .font(StrandFont.subhead) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .accessibilityLabel("Pause") + .tint(StrandPalette.surfaceRaised) + .buttonStyle(.bordered) + } + + Button(role: .destructive) { + workout.end() + } label: { + Label("End", systemImage: "stop.fill") + .labelStyle(.iconOnly) + .font(StrandFont.subhead) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .accessibilityLabel("End workout") + .tint(StrandPalette.statusCritical) + .buttonStyle(.borderedProminent) + .disabled(workout.phase == .ending) + } + } + + // MARK: Saved + + private var saved: some View { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(StrandPalette.chargeColor) + Text("Workout saved") + .font(StrandFont.rounded(20, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + // A small honest recap of what we banked. Whole-phrase per shape (no appended tail) + // so the kcal variant localizes as one string. + Text(workout.activeKcal.map { String(localized: "\(Self.clock(workout.elapsed)) · \($0) kcal") } + ?? Self.clock(workout.elapsed)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + Button("Done") { workout.reset() } + .font(StrandFont.subhead) + .tint(StrandPalette.effortColor) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: Helpers + + /// m:ss for short sessions, h:mm:ss once we cross the hour. Whole seconds, monospaced at the call site. + static func clock(_ seconds: TimeInterval) -> String { + let s = max(0, Int(seconds)) + let h = s / 3600, m = (s % 3600) / 60, sec = s % 60 + return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec) + : String(format: "%d:%02d", m, sec) + } +} + +// MARK: - WatchWorkoutSession — the HKWorkoutSession + HKLiveWorkoutBuilder engine +// +// Owns the live workout lifecycle on the watch. The view is pure; this object is the only thing that talks +// to HealthKit. Every published value comes from the builder's own statistics (HR / active energy) or the +// session's accumulated duration, so the numbers the wrist shows are the ones HealthKit will save. Nothing +// is invented: a metric stays nil until its first real sample lands and the UI renders a dash for nil. +final class WatchWorkoutSession: NSObject, ObservableObject { + + /// Where we are in the lifecycle. The view switches its whole layout on this. + enum Phase: Equatable { + case unavailable // HealthKit not on this device at all + case denied // workout write authorization refused + case idle // authorized, ready to start + case requesting // auth prompt in flight + case active // recording + case paused // recording, paused + case ending // end() in flight, saving to HealthKit + case saved // saved, showing the recap + } + + @Published private(set) var phase: Phase = .idle + /// Live wrist heart rate (whole BPM) from the builder, or nil before the first sample. + @Published private(set) var bpm: Int? + /// Session-average heart rate so far, or nil before the first sample. + @Published private(set) var avgBpm: Int? + /// Active energy burned this session in whole kcal, or nil before the first sample. + @Published private(set) var activeKcal: Int? + /// Accumulated session duration. Read live by the view's TimelineView while active. + @Published private(set) var elapsed: TimeInterval = 0 + + #if canImport(HealthKit) && os(watchOS) + private let store = HKHealthStore() + private var session: HKWorkoutSession? + private var builder: HKLiveWorkoutBuilder? + + private let hrUnit = HKUnit.count().unitDivided(by: .minute()) + private let kcalUnit = HKUnit.kilocalorie() + + /// What we ask to write: the workout itself plus the two series we surface live. Read-only HR is for the + /// live readout. Mirrors the phone's "we never invent, we record" stance. + private var shareTypes: Set { + var set: Set = [HKQuantityType.workoutType()] + if let e = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) { set.insert(e) } + if let hr = HKQuantityType.quantityType(forIdentifier: .heartRate) { set.insert(hr) } + return set + } + private var readTypes: Set { + var set: Set = [] + if let hr = HKQuantityType.quantityType(forIdentifier: .heartRate) { set.insert(hr) } + if let e = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) { set.insert(e) } + return set + } + #endif + + override init() { + super.init() + refreshAvailability() + } + + /// Decide the initial phase from HealthKit availability and the write status we already hold. We do not + /// ask for permission here, only on Start or the explicit "Allow access" button, so opening the tab is + /// quiet (Apple's guidance: prompt at the point of use). + private func refreshAvailability() { + #if canImport(HealthKit) && os(watchOS) + guard HKHealthStore.isHealthDataAvailable() else { phase = .unavailable; return } + let status = store.authorizationStatus(for: HKQuantityType.workoutType()) + phase = (status == .sharingDenied) ? .denied : .idle + #else + phase = .unavailable + #endif + } + + /// Explicit auth request (the "Allow access" button). Idempotent; HealthKit no-ops if already decided. + func requestAuthorization(then start: Bool = false) { + #if canImport(HealthKit) && os(watchOS) + guard HKHealthStore.isHealthDataAvailable() else { phase = .unavailable; return } + phase = .requesting + store.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] _, _ in + guard let self else { return } + DispatchQueue.main.async { + // requestAuthorization's `granted` only reports whether the sheet was shown, not the user's + // choice, so we read the real share status back. Denied write = no workout to save. + let status = self.store.authorizationStatus(for: HKQuantityType.workoutType()) + if status == .sharingDenied { + self.phase = .denied + } else { + self.phase = .idle + if start { self.start() } + } + } + } + #else + phase = .unavailable + #endif + } + + /// Begin recording a generic functional-strength workout indoors. If we have not been authorized yet, + /// route through the auth prompt first and auto-start on grant. + func start() { + #if canImport(HealthKit) && os(watchOS) + guard HKHealthStore.isHealthDataAvailable() else { phase = .unavailable; return } + let status = store.authorizationStatus(for: HKQuantityType.workoutType()) + guard status == .sharingAuthorized else { + requestAuthorization(then: true) + return + } + + let config = HKWorkoutConfiguration() + config.activityType = .functionalStrengthTraining + config.locationType = .indoor + + do { + let session = try HKWorkoutSession(healthStore: store, configuration: config) + let builder = session.associatedWorkoutBuilder() + builder.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: config) + session.delegate = self + builder.delegate = self + + self.session = session + self.builder = builder + + let begin = Date() + session.startActivity(with: begin) + builder.beginCollection(withStart: begin) { [weak self] _, _ in + // Collection started (or failed silently); the delegate callbacks drive the UI from here. + DispatchQueue.main.async { self?.phase = .active } + } + StrandHaptic.commit.play() // a firm tap confirms the session is live without looking + } catch { + // Could not create the session (rare). Fall back to idle so Start can be tried again. + phase = .idle + } + #else + phase = .unavailable + #endif + } + + func pause() { + #if canImport(HealthKit) && os(watchOS) + session?.pause() + // The session's didChangeTo callback flips us to .paused; haptic there so it matches the real state. + #endif + } + + func resume() { + #if canImport(HealthKit) && os(watchOS) + session?.resume() + #endif + } + + /// Stop the session, finalize collection, and save the workout to HealthKit. The recap appears on save. + func end() { + #if canImport(HealthKit) && os(watchOS) + guard let session, let builder, phase == .active || phase == .paused else { return } + phase = .ending + let stop = Date() + session.stopActivity(with: stop) + builder.endCollection(withEnd: stop) { [weak self] _, _ in + builder.finishWorkout { [weak self] _, _ in + DispatchQueue.main.async { + StrandHaptic.success.play() // milestone: the workout is banked to HealthKit + self?.phase = .saved + self?.session = nil + self?.builder = nil + } + } + } + #else + phase = .saved + #endif + } + + /// Clear the recap and return to idle so another workout can be started. + func reset() { + bpm = nil + avgBpm = nil + activeKcal = nil + elapsed = 0 + refreshAvailability() + } +} + +// MARK: - HealthKit delegates + +#if canImport(HealthKit) && os(watchOS) +extension WatchWorkoutSession: HKWorkoutSessionDelegate { + func workoutSession(_ workoutSession: HKWorkoutSession, + didChangeTo toState: HKWorkoutSessionState, + from fromState: HKWorkoutSessionState, + date: Date) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + switch toState { + case .running: + if self.phase != .active { StrandHaptic.selection.play() } + self.phase = .active + case .paused: + self.phase = .paused + StrandHaptic.light.play() // soft tap marks the pause landing + default: + break + } + } + } + + func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { + // The session died on us. Surface idle so the user can retry rather than sitting on a frozen screen. + DispatchQueue.main.async { [weak self] in + self?.phase = .idle + self?.session = nil + self?.builder = nil + } + } +} + +extension WatchWorkoutSession: HKLiveWorkoutBuilderDelegate { + func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) { + // Pause / resume events update the accumulated duration the elapsed readout shows. + DispatchQueue.main.async { [weak self] in + self?.elapsed = workoutBuilder.elapsedTime + } + } + + func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, + didCollectDataOf collectedTypes: Set) { + // A new batch of samples landed. Pull the latest HR, the running average HR, and total active + // energy straight from the builder's own statistics so the wrist shows exactly what HealthKit holds. + var newBpm: Int? + var newAvg: Int? + var newKcal: Int? + + if let hrType = HKQuantityType.quantityType(forIdentifier: .heartRate), + collectedTypes.contains(hrType), + let stats = workoutBuilder.statistics(for: hrType) { + if let recent = stats.mostRecentQuantity()?.doubleValue(for: hrUnit) { + newBpm = Int(recent.rounded()) + } + if let avg = stats.averageQuantity()?.doubleValue(for: hrUnit) { + newAvg = Int(avg.rounded()) + } + } + + if let eType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), + collectedTypes.contains(eType), + let stats = workoutBuilder.statistics(for: eType), + let total = stats.sumQuantity()?.doubleValue(for: kcalUnit) { + newKcal = Int(total.rounded()) + } + + let elapsedNow = workoutBuilder.elapsedTime + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if let newBpm { self.bpm = newBpm } + if let newAvg { self.avgBpm = newAvg } + if let newKcal { self.activeKcal = newKcal } + self.elapsed = elapsedNow + } + } +} +#endif diff --git a/NOOPWatchComplications/Info.plist b/NOOPWatchComplications/Info.plist new file mode 100644 index 0000000000..af3e062e39 --- /dev/null +++ b/NOOPWatchComplications/Info.plist @@ -0,0 +1,31 @@ + + + + + AppGroupIdentifier + $(APP_GROUP_ID) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NOOP + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/NOOPWatchComplications/Localizable.xcstrings b/NOOPWatchComplications/Localizable.xcstrings new file mode 100644 index 0000000000..fbe2aeef1d --- /dev/null +++ b/NOOPWatchComplications/Localizable.xcstrings @@ -0,0 +1,483 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@ %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %lld" + } + } + } + }, + "%@ calibrating" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ calibrating" + } + } + } + }, + "%@ unavailable" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ unavailable" + } + } + } + }, + "a while ago" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "a while ago" + } + } + } + }, + "cal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cal" + } + } + } + }, + "Charge" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carga" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carica" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заряд" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量" + } + } + } + }, + "Charge –" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge –" + } + } + } + }, + "Charge · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge · %@" + } + } + } + }, + "Charge · cal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge · cal" + } + } + } + }, + "Charge %lld · %lld bpm%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge %lld · %lld bpm%@" + } + } + } + }, + "Charge %lld out of 100" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge %lld out of 100" + } + } + } + }, + "Charge %lld%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge %lld%@" + } + } + } + }, + "Charge calibrating" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge calibrating" + } + } + } + }, + "Charge calibrating, needs more data" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge calibrating, needs more data" + } + } + } + }, + "Charge out of date, last synced %@. Open NOOP on iPhone." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge out of date, last synced %@. Open NOOP on iPhone." + } + } + } + }, + "Charge stale · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge stale · %@" + } + } + } + }, + "Charge unavailable" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge unavailable" + } + } + } + }, + "Effort" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anstrengung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effort" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esfuerzo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effort" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sforzo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усилие" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "消耗" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "消耗" + } + } + } + }, + "No data, open NOOP on iPhone" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No data, open NOOP on iPhone" + } + } + } + }, + "NOOP" : { + + }, + "NOOP · open on iPhone" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP · open on iPhone" + } + } + } + }, + "NOOP Charge" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP Charge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carga de NOOP" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carica NOOP" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP 能量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP 能量" + } + } + } + }, + "NOOP. %@, %@, %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP. %@, %@, %@." + } + } + } + }, + "NOOP. No data yet, open NOOP on your iPhone to sync." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP. No data yet, open NOOP on your iPhone to sync." + } + } + } + }, + "NOOP. Scores out of date, last synced %@. Open NOOP on iPhone to refresh." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP. Scores out of date, last synced %@. Open NOOP on iPhone to refresh." + } + } + } + }, + "old" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "old" + } + } + } + }, + "open iPhone" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open iPhone" + } + } + } + }, + "Open NOOP" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open NOOP" + } + } + } + }, + "Rest" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruhe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rest" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descanso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repos" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riposo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отдых" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "休息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "休息" + } + } + } + }, + "stale" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "stale" + } + } + } + }, + "Your Charge (recovery) on the watch face, with Effort and Rest in the rectangular card." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your Charge (recovery) on the watch face, with Effort and Rest in the rectangular card." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu Carga (recuperación) en la esfera del reloj, con Esfuerzo y Descanso en la tarjeta rectangular." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tua Carica (recupero) sul quadrante, con Sforzo e Riposo nella scheda rettangolare." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表盘上显示你的能量(恢复),矩形卡片中显示消耗和休息。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錶面上顯示你的能量(恢復),矩形卡片中則是消耗與休息。" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/NOOPWatchComplications/NOOPWatchComplication.swift b/NOOPWatchComplications/NOOPWatchComplication.swift new file mode 100644 index 0000000000..1576935225 --- /dev/null +++ b/NOOPWatchComplications/NOOPWatchComplication.swift @@ -0,0 +1,467 @@ +import WidgetKit +import SwiftUI +import StrandDesign + +// MARK: - NOOP watch-face complication +// +// The headline feature of M3: Charge (recovery) on the wrist. The iPhone is the brain +// (M1 computes Charge / Effort / Rest with confidence + provenance); this complication ONLY +// displays the latest `WatchScoreSnapshot` the phone pushed into the shared app group. It never +// recomputes a score. +// +// The honesty rule carries through from M1: a CALIBRATING score has a nil number plus its +// Calibrating flag set, and we render a dash with a subtle "cal" marker, never a fabricated +// number. When there is no snapshot at all we show a NEUTRAL placeholder (a dash + the NOOP +// glyph), not a zero, so an empty face never reads as "your Charge is 0". +// +// Families: accessoryCircular (ring + number), accessoryCorner, accessoryInline (text), and +// accessoryRectangular (a compact card with all three scores). + +// MARK: - Snapshot access +// +// We read the app group directly here rather than depending on a loader symbol from the bridge +// lane, so this extension only needs the shared `WatchScoreSnapshot` type from StrandDesign. The +// suite + key match the cross-lane contract: the phone-side bridge writes the latest snapshot to +// `group.bbdw.noop` under `latestWatchSnapshot`, and the watch app + this complication read +// it. The suite name is read from the extension's own Info.plist (AppGroupIdentifier) so it lives +// in one place, with the canonical group as a fallback. + +enum WatchSnapshotAccess { + // The app group is read from the extension's own Info.plist (AppGroupIdentifier) so the entitled + // value wins, then falls back to the canonical group the contract pins. The storage KEY is the + // shared one from StrandDesign so the writer and every reader can never desync on it. + static let suiteName: String = { + Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String + ?? WatchScoreSnapshot.appGroupId + }() + + static let storageKey = WatchScoreSnapshot.storageKey + + /// The last snapshot the phone pushed, or nil if nothing has synced yet. + static func load() -> WatchScoreSnapshot? { + guard let defaults = UserDefaults(suiteName: suiteName), + let data = defaults.data(forKey: storageKey), + let snap = try? JSONDecoder().decode(WatchScoreSnapshot.self, from: data) else { return nil } + return snap + } +} + +// MARK: - Timeline + +/// One timeline entry, backed by the latest snapshot (or nil when nothing has synced). +struct ChargeEntry: TimelineEntry { + let date: Date + let snapshot: WatchScoreSnapshot? +} + +struct ChargeProvider: TimelineProvider { + /// A friendly stand-in for the gallery / first paint. Shows a real-looking Charge so the + /// complication previews well, but it is never persisted and the live view falls back to the + /// neutral placeholder when there is genuinely no snapshot. + func placeholder(in context: Context) -> ChargeEntry { + ChargeEntry(date: Date(), snapshot: .preview) + } + + func getSnapshot(in context: Context, completion: @escaping (ChargeEntry) -> Void) { + // In the gallery (isPreview) show the friendly preview; on a real face show what synced. + let snap = context.isPreview ? WatchScoreSnapshot.preview : WatchSnapshotAccess.load() + completion(ChargeEntry(date: Date(), snapshot: snap)) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let snap = WatchSnapshotAccess.load() + // The phone forces a reload (WidgetCenter.reloadAllTimelines) whenever it pushes a fresh + // snapshot, so this periodic refresh is just a backstop. Roughly every 30 minutes keeps the + // "as of …" age honest without burning the watch's complication budget. + let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) + ?? Date().addingTimeInterval(1800) + completion(Timeline(entries: [ChargeEntry(date: Date(), snapshot: snap)], policy: .after(next))) + } +} + +// MARK: - Preview snapshot + +private extension WatchScoreSnapshot { + /// A representative snapshot for the widget gallery: a primed Charge, a mid Effort, a calibrating + /// Rest (so the gallery also shows the cal marker), a live HR and a short sleep line. + static var preview: WatchScoreSnapshot { + WatchScoreSnapshot( + charge: 74, chargeCalibrating: false, + effort: 41, effortCalibrating: false, + rest: nil, restCalibrating: true, + hr: 58, + sleepSummary: "7h 12m", + asOf: Date() + ) + } +} + +// MARK: - Score read-out helpers +// +// One place decides how a (value, calibrating) pair renders, so the four family views can never +// disagree and the honesty rule is enforced once. + +/// How a single score should be drawn: a real number, a calibrating dash, or simply absent. +private enum ScoreReadout { + case value(Int) + case calibrating + case missing + + /// Map the snapshot's (optional number + Calibrating flag) into a readout. A calibrating score + /// (number nil + flag true) is `.calibrating`; a present number is `.value`; everything else is + /// `.missing`. We never invent a number for a calibrating score. + init(value: Double?, calibrating: Bool) { + if let v = value { + self = .value(Int(v.rounded())) + } else if calibrating { + self = .calibrating + } else { + self = .missing + } + } + + /// The fraction (0...1) to fill a ring/gauge with. Calibrating + missing read as an empty track. + var fraction: Double { + if case let .value(v) = self { return min(max(Double(v) / 100.0, 0), 1) } + return 0 + } + + /// The big number, or a dash for calibrating / missing. + var numberText: String { + if case let .value(v) = self { return "\(v)" } + return "–" + } +} + +// MARK: - The complication view + +struct NOOPChargeView: View { + @Environment(\.widgetFamily) private var family + let entry: ChargeEntry + + // MARK: One shared staleness decision + // + // Every family routes through `isStale` so they stay consistent: a days-old snapshot must never + // read as live in ANY family. When stale we collapse each score to the SAME calibrating dash the + // missing/calibrating path already draws, rather than painting an old number. Mirrors how + // ScoreReadout centralises the calibrating/missing call so the four views can never disagree. + + /// True when we have a snapshot but it is too old to present as current. nil snapshot is handled + /// separately as the neutral placeholder, so this is purely about an aged-out real snapshot. + private var isStale: Bool { + guard let snap = entry.snapshot else { return false } + return snap.isStale(now: entry.date) + } + + /// Map one score, but force the calibrating dash when the whole snapshot is stale. Centralising it + /// here means circular / corner / inline / rectangular all degrade identically. + private func readout(_ value: Double?, _ calibrating: Bool) -> ScoreReadout { + if isStale { return .calibrating } + return ScoreReadout(value: value, calibrating: calibrating) + } + + private var charge: ScoreReadout { + readout(entry.snapshot?.charge, entry.snapshot?.chargeCalibrating ?? false) + } + private var effort: ScoreReadout { + readout(entry.snapshot?.effort, entry.snapshot?.effortCalibrating ?? false) + } + private var rest: ScoreReadout { + readout(entry.snapshot?.rest, entry.snapshot?.restCalibrating ?? false) + } + + /// True when nothing has ever synced from the phone. Drives the neutral placeholder. + private var noSnapshot: Bool { entry.snapshot == nil } + + /// The honest recency label for the families that have room for one, straight from the contract. + private var freshness: String? { + guard let snap = entry.snapshot else { return nil } + return snap.freshnessText(now: entry.date) + } + + /// True when the snapshot's scores read as current ("Today" / "just now"). The families below skip + /// the recency label in that case because it adds no information next to a live-looking number. + /// Decided by the SEMANTIC flag on the shared contract, never by comparing the localized display + /// text `freshness` returns; a display-text comparison would silently stop matching in every + /// language the string catalogs translate. + private var isFreshToday: Bool { + entry.snapshot?.isFreshToday(now: entry.date) ?? false + } + + var body: some View { + switch family { + case .accessoryCircular: circular + case .accessoryCorner: corner + case .accessoryInline: Text(inlineText) + case .accessoryRectangular: rectangular + default: circular + } + } + + // MARK: Charge tint + // + // Tinted to the Charge colour world only when we have a real number. A calibrating or missing + // Charge stays neutral so the empty ring never borrows a "good"/"bad" colour it did not earn. + + private var chargeTint: Color { + if case let .value(v) = charge { return StrandPalette.recoveryColor(Double(v)) } + return StrandPalette.textTertiary + } + + // MARK: accessoryCircular — a ring + the Charge number + // + // The clean NOOP ring, scaled to the watch face. WidgetKit tints accessory complications with the + // face's vibrant colour by default; we use a Gauge so the system renders a crisp circular ring, + // and tint it to the Charge colour where we have a real value. A small "cal" marker replaces the + // number when Charge is calibrating. + + private var circular: some View { + Gauge(value: charge.fraction, in: 0...1) { + // The minimumValueLabel slot stays empty; the centre carries the read-out. + EmptyView() + } currentValueLabel: { + VStack(spacing: 0) { + Text(charge.numberText) + .font(StrandFont.rounded(15, weight: .semibold)) + .minimumScaleFactor(0.6) + if case .calibrating = charge { + calPip + } + } + } + .gaugeStyle(.accessoryCircular) + .tint(chargeTint) + // The curved label carries the recency so even the tiny circle is honest: "Charge · 2h ago" + // when aging, plain "Charge" when fresh, a sync hint when nothing has synced. + .widgetLabel(circularLabel) + .widgetAccentable() + .accessibilityLabel(accessibilityCharge) + } + + /// The circular family's curved widgetLabel. Appends the freshness once a snapshot starts aging so + /// the number above it is never read as live; stays "Charge" while it is fresh. + private var circularLabel: String { + guard let fresh = freshness else { return String(localized: "Charge") } + if isStale { return String(localized: "Charge · \(fresh)") } + // A current snapshot's label adds no information next to a live-looking ring, so keep it clean. + if isFreshToday { return String(localized: "Charge") } + return String(localized: "Charge · \(fresh)") + } + + // MARK: accessoryCorner — number hugging the corner, "Charge" curved along the bezel + + private var corner: some View { + Text(charge.numberText) + .font(StrandFont.rounded(17, weight: .semibold)) + .foregroundStyle(chargeTint) + .widgetAccentable() + // The curved label rides the watch-face bezel. When calibrating we say so plainly rather + // than leaving a bare dash with no context. + .widgetLabel { + Text(cornerLabel) + } + .accessibilityLabel(accessibilityCharge) + } + + private var cornerLabel: String { + switch charge { + case .value: + // Real number: ride the bezel with the recency so an aging score stays honest. A current + // snapshot keeps the plain label (semantic flag, not a display-text comparison). + guard let fresh = freshness, !isFreshToday else { return String(localized: "Charge") } + return String(localized: "Charge · \(fresh)") + case .calibrating: + // When the dash is here because the whole snapshot went stale, say so plainly rather than + // "cal" (which means "needs more data", a different thing). + if isStale { + let fresh = freshness ?? String(localized: "stale") + return String(localized: "Charge · \(fresh)") + } + return String(localized: "Charge · cal") + case .missing: + return noSnapshot ? String(localized: "Open NOOP") : String(localized: "Charge") + } + } + + // MARK: accessoryInline — a single line of text along the top of the face + + private var inlineText: String { + if noSnapshot { return String(localized: "NOOP · open on iPhone") } + // When the snapshot has aged out we never print the old number; we say it is stale and how old. + if isStale { + let fresh = freshness ?? String(localized: "old") + return String(localized: "Charge stale · \(fresh)") + } + switch charge { + case .value(let v): + // A fresh number reads as live, so append the recency once it starts to age. + let suffix = inlineFreshnessSuffix + if let hr = entry.snapshot?.hr { return String(localized: "Charge \(v) · \(hr) bpm\(suffix)") } + return String(localized: "Charge \(v)\(suffix)") + case .calibrating: + return String(localized: "Charge calibrating") + case .missing: + return String(localized: "Charge –") + } + } + + /// " · 2h ago" appended to the inline line once a snapshot ages, empty while it is fresh so a live + /// reading stays uncluttered. Keyed off the semantic flag, never the localized display text. + private var inlineFreshnessSuffix: String { + guard let fresh = freshness, !isFreshToday else { return "" } + return " · \(fresh)" + } + + // MARK: accessoryRectangular — a compact card showing all three scores + // + // The richest family: a small NOOP header line plus the Charge / Effort / Rest triplet, each a + // number (or a dash + cal marker) over its label. This is the only place all three scores live, so + // it doubles as the "everything at a glance" face. + + private var rectangular: some View { + VStack(alignment: .leading, spacing: 3) { + // Header: the wordmark + the snapshot age (or a sync hint when empty). + HStack(spacing: 4) { + Text("NOOP") + .font(StrandFont.rounded(11, weight: .bold)) + .tracking(0.5) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + Text(headerTrailing) + .font(.system(size: 10)) + .foregroundStyle(StrandPalette.textTertiary) + } + // The three scores, equal-width. + HStack(alignment: .top, spacing: 0) { + scoreCell(String(localized: "Charge"), readout: charge, tint: chargeTint) + scoreCell(String(localized: "Effort"), readout: effort, tint: effortTint) + scoreCell(String(localized: "Rest"), readout: rest, tint: restTint) + } + } + .widgetAccentable() + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityRectangular) + } + + /// The trailing header text: a sync hint when empty, otherwise the honest recency label so a stale + /// snapshot reads as "Yesterday" / "2h ago" rather than implying it is live. The three cells below + /// already collapse to the calibrating dash when stale, so the header and the numbers agree. + private var headerTrailing: String { + guard let snap = entry.snapshot else { return String(localized: "open iPhone") } + return snap.freshnessText(now: entry.date) + } + + /// One labelled score in the rectangular card. A real value tints to its colour world; a + /// calibrating score shows a dash plus a tiny "cal" marker; missing shows a neutral dash. + private func scoreCell(_ label: String, readout: ScoreReadout, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(readout.numberText) + .font(StrandFont.rounded(18, weight: .semibold)) + .foregroundStyle(readoutIsValue(readout) ? tint : StrandPalette.textTertiary) + .minimumScaleFactor(0.7) + if case .calibrating = readout { + Text("cal") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + .padding(.horizontal, 2) + .background( + Capsule().fill(StrandPalette.surfaceInset) + ) + } + } + Text(label) + .font(.system(size: 9)) + .foregroundStyle(StrandPalette.textTertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func readoutIsValue(_ r: ScoreReadout) -> Bool { + if case .value = r { return true } + return false + } + + // MARK: Effort / Rest tints (rectangular only) + + private var effortTint: Color { + if case let .value(v) = effort { return StrandPalette.effortTint(fraction: Double(v) / 100) } + return StrandPalette.textTertiary + } + private var restTint: Color { + if case let .value(v) = rest { return StrandPalette.recoveryColor(Double(v)) } + return StrandPalette.textTertiary + } + + // MARK: The "cal" marker + // + // A subtle, lowercase "cal" pill. Small and tertiary so it reads as a status footnote, not an + // alarm. This is what the honesty rule looks like on a tiny face: a dash plus this, never a number. + + private var calPip: some View { + Text("cal") + .font(.system(size: 7, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + + // MARK: Accessibility + + private var accessibilityCharge: String { + // A stale snapshot collapses to the calibrating dash visually, but for VoiceOver we say WHY it + // is a dash plainly so it is never mistaken for "still calibrating". + if isStale { + let fresh = freshness ?? String(localized: "a while ago") + return String(localized: "Charge out of date, last synced \(fresh). Open NOOP on iPhone.") + } + switch charge { + case .value(let v): return String(localized: "Charge \(v) out of 100") + case .calibrating: return String(localized: "Charge calibrating, needs more data") + case .missing: return noSnapshot ? String(localized: "No data, open NOOP on iPhone") + : String(localized: "Charge unavailable") + } + } + + private var accessibilityRectangular: String { + if noSnapshot { return String(localized: "NOOP. No data yet, open NOOP on your iPhone to sync.") } + if isStale { + let fresh = freshness ?? String(localized: "a while ago") + return String(localized: "NOOP. Scores out of date, last synced \(fresh). Open NOOP on iPhone to refresh.") + } + func phrase(_ label: String, _ r: ScoreReadout) -> String { + switch r { + case .value(let v): return String(localized: "\(label) \(v)") + case .calibrating: return String(localized: "\(label) calibrating") + case .missing: return String(localized: "\(label) unavailable") + } + } + let chargePhrase = phrase(String(localized: "Charge"), charge) + let effortPhrase = phrase(String(localized: "Effort"), effort) + let restPhrase = phrase(String(localized: "Rest"), rest) + return String(localized: "NOOP. \(chargePhrase), \(effortPhrase), \(restPhrase).") + } + + // Snapshot recency now comes straight from the shared contract (`freshnessText` / `isStale` on + // WatchScoreSnapshot) so the watch app glance and this complication phrase age identically. The + // old local ageString helper was retired with that move. +} + +// MARK: - Widget declaration + +struct NOOPChargeComplication: Widget { + let kind = "NOOPChargeComplication" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ChargeProvider()) { entry in + NOOPChargeView(entry: entry) + .containerBackground(StrandPalette.surfaceBase, for: .widget) + } + .configurationDisplayName("NOOP Charge") + .description("Your Charge (recovery) on the watch face, with Effort and Rest in the rectangular card.") + .supportedFamilies([ + .accessoryCircular, + .accessoryCorner, + .accessoryInline, + .accessoryRectangular + ]) + } +} diff --git a/NOOPWatchComplications/NOOPWatchComplicationBundle.swift b/NOOPWatchComplications/NOOPWatchComplicationBundle.swift new file mode 100644 index 0000000000..610e2afa40 --- /dev/null +++ b/NOOPWatchComplications/NOOPWatchComplicationBundle.swift @@ -0,0 +1,12 @@ +import WidgetKit +import SwiftUI + +/// The watchOS complication extension entry point. Bundles the Charge complication so the watch face +/// can place it in any of the supported accessory families. The watch app (the glance UI) lives in a +/// separate target; this extension only draws the face complication. +@main +struct NOOPWatchComplicationBundle: WidgetBundle { + var body: some Widget { + NOOPChargeComplication() + } +} diff --git a/NOOPWatchComplications/NOOPWatchComplications.entitlements b/NOOPWatchComplications/NOOPWatchComplications.entitlements new file mode 100644 index 0000000000..67975e15b8 --- /dev/null +++ b/NOOPWatchComplications/NOOPWatchComplications.entitlements @@ -0,0 +1,12 @@ + + + + + + com.apple.security.application-groups + + $(APP_GROUP_ID) + + + diff --git a/NOOPWatchComplications/NOOPWatchComplicationsRelease.entitlements b/NOOPWatchComplications/NOOPWatchComplicationsRelease.entitlements new file mode 100644 index 0000000000..1f52297169 --- /dev/null +++ b/NOOPWatchComplications/NOOPWatchComplicationsRelease.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(APP_GROUP_ID) + + + diff --git a/NOOPiOSRelease.entitlements b/NOOPiOSRelease.entitlements new file mode 100644 index 0000000000..674f121fc1 --- /dev/null +++ b/NOOPiOSRelease.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + com.apple.security.application-groups + + $(APP_GROUP_ID) + + + diff --git a/NOOPiOSWidgetsRelease.entitlements b/NOOPiOSWidgetsRelease.entitlements new file mode 100644 index 0000000000..1f52297169 --- /dev/null +++ b/NOOPiOSWidgetsRelease.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(APP_GROUP_ID) + + + diff --git a/Packages/NoopLocalAccess/Package.resolved b/Packages/NoopLocalAccess/Package.resolved new file mode 100644 index 0000000000..fd559f8d7e --- /dev/null +++ b/Packages/NoopLocalAccess/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version" : "6.29.3" + } + } + ], + "version" : 2 +} diff --git a/Packages/NoopLocalAccess/Package.swift b/Packages/NoopLocalAccess/Package.swift new file mode 100644 index 0000000000..1684c1d02c --- /dev/null +++ b/Packages/NoopLocalAccess/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "NoopLocalAccess", + platforms: [.macOS(.v13)], + products: [ + .library(name: "NoopLocalAccessCore", targets: ["NoopLocalAccessCore"]), + .executable(name: "noop-local-access", targets: ["noop-local-access"]), + ], + dependencies: [ + // Supply-chain: pinned EXACT (not `from:`) so a clean resolve can't auto-pull a newer — + // potentially compromised — upstream release. Must match the same exact version in the + // other Packages/*/Package.swift and project.yml, or SPM resolution fails. Bump deliberately. + .package(url: "https://github.com/groue/GRDB.swift.git", exact: "6.29.3"), + ], + targets: [ + .target( + name: "NoopLocalAccessCore", + dependencies: [ + .product(name: "GRDB", package: "GRDB.swift"), + ] + ), + .executableTarget( + name: "noop-local-access", + dependencies: ["NoopLocalAccessCore"] + ), + .testTarget( + name: "NoopLocalAccessCoreTests", + dependencies: [ + "NoopLocalAccessCore", + .product(name: "GRDB", package: "GRDB.swift"), + ] + ), + ] +) diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/JSONValue.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/JSONValue.swift new file mode 100644 index 0000000000..70f5e9ebb3 --- /dev/null +++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/JSONValue.swift @@ -0,0 +1,93 @@ +import Foundation + +public enum JSONValue: Codable, Equatable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let values): + try container.encode(values) + case .object(let value): + try container.encode(value) + } + } + + public var objectValue: [String: JSONValue]? { + guard case .object(let value) = self else { return nil } + return value + } + + public var stringValue: String? { + guard case .string(let value) = self else { return nil } + return value + } + + public var intValue: Int? { + switch self { + case .int(let value): + return value + case .double(let value): + return Int(value) + case .string(let value): + return Int(value) + default: + return nil + } + } + + public var boolValue: Bool? { + guard case .bool(let value) = self else { return nil } + return value + } +} + +public func prettyJSON(_ value: JSONValue) -> String { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return String(decoding: try encoder.encode(value), as: UTF8.self) + } catch { + return "{\"error\":\"failed to encode JSON\"}" + } +} diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift new file mode 100644 index 0000000000..1f26a20c1a --- /dev/null +++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift @@ -0,0 +1,751 @@ +import Foundation +import GRDB + +public enum LocalAccessError: Error, CustomStringConvertible, Equatable { + case invalidParams(String) + case methodNotFound(String) + case toolNotFound(String) + case resourceNotFound(String) + case promptNotFound(String) + case databaseUnavailable(String) + + public var description: String { + switch self { + case .invalidParams(let message), + .databaseUnavailable(let message): + return message + case .methodNotFound(let method): + return "Unsupported MCP method: \(method)" + case .toolNotFound(let tool): + return "Unknown NOOP tool: \(tool)" + case .resourceNotFound(let uri): + return "Unknown NOOP resource: \(uri)" + case .promptNotFound(let name): + return "Unknown NOOP prompt: \(name)" + } + } + + public var rpcCode: Int { + switch self { + case .methodNotFound: + return -32601 + case .invalidParams, .toolNotFound, .resourceNotFound, .promptNotFound: + return -32602 + case .databaseUnavailable: + return -32603 + } + } +} + +public struct LocalAccessConfiguration: Equatable, Sendable { + public var databasePath: String? + public var bundleID: String? + public var deviceID: String + + public init(databasePath: String? = nil, bundleID: String? = nil, deviceID: String = "my-whoop") { + self.databasePath = databasePath + self.bundleID = bundleID + self.deviceID = deviceID + } + + public static func environment(_ env: [String: String] = ProcessInfo.processInfo.environment) -> LocalAccessConfiguration { + LocalAccessConfiguration( + databasePath: nonEmpty(env["NOOP_DB_PATH"]), + bundleID: nonEmpty(env["NOOP_BUNDLE_ID"]), + deviceID: nonEmpty(env["NOOP_DEVICE_ID"]) ?? "my-whoop" + ) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} + +public enum DatabasePathResolver { + public static let productionBundleID = "com.bbdw.noop" + + public static func resolve(configuration: LocalAccessConfiguration) throws -> String { + let fm = FileManager.default + if let explicit = configuration.databasePath { + let expanded = expandHome(explicit) + guard fm.fileExists(atPath: expanded) else { + throw LocalAccessError.databaseUnavailable("NOOP database not found at NOOP_DB_PATH.") + } + return expanded + } + + for candidate in candidates(bundleID: configuration.bundleID) where fm.fileExists(atPath: candidate) { + return candidate + } + + throw LocalAccessError.databaseUnavailable( + "No official NOOP database was found. Start NOOP once, or set NOOP_DB_PATH explicitly." + ) + } + + public static func candidates(bundleID: String? = nil, home: String = FileManager.default.homeDirectoryForCurrentUser.path) -> [String] { + var ids = [productionBundleID] + if let bundleID, bundleID != productionBundleID { + ids.insert(bundleID, at: 0) + } + + var paths: [String] = ids.map { + "\(home)/Library/Containers/\($0)/Data/Library/Application Support/OpenWhoop/whoop.sqlite" + } + paths.append("\(home)/Library/Application Support/OpenWhoop/whoop.sqlite") + return orderedUnique(paths) + } + + public static func expandHome(_ path: String, home: String = FileManager.default.homeDirectoryForCurrentUser.path) -> String { + guard path == "~" || path.hasPrefix("~/") else { return path } + return home + String(path.dropFirst()) + } +} + +public struct DailyMetricRow: Equatable, Sendable { + public let day: String + public let totalSleepMin: Double? + public let efficiency: Double? + public let deepMin: Double? + public let remMin: Double? + public let lightMin: Double? + public let disturbances: Int? + public let restingHr: Int? + public let avgHrv: Double? + public let recovery: Double? + public let strain: Double? + public let exerciseCount: Int? + public let spo2Pct: Double? + public let skinTempDevC: Double? + public let respRateBpm: Double? + public let steps: Int? + public let activeKcalEst: Double? +} + +public struct SleepSessionRow: Equatable, Sendable { + public let startTs: Int + public let endTs: Int + public let efficiency: Double? + public let restingHr: Int? + public let avgHrv: Double? + public let stagesJSON: String? + // NOTE (#318): this local-access read intentionally does NOT surface the user's `startTsAdjusted` + // onset correction — its SELECT must stay readable against pre-v14 / foreign sleepSession tables + // (see the `tableNames` guard), so adding the column would regress those. The MCP read therefore + // reports the DETECTED onset for a hand-edited night; the app's own screens use the corrected one. +} + +public struct MetricPointRow: Equatable, Sendable { + public let day: String + public let key: String + public let value: Double +} + +public struct AppleDailyRow: Equatable, Sendable { + public let day: String + public let steps: Int? + public let activeKcal: Double? + public let basalKcal: Double? + public let vo2max: Double? + public let avgHr: Int? + public let maxHr: Int? + public let walkingHr: Int? + public let weightKg: Double? +} + +public struct WorkoutRow: Equatable, Sendable { + public let startTs: Int + public let endTs: Int + public let sport: String + public let source: String + public let durationS: Double? + public let energyKcal: Double? + public let avgHr: Int? + public let maxHr: Int? + public let strain: Double? + public let distanceM: Double? + public let zonesJSON: String? + public let notes: String? +} + +public struct StorageStats: Equatable, Sendable { + public let decodedRows: Int + public let rawBatches: Int + public let rawBytes: Int +} + +public final class ReadonlyNoopStore { + private let dbQueue: DatabaseQueue + private let tableNames: Set + + public init(path: String) throws { + var config = Configuration() + config.readonly = true + config.busyMode = .timeout(5) + dbQueue = try DatabaseQueue(path: path, configuration: config) + tableNames = try dbQueue.read { db in + try Set(String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type = 'table'")) + } + try validateSchema() + } + + public func dailyMetrics(deviceId: String, from: String, to: String) throws -> [DailyMetricRow] { + guard tableNames.contains("dailyMetric") else { return [] } + return try dbQueue.read { db in + try Row.fetchAll(db, sql: """ + SELECT day, totalSleepMin, efficiency, deepMin, remMin, lightMin, disturbances, + restingHr, avgHrv, recovery, strain, exerciseCount, + spo2Pct, skinTempDevC, respRateBpm, steps, activeKcalEst + FROM dailyMetric + WHERE deviceId = ? AND day >= ? AND day <= ? + ORDER BY day ASC + """, arguments: [deviceId, from, to]) + .map { + DailyMetricRow(day: $0["day"], totalSleepMin: $0["totalSleepMin"], + efficiency: $0["efficiency"], deepMin: $0["deepMin"], + remMin: $0["remMin"], lightMin: $0["lightMin"], + disturbances: $0["disturbances"], restingHr: $0["restingHr"], + avgHrv: $0["avgHrv"], recovery: $0["recovery"], + strain: $0["strain"], exerciseCount: $0["exerciseCount"], + spo2Pct: $0["spo2Pct"], skinTempDevC: $0["skinTempDevC"], + respRateBpm: $0["respRateBpm"], steps: $0["steps"], + activeKcalEst: $0["activeKcalEst"]) + } + } + } + + public func sleepSessions(deviceId: String, from: Int, to: Int, limit: Int) throws -> [SleepSessionRow] { + guard tableNames.contains("sleepSession") else { return [] } + return try dbQueue.read { db in + try Row.fetchAll(db, sql: """ + SELECT startTs, endTs, efficiency, restingHr, avgHrv, stagesJSON + FROM sleepSession + WHERE deviceId = ? AND startTs >= ? AND startTs <= ? + ORDER BY startTs ASC LIMIT ? + """, arguments: [deviceId, from, to, limit]) + .map { + SleepSessionRow(startTs: $0["startTs"], endTs: $0["endTs"], + efficiency: $0["efficiency"], restingHr: $0["restingHr"], + avgHrv: $0["avgHrv"], stagesJSON: $0["stagesJSON"]) + } + } + } + + public func metricSeries(deviceId: String, key: String, from: String, to: String) throws -> [MetricPointRow] { + guard tableNames.contains("metricSeries") else { return [] } + return try dbQueue.read { db in + try Row.fetchAll(db, sql: """ + SELECT day, key, value FROM metricSeries + WHERE deviceId = ? AND key = ? AND day >= ? AND day <= ? + ORDER BY day ASC + """, arguments: [deviceId, key, from, to]) + .map { MetricPointRow(day: $0["day"], key: $0["key"], value: $0["value"]) } + } + } + + public func metricKeys(deviceId: String) throws -> [String] { + guard tableNames.contains("metricSeries") else { return [] } + return try dbQueue.read { db in + try String.fetchAll(db, sql: """ + SELECT DISTINCT key FROM metricSeries + WHERE deviceId = ? + ORDER BY key ASC + """, arguments: [deviceId]) + } + } + + public func appleDaily(deviceId: String, from: String, to: String) throws -> [AppleDailyRow] { + guard tableNames.contains("appleDaily") else { return [] } + return try dbQueue.read { db in + try Row.fetchAll(db, sql: """ + SELECT day, steps, activeKcal, basalKcal, vo2max, avgHr, maxHr, walkingHr, weightKg + FROM appleDaily + WHERE deviceId = ? AND day >= ? AND day <= ? + ORDER BY day ASC + """, arguments: [deviceId, from, to]) + .map { + AppleDailyRow(day: $0["day"], steps: $0["steps"], activeKcal: $0["activeKcal"], + basalKcal: $0["basalKcal"], vo2max: $0["vo2max"], + avgHr: $0["avgHr"], maxHr: $0["maxHr"], + walkingHr: $0["walkingHr"], weightKg: $0["weightKg"]) + } + } + } + + public func workouts(deviceId: String, from: Int, to: Int, limit: Int) throws -> [WorkoutRow] { + guard tableNames.contains("workout") else { return [] } + return try dbQueue.read { db in + try Row.fetchAll(db, sql: """ + SELECT startTs, endTs, sport, source, durationS, energyKcal, avgHr, maxHr, + strain, distanceM, zonesJSON, notes + FROM workout + WHERE deviceId = ? AND startTs >= ? AND startTs <= ? + ORDER BY startTs ASC LIMIT ? + """, arguments: [deviceId, from, to, limit]) + .map { + WorkoutRow(startTs: $0["startTs"], endTs: $0["endTs"], sport: $0["sport"], + source: $0["source"], durationS: $0["durationS"], + energyKcal: $0["energyKcal"], avgHr: $0["avgHr"], + maxHr: $0["maxHr"], strain: $0["strain"], + distanceM: $0["distanceM"], zonesJSON: $0["zonesJSON"], + notes: $0["notes"]) + } + } + } + + public func latestHRSampleTs(deviceId: String) throws -> Int? { + let hasHr = tableNames.contains("hrSample") + let hasPpg = tableNames.contains("ppgHrSample") + guard hasHr || hasPpg else { return nil } + + return try dbQueue.read { db in + switch (hasHr, hasPpg) { + case (true, true): + return try Int.fetchOne(db, sql: """ + SELECT MAX(ts) FROM ( + SELECT ts FROM hrSample WHERE deviceId = ? + UNION ALL + SELECT ts FROM ppgHrSample WHERE deviceId = ? + ) + """, arguments: [deviceId, deviceId]) + case (true, false): + return try Int.fetchOne(db, sql: "SELECT MAX(ts) FROM hrSample WHERE deviceId = ?", arguments: [deviceId]) + case (false, true): + return try Int.fetchOne(db, sql: "SELECT MAX(ts) FROM ppgHrSample WHERE deviceId = ?", arguments: [deviceId]) + case (false, false): + return nil + } + } + } + + public func storageStats() throws -> StorageStats { + try dbQueue.read { db in + let decodedTables = [ + "hrSample", "rrInterval", "event", "battery", "spo2Sample", + "skinTempSample", "respSample", "gravitySample", "ppgHrSample", "stepSample", + ] + var decodedRows = 0 + for table in decodedTables where tableNames.contains(table) { + decodedRows += try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table)") ?? 0 + } + let rawBatches = tableNames.contains("rawBatch") + ? (try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM rawBatch") ?? 0) + : 0 + let rawBytes = tableNames.contains("rawBatch") + ? (try Int.fetchOne(db, sql: "SELECT COALESCE(SUM(byteSize), 0) FROM rawBatch") ?? 0) + : 0 + return StorageStats(decodedRows: decodedRows, rawBatches: rawBatches, rawBytes: rawBytes) + } + } + + internal func writeProbeForTest() throws { + try dbQueue.write { db in + try db.execute(sql: "CREATE TABLE __noop_local_access_write_probe(id INTEGER)") + } + } + + internal func isReadOnlyForTest() throws -> Bool { + try dbQueue.read { db in db.configuration.readonly } + } + + private func validateSchema() throws { + if !tableNames.contains("grdb_migrations"), + tableNames.contains("device") || tableNames.contains("hrSample") { + throw LocalAccessError.databaseUnavailable( + "This looks like a NOOP-like SQLite file without GRDB migration metadata. Open NOOP to repair it before using local access." + ) + } + } +} + +public final class NoopDataAccess { + private let store: ReadonlyNoopStore + private let deviceId: String + private var computedDeviceId: String { deviceId + "-noop" } + + public init(store: ReadonlyNoopStore, deviceId: String = "my-whoop") { + self.store = store + self.deviceId = deviceId + } + + public static func open(configuration: LocalAccessConfiguration = .environment()) throws -> NoopDataAccess { + let path = try DatabasePathResolver.resolve(configuration: configuration) + return try NoopDataAccess(store: ReadonlyNoopStore(path: path), deviceId: configuration.deviceID) + } + + public func healthSnapshot(days: Int) throws -> JSONValue { + let (fromDay, toDay) = dayRange(days: days) + let daily = try mergedDaily(from: fromDay, to: toDay) + let apple = try store.appleDaily(deviceId: "apple-health", from: fromDay, to: toDay) + let latestHR = try store.latestHRSampleTs(deviceId: deviceId) + + let logical = logicalDayKey(Date()) + let displayed = daily.last(where: { $0.row.day == logical }) ?? daily.last + + return .object([ + "generatedAt": .string(iso(Date())), + "logicalToday": .string(logical), + "sources": Self.sources(), + "freshness": freshnessPayload(latestHR: latestHR, apple: apple, daily: daily), + "today": displayed.map { dailyJSON($0.row, source: $0.source) } ?? .null, + "recentDays": .array(daily.suffix(days).map { dailyJSON($0.row, source: $0.source) }), + "appleDaily": .array(apple.map(appleDailyJSON)), + ]) + } + + public func metricSeries( + key: String, + source: String, + days: Int, + fromDay explicitFrom: String?, + toDay explicitTo: String?, + limit: Int + ) throws -> JSONValue { + let defaultRange = dayRange(days: days) + let fromDay = explicitFrom ?? defaultRange.from + let toDay = explicitTo ?? defaultRange.to + let candidates = Self.sourceCandidates(forKey: key, preferredSource: source, actualWhoopSource: deviceId) + var mergedByDay: [String: JSONValue] = [:] + var usedSources: [String] = [] + + for candidate in candidates { + let rows = try store.metricSeries(deviceId: candidate.source, key: candidate.key, from: fromDay, to: toDay) + if !rows.isEmpty { usedSources.append(candidate.source) } + for row in rows where mergedByDay[row.day] == nil { + mergedByDay[row.day] = .object([ + "day": .string(row.day), + "key": .string(row.key), + "value": .double(row.value), + "source": .string(candidate.source), + "sourceKey": .string(candidate.key), + ]) + } + } + + let points = mergedByDay.keys.sorted().compactMap { mergedByDay[$0] } + let boundedPoints = Array(points.suffix(limit)) + return .object([ + "key": .string(key), + "requestedSource": .string(source), + "range": .object(["from": .string(fromDay), "to": .string(toDay)]), + "resolution": .object([ + "candidates": .array(candidates.map { .object(["source": .string($0.source), "key": .string($0.key)]) }), + "usedSources": .array(orderedUnique(usedSources).map { .string($0) }), + ]), + "returned": .int(boundedPoints.count), + "points": .array(boundedPoints), + ]) + } + + public func freshness() throws -> JSONValue { + let latestHR = try store.latestHRSampleTs(deviceId: deviceId) + let stats = try store.storageStats() + let now = Date() + let (fromDay, toDay) = dayRange(days: 4000) + let importedDaily = try store.dailyMetrics(deviceId: deviceId, from: fromDay, to: toDay) + let computedDaily = try store.dailyMetrics(deviceId: computedDeviceId, from: fromDay, to: toDay) + let appleDaily = try store.appleDaily(deviceId: "apple-health", from: fromDay, to: toDay) + let importedKeys = try store.metricKeys(deviceId: deviceId) + let computedKeys = try store.metricKeys(deviceId: computedDeviceId) + let appleKeys = try store.metricKeys(deviceId: "apple-health") + + return .object([ + "generatedAt": .string(iso(now)), + "deviceId": .string(deviceId), + "computedDeviceId": .string(computedDeviceId), + "latestHeartRateSample": timestampJSON(latestHR, now: now), + "storage": .object([ + "decodedRows": .int(stats.decodedRows), + "rawBatches": .int(stats.rawBatches), + "rawBytes": .int(stats.rawBytes), + ]), + "coverage": .object([ + "dailyImported": coverageJSON(importedDaily.map(\.day)), + "dailyComputed": coverageJSON(computedDaily.map(\.day)), + "appleDaily": coverageJSON(appleDaily.map(\.day)), + ]), + "metricKeys": .object([ + deviceId: .array(importedKeys.map { .string($0) }), + computedDeviceId: .array(computedKeys.map { .string($0) }), + "apple-health": .array(appleKeys.map { .string($0) }), + ]), + ]) + } + + public func sleepSummary(days: Int) throws -> JSONValue { + let (fromTs, toTs) = timestampRange(days: days) + let imported = try store.sleepSessions(deviceId: deviceId, from: fromTs, to: toTs, limit: 5000) + let computed = try store.sleepSessions(deviceId: computedDeviceId, from: fromTs, to: toTs, limit: 5000) + let merged = mergeSleep(imported: imported, computed: computed) + let durations = merged.map { Double(max(0, $0.endTs - $0.startTs)) / 60.0 } + let efficiencies = merged.compactMap(\.efficiency) + + return .object([ + "range": .object(["fromTs": .int(fromTs), "toTs": .int(toTs), "days": .int(days)]), + "count": .int(merged.count), + "averageDurationMin": optionalDouble(mean(durations)), + "averageEfficiency": optionalDouble(mean(efficiencies)), + "sessions": .array(merged.suffix(200).map(sleepJSON)), + ]) + } + + public func workoutSummary(days: Int) throws -> JSONValue { + let (fromTs, toTs) = timestampRange(days: days) + let imported = try store.workouts(deviceId: deviceId, from: fromTs, to: toTs, limit: 5000) + let apple = try store.workouts(deviceId: "apple-health", from: fromTs, to: toTs, limit: 5000) + let computed = try store.workouts(deviceId: computedDeviceId, from: fromTs, to: toTs, limit: 5000) + let rows = (imported + apple + computed).sorted { $0.startTs < $1.startTs } + let durationMin = rows.reduce(0.0) { total, row in + total + ((row.durationS ?? Double(max(0, row.endTs - row.startTs))) / 60.0) + } + let calories = rows.compactMap(\.energyKcal).reduce(0, +) + let strain = rows.compactMap(\.strain).reduce(0, +) + + return .object([ + "range": .object(["fromTs": .int(fromTs), "toTs": .int(toTs), "days": .int(days)]), + "count": .int(rows.count), + "totalDurationMin": .double(durationMin), + "totalEnergyKcal": .double(calories), + "totalStrain": .double(strain), + "workouts": .array(rows.suffix(300).map(workoutJSON)), + ]) + } + + public static func metricCatalog() -> JSONValue { + .object([ + "sources": sources(), + "keys": .array([ + "avg_hr", "max_hr", "energy_kcal", "recovery", "hrv", "rhr", "resp_rate", + "spo2", "skin_temp", "sleep_performance", "sleep_total_min", "sleep_efficiency", + "sleep_deep_min", "sleep_rem_min", "sleep_light_min", "sleep_need_min", + "sleep_debt_min", "strain", "steps", "active_kcal", "weight", "vo2max", + "body_fat", "lean_mass", "bmi", "stress", "mood", "calories_in", + "protein_g", "carbs_g", "fat_g", + ].map { .string($0) }), + "resolutionRule": .string("my-whoop resolves imported my-whoop first, then my-whoop-noop computed rows, then compatible Apple Health fill-ins for rhr/hrv/spo2/resp_rate."), + ]) + } + + public static func sources() -> JSONValue { + .object([ + "whoopImported": .string("my-whoop"), + "noopComputed": .string("my-whoop-noop"), + "appleHealth": .string("apple-health"), + "nutrition": .string("nutrition-csv"), + "mood": .string("noop-mood"), + "journal": .string("noop-journal"), + ]) + } + + private func mergedDaily(from: String, to: String) throws -> [(row: DailyMetricRow, source: String)] { + var byDay: [String: (DailyMetricRow, String)] = [:] + for row in try store.dailyMetrics(deviceId: computedDeviceId, from: from, to: to) { + byDay[row.day] = (row, computedDeviceId) + } + for row in try store.dailyMetrics(deviceId: deviceId, from: from, to: to) { + byDay[row.day] = (row, deviceId) + } + return byDay.values.sorted { $0.0.day < $1.0.day } + } + + private func mergeSleep(imported: [SleepSessionRow], computed: [SleepSessionRow]) -> [SleepSessionRow] { + var importedDays = Set() + for session in imported { + importedDays.insert(dayString(Date(timeIntervalSince1970: TimeInterval(session.endTs)))) + } + let computedKept = computed.filter { + !importedDays.contains(dayString(Date(timeIntervalSince1970: TimeInterval($0.endTs)))) + } + return (imported + computedKept).sorted { $0.startTs < $1.startTs } + } + + private func freshnessPayload(latestHR: Int?, apple: [AppleDailyRow], daily: [(row: DailyMetricRow, source: String)]) -> JSONValue { + .object([ + "latestHeartRateSample": timestampJSON(latestHR, now: Date()), + "latestDailyMetricDay": daily.last.map { .string($0.row.day) } ?? .null, + "latestAppleHealthDay": apple.last.map { .string($0.day) } ?? .null, + "dailyRows": .int(daily.count), + "appleDailyRows": .int(apple.count), + ]) + } + + private static func sourceCandidates(forKey key: String, preferredSource: String, actualWhoopSource: String) -> [MetricSourceCandidate] { + if preferredSource == "my-whoop" || preferredSource == actualWhoopSource { + var candidates = [ + MetricSourceCandidate(source: actualWhoopSource, key: key), + MetricSourceCandidate(source: actualWhoopSource + "-noop", key: key), + ] + if let appleKey = appleCompatibleKey(forWhoopKey: key) { + candidates.append(MetricSourceCandidate(source: "apple-health", key: appleKey)) + } + return orderedUnique(candidates) + } + return [MetricSourceCandidate(source: preferredSource, key: key)] + } + + private static func appleCompatibleKey(forWhoopKey key: String) -> String? { + switch key { + case "rhr": + return "resting_hr" + case "hrv", "spo2", "resp_rate": + return key + default: + return nil + } + } +} + +private struct MetricSourceCandidate: Hashable { + let source: String + let key: String +} + +private func dailyJSON(_ row: DailyMetricRow, source: String) -> JSONValue { + .object([ + "day": .string(row.day), + "source": .string(source), + "totalSleepMin": optionalDouble(row.totalSleepMin), + "efficiency": optionalDouble(row.efficiency), + "deepMin": optionalDouble(row.deepMin), + "remMin": optionalDouble(row.remMin), + "lightMin": optionalDouble(row.lightMin), + "disturbances": optionalInt(row.disturbances), + "restingHr": optionalInt(row.restingHr), + "avgHrv": optionalDouble(row.avgHrv), + "recovery": optionalDouble(row.recovery), + "strain": optionalDouble(row.strain), + "exerciseCount": optionalInt(row.exerciseCount), + "spo2Pct": optionalDouble(row.spo2Pct), + "skinTempDevC": optionalDouble(row.skinTempDevC), + "respRateBpm": optionalDouble(row.respRateBpm), + "steps": optionalInt(row.steps), + "activeKcalEst": optionalDouble(row.activeKcalEst), + ]) +} + +private func appleDailyJSON(_ row: AppleDailyRow) -> JSONValue { + .object([ + "day": .string(row.day), + "steps": optionalInt(row.steps), + "activeKcal": optionalDouble(row.activeKcal), + "basalKcal": optionalDouble(row.basalKcal), + "vo2max": optionalDouble(row.vo2max), + "avgHr": optionalInt(row.avgHr), + "maxHr": optionalInt(row.maxHr), + "walkingHr": optionalInt(row.walkingHr), + "weightKg": optionalDouble(row.weightKg), + ]) +} + +private func sleepJSON(_ row: SleepSessionRow) -> JSONValue { + .object([ + "startTs": .int(row.startTs), + "endTs": .int(row.endTs), + "start": .string(iso(Date(timeIntervalSince1970: TimeInterval(row.startTs)))), + "end": .string(iso(Date(timeIntervalSince1970: TimeInterval(row.endTs)))), + "durationMin": .double(Double(max(0, row.endTs - row.startTs)) / 60.0), + "efficiency": optionalDouble(row.efficiency), + "restingHr": optionalInt(row.restingHr), + "avgHrv": optionalDouble(row.avgHrv), + "hasStages": .bool(row.stagesJSON != nil), + ]) +} + +private func workoutJSON(_ row: WorkoutRow) -> JSONValue { + .object([ + "startTs": .int(row.startTs), + "endTs": .int(row.endTs), + "start": .string(iso(Date(timeIntervalSince1970: TimeInterval(row.startTs)))), + "end": .string(iso(Date(timeIntervalSince1970: TimeInterval(row.endTs)))), + "sport": .string(row.sport), + "source": .string(row.source), + "durationS": optionalDouble(row.durationS), + "energyKcal": optionalDouble(row.energyKcal), + "avgHr": optionalInt(row.avgHr), + "maxHr": optionalInt(row.maxHr), + "strain": optionalDouble(row.strain), + "distanceM": optionalDouble(row.distanceM), + "hasZones": .bool(row.zonesJSON != nil), + "hasNotes": .bool(row.notes != nil), + ]) +} + +private func timestampJSON(_ ts: Int?, now: Date) -> JSONValue { + guard let ts else { return .null } + let date = Date(timeIntervalSince1970: TimeInterval(ts)) + return .object([ + "ts": .int(ts), + "iso": .string(iso(date)), + "ageSeconds": .int(max(0, Int(now.timeIntervalSince(date)))), + ]) +} + +private func coverageJSON(_ days: [String]) -> JSONValue { + .object([ + "count": .int(days.count), + "firstDay": days.min().map { .string($0) } ?? .null, + "lastDay": days.max().map { .string($0) } ?? .null, + ]) +} + +private func optionalDouble(_ value: Double?) -> JSONValue { + value.map { .double($0) } ?? .null +} + +private func optionalInt(_ value: Int?) -> JSONValue { + value.map { .int($0) } ?? .null +} + +func boundedDays(_ value: JSONValue?, default defaultValue: Int, max maxValue: Int) -> Int { + guard let raw = value?.intValue else { return defaultValue } + return min(max(raw, 1), maxValue) +} + +func boundedLimit(_ value: JSONValue?, default defaultValue: Int, max maxValue: Int) -> Int { + guard let raw = value?.intValue else { return defaultValue } + return min(max(raw, 1), maxValue) +} + +func orderedUnique(_ values: [T]) -> [T] { + var seen = Set() + var result: [T] = [] + for value in values where !seen.contains(value) { + seen.insert(value) + result.append(value) + } + return result +} + +private func mean(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + return values.reduce(0, +) / Double(values.count) +} + +private func dayRange(days: Int) -> (from: String, to: String) { + let now = Date() + return ( + from: dayString(now.addingTimeInterval(-Double(max(1, days) - 1) * 86_400)), + to: dayString(now.addingTimeInterval(86_400)) + ) +} + +private func timestampRange(days: Int) -> (from: Int, to: Int) { + let now = Int(Date().timeIntervalSince1970) + return (now - max(1, days) * 86_400, now + 86_400) +} + +private func dayString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) +} + +private func logicalDayKey(_ now: Date) -> String { + dayString(now.addingTimeInterval(-4 * 3_600)) +} + +private func iso(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) +} diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift new file mode 100644 index 0000000000..a5d34a5557 --- /dev/null +++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift @@ -0,0 +1,374 @@ +import Foundation + +public let noopLocalAccessServerName = "noop-local-access" +public let noopLocalAccessServerVersion = "0.1.0" +public let noopLocalAccessProtocolVersion = "2025-06-18" + +public struct RPCRequest: Decodable, Equatable { + public let id: JSONValue? + public let method: String + public let params: JSONValue? + + public init(id: JSONValue?, method: String, params: JSONValue?) { + self.id = id + self.method = method + self.params = params + } +} + +public final class NoopMCPServer { + private let configuration: LocalAccessConfiguration + private var dataAccess: NoopDataAccess? + + public init(configuration: LocalAccessConfiguration = .environment()) { + self.configuration = configuration + } + + public func handleLine(_ line: String) -> JSONValue { + do { + let request = try JSONDecoder().decode(RPCRequest.self, from: Data(line.utf8)) + return try handle(request) ?? .null + } catch { + return Self.errorResponse(id: .null, code: -32700, message: "Parse error: \(error)") + } + } + + public func handle(_ request: RPCRequest) throws -> JSONValue? { + if request.id == nil, request.method.hasPrefix("notifications/") { + return nil + } + guard let id = request.id else { return nil } + + do { + let result = try result(for: request) + return Self.response(id: id, result: result) + } catch let error as LocalAccessError { + return Self.errorResponse(id: id, code: error.rpcCode, message: error.description) + } catch { + return Self.errorResponse(id: id, code: -32603, message: "Internal error: \(error)") + } + } + + public static func response(id: JSONValue, result: JSONValue) -> JSONValue { + .object([ + "jsonrpc": .string("2.0"), + "id": id, + "result": result, + ]) + } + + public static func errorResponse(id: JSONValue, code: Int, message: String) -> JSONValue { + .object([ + "jsonrpc": .string("2.0"), + "id": id, + "error": .object([ + "code": .int(code), + "message": .string(message), + ]), + ]) + } + + private func result(for request: RPCRequest) throws -> JSONValue { + switch request.method { + case "initialize": + return initializeResult() + case "tools/list": + return toolsList() + case "tools/call": + return try callTool(params: request.params) + case "resources/list": + return resourcesList() + case "resources/read": + return try readResource(params: request.params) + case "resources/templates/list": + return .object(["resourceTemplates": .array([])]) + case "prompts/list": + return promptsList() + case "prompts/get": + return try getPrompt(params: request.params) + case "ping": + return .object([:]) + default: + throw LocalAccessError.methodNotFound(request.method) + } + } + + private func initializeResult() -> JSONValue { + .object([ + "protocolVersion": .string(noopLocalAccessProtocolVersion), + "capabilities": .object([ + "tools": .object(["listChanged": .bool(false)]), + "resources": .object(["listChanged": .bool(false)]), + "prompts": .object(["listChanged": .bool(false)]), + ]), + "instructions": .string(Self.instructions), + "serverInfo": .object([ + "name": .string(noopLocalAccessServerName), + "version": .string(noopLocalAccessServerVersion), + ]), + ]) + } + + public static let instructions = """ + NOOP local access is read-only and returns personal health context from the user's on-device SQLite store. Use bounded tools, check data_freshness before stale-data claims, separate facts from inference, and do not diagnose medical conditions. No tool writes data or calls a network service. + """ + + private func data() throws -> NoopDataAccess { + if let dataAccess { return dataAccess } + do { + let access = try NoopDataAccess.open(configuration: configuration) + dataAccess = access + return access + } catch let error as LocalAccessError { + throw error + } catch { + throw LocalAccessError.databaseUnavailable("NOOP database is not available: \(error)") + } + } + + private func callTool(params: JSONValue?) throws -> JSONValue { + guard let object = params?.objectValue, + let name = object["name"]?.stringValue + else { + throw LocalAccessError.invalidParams("tools/call requires a tool name") + } + let arguments = object["arguments"]?.objectValue ?? [:] + let payload: JSONValue + switch name { + case "health_snapshot": + payload = try data().healthSnapshot(days: boundedDays(arguments["days"], default: 14, max: 120)) + case "metric_series": + guard let key = arguments["key"]?.stringValue else { + throw LocalAccessError.invalidParams("metric_series requires key") + } + payload = try data().metricSeries( + key: key, + source: arguments["source"]?.stringValue ?? "my-whoop", + days: boundedDays(arguments["days"], default: 90, max: 4000), + fromDay: arguments["from_day"]?.stringValue, + toDay: arguments["to_day"]?.stringValue, + limit: boundedLimit(arguments["limit"], default: 500, max: 2000) + ) + case "data_freshness": + payload = try data().freshness() + case "sleep_summary": + payload = try data().sleepSummary(days: boundedDays(arguments["days"], default: 30, max: 4000)) + case "workout_summary": + payload = try data().workoutSummary(days: boundedDays(arguments["days"], default: 90, max: 4000)) + default: + throw LocalAccessError.toolNotFound(name) + } + return toolResult(payload) + } + + private func readResource(params: JSONValue?) throws -> JSONValue { + guard let uri = params?.objectValue?["uri"]?.stringValue else { + throw LocalAccessError.invalidParams("resources/read requires uri") + } + let payload: JSONValue + switch uri { + case "noop://health/snapshot": + payload = try data().healthSnapshot(days: 14) + case "noop://data/freshness": + payload = try data().freshness() + case "noop://metrics/catalog": + payload = NoopDataAccess.metricCatalog() + case "noop://sources": + payload = NoopDataAccess.sources() + default: + throw LocalAccessError.resourceNotFound(uri) + } + return .object([ + "contents": .array([ + .object([ + "uri": .string(uri), + "mimeType": .string("application/json"), + "text": .string(prettyJSON(payload)), + ]), + ]), + ]) + } + + private func getPrompt(params: JSONValue?) throws -> JSONValue { + guard let name = params?.objectValue?["name"]?.stringValue else { + throw LocalAccessError.invalidParams("prompts/get requires name") + } + + let text: String + let description: String + switch name { + case "weekly_health_review": + description = "Review the last week of NOOP data" + text = """ + Use the NOOP local access tools to review the last 7 days. Start with health_snapshot, then inspect any weak driver with metric_series. Separate facts, inferred patterns, and uncertainty. Do not diagnose medical conditions. + """ + case "debug_data_freshness": + description = "Find why a NOOP screen looks stale" + text = """ + Use data_freshness, then compare health_snapshot with metric_series for the affected metric. Identify whether the issue is source freshness, import coverage, computed-source fallback, or a UI read-model problem. + """ + case "explain_recovery": + description = "Explain recovery drivers from local NOOP data" + text = """ + Use health_snapshot and metric_series for recovery, hrv, rhr, resp_rate, strain, and sleep_total_min. Explain what changed against recent baseline, what is only correlation, and what action is low-risk today. + """ + default: + throw LocalAccessError.promptNotFound(name) + } + + return .object([ + "description": .string(description), + "messages": .array([ + .object([ + "role": .string("user"), + "content": .object([ + "type": .string("text"), + "text": .string(text), + ]), + ]), + ]), + ]) + } +} + +public func toolsList() -> JSONValue { + .object([ + "tools": .array([ + tool( + name: "health_snapshot", + title: "Health Snapshot", + description: "Return a bounded recent NOOP health snapshot with merged WHOOP imported/computed daily metrics and freshness metadata.", + properties: [ + "days": integerProperty("Trailing days to include, default 14, max 120."), + ] + ), + tool( + name: "metric_series", + title: "Metric Series", + description: "Return one bounded metric series from WHOOP, NOOP computed, Apple Health, nutrition, or mood sources.", + properties: [ + "key": stringProperty("Metric key, such as recovery, hrv, rhr, resp_rate, spo2, strain, sleep_total_min, steps, or active_kcal."), + "source": stringProperty("Source id. Defaults to my-whoop and resolves my-whoop + my-whoop-noop + compatible Apple Health fill-ins."), + "days": integerProperty("Trailing days if from_day/to_day are not provided, default 90, max 4000."), + "from_day": stringProperty("Inclusive YYYY-MM-DD start day."), + "to_day": stringProperty("Inclusive YYYY-MM-DD end day."), + "limit": integerProperty("Maximum returned points, default 500, max 2000."), + ], + required: ["key"] + ), + tool( + name: "data_freshness", + title: "Data Freshness", + description: "Report local NOOP source freshness, storage counts, available metric keys, and latest heart-rate sample timestamp.", + properties: [:] + ), + tool( + name: "sleep_summary", + title: "Sleep Summary", + description: "Return bounded sleep sessions and aggregate sleep duration/efficiency from local NOOP data.", + properties: [ + "days": integerProperty("Trailing days to include, default 30, max 4000."), + ] + ), + tool( + name: "workout_summary", + title: "Workout Summary", + description: "Return bounded workout rows and aggregate effort/calorie/duration summaries from local NOOP data.", + properties: [ + "days": integerProperty("Trailing days to include, default 90, max 4000."), + ] + ), + ]), + ]) +} + +public func resourcesList() -> JSONValue { + .object([ + "resources": .array([ + resource("noop://health/snapshot", name: "health_snapshot", title: "NOOP Health Snapshot", description: "Recent merged daily metrics and freshness", mimeType: "application/json"), + resource("noop://data/freshness", name: "data_freshness", title: "NOOP Data Freshness", description: "Source coverage and latest sample timestamps", mimeType: "application/json"), + resource("noop://metrics/catalog", name: "metrics_catalog", title: "NOOP Metrics Catalog", description: "Supported metric keys and source ids", mimeType: "application/json"), + resource("noop://sources", name: "sources", title: "NOOP Sources", description: "Canonical local source identifiers", mimeType: "application/json"), + ]), + ]) +} + +public func promptsList() -> JSONValue { + .object([ + "prompts": .array([ + prompt("weekly_health_review", title: "Weekly Health Review", description: "Review the last week of NOOP data with uncertainty separated from facts."), + prompt("debug_data_freshness", title: "Debug Data Freshness", description: "Diagnose why a NOOP screen or metric is stale."), + prompt("explain_recovery", title: "Explain Recovery", description: "Explain recovery drivers using local metrics and recent baselines."), + ]), + ]) +} + +private func tool( + name: String, + title: String, + description: String, + properties: [String: JSONValue], + required: [String] = [] +) -> JSONValue { + .object([ + "name": .string(name), + "title": .string(title), + "description": .string(description), + "inputSchema": .object([ + "type": .string("object"), + "properties": .object(properties), + "required": .array(required.map { .string($0) }), + "additionalProperties": .bool(false), + ]), + "annotations": .object([ + "readOnlyHint": .bool(true), + "openWorldHint": .bool(false), + ]), + ]) +} + +private func resource(_ uri: String, name: String, title: String, description: String, mimeType: String) -> JSONValue { + .object([ + "uri": .string(uri), + "name": .string(name), + "title": .string(title), + "description": .string(description), + "mimeType": .string(mimeType), + ]) +} + +private func prompt(_ name: String, title: String, description: String) -> JSONValue { + .object([ + "name": .string(name), + "title": .string(title), + "description": .string(description), + "arguments": .array([]), + ]) +} + +private func stringProperty(_ description: String) -> JSONValue { + .object([ + "type": .string("string"), + "description": .string(description), + ]) +} + +private func integerProperty(_ description: String) -> JSONValue { + .object([ + "type": .string("integer"), + "description": .string(description), + ]) +} + +private func toolResult(_ payload: JSONValue) -> JSONValue { + .object([ + "content": .array([ + .object([ + "type": .string("text"), + "text": .string(prettyJSON(payload)), + ]), + ]), + "structuredContent": payload, + "isError": .bool(false), + ]) +} diff --git a/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift b/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift new file mode 100644 index 0000000000..5f95ed2f44 --- /dev/null +++ b/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift @@ -0,0 +1,89 @@ +import Foundation +import NoopLocalAccessCore + +@main +enum NoopLocalAccessMain { + static func main() { + var args = Array(CommandLine.arguments.dropFirst()) + let command = args.first ?? "mcp" + if !args.isEmpty { args.removeFirst() } + + switch command { + case "mcp": + runMCP(configuration: .environment()) + case "codex-config": + print(codexConfig(arguments: args)) + case "--help", "-h", "help": + print(helpText) + default: + fputs("Unknown command: \(command)\n\n\(helpText)\n", stderr) + Foundation.exit(64) + } + } + + private static func runMCP(configuration: LocalAccessConfiguration) { + let server = NoopMCPServer(configuration: configuration) + while let line = readLine(strippingNewline: true) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let response = server.handleLine(trimmed) + guard response != .null else { continue } + write(response) + } + } + + private static func write(_ value: JSONValue) { + do { + let data = try JSONEncoder().encode(value) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) + } catch { + fputs("[noop-local-access] failed to encode response: \(error)\n", stderr) + } + } + + private static func codexConfig(arguments: [String]) -> String { + let executable = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL.path + var dbPath: String? + var iterator = arguments.makeIterator() + while let arg = iterator.next() { + if arg == "--db-path" { + dbPath = iterator.next() + } + } + + var lines = [ + "[mcp_servers.noop]", + "command = \"\(toml(executable))\"", + "args = [\"mcp\"]", + "startup_timeout_sec = 10", + "tool_timeout_sec = 60", + "default_tools_approval_mode = \"prompt\"", + ] + if let dbPath, !dbPath.isEmpty { + lines.append("") + lines.append("[mcp_servers.noop.env]") + lines.append("NOOP_DB_PATH = \"\(toml(dbPath))\"") + } + return lines.joined(separator: "\n") + } + + private static func toml(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private static let helpText = """ + Usage: + noop-local-access mcp + noop-local-access codex-config [--db-path /absolute/path/to/whoop.sqlite] + + Environment: + NOOP_DB_PATH Explicit NOOP SQLite path. Optional; otherwise the official macOS app container is used. + NOOP_BUNDLE_ID Optional non-default bundle id. Not needed for the official app. + NOOP_DEVICE_ID Optional source id. Defaults to my-whoop. + + The MCP server is read-only, stdio-based, and exposes bounded local NOOP data tools. + """ +} diff --git a/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/DatabasePathResolverTests.swift b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/DatabasePathResolverTests.swift new file mode 100644 index 0000000000..5edc93fcae --- /dev/null +++ b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/DatabasePathResolverTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import NoopLocalAccessCore + +final class DatabasePathResolverTests: XCTestCase { + func testPersonalBundleIsNotADefaultCandidate() { + let candidates = DatabasePathResolver.candidates(home: "/Users/example") + + XCTAssertTrue(candidates.contains("/Users/example/Library/Containers/com.bbdw.noop/Data/Library/Application Support/OpenWhoop/whoop.sqlite")) + XCTAssertFalse(candidates.contains { $0.contains("com.bbdw.noop.personal") }) + } + + func testCustomBundleIDIsExplicitOptIn() { + let candidates = DatabasePathResolver.candidates(bundleID: "com.example.noop", home: "/Users/example") + + XCTAssertEqual( + candidates.first, + "/Users/example/Library/Containers/com.example.noop/Data/Library/Application Support/OpenWhoop/whoop.sqlite" + ) + XCTAssertTrue(candidates.contains("/Users/example/Library/Containers/com.bbdw.noop/Data/Library/Application Support/OpenWhoop/whoop.sqlite")) + } + + func testExplicitPathMustExist() throws { + let url = try TemporaryDatabase.emptyFileURL() + let config = LocalAccessConfiguration(databasePath: url.path) + + XCTAssertEqual(try DatabasePathResolver.resolve(configuration: config), url.path) + } + + func testExplicitPathFailureDoesNotFallBack() { + let config = LocalAccessConfiguration(databasePath: "/definitely/not/noop/whoop.sqlite") + + XCTAssertThrowsError(try DatabasePathResolver.resolve(configuration: config)) { error in + XCTAssertEqual(error as? LocalAccessError, .databaseUnavailable("NOOP database not found at NOOP_DB_PATH.")) + } + } +} diff --git a/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/MCPServerTests.swift b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/MCPServerTests.swift new file mode 100644 index 0000000000..7b14cb597d --- /dev/null +++ b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/MCPServerTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import NoopLocalAccessCore + +final class MCPServerTests: XCTestCase { + func testInitializeIncludesReadOnlyInstructionsForCodex() throws { + let server = NoopMCPServer(configuration: LocalAccessConfiguration(databasePath: "/unused")) + let response = try XCTUnwrap(try server.handle(RPCRequest(id: .int(1), method: "initialize", params: nil))) + let result = try XCTUnwrap(response.objectValue?["result"]?.objectValue) + + XCTAssertEqual(result["protocolVersion"], .string(noopLocalAccessProtocolVersion)) + XCTAssertTrue(result["instructions"]?.stringValue?.contains("read-only") == true) + XCTAssertTrue(result["instructions"]?.stringValue?.contains("do not diagnose") == true) + } + + func testToolsAreAnnotatedReadOnly() throws { + let tools = try XCTUnwrap(toolsList().objectValue?["tools"]) + guard case .array(let values) = tools else { + return XCTFail("Expected tools array") + } + + XCTAssertFalse(values.isEmpty) + for tool in values { + let annotations = try XCTUnwrap(tool.objectValue?["annotations"]?.objectValue) + XCTAssertEqual(annotations["readOnlyHint"], .bool(true)) + XCTAssertEqual(annotations["openWorldHint"], .bool(false)) + } + } + + func testMetricSeriesUsesComputedFallbackForMissingImportedDay() throws { + let url = try TemporaryDatabase.seeded() + let server = NoopMCPServer(configuration: LocalAccessConfiguration(databasePath: url.path)) + let response = try XCTUnwrap(try server.handle(RPCRequest( + id: .int(2), + method: "tools/call", + params: .object([ + "name": .string("metric_series"), + "arguments": .object([ + "key": .string("hrv"), + "from_day": .string("2026-06-10"), + "to_day": .string("2026-06-11"), + ]), + ]) + ))) + + let structured = try XCTUnwrap(response.objectValue?["result"]?.objectValue?["structuredContent"]?.objectValue) + XCTAssertEqual(structured["returned"], .int(2)) + guard case .array(let points) = structured["points"] else { + return XCTFail("Expected points array") + } + XCTAssertEqual(points.compactMap { $0.objectValue?["source"]?.stringValue }, ["my-whoop", "my-whoop-noop"]) + } +} diff --git a/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/ReadonlyNoopStoreTests.swift b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/ReadonlyNoopStoreTests.swift new file mode 100644 index 0000000000..2a3f5ea815 --- /dev/null +++ b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/ReadonlyNoopStoreTests.swift @@ -0,0 +1,35 @@ +import GRDB +import XCTest +@testable import NoopLocalAccessCore + +final class ReadonlyNoopStoreTests: XCTestCase { + func testReadsSeededNoopStoreWithoutOpeningWritableHandle() throws { + let url = try TemporaryDatabase.seeded() + let store = try ReadonlyNoopStore(path: url.path) + + XCTAssertTrue(try store.isReadOnlyForTest()) + XCTAssertEqual(try store.latestHRSampleTs(deviceId: "my-whoop"), 102) + XCTAssertEqual(try store.metricKeys(deviceId: "my-whoop"), ["hrv"]) + + let daily = try store.dailyMetrics(deviceId: "my-whoop", from: "2026-06-01", to: "2026-06-30") + XCTAssertEqual(daily.map(\.day), ["2026-06-10"]) + XCTAssertEqual(daily.first?.recovery, 67) + + let stats = try store.storageStats() + XCTAssertEqual(stats.decodedRows, 4) + XCTAssertEqual(stats.rawBatches, 1) + XCTAssertEqual(stats.rawBytes, 12) + } + + func testForeignNoopLikeDatabaseIsRejectedWithoutQuarantine() throws { + let url = try TemporaryDatabase.foreignNoopLike() + + XCTAssertThrowsError(try ReadonlyNoopStore(path: url.path)) { error in + guard case .databaseUnavailable(let message) = error as? LocalAccessError else { + return XCTFail("Expected LocalAccessError.databaseUnavailable") + } + XCTAssertTrue(message.contains("without GRDB migration metadata")) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } +} diff --git a/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/TemporaryDatabase.swift b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/TemporaryDatabase.swift new file mode 100644 index 0000000000..5716a7332c --- /dev/null +++ b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/TemporaryDatabase.swift @@ -0,0 +1,110 @@ +import Foundation +import GRDB + +enum TemporaryDatabase { + static func emptyFileURL() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("NoopLocalAccessTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("whoop.sqlite") + FileManager.default.createFile(atPath: url.path, contents: Data()) + return url + } + + static func seeded() throws -> URL { + let url = try emptyFileURL() + let dbQueue = try DatabaseQueue(path: url.path) + try dbQueue.write { db in + try createSchema(db) + try seed(db) + } + return url + } + + static func foreignNoopLike() throws -> URL { + let url = try emptyFileURL() + let dbQueue = try DatabaseQueue(path: url.path) + try dbQueue.write { db in + try db.execute(sql: "CREATE TABLE device(id TEXT PRIMARY KEY)") + try db.execute(sql: "CREATE TABLE hrSample(deviceId TEXT NOT NULL, ts INTEGER NOT NULL, bpm INTEGER NOT NULL, PRIMARY KEY(deviceId, ts))") + } + return url + } + + private static func createSchema(_ db: Database) throws { + try db.execute(sql: "CREATE TABLE grdb_migrations(identifier TEXT PRIMARY KEY)") + try db.execute(sql: """ + CREATE TABLE dailyMetric( + deviceId TEXT NOT NULL, day TEXT NOT NULL, totalSleepMin DOUBLE, efficiency DOUBLE, + deepMin DOUBLE, remMin DOUBLE, lightMin DOUBLE, disturbances INTEGER, + restingHr INTEGER, avgHrv DOUBLE, recovery DOUBLE, strain DOUBLE, + exerciseCount INTEGER, spo2Pct DOUBLE, skinTempDevC DOUBLE, respRateBpm DOUBLE, + steps INTEGER, activeKcalEst DOUBLE, PRIMARY KEY(deviceId, day) + ) + """) + try db.execute(sql: """ + CREATE TABLE metricSeries( + deviceId TEXT NOT NULL, day TEXT NOT NULL, key TEXT NOT NULL, value DOUBLE NOT NULL, + PRIMARY KEY(deviceId, day, key) + ) + """) + try db.execute(sql: """ + CREATE TABLE appleDaily( + deviceId TEXT NOT NULL, day TEXT NOT NULL, steps INTEGER, activeKcal DOUBLE, + basalKcal DOUBLE, vo2max DOUBLE, avgHr INTEGER, maxHr INTEGER, + walkingHr INTEGER, weightKg DOUBLE, PRIMARY KEY(deviceId, day) + ) + """) + try db.execute(sql: """ + CREATE TABLE sleepSession( + deviceId TEXT NOT NULL, startTs INTEGER NOT NULL, endTs INTEGER NOT NULL, + efficiency DOUBLE, restingHr INTEGER, avgHrv DOUBLE, stagesJSON TEXT, + PRIMARY KEY(deviceId, startTs) + ) + """) + try db.execute(sql: """ + CREATE TABLE workout( + deviceId TEXT NOT NULL, startTs INTEGER NOT NULL, endTs INTEGER NOT NULL, + sport TEXT NOT NULL, source TEXT NOT NULL, durationS DOUBLE, energyKcal DOUBLE, + avgHr INTEGER, maxHr INTEGER, strain DOUBLE, distanceM DOUBLE, zonesJSON TEXT, + notes TEXT, PRIMARY KEY(deviceId, startTs, sport) + ) + """) + try db.execute(sql: "CREATE TABLE hrSample(deviceId TEXT NOT NULL, ts INTEGER NOT NULL, bpm INTEGER NOT NULL, PRIMARY KEY(deviceId, ts))") + try db.execute(sql: "CREATE TABLE ppgHrSample(deviceId TEXT NOT NULL, ts INTEGER NOT NULL, bpm DOUBLE NOT NULL, conf DOUBLE NOT NULL, PRIMARY KEY(deviceId, ts))") + try db.execute(sql: "CREATE TABLE rrInterval(deviceId TEXT NOT NULL, ts INTEGER NOT NULL, rrMs INTEGER NOT NULL, PRIMARY KEY(deviceId, ts, rrMs))") + try db.execute(sql: "CREATE TABLE rawBatch(batchId TEXT PRIMARY KEY, deviceId TEXT NOT NULL, byteSize INTEGER NOT NULL)") + } + + private static func seed(_ db: Database) throws { + try db.execute(sql: """ + INSERT INTO dailyMetric(deviceId, day, totalSleepMin, efficiency, restingHr, avgHrv, recovery, strain) + VALUES + ('my-whoop', '2026-06-10', 420, 91, 48, 72, 67, 12.5), + ('my-whoop-noop', '2026-06-11', 410, 88, 50, 66, 61, 10.0) + """) + try db.execute(sql: """ + INSERT INTO metricSeries(deviceId, day, key, value) + VALUES + ('my-whoop', '2026-06-10', 'hrv', 72), + ('my-whoop-noop', '2026-06-11', 'hrv', 66), + ('apple-health', '2026-06-11', 'hrv', 64) + """) + try db.execute(sql: """ + INSERT INTO appleDaily(deviceId, day, steps, activeKcal, vo2max, avgHr, maxHr, weightKg) + VALUES ('apple-health', '2026-06-11', 8000, 420, 47.2, 69, 151, 82.5) + """) + try db.execute(sql: """ + INSERT INTO sleepSession(deviceId, startTs, endTs, efficiency, restingHr, avgHrv) + VALUES ('my-whoop', 1000, 2000, 91, 48, 72) + """) + try db.execute(sql: """ + INSERT INTO workout(deviceId, startTs, endTs, sport, source, durationS, energyKcal, avgHr, maxHr, strain) + VALUES ('my-whoop', 3000, 4800, 'run', 'whoop', 1800, 310, 140, 171, 8.5) + """) + try db.execute(sql: "INSERT INTO hrSample(deviceId, ts, bpm) VALUES ('my-whoop', 100, 70), ('my-whoop', 101, 72)") + try db.execute(sql: "INSERT INTO ppgHrSample(deviceId, ts, bpm, conf) VALUES ('my-whoop', 102, 73.2, 0.8)") + try db.execute(sql: "INSERT INTO rrInterval(deviceId, ts, rrMs) VALUES ('my-whoop', 101, 850)") + try db.execute(sql: "INSERT INTO rawBatch(batchId, deviceId, byteSize) VALUES ('batch-1', 'my-whoop', 12)") + } +} diff --git a/Packages/StrandAnalytics/Package.swift b/Packages/StrandAnalytics/Package.swift index efbe00a011..21fe27457a 100644 --- a/Packages/StrandAnalytics/Package.swift +++ b/Packages/StrandAnalytics/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "StrandAnalytics", - platforms: [.macOS(.v13), .iOS(.v16)], + platforms: [.macOS(.v13), .iOS(.v16), .watchOS(.v10)], products: [.library(name: "StrandAnalytics", targets: ["StrandAnalytics"])], dependencies: [ .package(path: "../WhoopProtocol"), diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ActivityCostEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ActivityCostEngine.swift new file mode 100644 index 0000000000..e28b0a919a --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ActivityCostEngine.swift @@ -0,0 +1,243 @@ +import Foundation + +// ActivityCostEngine.swift — "what each activity costs your recovery". +// +// Pure, deterministic, DB-free. Given which days you tagged each SPORT on and your +// daily Charge (recovery, 0–100) history, this answers, per sport: how far does your +// next-morning Charge sit BELOW your rest-day baseline after a session, and how many +// days does it take to bounce back? +// +// This is a descriptive AVERAGE, not a measurement of any single session — it leans +// on the levers that are actually in the data (the day a session was tagged, and the +// Charge values on the days after) and stays explainable line by line. Nothing here +// is learned; it is plain means over aligned day keys. +// +// Per sport S: +// +// restDays = days with a Charge value that are neither tagged with ANY sport NOR inside a +// session's forward recovery window (D+1…D+maxLookahead) — your UNTOUCHED days. +// baselineMean = mean Charge over restDays. This is your "untouched" recovery — the +// bar each sport is measured against. (Shared across all sports.) +// +// For each tagged day D of sport S that HAS a Charge value on D+1: +// nextMorning(D) = Charge[D+1] +// meanNextMorning = mean of those nextMorning(D). +// n = how many tagged days contributed a D+1 value. +// +// delta ("cost") = baselineMean − meanNextMorning. +// POSITIVE → the morning after this sport your Charge sits BELOW +// your rest baseline (it cost you); negative → you wake higher. +// +// daysToBaseline = how long recovery takes to climb back. Build an AVERAGED forward +// trajectory traj[k] = mean over tagged days D (that have a Charge +// on D+k) of Charge[D+k], for k = 1…maxLookahead. daysToBaseline is +// the smallest k whose traj[k] ≥ baselineMean − tol (tol = 3 pts). +// nil if it never gets within tol inside the window, or n is too thin. +// +// Confidence (reuses ScoreConfidence): a sport with fewer than minSessions tagged +// next-morning pairs is OMITTED entirely (too thin to say anything honest); +// minSessions… String { + let mag = abs(delta) + let points = ActivityCostEngine.roundToInt(mag) + if mag < ActivityCostEngine.barelyMovesPoints { + return "Sessions like this barely move your next-day Charge (n=\(n))." + } + let direction = delta >= 0 ? "cost you" : "lift" + let head = "Sessions like this usually \(direction) about \(points) Charge " + + "point\(points == 1 ? "" : "s") the next morning" + if let days = daysToBaseline { + return head + " and take about \(days) day\(days == 1 ? "" : "s") to bounce back (n=\(n))." + } + return head + " (n=\(n))." + } +} + +// MARK: - Engine + +public enum ActivityCostEngine { + + // MARK: Tunables (documented, deterministic — NOT learned) + + /// Tagged next-morning pairs below which a sport is OMITTED (too thin to report). + public static let minSessions: Int = 4 + /// Pairs at/above which a sport's confidence is `.solid` (else `.building`). + public static let solidSessions: Int = 8 + /// How many days forward the bounce-back trajectory is probed (D+1 … D+maxLookahead). + public static let maxLookahead: Int = 7 + /// Charge points within the baseline that count as "recovered" for daysToBaseline. + public static let tolerance: Double = 3.0 + /// |delta| under this (points) reads as "barely moves" in `sentence()`. + public static let barelyMovesPoints: Double = 1.0 + + // MARK: - Evaluate + + /// Compute each sport's recovery cost from tagged activity days and daily Charge. + /// + /// - Parameters: + /// - activityDaysBySport: per sport, the SET of "yyyy-MM-dd" day keys that sport + /// was tagged on. Using a Set means same-day duplicates are already collapsed. + /// - recoveryByDay: daily Charge (recovery, 0–100) keyed by "yyyy-MM-dd". + /// - Returns: one `ActivityCost` per sport that cleared `minSessions`, ranked by + /// |delta| desc, `.solid` before `.building`, sport name ascending on a tie. + /// Empty input (or no sport thick enough) → an empty array. + public static func evaluate(activityDaysBySport: [String: Set], + recoveryByDay: [String: Double]) -> [ActivityCost] { + guard !activityDaysBySport.isEmpty, !recoveryByDay.isEmpty else { return [] } + + // Rest days = days WITH a Charge value that are neither tagged with ANY sport NOR sit inside + // the forward recovery window (D+1 … D+maxLookahead) of any tagged day. Excluding the + // after-effect window matters: the mornings *after* a session are exactly the days the cost + // suppresses, so counting them as "rest" would contaminate the baseline with the very thing + // we're measuring (understating every cost). The baseline must be your genuinely UNTOUCHED days. + var activeUnion: Set = [] + for (_, days) in activityDaysBySport { activeUnion.formUnion(days) } + var affected = activeUnion + for day in activeUnion { + for k in 1...maxLookahead { + if let d = CorrelationEngine.shiftDay(day, by: k) { affected.insert(d) } + } + } + var restValues: [Double] = [] + for (day, value) in recoveryByDay where !affected.contains(day) { + restValues.append(value) + } + // No untouched days → no baseline to measure against → nothing honest to say. + guard !restValues.isEmpty else { return [] } + let baselineMean = mean(restValues) + + var results: [ActivityCost] = [] + // Sort sports up front so the build order (and any downstream tie behaviour) is + // deterministic regardless of dictionary iteration order. + for sport in activityDaysBySport.keys.sorted() { + let taggedDays = activityDaysBySport[sport]! + + // Collect next-morning (D+1) Charge for each tagged day that has one. + var nextMornings: [Double] = [] + for day in taggedDays { + guard let d1 = CorrelationEngine.shiftDay(day, by: 1), + let v = recoveryByDay[d1] else { continue } + nextMornings.append(v) + } + let n = nextMornings.count + // Thin sports are omitted entirely — better silent than fabricated. + if n < minSessions { continue } + + let meanNextMorning = mean(nextMornings) + let delta = baselineMean - meanNextMorning + let daysToBaseline = forwardDaysToBaseline(taggedDays: taggedDays, + recoveryByDay: recoveryByDay, + baselineMean: baselineMean) + let confidence: ScoreConfidence = n >= solidSessions ? .solid : .building + + results.append(ActivityCost(sport: sport, delta: delta, + meanNextMorning: meanNextMorning, + baselineMean: baselineMean, + daysToBaseline: daysToBaseline, + n: n, confidence: confidence)) + } + + return rank(results) + } + + // MARK: - Bounce-back trajectory + + /// Smallest k in 1…maxLookahead where the AVERAGED forward Charge trajectory + /// traj[k] = mean over tagged days D (with a Charge on D+k) of Charge[D+k] climbs to + /// within `tolerance` of `baselineMean`. nil if it never does inside the window or no + /// day contributed a value at that horizon. + static func forwardDaysToBaseline(taggedDays: Set, + recoveryByDay: [String: Double], + baselineMean: Double) -> Int? { + let target = baselineMean - tolerance + for k in 1...maxLookahead { + var vals: [Double] = [] + for day in taggedDays { + guard let dk = CorrelationEngine.shiftDay(day, by: k), + let v = recoveryByDay[dk] else { continue } + vals.append(v) + } + guard !vals.isEmpty else { continue } + if mean(vals) >= target { return k } + } + return nil + } + + // MARK: - Ranking + + /// Stable rank: |delta| desc, then .solid before .building, then sport name asc. + static func rank(_ items: [ActivityCost]) -> [ActivityCost] { + items.sorted { a, b in + let da = abs(a.delta), db = abs(b.delta) + if da != db { return da > db } + let ra = confidenceRank(a.confidence), rb = confidenceRank(b.confidence) + if ra != rb { return ra > rb } // higher rank (solid) first + return a.sport < b.sport + } + } + + /// Ordinal so .solid sorts ahead of .building (and .calibrating last). + static func confidenceRank(_ c: ScoreConfidence) -> Int { + switch c { + case .solid: return 2 + case .building: return 1 + case .calibrating: return 0 + } + } + + // MARK: - Stats (self-contained so the Kotlin mirror is line-for-line) + + static func mean(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + return values.reduce(0, +) / Double(values.count) + } + + /// Round half away from zero to an Int — matches Kotlin's roundToInt for the + /// non-negative magnitudes used in `sentence()`. + static func roundToInt(_ x: Double) -> Int { + Int(x.rounded()) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index ad42681535..4319886511 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -14,14 +14,39 @@ import WhoopProtocol public enum AnalyticsEngine { + /// Pair the strap's WRIST_OFF/WRIST_ON events into off-wrist `[start, end)` intervals for the sleep + /// detector's fractional wear filter (#500; design credited to j0b-dev's #504). Each WRIST_OFF opens + /// an interval that closes at the next WRIST_ON, or at `windowEnd` if the strap is still off at the + /// end of the read window. Events need not be pre-sorted; kinds are formatted "NAME(n)" (e.g. + /// "WRIST_OFF(10)"), matched by prefix. Repeated OFFs/ONs without a partner are coalesced. + public static func offWristIntervals(events: [WhoopEvent], windowEnd: Int) -> [(start: Int, end: Int)] { + let wear = events + .filter { $0.kind.hasPrefix("WRIST_OFF") || $0.kind.hasPrefix("WRIST_ON") } + .sorted { $0.ts < $1.ts } + var intervals: [(start: Int, end: Int)] = [] + var offStart: Int? = nil + for e in wear { + if e.kind.hasPrefix("WRIST_OFF") { + if offStart == nil { offStart = e.ts } // ignore repeated OFFs + } else { // WRIST_ON closes an open off-wrist span + if let s = offStart, e.ts > s { intervals.append((start: s, end: e.ts)) } + offStart = nil + } + } + if let s = offStart, windowEnd > s { intervals.append((start: s, end: windowEnd)) } + return intervals + } + /// Baselines passed in by the caller (built from prior nights via Baselines). public struct ProfileBaselines: Sendable { public let hrv: BaselineState? public let restingHR: BaselineState? public let resp: BaselineState? + public let skinTemp: BaselineState? public init(hrv: BaselineState? = nil, restingHR: BaselineState? = nil, - resp: BaselineState? = nil) { + resp: BaselineState? = nil, skinTemp: BaselineState? = nil) { self.hrv = hrv; self.restingHR = restingHR; self.resp = resp + self.skinTemp = skinTemp } } @@ -40,17 +65,70 @@ public enum AnalyticsEngine { public let cachedSleep: [CachedSleepSession] /// Detected workout/exercise sessions. public let workouts: [ExerciseSession] - /// Recovery score [0,100] or nil (cold-start / no HRV baseline). + /// Recovery / "Charge" score [0,100] or nil (cold-start / no HRV baseline). public let recovery: Double? - /// Day strain [0,21] or nil (insufficient HR samples / invalid HRR). + /// Ordered Charge driver breakdown (one row per real term that fed the score, biggest + /// mover first). Empty when there is no score (cold-start) or no driver computed. The UI + /// renders one row per driver under the Charge ring; it never recomputes the score. + public let chargeDrivers: [ChargeDriver] + /// A5: skin temperature as a RELATIVE deviation-from-baseline marker (a trend, never a + /// clinical absolute), or nil when no deviation is available. Carries the signed °C + /// deviation + the relative tier (cooler / typical / warmer) for the UI to present. + public let skinTempRelative: SkinTempRelative? + /// Day strain / "Effort" [0,100] or nil (insufficient HR samples / invalid HRR). public let strain: Double? + /// Rest composite [0,100] or nil (no in-bed data). This is the value the + /// `sleep_performance` metric key carries (duration-vs-need 0.50 + efficiency + /// 0.20 + restorative share 0.20 + consistency 0.10). The downstream metric-series + /// builder reads it from here; the Charge "Rest quality" term reads it ÷100. + public let restScore: Double? + /// Per-score confidence tiers (Charge / Effort / Rest) for the small label under + /// each score. Always present (worst case `.calibrating`). + public let chargeConfidence: ScoreConfidence + public let effortConfidence: ScoreConfidence + public let restConfidence: ScoreConfidence + /// Wear-gated mean in-bed skin temperature (°C) for this night, or nil when no worn + /// in-bed samples were available. Baseline-INDEPENDENT (like avgHrv): the caller seeds + /// a personal skin-temp baseline from these nightly means and re-derives + /// `DailyMetric.skinTempDevC` in a second pass. APPROXIMATE. + public let nightlySkinTempC: Double? + /// Per-session per-epoch MOTION magnitudes (H8), keyed by each matched session's detected start + /// (`SleepSession.start`), on the same 30 s epoch grid as that session's `stagesJSON`. The caller + /// persists these via `WhoopStore.persistSessionMotion` after upserting the sleep-session rows. A + /// session with too little gravity to grid is OMITTED (no key), so the caller never persists a + /// fabricated zero series. (H8) + public let sessionMotionByStart: [Int: [Double]] + /// Per-session per-epoch BAND sleep_state (#175), keyed by each matched session's detected start, + /// on the same 30 s grid as `stagesJSON` / `sessionMotionByStart`. The strap's OWN @81 code + /// (0 wake/1 still/2 asleep/3 up) gridded per session, for the caller to persist via + /// `WhoopStore.persistSessionSleepState`. A session with no band-state samples is OMITTED (no key), + /// so the caller persists NULL there rather than a fabricated array. Feeds the H7 re-onset CONFIRM + /// guard on the NEXT pass; never overrides the derived hypnogram. Empty on a WHOOP 4.0. (#175) + public let sessionSleepStateByStart: [Int: [Int]] public init(daily: DailyMetric, sleepSessions: [SleepSession], cachedSleep: [CachedSleepSession], workouts: [ExerciseSession], - recovery: Double?, strain: Double?) { + recovery: Double?, strain: Double?, nightlySkinTempC: Double? = nil, + restScore: Double? = nil, + chargeConfidence: ScoreConfidence = .calibrating, + effortConfidence: ScoreConfidence = .calibrating, + restConfidence: ScoreConfidence = .calibrating, + sessionMotionByStart: [Int: [Double]] = [:], + sessionSleepStateByStart: [Int: [Int]] = [:], + chargeDrivers: [ChargeDriver] = [], + skinTempRelative: SkinTempRelative? = nil) { self.daily = daily; self.sleepSessions = sleepSessions self.cachedSleep = cachedSleep; self.workouts = workouts self.recovery = recovery; self.strain = strain + self.chargeDrivers = chargeDrivers + self.skinTempRelative = skinTempRelative + self.nightlySkinTempC = nightlySkinTempC + self.restScore = restScore + self.chargeConfidence = chargeConfidence + self.effortConfidence = effortConfidence + self.restConfidence = restConfidence + self.sessionMotionByStart = sessionMotionByStart + self.sessionSleepStateByStart = sessionSleepStateByStart } } @@ -67,9 +145,70 @@ public enum AnalyticsEngine { isoDay.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) } + /// Format a unix-seconds timestamp as the device's LOCAL YYYY-MM-DD day string (#277). + /// + /// The day key is the core aggregation key for daily metrics; the dashboard reads "today" by + /// the device's LOCAL calendar day, so the bucket must be the LOCAL day too. A west-of-UTC + /// user's evening (which crosses midnight UTC) would otherwise flow into the next UTC bucket + /// and the local "today" read would never find it — freezing the dashboard (Toronto/UTC-4 + /// report). `offsetSec` is seconds EAST of UTC (TimeZone.current.secondsFromGMT()). The local + /// date is the UTC date of `(ts + offsetSec)`: shifting the instant by the offset turns the + /// fixed-UTC formatter into a local-calendar formatter. `offsetSec == 0` is byte-identical to + /// the UTC `dayString(_:)` above, so pure-function callers/tests on UTC are unchanged. + public static func dayString(_ ts: Int, offsetSec: Int) -> String { + dayString(ts + offsetSec) + } + + /// UTC-midnight epoch seconds of an ISO `day` key (yyyy-MM-dd). `isoDay` is a FIXED-UTC formatter, + /// so `dayString(ts, offsetSec:) == day` ⇔ `(ts + offsetSec) ∈ [dayStartUtcSeconds(day), +86400)` — + /// an integer range check that replaces the per-sample DateFormatter the full-day stream filters in + /// `analyzeDay` used to run (~170k formatter invocations per scored day, ×maxDays, every pass; #996, + /// found by ryanbr's Kotlin↔Swift diff review). A malformed `day` falls back to 0 — an empty 1970 + /// window no real sample matches — rather than trapping. Unreachable in practice (`day` always comes + /// from `dayString`), and the Kotlin mirror degrades the SAME way (`runCatching { … }.getOrDefault(0)`) + /// instead of throwing, so a single bad day key can never take down a whole scoring pass on either + /// platform (nil-tolerant over fail-fast, per the #996 review). + static func dayStartUtcSeconds(_ day: String) -> Int { + Int(isoDay.date(from: day)?.timeIntervalSince1970 ?? 0) + } + + /// Skip the redundant calendar-day re-read in analyzeRecent's per-day scan (#997, ryanbr). For a + /// PAST day the night window `[nightLo, nightHi]` reads through to the NEXT local midnight, so the + /// calendar day `[dayLo, dayHi]` is a strict SUBSET of the hr/steps/gravity streams already in + /// memory — the dayHr/daySteps/dayGravity re-reads (~60 per pass, including the big ~86k-row HR + /// ones) re-query rows the caller already holds. When the day span is a NON-truncated subset of the + /// night window, return the day's samples by filtering the night list in memory; return nil when + /// the shortcut is unsafe and the caller must read the store directly: + /// - TODAY: its calendar day runs past the 18 h night cap (`dayHi > nightHi`). + /// - a night read that came back at `limit` rows may be truncated INSIDE the day span + /// (`ORDER BY ts ASC LIMIT` drops the LATE rows — exactly where the day sits). + /// Byte-identical to the direct read: same owner (the caller reads both windows from one device), + /// same INCLUSIVE `[dayLo, dayHi]` bounds (matching the store's `ts >= from AND ts <= to` range), + /// same order (the night list came from the SAME ts-ASC store method, and filtering preserves + /// order), and the store's HR coalesce (measured ∪ v26 PPG, #156) dedups on a range-INDEPENDENT + /// `h.ts = p.ts` anti-join, so coalescing-then-filtering equals coalescing over the day range. The + /// guards are self-protecting — a DST-shifted `dayLo`/`dayHi` simply falls outside the window and + /// declines — so the shortcut can only ever DECLINE to a direct read, never return wrong data. + /// Mirrors Kotlin `IntelligenceEngine.daySliceFromNight`; lives here (like `offWristIntervals`) + /// so the pure logic is package-testable. (#997) + public static func daySliceFromNight(_ night: [T], + nightLo: Int, nightHi: Int, + dayLo: Int, dayHi: Int, + limit: Int = 200_000, + ts: (T) -> Int) -> [T]? { + guard dayLo >= nightLo, dayHi <= nightHi, night.count < limit else { return nil } + return night.filter { ts($0) >= dayLo && ts($0) <= dayHi } + } + /// JSON-encode stage segments to the verbatim array shape CachedSleepSession stores. - static func encodeStages(_ stages: [StageSegment]) -> String? { - guard let data = try? JSONEncoder().encode(stages) else { return nil } + /// `.sortedKeys` makes the output deterministic — JSONEncoder otherwise emits object keys in an + /// unstable order (it can vary call to call), which would make stored stage JSON non-reproducible + /// and defeat the post-sync self-heal's "skip the write when the re-derived JSON is unchanged" check. + /// Decoders are key-order-independent, so this is purely a stabilization. + public static func encodeStages(_ stages: [StageSegment]) -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + guard let data = try? encoder.encode(stages) else { return nil } return String(data: data, encoding: .utf8) } @@ -89,32 +228,215 @@ public enum AnalyticsEngine { rr: [RRInterval] = [], resp: [RespSample] = [], gravity: [GravitySample] = [], + steps: [StepSample] = [], + // Calendar-day-scoped overrides for the ADDITIVE daily totals + // (steps + activeKcalEst) AND workout detection. When nil, each + // falls back to the same night window the rest of the analysis uses + // (preserving the pure-function contract). The caller + // (IntelligenceEngine) supplies a full + // [localMidnight(day), localMidnight(day)+86400) read here so a + // day's late hours — which fall outside the ~42h night-detection + // window (it ends at dayStart+12h ≈ noon) — are still seen. + // + // dayHr/daySteps drive the additive step + calorie totals. + // dayHr/dayGravity ALSO feed WorkoutDetector so an afternoon / + // evening workout is detected on its OWN calendar day instead of + // lagging to the next pass (the old night window only reached noon, + // so a 5 pm run was invisible until tomorrow's run re-read it). A + // workout straddling local midnight is split at the day boundary — + // the same accepted tradeoff the step/calorie totals already make. + // dayHr ALSO drives Strain / "Effort" so the day's load reflects the + // WHOLE calendar day (afternoon workouts included), not midnight→noon. + // + // Sleep / recovery keep using hr/rr/resp/gravity — staging needs the + // pre-midnight night span the calendar day omits. + dayHr: [HRSample]? = nil, + daySteps: [StepSample]? = nil, + dayGravity: [GravitySample]? = nil, + // Wear-gated nightly skin-temp mean is harvested here + // (baseline-independent); IntelligenceEngine seeds a personal + // baseline from these means across nights and re-derives + // skinTempDevC in pass 2 (same two-pass shape as avgHrv→recovery). + skinTemp: [SkinTempSample] = [], + // Device family that wrote `skinTemp`, so the raw→°C conversion picks + // the right scale (#938): 5/MG banks CENTIDEGREES (raw/100), the WHOOP + // 4.0 v24 field is a RAW ADC on a different scale. Default `.whoop5` + // keeps every 5/MG + pure-function caller byte-identical; + // IntelligenceEngine passes the day owner's real family. + skinTempFamily: DeviceFamily = .whoop5, profile: UserProfile, baselines: ProfileBaselines = ProfileBaselines(), - maxHROverride: Double? = nil) -> DayResult { + maxHROverride: Double? = nil, + // Wall-clock UTC offset (seconds) for the sleep detector's daytime + // false-sleep guard (#90). Default 0 keeps pure-function callers/tests + // on UTC; IntelligenceEngine passes the device's real offset. + tzOffsetSeconds: Int = 0, + // Off-wrist `[start, end)` intervals (unix seconds) for the off-wrist + // sleep backstop (#500), paired from WRIST_OFF/WRIST_ON events by + // `offWristIntervals`. The HR-gap proxy in detectSleep is the always-on + // guard; these explicit intervals sharpen it under the FRACTIONAL rule + // (#504) — a session is dropped only when its off-wrist coverage reaches + // maxOffWristSleepFraction. Default empty keeps pure-function callers/ + // tests event-free; IntelligenceEngine passes the night window's intervals. + wristOff: [(start: Int, end: Int)] = [], + // Rest composite (Charge/Effort/Rest) personalization. Both default to + // their neutral form so pure-function callers/tests get a well-defined + // Rest from a single night; IntelligenceEngine refines them from history. + // sleepNeedHours: personal sleep need (h). Default 8 h; the caller + // refines it toward the recent average. Drives the 0.50 duration term. + // sleepConsistency: sleep/wake regularity in [0,1] (1 = perfectly + // regular). nil → the consistency term is neutral (0.5) since a single + // day carries no regularity signal — the caller supplies it from history. + sleepNeedHours: Double = Rest.defaultNeedHours, + sleepConsistency: Double? = nil, + // The user's learned habitual midsleep (local time-of-day seconds in + // [0, 86400)) for the main-night scored pick, so a late/shift sleeper's + // real night out-scores a daytime nap. nil = cold-start: the selector + // falls back to the broad overnight-band bonus. IntelligenceEngine + // computes this once per run from the trailing sleep history and threads + // it down; pure-function callers/tests leave it nil and stay on the + // cold-start band. (#547) + habitualMidsleepSec: Int? = nil, + // The strap's OWN persisted v18 BAND sleep_state per timestamp (Interpreter's + // `(sb>>4)&3`: 0 wake/1 still/2 asleep/3 up). Consumed ONLY to confirm a + // borderline H7 morning re-onset — a daytime block the strap itself scored + // "asleep" is kept even on a borderline HR dip (#531). Default empty keeps + // pure-function callers/tests free of it; IntelligenceEngine threads the + // night window's persisted band state. (#531 / H8 consume) + bandSleepState: [(ts: Int, state: Int)] = [], + // Opt-in experimental sleep staging (V2). When true, detected nights are + // staged by `SleepStagerV2` instead of V1. Default false keeps V1 the + // byte-identical default for pure-function callers/tests; IntelligenceEngine + // threads `PuffinExperiment.experimentalSleepV2Enabled`. (V7 / #690) + useSleepStagerV2: Bool = false, + // Sleep PROVENANCE for the per-day sleep trace (CAPTURE-C / #799). The + // measured BLE path is `.measured` (the default); the caller passes + // `.imported(...)` when a previously-imported sleep row WON the daily merge, + // so the trace shows the import winning instead of silently substituting the + // measured night. Trace-only: never alters the DayResult. nil/default keeps + // pure-function callers/tests byte-identical (still emits `measured`). + sleepProvenance: SleepProvenance = .measured, + // Sleep & Rest test-mode trace sink (zero-cost default nil = byte-identical). + // When non-nil, the gate trace from detectSleep and the Rest sub-score line + // are forwarded line-by-line. Side-effect-only; never alters the DayResult. + traceSink: ((String) -> Void)? = nil) -> DayResult { + + // Precompute the day's UTC bounds ONCE (#996). `dayString(ts, offsetSec:)` formats the UTC + // calendar day of (ts + offset) with a FIXED offset, so "== day" is exactly membership in + // [dayStartUtc, +86400). That turns the day-bucketing filters below — otherwise a per-sample + // DateFormatter over the full-day dayHr/daySteps streams (~86k 1 Hz samples each) once per + // analyzeDay, ×maxDays every pass — into an integer range check. Byte-identical to the + // formatter compare (locked by AnalyticsEngineDayBoundsTests, incl. fractional offsets). + let dayStartUtc = dayStartUtcSeconds(day) + let dayEndUtc = dayStartUtc + 86_400 + func tsInDay(_ ts: Int) -> Bool { (ts + tzOffsetSeconds) >= dayStartUtc && (ts + tzOffsetSeconds) < dayEndUtc } // ── Sleep detection + staging ───────────────────────────────────────── - let allSessions = SleepStager.detectSleep(hr: hr, rr: rr, resp: resp, gravity: gravity) - // Sessions attributed to `day` = those whose end falls on `day` (UTC). - let matched = allSessions.filter { dayString($0.end) == day } + let allSessions = SleepStager.detectSleep(hr: hr, rr: rr, resp: resp, gravity: gravity, + tzOffsetSeconds: tzOffsetSeconds, wristOff: wristOff, + bandSleepState: bandSleepState, + useSleepStagerV2: useSleepStagerV2, + traceSink: traceSink) + // Sessions attributed to `day` = those whose end falls on `day` (LOCAL day, #277). `day` is + // the caller's local-day key; attribute by the same offset so the bucket and the key agree. + let matched = allSessions.filter { tsInDay($0.end) } + + // ── The day's MAIN night (#525) ─────────────────────────────────────── + // A day can hold an overnight AND a daytime nap (both end on `day`, so both are in `matched`). + // The sleep-DURATION figures (total sleep / stage minutes / efficiency / disturbances, hence the + // Rest composite, the debt ledger, and the dashboard card) describe the MAIN night — the SAME + // block the Sleep tab's hero shows (longest, preferring an overnight-anchored onset). They must + // NOT silently sum the nap in, or the "your night" number disagrees across screens (the #525 + // report). Naps stay their OWN session rows in `sleepSessions` / `cachedSleep`, where the Sleep + // tab lists and labels them separately. `SleepStageTotals.mainNightIndex` is the single shared + // selector so the analytics rollup and the Sleep tab resolve to the identical block. + // Pick by the LEARNED-TIMING score, threading the user's learned habitual midsleep so a + // late/shift sleeper's real night out-scores a daytime nap (nil = cold-start overnight band). + // BIPHASIC GAP-BRIDGE (#561): a main sleep briefly interrupted by a short wake (a fragment the + // detector left split because the wake gap was longer than its sparse-gravity bridge, or a true + // biphasic night) is scored as ONE night via `mainNightGroupIndices`: it bridges adjacent blocks + // whose gap is < `gapBridgeMaxMin`, scores the bridged span, and returns ALL the fragments in the + // winning group. The AASM aggregate below then SUMS the group's stages — in-bed is the SUM of each + // fragment's own in-bed span (the inter-fragment wake gap is NOT part of any fragment, so it is + // excluded and we do NOT invent WASO for it). A day with no bridgeable gap collapses to the single + // block the bare `mainNightIndex` would pick. Intelligence / the Ledger / the Sleep tab all read + // this SAME group (the seam below passes the same `gapBridgeMaxMin`), so #525 does not regress. + let mainGroupIdx = SleepStageTotals.mainNightGroupIndices( + matched.map { SleepStageTotals.NightBlock(start: $0.start, end: $0.end) }, + offsetSec: tzOffsetSeconds, habitualMidsleepSec: habitualMidsleepSec) ?? [] + let mainGroup: [SleepSession] = mainGroupIdx.map { matched[$0] } - // ── Daily sleep aggregates (AASM, in-bed weighted) ──────────────────── + // ── Daily sleep aggregates (AASM) SUMMED over the main-night GROUP (#525 / #561) ── var deepS = 0.0, remS = 0.0, lightS = 0.0, tstS = 0.0 var inBedS = 0.0, effWeighted = 0.0 var disturbances = 0 - for s in matched { + for s in mainGroup { let m = SleepStager.hypnogramMetrics(s) let inBed = Double(s.end - s.start) - inBedS += inBed - effWeighted += s.efficiency * inBed + inBedS += inBed // each fragment's own in-bed span (the gap is added below) + effWeighted += s.efficiency * inBed // in-bed-weighted efficiency across the group deepS += m.deepMin * 60.0 remS += m.remMin * 60.0 lightS += m.lightMin * 60.0 tstS += m.tstS disturbances += m.disturbances } + // OUT-OF-BED time BETWEEN bridged fragments is AWAKE (#777/#705): a main night bridged from two + // fragments split by a 20-min wake gap was reporting that gap as nowhere (it is in no fragment's + // [start,end) span), so 20+ min of real awake read as ~4 min - a v7.1 regression, multi-reporter. + // Fold the gap into AWAKE by extending the in-bed denominator (in-bed = asleep + awake; tstS is + // unchanged), so efficiency and the Rest composite both reflect it. ONE shared definition with the + // edit/recompute seam (`SleepStageTotals.interFragmentAwakeSeconds`), so the two paths agree and the + // denominator is never double-counted. A bridged gap also counts as one disturbance. + let gapAwakeS = SleepStageTotals.interFragmentAwakeSeconds(mainGroup.map { (start: $0.start, end: $0.end) }) + if gapAwakeS > 0 { + inBedS += gapAwakeS // the gap is fully awake: extends in-bed, adds 0 to effWeighted + disturbances += 1 + } let efficiency = inBedS > 0 ? effWeighted / inBedS : 0.0 + // ── Rest composite (Charge/Effort/Rest) ─────────────────────────────── + // The 0–100 sleep score the `sleep_performance` metric key now carries: + // duration-vs-personal-need 0.50 + efficiency 0.20 + restorative share 0.20 + // + consistency 0.10. nil when there is no in-bed data. The Charge "Rest + // quality" term reads it ÷100 (replacing raw efficiency). + let hasStagedSleep = (deepS + remS) > 0 + let restScore: Double? = matched.isEmpty ? nil : Rest.composite( + tstSeconds: tstS, + inBedSeconds: inBedS, + efficiency: efficiency, + restorativeSeconds: deepS + remS, + needHours: sleepNeedHours, + consistency: sleepConsistency, + deepSeconds: deepS) + // Sleep & Rest test mode (E5): emit the Rest sub-score breakdown for this night, reusing the + // IDENTICAL inputs `restScore` consumed above so the trace can never disagree with the score. + // `subScoreLine` itself reuses `Rest.composite` for the final value. Side-effect-only; emitted + // only when a trace is requested and this day actually scored a night. + if let traceSink, !matched.isEmpty { + traceSink(Rest.subScoreLine( + tstSeconds: tstS, inBedSeconds: inBedS, efficiency: efficiency, + restorativeSeconds: deepS + remS, needHours: sleepNeedHours, + consistency: sleepConsistency, deepSeconds: deepS, + groupFragments: mainGroup.count, groupInBedSeconds: inBedS)) + // CAPTURE-C (#799): append the sleep PROVENANCE so an imported row winning the merge is visible + // (not silently swapped for the measured night). hoursAsleep = the scored night's tst in minutes; + // sourceRowId = the main-night's start ts for the measured path (stable per night), else the + // caller-supplied winning-row id. Trace-only; the DayResult is unchanged. + let mainStart = mainGroup.map { $0.start }.min() ?? matched.map { $0.start }.min() ?? 0 + traceSink(sleepProvenanceLine(provenance: sleepProvenance, + hoursAsleepMin: tstS / 60.0, + sourceRowId: String(mainStart))) + } + + // #525 NOTE: the sleep-DURATION figures above are main-night-only (the headline "your night"), + // but the physiological aggregates below (resting HR, HRV, respiration) intentionally stay over + // ALL matched sessions. This is deliberate, not an oversight: recovery should reflect the body's + // best resting physiology for the day, the main overnight dominates these anyway (it is far longer + // than any nap and HRV is in-bed-weighted by duration), and narrowing them to the main night would + // widen the change's blast radius into the recovery score right at a release boundary for a + // negligible shift. The Rest/sleep-quality term is main-night; the recovery physiology is + // day-best-resting, night-dominated. Keep these two definitions distinct on purpose. // Daily resting HR = lowest per-session resting HR across matched sessions. let restingHRDaily = matched.compactMap { $0.restingHR }.min() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. @@ -128,38 +450,139 @@ public enum AnalyticsEngine { return weight > 0 ? total / weight : nil }() + // Nightly APPROXIMATE respiratory rate (breaths/min) from the R-R stream via + // RSA. WHOOP5 v18 carries no raw resp ADC, so this is an on-device estimate, + // NOT a cloud/clinical respiration value. Per matched in-bed session, estimate + // over [start, end]; the night's value = median of finite per-session + // estimates; nil only when no session yields a finite estimate. + let respRateDaily: Double? = { + let perSession = matched + .map { SleepStager.respRateFromRR(rr, start: $0.start, end: $0.end) } + .filter { $0.isFinite } + return perSession.isEmpty ? nil : HRVAnalyzer.median(perSession) + }() + let sleepStart = matched.map { $0.start }.min() let sleepEnd = matched.map { $0.end }.max() - // ── Recovery ────────────────────────────────────────────────────────── + // ── Skin-temperature deviation (offline) ────────────────────────────── + // Computed BEFORE recovery so Charge can fold it in. Wear-gated in-bed mean + // (baseline-independent, harvested every pass) + the deviation against the + // personal baseline. In pass 1 baselines.skinTemp is nil so the deviation is nil + // and the mean is harvested; IntelligenceEngine seeds the baseline from those means + // and re-derives the deviation in pass 2 (mirrors avgHrv→recovery). APPROXIMATE. + let nightlySkinTempC = wornNightlySkinTempC(matched, hr: hr, skinTemp: skinTemp, family: skinTempFamily) + let skinTempDevC: Double? = nightlySkinTempC.flatMap { (v: Double) -> Double? in + guard let b = baselines.skinTemp, b.usable else { return nil } + return round2(Baselines.deviation(v, state: b).delta) + } + + // ── Recovery / "Charge" ─────────────────────────────────────────────── var recovery: Double? = nil + // Ordered "why is Charge what it is" rows, built from the SAME inputs as the score + // (empty when there is no score / cold-start). Surfaced on DayResult for the UI. + var chargeDrivers: [ChargeDriver] = [] if let hrvVal = avgHRVDaily, let rhrVal = restingHRDaily, let hrvBase = baselines.hrv { - // Sleep-performance proxy = in-bed-weighted efficiency (0..1). - let sleepPerf = matched.isEmpty ? nil : efficiency + // Rest-quality term = the Rest composite ÷100 (replaces raw efficiency). + let sleepPerf = restScore.map { $0 / 100.0 } recovery = RecoveryScorer.recovery( hrv: hrvVal, rhr: Double(rhrVal), - resp: nil, // raw resp not aggregated to a nightly scalar here + resp: respRateDaily, // term drops + renormalizes when nil / no baseline + hrvBaseline: hrvBase, + rhrBaseline: baselines.restingHR, + respBaseline: baselines.resp, + sleepPerf: sleepPerf, + skinTempDev: skinTempDevC) // symmetric penalty; drops + renormalizes when nil + // Driver breakdown from the identical inputs; omits any missing term, never faked. + chargeDrivers = RecoveryScorer.chargeDrivers( + hrv: hrvVal, + rhr: Double(rhrVal), + resp: respRateDaily, hrvBaseline: hrvBase, rhrBaseline: baselines.restingHR, respBaseline: baselines.resp, - sleepPerf: sleepPerf) + sleepPerf: sleepPerf, + skinTempDev: skinTempDevC) } + // A5: skin temp as a RELATIVE deviation marker (trend, not a clinical absolute). nil + // when no deviation is available (no baseline yet / not worn) so the UI shows nothing. + let skinTempRelative = RecoveryScorer.skinTempRelative(deviationC: skinTempDevC) - // ── Strain (day cardiovascular load over the full HR window) ────────── + // ── Strain / "Effort" (cardiovascular load over the full CALENDAR day) ── + // Integrate dayHr ([localMidnight, localMidnight+24h), clamped to `now` for today) when the + // caller supplies it, so Effort covers the WHOLE day — an afternoon/evening workout lands in + // today's Effort same-day instead of being cut off at the night window's ≈ noon bound, and + // the prior evening's HR (the night window's −30h tail) no longer bleeds in. Falls back to the + // night `hr` for pure-function callers/tests. let effMaxHR: Double? = maxHROverride ?? (profile.age > 0 ? StrainScorer.tanakaHRmax(age: profile.age) : nil) let restForStrain = restingHRDaily.map(Double.init) ?? StrainScorer.defaultRestingHR - let strain = StrainScorer.strain(hr, maxHR: effMaxHR, restingHR: restForStrain, + let strain = StrainScorer.strain(dayHr ?? hr, maxHR: effMaxHR, restingHR: restForStrain, sex: profile.sex) // ── Workouts ────────────────────────────────────────────────────────── + // Detect over the full CALENDAR day (dayHr/dayGravity) when the caller supplies it, so a + // current-day afternoon/evening workout is caught on its own day rather than lagging until + // a later pass re-reads it through the next night window (which ends at ≈ noon). Falls back + // to the night window for pure-function callers/tests. restingHR still comes from the night's + // sleep sessions; nil → WorkoutDetector derives it from the day's own HR floor. let workouts = WorkoutDetector.detect( - hr: hr, gravity: gravity, + hr: dayHr ?? hr, gravity: dayGravity ?? gravity, restingHR: restingHRDaily.map(Double.init), maxHR: maxHROverride, age: profile.age > 0 ? profile.age : nil, profile: profile) + // ── Steps (APPROXIMATE) ─────────────────────────────────────────────── + // step_motion_counter@57 is a CUMULATIVE u16 running counter (it climbs while you move, holds + // flat when still, and wraps at 65536). The daily total is the SUM of WRAP-AWARE increments of + // that counter across the time-ordered 1 Hz records: delta = (cur - prev) & 0xFFFF. The first + // record has no predecessor (contributes 0). The day's read window may include adjacent-day + // samples, so filter to the LOCAL-day key dayString(ts, tzOffset)==day first (#277). + // + // Reading byte @57 ALONE and summing it (the old bug, #132/#276/#316: exzanimo saw ~24× too + // many steps) both ignored the high byte and summed a running total — exploding the count to + // ~10M/day. Decoding the full u16 and summing wrap-aware DELTAS yields a sane ~14k. ESTIMATE + // only — not cloud/clinical parity. + let stepsTotal: Int? = { + // Prefer the full-calendar-day stream for the additive total; fall back to the + // night-window stream when the caller didn't supply one (pure-function callers/tests). + let sorted = (daySteps ?? steps).filter { tsInDay($0.ts) }.sorted { $0.ts < $1.ts } + if sorted.count < 2 { return nil } + // A delta this large is a big time-gap / disconnect boundary between sync sessions (or a + // firmware reboot, byte-indistinguishable from a wrap), NOT real steps — drop it so gaps + // don't inflate the total. Real 1 Hz motion never ticks this fast between adjacent records. + let maxStepDelta = 512 + var total = 0 + for i in 1..= 1 && delta < maxStepDelta { total += delta } // ignore a delta >= 512 (gap/reset) + } + if total <= 0 { return nil } + // @57 counts motion ticks, not validated steps — the 5/MG counter overcounts. Divide + // by the user-calibrated ticks-per-step (default 1.0 = raw pass-through; floor 0.5 so + // a bad pref can at most double, never explode, the total). (#139) + let scaled = Int((Double(total) / max(profile.stepTicksPerStep, 0.5)).rounded()) + return scaled > 0 ? scaled : nil + }() + + // ── Daily calories (APPROXIMATE, HR-only whole-day estimate) ────────── + // Whole-day active+resting energy from the full HR window, using the same resting/active + // per-second model the per-workout estimate uses (resting BMR below activeThreshold, Keytel + // active above). effMaxHR + restingHRDaily are the same effective HRmax / resting baseline + // strain uses. Nil when there is no HR. A heart-rate ESTIMATE — not cloud/clinical parity. + // Whole-day additive totals (steps above, calories here) are summed over the full LOCAL + // calendar day supplied by the caller (dayHr / daySteps), NOT the ~42h sleep-detection + // window — which, anchored to the current time-of-day, would drop a past day's late hours + // and double-count seconds shared with adjacent days. The filter uses the LOCAL-day key + // (dayString(ts, tzOffset)) so it agrees with the bucket (#277). Fall back to the + // night-window hr for pure-function callers that don't supply dayHr. Strain keeps the full + // window (bounded log). + let dayHrFiltered = (dayHr ?? hr).filter { tsInDay($0.ts) } + let activeKcalEst: Double? = dayHrFiltered.isEmpty ? nil : Calories.estimateDayCalories( + dayHrFiltered, profile: profile, hrmax: effMaxHR, + restingHR: restingHRDaily.map(Double.init)) + // ── Assemble DailyMetric ────────────────────────────────────────────── let daily = DailyMetric( day: day, @@ -175,8 +598,10 @@ public enum AnalyticsEngine { strain: strain, exerciseCount: workouts.count, spo2Pct: nil, - skinTempDevC: nil, - respRateBpm: nil) + skinTempDevC: skinTempDevC, + respRateBpm: respRateDaily, + steps: stepsTotal, + activeKcalEst: activeKcalEst) _ = sleepStart; _ = sleepEnd // available for callers wiring sleep_start/end columns // ── Cache rows ──────────────────────────────────────────────────────── @@ -189,7 +614,266 @@ public enum AnalyticsEngine { stagesJSON: encodeStages(s.stages)) } + // ── Per-session per-epoch motion (H8) ───────────────────────────────── + // The strap's per-epoch movement on the SAME 30 s grid as each session's stages, for the caller to + // persist beside `stagesJSON`. A session that can't grid (too little gravity) is omitted, so the + // caller persists NULL there rather than a fabricated zero series. + var sessionMotionByStart: [Int: [Double]] = [:] + for s in matched { + let motion = SleepStager.sessionEpochMotion(start: s.start, end: s.end, grav: gravity) + if !motion.isEmpty { sessionMotionByStart[s.start] = motion } + } + + // ── Per-session per-epoch BAND sleep_state (#175) ───────────────────── + // Grid the strap's OWN band sleep_state (the SAME `bandSleepState` samples the H7 guard consumes) + // onto each matched session's 30 s epochs, for the caller to persist beside `stagesJSON`. This is + // the source the band-state chain lacked (persist → next pass's H7 re-onset CONFIRM). A session + // whose window carries no band samples is omitted (no key) → the caller persists NULL, an absent + // signal stays absent. Empty on a WHOOP 4.0 (no band_sleep_state stream). The band code is carried + // verbatim; it NEVER overrides the derived hypnogram, only confirms a borderline morning re-onset. + var sessionSleepStateByStart: [Int: [Int]] = [:] + if !bandSleepState.isEmpty { + for s in matched { + let states = SleepStager.sessionEpochSleepState(start: s.start, end: s.end, + sleepState: bandSleepState) + if !states.isEmpty { sessionSleepStateByStart[s.start] = states } + } + } + + // ── Per-score confidence tiers ──────────────────────────────────────── + let chargeConfidence = ScoreConfidence.charge(recovery: recovery, hrvBaseline: baselines.hrv) + let effortConfidence = ScoreConfidence.effort(strain: strain, hrSampleCount: hr.count) + // Rest confidence with H9: downgrade a high-efficiency night whose deep+REM share is implausibly low + // to low-confidence (likely staging miss) — honest, no faked stages. tstS/efficiency are the + // main-group totals computed above; restorative = deepS + remS. + let restConfidence = ScoreConfidence.rest(hasSession: !matched.isEmpty, + hasStagedSleep: hasStagedSleep, + asleepSeconds: tstS, restorativeSeconds: deepS + remS, + efficiency: efficiency) + return DayResult(daily: daily, sleepSessions: matched, cachedSleep: cachedSleep, - workouts: workouts, recovery: recovery, strain: strain) + workouts: workouts, recovery: recovery, strain: strain, + nightlySkinTempC: nightlySkinTempC, + restScore: restScore, + chargeConfidence: chargeConfidence, + effortConfidence: effortConfidence, + restConfidence: restConfidence, + sessionMotionByStart: sessionMotionByStart, + sessionSleepStateByStart: sessionSleepStateByStart, + chargeDrivers: chargeDrivers, + skinTempRelative: skinTempRelative) + } + + // MARK: - Rest composite (Charge/Effort/Rest) + + /// The 0–100 Rest score. Composite of four published-sleep-quality components: + /// - duration vs personal need (0.50): hours asleep ÷ need, clamped to 1.0. + /// - efficiency (0.20): asleep / in-bed, already in [0,1]. + /// - restorative share (0.20): (deep + REM) ÷ asleep, clamped to a 0.50 target + /// (≈50% deep+REM is "full marks"; healthy adults sit ~40–50%). + /// - consistency (0.10): sleep/wake regularity in [0,1]; a single day carries no + /// regularity signal, so the caller supplies it from history — nil → neutral 0.5. + /// All sub-scores clamp to [0,1]; the weighted sum scales to [0,100]. Kept + /// dependency-free + constant-explicit so the Kotlin mirror is byte-identical. + /// + /// DEEP-sleep honesty (Reddit HRV/sleep report): pooling deep+REM let a night with normal REM + /// but almost no DEEP still earn near-full restorative credit (so Rest read 95+ with little deep). + /// When the caller supplies the DEEP split (`deepSeconds`), the restorative sub-score is scaled by + /// a gentle deep-adequacy factor: full credit once deep ≥ `deepShareTarget` (~13% of asleep is the + /// healthy floor), ramping to `deepFloorFactor` (0.5 — never zeroed) as deep → 0. So a near-zero-deep + /// night loses up to half the 0.20 restorative term (~10 pts) — honest, not tanking, no fabricated + /// stages. Deep unknown (`deepSeconds == nil`, e.g. an imported night with only a pooled total) → + /// factor 1.0, identical to the prior pooled behaviour. + public enum Rest { + /// Default personal sleep need (hours) before the caller refines it. + public static let defaultNeedHours: Double = 8.0 + /// "Full marks" restorative (deep+REM) share of asleep time. + public static let restorativeTarget: Double = 0.50 + /// Deep-sleep share of asleep time that earns FULL restorative credit (~13% is the healthy + /// floor for adults; below it the restorative term is scaled down toward `deepFloorFactor`). + public static let deepShareTarget: Double = 0.13 + /// The most the restorative term is scaled down by when deep is ~absent — half, never zero, + /// so a low-deep night reads honestly without the whole night tanking. + public static let deepFloorFactor: Double = 0.5 + /// Neutral consistency when the caller supplies no regularity signal. + public static let neutralConsistency: Double = 0.5 + + public static let wDuration: Double = 0.50 + public static let wEfficiency: Double = 0.20 + public static let wRestorative: Double = 0.20 + public static let wConsistency: Double = 0.10 + + /// Build the composite. `tstSeconds` = total sleep time, `restorativeSeconds` = deep+REM + /// seconds, `deepSeconds` = deep-stage seconds (nil → no deep-adequacy adjustment, pooled + /// behaviour). Returns a value in [0,100]. + public static func composite(tstSeconds: Double, + inBedSeconds: Double, + efficiency: Double, + restorativeSeconds: Double, + needHours: Double, + consistency: Double?, + deepSeconds: Double? = nil) -> Double { + func clamp01(_ x: Double) -> Double { max(0.0, min(1.0, x)) } + + let needSeconds = max(needHours, 0.1) * 3600.0 + let durationScore = clamp01(tstSeconds / needSeconds) + let efficiencyScore = clamp01(efficiency) + // Deep-adequacy factor in [deepFloorFactor, 1]: 1.0 once deep ≥ target share, ramping + // down to the floor as deep → 0. nil deep (unknown split) ⇒ 1.0 (no adjustment). + let deepFactor: Double = { + guard let deep = deepSeconds, tstSeconds > 0, deepShareTarget > 0 else { return 1.0 } + let adequacy = clamp01((deep / tstSeconds) / deepShareTarget) + return deepFloorFactor + (1.0 - deepFloorFactor) * adequacy + }() + let restorativeScore = tstSeconds > 0 + ? clamp01((restorativeSeconds / tstSeconds) / restorativeTarget) * deepFactor + : 0.0 + let consistencyScore = clamp01(consistency ?? neutralConsistency) + + let weighted = wDuration * durationScore + + wEfficiency * efficiencyScore + + wRestorative * restorativeScore + + wConsistency * consistencyScore + // weighted is in [0,1] (weights sum to 1). Scale to [0,100] and round to 2dp. + return (weighted * 10000.0).rounded() / 100.0 + } + + /// Rest composite [0,100] derived from a persisted `DailyMetric` (the pass-2 / display path — + /// the raw streams are gone, but the night's totals remain). nil when there's no sleep. + /// Single source of truth so the persisted `sleep_performance` series and the Charge + /// "Rest quality" term agree. `consistency` is the caller's regularity signal (nil → neutral). + public static func composite(daily d: DailyMetric, needHours: Double = defaultNeedHours, + consistency: Double? = nil) -> Double? { + guard let tstMin = d.totalSleepMin, tstMin > 0, let eff = d.efficiency else { return nil } + let tstSec = tstMin * 60.0 + let deepSec = (d.deepMin ?? 0) * 60.0 + let restorativeSec = (d.deepMin ?? 0) * 60.0 + (d.remMin ?? 0) * 60.0 + return composite(tstSeconds: tstSec, inBedSeconds: tstSec / max(eff, 0.01), + efficiency: eff, restorativeSeconds: restorativeSec, + needHours: needHours, consistency: consistency, + deepSeconds: deepSec) + } + } + + /// Round to 2 decimal places (matches the imported/demo skin-temp deviation precision). + static func round2(_ v: Double) -> Double { (v * 100.0).rounded() / 100.0 } + + /// Min worn, in-bed skin-temp samples (1 Hz ⇒ seconds) before a nightly mean is trusted. + /// ~5 min guards against a few stray samples fabricating a baseline value. + public static let minSkinTempSamples = 300 + + /// Plausible worn skin-temperature range (°C). Off-wrist/charging samples drift to ambient + /// and are excluded; the strap's own decode gate is the looser 5–45. + static let skinTempMinC = 28.0 + static let skinTempMaxC = 42.0 + + /// Wear-gated mean in-bed skin temperature (°C) for the night, or nil when too few worn + /// samples. A sample counts when (a) its timestamp falls inside a detected in-bed `sessions` + /// span, (b) a concurrent HR sample reads a worn, alive BPM (the strap streams HR only + /// on-wrist), and (c) the value is in the plausible worn range — so an on-charger interval + /// drifting to ambient can't poison the nightly mean. + /// + /// The raw→°C conversion is DEVICE-FAMILY-AWARE (#938): 5/MG stores CENTIDEGREES in + /// skin_temp_raw@73 (°C = raw/100 — the Whoop5HistoricalTests captures read worn 3057 = 30.6 °C / + /// off-wrist 2247 = 22.5 °C, physically right on both ends), but the WHOOP 4.0 v24 field@72 is a + /// RAW ADC on a different scale — running it through /100 read every worn 4.0 night ~8 °C, below + /// the 28 °C worn gate, so kept=0 and skin temp + the illness signal vanished (issue #938). The + /// shared `skinTempCelsius(raw:family:)` (WhoopProtocol) picks the right scale; `family` defaults + /// to `.whoop5` so every existing 5/MG + pure-function caller is byte-identical. All values + /// APPROXIMATE. + static func wornNightlySkinTempC(_ sessions: [SleepSession], + hr: [HRSample], + skinTemp: [SkinTempSample], + family: DeviceFamily = .whoop5, + minSamples: Int = minSkinTempSamples) -> Double? { + skinTempFunnel(sessions, hr: hr, skinTemp: skinTemp, family: family, minSamples: minSamples).mean + } + + // MARK: - Skin-temp funnel diagnostic (#752) + + // Skin temp coming out 0/absent on a WHOOP 4.0 (or any) night is opaque: the user can't tell whether + // there were no samples at all, every sample fell outside a detected in-bed span, none were worn (no + // concurrent live HR), every value was outside the plausible worn range, or there simply weren't enough + // survivors to trust a mean. This pure, READ-ONLY funnel re-runs the SAME gates `wornNightlySkinTempC` + // applies and counts where samples dropped - WITHOUT changing the mean or any score - so an absent + // skin-temp can be triaged ("0 raw samples in window" vs "1842 samples but none worn" vs "all out of the + // 28–42 °C range - likely off-wrist/charging"). It is a triage surface, logged by the caller, never a + // scoring change. Mirrors the REM-funnel diagnostic shape (#688). (#752) + + /// Why nightly skin temp funneled toward absent for one night. Counts are over the night's raw skin-temp + /// samples; each sample is attributed to the FIRST gate that dropped it, in the SAME order + /// `wornNightlySkinTempC` applies (not-worn → out-of-window → out-of-range → kept), so the four drop + /// buckets plus `kept` sum to `totalSamples`. Pure + deterministic; shares the exact gate logic with the + /// real computation, so it explains the SAME mean the app uses. (#752) + public struct SkinTempFunnelDiagnostic: Equatable, Sendable { + /// Raw skin-temp samples seen for the night (the funnel's mouth). + public let totalSamples: Int + /// Dropped because no concurrent worn-HR second (the strap streams HR only on-wrist). + public let droppedNotWorn: Int + /// Worn, but the sample's timestamp fell in no detected in-bed session span. + public let droppedOutOfWindow: Int + /// Worn + in-window, but the value was outside the plausible worn range (28–42 °C) - likely + /// off-wrist/charging drift to ambient. + public let droppedOutOfRange: Int + /// Samples that passed every gate and fed the nightly mean. + public let kept: Int + /// Minimum kept samples required before a nightly mean is trusted (the last gate). + public let minSamples: Int + /// The nightly mean (°C) the gates produced, or nil when `kept < minSamples` (or no input). + public let mean: Double? + + public init(totalSamples: Int, droppedNotWorn: Int, droppedOutOfWindow: Int, + droppedOutOfRange: Int, kept: Int, minSamples: Int, mean: Double?) { + self.totalSamples = totalSamples; self.droppedNotWorn = droppedNotWorn + self.droppedOutOfWindow = droppedOutOfWindow; self.droppedOutOfRange = droppedOutOfRange + self.kept = kept; self.minSamples = minSamples; self.mean = mean + } + + /// True when the night produced no usable mean - the case this diagnostic exists to triage. + public var isAbsent: Bool { mean == nil } + + /// One human-readable line for the caller to LOG. No I/O here - the engine stays pure. + public var summary: String { + "skin-temp-funnel: \(totalSamples) samples → kept \(kept)/\(minSamples) " + + "(mean=\(mean.map { String(format: "%.2f°C", $0) } ?? "absent")); " + + "dropped[notWorn=\(droppedNotWorn), outOfWindow=\(droppedOutOfWindow), " + + "outOfRange=\(droppedOutOfRange)]" + } + } + + /// Read-only skin-temp funnel for one night (#752). Re-runs the SAME wear/window/range gates + /// `wornNightlySkinTempC` uses (and produces the IDENTICAL mean), additionally counting where each + /// sample dropped, so an absent skin temp is self-explaining. The public `wornNightlySkinTempC` is a + /// thin wrapper over this, so the two can never disagree. Pure + deterministic. (#752) + public static func skinTempFunnel(_ sessions: [SleepSession], + hr: [HRSample], + skinTemp: [SkinTempSample], + family: DeviceFamily = .whoop5, + minSamples: Int = minSkinTempSamples) -> SkinTempFunnelDiagnostic { + let total = skinTemp.count + // No sessions ⇒ every sample is out of window; no samples ⇒ an empty funnel. Either way the mean is + // nil, exactly as `wornNightlySkinTempC`'s early return produced before. + if sessions.isEmpty || skinTemp.isEmpty { + return SkinTempFunnelDiagnostic(totalSamples: total, droppedNotWorn: 0, + droppedOutOfWindow: sessions.isEmpty ? total : 0, + droppedOutOfRange: 0, kept: 0, minSamples: minSamples, mean: nil) + } + var wornSeconds = Set(minimumCapacity: hr.count) + for h in hr where (30...220).contains(h.bpm) { wornSeconds.insert(h.ts) } + var sum = 0.0 + var kept = 0 + var notWorn = 0, outOfWindow = 0, outOfRange = 0 + for t in skinTemp { + if !wornSeconds.contains(t.ts) { notWorn += 1; continue } + if !sessions.contains(where: { t.ts >= $0.start && t.ts <= $0.end }) { outOfWindow += 1; continue } + let c = skinTempCelsius(raw: t.raw, family: family) // #938: family-aware (5/MG=raw/100, 4.0=raw ADC map) + if c < skinTempMinC || c > skinTempMaxC { outOfRange += 1; continue } + sum += c + kept += 1 + } + let mean = kept >= minSamples ? sum / Double(kept) : nil + return SkinTempFunnelDiagnostic(totalSamples: total, droppedNotWorn: notWorn, + droppedOutOfWindow: outOfWindow, droppedOutOfRange: outOfRange, + kept: kept, minSamples: minSamples, mean: mean) } } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsMemo.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsMemo.swift new file mode 100644 index 0000000000..b582641e63 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsMemo.swift @@ -0,0 +1,105 @@ +import Foundation + +// AnalyticsMemo.swift — a tiny, bounded, thread-safe compute cache for the array-heavy analytics +// entry points (v7.0.2 perf hardening, iOS/macOS twin of the Android A1 pass). +// +// WHY THIS EXISTS (#707 — a real OOM on Android, mirrored defensively here): the heavy engines +// (sleep staging, per-day scoring, the history-walking readiness/stress reads) are PURE functions +// that the app calls REPEATEDLY with byte-identical inputs — the post-sync scoring loop re-runs +// them across passes, and a SwiftUI `body` re-evaluation (the iOS equivalent of a Compose +// recompose) re-reads any computed property that calls them. Each call re-allocates large transient +// per-second dictionaries before collapsing to a SMALL result. Recomputing the same night again and +// again is what exhausts the heap, even before any scroll. +// +// The fix mirrors the Android A1 rules EXACTLY: +// • compute-once-cache: a result is computed at most once per (engine, input-fingerprint); +// • FULL key: the fingerprint covers every input that can change the output, so distinct inputs +// never collide onto a stale result (correctness over a marginally smaller key); +// • BOUNDED: the cache holds at most `capacity` entries and evicts the oldest INSERTION first, so +// the cache itself can never be the thing that OOMs; +// • NO retained raw arrays: only the small `Value` result + its `Key` fingerprint are stored — the +// multi-hour input streams are never held past the call; +// • invalidate on edit: keys fold in the inputs a user edit changes (e.g. the sleep-V2 toggle, +// the locked bed/wake window), so an edited night re-keys to a fresh compute, never a stale hit. +// +// Appearance / behaviour are byte-identical: a cache HIT returns the exact value a recompute would, +// and a MISS runs the unchanged engine. This file only adds a lookup in front of pure functions. + +/// A bounded, thread-safe memoization cache. `Key` is a cheap value-type fingerprint of the inputs; +/// `Value` is the engine's small result. Eviction is insertion-order (FIFO) once `capacity` is +/// reached — the access pattern here is "the same night/day re-requested", so the hot set stays +/// resident and the cap simply stops unbounded growth across a long session. +final class AnalyticsMemoCache: @unchecked Sendable { + private let lock = NSLock() + private var store: [Key: Value] = [:] + private var order: [Key] = [] // insertion order, for FIFO eviction + private let capacity: Int + + init(capacity: Int) { + // At least 1 so the cache is never degenerate; the callers pass small, deliberate caps. + self.capacity = max(1, capacity) + } + + /// Return the cached value for `key`, or compute it with `build`, store it (evicting the oldest + /// entry if at capacity), and return it. `build` runs OUTSIDE the lock so a slow compute never + /// serialises other engines; a benign duplicate compute under contention is acceptable (the + /// result is deterministic, and the second writer just overwrites with the identical value). + func value(_ key: Key, _ build: () -> Value) -> Value { + lock.lock() + if let hit = store[key] { lock.unlock(); return hit } + lock.unlock() + + let computed = build() + + lock.lock() + if let raced = store[key] { + // Another thread computed the same key while we were building — keep the existing entry + // (identical value, deterministic function) rather than re-inserting/re-ordering. + lock.unlock() + return raced + } + store[key] = computed + order.append(key) + if order.count > capacity { + let evict = order.removeFirst() + store.removeValue(forKey: evict) + } + lock.unlock() + return computed + } +} + +/// A cheap, allocation-light fingerprint of a sample stream: its count, the first/last timestamps, and a +/// checksum folded over EVERY sample's ts + quantised value. Folding every element is deliberate (#707 +/// audit + Android parity): we already walk the stream once to count it, so the full fold is the SAME O(n) +/// — and a strided subset could miss a changed interior sample (two different nights that share count + +/// edge timestamps but differ in the middle would collide onto a STALE cached result). Walking every +/// element costs nothing the count didn't already, and the win we're protecting (not re-running the heavy +/// staging/scoring engines) dwarfs this fold, so correctness wins outright. The Android twin folds every +/// sample identically. +struct StreamFingerprint: Hashable { + let count: Int + let firstTs: Int + let lastTs: Int + let checksum: UInt64 + + /// Build from any sample sequence given a `ts` accessor and a `quant` accessor that maps a sample to an + /// integer carrying its value (e.g. bpm, or a scaled gravity component). Folds EVERY sample — O(n), the + /// same order as the `count` it already needs — so no interior change can alias onto a stale entry. + static func of(_ samples: S, ts: (S.Element) -> Int, + quant: (S.Element) -> Int) -> StreamFingerprint { + var count = 0 + var firstTs = 0, lastTs = 0 + var sum: UInt64 = 1469598103934665603 // FNV offset basis + for e in samples { + let t = ts(e) + if count == 0 { firstTs = t } + lastTs = t + count += 1 + sum = (sum ^ UInt64(bitPattern: Int64(t))) &* 1099511628211 + sum = (sum ^ UInt64(bitPattern: Int64(quant(e)))) &* 1099511628211 + } + if count == 0 { return StreamFingerprint(count: 0, firstTs: 0, lastTs: 0, checksum: 0) } + return StreamFingerprint(count: count, firstTs: firstTs, lastTs: lastTs, checksum: sum) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector+Trace.swift new file mode 100644 index 0000000000..62dd6c26d7 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector+Trace.swift @@ -0,0 +1,214 @@ +import Foundation +import WhoopProtocol + +// AutoWorkoutDetector+Trace.swift - the Workouts & GPS test-mode auto-detect trace + line formatters. +// +// detectTrace(...) is the side-effect-free twin of AutoWorkoutDetector.detect(...): it returns the SAME +// [DetectedWorkout] detect would (it reuses detect verbatim), plus a trace that names the detector's inputs +// (HR sample count, resting floor), the thresholds it applied (elevated margin, sustained minutes, dip / +// merge / motion-confirm constants), and WHY each candidate window was offered or dropped (too short, +// motion-not-confirmed, overlaps a saved session). So a "my workout went missing / auto-detect didn't fire" +// report shows exactly which gate kept or dropped each window. +// +// WorkoutsTrace adds the line formatters the app-target emitters use for the session lifecycle, the GPS-fix +// count and the cross-source dedup decisions (the app layer owns the live state, this owns the line shape so +// the two platforms read identically and a fixture pins them). Everything here is pure, no clock, no I/O, no +// PII (counts / bpm / seconds / sport keys only). The Workouts test mode gates each call behind +// TestCentre.active(.workouts) at the call site; when the mode is off it is never called, so there is zero +// cost. No em-dashes. The Kotlin twin is AutoWorkoutDetectorTrace / WorkoutsTrace. + +extension AutoWorkoutDetector { + + /// Side-effect-free diagnostic twin of `detect(...)`: returns the SAME `[DetectedWorkout]` detect would, + /// plus the trace. The returned windows ARE `detect(...)`'s verbatim, so the trace can never disagree + /// with what the Today card actually suggests. The trace logs the inputs + the thresholds, then walks the + /// detector's own gates (sustained-minutes, motion-confirm, saved-overlap) to name why each merged window + /// survived or dropped, mirroring the algorithm exactly. The Kotlin twin is + /// `AutoWorkoutDetectorTrace.detectTrace`. + /// + /// - Parameters mirror `detect(...)` exactly. `path` tags the call ("autoDetect" / "manualReview") so a + /// report shows which entry point produced it. + public static func detectTrace(hr: [(ts: Int, bpm: Int)], + restingBpm: Int?, + motion: [MotionPoint]? = nil, + savedSpans: [SavedWorkoutSpan] = [], + path: String = "autoDetect") + -> (results: [DetectedWorkout], trace: [String]) { + + // The result the Today card reads, verbatim, so the trace cannot diverge from it. + let results = detect(hr: hr, restingBpm: restingBpm, motion: motion, savedSpans: savedSpans) + + var lines: [String] = [] + let floor = (restingBpm ?? defaultRestingHR) + elevatedMarginBPM + let hasMotion = !(motion?.isEmpty ?? true) + + // Inputs the detector saw. + lines.append("autoDetect path=\(path) hrSamples=\(hr.count) " + + "restingBpm=\(restingBpm.map(String.init) ?? "default(\(defaultRestingHR))") " + + "elevatedFloor=\(floor)bpm motion=\(hasMotion ? "supplied" : "hrOnly") savedSpans=\(savedSpans.count)") + + // Thresholds applied (the autoDetectThresholds capture). Stated once so a report carries the + // calibration the windows were judged against. + lines.append("autoDetect thresholds elevatedMargin=\(elevatedMarginBPM)bpm " + + "minSustainedMin=\(minSustainedMin) maxDipS=\(maxDipS) mergeGapS=\(mergeGapS) " + + "motionConfirmMean=\(motionConfirmMean)") + + // Rebuild the SAME merged windows the detector forms (sustained spans tolerating dips, then merge), + // so we can name why each survived or dropped WITHOUT changing the returned `results`. This mirrors + // detect(...)'s steps 1-4 exactly; the per-window verdict below mirrors steps 5-6. + let seg = hr.sorted { $0.ts < $1.ts } + if seg.isEmpty { + lines.append("autoDetect result windows=0 (no HR samples)") + return (results, lines) + } + + var spans: [(start: Int, end: Int)] = [] + var spanStart: Int? = nil + var spanEnd = 0 + var dipStart: Int? = nil + func closeSpan() { + if let s = spanStart, Double(spanEnd - s) >= minSustainedMin * 60.0 { spans.append((s, spanEnd)) } + spanStart = nil + dipStart = nil + } + for sample in seg { + if sample.bpm >= floor { + if spanStart == nil { spanStart = sample.ts } + spanEnd = sample.ts + dipStart = nil + } else if spanStart != nil { + if dipStart == nil { dipStart = sample.ts } + if let d = dipStart, sample.ts - d > maxDipS { closeSpan() } + } + } + closeSpan() + + if spans.isEmpty { + lines.append("autoDetect why=noSustainedSpan " + + "(no contiguous run held >=\(minSustainedMin)min above \(floor)bpm)") + lines.append("autoDetect result windows=0") + return (results, lines) + } + + // Merge spans whose gap is strictly < mergeGapS (same as detect step 4). + var merged: [(start: Int, end: Int)] = [] + var curStart = spans[0].start + var curEnd = spans[0].end + for k in 1..= start && $0.ts <= end }.map { $0.intensity } + let meanMotion = inWin.isEmpty ? 0.0 : inWin.reduce(0.0, +) / Double(inWin.count) + if meanMotion < motionConfirmMean { + lines.append("autoDetect window durMin=\(durMin) verdict=dropped why=motionNotConfirmed " + + "(mean=\((meanMotion * 1000).rounded() / 1000) < \(motionConfirmMean))") + continue + } + } + lines.append("autoDetect window durMin=\(durMin) verdict=offered") + } + lines.append("autoDetect result windows=\(results.count) " + + "(offered the most recent that is not saved or dismissed)") + return (results, lines) + } +} + +/// Pure line formatters + the live-readout parser for the Workouts & GPS test mode. The app-target emitters +/// (AppModel session lifecycle, GpsWorkoutRecorder fixes, Repository cross-source dedup) own the live state; +/// these own the line SHAPE so both platforms read identically and a fixture pins them. WorkoutsReadout +/// parses the `.workouts`-tagged log tail back into the `lastSessionSummary` id the panel binds. No state, +/// no side effects, no PII (counts / seconds / sport keys only). No em-dashes. The Kotlin twin is WorkoutsTrace. +public enum WorkoutsTrace { + + /// A session-lifecycle line. `event` is "start" / "end" / "discarded"; the counts are the captured HR + /// window size and (for an end) the duration + whether a GPS route landed, so the lifecycle of a missing + /// workout is visible end to end. Sport is the normalised key, never free text. + public static func sessionLine(event: String, + sportKey: String, + hrSamples: Int, + durationSec: Int? = nil, + gpsPoints: Int? = nil) -> String { + var line = "session event=\(event) sport=\(sportKey) hrSamples=\(hrSamples)" + if let durationSec { line += " durationSec=\(durationSec)" } + if let gpsPoints { line += " gpsPoints=\(gpsPoints)" } + return line + } + + /// A GPS-fix-progress line: the raw fixes seen, how many the accuracy / speed filter accepted, and the + /// running distance. So a route that under-records (a weak signal, a denied permission) is visible. + /// + /// `rawFixes` is OPTIONAL: macOS sees the pre-filter raw stream and passes a real count, so the line can + /// show a true accept rate. Android's LocationTracker pre-filters upstream, so the raw count is NOT + /// available at the GpsSession seam (every fix here is already accepted); it passes nil and the line + /// renders `rawFixes=n/a` rather than implying an accept rate the platform cannot actually measure. + public static func gpsLine(rawFixes: Int?, acceptedPoints: Int, distanceM: Double) -> String { + "gps rawFixes=\(rawFixes.map(String.init) ?? "n/a") accepted=\(acceptedPoints) " + + "distanceM=\(Int(distanceM.rounded())) (filter: accuracy+speed gate)" + } + + /// An engine detected-bout decision line: the IntelligenceEngine derives a workout bout from the raw HR + /// stream, then either PERSISTS it (source "-noop", sport "detected") or DROPS it because it overlaps a + /// real session the user already logged (manual / imported), so the same bout is never counted twice. + /// This is the "auto workout appeared then vanished" seam (#975): a bout can persist on one pass then be + /// dropped on the next once the manual row lands, and without this line the export shows NO workouts + /// trace for the auto path at all. `verdict` is "persisted" / "droppedOverlap"; `durMin` is the whole- + /// minute bout length; on a drop, `overlapSource` names the real row it collided with. No PII (a source + /// label + minutes + bpm only). Mirrors the Kotlin `WorkoutsTrace.detectedBoutLine`. + public static func detectedBoutLine(verdict: String, + durMin: Int, + avgBpm: Int, + overlapSource: String? = nil) -> String { + var line = "detectedBout verdict=\(verdict) durMin=\(durMin) avgBpm=\(avgBpm)" + if let overlapSource { line += " overlapSource=\(overlapSource)" } + return line + } + + /// A cross-source dedup decision line: two same-activity rows from different sources were collapsed to + /// the richer one. Reports the sources, the kept richness, and the overlap, so a "my workout shows twice" + /// or "the richer one disappeared" report shows exactly which pair merged and which won. + public static func dedupLine(sportKey: String, + keptSource: String, + droppedSource: String, + keptRichness: Int, + droppedRichness: Int) -> String { + "dedup sport=\(sportKey) kept=\(keptSource)(richness=\(keptRichness)) " + + "dropped=\(droppedSource)(richness=\(droppedRichness)) (same activity, richer kept)" + } +} + +/// Pure values for the Workouts & GPS live-readout panel. Parses the `.workouts`-tagged log tail the +/// emitters write, so the panel reflects exactly the last session without the app layer exposing new +/// published properties. No state, no side effects, no em-dashes. The Kotlin twin is the WorkoutsReadout object. +public enum WorkoutsReadout { + + /// The last session summary for the `lastSessionSummary` id: the most recent session-lifecycle line's + /// fragment (event + sport + counts), or nil when none is present. So the panel reads the same outcome + /// the lifecycle emitter wrote. + public static func lastSessionSummary(taggedTail: [String]) -> String? { + for line in taggedTail.reversed() { + if let r = line.range(of: "session ") { + let frag = String(line[r.upperBound...]).trimmingCharacters(in: .whitespaces) + if !frag.isEmpty { return frag } + } + } + return nil + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector.swift new file mode 100644 index 0000000000..bbe3210f6a --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector.swift @@ -0,0 +1,227 @@ +import Foundation +import WhoopProtocol + +// AutoWorkoutDetector.swift — opt-in MVP "did you just work out?" detector. +// +// Faithful Swift twin of android/.../com/noop/analytics/AutoWorkoutDetector.kt — the two MUST +// stay BYTE-PARITY on the detection logic (same thresholds, same span/merge/overlap rules, same +// outputs), verified by the mirrored unit tests on each platform. +// +// This is DELIBERATELY SEPARATE from `WorkoutDetector` (the exercise.py port that computes +// calories / zones / strain and writes the durable "detected" rows the IntelligenceEngine +// churns). This one is the lightweight, OPT-IN, NON-DESTRUCTIVE MVP that only ever SUGGESTS a +// workout via a dismissible Today card — it never writes a row on its own. The user taps "Save" +// to turn a suggestion into a manual workout, or X to dismiss it forever. +// +// The thresholds here are intentionally CONSERVATIVE (low sensitivity): a sustained ≥ 12-min +// elevation of HR ≥ resting + 30 bpm, brief (≤ 90 s) dips tolerated, near windows merged. Tuned +// to avoid false positives from stress / caffeine / a brief flight of stairs, at the cost of +// missing the odd short or gentle session — exactly right for a SUGGESTION you can decline. An +// OPTIONAL continuous motion signal, when one is readily available, is required as confirmation; +// with no motion series it runs HR-only. +// +// Pure / headless: no I/O, no clock. All ts/start/end are unix SECONDS. NOT medical advice. + +/// A candidate workout window the user can accept (Save) or reject (dismiss). All fields are +/// derived purely from the HR samples inside the window. Mirrors the Kotlin `DetectedWorkout`. +public struct DetectedWorkout: Equatable, Sendable { + public let startSec: Int + public let endSec: Int + public let avgBpm: Int + public let peakBpm: Int + /// Whole minutes, floor of (endSec - startSec) / 60 — what the prompt shows. + public let durationMin: Int + + public init(startSec: Int, endSec: Int, avgBpm: Int, peakBpm: Int, durationMin: Int) { + self.startSec = startSec + self.endSec = endSec + self.avgBpm = avgBpm + self.peakBpm = peakBpm + self.durationMin = durationMin + } +} + +/// A [startSec, endSec] span of an already-saved workout, used to exclude windows that overlap a +/// session the user has already logged (so we never re-suggest one). Mirrors the Kotlin +/// `Pair` saved-workout argument. +public struct SavedWorkoutSpan: Equatable, Sendable { + public let startSec: Int + public let endSec: Int + public init(startSec: Int, endSec: Int) { + self.startSec = startSec + self.endSec = endSec + } +} + +public enum AutoWorkoutDetector { + + // MARK: - Constants (keep byte-identical with the Kotlin twin) + + /// Elevated gate: bpm must be at least restingHR + this margin to count as "working". + public static let elevatedMarginBPM = 30 + /// A candidate must hold the elevated gate for a contiguous span of at least this long (12 min). + public static let minSustainedMin: Double = 12.0 + /// A dip below the gate no longer than this does NOT break the span (a red light, a sip of water). + public static let maxDipS = 90 + /// Two detected windows whose gap is strictly less than this are merged into one (5 min). + public static let mergeGapS = 5 * 60 + /// When an OPTIONAL continuous motion series is supplied, a window must ALSO show elevated motion + /// to qualify (confirmation). "Elevated motion" = the window's mean per-second motion intensity + /// (L2 gravity-delta) is at least this. Ignored entirely in HR-only mode. Matches the Kotlin twin. + public static let motionConfirmMean = 0.05 + /// Resting-HR fallback when the caller has no nightly RHR for the day. + public static let defaultRestingHR = 60 + + // MARK: - Inputs + + /// One motion-intensity reading aligned to the HR timeline (optional confirmation signal). + /// `intensity` is on the same L2-gravity-delta scale as `WorkoutDetector.activitySeries`. + /// (The Kotlin twin takes raw `GravitySample`s and derives this internally; the Swift call site + /// — `Repository.autoDetectCandidate` — has the gravity already decoded, so it passes the points.) + public struct MotionPoint: Equatable, Sendable { + public let ts: Int + public let intensity: Double + public init(ts: Int, intensity: Double) { + self.ts = ts + self.intensity = intensity + } + } + + /// Per-second motion intensity = L2 magnitude of the gravity change vs the previous record. + /// First row → 0. Empty input → []. Mirrors the Kotlin `motionIntensityByTs` (and + /// `WorkoutDetector.activitySeries`) so a caller can build the optional `motion` argument. + public static func motionPoints(_ gravity: [GravitySample]) -> [MotionPoint] { + if gravity.isEmpty { return [] } + let rows = gravity.sorted { $0.ts < $1.ts } + var out: [MotionPoint] = [] + out.reserveCapacity(rows.count) + var prev: GravitySample? = nil + for (i, row) in rows.enumerated() { + let intensity: Double + if i == 0, prev == nil { + intensity = 0.0 + } else if let p = prev { + let dx = row.x - p.x, dy = row.y - p.y, dz = row.z - p.z + intensity = (dx * dx + dy * dy + dz * dz).squareRoot() + } else { + intensity = 0.0 + } + out.append(MotionPoint(ts: row.ts, intensity: intensity)) + prev = row + } + return out + } + + // MARK: - Public API + + /// Detect candidate sustained-elevated-HR workout windows. + /// + /// Algorithm (kept byte-identical with the Kotlin twin): + /// 1. Sort HR ascending. Floor = restingHR + `elevatedMarginBPM`. A sample is "elevated" when + /// bpm >= floor. + /// 2. Grow a contiguous span across elevated samples. A run of NON-elevated samples is tolerated + /// (does not end the span) ONLY while the dip's wall-clock duration (from the first sub-threshold + /// sample) stays <= `maxDipS`; a longer dip closes the span. The span's [start, end] are the + /// first/last ELEVATED sample timestamps. + /// 3. Keep a span only when it lasts >= `minSustainedMin` (applied per-span, BEFORE merge). + /// 4. Merge two kept spans when the gap between them is strictly < `mergeGapS`. + /// 5. If a motion series is supplied, drop a window unless its mean motion intensity over the + /// window is >= `motionConfirmMean` (confirmation). With no motion series, HR-only — keep it. + /// 6. Drop a window that OVERLAPS any saved span (touching endpoints count) — never re-suggest one. + /// 7. Emit a `DetectedWorkout` per surviving window (avg/peak bpm + whole-minute duration). + /// + /// - Parameters: + /// - hr: the day's (or last day or two's) HR samples `[(ts, bpm)]`; any order; empty → []. + /// - restingBpm: the nightly resting HR for the day; nil → `defaultRestingHR` (60). + /// - motion: OPTIONAL continuous motion series for confirmation; nil/empty → HR-only. + /// - savedSpans: already-saved workout windows to exclude by overlap. + public static func detect(hr: [(ts: Int, bpm: Int)], + restingBpm: Int?, + motion: [MotionPoint]? = nil, + savedSpans: [SavedWorkoutSpan] = []) -> [DetectedWorkout] { + let seg = hr.sorted { $0.ts < $1.ts } + if seg.isEmpty { return [] } + + let floor = (restingBpm ?? defaultRestingHR) + elevatedMarginBPM + + // --- 1 + 2 + 3: grow sustained spans tolerating brief dips --- + // A span is [spanStart, spanEnd] over ELEVATED-sample timestamps. `dipStart` marks where the + // current sub-threshold run began (nil = not in a dip); a dip longer than maxDipS closes the span. + var spans: [(start: Int, end: Int)] = [] + var spanStart: Int? = nil + var spanEnd = 0 + var dipStart: Int? = nil + + func closeSpan() { + if let s = spanStart, Double(spanEnd - s) >= minSustainedMin * 60.0 { + spans.append((s, spanEnd)) + } + spanStart = nil + dipStart = nil + } + + for sample in seg { + if sample.bpm >= floor { + if spanStart == nil { spanStart = sample.ts } + spanEnd = sample.ts + dipStart = nil // the dip (if any) is bridged + } else if spanStart != nil { + // In a span: tolerate the dip until it runs longer than maxDipS. `dipStart` is the + // first sub-threshold sample of the current dip (set once, cleared on the next elevated). + if dipStart == nil { dipStart = sample.ts } + if let d = dipStart, sample.ts - d > maxDipS { closeSpan() } + } + } + closeSpan() + + if spans.isEmpty { return [] } + + // --- 4: merge spans whose gap is strictly < mergeGapS (spans are start-ascending by build) --- + var merged: [(start: Int, end: Int)] = [] + var curStart = spans[0].start + var curEnd = spans[0].end + for k in 1..= start && $0.ts <= end } + if window.isEmpty { continue } + + // 5: motion confirmation, only when a continuous motion series was supplied. + if let motionSeries = motionSeries { + let inWin = motionSeries.filter { $0.ts >= start && $0.ts <= end }.map { $0.intensity } + let meanMotion = inWin.isEmpty ? 0.0 : inWin.reduce(0.0, +) / Double(inWin.count) + if meanMotion < motionConfirmMean { continue } + } + + let bpms = window.map { $0.bpm } + let avg = Int((Double(bpms.reduce(0, +)) / Double(bpms.count)).rounded()) + let peak = bpms.max() ?? avg + let durMin = (end - start) / 60 + results.append(DetectedWorkout(startSec: start, endSec: end, + avgBpm: avg, peakBpm: peak, durationMin: durMin)) + } + return results + } + + /// Two closed [aStart, aEnd] / [bStart, bEnd] intervals overlap (touching endpoints count). + /// Mirrors the Kotlin `overlaps`. + static func overlaps(_ aStart: Int, _ aEnd: Int, _ bStart: Int, _ bEnd: Int) -> Bool { + aStart <= bEnd && bStart <= aEnd + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift index 7892d4fa64..dfc8bbb8a4 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift @@ -101,6 +101,50 @@ public enum Baselines { /// Missing-night count after which a baseline is marked stale. public static let staleDays: Int = 14 + // MARK: - Early-life anti-anchoring (Reddit HRV report) + // + // The original model seeds the center on the first valid night with spread pinned at the + // floor, then becomes "usable" at minNightsSeed. If those first few nights read artificially + // HIGH (a common cold-start artefact), three things compounded to lock the baseline high for + // ~2-3 weeks: (a) the seed fixed the mean high while spread sat at the floor; (b) the hard + // outlier gate then REJECTED the user's genuine LOWER nights (a true 54ms vs an anchored ~85ms + // baseline is >5× the floor spread → "seen but not folded"); (c) the still-tight spread made + // the z-score hypersensitive, crushing Charge to 1-2. + // + // The fix is conservative and principled: during the baseline's EARLY life let reality pull + // the center down quickly, THEN settle to the normal long-term smoothing. + // - Skip the hard-outlier rejection while the baseline is young (nValid below the threshold) + // OR while spread is still at the floor — so legitimate lower nights are never discarded + // before the spread has had a chance to widen to reflect them. + // - Use a faster effective center half-life for the first few nights so it tracks reality in + // days, not weeks, before relaxing back to halfLifeB. + // - Widen the effective spread used for Winsor clamping during early life so an honest lower + // night isn't clamped flat against a floor-tight band. + // Long-term behaviour (after earlyAdaptNights, once spread has lifted) is byte-identical to + // before, so the baseline stays smooth and non-jittery once it has settled. + + /// Valid-night count below which the baseline is treated as "young": fast center adaptation + /// and a suspended hard-outlier gate. Chosen so convergence happens in days, not weeks. + public static let earlyAdaptNights: Int = 8 + /// Center half-life (nights) used while the baseline is young — much faster than halfLifeB so a + /// high seed is pulled toward reality within days. + public static let earlyHalfLifeB: Double = 3.0 + /// Multiplier on spread for the Winsor clamp while young, so an honest lower night isn't clamped + /// flat against a floor-tight band before the spread has had a chance to widen. + public static let earlySpreadInflate: Double = 2.5 + + /// UserDefaults key for the manual HRV-baseline recalibration epoch (epoch SECONDS). + /// 0 / absent = no recalibration. Written by the Settings "Recalibrate HRV baseline" button. + public static let hrvBaselineEpochKey: String = "noop.hrvBaselineEpoch" + + /// UserDefaults key for the manual RECOVERY-baseline recalibration epoch (epoch SECONDS). + /// 0 / absent = no recalibration. This is the Charge-wide sibling of `hrvBaselineEpochKey`: HRV is + /// the dominant Charge driver and re-anchors on its own epoch today, while the resting-HR / + /// respiration / skin-temp baselines that also feed Charge re-anchor on THIS epoch. The Settings + /// "Recalibrate Charge baseline" button writes BOTH keys to now (see `recalibrateRecoveryBaselines`) + /// so the whole Charge build-up restarts cleanly. Same string on iOS UserDefaults + Android prefs. + public static let recoveryBaselineEpochKey: String = "noop.recoveryBaselineEpoch" + /// Default per-metric configurations (HRV, resting HR, respiration, skin temp). public static let metricCfg: [String: MetricCfg] = [ "hrv": MetricCfg(minVal: 5.0, maxVal: 250.0, floorSpread: 5.0, @@ -169,8 +213,17 @@ public enum Baselines { status: computeStatus(nValid: state.nValid, nightsSinceUpdate: m)) } - // Hard outlier rejection (only once seeded): seen, but not folded. - if state.nValid >= minNightsSeed { + // Is the baseline still "young"? While young we adapt faster and suspend the hard-outlier + // gate so genuine lower nights are never discarded before the spread reflects them. Tied to + // the valid-night count (NOT spread): a long flat history is settled even though its spread + // never lifted off the floor, and must still reject a wild one-off outlier. + let isYoung = state.nValid < earlyAdaptNights + + // Hard outlier rejection (only once seeded AND no longer young): seen, but not folded. + // Suspending this during early life is the core anti-anchoring fix — a high seed with a + // floor-tight spread would otherwise reject the user's real, lower readings as "outliers" + // (a true 54ms vs an anchored ~90ms baseline is >5× the floor spread). + if state.nValid >= minNightsSeed && !isYoung { let dev = abs(value - state.baseline) if dev > hardOutlierK * state.spread { return BaselineState(baseline: state.baseline, spread: state.spread, @@ -186,10 +239,15 @@ public enum Baselines { } // Step 1: Winsorized EWMA update. - let lo = state.baseline - winsorK * state.spread - let hi = state.baseline + winsorK * state.spread + // While young, widen the clamp band (inflate the effective spread) so an honest lower night + // isn't clamped flat against a floor-tight band, and use the faster early center half-life so + // the center tracks reality in days. Both relax to the normal values once settled. + let effSpread = isYoung ? state.spread * earlySpreadInflate : state.spread + let effLb = isYoung ? lambda(halfLife: earlyHalfLifeB) : lb + let lo = state.baseline - winsorK * effSpread + let hi = state.baseline + winsorK * effSpread let clamped = max(lo, min(hi, value)) - let newBaseline = lb * clamped + (1.0 - lb) * state.baseline + let newBaseline = effLb * clamped + (1.0 - effLb) * state.baseline // Spread uses the UNCLAMPED value so true deviations are tracked. let absDev = abs(value - newBaseline) @@ -212,6 +270,73 @@ public enum Baselines { nightsSinceUpdate: 0, status: .calibrating) } + /// Read the persisted manual-recalibration epoch (epoch SECONDS) for the HRV baseline. + /// 0 = no recalibration. The Settings "Recalibrate HRV baseline" button writes now-seconds here. + public static func hrvBaselineEpoch(_ defaults: UserDefaults = .standard) -> Double { + defaults.double(forKey: hrvBaselineEpochKey) + } + + /// Read the persisted manual-recalibration epoch (epoch SECONDS) for the wider RECOVERY baseline + /// (resting HR / respiration / skin temp). 0 = no recalibration. Written alongside the HRV epoch by + /// `recalibrateRecoveryBaselines`. A fold that wants to honour this passes it through the day-keyed + /// `foldHistory(_:dayKeys:cfg:baselineEpoch:)` overload exactly like the HRV path. + public static func recoveryBaselineEpoch(_ defaults: UserDefaults = .standard) -> Double { + defaults.double(forKey: recoveryBaselineEpochKey) + } + + /// Recalibrate every baseline that feeds Charge: drop the anchor so the ~4-night build-up restarts + /// from `now`. This is the single source of truth behind the Settings "Recalibrate Charge baseline" + /// button on all platforms — it writes `now` (epoch SECONDS) to BOTH the HRV epoch and the recovery + /// epoch, so HRV (the dominant driver, already wired) and the resting-HR / respiration / skin-temp + /// baselines re-anchor together. It does NOT delete any stored day: only the day from which the + /// baselines re-learn moves. After this the next baseline computation re-seeds from the first + /// on-or-after-`now` night, so Today honestly shows the calibrating/building state again. + /// - Parameters: + /// - now: the anchor instant (epoch SECONDS). Defaults to the current time. + /// - defaults: the store to write to (overridable for tests). + public static func recalibrateRecoveryBaselines(now: Double = Date().timeIntervalSince1970, + defaults: UserDefaults = .standard) { + defaults.set(now, forKey: hrvBaselineEpochKey) + defaults.set(now, forKey: recoveryBaselineEpochKey) + } + + /// Replay an ordered sequence of nightly values (oldest first) to build state, honouring a + /// manual recalibration `baselineEpoch` (epoch SECONDS; 0 = no recalibration). + /// + /// `dayKeys` runs parallel to `values` ("yyyy-MM-dd", same order/length). Any night whose day + /// STARTS before `baselineEpoch` is ignored entirely (NOT a skip-and-hold — it is dropped, so the + /// baseline re-seeds from the first on-or-after-epoch night). This lets the user reset a baseline + /// that anchored too high: tap Recalibrate, and the Charge baseline re-learns from tonight onward. + /// + /// When `baselineEpoch <= 0` (the default / no recalibration) this is byte-identical to the plain + /// `foldHistory(_:cfg:)`. When `baselineEpoch` is nil it is read from UserDefaults via + /// `hrvBaselineEpoch()` so callers that already use the HRV config get recalibration for free. + public static func foldHistory(_ values: [Double?], dayKeys: [String], cfg: MetricCfg, + baselineEpoch: Double? = nil) -> BaselineState { + let epoch = baselineEpoch ?? hrvBaselineEpoch() + guard epoch > 0 else { return foldHistory(values, cfg: cfg) } + + // Pre-build the day-start epoch (UTC) for each "yyyy-MM-dd" key once. + let fmt = DateFormatter() + fmt.calendar = Calendar(identifier: .gregorian) + fmt.timeZone = TimeZone(secondsFromGMT: 0) + fmt.dateFormat = "yyyy-MM-dd" + + var state: BaselineState? = nil + for (i, v) in values.enumerated() { + // Drop (not skip-and-hold) any night dated before the recalibration epoch. + if i < dayKeys.count, let d = fmt.date(from: dayKeys[i]), + d.timeIntervalSince1970 < epoch { + continue + } + state = update(state, value: v, cfg: cfg) + } + if let s = state { return s } + let seed = (cfg.minVal + cfg.maxVal) / 2.0 + return BaselineState(baseline: seed, spread: cfg.floorSpread, nValid: 0, + nightsSinceUpdate: 0, status: .calibrating) + } + // MARK: - Deviation /// Compute z / delta / ratio / in-normal-range for a value vs a baseline. diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/BatteryEstimator.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/BatteryEstimator.swift new file mode 100644 index 0000000000..8491f7292f --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/BatteryEstimator.swift @@ -0,0 +1,235 @@ +import Foundation + +/// "~X days left" for a strap, worked out from its battery state-of-charge (SoC) history (#713). Neither +/// the WHOOP app nor WHOOP's API ever give you a runtime estimate, but NOOP already banks a SoC time +/// series from the strap over BLE, so no manual logging is needed. We fit the recent DISCHARGE slope and +/// divide the current charge by it. When the discharge run is too short or too flat to trust, we fall back +/// to the device's typical full-charge life for its generation. +/// +/// The measured slope already bakes in how the user actually runs their strap (HR broadcast, strain, +/// recording), so there are no hand-tuned usage multipliers. The discharge curve IS the personalisation. +/// +/// Honest about the limits: battery drain is non-linear (faster near full and near empty) and the strap +/// reports SoC sparsely, so this is an estimate, not a guarantee. Pure value type with no I/O. The Kotlin +/// twin is BatteryEstimator.kt, kept behaviour-identical (same fixtures, same numbers). +public enum BatteryEstimator { + + // MARK: - Rated full-charge life (the cold-start fallback) + + /// Typical full-charge life in hours per WHOOP generation, used before enough of the user's own + /// discharge has been seen to fit a slope. WHOOP 4.0 is about 4.5 days, WHOOP 5.0 / MG about 12 days + /// (the figures cited in #713). The caller maps its connected strap to one of these. + public static let ratedLifeHoursWhoop4: Double = 108 // 4.5 days + public static let ratedLifeHoursWhoop5: Double = 288 // 12 days + + /// A discharge run has to span at least this long AND drop at least this much before its measured + /// slope is trusted over the rated fallback. Short or noisy spans produce wild rates. + public static let minSpanHours: Double = 2.0 + public static let minDropPct: Double = 2.0 + + /// A SoC rise larger than this (percentage points) between two consecutive readings marks a CHARGE. + /// The discharge run restarts after it, so we never fit a rate across a charge. + public static let chargeStepPct: Double = 1.0 + + /// A charge only ANCHORS a fresh discharge run when it returns the strap NEAR FULL (#8). A mere partial + /// top-up (e.g. 40% -> 55% on a quick desk charge) used to reset the run exactly like a 0% -> 100% + /// charge, discarding the long clean discharge history before it and inflating "days left" off the short + /// post-top-up tail. So a rise is treated as a run-reset anchor only when the post-rise SoC reaches this; + /// a partial top-up is instead stepped over, and the fit prefers the longer pre-top-up discharge segment. + public static let nearFullPct: Double = 90.0 + + // MARK: - Output + + /// Where the drain rate came from: the user's own measured discharge, or the rated fallback. + public enum Source: String, Equatable, Sendable { case measured, rated } + + public struct Estimate: Equatable, Sendable { + /// Estimated hours of runtime left at the latest reading. + public let remainingHours: Double + public let source: Source + /// The latest SoC the estimate is anchored to, in percent. + public let currentSoc: Double + + public init(remainingHours: Double, source: Source, currentSoc: Double) { + self.remainingHours = remainingHours + self.source = source + self.currentSoc = currentSoc + } + + /// Convenience for callers that just want the days figure. + public var daysRemaining: Double { remainingHours / 24 } + /// Mirror so callers can read either name. + public var hoursRemaining: Double { remainingHours } + } + + // MARK: - Estimate + + /// Estimate remaining runtime from a SoC series. + /// + /// - Parameters: + /// - samples: `(unix-seconds, SoC%)` pairs in any order. The caller drops nil-SoC rows and maps the + /// banked battery series into this shape. + /// - ratedHours: the strap's typical full-charge life, one of the `ratedLifeHours…` constants, + /// chosen by the caller from the connected strap's generation. + /// - Returns: an estimate, or nil when there isn't a single reading to anchor to. + public static func estimate(samples: [(ts: Int, soc: Double)], ratedHours: Double) -> Estimate? { + let sorted = samples.sorted { $0.ts < $1.ts } + guard let last = sorted.last else { return nil } + let current = last.soc + + // The discharge segment whose slope we fit: anchored at the most recent NEAR-FULL charge, and ending + // before any later partial top-up, so neither a charge earlier in the buffer nor a quick desk top-up + // distorts the fitted slope (#8). + let run = dischargeFitWindow(sorted) + + // Fit the discharge slope over the segment as a simple endpoints rate (%/h). The series is short and + // monotone-ish within a segment, so endpoints are as good as a least-squares line and far cheaper, + // and they keep the test fixtures exact. nil when it's too short, too flat, or not discharging. The + // estimate stays anchored to `current` (the latest SoC), even when the fit window ends earlier. + let measuredRate: Double? = { + guard run.count >= 2, let first = run.first, let lastRun = run.last else { return nil } + let spanHours = Double(lastRun.ts - first.ts) / 3600.0 + let drop = first.soc - lastRun.soc + guard spanHours >= minSpanHours, drop >= minDropPct else { return nil } + let rate = drop / spanHours + return rate > 0 ? rate : nil + }() + + let rate = measuredRate ?? (100.0 / max(ratedHours, 1)) + let remaining = max(0, current) / rate + // A fresh full charge can't realistically beat about 1.5x the rated life, so clamp out any wild + // estimate from a near-flat measured run that still squeaked past the drop gate. + let clamped = min(remaining, ratedHours * 1.5) + return Estimate(remainingHours: clamped, + source: measuredRate != nil ? .measured : .rated, + currentSoc: current) + } + + /// The slice of the sorted SoC series whose endpoints we fit the discharge slope on (#8). Two rules, + /// both keyed off "is this rise a real charge or a partial top-up": + /// 1. START at the most recent NEAR-FULL charge: the most recent rise > chargeStepPct that LANDS at + /// >= nearFullPct. A partial top-up (rise that doesn't reach near-full) is NOT an anchor: the scan + /// steps over it and keeps looking further back, so a quick 40->55 desk charge no longer throws away + /// the long clean discharge before it. If there is no near-full charge in the buffer, start = 0. + /// 2. END before the most recent partial top-up that falls AFTER the start anchor, so the fitted slope + /// is the longer pre-top-up discharge segment, never the short, slope-flattening post-top-up tail. + /// `current` (the latest SoC the estimate is anchored to) is taken by the caller from the series end, not + /// from this window, so trimming the tail changes only the slope, never the SoC the runtime divides into. + /// Pure; the Kotlin twin is `dischargeFitWindow`. + static func dischargeFitWindow(_ sorted: [(ts: Int, soc: Double)]) -> [(ts: Int, soc: Double)] { + guard sorted.count >= 2 else { return sorted } + + // 1. Most recent NEAR-FULL charge anchors the run start; partial top-ups are stepped over. + var startIdx = 0 + for i in stride(from: sorted.count - 1, through: 1, by: -1) + where sorted[i].soc > sorted[i - 1].soc + chargeStepPct && sorted[i].soc >= nearFullPct { + startIdx = i + break + } + + // 1b. #919: with no near-full (>=90%) charge to anchor on - common on a 12-day WHOOP 5.0 that rarely + // tops past 90% between charges - anchor at the buffer's HIGHEST SoC (the top of the most recent + // discharge) rather than the oldest reading, which can sit below a later charge and net to a + // NON-discharge window (drop < 0 -> stuck on `rated`). The max is >= every later reading, so the + // window can only discharge; the >=minDropPct gate still rejects a flat run. Preserves #8: its + // buffer starts at the max, so this stays index 0 there. Last occurrence of the max (>=), for + // parity with the Kotlin twin. + if startIdx == 0 { + var maxIdx = 0 + for i in sorted.indices where sorted[i].soc >= sorted[maxIdx].soc { maxIdx = i } + startIdx = maxIdx + } + + // 2. End before the most recent PARTIAL top-up after the start anchor (a rise > chargeStepPct that + // does NOT reach near-full), so the fit prefers the longer pre-top-up discharge segment. + var endIdx = sorted.count - 1 + if endIdx - startIdx >= 1 { + for i in stride(from: sorted.count - 1, through: startIdx + 1, by: -1) + where sorted[i].soc > sorted[i - 1].soc + chargeStepPct && sorted[i].soc < nearFullPct { + endIdx = i - 1 + break + } + } + guard endIdx > startIdx else { return Array(sorted[startIdx...]) } + return Array(sorted[startIdx...endIdx]) + } + + /// Side-effect-free diagnostic twin of `estimate`: returns the SAME `Estimate` plus a list of trace + /// lines describing the full (t, soc) series, the detected charge step(s), the trailing discharge run + /// start/span/drop, the fitted slope, and which gate (minSpanHours / minDropPct) decided source = + /// measured vs rated. The Battery test mode gates this behind TestCentre.active(.battery) and feeds the + /// lines to append(log:domain:.battery); when the mode is off it is never called, so there is zero + /// cost. Pure: no clock, no I/O, so fixtures stay exact. The Kotlin twin is BatteryEstimator.estimateTrace. + public static func estimateTrace(samples: [(ts: Int, soc: Double)], ratedHours: Double) + -> (estimate: Estimate?, trace: [String]) { + let sorted = samples.sorted { $0.ts < $1.ts } + guard let last = sorted.last, let first0 = sorted.first else { + return (nil, ["battery series=0 readings, no reading to anchor to"]) + } + var lines: [String] = [] + lines.append("battery series=\(sorted.count) readings span \(first0.ts)..\(last.ts)s") + for s in sorted { lines.append("battery read t=\(s.ts)s soc=\(socText(s.soc))") } + + // The most recent NEAR-FULL charge anchors the run start (same scan as estimate, #8); a partial + // top-up does NOT anchor and is reported separately below. + var startIdx = 0 + if sorted.count >= 2 { + for i in stride(from: sorted.count - 1, through: 1, by: -1) + where sorted[i].soc > sorted[i - 1].soc + chargeStepPct && sorted[i].soc >= nearFullPct { + startIdx = i + let rise = sorted[i].soc - sorted[i - 1].soc + lines.append("battery chargeStep at t=\(sorted[i].ts)s +\(socText(rise))pp " + + "(>chargeStepPct \(socText(chargeStepPct)))") + break + } + } + // The most recent PARTIAL top-up after the anchor (a rise that does NOT reach near-full): the fit + // ends before it and prefers the longer pre-top-up discharge segment (#8). + if sorted.count >= 2, startIdx < sorted.count - 1 { + for i in stride(from: sorted.count - 1, through: startIdx + 1, by: -1) + where sorted[i].soc > sorted[i - 1].soc + chargeStepPct && sorted[i].soc < nearFullPct { + let rise = sorted[i].soc - sorted[i - 1].soc + lines.append("battery partialTopUp at t=\(sorted[i].ts)s +\(socText(rise))pp " + + "( fit pre-top-up segment") + break + } + } + let run = dischargeFitWindow(sorted) + + var spanPass = false + var dropPass = false + if run.count >= 2, let runFirst = run.first, let runLast = run.last { + let spanHours = Double(runLast.ts - runFirst.ts) / 3600.0 + let drop = runFirst.soc - runLast.soc + lines.append("battery dischargeRun start=\(runFirst.ts)s " + + "span=\(hoursText(spanHours))h drop=\(socText(drop))pp") + spanPass = spanHours >= minSpanHours + dropPass = drop >= minDropPct + if spanPass && dropPass && drop / spanHours > 0 { + lines.append("battery slope=\(slopeText(drop / spanHours))pct/h fitted from run endpoints") + } + } else { + lines.append("battery dischargeRun too short to fit (run=\(run.count) readings)") + } + + let measured = spanPass && dropPass && run.count >= 2 + && (run.first!.soc - run.last!.soc) / (Double(run.last!.ts - run.first!.ts) / 3600.0) > 0 + lines.append("battery gate minSpanHours \(hoursText(minSpanHours)) " + + "\(spanPass ? "PASS" : "FAIL"), minDropPct \(socText(minDropPct)) " + + "\(dropPass ? "PASS" : "FAIL") -> source=\(measured ? "measured" : "rated")") + + return (estimate(samples: samples, ratedHours: ratedHours), lines) + } + + private static func socText(_ v: Double) -> String { String(format: "%.1f", v) } + private static func hoursText(_ v: Double) -> String { String(format: "%.1f", v) } + private static func slopeText(_ v: Double) -> String { String(format: "%.1f", v) } + + /// Display rule from #713: show hours under 48h ("~14h"), days above ("~4.5 days"). Unit text only, + /// the caller adds the "left" / "remaining" copy. Locale-free so the tests stay stable; the UI + /// localises the number when it renders. + public static func label(hours: Double) -> String { + if hours < 48 { return "~\(Int(hours.rounded()))h" } + return "~\(String(format: "%.1f", hours / 24)) days" + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/BreathPacer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/BreathPacer.swift new file mode 100644 index 0000000000..040036be77 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/BreathPacer.swift @@ -0,0 +1,99 @@ +import Foundation + +// BreathPacer.swift — turn a breathing pace into a deterministic inhale/exhale haptic cue list you can +// "feel" with your eyes closed, screen-off. PURE + unit-tested; the BLE layer maps each `BreathCue` onto +// the strap's actual haptic command (`AppModel.buzz(loops:)` / `send(.runHapticsPattern)`) and schedules +// the gaps with the proven `HapticClock` asyncAfter walk. No I/O here. +// +// See docs/superpowers/specs/2026-06-19-v5-haptic-biofeedback-design.md (L1 "Act (the pacer)"). +// +// Felt language (identical to the shipped Breathe screen, so existing users already know it): +// • Inhale onset → ONE light pulse (loops: 1) +// • Exhale onset → TWO pulses (loops: 2) +// Each WHOOP notification buzz is a FIXED-LENGTH motor pulse — we can't vary on-time per pulse, only the +// *count* (stacked loops) and the *timing*. So the cue is encoded purely as "fire N loops at offset T". +// +// A breath cycle of `bpm` breaths/min lasts 60000/bpm ms; `inhaleFraction` splits it into inhale vs +// exhale (the calming long-exhale ratio Breathe's "Relax" preset uses is ~0.4 inhale : 0.6 exhale). +// Mirrors `HapticClockEncoder.pulses` in shape: a pure `(params) -> [Cue]` list, walkable by the BLE seam. + +/// Which phase of the breath a cue marks. The on-screen orb (when the screen is on) is driven by the +/// same phase clock; screen-off, the buzz is the whole cue. +public enum BreathPhase: String, Equatable, Sendable { + /// The start of an inhale — a single light pulse. + case inhale + /// The start of an exhale — a heavier (two-pulse) cue. + case exhale +} + +/// One element of a paced-breathing haptic schedule: fire `loops` buzz loops at `offsetMs` from session +/// start, marking the onset of `phase`. The BLE layer schedules the wait then calls the proven buzz. +public struct BreathCue: Equatable, Sendable { + /// Milliseconds from the start of the session at which to fire this cue. + public let offsetMs: Int + /// Which breath phase this cue marks (inhale = light, exhale = heavy). + public let phase: BreathPhase + /// How many buzz loops to play — the felt-strength language (1 = inhale, 2 = exhale). + public let loops: Int + + public init(offsetMs: Int, phase: BreathPhase, loops: Int) { + self.offsetMs = offsetMs + self.phase = phase + self.loops = loops + } +} + +public enum BreathPacer { + + // MARK: - Tunables (Breathe parity) + + /// Loops for an inhale onset — one light pulse, as Breathe fires today. + public static let inhaleLoops: Int = 1 + /// Loops for an exhale onset — two pulses (heavier), as Breathe fires today. + public static let exhaleLoops: Int = 2 + /// Default inhale fraction of the cycle — the calming long-exhale ratio (≈40:60) the "Relax" preset + /// uses. Exhale gets the remaining 0.6. + public static let defaultInhaleFraction: Double = 0.4 + /// Slowest / fastest paces we ever schedule (the resonance sweep band, 4.5–7 br/min). Out-of-range + /// `bpm` is clamped so the pacer never traps or emits absurd offsets. + public static let minBpm: Double = 3.0 + public static let maxBpm: Double = 12.0 + + // MARK: - Pacer + + /// Build the haptic cue list for `cycles` full breaths at `bpm` breaths/min, splitting each cycle into + /// inhale (`inhaleFraction`) and exhale (the remainder). One inhale cue + one exhale cue per cycle, in + /// time order. Pure: identical inputs → identical list (the `HapticClock` precedent). + /// + /// `bpm` is clamped to [minBpm, maxBpm] and `inhaleFraction` to a safe (0.1…0.9) interior so each + /// phase always carries some duration. `cycles` below 1 yields an empty schedule. + public static func schedule(bpm: Double, + inhaleFraction: Double = defaultInhaleFraction, + cycles: Int) -> [BreathCue] { + guard cycles >= 1 else { return [] } + let safeBpm = min(max(bpm, minBpm), maxBpm) + let frac = min(max(inhaleFraction, 0.1), 0.9) + + // Cycle length in ms; integer so offsets are exact and platform-identical (no float drift). + let cycleMs = Int((60_000.0 / safeBpm).rounded()) + let inhaleMs = Int((Double(cycleMs) * frac).rounded()) + + var out: [BreathCue] = [] + out.reserveCapacity(cycles * 2) + for c in 0.. Int { + guard cycles >= 1 else { return 0 } + let safeBpm = min(max(bpm, minBpm), maxBpm) + let cycleMs = Int((60_000.0 / safeBpm).rounded()) + return cycleMs * cycles + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureAccumulator.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureAccumulator.swift new file mode 100644 index 0000000000..7305ea38cc --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureAccumulator.swift @@ -0,0 +1,105 @@ +import Foundation + +// CaptureAccumulator.swift - the per-mode day/night capture accumulator (#965). +// +// #965 taught us the Test Centre "Capturing K of N" row was lying: K came from ceil(elapsedDays), a pure +// wall-clock proxy that advances (or sits) regardless of whether the mode actually captured anything. A +// tester running Sleep + Battery + Steps together saw every row stuck at "1 of 3" because the count was +// never tied to real captured data at all: it counted elapsed time, not distinct days each mode produced +// its own trace on. Worse, a shared clock meant the three modes could never diverge - one number drove +// them all - so a mode that captured three nights and a mode that captured none read identically. +// +// This is the honest replacement: for a given domain, count the DISTINCT local calendar days that +// domain's own tagged trace lines carry, so each active mode INDEPENDENTLY accumulates its own count off +// the shareable strap log. A domain that captured nights on three different days reads 3; a dead-trace +// mode reads 0. Sleep counts nights (its `sleep day=` / `[sleep] gate run=` lines), Battery counts days +// (its `[battery] bank soc= t=s` samples, folded to a local day), Steps counts days (`stepsRaw +// day=`), and the universal `dayOwner day=` line accumulates once per scored day for the universal row. +// +// Everything here is PURE and side-effect-free: it takes the domain, the already-redacted report text and +// a timezone offset and returns an Int. No I/O, no live clock, no PII (it only extracts day keys and unix +// stamps that are already in the log). The Kotlin twin is CaptureAccumulator.kt, kept aligned by a parity +// test (same day-token map, same fold). No em-dashes. + +public enum CaptureAccumulator { + + /// Per-domain "how a captured day shows up in the log". `.dayKey` domains write an explicit + /// `day=YYYY-MM-DD` on their trace line (the day the night/score is attributed to); `.epoch` domains + /// write a `t=s` wall stamp (battery banks a SoC sample per reading, not a day-keyed row) that we + /// fold to a LOCAL calendar day. A domain not listed here has no day-bearing trace, so its captured-day + /// count is 0 (never a fabricated number). The token(s) also SCOPE the scan so an unrelated line that + /// happens to carry `day=` is not counted toward the wrong mode. + enum DayMarker: Equatable { + case dayKey(tokens: [String]) // a `day=YYYY-MM-DD` on a line carrying any of `tokens` + case epoch(tokens: [String]) // a `t=s` on a line carrying any of `tokens` + } + + /// The declarative {domain -> day-marker} map. Tokens are the verbatim leading text the live emitters + /// write (verified against the emitters, mirroring CaptureCompleteness.tokens), so a captured-day count + /// is scoped to that mode's own lines. A domain absent from the map accumulates 0 (no day-bearing trace). + /// + /// sleep -> the per-day sleep-provenance line (`sleep day=YYYY-MM-DD ...`) + /// steps -> the raw step-counter trace (`stepsRaw day=YYYY-MM-DD ...`) + /// recovery -> the per-day Charge line (`charge ... day=YYYY-MM-DD` / `charge day=YYYY-MM-DD ...`) + /// battery -> the banked SoC series (`bank soc=... t=s`), folded to a local day + /// universal -> the dayOwner self-diagnostic (`dayOwner day=YYYY-MM-DD ...`), one per scored day + static let markers: [TestDomain: DayMarker] = [ + .sleep: .dayKey(tokens: ["sleep day=", "gate run="]), + .steps: .dayKey(tokens: ["stepsRaw", "stepsEst day="]), + .recovery: .dayKey(tokens: ["charge "]), + .battery: .epoch(tokens: ["bank soc="]), + .universal: .dayKey(tokens: ["dayOwner "]), + ] + + /// yyyy-MM-dd matcher for `day=`. A separate scan for the unix stamp on epoch lines. + private static let dayKeyRegex = try? NSRegularExpression(pattern: "day=([0-9]{4}-[0-9]{2}-[0-9]{2})") + private static let epochRegex = try? NSRegularExpression(pattern: "\\bt=([0-9]{6,})s") + + /// The count of DISTINCT local calendar days `domain` captured, read from `reportText`. `.dayKey` + /// domains contribute the set of `day=` keys on their tagged lines; `.epoch` domains fold each `t=s` + /// sample to a local day (via `tzOffsetSeconds`, seconds EAST of UTC, the same convention + /// `AnalyticsEngine.dayString(_:offsetSec:)` uses). A domain with no marker, or whose trace never landed, + /// returns 0. Pure: no clock, no I/O. + public static func capturedDays(domain: TestDomain, reportText: String, tzOffsetSeconds: Int) -> Int { + capturedDayKeys(domain: domain, reportText: reportText, tzOffsetSeconds: tzOffsetSeconds).count + } + + /// The SET of distinct local day keys `domain` captured (yyyy-MM-dd). Exposed for tests and for a caller + /// that wants the keys, not just the count. Empty when the mode has no day-bearing trace / captured none. + static func capturedDayKeys(domain: TestDomain, reportText: String, tzOffsetSeconds: Int) -> Set { + guard let marker = markers[domain] else { return [] } + var days = Set() + for rawLine in reportText.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(rawLine) + switch marker { + case let .dayKey(tokens): + guard tokens.contains(where: { line.contains($0) }) else { continue } + if let key = firstDayKey(in: line) { days.insert(key) } + case let .epoch(tokens): + guard tokens.contains(where: { line.contains($0) }) else { continue } + if let unix = firstEpoch(in: line) { + days.insert(AnalyticsEngine.dayString(unix, offsetSec: tzOffsetSeconds)) + } + } + } + return days + } + + /// Extract the first `day=YYYY-MM-DD` value on a line, or nil. + private static func firstDayKey(in line: String) -> String? { + guard let re = dayKeyRegex else { return nil } + let ns = line as NSString + guard let m = re.firstMatch(in: line, range: NSRange(location: 0, length: ns.length)), + m.numberOfRanges > 1 else { return nil } + return ns.substring(with: m.range(at: 1)) + } + + /// Extract the first `t=s` value on a line, or nil. + private static func firstEpoch(in line: String) -> Int? { + guard let re = epochRegex else { return nil } + let ns = line as NSString + guard let m = re.firstMatch(in: line, range: NSRange(location: 0, length: ns.length)), + m.numberOfRanges > 1 else { return nil } + return Int(ns.substring(with: m.range(at: 1))) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift new file mode 100644 index 0000000000..9c0e6b2fad --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift @@ -0,0 +1,139 @@ +import Foundation + +// CaptureCompleteness.swift - the report-completeness guard (#812, generalised). +// +// #812 taught us that a Test Centre report can ship THIN: the mode was on, the user filled the +// questionnaire, but the killer trace for that domain never landed in the log (a dead emitter, a gate that +// never fired, an offload that produced only console frames). The report looked complete at submit time and +// only revealed itself as empty days later when a maintainer opened it. This guard makes that self-evident +// AT EXPORT: for every domain that was ACTIVE during the capture, it scans the redacted report text for that +// domain's expected key-trace token(s) and reports OK (token present, with a count) or INCOMPLETE (mode was +// on but produced no trace, naming the missing token). The result is written into report.txt as a "Capture +// check" section and into meta.json as a machine-readable `capture_check` field, so a thin report is obvious +// the moment it is assembled rather than after a round-trip. +// +// Everything here is PURE and side-effect-free: it takes the active-domain set and the already-redacted +// report text and returns values. No I/O, no clock, no PII (it only counts token occurrences). The token map +// is the single declarative source of truth shared by the report renderer and the meta field. No em-dashes. +// The Kotlin twin is CaptureCompleteness.kt, kept aligned by a parity test (same tokens, same status words). + +/// Whether a domain that was active during the capture produced its killer trace. +public enum CaptureStatus: String, Sendable, Codable, Equatable { + case ok // at least one expected token was found + case incomplete // the mode was active but NONE of its expected tokens appear (the dead-trace warning) +} + +/// One domain's completeness verdict: which domain, OK/INCOMPLETE, how many matching token lines were +/// found, and which token(s) we looked for (so an INCOMPLETE result names exactly what is missing). +public struct CaptureCheck: Sendable, Codable, Equatable { + public let domain: String // TestDomain.id + public let status: CaptureStatus + public let count: Int // total matching token-bearing lines found across this domain's tokens + public let tokens: [String] // the expected token(s) for this domain (what we scanned for) + public init(domain: String, status: CaptureStatus, count: Int, tokens: [String]) { + self.domain = domain; self.status = status; self.count = count; self.tokens = tokens + } +} + +public enum CaptureCompleteness { + + /// The declarative map {domain -> expected killer-trace token(s)}. A domain is OK at export when the + /// redacted report contains at least one line carrying ANY of its tokens. The tokens are the verbatim + /// leading words the per-domain emitters write (verified against the live emitters, not guessed): + /// + /// sleep -> the gate-run trace ("gate run=...") and the sleep-provenance line + /// connection -> the promoted clock-drift summary and the bond-state line + /// workouts -> the auto-detect verdict line, the session lifecycle line, and the engine detected-bout decision + /// display -> the data-volume line and the frame-time digest + /// import -> the per-stage rowsIn/rowsOut line + /// steps -> the raw step-counter trace, INCLUDING its no-counter sentinel + /// battery -> the banked SoC series line + /// recovery -> the Charge term-breakdown line + /// hrv -> the rMSSD / spot-reading result line + /// universal -> the dayOwner self-diagnostic that rides every export + /// + /// Tokens are matched as plain substrings against each line. Each entry lists every acceptable token; + /// a domain matches if a line contains any one of them. The "steps" entry deliberately lists both the + /// computed delta line ("stepsRaw") and the explicit no-data sentinel, because a steps capture that + /// found no raw counter STILL emits an honest "noRawCounter"-style line and that counts as a real + /// trace (the mode worked; the strap just had nothing), not an INCOMPLETE. + public static let tokens: [TestDomain: [String]] = [ + .sleep: ["gate run=", "sleepProvenance"], + .connection: ["clockDrift", "bondState"], + .workouts: ["autoDetect", "session event=", "detectedBout"], + .display: ["dataVolume", "frameSummary"], + .dataImport: ["import stage=", "rowsIn="], + .steps: ["stepsRaw", "stepsCal"], + .battery: ["bank soc=", "socSeries"], + .recovery: ["charge term", "charge score=", "charge nilScore"], + .hrv: ["hrv rmssd=", "hrv result="], + .universal: ["dayOwner ", "strapClock "], + ] + + /// The expected tokens for one domain (empty for a domain with no registered emitter, e.g. notifications). + public static func expectedTokens(for domain: TestDomain) -> [String] { + tokens[domain] ?? [] + } + + /// Count how many lines of `reportText` carry ANY of `tokens` (plain substring match). One line that + /// happens to carry two of the tokens counts once, so the figure reads as "trace lines for this domain". + static func countMatches(reportText: String, tokens: [String]) -> Int { + guard !tokens.isEmpty else { return 0 } + var n = 0 + for line in reportText.split(separator: "\n", omittingEmptySubsequences: false) { + if tokens.contains(where: { line.contains($0) }) { n += 1 } + } + return n + } + + /// Run the guard over the redacted `reportText` for each domain in `activeDomains`. A domain is OK when + /// at least one of its expected tokens appears, INCOMPLETE when the mode was on but no token landed (the + /// dead-trace warning). A domain with no registered tokens (no emitter, e.g. notifications) is SKIPPED + /// entirely rather than reported INCOMPLETE, so the guard never flags a domain we never promised a trace + /// for. Results are returned in a stable order (the registry's declaration order, universal last) so the + /// report and meta read identically every time. + /// + /// `activeDomains` is the set of domains that were ACTIVE during the capture (the assembler passes the + /// TestCentre.active(_:) view). `.universal` is included by the caller when any mode was active, because + /// the dayOwner line rides every export. + public static func evaluate(activeDomains: Set, reportText: String) -> [CaptureCheck] { + // Stable order: TestDomain.allCases declaration order, but with universal pushed to the end so the + // per-domain rows read first and the always-present universal row closes the section. + let ordered = TestDomain.allCases.filter { $0 != .universal } + [.universal] + return ordered.compactMap { domain -> CaptureCheck? in + guard activeDomains.contains(domain) else { return nil } + let toks = expectedTokens(for: domain) + guard !toks.isEmpty else { return nil } // no emitter promised => not graded + let count = countMatches(reportText: reportText, tokens: toks) + return CaptureCheck(domain: domain.id, + status: count > 0 ? .ok : .incomplete, + count: count, tokens: toks) + } + } + + /// Render the "Capture check" section appended to report.txt. One line per graded domain: an OK row + /// names the count, an INCOMPLETE row names the missing token(s) so a maintainer (and the tester, in the + /// review sheet) sees instantly WHICH capture failed. Returns an empty string when nothing was graded + /// (no active domain had a registered trace), so a non-test export adds no section. + public static func reportSection(_ checks: [CaptureCheck]) -> String { + guard !checks.isEmpty else { return "" } + var lines = ["", String(repeating: "-", count: 40), "Capture check"] + for c in checks { + switch c.status { + case .ok: + lines.append(" [OK] \(c.domain): \(c.count) trace line\(c.count == 1 ? "" : "s") " + + "(\(c.tokens.joined(separator: " / ")))") + case .incomplete: + lines.append(" [INCOMPLETE] \(c.domain): mode was on but produced NO trace " + + "(expected \(c.tokens.joined(separator: " / ")))") + } + } + return lines.joined(separator: "\n") + } + + /// True when any graded domain came back INCOMPLETE, a one-glance "this report is thin" flag for the + /// meta and for the review sheet. + public static func anyIncomplete(_ checks: [CaptureCheck]) -> Bool { + checks.contains { $0.status == .incomplete } + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift new file mode 100644 index 0000000000..e79910aae3 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift @@ -0,0 +1,271 @@ +import Foundation + +// ChargeDrivers.swift - the ordered "why is my Charge what it is" driver list. +// +// SHARED CONTRACT (engine <-> iOS UI <-> Android): the Charge (recovery) result gains an +// ordered list of drivers, one row per real term that fed the score. Each row carries the +// term's signed contribution to the score in POINTS, the measured value, the personal +// baseline it was compared against, and a short plain-English verdict. The UI renders one +// row per driver under the Charge ring; it never recomputes the score or invents a row. +// +// HONESTY RULES (non-negotiable, mirror RecoveryScorer.recovery exactly): +// - A driver exists ONLY when its term actually fed the score. A missing / uncalibrated +// input (nil HRV-baseline-not-usable, nil resp, nil sleepPerf, nil skin-temp deviation) +// produces NO row, never a fabricated zero. +// - deltaPoints is the term's REAL marginal contribution to the 0-100 score: the score +// WITH every present term minus the score recomputed with THIS term omitted (and the +// remaining weights renormalized, which is exactly what recovery(...) already does when +// an input is nil). So the points come from the same weighting the headline uses, not an +// invented number. A positive deltaPoints means the term pushed Charge UP vs leaving it +// out; negative means it pulled Charge DOWN. +// - The score is logistic, so per-term deltas do NOT sum to the headline; they are each an +// honest "what this term was worth" marginal, ordered by magnitude (biggest mover first). +// +// Pure and side-effect-free: no clock, no I/O. The Kotlin twin is RecoveryScorer.chargeDrivers. +// No em-dashes. + +/// One row of the Charge driver breakdown (SHARED CONTRACT shape). +public struct ChargeDriver: Equatable, Sendable { + /// Human label for the term, e.g. "Resting heart rate". + public let label: String + /// Signed contribution of this term to the 0-100 Charge score, in points. Positive = + /// supported recovery (pushed Charge up vs omitting the term); negative = suppressed it. + public let deltaPoints: Int + /// The measured value, formatted with units, e.g. "58 bpm". + public let valueText: String + /// The personal baseline this value was compared against, e.g. "61 bpm baseline". + /// Empty for the sleep / skin-temp terms whose reference is a fixed centre / zero, not a + /// learned per-night baseline; the UI omits the baseline line when this is empty. + public let baselineText: String + /// Short plain-English read of the direction, e.g. "below baseline, supporting recovery". + public let verdict: String + + public init(label: String, deltaPoints: Int, valueText: String, + baselineText: String, verdict: String) { + self.label = label + self.deltaPoints = deltaPoints + self.valueText = valueText + self.baselineText = baselineText + self.verdict = verdict + } +} + +/// A5: a skin-temperature reading presented as a RELATIVE deviation from the personal +/// baseline (a trend), never a fake clinical absolute. Carries the signed deviation and the +/// relative tier so the UI can label it "warmer / typical / cooler than your baseline". +public struct SkinTempRelative: Equatable, Sendable { + + /// Relative tier: where tonight's skin temp sits versus the personal baseline. NOT a + /// clinical absolute - purely a deviation band. + public enum Tier: String, Equatable, Sendable, Codable { + case cooler // meaningfully below the personal baseline + case typical // within the normal personal range + case warmer // meaningfully above the personal baseline + } + + /// Signed deviation from the personal baseline, in °C (value - baseline). + is warmer. + public let deviationC: Double + /// The relative tier for that deviation. + public let tier: Tier + + public init(deviationC: Double, tier: Tier) { + self.deviationC = deviationC + self.tier = tier + } +} + +extension RecoveryScorer { + + // MARK: - A5: skin-temp relative tier + + /// Half the width (°C) of the "typical" band around the personal baseline. A deviation + /// whose magnitude is at or below this reads `.typical`; beyond it reads `.warmer` / + /// `.cooler`. 0.3 °C matches `VitalBands.skinTempDeviationCfg.floorSpread` (the floored + /// per-night spread of the deviation series), so the band tracks real measurement noise + /// rather than an arbitrary clinical cutoff. + public static let skinTempTypicalBandC: Double = 0.3 + + /// Build the RELATIVE skin-temp marker from a signed deviation (°C from the personal + /// baseline). Returns nil when no deviation is available (no baseline yet / not worn), so + /// the UI shows nothing rather than a fake absolute. The tier is a deviation band only. + public static func skinTempRelative(deviationC: Double?) -> SkinTempRelative? { + guard let dev = deviationC else { return nil } + let tier: SkinTempRelative.Tier + if dev > skinTempTypicalBandC { + tier = .warmer + } else if dev < -skinTempTypicalBandC { + tier = .cooler + } else { + tier = .typical + } + return SkinTempRelative(deviationC: dev, tier: tier) + } + + // MARK: - A2/A1: Charge driver list + + /// Build the ordered Charge driver list from the SAME inputs `recovery(...)` reads. + /// + /// One row per term that actually fed the score (HRV, resting HR, rest quality, + /// respiration, skin-temp deviation). A term whose input is missing / uncalibrated is + /// OMITTED (no row), exactly as `recovery(...)` drops it. Each row's `deltaPoints` is the + /// term's marginal contribution to the 0-100 score: `recovery(all terms)` minus + /// `recovery(this term omitted)`, rounded - real points from the real weighting, never + /// invented. Rows are ordered by |deltaPoints| descending (biggest mover first); ties keep + /// a stable term order (hrv, rhr, sleepPerf, resp, skinTempDev). + /// + /// Returns an EMPTY list when there is no score at all (cold-start: HRV baseline not + /// usable), since there are no real contributions to attribute. Parameters mirror + /// `recovery(...)`; `*ValueText` closures format each measured value for display so the + /// engine stays unit-agnostic (the caller supplies "58 bpm" etc.). + /// + /// The Kotlin twin is `RecoveryScorer.chargeDrivers`. + public static func chargeDrivers(hrv: Double, + rhr: Double, + resp: Double?, + hrvBaseline: BaselineState, + rhrBaseline: BaselineState?, + respBaseline: BaselineState?, + sleepPerf: Double?, + skinTempDev: Double? = nil) -> [ChargeDriver] { + + // No score => no real contributions to attribute (cold-start). recovery(...) enforces + // the usable gate; mirror it so a nil headline never yields fabricated driver rows. + guard let full = recovery(hrv: hrv, rhr: rhr, resp: resp, + hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + respBaseline: respBaseline, sleepPerf: sleepPerf, + skinTempDev: skinTempDev) else { + return [] + } + + // Marginal-vs-neutral attribution: a term's deltaPoints is the full score minus the score + // recomputed with THAT term held at its personal baseline (its z forced to 0) while every + // term, including this one, keeps its weight. So a term sitting exactly at baseline is + // worth 0 points; a term above/below baseline is worth the points it added/subtracted vs + // being neutral. This routes through recovery(...) itself (same terms, same weighting, same + // logistic), so the points can never drift from the headline. A term reaches z = 0 at: + // HRV / resting HR / respiration = the baseline mean (recovery uses BaselineState.baseline + // as the mean), Rest quality = sleepPerfCenter, skin-temp deviation = 0. Renormalised + // leave-one-out would be WRONG here: when the surviving terms average to the same z as the + // full set, dropping one and renormalising returns the same score, collapsing the delta to + // 0 even for a clearly good or bad term. + func points(_ neutralised: Double?) -> Int { + Int((full - (neutralised ?? full)).rounded()) + } + + var drivers: [ChargeDriver] = [] + + // ── HRV (dominant driver; always present once the score exists) ────────── + // Higher HRV vs baseline supports recovery. Neutral = HRV at the baseline mean. + drivers.append(ChargeDriver( + label: "Heart rate variability", + deltaPoints: points(recovery(hrv: hrvBaseline.baseline, rhr: rhr, resp: resp, + hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + respBaseline: respBaseline, sleepPerf: sleepPerf, + skinTempDev: skinTempDev)), + valueText: "\(Int(hrv.rounded())) ms", + baselineText: "\(Int(hrvBaseline.baseline.rounded())) ms baseline", + verdict: hrvVerdict(value: hrv, baseline: hrvBaseline.baseline))) + + // ── Resting HR (lower vs baseline supports recovery) ───────────────────── + // Neutral = resting HR at the baseline mean. + if let b = rhrBaseline { + drivers.append(ChargeDriver( + label: "Resting heart rate", + deltaPoints: points(recovery(hrv: hrv, rhr: b.baseline, resp: resp, + hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + respBaseline: respBaseline, sleepPerf: sleepPerf, + skinTempDev: skinTempDev)), + valueText: "\(Int(rhr.rounded())) bpm", + baselineText: "\(Int(b.baseline.rounded())) bpm baseline", + verdict: rhrVerdict(value: rhr, baseline: b.baseline))) + } + + // ── Rest quality (the Rest composite; neutral at sleepPerfCenter) ──────── + if let sp = sleepPerf { + drivers.append(ChargeDriver( + label: "Sleep quality", + deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: resp, + hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + respBaseline: respBaseline, sleepPerf: sleepPerfCenter, + skinTempDev: skinTempDev)), + valueText: "\(Int((sp * 100).rounded()))%", + baselineText: "", // centred on a fixed "good night", not a learned baseline + verdict: sleepVerdict(sleepPerf: sp))) + } + + // ── Respiration (lower vs baseline supports recovery) ──────────────────── + // Neutral = respiration at the baseline mean. + if let r = resp, let b = respBaseline { + drivers.append(ChargeDriver( + label: "Respiratory rate", + deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: b.baseline, + hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + respBaseline: respBaseline, sleepPerf: sleepPerf, + skinTempDev: skinTempDev)), + valueText: String(format: "%.1f br/min", r), + baselineText: String(format: "%.1f br/min baseline", b.baseline), + verdict: respVerdict(value: r, baseline: b.baseline))) + } + + // ── Skin-temp deviation (symmetric penalty: any drift lowers Charge) ───── + // Neutral = zero drift, so the delta is always <= 0 (a penalty removed). + if let dev = skinTempDev { + drivers.append(ChargeDriver( + label: "Skin temperature", + deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: resp, + hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + respBaseline: respBaseline, sleepPerf: sleepPerf, + skinTempDev: 0)), + valueText: skinTempDevText(dev), + baselineText: "", // a deviation already; the reference is the personal baseline (0) + verdict: skinTempVerdict(dev))) + } + + // Biggest mover first; stable on ties (preserves the append order above). + return drivers.enumerated() + .sorted { a, b in + let am = abs(a.element.deltaPoints), bm = abs(b.element.deltaPoints) + return am != bm ? am > bm : a.offset < b.offset + } + .map { $0.element } + } + + // MARK: - Plain-English verdicts (no fabricated numbers; direction only) + + static func hrvVerdict(value: Double, baseline: Double) -> String { + if value > baseline { return "above baseline, supporting recovery" } + if value < baseline { return "below baseline, limiting recovery" } + return "at baseline" + } + + static func rhrVerdict(value: Double, baseline: Double) -> String { + if value < baseline { return "below baseline, supporting recovery" } + if value > baseline { return "above baseline, limiting recovery" } + return "at baseline" + } + + static func respVerdict(value: Double, baseline: Double) -> String { + if value < baseline { return "below baseline, supporting recovery" } + if value > baseline { return "above baseline, limiting recovery" } + return "at baseline" + } + + static func sleepVerdict(sleepPerf: Double) -> String { + if sleepPerf > sleepPerfCenter { return "a strong night, supporting recovery" } + if sleepPerf < sleepPerfCenter { return "below a good night, limiting recovery" } + return "a typical night" + } + + static func skinTempVerdict(_ dev: Double) -> String { + // Symmetric penalty: any drift from baseline lowers Charge; at baseline it is neutral. + if abs(dev) <= skinTempTypicalBandC { return "near baseline" } + return dev > 0 + ? "warmer than baseline, limiting recovery" + : "cooler than baseline, limiting recovery" + } + + static func skinTempDevText(_ dev: Double) -> String { + let sign = dev >= 0 ? "+" : "" + return "\(sign)\(String(format: "%.1f", dev)) C vs baseline" + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift new file mode 100644 index 0000000000..b2e5f560e9 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift @@ -0,0 +1,320 @@ +import Foundation + +// CircadianEngine.swift — on-device body-clock phase estimate + a jet-lag / shift-work LIGHT & SLEEP-TIMING +// plan. Pure, deterministic, DB-free. +// +// INDEPENDENT implementation of published methods: +// • Single-component COSINOR (Halberg's cosine fit) over the rest-activity rhythm — the standard +// actigraphy method for estimating circadian phase (the acrophase = peak-activity clock time) and +// amplitude. We fit M + A·cos(2π(t − φ)/24) by ordinary least squares on cos/sin regressors and +// recover amplitude + phase. The accelerometer rest-activity rhythm is the primary phase signal; the +// nightly skin-temperature minimum corroborates it (wrist skin temperature runs broadly ANTI-phase to +// core temperature, and the core-body-temperature minimum, CBTmin, is the canonical phase marker +// sitting ~2–3 h before habitual wake). +// • Phase-response-curve (PRC) DIRECTION rule for the advisory: to ADVANCE the clock (eastward travel / +// an earlier shift) → bright light in the morning, dim evenings, earlier sleep, stepped ~1 h/day; to +// DELAY (westward / a later shift) → bright light in the evening, the reverse. +// +// WELLNESS / BEHAVIOURAL AWARENESS ONLY — APPROXIMATE. Light + sleep TIMING only. The engine NEVER +// prescribes melatonin or any supplement/drug, and never guarantees an outcome ("consider"/"aim for", +// never "you must"). Irregular schedules get an honest "your rhythm is hard to read right now." +public enum CircadianEngine { + + // MARK: - Tuning constants (pinned by test; mirror the Kotlin twin exactly) + + /// Minimum days with a usable activity profile before a stable cosinor fit is reported. + public static let minDaysForFit: Int = 7 + /// Days at/above which the fit reads as full-confidence. + public static let goodDaysForFit: Int = 14 + /// A cosinor fit with amplitude below this fraction of the mesor is "arrhythmic" — too flat to phase. + public static let minRelativeAmplitude: Double = 0.10 + /// Max clock-shift the planner steps per day (hours) — the well-established ~1 h/day re-entrainment rate. + public static let maxShiftPerDayHours: Double = 1.0 + /// CBTmin sits roughly this many hours before habitual wake; used to translate the activity acrophase + /// into an estimated temperature-minimum clock time when the thermal series is thin. + public static let cbtMinBeforeWakeHours: Double = 2.5 + /// Activity acrophase (peak activity) sits roughly this many hours after CBTmin in a typical day — the + /// offset used to convert the cosinor acrophase into an estimated temperature-minimum time. + public static let acrophaseAfterCbtMinHours: Double = 12.0 + + // MARK: - Inputs + + /// One per-hour rest-activity sample: the local clock hour (0..<24, may be fractional) and the motion + /// volume in that bin (e.g. StepsEstimateEngine.dayMotionIntensity per hour). Higher = more active. + public struct ActivityBin: Equatable, Sendable { + public let hour: Double + public let activity: Double + public init(hour: Double, activity: Double) { + self.hour = hour; self.activity = activity + } + } + + // MARK: - Cosinor + + /// A single-component cosinor fit: y ≈ mesor + amplitude·cos(2π(hour − acrophaseHours)/24). + public struct CosinorFit: Equatable, Sendable { + public let mesor: Double // rhythm-adjusted mean + public let amplitude: Double // half the peak-to-trough swing (≥ 0) + public let acrophaseHours: Double // clock hour of the activity PEAK, in [0, 24) + public init(mesor: Double, amplitude: Double, acrophaseHours: Double) { + self.mesor = mesor; self.amplitude = amplitude; self.acrophaseHours = acrophaseHours + } + } + + /// Fit a single 24 h cosine to the (hour, activity) bins by ordinary least squares. + /// + /// Model: y = M + β·cos(ωt) + γ·sin(ωt), ω = 2π/24. + /// amplitude = √(β² + γ²) + /// acrophase = atan2(γ, β) converted to a clock hour in [0, 24); this is the time of the PEAK. + /// Returns nil with fewer than 3 distinct points or a degenerate design (zero variance). + public static func cosinor(_ bins: [ActivityBin]) -> CosinorFit? { + guard bins.count >= 3 else { return nil } + let w = 2.0 * Double.pi / 24.0 + let n = Double(bins.count) + + var sumY = 0.0, sumC = 0.0, sumS = 0.0 + var sumCC = 0.0, sumSS = 0.0, sumCS = 0.0 + var sumYC = 0.0, sumYS = 0.0 + for b in bins { + let c = cos(w * b.hour) + let s = sin(w * b.hour) + let y = b.activity + sumY += y; sumC += c; sumS += s + sumCC += c * c; sumSS += s * s; sumCS += c * s + sumYC += y * c; sumYS += y * s + } + + // Solve the 3×3 normal equations for (M, β, γ) via Cramer's rule. + // [ n sumC sumS ] [M] = [sumY ] + // [ sumC sumCC sumCS] [β] = [sumYC] + // [ sumS sumCS sumSS] [γ] = [sumYS] + let a11 = n, a12 = sumC, a13 = sumS + let a21 = sumC, a22 = sumCC, a23 = sumCS + let a31 = sumS, a32 = sumCS, a33 = sumSS + let det = a11 * (a22 * a33 - a23 * a32) + - a12 * (a21 * a33 - a23 * a31) + + a13 * (a21 * a32 - a22 * a31) + guard abs(det) > 1e-12 else { return nil } + + let detM = sumY * (a22 * a33 - a23 * a32) + - a12 * (sumYC * a33 - a23 * sumYS) + + a13 * (sumYC * a32 - a22 * sumYS) + let detB = a11 * (sumYC * a33 - a23 * sumYS) + - sumY * (a21 * a33 - a23 * a31) + + a13 * (a21 * sumYS - sumYC * a31) + let detG = a11 * (a22 * sumYS - sumYC * a32) + - a12 * (a21 * sumYS - sumYC * a31) + + sumY * (a21 * a32 - a22 * a31) + + let m = detM / det + let beta = detB / det + let gamma = detG / det + + let amplitude = (beta * beta + gamma * gamma).squareRoot() + // Peak time: cos(ω(t − φ)) is maximal when ω(t − φ) = 0, i.e. φ where β·cos+γ·sin peaks. + var phase = atan2(gamma, beta) / w // hours + phase = phase.truncatingRemainder(dividingBy: 24.0) + if phase < 0 { phase += 24.0 } + return CosinorFit(mesor: m, amplitude: amplitude, acrophaseHours: phase) + } + + // MARK: - Phase estimate + + public enum PhaseConfidence: String, Equatable, Sendable, Codable { + case unreadable // too few days / arrhythmic — "hard to read right now" + case wide // a fit, but thin data → wide band + case solid // a stable fit over enough days + } + + public struct PhaseEstimate: Equatable, Sendable { + /// Estimated clock hour of the body-clock temperature minimum, in [0, 24). + public let tempMinHour: Double + /// Estimated activity acrophase (peak activity clock hour). + public let acrophaseHours: Double + /// Signed minutes the body clock leads (−) or lags (+) the user's own sleep schedule. Positive = + /// the clock is LATER than the schedule implies (a "night-owl lean"). + public let offsetVsScheduleMinutes: Double + public let confidence: PhaseConfidence + public let note: String + public init(tempMinHour: Double, acrophaseHours: Double, offsetVsScheduleMinutes: Double, + confidence: PhaseConfidence, note: String) { + self.tempMinHour = tempMinHour; self.acrophaseHours = acrophaseHours + self.offsetVsScheduleMinutes = offsetVsScheduleMinutes + self.confidence = confidence; self.note = note + } + } + + /// Estimate the body-clock phase from a pooled activity profile and the user's habitual wake time. + /// + /// - Parameters: + /// - bins: pooled per-hour activity over the trailing window. + /// - daysObserved: distinct days backing the profile (drives confidence). + /// - habitualWakeHour: the user's typical wake clock hour (for the schedule-offset comparison). + /// - observedTempMinHour: optional measured nightly temp-minimum clock hour; when present it + /// corroborates / overrides the activity-derived estimate (the pillar's own signal). + public static func estimatePhase(bins: [ActivityBin], + daysObserved: Int, + habitualWakeHour: Double, + observedTempMinHour: Double? = nil) -> PhaseEstimate? { + guard let fit = cosinor(bins) else { return nil } + + let relativeAmplitude = fit.mesor != 0 ? fit.amplitude / abs(fit.mesor) : 0 + if daysObserved < minDaysForFit || relativeAmplitude < minRelativeAmplitude { + // A reading is returned, but flagged unreadable so the surface says "hard to read right now." + let tmin = observedTempMinHour ?? wrap24(fit.acrophaseHours - acrophaseAfterCbtMinHours) + return PhaseEstimate(tempMinHour: tmin, acrophaseHours: fit.acrophaseHours, + offsetVsScheduleMinutes: 0, confidence: .unreadable, + note: "Your rhythm is hard to read right now - keep wearing it for a clearer picture.") + } + + // Activity-derived temp-minimum ≈ acrophase − ~12 h (activity peaks roughly half a day after CBTmin). + let derivedTempMin = wrap24(fit.acrophaseHours - acrophaseAfterCbtMinHours) + let tempMinHour = observedTempMinHour ?? derivedTempMin + + // A perfectly entrained clock has CBTmin ~cbtMinBeforeWakeHours before wake. The offset is how far + // the ESTIMATED temp-minimum sits from that ideal, in minutes (signed; + = clock later than schedule). + let idealTempMin = wrap24(habitualWakeHour - cbtMinBeforeWakeHours) + let offsetHours = signedHourDelta(from: idealTempMin, to: tempMinHour) + let offsetMinutes = offsetHours * 60.0 + + let confidence: PhaseConfidence = daysObserved >= goodDaysForFit ? .solid : .wide + let lean: String + if offsetMinutes > 20 { lean = "later (a night-owl lean)" } + else if offsetMinutes < -20 { lean = "earlier (a morning-lark lean)" } + else { lean = "well-aligned with your schedule" } + let note = "Your body clock looks \(lean)." + + return PhaseEstimate(tempMinHour: tempMinHour, acrophaseHours: fit.acrophaseHours, + offsetVsScheduleMinutes: offsetMinutes, confidence: confidence, note: note) + } + + // MARK: - Jet-lag / shift planner + + public enum ShiftDirection: String, Equatable, Sendable, Codable { + case advance // move the clock EARLIER (eastward travel / earlier shift) + case delay // move the clock LATER (westward travel / later shift) + case none // no meaningful shift required + } + + /// One day of the re-entrainment plan: when to seek bright light, when to keep it dim, and the target + /// sleep window — light + timing only, never a supplement. + public struct DayPlan: Equatable, Sendable { + public let dayIndex: Int // 1-based + public let brightLightStartHour: Double + public let brightLightEndHour: Double + public let dimFromHour: Double + public let targetSleepHour: Double + public let targetWakeHour: Double + public let guidance: String + public init(dayIndex: Int, brightLightStartHour: Double, brightLightEndHour: Double, + dimFromHour: Double, targetSleepHour: Double, targetWakeHour: Double, guidance: String) { + self.dayIndex = dayIndex + self.brightLightStartHour = brightLightStartHour; self.brightLightEndHour = brightLightEndHour + self.dimFromHour = dimFromHour + self.targetSleepHour = targetSleepHour; self.targetWakeHour = targetWakeHour + self.guidance = guidance + } + } + + public struct JetLagPlan: Equatable, Sendable { + public let direction: ShiftDirection + public let totalShiftHours: Double // absolute size of the shift to absorb + public let estimatedDays: Int // days to close it at the stepped rate + public let days: [DayPlan] + public let note: String + public init(direction: ShiftDirection, totalShiftHours: Double, estimatedDays: Int, + days: [DayPlan], note: String) { + self.direction = direction; self.totalShiftHours = totalShiftHours + self.estimatedDays = estimatedDays; self.days = days; self.note = note + } + } + + /// Build a stepped light + sleep-timing plan to absorb a required clock shift. + /// + /// - Parameters: + /// - shiftHours: the phase shift required (hours). POSITIVE = need to ADVANCE (go earlier; eastward). + /// NEGATIVE = need to DELAY (go later; westward). For a destination time-zone, this is the + /// eastward(+)/westward(−) offset; for a shift-work change, the difference in target wake time. + /// - currentSleepHour / currentWakeHour: the user's current sleep window (clock hours). + public static func planShift(shiftHours: Double, + currentSleepHour: Double, + currentWakeHour: Double) -> JetLagPlan { + let magnitude = abs(shiftHours) + guard magnitude >= 0.5 else { + return JetLagPlan(direction: .none, totalShiftHours: 0, estimatedDays: 0, days: [], + note: "No meaningful body-clock shift needed - you're about aligned.") + } + + let advancing = shiftHours > 0 + let direction: ShiftDirection = advancing ? .advance : .delay + let days = Int(ceil(magnitude / maxShiftPerDayHours)) + + var plan: [DayPlan] = [] + var cumulative = 0.0 + for i in 1...days { + let stepRemaining = magnitude - cumulative + let step = min(maxShiftPerDayHours, stepRemaining) + cumulative += step + // Advancing → shift the window EARLIER each day (subtract); delaying → LATER (add). + let signed = advancing ? -cumulative : cumulative + let sleep = wrap24(currentSleepHour + signed) + let wake = wrap24(currentWakeHour + signed) + + let brightStart: Double + let brightEnd: Double + let dimFrom: Double + let guidance: String + if advancing { + // ADVANCE: bright light in the MORNING just after the new wake; dim the evening. + brightStart = wake + brightEnd = wrap24(wake + 2.0) + dimFrom = wrap24(sleep - 2.0) + guidance = "Get bright light early after waking and keep the evening dim - this nudges your " + + "clock earlier. Aim for lights-out around \(clock(sleep))." + } else { + // DELAY: bright light in the EVENING; avoid bright morning light; go to bed later. + brightStart = wrap24(sleep - 3.0) + brightEnd = wrap24(sleep - 1.0) + dimFrom = wrap24(wake) + guidance = "Get bright light in the evening and go easy on bright morning light - this nudges " + + "your clock later. Aim for lights-out around \(clock(sleep))." + } + plan.append(DayPlan(dayIndex: i, brightLightStartHour: brightStart, brightLightEndHour: brightEnd, + dimFromHour: dimFrom, targetSleepHour: sleep, targetWakeHour: wake, + guidance: guidance)) + } + + let dirWord = advancing ? "earlier" : "later" + let note = "Shifting your clock \(String(format: "%.1f", magnitude)) h \(dirWord), about " + + "\(maxShiftPerDayHours == 1.0 ? "an hour" : "\(maxShiftPerDayHours) h") a day. Light and sleep " + + "timing only." + return JetLagPlan(direction: direction, totalShiftHours: magnitude, estimatedDays: days, + days: plan, note: note) + } + + // MARK: - Helpers + + /// Wrap an hour value into [0, 24). + static func wrap24(_ h: Double) -> Double { + var x = h.truncatingRemainder(dividingBy: 24.0) + if x < 0 { x += 24.0 } + return x + } + + /// Signed shortest delta in hours from `a` to `b` on the 24 h clock, in (−12, 12]. + static func signedHourDelta(from a: Double, to b: Double) -> Double { + var d = (b - a).truncatingRemainder(dividingBy: 24.0) + if d > 12.0 { d -= 24.0 } + if d <= -12.0 { d += 24.0 } + return d + } + + /// Format a clock hour as "HH:MM" (24 h). Pure, locale-free for cross-platform string parity. + static func clock(_ hour: Double) -> String { + let h = wrap24(hour) + var hh = Int(h) + var mm = Int(((h - Double(hh)) * 60.0).rounded()) + if mm == 60 { mm = 0; hh = (hh + 1) % 24 } + return String(format: "%02d:%02d", hh, mm) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift new file mode 100644 index 0000000000..45d3305a0a --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift @@ -0,0 +1,235 @@ +import Foundation + +// ConnectionReadout.swift - pure values + line formatters for the Connection & Sync test mode. +// +// ConnectionTrace builds the upfront diagnostic lines the Connection emitters write: the CLOCK-DRIFT +// summary (the strap-reported banked-record range vs wall clock, with a future-date flag, promoted from +// the buried raw GET_DATA_RANGE frames to one summary line), the firmware-layout line, and the +// no-cursor / trim sentinel line. ConnectionReadout parses the tagged log tail back into the three +// liveReadout ids the in-app panel binds (connectionUptime, reconnectCount, lastOffloadResult). +// +// Everything here is pure and side-effect-free (no clock read of its own, no I/O), so a fixture pins the +// exact lines and the BLE layer simply gates the call behind TestCentre.active(.connection). No PII - +// counts, durations and ISO dates only. No em-dashes. The Kotlin twin is ConnectionReadout.kt. + +public enum ConnectionTrace { + + /// The CLOCK-DRIFT summary line (#767 / #754 cluster): the strap-reported banked-record window + /// [oldest, newest] against the wall clock, with a FUTURE-DATE flag when the strap's newest record is + /// dated ahead of wall-now (the tell of a wandering / un-clocked strap). Promoted from the buried raw + /// GET_DATA_RANGE frames to one upfront `.connection` line so a clock-broken strap is visible at a + /// glance rather than only via the per-record drop diagnostics. + /// + /// All three timestamps are unix seconds in the SAME wall domain (the caller decodes oldest/newest + /// from the strap's GET_DATA_RANGE reply and passes its own wall-now), so the future-date test is a + /// plain comparison: `newest > wallNow + tolerance`. `oldest` is optional (a half/short range reply + /// gives only the upper bound). The span is reported in days for the backlog-depth read. + /// + /// - Parameter futureToleranceSeconds: slack before flagging FUTURE (clock skew between the strap RTC + /// and the phone is normal up to a minute or two); the default mirrors a couple of minutes. + /// - Parameter behindToleranceSeconds: slack before flagging a BEHIND drift (#990). A newest banked + /// record naturally trails wall time by hours (unworn strap, backlog), so the default is 48 h; + /// beyond that the old line claimed "clockOk" at -363 days, hiding the exact clock fault the + /// reporter needed to see. + public static func clockDriftLine(oldestUnix: Int?, + newestUnix: Int, + wallNowUnix: Int, + futureToleranceSeconds: Int = 120, + behindToleranceSeconds: Int = behindToleranceDefault) -> String { + let iso = isoDate(newestUnix) + let aheadSeconds = newestUnix - wallNowUnix + var line = "clockDrift newest=\(iso) wall=\(isoDate(wallNowUnix)) " + + "newestVsWall=\(signed(aheadSeconds))s" + if let oldestUnix { + let spanDays = max(0, (newestUnix - oldestUnix)) / 86_400 + line += " oldest=\(isoDate(oldestUnix)) spanDays=\(spanDays)" + } + line += clockVerdict(aheadSeconds: aheadSeconds, newestUnix: newestUnix, + futureToleranceSeconds: futureToleranceSeconds, + behindToleranceSeconds: behindToleranceSeconds) + return line + } + + // MARK: - Strap-clock verdict (shared by clockDriftLine + UniversalTrace.clockDriftLine, #990/#987) + + /// 1972-01-01 unix. A strap RTC that was never set counts up from its 1970 epoch, so any strap-side + /// timestamp below this ceiling means "the clock never latched" (the #77/#91/#987 cluster tell: + /// the strap banks nothing to flash until its clock is set). Public so the readout warning (#987) + /// and the export line share ONE definition of "epoch-era". + public static let rtcEpochCeilingUnix = 63_072_000 + + /// The default BEHIND drift tolerance (#990): ±48 h. Being a day or two behind is a strap that + /// simply was not worn; beyond that the line must read as a clock warning, never "clockOk". + public static let behindToleranceDefault = 48 * 3_600 + + /// The strap-clock VERDICT token both clock-drift lines end with. One function so the Connection + /// and the universal line can never disagree about what counts as a clock fault. Ordered most + /// specific first: FUTURE (RTC ahead), RTC-EPOCH (never set, ~1970/71), CLOCK-WARNING (behind by + /// more than the tolerance, #990: a -363 d drift used to read "clockOk"), else clockOk. Honest + /// wording on the behind case: a reset clock and a long-unworn strap look identical from here, so + /// the line names both instead of guessing. + static func clockVerdict(aheadSeconds: Int, newestUnix: Int, + futureToleranceSeconds: Int, behindToleranceSeconds: Int) -> String { + if aheadSeconds > futureToleranceSeconds { return " FUTURE-DATED (strap clock ahead of wall)" } + if newestUnix < rtcEpochCeilingUnix { + return " RTC-EPOCH (strap clock reads 1970/71, never set; charge to 100% and reconnect so it latches)" + } + if aheadSeconds < -behindToleranceSeconds { + let days = -aheadSeconds / 86_400 + return " CLOCK-WARNING (newest banked record \(days)d behind wall; strap clock reset or history stale)" + } + return " clockOk" + } + + /// The firmware-layout line for a HEALTHY sync: which historical record layout the strap emits + /// (v18/v24/v25/v26). Surfaced once per distinct version so the connection report always reveals the + /// firmware the strap hands over, not only when NOOP cannot decode it. + public static func firmwareLine(version: Int, decodable: Bool) -> String { + "firmware layout=v\(version) \(decodable ? "decodable" : "UNMAPPED (no motion/HR decoded)")" + } + + /// The trim / no-cursor sentinel line: the strap reported trim=0xFFFFFFFF, its "no valid flash cursor" + /// marker, so it has no banked history to offload (a clock/charge state, not a decode bug). + public static func noCursorLine() -> String { + "offload trim=0xFFFFFFFF noCursor (strap has no banked history to offload)" + } + + /// Compact ISO-8601 date-time (no fractional seconds), UTC, for the strap-record timestamps. UTC keeps + /// the line stable across the tester's timezone so a shared report reads identically everywhere. + static func isoDate(_ unix: Int) -> String { + let f = ISO8601DateFormatter() + f.timeZone = TimeZone(identifier: "UTC") + f.formatOptions = [.withFullDate, .withTime, .withColonSeparatorInTime, .withSpaceBetweenDateAndTime] + return f.string(from: Date(timeIntervalSince1970: TimeInterval(unix))) + } + + /// Sign-prefixed integer so the newest-vs-wall delta reads as a signed offset ("+30" / "-3600"). + static func signed(_ n: Int) -> String { n >= 0 ? "+\(n)" : "\(n)" } +} + +/// Pure values for the Connection & Sync live-readout panel. Each parses the `.connection`-tagged log +/// tail the Connection emitters write, so the panel reflects exactly the live link state without the +/// BLE layer having to expose new published properties. No state, no side effects, no em-dashes. The +/// Kotlin twin is the ConnectionReadout object in ConnectionReadout.kt. +public enum ConnectionReadout { + + /// Connection uptime for the readout's `connectionUptime` id. The connect emitter writes + /// "[connection] connect ... uptimeStart=" at the instant the link comes up and clears it on + /// disconnect, so the most recent connect-or-disconnect line tells us whether we are up and since + /// when. `nowUnix` is injected so the readout is testable without a live clock. Returns a short + /// human label ("3m 12s" / "not connected"). + public static func uptimeLabel(taggedTail: [String], nowUnix: Int) -> String { + for line in taggedTail.reversed() { + if line.contains("connect down") { return "not connected" } + if let start = intField(line, key: "uptimeStart=") { + let secs = max(0, nowUnix - start) + return durationLabel(secs) + } + } + return "not connected" + } + + /// Reconnect count for the readout's `reconnectCount` id: the highest `reconnect n=` seen in + /// the tail this session (the reconnect-churn emitter increments it on each involuntary reconnect). + /// 0 when no reconnect line is present. + public static func reconnectCount(taggedTail: [String]) -> Int { + var maxN = 0 + for line in taggedTail where line.contains("reconnect ") { + if let n = intField(line, key: "n=") { maxN = max(maxN, n) } + } + return maxN + } + + /// Last offload result for the readout's `lastOffloadResult` id: the most recent "offload result=<...>" + /// fragment the offload-progress emitter writes (e.g. "complete rows=42 nights=2", "empty (console + /// only)", "stalled (idle timeout)"). nil when no offload has finished this session. + public static func lastOffloadResult(taggedTail: [String]) -> String? { + for line in taggedTail.reversed() { + if let r = line.range(of: "offload result=") { + let frag = String(line[r.upperBound...]).trimmingCharacters(in: .whitespaces) + if !frag.isEmpty { return frag } + } + } + return nil + } + + /// Rows drained (persisted) THIS session, for the readout row beside the all-time tally (#990): + /// the newest `sessionRows=` running total the per-chunk offload-progress emitter writes, falling + /// back to the final `offload result= ... rows=` when the session already summarised. nil when no + /// offload has drained anything this session. + public static func sessionRows(taggedTail: [String]) -> Int? { + for line in taggedTail.reversed() { + // A finished session's result line wins (it is the newest line). An "empty (console only)" + // result carries no rows= field and honestly means 0, NOT an older session's running total. + if line.contains("offload result=") { return intField(line, key: "rows=") ?? 0 } + if let n = intField(line, key: "sessionRows=") { return n } + } + return nil + } + + /// #990: parse the Backfiller's session summary ("Backfill: session persisted N rows (...) across + /// K night(s).") back into its row count, so the log sink can fold each session into the persisted + /// ALL-TIME drained-rows tally. That summary is emitted UNCONDITIONALLY whenever rows landed (the + /// #150 win-rate line), so the cumulative counter accrues on every session, not only while the + /// Connection test mode is on. nil for any other line. + public static func drainedRowsFromSummary(_ line: String) -> Int? { + guard let r = line.range(of: "session persisted ") else { return nil } + let rest = line[r.upperBound...] + let digits = rest.prefix { $0.isNumber } + guard !digits.isEmpty, rest.dropFirst(digits.count).hasPrefix(" rows") else { return nil } + return Int(digits) + } + + /// #987: the device-side clock value from the newest "Clock correlated: device= wall=" line + /// the correlation path logs, or nil when no correlation happened this session. Parsed from the + /// UNTAGGED log tail (correlation is not a test-mode emitter), so the caller passes the full log lines. + public static func clockCorrelatedDevice(logLines: [String]) -> Int? { + for line in logLines.reversed() where line.contains("Clock correlated:") { + return intField(line, key: "device=") + } + return nil + } + + /// #987: the "clock latched" readout value. "yes" once a correlation landed with a plausible (post- + /// 1972) device clock; "no (RTC reads 1970/71)" when the strap answered with an epoch-era clock + /// (never set, so it banks no history); "no (waiting for the strap clock)" before any reply. + public static func clockLatchedLabel(deviceClockUnix: Int?) -> String { + guard let d = deviceClockUnix else { return "no (waiting for the strap clock)" } + return d < ConnectionTrace.rtcEpochCeilingUnix ? "no (RTC reads 1970/71)" : "yes" + } + + /// #987: a plain-words warning when the strap RTC reads epoch-era (~1970/71), from EITHER signal we + /// hold: the correlated device clock or the strap's newest banked-record timestamp. nil when both + /// look sane (or neither was seen yet - we never fabricate a fault). One string, shown verbatim on + /// the Devices / Test Centre connection readout, naming the consequence and the fix. + public static func rtcWarning(deviceClockUnix: Int?, strapNewestUnix: Int?) -> String? { + let ceiling = ConnectionTrace.rtcEpochCeilingUnix + let clockBad = deviceClockUnix.map { $0 > 0 && $0 < ceiling } ?? false + let newestBad = strapNewestUnix.map { $0 > 0 && $0 < ceiling } ?? false + guard clockBad || newestBad else { return nil } + return "Strap clock reads 1970/71 (never set since its last reset), so it is not banking history. " + + "Charge the strap to 100% and reconnect so the clock latches." + } + + /// #987: freshness label for the "last frame" readout row: how long ago the most recent strap frame + /// was routed ("12s ago"), or "no frames yet" before the first one. `nowUnix` injected for testability. + public static func lastFrameLabel(lastFrameUnix: Int?, nowUnix: Int) -> String { + guard let t = lastFrameUnix else { return "no frames yet" } + return durationLabel(max(0, nowUnix - t)) + " ago" + } + + /// Parse a `key=` field out of a line (the value runs up to the next space). nil when absent or + /// non-numeric. + static func intField(_ line: String, key: String) -> Int? { + guard let r = line.range(of: key) else { return nil } + let token = line[r.upperBound...].prefix { $0 != " " } + return Int(token) + } + + /// Short "Xm Ys" / "Xs" / "Xh Ym" duration label for the uptime readout. + static func durationLabel(_ seconds: Int) -> String { + if seconds < 60 { return "\(seconds)s" } + if seconds < 3600 { return "\(seconds / 60)m \(seconds % 60)s" } + return "\(seconds / 3600)h \((seconds % 3600) / 60)m" + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CyclePhaseEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CyclePhaseEngine.swift new file mode 100644 index 0000000000..5fcb3042bb --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CyclePhaseEngine.swift @@ -0,0 +1,344 @@ +import Foundation + +// CyclePhaseEngine.swift — on-device menstrual-cycle PHASE AWARENESS from the nightly skin-temperature +// series, corroborated by the luteal resting-HR rise and the luteal HRV drop. Pure, deterministic, DB-free. +// +// INDEPENDENT implementation of a publicly documented method (wrist skin-temperature cycle tracking, +// e.g. PMC11294004, and the broader biphasic-ovulatory-shift literature): skin temperature runs roughly +// 0.3–0.5 °C HIGHER in the luteal phase than the follicular phase, with a nadir around ovulation, +// mirrored by a luteal RESTING-HR RISE and a luteal HRV (RMSSD) DROP. NOOP re-derives this from the +// user's OWN banked nightly signals against their OWN baseline — it reproduces no competitor's model. +// +// WELLNESS / AWARENESS ONLY — APPROXIMATE. This is NOT contraception, NOT a fertility/ovulation predictor, +// NOT a medical device, and NOT a diagnosis. It never frames a "fertile window" or "safe days," never +// emits a single confident period DATE (only a probabilistic WINDOW), and never diagnoses PCOS, +// pregnancy, perimenopause or any condition — when the signal is flat/irregular it says "no clear +// pattern," never a verdict. All of this is the load-bearing legal/ethical framing. +public enum CyclePhaseEngine { + + // MARK: - Tuning constants (pinned by test; mirror the Kotlin twin exactly) + + /// Weights for the fused luteal index z = wTemp·zTemp + wRHR·zRHR + wHRV·(−zHRV). Temperature is + /// dominant (it is the pillar's signal); RHR and HRV corroborate. The HRV term is NEGATED so a drop + /// pushes the index UP (luteal-ward), matching temp ↑ and RHR ↑. + public static let wTemp: Double = 0.6 + public static let wRHR: Double = 0.2 + public static let wHRV: Double = 0.2 + + /// A night counts as "elevated" (luteal-ward) when its fused index sits at least this many spreads + /// above the personal series mean. Robust spread = the series' median absolute deviation (MAD), so a + /// few extreme nights don't widen the gate. + public static let elevationK: Double = 0.5 + + /// Plausibility clamp on the estimated cycle length (days). Anything outside is treated as "no clear + /// pattern" rather than a fabricated cadence. + public static let minCycleDays: Int = 21 + public static let maxCycleDays: Int = 40 + /// Typical cycle length used as the prior for the next-period WINDOW when the personal median isn't + /// yet reliable. Deliberately a textbook average, not a claim about this user. + public static let defaultCycleDays: Int = 28 + + /// Minimum number of nights of usable data before the engine will classify at all (~1.5 cycles). + public static let minNightsToClassify: Int = 42 + + /// Half-width (days) of the peri-ovulatory band around the estimated elevation onset — the days + /// straddling the follicular→luteal temperature shift. + public static let periOvulatoryHalfWidth: Int = 2 + + // MARK: - Inputs + + /// One night's already-standardized inputs. `tempZ` / `rhrZ` / `hrvZ` are z-scores from + /// `Baselines.deviation` against each metric's personal baseline (the caller computes them so the + /// engine stays I/O-free). `day` is a "yyyy-MM-dd" key, oldest→newest in the array. A missing signal + /// is nil and simply doesn't contribute to that night's fused index. + public struct Night: Equatable, Sendable { + public let day: String + public let tempZ: Double? + public let rhrZ: Double? + public let hrvZ: Double? + public init(day: String, tempZ: Double?, rhrZ: Double?, hrvZ: Double?) { + self.day = day; self.tempZ = tempZ; self.rhrZ = rhrZ; self.hrvZ = hrvZ + } + } + + // MARK: - Output + + public enum Phase: String, Equatable, Sendable, Codable { + case follicular + case periOvulatory + case luteal + case unknown // no clear pattern — NEVER a fabricated phase + case learning // not enough data yet + } + + public enum Confidence: String, Equatable, Sendable, Codable { + case learning // < minNightsToClassify, or baseline not usable + case building // classifies, but the cadence is still coarse (one elevation seen) + case solid // a stable repeating shift detected + } + + /// A detected follicular→luteal temperature shift onset (for the curve markers). + public struct ShiftMarker: Equatable, Sendable { + public let day: String + public init(day: String) { self.day = day } + } + + /// A probabilistic next-period WINDOW. Always a range of days, never a single confident date. + public struct NextPeriodWindow: Equatable, Sendable { + public let earliestDay: String + public let latestDay: String + public init(earliestDay: String, latestDay: String) { + self.earliestDay = earliestDay; self.latestDay = latestDay + } + } + + public struct Result: Equatable, Sendable { + public let phase: Phase + public let confidence: Confidence + /// Inclusive cycle-day estimate as a RANGE, not a point (nil when unknown/learning). + public let cycleDayLow: Int? + public let cycleDayHigh: Int? + /// Estimated personal cycle length in days (nil until a repeat is seen). + public let cycleLengthDays: Int? + /// Probabilistic next-period window (nil unless a usable cadence + recent elevation exist). + public let nextPeriodWindow: NextPeriodWindow? + /// Temperature-shift onsets across the window (oldest→newest) for the detail curve. + public let shiftMarkers: [ShiftMarker] + /// A short, non-clinical status line. + public let note: String + + public init(phase: Phase, confidence: Confidence, cycleDayLow: Int?, cycleDayHigh: Int?, + cycleLengthDays: Int?, nextPeriodWindow: NextPeriodWindow?, + shiftMarkers: [ShiftMarker], note: String) { + self.phase = phase; self.confidence = confidence + self.cycleDayLow = cycleDayLow; self.cycleDayHigh = cycleDayHigh + self.cycleLengthDays = cycleLengthDays; self.nextPeriodWindow = nextPeriodWindow + self.shiftMarkers = shiftMarkers; self.note = note + } + } + + /// Standing awareness-only line shown on every cycle surface (legal/ethical framing). + public static let awarenessLine = + "For awareness only. Not a medical device, not contraception, not a substitute for professional care." + + // MARK: - Classify + + /// Classify the most recent night from the trailing series. + /// + /// - Parameters: + /// - nights: oldest→newest nightly inputs. + /// - baselineUsable: whether the personal skin-temp baseline is at least `usable` (the caller + /// passes `BaselineState.usable`). Below this we stay in `.learning` and never invent a phase. + /// - loggedPeriodStarts: optional "yyyy-MM-dd" period-start days the user logged. When present, the + /// most recent one anchors cycle-day 1 and we CROSS-VALIDATE it against the detected shift, flagging + /// a mistimed log rather than trusting it blindly. Optional — the engine works temperature-only. + public static func classify(_ nights: [Night], + baselineUsable: Bool, + loggedPeriodStarts: [String] = []) -> Result { + // Gate: need a usable baseline and ~1.5 cycles of data. + guard baselineUsable, nights.count >= minNightsToClassify else { + return Result(phase: .learning, confidence: .learning, cycleDayLow: nil, cycleDayHigh: nil, + cycleLengthDays: nil, nextPeriodWindow: nil, shiftMarkers: [], + note: "Learning your pattern from your nightly temperature - keep wearing it overnight.") + } + + // Fuse each night into a single luteal index; nil where no signal at all. + let fused: [(day: String, value: Double?)] = nights.map { n in + (n.day, fusedIndex(tempZ: n.tempZ, rhrZ: n.rhrZ, hrvZ: n.hrvZ)) + } + let values = fused.compactMap { $0.value } + guard values.count >= minNightsToClassify else { + return Result(phase: .learning, confidence: .learning, cycleDayLow: nil, cycleDayHigh: nil, + cycleLengthDays: nil, nextPeriodWindow: nil, shiftMarkers: [], + note: "Learning your pattern from your nightly temperature - keep wearing it overnight.") + } + + let center = median(values) + let spread = max(1e-9, medianAbsoluteDeviation(values, center: center)) + + // Per-night elevated flag (luteal-ward run detection). + let elevated: [Bool] = fused.map { row in + guard let v = row.value else { return false } + return (v - center) >= elevationK * spread + } + + // Detect rising EDGES (follicular→luteal onsets) — the temperature-shift markers. + var onsets: [Int] = [] + for i in fused.indices { + if elevated[i] && (i == 0 || !elevated[i - 1]) { onsets.append(i) } + } + let shiftMarkers = onsets.map { ShiftMarker(day: fused[$0].day) } + + // No detectable shift at all → honest "no clear pattern," never a fabricated phase. + guard let lastOnsetIdx = onsets.last else { + return Result(phase: .unknown, confidence: .building, cycleDayLow: nil, cycleDayHigh: nil, + cycleLengthDays: nil, nextPeriodWindow: nil, shiftMarkers: shiftMarkers, + note: "No clear temperature pattern yet - this can happen with irregular cycles, " + + "hormonal birth control, or shift work.") + } + + // Personal cycle length from the median gap between successive onsets (in calendar days). + var onsetGaps: [Int] = [] + if onsets.count >= 2 { + for k in 1..= minCycleDays, g <= maxCycleDays else { return nil } + return g + }() + let confidence: Confidence = cycleLength != nil ? .solid : .building + + // Optional logged-period cross-validation (better mode). The most recent logged start that falls + // on/before the latest night anchors cycle-day 1; we compare it to the detected onset. + let lastNightDay = fused.last!.day + var note = "" + var anchorDay = fused[lastOnsetIdx].day // default anchor = the temperature shift onset + var anchoredByLog = false + if let loggedStart = mostRecentOnOrBefore(loggedPeriodStarts, day: lastNightDay) { + anchorDay = loggedStart + anchoredByLog = true + let delta = daysBetween(loggedStart, fused[lastOnsetIdx].day) + // The temperature SHIFT (luteal onset) sits well after period-start in a normal cycle; an + // implausible offset OR a logged start older than a full cycle before the latest night (a + // newer period is overdue) means the log is likely mistimed — FLAG it, don't silently trust. + let sinceLog = daysBetween(loggedStart, lastNightDay) ?? 0 + if (delta.map { $0 < 0 || $0 > maxCycleDays } ?? false) || sinceLog > maxCycleDays { + note = "Your temperature shift came at a different time than your logged date - " + + "the logged start may be off." + } + } + + // Cycle-day estimate as a RANGE. If anchored by a log we count from day 1 at the log; in + // temperature-only mode we count from the shift onset, which in a typical cycle is the start of + // the luteal phase (~day 14–16), so we offset by a coarse follicular-length prior. + let daysSinceAnchor = daysBetween(anchorDay, lastNightDay) ?? 0 + let (cycleDayLow, cycleDayHigh): (Int?, Int?) = { + if anchoredByLog { + let d = max(1, daysSinceAnchor + 1) + return (max(1, d - 1), d + 1) // ±1 day band + } else { + // Shift onset ≈ luteal start; place it near a typical follicular length, widen the band. + let lutealStartDay = (cycleLength ?? defaultCycleDays) / 2 + let d = lutealStartDay + daysSinceAnchor + return (max(1, d - 2), d + 2) // ±2 day band (coarser without a log) + } + }() + + // Phase of the MOST RECENT night relative to the latest onset. + let daysSinceOnset = daysBetween(fused[lastOnsetIdx].day, lastNightDay) ?? 0 + let phase: Phase + if elevated[fused.count - 1] { + // Currently in an elevated run → luteal, unless we're right at the onset edge (peri-ovulatory). + phase = daysSinceOnset <= periOvulatoryHalfWidth ? .periOvulatory : .luteal + } else { + // Below the elevation gate. Near a known onset it's the peri-ovulatory dip; otherwise follicular. + phase = daysSinceOnset <= periOvulatoryHalfWidth ? .periOvulatory : .follicular + } + + // Probabilistic next-period WINDOW: the luteal→follicular temperature drop precedes menses, so a + // period is likely roughly one cycle length on from the anchor. Always a RANGE, never a date. + var window: NextPeriodWindow? = nil + if let len = cycleLength { + // Next expected onset of menses ≈ anchor + cycle length. Window = ±2 days around it, but only + // surfaced once we're within range and on/after the anchor. + if let earliest = shiftDay(anchorDay, by: len - 2), + let latest = shiftDay(anchorDay, by: len + 2), + latest >= lastNightDay { + window = NextPeriodWindow(earliestDay: max(lastNightDay, earliest), latestDay: latest) + } + } + + if note.isEmpty { + note = phaseNote(phase) + } + + return Result(phase: phase, confidence: confidence, + cycleDayLow: cycleDayLow, cycleDayHigh: cycleDayHigh, + cycleLengthDays: cycleLength, nextPeriodWindow: window, + shiftMarkers: shiftMarkers, note: note) + } + + // MARK: - Fusion + + /// Weighted fused luteal index for one night. The HRV z is negated (a drop is luteal-ward). Weights + /// are renormalised over only the signals that are present, so a temp-only night still scores. + public static func fusedIndex(tempZ: Double?, rhrZ: Double?, hrvZ: Double?) -> Double? { + var weighted = 0.0 + var wSum = 0.0 + if let t = tempZ { weighted += wTemp * t; wSum += wTemp } + if let r = rhrZ { weighted += wRHR * r; wSum += wRHR } + if let h = hrvZ { weighted += wHRV * (-h); wSum += wHRV } + guard wSum > 0 else { return nil } + return weighted / wSum + } + + // MARK: - Copy + + static func phaseNote(_ phase: Phase) -> String { + switch phase { + case .follicular: + return "Follicular range - temperature sitting at your baseline." + case .periOvulatory: + return "Around your mid-cycle shift - temperature is turning." + case .luteal: + return "Luteal range - temperature is running above your baseline." + case .unknown: + return "No clear pattern yet." + case .learning: + return "Learning your pattern - keep wearing it overnight." + } + } + + // MARK: - Small stats / day helpers (self-contained so the engine stays I/O-free and parity-clean) + + static func median(_ xs: [Double]) -> Double { + guard !xs.isEmpty else { return 0 } + let s = xs.sorted() + let n = s.count + return n % 2 == 1 ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0 + } + + /// Median absolute deviation about `center` — a robust spread estimate. + static func medianAbsoluteDeviation(_ xs: [Double], center: Double) -> Double { + guard !xs.isEmpty else { return 0 } + return median(xs.map { abs($0 - center) }) + } + + /// Calendar days from `a` to `b` ("yyyy-MM-dd"), b − a. nil if either is unparseable. UTC, pure. + static func daysBetween(_ a: String, _ b: String) -> Int? { + guard let da = parseDay(a), let db = parseDay(b) else { return nil } + let secs = db.timeIntervalSince(da) + return Int((secs / 86_400).rounded()) + } + + /// Most recent entry in `days` that is on or before `day` (string compare is valid for ISO dates). + static func mostRecentOnOrBefore(_ days: [String], day: String) -> String? { + days.filter { $0 <= day }.max() + } + + static func parseDay(_ day: String) -> Date? { + let parts = day.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]), + (1...12).contains(m), d >= 1, d <= 31 else { return nil } + var comps = DateComponents() + comps.year = y; comps.month = m; comps.day = d + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + return cal.date(from: comps) + } + + /// Shift a "yyyy-MM-dd" by `delta` days. UTC, deterministic. nil if unparseable. + static func shiftDay(_ day: String, by delta: Int) -> String? { + guard let base = parseDay(day) else { return nil } + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + guard let shifted = cal.date(byAdding: .day, value: delta, to: base) else { return nil } + let out = cal.dateComponents([.year, .month, .day], from: shifted) + guard let oy = out.year, let om = out.month, let od = out.day else { return nil } + return String(format: "%04d-%02d-%02d", oy, om, od) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/DayOwnerResolver.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DayOwnerResolver.swift new file mode 100644 index 0000000000..22e148f2dd --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DayOwnerResolver.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Decides which single device owns a given day's displayed/scored metrics, so scores are never +/// computed from a mix of sources (invariant I2). Pure — the caller supplies candidates (each device +/// that has any data near the day, with a priority) and any locked override from the dayOwnership table. +public enum DayOwnerResolver { + public struct Candidate: Equatable { + public let deviceId: String + public let priority: Int // 0 = active strap, 1 = other live straps, 2 = imports (lower wins) + public let hasData: Bool + public init(deviceId: String, priority: Int, hasData: Bool) { + self.deviceId = deviceId; self.priority = priority; self.hasData = hasData + } + } + /// Returns the owning deviceId, or nil if no candidate has data for the day. + public static func resolve(day: String, lockedOwner: String?, candidates: [Candidate]) -> String? { + if let locked = lockedOwner { return locked } + return candidates.filter { $0.hasData }.sorted { $0.priority < $1.priority }.first?.deviceId + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeStress.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeStress.swift new file mode 100644 index 0000000000..0d09e2814f --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeStress.swift @@ -0,0 +1,295 @@ +import Foundation +import WhoopProtocol + +// DaytimeStress.swift — an intraday (hour-by-hour) read of the SAME autonomic stress +// proxy the daily Stress monitor shows, computed from the day's banked HR + R-R. +// +// The daily Stress score (StressView / StressScreen) maps "resting HR up + HRV down vs +// a personal baseline" onto a 0–3 logistic. This helper applies that SAME math at the +// per-hour grain so the Stress screen can show *when* in the day stress ran high — not +// a new score. For each waking hour it computes: +// +// • mean HR over the hour (HR up = stress, like daily RHR) +// • RMSSD over the hour's clean R-R (HRV down = stress, like daily avgHRV) +// +// and z-scores each against the day's OWN quiet reference (the calm-hour median + the +// spread across hours), then squashes the z-sum onto 0–3 with the identical logistic +// stress = 3 / (1 + e^(−raw)). 0 calm · 1.5 baseline · 3 high — same bands as the daily +// score. The day is its own baseline: a desk day with one tense afternoon reads that +// afternoon as elevated *relative to that person's own calm hours*, no cloud, no history +// needed beyond the day itself. +// +// "Sustained high stress" is an honest, conservative flag: the most recent +// `sustainedHours` covered hours must ALL sit in the HIGH band (≥ highBandFloor). It +// drives a passive in-app suggestion to run a Breathe session — never a notification. +// +// APPROXIMATE and non-clinical: an hour with too little data (few HR samples / too few +// clean beats) is reported as `.noData` and never invented. + +public enum DaytimeStress { + + // MARK: - Tunables + + /// Minimum HR samples in an hour before its mean HR is trusted (~5 min at 1 Hz). + public static let minHourHRSamples: Int = 300 + /// Bucket width for the timeline, in seconds (one hour). + public static let bucketSeconds: Int = 3_600 + /// Band floor for "high" on the shared 0–3 scale (matches StressBand .high). + public static let highBandFloor: Double = 2.0 + /// Consecutive most-recent covered hours that must all be HIGH to flag sustained stress. + public static let sustainedHours: Int = 3 + /// First/last local hour-of-day treated as "waking" for the timeline (06:00–22:00). + public static let wakingStartHour: Int = 6 + public static let wakingEndHour: Int = 22 + + // MARK: - Output + + /// One hour of the daytime timeline. `level` is the shared 0–3 stress proxy, or nil + /// when the hour had too little signal to score honestly. + public struct HourPoint: Equatable, Sendable { + /// Hour-of-day on the LOCAL clock (0–23), the bucket this point covers. + public let hour: Int + /// Unix seconds at the start of the bucket (wall-clock). + public let startTs: Int + /// Shared 0–3 stress proxy for the hour, or nil when `.noData`. + public let level: Double? + /// Mean HR over the hour (bpm), or nil. + public let meanHR: Double? + /// RMSSD over the hour's clean R-R (ms), or nil (too few clean beats). + public let rmssd: Double? + + /// True when the hour was scored (had enough HR to place on the curve). + public var hasData: Bool { level != nil } + + public init(hour: Int, startTs: Int, level: Double?, meanHR: Double?, rmssd: Double?) { + self.hour = hour + self.startTs = startTs + self.level = level + self.meanHR = meanHR + self.rmssd = rmssd + } + } + + /// The full daytime read: the hourly timeline plus the sustained-high summary. + public struct Result: Equatable, Sendable { + /// Waking-hour timeline, earliest → latest. Hours with no signal carry `level == nil`. + public let hours: [HourPoint] + /// True when the most recent `sustainedHours` SCORED hours all sit in the HIGH band. + public let sustainedHigh: Bool + /// Count of trailing high hours backing `sustainedHigh` (0 when not sustained). + public let sustainedRun: Int + /// Mean stress across the SCORED hours, or nil when none were scorable. + public let dayMean: Double? + /// Peak scored hour (highest `level`), or nil. + public let peak: HourPoint? + + public init(hours: [HourPoint], sustainedHigh: Bool, sustainedRun: Int, + dayMean: Double?, peak: HourPoint?) { + self.hours = hours + self.sustainedHigh = sustainedHigh + self.sustainedRun = sustainedRun + self.dayMean = dayMean + self.peak = peak + } + + /// The scored hours only (level non-nil), in time order. + public var scored: [HourPoint] { hours.filter { $0.level != nil } } + + /// Empty read — used when the day had no usable intraday HR at all. + public static let empty = Result(hours: [], sustainedHigh: false, sustainedRun: 0, + dayMean: nil, peak: nil) + } + + // MARK: - Shared stress math (identical formula to the daily StressModel) + + static func mean(_ xs: [Double]) -> Double? { + guard !xs.isEmpty else { return nil } + return xs.reduce(0, +) / Double(xs.count) + } + + /// Population standard deviation; 0 when there's no spread. (Matches StressMath.std.) + static func std(_ xs: [Double], mean m: Double?) -> Double { + guard let m, xs.count > 1 else { return 0 } + let v = xs.map { ($0 - m) * ($0 - m) }.reduce(0, +) / Double(xs.count) + return v.squareRoot() + } + + /// Combined autonomic z-score. HR-up and HRV-down both push it positive — the SAME + /// directionality as the daily score (RHR up = stress, HRV down = stress). + static func rawScore(hr: Double?, meanHR: Double?, sdHR: Double, + rmssd: Double?, meanRMSSD: Double?, sdRMSSD: Double) -> Double { + var sum = 0.0 + if let h = hr, let m = meanHR, sdHR > 0.0001 { + sum += (h - m) / sdHR // HR up = stress + } + if let r = rmssd, let m = meanRMSSD, sdRMSSD > 0.0001 { + sum += (m - r) / sdRMSSD // HRV (RMSSD) down = stress + } + return sum + } + + /// Logistic squash of the raw z-sum onto 0–3 (baseline 0 → 1.5). Identical to + /// StressMath.squash, so an hourly point shares the daily score's scale and bands. + static func squash(_ raw: Double) -> Double { + let s = 3.0 / (1.0 + exp(-raw)) + return min(max(s, 0), 3) + } + + // MARK: - Public API + + /// Build the daytime stress timeline from a day's banked HR + R-R. + /// + /// - Parameters: + /// - hr: the day's `[HRSample]` (any order; bucketed by ts here). + /// - rr: the day's `[RRInterval]`. + /// - tzOffsetSeconds: seconds east of UTC, for placing each bucket on the LOCAL + /// clock (so "waking hours" and the hour labels are local). Defaults to UTC. + /// + /// Returns `.empty` when there isn't a single hour with enough HR to score. + public static func analyze(hr: [HRSample], rr: [RRInterval], + tzOffsetSeconds: Int = 0) -> Result { + // v7.0.2 perf (#707): buckets the day's full HR + R-R streams into per-hour aggregates and runs an + // RMSSD per hour — invoked from the Stress view, so a `body` re-evaluation re-buckets the whole day. + // Memoize on the streams' fingerprint + tz offset; result is a small `Result`, raw arrays not held. + let key = StressKey( + hr: StreamFingerprint.of(hr, ts: { $0.ts }, quant: { Int($0.bpm) }), + rr: StreamFingerprint.of(rr, ts: { $0.ts }, quant: { Int($0.rrMs) }), + tz: tzOffsetSeconds) + return analyzeCache.value(key) { analyzeUncached(hr: hr, rr: rr, tzOffsetSeconds: tzOffsetSeconds) } + } + + private struct StressKey: Hashable { let hr: StreamFingerprint; let rr: StreamFingerprint; let tz: Int } + private static let analyzeCache = AnalyticsMemoCache(capacity: 8) + + private static func analyzeUncached(hr: [HRSample], rr: [RRInterval], + tzOffsetSeconds: Int) -> Result { + guard !hr.isEmpty else { return .empty } + + // 1) Bucket HR + R-R into LOCAL hour-of-day buckets, keyed by the bucket start + // (floored to the hour on the local clock). + var hrByBucket: [Int: [Double]] = [:] + for s in hr { + let local = s.ts + tzOffsetSeconds + let bucket = floorDiv(local, bucketSeconds) * bucketSeconds + hrByBucket[bucket, default: []].append(Double(s.bpm)) + } + var rrByBucket: [Int: [Double]] = [:] + for s in rr { + let local = s.ts + tzOffsetSeconds + let bucket = floorDiv(local, bucketSeconds) * bucketSeconds + rrByBucket[bucket, default: []].append(Double(s.rrMs)) + } + + // 2) Per-hour mean HR + RMSSD (RMSSD via the shared HRV cleaner, so ectopic + // beats can't fabricate variability). An hour with < minHourHRSamples HR is + // left unscored (noData) — never invented. + struct HourAgg { let bucket: Int; let meanHR: Double?; let rmssd: Double?; let nHR: Int } + let orderedBuckets = hrByBucket.keys.sorted() + var aggs: [HourAgg] = [] + aggs.reserveCapacity(orderedBuckets.count) + for b in orderedBuckets { + let hrs = hrByBucket[b] ?? [] + let mHR = hrs.count >= minHourHRSamples ? mean(hrs) : nil + let rrRes = HRVAnalyzer.analyze(rawRR: rrByBucket[b] ?? []) + aggs.append(HourAgg(bucket: b, meanHR: mHR, rmssd: rrRes.rmssd, nHR: hrs.count)) + } + + // 3) The day's OWN quiet reference: centre on the CALM end (the lower quartile of + // hourly mean HR, the upper quartile of hourly RMSSD), and spread from the + // across-hour SD. This makes a flat day read ~baseline and a spiky day surface + // its tense hours — without any cross-day history. Falls back to the plain mean + // when there are too few scored hours for a quartile. + // + // Built from the WAKING hours only — the same hours scored in step 4. Sleep is the + // calmest, lowest-HR / highest-HRV stretch of the day, and the analysis window + // always begins at local midnight, so the current day routinely carries several + // hours of it. Letting those night hours into the reference drags the "calm" anchor + // far beneath every waking hour, inflating an ordinary calm day toward HIGH and + // falsely tripping the sustained-high Breathe nudge. + let referenceAggs = aggs.filter { isWakingHour($0.bucket) } + let hrMeans = referenceAggs.compactMap { $0.meanHR } + let rmssdVals = referenceAggs.compactMap { $0.rmssd } + let refHR = calmReference(hrMeans, calmIsLow: true) // calm HR is LOW + let refRMSSD = calmReference(rmssdVals, calmIsLow: false) // calm HRV is HIGH + let sdHR = std(hrMeans, mean: mean(hrMeans)) + let sdRMSSD = std(rmssdVals, mean: mean(rmssdVals)) + + // 4) Score each waking-hour bucket on the shared 0–3 curve. + var points: [HourPoint] = [] + points.reserveCapacity(aggs.count) + for a in aggs { + guard isWakingHour(a.bucket) else { continue } + let hourOfDay = floorDiv(a.bucket, bucketSeconds) % 24 + // The wall-clock bucket start (undo the local shift applied above). + let wallStart = a.bucket - tzOffsetSeconds + // Score only when at least one signal is present AND HR cleared the count gate + // (HR is the always-available anchor; RMSSD enriches it when beats allow). + let level: Double? = a.meanHR != nil + ? squash(rawScore(hr: a.meanHR, meanHR: refHR, sdHR: sdHR, + rmssd: a.rmssd, meanRMSSD: refRMSSD, sdRMSSD: sdRMSSD)) + : nil + points.append(HourPoint(hour: hourOfDay, startTs: wallStart, + level: level, meanHR: a.meanHR, rmssd: a.rmssd)) + } + + let scored = points.compactMap { p -> (HourPoint, Double)? in p.level.map { (p, $0) } } + guard !scored.isEmpty else { + // No scorable waking hour — still return the (unscored) timeline so the UI can + // show "not enough data" rather than nothing. + return points.isEmpty ? .empty + : Result(hours: points, sustainedHigh: false, sustainedRun: 0, + dayMean: nil, peak: nil) + } + + // 5) Sustained-high flag: walk back from the latest SCORED hour while each is HIGH. + var run = 0 + for (_, lvl) in scored.reversed() { + if lvl >= highBandFloor { run += 1 } else { break } + } + let sustained = run >= sustainedHours + + let dayMean = mean(scored.map { $0.1 }) + let peak = scored.max { $0.1 < $1.1 }?.0 + + return Result(hours: points, sustainedHigh: sustained, sustainedRun: run, + dayMean: dayMean, peak: peak) + } + + // MARK: - Helpers + + /// Floor-division that is correct for negative numerators (so a local time just before + /// the UTC epoch still buckets to the hour below, not toward zero). + static func floorDiv(_ a: Int, _ b: Int) -> Int { + let q = a / b, r = a % b + return (r != 0 && (r < 0) != (b < 0)) ? q - 1 : q + } + + /// Whether a local hour-bucket start falls inside the waking window the timeline scores + /// (06:00–22:00). The single source of truth for "waking" — used both to build the calm + /// reference and to pick the hours to score, so the two can never drift apart. + static func isWakingHour(_ bucket: Int) -> Bool { + let hourOfDay = floorDiv(bucket, bucketSeconds) % 24 + return hourOfDay >= wakingStartHour && hourOfDay < wakingEndHour + } + + /// The day's "calm" reference for a signal: the quartile toward the calm end (lower + /// quartile when calm is LOW, e.g. HR; upper quartile when calm is HIGH, e.g. RMSSD). + /// Falls back to the plain mean below 4 values, and to nil when empty. + static func calmReference(_ xs: [Double], calmIsLow: Bool) -> Double? { + guard !xs.isEmpty else { return nil } + guard xs.count >= 4 else { return mean(xs) } + let s = xs.sorted() + return calmIsLow ? quantile(s, 0.25) : quantile(s, 0.75) + } + + /// Linear-interpolated quantile of an already-sorted, non-empty array. + static func quantile(_ sorted: [Double], _ q: Double) -> Double { + let n = sorted.count + guard n > 0 else { return 0 } // defensive: callers guard emptiness; never trap on [] + if n == 1 { return sorted[0] } + let pos = q * Double(n - 1) + let lo = Int(pos), hi = min(lo + 1, n - 1) + let frac = pos - Double(lo) + return sorted[lo] + frac * (sorted[hi] - sorted[lo]) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/DisplayTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DisplayTrace.swift new file mode 100644 index 0000000000..70c6e73b4c --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DisplayTrace.swift @@ -0,0 +1,165 @@ +import Foundation + +// DisplayTrace.swift - pure values + line formatters for the Display & Performance test mode. +// +// The Display mode captures UI/runtime diagnostics: the device metrics (size class, safe-area insets, +// Dynamic Type, orientation, theme), a rolling frame-time / hitch summary, and the memory high-water. +// The platform layer READS the live values (UITraitCollection / UIScreen on iOS, NSScreen on macOS, +// the Configuration on Android) and feeds them to these PURE formatters, so the exact line shapes are +// pinned by a fixture and read identically in a shared report on either platform. +// +// DisplayMetrics is the value carrier (every field already resolved by the caller, so this stays pure - +// no UIKit / AppKit import, no clock, no IO, no PII). DisplayTrace formats the three tagged-line shapes. +// DisplayReadout parses the deviceMetrics line back into the single liveReadout id the in-app panel +// binds (deviceMetricsNow). No em-dashes anywhere. The Kotlin twin is DisplayTrace.kt. + +/// A platform-resolved snapshot of the display environment. Every field is already read by the caller +/// (UITraitCollection / UIScreen, NSScreen, or the Android Configuration), so this type is pure data and +/// the formatter below has no platform dependency. Optionals are for metrics a platform cannot offer +/// (e.g. a true size class is iOS-only); the formatter prints "n/a" for a nil, never fabricating a value. +public struct DisplayMetrics: Sendable, Equatable { + /// Horizontal size class, "compact" / "regular" / nil where the platform has no size class (macOS). + public let horizontalSizeClass: String? + /// Vertical size class, same convention. + public let verticalSizeClass: String? + /// Logical points wide / tall of the key window (or the screen on macOS). + public let widthPt: Double + public let heightPt: Double + /// Backing scale (UIScreen.scale / NSScreen.backingScaleFactor); 0 when unknown. + public let scale: Double + /// Safe-area insets in points (top, bottom, leading, trailing). Zeroed where there is no notch / inset. + public let safeTop: Double + public let safeBottom: Double + public let safeLeading: Double + public let safeTrailing: Double + /// Dynamic Type / font scale: the content-size category name on iOS (e.g. "L", "XXL", "AX3"), or a + /// scale-factor label on Android ("1.30"). nil where the platform exposes neither (macOS). + public let dynamicType: String? + /// "portrait" / "landscape" / "unknown". + public let orientation: String + /// "light" / "dark". + public let theme: String + + public init(horizontalSizeClass: String?, verticalSizeClass: String?, + widthPt: Double, heightPt: Double, scale: Double, + safeTop: Double, safeBottom: Double, safeLeading: Double, safeTrailing: Double, + dynamicType: String?, orientation: String, theme: String) { + self.horizontalSizeClass = horizontalSizeClass + self.verticalSizeClass = verticalSizeClass + self.widthPt = widthPt; self.heightPt = heightPt; self.scale = scale + self.safeTop = safeTop; self.safeBottom = safeBottom + self.safeLeading = safeLeading; self.safeTrailing = safeTrailing + self.dynamicType = dynamicType; self.orientation = orientation; self.theme = theme + } +} + +/// A platform-resolved snapshot of the on-device DATA VOLUME (CAPTURE-D / #797): the read-set that backs +/// the screens, so import-driven lag shows what it's rendering over, not just frame stats. Every count is +/// already read from the STORE by the caller (never via the Repository / @Published view-models), so this +/// type is pure data and the formatter below has no store dependency. +public struct DataVolume: Sendable, Equatable { + /// Total raw stream rows in the store (HR + RR + events + the biometric streams) , the dominant cost. + public let dbRows: Int + /// Number of distinct days that carry IMPORTED daily metrics (the #799 import surface). + public let importedDays: Int + /// Total detected/recorded workout rows. + public let workouts: Int + /// Rows touched by the most recent render the caller measured, or nil when it hasn't measured one yet. + public let lastRenderRows: Int? + + public init(dbRows: Int, importedDays: Int, workouts: Int, lastRenderRows: Int?) { + self.dbRows = dbRows; self.importedDays = importedDays + self.workouts = workouts; self.lastRenderRows = lastRenderRows + } +} + +public enum DisplayTrace { + + /// The data-volume line (CAPTURE-D / #797): one upfront `.display` summary of the store's read-set, so a + /// "feels laggy after import" report shows HOW MUCH data the screens are rendering over (db rows, + /// imported days, workouts, last render's row count), not only frame timings. A nil `lastRenderRows` + /// (no render measured yet) prints "n/a" rather than fabricating a 0. + public static func dataVolumeLine(_ v: DataVolume) -> String { + let last = v.lastRenderRows.map(String.init) ?? "n/a" + return "dataVolume dbRows=\(v.dbRows) importedDays=\(v.importedDays) " + + "workouts=\(v.workouts) lastRenderRows=\(last)" + } + + /// The device-metrics line: one upfront `.display` summary of the resolved DisplayMetrics, so a + /// "screen looks wrong" report carries the exact layout environment the screen was rendered in. All + /// numbers are rounded to whole points (sub-point precision is noise for a layout bug). A nil size + /// class / Dynamic Type prints "n/a" rather than a fabricated value. + public static func deviceMetricsLine(_ m: DisplayMetrics) -> String { + let h = m.horizontalSizeClass ?? "n/a" + let v = m.verticalSizeClass ?? "n/a" + let dt = m.dynamicType ?? "n/a" + return "deviceMetrics " + + "size=\(pt(m.widthPt))x\(pt(m.heightPt))pt @\(scaleLabel(m.scale))x " + + "sizeClass=\(h)/\(v) " + + "safeArea=t\(pt(m.safeTop)) b\(pt(m.safeBottom)) l\(pt(m.safeLeading)) r\(pt(m.safeTrailing)) " + + "dynamicType=\(dt) orientation=\(m.orientation) theme=\(m.theme)" + } + + /// The rolling frame-time / hitch summary line: a periodic digest (NOT a per-frame line) of the + /// frame-time monitor's last window. `meanMs` / `p95Ms` describe the frame-duration distribution, + /// `hitches` is the count of frames over the hitch threshold this window, and `worstMs` is the single + /// longest frame. Emitted on a cadence (e.g. once a window of N frames), never every frame, so the + /// trace itself is not a performance cost. `frames` is the window size the digest summarises. + public static func frameSummaryLine(frames: Int, meanMs: Double, p95Ms: Double, + hitches: Int, worstMs: Double, hitchThresholdMs: Double) -> String { + "frameSummary frames=\(frames) mean=\(ms(meanMs))ms p95=\(ms(p95Ms))ms " + + "hitches=\(hitches) worst=\(ms(worstMs))ms threshold=\(ms(hitchThresholdMs))ms" + } + + /// The memory high-water line: the peak resident footprint seen while the mode was active, in MB. The + /// caller reads the live footprint (phys_footprint via task_info on Apple, Debug / Runtime on Android) + /// and tracks the maximum; this formats that single peak so a "feels laggy / killed" report shows how + /// close the app ran to its memory ceiling. + public static func memoryHighWaterLine(peakMB: Double) -> String { + "memoryHighWater peak=\(ms(peakMB))MB" + } + + /// Round a point value to a whole number for the line ("390"). Negative insets clamp to 0 (an inset is + /// never negative; a stray negative is a read glitch, not real layout). + static func pt(_ v: Double) -> String { String(Int((max(0, v)).rounded())) } + + /// Backing scale to one decimal ("2.0" / "3.0"); "?" when the caller could not read it (0). + static func scaleLabel(_ v: Double) -> String { v > 0 ? String(format: "%.1f", v) : "?" } + + /// Millisecond / MB value to one decimal so the distribution reads cleanly without sub-tenth noise. + static func ms(_ v: Double) -> String { String(format: "%.1f", max(0, v)) } +} + +/// Pure values for the Display & Performance live-readout panel. Parses the `.display`-tagged log tail +/// the device-metrics emitter writes, so the panel reflects exactly the metrics line in the report +/// without the platform layer having to expose new published properties. No state, no IO, no em-dashes. +/// The Kotlin twin is the DisplayReadout object in DisplayTrace.kt. +public enum DisplayReadout { + + /// The most recent device-metrics summary for the `deviceMetricsNow` id: everything after the + /// "deviceMetrics " marker on the last device-metrics line in the tagged tail, so the panel reads the + /// same size / size-class / Dynamic Type / orientation / theme the report carries. nil when no metrics + /// line is present yet (the emitter writes one on activate and on each trait change). + public static func deviceMetricsNow(taggedTail: [String]) -> String? { + for line in taggedTail.reversed() { + if let r = line.range(of: "deviceMetrics ") { + let frag = String(line[r.upperBound...]).trimmingCharacters(in: .whitespaces) + if !frag.isEmpty { return frag } + } + } + return nil + } + + /// The most recent frame-summary fragment for an at-a-glance perf read (mean / p95 / hitches), or nil + /// when the frame monitor has not yet emitted a window. Parsed off the same tagged tail; the panel uses + /// it as a secondary readout line under the device metrics. + public static func frameSummaryNow(taggedTail: [String]) -> String? { + for line in taggedTail.reversed() { + if let r = line.range(of: "frameSummary ") { + let frag = String(line[r.upperBound...]).trimmingCharacters(in: .whitespaces) + if !frag.isEmpty { return frag } + } + } + return nil + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/DoseResponseEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DoseResponseEngine.swift new file mode 100644 index 0000000000..17ca80761d --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DoseResponseEngine.swift @@ -0,0 +1,239 @@ +import Foundation + +// DoseResponseEngine.swift — a personal dose→outcome slope that SHRINKS toward a +// documented population prior until the user has logged enough nights. +// +// Pure, deterministic, DB-free. Journal behaviours can carry an optional integer DOSE +// (alcohol: drinks 0/1/2/3+; caffeine: a time-of-day bucket 0..3). For a dosed behaviour +// we estimate "how much does each extra unit move tomorrow's outcome FOR YOU?": +// +// 1. Pair (dose_d, outcome_{d+1}) for each logged day with a next-day outcome (the same +// L=1 alignment EffectRanker / ActivityCostEngine use, via CorrelationEngine.shiftDay). +// 2. Fit the PERSONAL slope β_user = OLS slope of outcome on dose via the slope that +// CorrelationEngine.pearson already returns; n_user = paired days. (pearson needs ≥ 3 +// pairs AND spread in both axes; if it can't fit, β_user is undefined → pure prior.) +// 3. SHRINK toward the conservative documented prior β_prior (DoseResponsePriors): +// β = w · β_user + (1 − w) · β_prior, w = n_user / (n_user + k) +// with k = shrinkageK (the pseudo-count of "prior days"). n_user = 0 ⇒ β = β_prior; +// n_user ≫ k ⇒ β → β_user. β is then clamped to the prior's sane range. +// 4. Report the per-incremental-unit Δ (= β), the personal curve points for the chart, and +// a ScoreConfidence from n_user / w (mostly-prior → calibrating; blended → building; +// personal-dominant with enough nights → solid). +// +// HONESTY baked in (the product IS the honesty): +// - Below the dose gate (n_user < minDoseDays) the result is flagged `priorDominated` so the +// card can say "based mostly on typical patterns, not yet yours" and show the prior. +// - Once the user has enough data, the PERSON overrides the population: if the personal slope +// contradicts the prior (e.g. your drink-nights show no dip), β follows the user, and +// `contradictsPrior` flags the "in your data so far, this doesn't move your Charge" copy. +// - Caffeine "dose" is a TIMING proxy (later = stronger), never mg — the priors table & UI say so. +// Nothing here is a causal/clinical claim; it is association on the user's own logged days. +// +// Mirrors the Kotlin DoseResponseEngine twin byte-for-byte. (Spec: +// 2026-06-19-v5-insights-correlation-engine-design.md — "Personal dose-response with +// population-prior shrinkage".) + +// MARK: - Result + +/// A personal, prior-shrunk dose-response estimate for one dosed behaviour on one outcome. +public struct DoseResponse: Equatable, Sendable { + /// The dosed behaviour this estimate is for. + public let behavior: DosedBehavior + /// The outcome label the slope is expressed in (e.g. "Charge", "HRV"). + public let outcome: String + /// The SHRUNK, clamped effect per ONE extra unit of dose (signed). This is the headline + /// "each extra drink ≈ Δ for you" number. + public let perUnit: Double + /// The user's own OLS slope (signed), or nil when there weren't enough/spread-y pairs to fit. + public let userSlope: Double? + /// The documented population prior's per-unit slope this estimate shrank toward. + public let priorSlope: Double + /// The shrinkage weight w = n_user / (n_user + k) in [0, 1]; 0 = pure prior, →1 = pure personal. + public let weight: Double + /// Number of (dose, next-day-outcome) pairs that backed the personal fit. + public let nUser: Int + /// True while the estimate is mostly the prior (n_user below the dose gate) — show the + /// "typical patterns, not yet yours" banner. + public let priorDominated: Bool + /// True when the user has enough data AND their slope's SIGN disagrees with the prior — show + /// the "in your data so far, this doesn't move your …" copy (person overrides population). + public let contradictsPrior: Bool + /// Per-result certainty tier from n_user / w. + public let confidence: ScoreConfidence + /// Curve points (dose, projectedOutcomeDelta-from-baseline) for the chart, dose 0…maxDose, + /// using the shrunk slope from a 0-dose anchor of 0 (relative deltas, so the UI can offset + /// them onto any baseline). Always present for dose 0…maxCurveDose. + public let curve: [DoseCurvePoint] + + public init(behavior: DosedBehavior, outcome: String, perUnit: Double, + userSlope: Double?, priorSlope: Double, weight: Double, nUser: Int, + priorDominated: Bool, contradictsPrior: Bool, + confidence: ScoreConfidence, curve: [DoseCurvePoint]) { + self.behavior = behavior + self.outcome = outcome + self.perUnit = perUnit + self.userSlope = userSlope + self.priorSlope = priorSlope + self.weight = weight + self.nUser = nUser + self.priorDominated = priorDominated + self.contradictsPrior = contradictsPrior + self.confidence = confidence + self.curve = curve + } + + /// The signed Δ on the outcome for going from `fromDose` to `toDose` units. Used by the + /// evening Damage Forecast: each incremental unit contributes `perUnit`. + public func delta(fromDose: Int, toDose: Int) -> Double { + Double(toDose - fromDose) * perUnit + } + + /// Plain-English read. Honest about whether it's still the prior or now the user's own. + public func sentence() -> String { + let mag = DoseResponseEngine.round1(abs(perUnit)) + let dir = perUnit <= 0 ? "lower" : "higher" + if priorDominated { + return "Each extra unit typically lines up with about \(mag) \(outcome) \(dir) " + + " - typical patterns, not yet yours (n=\(nUser))." + } + if contradictsPrior { + return "In your data so far, this doesn't move your \(outcome) the way it typically " + + "does (n=\(nUser))." + } + return "Each extra unit tends to line up with about \(mag) \(outcome) \(dir) for you " + + "(n=\(nUser))." + } +} + +/// One point on the personal dose-response curve: a dose level and the modelled outcome +/// DELTA from the 0-dose anchor at that dose (so the UI can offset onto any baseline). +public struct DoseCurvePoint: Equatable, Sendable { + public let dose: Int + public let outcomeDelta: Double + public init(dose: Int, outcomeDelta: Double) { + self.dose = dose + self.outcomeDelta = outcomeDelta + } +} + +// MARK: - Engine + +public enum DoseResponseEngine { + + // MARK: Tunables (documented, deterministic — NOT learned). Mirror Kotlin exactly. + + /// Pseudo-count of "prior days": the shrinkage constant k in w = n/(n+k). With n_user = k + /// the estimate is a 50/50 blend; larger k leans harder on the prior for longer. + public static let shrinkageK: Double = 8.0 + /// Paired (dose, next-day) days below which the estimate is `priorDominated` (show the + /// "typical patterns, not yet yours" banner). + public static let minDoseDays: Int = 5 + /// n_user at/above which confidence can reach `.solid` (the personal slope is trusted). + public static let solidDoseDays: Int = 12 + /// The highest dose level the curve enumerates (0…maxCurveDose), matching the 0/1/2/3+ axis. + public static let maxCurveDose: Int = 3 + + // MARK: - Estimate + + /// Estimate the prior-shrunk dose-response for a behaviour against its DEFAULT outcome. + /// + /// - Parameters: + /// - behavior: the dosed behaviour (must have a documented prior or this returns nil). + /// - doseByDay: dose integer per "yyyy-MM-dd" the behaviour was logged with a dose ≥ 0. + /// - outcomeByDay: the daily outcome series keyed "yyyy-MM-dd". + /// - Returns: a `DoseResponse`, or nil when no prior is documented for the behaviour. + public static func estimate(behavior: DosedBehavior, + doseByDay: [String: Int], + outcomeByDay: [String: Double]) -> DoseResponse? { + let outcome = DoseResponsePriors.defaultOutcome(for: behavior) + return estimate(behavior: behavior, outcome: outcome, + doseByDay: doseByDay, outcomeByDay: outcomeByDay) + } + + /// Estimate the prior-shrunk dose-response for a behaviour against a NAMED outcome. + /// Returns nil when `(behaviour, outcome)` has no documented prior to shrink toward. + public static func estimate(behavior: DosedBehavior, + outcome: String, + doseByDay: [String: Int], + outcomeByDay: [String: Double]) -> DoseResponse? { + guard let prior = DoseResponsePriors.prior(for: behavior, outcome: outcome) else { return nil } + + // Pair each logged dose day D with the NEXT-day outcome (D+1) — the L=1 alignment. + var pairs: [(Double, Double)] = [] + // Sort the day keys so the pair order (and thus any float reduction) is deterministic. + for day in doseByDay.keys.sorted() { + let dose = doseByDay[day]! + guard let d1 = CorrelationEngine.shiftDay(day, by: 1), + let outVal = outcomeByDay[d1] else { continue } + pairs.append((Double(dose), outVal)) + } + let nUser = pairs.count + + // Personal OLS slope of outcome on dose (nil if < 3 pairs or no spread in either axis). + let userSlope = CorrelationEngine.pearson(pairs)?.slope + + // Shrinkage weight: 0 with no data, → 1 as data accumulates past k. + let w = Double(nUser) / (Double(nUser) + shrinkageK) + + // Blend, then clamp to the prior's sane range. With no usable personal slope the blend + // falls back to the prior alone (w applied to nothing), which is the honest cold-start. + let blended: Double + if let us = userSlope { + blended = w * us + (1.0 - w) * prior.slopePerUnit + } else { + blended = prior.slopePerUnit + } + let perUnit = clamp(blended, prior.clampLow, prior.clampHigh) + + let priorDominated = nUser < minDoseDays + // Person overrides population only once they have enough data AND the signs disagree + // (e.g. prior says drinking lowers Charge but your slope is ≥ 0). A flat personal slope + // counts as "doesn't move it the way it typically does." + let contradicts: Bool + if let us = userSlope, nUser >= minDoseDays { + contradicts = !sameSign(us, prior.slopePerUnit) + } else { + contradicts = false + } + + let confidence = confidenceFor(nUser: nUser) + + // Curve: relative outcome delta from a 0-dose anchor, dose 0…maxCurveDose, using perUnit. + var curve: [DoseCurvePoint] = [] + for dose in 0...maxCurveDose { + curve.append(DoseCurvePoint(dose: dose, outcomeDelta: Double(dose) * perUnit + 0.0)) // +0.0 normalises -0.0 → 0.0 + } + + return DoseResponse(behavior: behavior, outcome: outcome, perUnit: perUnit, + userSlope: userSlope, priorSlope: prior.slopePerUnit, + weight: w, nUser: nUser, priorDominated: priorDominated, + contradictsPrior: contradicts, confidence: confidence, curve: curve) + } + + // MARK: - Confidence + + /// Calibrating while mostly prior (n_user < minDoseDays); building while blended; solid + /// once the personal fit dominates (n_user ≥ solidDoseDays). + static func confidenceFor(nUser: Int) -> ScoreConfidence { + if nUser < minDoseDays { return .calibrating } + return nUser >= solidDoseDays ? .solid : .building + } + + // MARK: - Helpers (self-contained so the Kotlin mirror is line-for-line) + + /// True when a and b share a sign. Zero is treated as "not the same sign" as a non-zero + /// value (a flat personal slope DOES contradict a non-zero prior — that's the honest read). + static func sameSign(_ a: Double, _ b: Double) -> Bool { + if a > 0 && b > 0 { return true } + if a < 0 && b < 0 { return true } + if a == 0 && b == 0 { return true } + return false + } + + static func clamp(_ x: Double, _ lo: Double, _ hi: Double) -> Double { + Swift.min(Swift.max(x, lo), hi) + } + + /// Round to one decimal place (half away from zero via Foundation's rounded()). + static func round1(_ x: Double) -> Double { (x * 10).rounded() / 10 } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/DoseResponsePriors.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DoseResponsePriors.swift new file mode 100644 index 0000000000..0598157499 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DoseResponsePriors.swift @@ -0,0 +1,93 @@ +import Foundation + +// DoseResponsePriors.swift — the documented, conservative POPULATION priors that the +// per-user dose-response fit shrinks toward until the user has logged enough nights. +// +// Pure data + a tiny lookup. These are deliberately CONSERVATIVE, clearly-labelled +// "typical patterns, not yours" constants — never learned from any user, never updated +// from the field. The shrinkage in DoseResponseEngine blends the user's own OLS slope +// with one of these priors weighted by how much data they have; with no data the user +// sees the prior, with enough data the prior fades out entirely (see DoseResponseEngine). +// +// Each prior is an EFFECT PER INCREMENTAL UNIT of dose on a named outcome: +// - Alcohol → Charge (recovery, 0–100): roughly −Δ points per extra drink. +// - Caffeine → HRV (ms): roughly −Δ ms for each step LATER in the day a caffeine +// dose lands (the caffeine "dose" axis is a TIMING bucket, not mg — copy says so). +// +// Magnitudes are intentionally modest and are surfaced to the user AS priors, framed as +// "typical patterns" — wellness association, never a causal/clinical claim. Values mirror +// the Kotlin DoseResponsePriors twin byte-for-byte so a future sync round-trips. +// +// (Spec: 2026-06-19-v5-insights-correlation-engine-design.md — "Personal dose-response +// with population-prior shrinkage", DoseResponsePriors.swift in the file table.) + +/// Identifies a dosed behaviour whose dose-response has a documented population prior. +/// The raw string is the stable storage / lookup key (mirrors Kotlin's enum `raw`). +public enum DosedBehavior: String, Equatable, Sendable, Codable, CaseIterable { + /// Alcoholic drinks, dose = number of drinks (0/1/2/3+ ⇒ 0,1,2,3). + case alcohol + /// Caffeine, dose = a TIME-OF-DAY bucket (morning/midday/after-2pm/evening ⇒ 0..3); + /// "dose" here is timing intensity (later = stronger), NOT milligrams. + case caffeine +} + +/// A single documented population prior: the typical per-unit effect of a dosed behaviour +/// on one outcome, with a sane clamp range so a runaway extrapolation can never escape it. +public struct DoseResponsePrior: Equatable, Sendable { + /// The dosed behaviour this prior describes. + public let behavior: DosedBehavior + /// The outcome label this prior is expressed in (e.g. "Charge", "HRV"). + public let outcome: String + /// Typical signed effect per ONE extra unit of dose (e.g. −X Charge points per drink). + /// Negative = the outcome typically sits lower with more dose. + public let slopePerUnit: Double + /// Lower clamp for the (shrunk) per-unit effect — keeps a noisy personal slope sane. + public let clampLow: Double + /// Upper clamp for the (shrunk) per-unit effect. + public let clampHigh: Double + + public init(behavior: DosedBehavior, outcome: String, slopePerUnit: Double, + clampLow: Double, clampHigh: Double) { + self.behavior = behavior + self.outcome = outcome + self.slopePerUnit = slopePerUnit + self.clampLow = clampLow + self.clampHigh = clampHigh + } +} + +public enum DoseResponsePriors { + + /// The default outcome each dosed behaviour's headline prior is expressed in. + /// Alcohol's headline effect is on Charge; caffeine's is on HRV (timing proxy). + public static func defaultOutcome(for behavior: DosedBehavior) -> String { + switch behavior { + case .alcohol: return "Charge" + case .caffeine: return "HRV" + } + } + + /// The documented, conservative priors. Kept small and explicit so the Kotlin twin is + /// byte-identical. Magnitudes are "typical, not yours" and are always overridable by the + /// user's own data once they have enough of it (see DoseResponseEngine shrinkage). + /// + /// - Alcohol → Charge: ≈ −5 Charge points per extra drink (clamped −15…+2). + /// - Caffeine → HRV: ≈ −4 ms per step later in the day (clamped −20…+4). + static let table: [DoseResponsePrior] = [ + DoseResponsePrior(behavior: .alcohol, outcome: "Charge", + slopePerUnit: -5.0, clampLow: -15.0, clampHigh: 2.0), + DoseResponsePrior(behavior: .caffeine, outcome: "HRV", + slopePerUnit: -4.0, clampLow: -20.0, clampHigh: 4.0), + ] + + /// Look up the documented prior for a `(behaviour, outcome)` pair, or nil if none is + /// documented (the engine then can't shrink — it returns a prior-less / nil result). + public static func prior(for behavior: DosedBehavior, outcome: String) -> DoseResponsePrior? { + table.first { $0.behavior == behavior && $0.outcome == outcome } + } + + /// Convenience: the prior for a behaviour's default headline outcome. + public static func prior(for behavior: DosedBehavior) -> DoseResponsePrior? { + prior(for: behavior, outcome: defaultOutcome(for: behavior)) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift new file mode 100644 index 0000000000..0ff4645c04 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift @@ -0,0 +1,195 @@ +import Foundation + +// EffectRanker.swift — the unified, LAG-AWARE "what moves your Charge" ranker. +// +// Pure, deterministic, DB-free. Generalises ActivityCostEngine's single-sport D+1 to +// EVERY logged journal behaviour against EVERY daily outcome, and searches a small fixed +// lag set so it can tell "same day" from "shows up the next morning." +// +// For each (behaviour b, outcome o, lag L ∈ {0, +1, +2}): +// 1. behaviourDays = the day keys b was logged on (dose ≥ 1; callers pass the set). +// 2. Pair behaviour day D with outcome day D+L by SHIFTING the outcome map back by L — +// i.e. re-key outcome[D+L] under D via CorrelationEngine.shiftDay(day, by: -L). This is +// exactly the alignment ActivityCostEngine does for D+1, parameterised over the lag and +// reusing the same fixed-UTC day arithmetic, so behaviour day D is compared with the +// outcome that landed L days later. +// 3. Run the EXISTING BehaviorInsights.effect on the shifted outcome map → meanWith, +// meanWithout, delta, cohensD, Welch pApprox, significant (already gated at +// min(nWith, nWithout) ≥ 5). +// 4. Keep, per (b, o), the lag L* with the LARGEST |cohensD| among lags whose effect +// computed AND cleared the significance group gate, carrying L* as the lead/lag. +// +// HONESTY (effect-size first, not stargazing): the primary signal is the effect SIZE + +// n + a ScoreConfidence tier, never a bare "significant" stamp. The lag search is capped +// at the small fixed set {0,1,2} so the comparison count stays bounded and explainable; we +// never claim a behaviour "causes" anything — only that it lines up with a change. +// +// Output: one RankedEffect per (b, o) that produced a usable lag, ranked with the same rule +// as BehaviorInsights.rank (significant first, |cohensD| desc, stable tiebreak), so the feed +// matches the existing Behaviour Effects ordering exactly. Self-contained except for the two +// reused primitives, so the Kotlin twin is line-for-line. +// +// (Spec: 2026-06-19-v5-insights-correlation-engine-design.md — "Lag-aware effect ranking".) + +// MARK: - Result + +/// One ranked, lag-aware behaviour→outcome effect: the best lag's BehaviorEffect plus the +/// lead/lag it was found at and a confidence tier from the paired-day count. +public struct RankedEffect: Equatable, Sendable { + /// The behaviour label (e.g. "Alcohol"). + public let behavior: String + /// The outcome metric label (e.g. "Charge"). + public let outcome: String + /// The lag (in days) at which the strongest honest effect was found: 0 = same day, + /// +1 = the next morning, +2 = two mornings later. + public let lag: Int + /// The measured effect at `lag` (means, delta, Cohen's d, Welch p, significant). + public let effect: BehaviorEffect + /// Per-result certainty tier from the smaller group's size at the chosen lag. + public let confidence: ScoreConfidence + + public init(behavior: String, outcome: String, lag: Int, + effect: BehaviorEffect, confidence: ScoreConfidence) { + self.behavior = behavior + self.outcome = outcome + self.lag = lag + self.effect = effect + self.confidence = confidence + } + + /// Plain-English lead/lag chip text, e.g. "same day" / "next morning" / "2 mornings later". + public var leadLagText: String { + switch lag { + case 0: return "same day" + case 1: return "next morning" + default: return "\(lag) mornings later" + } + } + + /// The sign-aware sentence for this row, reusing BehaviorInsights' renderer plus the + /// lead/lag clause so each card reads "…, showing up the next morning." + public func sentence() -> String { + let base = BehaviorInsights.sentence(effect) + // Drop the trailing period, append the lead/lag, restore it. + let trimmed = base.hasSuffix(".") ? String(base.dropLast()) : base + return "\(trimmed) (\(leadLagText))." + } +} + +// MARK: - Engine + +public enum EffectRanker { + + /// The fixed, bounded lag set searched per (behaviour, outcome). Kept small and explicit + /// — not a fitted VAR — so the multiple-comparison count stays bounded and honest at small n. + public static let lagSet: [Int] = [0, 1, 2] + + /// Paired-day count below which a chosen lag's confidence is `.calibrating` (too thin to + /// shout). This equals BehaviorInsights' significance group gate, so a row that clears the + /// gate is never `.calibrating`. + public static let calibratingBelow: Int = BehaviorInsights.minGroupForSignificance // 5 + /// Paired-day count at/above which a chosen lag's confidence is `.solid` (else `.building`). + public static let solidPairs: Int = 10 + + // MARK: - Rank + + /// Rank every behaviour against one outcome across the lag set, keeping each behaviour's + /// best lag. Mirrors BehaviorInsights.rank's ordering on the surviving rows. + /// + /// - Parameters: + /// - behaviors: per behaviour name, the SET of "yyyy-MM-dd" days it was logged (dose ≥ 1). + /// - outcomeByDay: the daily outcome series keyed "yyyy-MM-dd" (e.g. Charge 0–100). + /// - outcome: the outcome label carried onto each RankedEffect (e.g. "Charge"). + /// - Returns: one RankedEffect per behaviour that produced a usable lag, ranked + /// significant-first, |cohensD| desc, then behaviour name asc. Behaviours with no + /// computable lag are dropped. + public static func rank(behaviors: [String: Set], + outcomeByDay: [String: Double], + outcome: String) -> [RankedEffect] { + var rows: [RankedEffect] = [] + // Sort behaviour names so the build order is deterministic regardless of dict order. + for name in behaviors.keys.sorted() { + let days = behaviors[name]! + if let row = bestLag(behaviorDays: days, outcomeByDay: outcomeByDay, + behavior: name, outcome: outcome) { + rows.append(row) + } + } + return sorted(rows) + } + + /// Find the best-lag RankedEffect for ONE behaviour against ONE outcome, or nil when no + /// lag in `lagSet` yields a computable effect that clears the group gate. + public static func bestLag(behaviorDays: Set, + outcomeByDay: [String: Double], + behavior: String, + outcome: String) -> RankedEffect? { + var best: (lag: Int, effect: BehaviorEffect)? + for lag in lagSet { + let shifted = shiftedOutcome(outcomeByDay, byLag: lag) + guard let e = BehaviorInsights.effect(behaviorDays: behaviorDays, + outcomeByDay: shifted, + behavior: behavior, + outcome: outcome) else { continue } + // Group gate: a lag only competes if both sides clear the significance minimum, + // so a 2-day lag can't win on a fluke. (Mirrors BehaviorInsights.significant's n-gate.) + guard Swift.min(e.nWith, e.nWithout) >= BehaviorInsights.minGroupForSignificance else { continue } + + if let cur = best { + // Largest |cohensD| wins; ties break to the SMALLER lag (prefer same-day / + // shorter lead over a longer one when the effect size is identical). + let better = abs(e.cohensD) > abs(cur.effect.cohensD) + || (abs(e.cohensD) == abs(cur.effect.cohensD) && lag < cur.lag) + if better { best = (lag, e) } + } else { + best = (lag, e) + } + } + guard let chosen = best else { return nil } + let pairs = Swift.min(chosen.effect.nWith, chosen.effect.nWithout) + return RankedEffect(behavior: behavior, outcome: outcome, lag: chosen.lag, + effect: chosen.effect, confidence: confidence(forPairs: pairs)) + } + + // MARK: - Lag alignment + + /// Re-key the outcome series so behaviour day D is paired with the outcome that landed + /// `lag` days later: out'[D] = out[D+lag]. We move the VALUE from key D+lag back to key D + /// by shifting each existing key BACKWARD by `lag` (shiftDay(day, by: -lag)). Then a plain + /// BehaviorInsights split on behaviour day D reads the D+lag outcome — the same join + /// ActivityCostEngine performs for D+1, generalised. `lag == 0` is the identity map. + static func shiftedOutcome(_ outcomeByDay: [String: Double], byLag lag: Int) -> [String: Double] { + if lag == 0 { return outcomeByDay } + var out: [String: Double] = [:] + out.reserveCapacity(outcomeByDay.count) + for (day, value) in outcomeByDay { + // The outcome ON day `day` belongs to behaviour day `day - lag`. + if let behaviourKey = CorrelationEngine.shiftDay(day, by: -lag) { + out[behaviourKey] = value + } + } + return out + } + + // MARK: - Confidence + + /// Confidence from the smaller group's paired-day count: below the group gate → + /// `.calibrating`; gate… ScoreConfidence { + if pairs < calibratingBelow { return .calibrating } + return pairs >= solidPairs ? .solid : .building + } + + // MARK: - Ranking + + /// Stable rank matching BehaviorInsights.rank: significant first, |cohensD| desc, then + /// behaviour name ascending. + static func sorted(_ rows: [RankedEffect]) -> [RankedEffect] { + rows.sorted { a, b in + if a.effect.significant != b.effect.significant { return a.effect.significant } + let la = abs(a.effect.cohensD), lb = abs(b.effect.cohensD) + if la != lb { return la > lb } + return a.behavior < b.behavior + } + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/FitnessAgeEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/FitnessAgeEngine.swift new file mode 100644 index 0000000000..726d356e1d --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/FitnessAgeEngine.swift @@ -0,0 +1,259 @@ +import Foundation + +// FitnessAgeEngine.swift — on-device "Fitness Age" from resting HR + activity + profile. +// +// INDEPENDENT implementation of published, peer-reviewed methods (NOT medical advice; a fitness +// comparison, never a "biological age"): +// • VO₂max estimate: Nes et al. 2011 HUNT non-exercise model, WAIST-CIRCUMFERENCE variant — the +// CONFIRMED original (Nes 2011, Med Sci Sports Exerc 43(11):2024-2030; coefficients reproduced +// verbatim in JAHA/Ball State 2020, PMC7428991, and corroborated by CERG/NTNU). SEE ≈ 5.70 (men) / +// 5.14 (women). (The BMI-variant coefficients that circulate from a 2019 secondary source were NOT +// reliably confirmable against the original and are deliberately NOT used here.) +// • Physical-activity index: HUNT1 PA-Q (Kurtze 2008), frequency×intensity×duration ∈ [0, 15]; +// NOOP has no questionnaire, so it RECONSTRUCTS each factor from measured weekly signals. +// • Fitness Age: invert the SAME Nes equation self-consistently — the normative curve is the Nes +// model at population-reference resting HR and PA-index. The body term (waist) appears in both the +// user's estimate and the normative curve, so it CANCELS: Fitness Age depends only on how the +// user's resting HR and activity compare to a reference-fit peer of their age. This means the +// headline number needs no body measurement at all, and an average-fitness person maps to their +// own chronological age by construction. (We do NOT mix in a different population's reference +// curve — e.g. the US FRIEND equation — because the scale offset would bias everyone by ~15 yr.) +// +// All numbers below are literal published coefficients. Do not change without re-verifying the source. +public enum FitnessAgeEngine { + + // MARK: - Nes 2011 waist-circumference coefficients (JAHA PMC7428991, confirmed vs CERG) + // VO₂max = intercept − ageC·age + paiC·PA − wcC·waist − rhrC·RHR + static let menIntercept = 100.27, menAge = 0.296, menWC = 0.369, menRHR = 0.155, menPAI = 0.226 + static let womenIntercept = 74.74, womenAge = 0.247, womenWC = 0.259, womenRHR = 0.114, womenPAI = 0.198 + public static let seeMen = 5.70, seeWomen = 5.14 + + // MARK: - Normative reference point (the "average peer" the Fitness Age compares against) + /// Population-reference resting HR (bpm): an average healthy adult. At this RHR + paiReference a + /// person's Fitness Age equals their chronological age by construction. + public static let restingHRReference = 65.0 + /// Population-reference PA-index (0–15): ≈ "moderately active, a few sessions a week". + public static let paiReference = 5.0 + + /// Displayed uncertainty band (years) — a presentation constant; the per-reading Nes SEE (≈5–6 + /// ml/kg/min over the ~0.3/yr age slope) is far wider, so we compute on rolling 7-day medians and + /// show a conservative fixed ±band with a "fitness comparison, not a biological age" disclaimer. + public static let displayBandYears = 5.0 + public static let minAge = 20.0, maxAge = 80.0 + + private static func isFemale(_ sex: String) -> Bool { sex.lowercased() == "female" } + + /// Coefficient tuple for the user's sex (intercept, ageC, wcC, rhrC, paiC). Non-binary uses men's. + private static func coeffs(_ sex: String) -> (Double, Double, Double, Double, Double) { + isFemale(sex) + ? (womenIntercept, womenAge, womenWC, womenRHR, womenPAI) + : (menIntercept, menAge, menWC, menRHR, menPAI) + } + + /// Body-mass index from metric height/weight (used by callers; not required for Fitness Age). + public static func bmi(weightKg: Double, heightCm: Double) -> Double { + let m = heightCm / 100.0 + guard m > 0 else { return 0 } + return weightKg / (m * m) + } + + /// Nes 2011 waist-variant VO₂max (ml/kg/min). Optional display metric — needs a waist measurement. + public static func estimateVO2max(age: Double, sex: String, waistCm: Double, + restingHR: Double, paIndex: Double) -> Double { + let (intercept, ageC, wcC, rhrC, paiC) = coeffs(sex) + return intercept - ageC*age + paiC*paIndex - wcC*waistCm - rhrC*restingHR + } + + /// Self-consistent Fitness Age (years, clamped [20,80]). The waist term cancels, so this needs only + /// age, sex, resting HR and the PA-index: `FA = age + (rhrC·(RHR−RHRref) − paiC·(PAI−PAIref)) / ageC`. + public static func fitnessAge(age: Double, sex: String, restingHR: Double, paIndex: Double) -> Double { + let (_, ageC, _, rhrC, paiC) = coeffs(sex) + let fa = age + (rhrC*(restingHR - restingHRReference) - paiC*(paIndex - paiReference)) / ageC + return min(maxAge, max(minAge, fa)) + } + + /// Reconstruct the HUNT PA-index (0–15 = frequency×intensity×duration) from measured weekly + /// aggregates. Bucket edges mirror the HUNT1 PA-Q response options (Kurtze 2008): + /// frequency ∈ {0.0, 0.5, 1.0, 2.5, 5.0} ← active days in the last 7 + /// intensity ∈ {1, 2, 3} ← share of active time at high intensity (HR zone 4–5) + /// duration ∈ {0.10, 0.38, 0.75, 1.0} ← average active minutes per active day + public static func physicalActivityIndex(activeDaysPerWeek: Int, + avgActiveMinutesPerDay: Double, + highIntensityFraction: Double) -> Double { + let frequency: Double + switch activeDaysPerWeek { + case ..<1: frequency = 0.0 + case 1: frequency = 0.5 + case 2: frequency = 1.0 + case 3...4: frequency = 2.5 + default: frequency = 5.0 // 5+ days ≈ "almost every day" + } + let intensity: Double + switch highIntensityFraction { + case ..<0.15: intensity = 1.0 // easy, no real sweat + case ..<0.5: intensity = 2.0 // sweaty / breathless + default: intensity = 3.0 // near exhaustion + } + let duration: Double + switch avgActiveMinutesPerDay { + case ..<15: duration = 0.10 + case ..<30: duration = 0.38 + case ..<60: duration = 0.75 + default: duration = 1.0 + } + if frequency == 0 { return 0 } + return frequency * intensity * duration + } + + /// PA-index (0–15) from NOOP's measured weekly load — the UNIVERSAL path the orchestrator uses + /// (works on any device, since `strain` is computed from HR alone; HR-zone minutes only exist for + /// CSV-importers). `strain` (0–100, TRIMP-based) already integrates intensity × duration, so we map + /// the mean active-day strain straight to the HUNT intensity×duration PRODUCT (0–3) and multiply by + /// the frequency factor — deliberately NOT re-deriving intensity and duration separately, which + /// would double-count the same HR load. Calibrated so the reference peer (≈4 active days, mean + /// strain ≈60) lands near PA-index 5. + public static func physicalActivityIndexFromStrain(activeDaysPerWeek: Int, + meanActiveStrain: Double) -> Double { + let frequency: Double + switch activeDaysPerWeek { + case ..<1: frequency = 0.0 + case 1: frequency = 0.5 + case 2: frequency = 1.0 + case 3...4: frequency = 2.5 + default: frequency = 5.0 + } + if frequency == 0 { return 0 } + let intensityDuration = min(3.0, max(0.0, meanActiveStrain / 30.0)) // strain 30→1, 60→2, 90→3 + return frequency * intensityDuration + } + + /// Full Fitness Age from already-aggregated weekly inputs. Returns nil only if RHR or age is + /// missing (the headline number needs nothing else). `vo2max` is filled only when a waist + /// measurement is supplied; callers gate data-coverage (≥4 of 7 days) separately. + public static func compute(age: Double, sex: String, restingHR: Double, paIndex: Double, + waistCm: Double? = nil, lowerConfidence: Bool = false) -> FitnessAgeResult? { + guard age > 0, restingHR > 0 else { return nil } + let fa = fitnessAge(age: age, sex: sex, restingHR: restingHR, paIndex: paIndex) + let vo2: Double? + if let w = waistCm, w > 0 { + vo2 = estimateVO2max(age: age, sex: sex, waistCm: w, restingHR: restingHR, paIndex: paIndex) + } else { + vo2 = nil + } + let nb = sex.lowercased() != "male" && sex.lowercased() != "female" + return FitnessAgeResult( + vo2max: vo2, fitnessAge: fa, chronoAge: age, deltaYears: age - fa, + bandYears: displayBandYears, lowerConfidence: lowerConfidence || nb) + } +} + +// MARK: - Readiness checklist +// +// Transparency over a black-box number: show the user exactly which inputs we have, grouped by what +// each one unlocks, and a single confidence verdict. Weight/height/waist deliberately sit under "your +// VO₂max number" — NOT under the Fitness Age — because the body term cancels out of the age (see the +// engine doc); claiming weight sharpens the age would be dishonest. The age is driven by age, sex, and +// the COVERAGE of resting-HR + activity over the last 7 days. + +public enum FitnessReadinessStatus: String, Sendable { case satisfied, partial, missing } + +/// What a given input affects — so the checklist can be honest about its impact. +public enum FitnessReadinessRole: String, Sendable { + case drivesAge // required for / sharpens the headline Fitness Age + case unlocksVO2max // only powers the separate VO₂max estimate +} + +public struct FitnessReadinessItem: Equatable, Sendable { + public let key: String + public let label: String + public let status: FitnessReadinessStatus + public let required: Bool // true → the number can't be computed without it + public let role: FitnessReadinessRole + public let detail: String // short hint, e.g. "4 of last 7 nights" + public init(key: String, label: String, status: FitnessReadinessStatus, + required: Bool, role: FitnessReadinessRole, detail: String) { + self.key = key; self.label = label; self.status = status + self.required = required; self.role = role; self.detail = detail + } +} + +public enum FitnessAgeConfidence: String, Sendable { + case ready // everything we need, good coverage + case estimate // computes, but partial coverage — a softer claim + case notReady // can't compute yet (missing a required input) +} + +public struct FitnessAgeReadiness: Equatable, Sendable { + public let items: [FitnessReadinessItem] + public let confidence: FitnessAgeConfidence + public var canCompute: Bool { confidence != .notReady } + public init(items: [FitnessReadinessItem], confidence: FitnessAgeConfidence) { + self.items = items; self.confidence = confidence + } +} + +extension FitnessAgeEngine { + /// Minimum nights of resting-HR before the headline can be computed at all. + public static let minCoverageDays = 4 + /// Coverage at/above which an input reads as fully satisfied (a "confident" week). + public static let goodCoverageDays = 6 + + private static func coverageStatus(_ days: Int, floor: Int) -> FitnessReadinessStatus { + if days >= goodCoverageDays { return .satisfied } + if days >= floor || days > 0 { return .partial } + return .missing + } + + /// Build the readiness checklist + overall confidence from the inputs we have. The orchestrator + /// passes profile-completeness flags and the 7-day coverage counts. + public static func assessReadiness(hasAge: Bool, hasSex: Bool, + rhrDays: Int, activityDays: Int, + hasHeightWeight: Bool, hasWaist: Bool) -> FitnessAgeReadiness { + let items: [FitnessReadinessItem] = [ + FitnessReadinessItem(key: "age", label: "Your age", + status: hasAge ? .satisfied : .missing, required: true, role: .drivesAge, + detail: hasAge ? "Set" : "Add it in Settings"), + FitnessReadinessItem(key: "sex", label: "Biological sex", + status: hasSex ? .satisfied : .missing, required: true, role: .drivesAge, + detail: hasSex ? "Set" : "Add it in Settings"), + FitnessReadinessItem(key: "rhr", label: "Resting heart rate", + status: coverageStatus(rhrDays, floor: minCoverageDays), required: true, role: .drivesAge, + detail: "\(rhrDays) of last 7 nights"), + FitnessReadinessItem(key: "activity", label: "Recent activity", + status: coverageStatus(activityDays, floor: minCoverageDays), required: false, role: .drivesAge, + detail: "\(activityDays) of last 7 days"), + FitnessReadinessItem(key: "bodyMetrics", label: "Height & weight", + status: hasHeightWeight ? .satisfied : .missing, required: false, role: .unlocksVO2max, + detail: hasHeightWeight ? "Unlocks your VO₂max" : "Add to also see VO₂max"), + FitnessReadinessItem(key: "waist", label: "Waist (optional)", + status: hasWaist ? .satisfied : .missing, required: false, role: .unlocksVO2max, + detail: hasWaist ? "Sharpens VO₂max" : "Optional - sharpens VO₂max"), + ] + let confidence: FitnessAgeConfidence + if !hasAge || !hasSex || rhrDays < minCoverageDays { + confidence = .notReady + } else if rhrDays >= goodCoverageDays && activityDays >= goodCoverageDays { + confidence = .ready + } else { + confidence = .estimate + } + return FitnessAgeReadiness(items: items, confidence: confidence) + } +} + +/// A computed Fitness Age plus the inputs needed to present it honestly. `vo2max` is optional — the +/// headline Fitness Age does not require a body measurement; the VO₂max estimate does (a waist entry). +public struct FitnessAgeResult: Equatable, Sendable { + public let vo2max: Double? // estimated VO₂max (ml/kg/min), nil without a waist measurement + public let fitnessAge: Double // years, clamped [20, 80] + public let chronoAge: Double // the user's calendar age + public let deltaYears: Double // chronoAge − fitnessAge (positive = younger than your age) + public let bandYears: Double // ± presentation band + public let lowerConfidence: Bool // true for non-binary (sex-specific model) or sparse data + + public init(vo2max: Double?, fitnessAge: Double, chronoAge: Double, + deltaYears: Double, bandYears: Double, lowerConfidence: Bool) { + self.vo2max = vo2max; self.fitnessAge = fitnessAge; self.chronoAge = chronoAge + self.deltaYears = deltaYears; self.bandYears = bandYears; self.lowerConfidence = lowerConfidence + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/FusionResolver.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/FusionResolver.swift new file mode 100644 index 0000000000..e0671d2b2a --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/FusionResolver.swift @@ -0,0 +1,115 @@ +import Foundation + +// MARK: - FusionResolver (v5 — Local Multi-Device Fusion) +// +// Pure, deterministic, on-device fusion per +// docs/superpowers/specs/2026-06-19-v5-local-multi-device-fusion-design.md §1–§2. Given the per-source +// values for ONE (metric, day), it: +// 1. ranks sources by trust tier (MetricArbitrationPolicy), stable tiebreak, and picks the winner's +// value VERBATIM (best signal wins — never an average); +// 2. cross-validates the other sources against the winner and classifies agreement as +// single / agree / minorDelta / conflict (the honest part — conflicts are shown, not merged). +// No I/O — the Repository feeds it rows it already loads. Value-for-value Kotlin twin in +// android/.../analytics/FusionResolver.kt. +public enum FusionResolver { + + /// Resolve one metric for one day from each source's value. `metricKey` is the resolver series key + /// (e.g. "rhr", "steps", "sleep_total_min"); it picks the trust tiers and tolerance via + /// `MetricArbitrationPolicy`. Returns nil only when `inputs` is empty (no source has the metric). + /// + /// The winner is the lowest-tier source, ties broken by `sourcePriority` (stable, deterministic). + /// Its value passes through unchanged. The agreement state classifies how far the OTHER sources sit + /// from the winning value, per the metric's tolerance band. + public static func resolve(metricKey: String, inputs: [FusionInput]) -> FusedMetricPoint? { + guard !inputs.isEmpty else { return nil } + let kind = MetricArbitrationPolicy.kind(forKey: metricKey) + + // Build a contributor for every source, tagged with its trust tier + reason. + let contributorsUnsorted: [ContributingSource] = inputs.map { input in + ContributingSource( + source: input.source, + value: input.value, + tier: MetricArbitrationPolicy.tier(metric: kind, source: input.source), + sourcePriority: MetricArbitrationPolicy.sourcePriority(input.source), + reason: MetricArbitrationPolicy.reason(metric: kind, source: input.source) + ) + } + + // Winner = lowest tier, then lowest source-priority. Stable: equal keys keep input order via a + // final index tiebreak so the result is fully deterministic across platforms. + let ranked = contributorsUnsorted.enumerated().sorted { lhs, rhs in + if lhs.element.tier != rhs.element.tier { return lhs.element.tier < rhs.element.tier } + if lhs.element.sourcePriority != rhs.element.sourcePriority { + return lhs.element.sourcePriority < rhs.element.sourcePriority + } + return lhs.offset < rhs.offset + }.map { $0.element } + + let winner = ranked[0] + let agreement = classify(metric: kind, winningValue: winner.value, contributors: ranked) + + return FusedMetricPoint( + metric: metricKey, + value: winner.value, + winningSource: winner.source, + contributors: ranked, + agreement: agreement + ) + } + + /// Classify how the non-winning sources agree with `winningValue`, using the metric's tolerance. + /// Worst case across all other sources wins (one conflicting source makes the point a conflict). + /// One source ⇒ `.single` (nothing to cross-check). Public so the Repository can reuse it directly. + public static func classify(metric: MetricArbitrationPolicy.MetricKind, + winningValue: Double, + contributors: [ContributingSource]) -> AgreementState { + // Only one source reported the metric → nothing to compare against. + guard contributors.count >= 2 else { return .single } + + let tol = MetricArbitrationPolicy.tolerance(metric: metric) + var worst: AgreementState = .agree + + for c in contributors.dropFirst() { // skip the winner (index 0) + let delta = abs(c.value - winningValue) + let agreeEdge: Double + let minorEdge: Double + if tol.isPercent { + // Percentage band is relative to the winning value's magnitude. With a zero winner, + // any non-zero second value can't be a fraction of it → fall back to absolute deltas. + let base = abs(winningValue) + agreeEdge = tol.agree * base + minorEdge = tol.minorDelta * base + } else { + agreeEdge = tol.agree + minorEdge = tol.minorDelta + } + + let state: AgreementState + if delta <= agreeEdge { + state = .agree + } else if delta <= minorEdge { + state = .minorDelta + } else { + state = .conflict + } + worst = worse(worst, state) + } + return worst + } + + /// Order of severity for the worst-case fold: agree < minorDelta < conflict. (`single` never + /// enters here — it's the >= 2 guard's job.) + private static func severity(_ s: AgreementState) -> Int { + switch s { + case .single: return 0 + case .agree: return 1 + case .minorDelta: return 2 + case .conflict: return 3 + } + } + + /// The more-severe of two agreement states. + private static func worse(_ a: AgreementState, _ b: AgreementState) -> AgreementState { + severity(a) >= severity(b) ? a : b + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/FusionTypes.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/FusionTypes.swift new file mode 100644 index 0000000000..e91292bb26 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/FusionTypes.swift @@ -0,0 +1,109 @@ +import Foundation + +// MARK: - Fusion value types (v5 — Local Multi-Device Fusion) +// +// The plain inputs/outputs for the pure fusion engine described in +// docs/superpowers/specs/2026-06-19-v5-local-multi-device-fusion-design.md. No I/O, no model, no +// network — the Repository feeds these rows it already loads, the engine returns a resolved point. +// These deliberately mirror the existing `DailyMetricSource` / `SourcedDailyMetric` provenance +// vocabulary (Repository.swift L61-82), generalised to cover every importer source NOOP writes. + +/// Where a fused number came from. The rawValue is the canonical source id (`Repository.whoopSource` +/// etc.) so a `FusionSource` round-trips to/from the stored `deviceId` / source string without a +/// lookup table. +public enum FusionSource: String, Equatable, Sendable, CaseIterable, Codable { + /// Imported WHOOP record (CSV/zip export under the strap's `deviceId`, e.g. "my-whoop"). + case whoopImport = "my-whoop" + /// NOOP-computed score derived on-device from the raw strap streams (the "$deviceId-noop" sibling). + case noopComputed = "my-whoop-noop" + /// Apple Health aggregate of a declared-compatible quantity. + case appleHealth = "apple-health" + /// Nutrition CSV import (single-source passthrough — calories/macros). + case nutritionCsv = "nutrition-csv" + /// Locally-cached fallback row with no richer provenance. + case localCache = "local-cache" + + /// Human-facing source name for a provenance pill ("from WHOOP"). Never a clinical claim. + public var displayName: String { + switch self { + case .whoopImport: return "WHOOP" + case .noopComputed: return "NOOP" + case .appleHealth: return "Apple Health" + case .nutritionCsv: return "Nutrition" + case .localCache: return "Cached" + } + } +} + +/// How well a metric's value agrees across the sources that reported it on the same day. +/// `agree` → quiet parenthetical; `minorDelta` → show both, neutral; `conflict` → flag, never merge. +/// Deterministic threshold output (no statistics beyond a clamp + a percentage); see +/// `MetricArbitrationPolicy.tolerance(metric:)`. +public enum AgreementState: String, Equatable, Sendable, CaseIterable, Codable { + /// Only one source reported the metric — nothing to cross-check, no chip shown. + case single + /// Within the metric's tolerance — sources agree. + case agree + /// Outside tolerance but inside the plausible-spread band — show both, no alarm. + case minorDelta + /// Large divergence — flag prominently, keep both, never silently average. + case conflict +} + +/// One source's value for a `(metric, day)`, with the trust tier the policy assigned it. The winner +/// is the lowest `tier` (most trusted), ties broken by `sourcePriority` (stable). `reason` is the +/// published, plain-English justification ("counts directly", "best stager") — the honesty contract. +public struct ContributingSource: Equatable, Sendable { + public let source: FusionSource + public let value: Double + /// Trust tier (lower = more trusted); from `MetricArbitrationPolicy.tier(metric:source:)`. + public let tier: Int + /// Stable tiebreak within a tier (lower wins); from the policy's source ordering. + public let sourcePriority: Int + /// The visible "best signal" reason for this source on this metric. Never "accurate"/"correct". + public let reason: String + + public init(source: FusionSource, value: Double, tier: Int, sourcePriority: Int, reason: String) { + self.source = source + self.value = value + self.tier = tier + self.sourcePriority = sourcePriority + self.reason = reason + } +} + +/// The fused result for one `(metric, day)`: the winning value, the source that supplied it, every +/// contributor (for the compare sheet), and the agreement classification. Pure data — existing +/// consumers that ignore `agreement` are unaffected. +public struct FusedMetricPoint: Equatable, Sendable { + /// The metric key this point resolves (matches the resolver's series keys, e.g. "rhr", "steps"). + public let metric: String + /// The chosen value (verbatim from `winningSource`'s row — never an average). + public let value: Double + /// The source that supplied `value` (highest trust, stable tiebreak). + public let winningSource: FusionSource + /// Every source that reported this metric for the day, winner first, then by tier/priority. + public let contributors: [ContributingSource] + /// Cross-validation outcome across `contributors`. + public let agreement: AgreementState + + public init(metric: String, value: Double, winningSource: FusionSource, + contributors: [ContributingSource], agreement: AgreementState) { + self.metric = metric + self.value = value + self.winningSource = winningSource + self.contributors = contributors + self.agreement = agreement + } +} + +/// A single source's raw input to the fusion engine: a value for a metric on a day. The Repository +/// builds these from rows it already reads (it does the I/O); the engine stays pure. +public struct FusionInput: Equatable, Sendable { + public let source: FusionSource + public let value: Double + public init(source: FusionSource, value: Double) { + self.source = source + self.value = value + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/GuidedCaptureProgress.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/GuidedCaptureProgress.swift new file mode 100644 index 0000000000..c0ac4d0bbd --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/GuidedCaptureProgress.swift @@ -0,0 +1,30 @@ +import Foundation + +// GuidedCaptureProgress.swift - the pure state machine for the guided "wear it N nights" Sleep +// (and N days Battery) capture. No scheduling, no IO; the app reuses ScheduledDebugExport for the +// daily fire and feeds this the counts. A night with no data is a recorded GAP, never a stall +// (spec section 12: the morning nudge records "no data this night" rather than stalling). +// No em-dashes. + +public enum GuidedCaptureProgress: Equatable, Sendable { + case capturing(done: Int, target: Int) + case complete + + /// `nightsWithData` = nights (or days) that produced usable data; `nightsElapsed` = calendar + /// units since start. Complete once enough nights have data, regardless of gaps. + public static func evaluate(target: Int, nightsWithData: Int, nightsElapsed: Int) -> GuidedCaptureProgress { + if nightsWithData >= target { return .complete } + return .capturing(done: nightsWithData, target: target) + } + + /// The morning-nudge label. A gap night reads honestly via `gapNudge()`. + public static func label(for state: GuidedCaptureProgress) -> String { + switch state { + case .complete: return "Capture complete. Tap Report to export." + case let .capturing(done, target): return "Captured \(done) of \(target) nights. Wear it again tonight." + } + } + + /// The gap-night nudge, shown when a scheduled morning fire found no night data. + public static func gapNudge() -> String { "No data last night. Wear the strap tonight to continue." } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRDownPacer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRDownPacer.swift new file mode 100644 index 0000000000..b6670bb5ad --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRDownPacer.swift @@ -0,0 +1,139 @@ +import Foundation + +// HRDownPacer.swift — the L2 "buzz-below-heart-rate" relaxation metronome. Give the heart a felt rhythm a +// few bpm BELOW its current rate; HR tends to drift toward an external rhythmic cue (ISWC 2025). PURE + +// unit-tested; the live controller reads smoothed HR off `AppModel.bpm`, calls `next(...)`, fires ONE +// light buzz per returned interval, and re-asks every recompute window. No I/O / BLE here. +// +// See docs/superpowers/specs/2026-06-19-v5-haptic-biofeedback-design.md (L2). +// +// SAFETY ENVELOPE (a relaxation metronome, NOT cardiac control — bounded, never therapeutic): +// • Target tempo T = smoothedHR − Δ, where Δ RAMPS from `startDeltaBpm` to `maxDeltaBpm` over the +// session so the cue trails the heart down rather than yanking it. +// • T never drops below `hrFloorBpm` (a safe rate) and never more than `maxDeltaBpm` below live HR. +// • The cue TRAILS the heart: T is recomputed every `recomputeSeconds` from the new smoothed HR, so it +// follows HR down instead of dragging it. +// • Auto-stops when HR settles near a calm target, on timeout (`maxDurationSeconds`), or on user stop. +// • If HR DIDN'T fall, the caller says so plainly — no fabricated success (project evidence-first rule). +// +// We never claim it "lowers your heart rate" as a therapeutic outcome — it offers a rhythm to relax toward. + +public enum HRDownPacer { + + // MARK: - Config + + /// The L2 safety + behaviour envelope. Defaults are conservative (spec §L2 / Open Q4); the caller may + /// expose a subset. All bpm values are beats/min; durations are seconds. + public struct Config: Equatable, Sendable { + /// Initial Δ below live HR at session start (gentle). + public var startDeltaBpm: Double + /// Maximum Δ below live HR (a felt cue, never a shock). + public var maxDeltaBpm: Double + /// Seconds over which Δ ramps from start → max. + public var deltaRampSeconds: Double + /// Absolute floor for the target tempo — never pace below this rate. + public var hrFloorBpm: Double + /// Recompute the target every this-many seconds from fresh smoothed HR (the cue trails the heart). + public var recomputeSeconds: Double + /// Stop once smoothed HR is at/under this calm target (the session has done its job). + public var calmTargetBpm: Double + /// Hard cap on session length. + public var maxDurationSeconds: Double + + public init(startDeltaBpm: Double = 3.0, + maxDeltaBpm: Double = 8.0, + deltaRampSeconds: Double = 120.0, + hrFloorBpm: Double = 50.0, + recomputeSeconds: Double = 15.0, + calmTargetBpm: Double = 60.0, + maxDurationSeconds: Double = 180.0) { + self.startDeltaBpm = startDeltaBpm + self.maxDeltaBpm = maxDeltaBpm + self.deltaRampSeconds = deltaRampSeconds + self.hrFloorBpm = hrFloorBpm + self.recomputeSeconds = recomputeSeconds + self.calmTargetBpm = calmTargetBpm + self.maxDurationSeconds = maxDurationSeconds + } + + /// The conservative shipped default envelope. + public static let `default` = Config() + } + + // MARK: - Step output + + /// The next step the metronome should take: either fire a pulse at `intervalMs` (one light buzz per + /// target beat), or `stop` with a reason. When `stop`, `intervalMs` is nil. `targetBpm` is the tempo + /// the controller settled on this step (for the live "78 → settling" UI / logs). + public struct Step: Equatable, Sendable { + /// Inter-pulse interval in ms (60000 / targetBpm), or nil when stopping. + public let intervalMs: Int? + /// True when the session should end now. + public let stop: Bool + /// The target tempo (bpm) chosen this step, or nil when stopping with no tempo. + public let targetBpm: Double? + /// Why we stopped (nil while running) — for an honest outcome line. + public let stopReason: StopReason? + + public init(intervalMs: Int?, stop: Bool, targetBpm: Double?, stopReason: StopReason?) { + self.intervalMs = intervalMs; self.stop = stop + self.targetBpm = targetBpm; self.stopReason = stopReason + } + } + + /// Why an L2 session ended — drives the honest outcome copy. + public enum StopReason: String, Equatable, Sendable { + /// HR reached the calm target — the session did its job. + case settled + /// The max-duration cap was hit. + case timeout + /// Live HR was implausible / out of the resting band (caller should gate before starting). + case invalidHR + } + + // MARK: - Controller + + /// Compute the next metronome step from the current smoothed HR and the elapsed session time. + /// + /// - `currentHR`: latest SMOOTHED live HR (bpm). The caller smooths; the pacer trusts it. + /// - `elapsed`: seconds since session start (drives both the Δ ramp and the timeout). + /// + /// Returns a `Step`: while running, `intervalMs` paces one light pulse per target beat at a tempo + /// `currentHR − Δ(elapsed)`, BOUNDED below by `hrFloorBpm` and by `currentHR − maxDeltaBpm`. Stops + /// (settled / timeout / invalidHR). Pure + monotone in the documented sense: for a given config a + /// non-increasing HR trajectory yields non-increasing target tempos, so the cue only ever trails down. + public static func next(currentHR: Double, elapsed: Double, config: Config = .default) -> Step { + // Implausible HR (caller should gate on the resting band; this is the last-ditch guard). + guard currentHR.isFinite, currentHR > 0 else { + return Step(intervalMs: nil, stop: true, targetBpm: nil, stopReason: .invalidHR) + } + if elapsed >= config.maxDurationSeconds { + return Step(intervalMs: nil, stop: true, targetBpm: nil, stopReason: .timeout) + } + if currentHR <= config.calmTargetBpm { + return Step(intervalMs: nil, stop: true, targetBpm: nil, stopReason: .settled) + } + + // Δ ramps linearly start → max over `deltaRampSeconds`, then holds at max. + let delta = rampedDelta(elapsed: elapsed, config: config) + + // Target = HR − Δ, but never below the floor and never below the calm target either (we'd have + // stopped). Clamp also guarantees we never pace *above* HR. + var target = currentHR - delta + if target < config.hrFloorBpm { target = config.hrFloorBpm } + if target > currentHR { target = currentHR } // defensive: never pace at/above live HR + // Keep the cue meaningfully below the heart: at least 1 bpm under, so it's a "below-HR" metronome. + if target > currentHR - 1.0 { target = max(config.hrFloorBpm, currentHR - 1.0) } + + let intervalMs = Int((60_000.0 / target).rounded()) + return Step(intervalMs: intervalMs, stop: false, targetBpm: target, stopReason: nil) + } + + /// The Δ-below-HR for a given elapsed time: linear ramp `startDeltaBpm → maxDeltaBpm` over + /// `deltaRampSeconds`, clamped to `maxDeltaBpm` after. Exposed for tests / the UI ramp readout. + public static func rampedDelta(elapsed: Double, config: Config = .default) -> Double { + guard config.deltaRampSeconds > 0 else { return config.maxDeltaBpm } + let t = min(max(elapsed, 0), config.deltaRampSeconds) / config.deltaRampSeconds + return config.startDeltaBpm + (config.maxDeltaBpm - config.startDeltaBpm) * t + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer+Trace.swift new file mode 100644 index 0000000000..ac025a10a9 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer+Trace.swift @@ -0,0 +1,73 @@ +import Foundation + +// HRVAnalyzer+Trace.swift - the HRV & Autonomic test-mode cleaning trace. +// +// Recomputes the cleaning-pipeline counts (range filter, Malik ectopic rejection, the minBeats gate, +// the spot rejected-fraction gate) from the SAME raw RR the analyzer reads, then reuses analyze(...) +// verbatim for the result so the trace can never disagree with the RMSSD/SDNN the screen shows. Pure +// and side-effect-free: no clock, no I/O, so a fixture beat series pins the exact lines. The HRV test +// mode gates this behind TestCentre.active(.hrv) at the call site (the spot reading); when the mode is +// off it is never called, so there is zero cost. No em-dashes. Counts and ms only, no PII. + +extension HRVAnalyzer { + + /// Side-effect-free diagnostic twin of `analyze(rawRR:maxRejectedFraction:)`: returns the SAME + /// HRVResult analyze(...) would, plus the cleaning trace. Reports nInput / nClean / rejected fraction, + /// RMSSD / SDNN / meanNN, whether the `minBeats` gate cleared, the range + Malik ectopic rejection + /// counts, and (when a ceiling is supplied) the spot rejected-fraction honesty gate. `path` tags the + /// reading "spot" or "continuous" so a report shows which window produced it. + /// + /// The returned result IS `analyze(...)` verbatim, and every count is recomputed with the EXACT same + /// filters (`rangeFilter` then `rejectEctopic`), so the trace and the headline can never diverge. The + /// Kotlin twin is HrvAnalyzer.analyzeTrace. + /// + /// - Parameter maxRejectedFraction: the SPOT-ONLY ceiling (#585). nil (the nightly/continuous default) + /// skips the rejected-fraction gate, exactly like `analyze(...)`. + /// - Parameter path: "spot" for a live snapshot, "continuous" for the nightly windowed path. + public static func analyzeTrace(rawRR: [Double], + maxRejectedFraction: Double? = nil, + path: String = "spot") + -> (result: HRVResult, trace: [String]) { + + func r2(_ x: Double) -> Double { (x * 100.0).rounded() / 100.0 } + + // The result the screen reads, verbatim, so the trace cannot diverge from it. + let result = analyze(rawRR: rawRR, maxRejectedFraction: maxRejectedFraction) + + var lines: [String] = [] + let nInput = rawRR.count + + // Stage counts: range filter then Malik ectopic rejection (the SAME order cleanRR runs). + let ranged = rangeFilter(rawRR) + let clean = rejectEctopic(ranged) + let outOfRange = nInput - ranged.count + let ectopic = ranged.count - clean.count + let rejectedFraction = nInput > 0 ? 1.0 - Double(clean.count) / Double(nInput) : 0.0 + + lines.append("hrv path=\(path) nInput=\(nInput) nClean=\(clean.count) " + + "rejectedFraction=\(r2(rejectedFraction))") + lines.append("hrv reject range=\(outOfRange) (bounds \(Int(rrMinMs))..\(Int(rrMaxMs))ms) " + + "ectopic=\(ectopic) (Malik >\(Int(ectopicThreshold * 100))% of local median)") + + // minBeats gate: the first reason analyze(...) returns an empty result. + let minBeatsCleared = clean.count >= minBeats + lines.append("hrv minBeats need=\(minBeats) clean=\(clean.count) " + + "\(minBeatsCleared ? "CLEARED" : "FAILED")") + + // Spot honesty gate (#585): only when a ceiling is supplied AND minBeats cleared. + if let ceiling = maxRejectedFraction, minBeatsCleared { + let gatePass = !(rejectedFraction > ceiling) + lines.append("hrv spotGate maxRejectedFraction=\(r2(ceiling)) " + + "rejectedFraction=\(r2(rejectedFraction)) \(gatePass ? "PASS" : "FAIL")") + } + + // RMSSD / SDNN / meanNN read from the verbatim result (nil when a gate refused the reading). + if let rmssd = result.rmssd, let sdnn = result.sdnn, let mean = result.meanNN { + lines.append("hrv rmssd=\(r2(rmssd))ms sdnn=\(r2(sdnn))ms meanNN=\(r2(mean))ms") + } else { + lines.append("hrv result=nil (a gate above refused the reading)") + } + + return (result, lines) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift index 3ff9caebae..e28bc6a871 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift @@ -39,6 +39,12 @@ public enum HRVAnalyzer { /// Malik moving-window implementations. public static let ectopicWindowRadius: Int = 2 + /// Default ceiling on the fraction of input beats the cleaning pipeline may reject before a SPOT + /// reading is refused as too noisy (#585). Spot-only: passed by the on-demand callers, never by the + /// nightly windowed path. 0.35 == refuse once more than 35% of beats were dropped as out-of-range or + /// ectopic, even if `minBeats` clean intervals survive — a quiet honesty gate on a short, live capture. + public static let defaultSpotMaxRejectedFraction: Double = 0.35 + /// Result of an HRV computation over a window. public struct HRVResult: Equatable, Sendable { /// RMSSD in milliseconds, or nil when too few valid beats. @@ -156,12 +162,26 @@ public enum HRVAnalyzer { /// Compute HRV from raw RR-interval values (ms), applying the full cleaning /// pipeline. Returns an empty result when fewer than `minBeats` survive. - public static func analyze(rawRR: [Double]) -> HRVResult { + /// + /// - Parameter maxRejectedFraction: SPOT-ONLY honesty gate (#585). When non-nil, the reading is ALSO + /// refused (empty result) if the fraction of input beats dropped by cleaning exceeds this value — + /// even when `minBeats` clean intervals survive — because a short live capture that threw away most + /// of its beats is too noisy to trust. nil (the default, and what the NIGHTLY windowed path passes) + /// skips the gate entirely, so the nightly RMSSD is byte-identical to before this parameter existed. + public static func analyze(rawRR: [Double], maxRejectedFraction: Double? = nil) -> HRVResult { let nInput = rawRR.count let clean = cleanRR(rawRR) guard clean.count >= minBeats else { return .empty(nInput: nInput) } + // Spot-only: refuse when too large a fraction of beats was noise (out-of-range or ectopic). Only + // applied when a ceiling is supplied; a guard against nInput == 0 is implicit (clean ≥ minBeats > 0). + if let maxRejectedFraction, nInput > 0 { + let rejectedFraction = 1.0 - Double(clean.count) / Double(nInput) + if rejectedFraction > maxRejectedFraction { + return .empty(nInput: nInput) + } + } let rmssd = rmssdRaw(clean) let sdnn = sdnnRaw(clean) let mean = clean.reduce(0, +) / Double(clean.count) @@ -175,6 +195,61 @@ public enum HRVAnalyzer { nInput: nInput, nClean: clean.count) } + // MARK: - Rolling / windowed rMSSD timeline (#803) + + /// One windowed rMSSD point: the rMSSD (ms) over the trailing `windowSec` of R-R intervals ending at + /// `ts` (wall-clock unix seconds). This is an HONEST windowed rMSSD, NOT a single "HRV" number for the + /// night - the .hrv timeline plots a point per emitted window so an autonomic-tone report (#803) shows + /// rMSSD MOVING across the session instead of one opaque figure. + public struct RollingRmssdPoint: Equatable, Sendable { + /// Wall-clock unix seconds of the last R-R interval folded into this window (the window's right edge). + public let ts: Int + /// rMSSD (ms) over the cleaned R-R intervals inside the trailing window. + public let rmssd: Double + public init(ts: Int, rmssd: Double) { self.ts = ts; self.rmssd = rmssd } + } + + /// Pure rolling/windowed rMSSD over an R-R series (#803). For each input interval, the window is the + /// trailing `windowSec` seconds ending at that interval's `ts`; the window's R-R values are cleaned with + /// the SAME range filter + Malik ectopic rejection the nightly path uses (`cleanRR`), and a point is + /// emitted only when at least `minBeatsPerWindow` clean intervals survive (so a sparse / artifact-heavy + /// window emits nothing rather than a noisy spike). The result is one `(ts, rMSSD)` per qualifying + /// window, in input order. + /// + /// - Parameters: + /// - rr: the R-R intervals (each carries its own wall-clock `ts` and `rrMs`). Need not be pre-sorted; + /// sorted ascending by `ts` internally so the trailing window is well-defined. + /// - windowSec: the trailing window width in seconds (e.g. 120 for a 2-minute rMSSD). + /// - stepSec: emit at most one point per this many seconds of advance (a thinning stride so a 1 Hz + /// stream does not emit a point per beat). 0 (the default) emits a point at every interval. + /// - minBeatsPerWindow: minimum clean intervals a window needs to emit a point. Defaults to a small + /// floor (8) because a short window legitimately holds far fewer beats than the nightly `minBeats`. + public static func rollingRmssd(rr: [RRInterval], + windowSec: Int, + stepSec: Int = 0, + minBeatsPerWindow: Int = 8) -> [RollingRmssdPoint] { + guard windowSec > 0, rr.count >= minBeatsPerWindow else { return [] } + let sorted = rr.sorted { $0.ts < $1.ts } + var out: [RollingRmssdPoint] = [] + var lastEmitTs: Int? = nil + var left = 0 // index of the oldest interval still inside the trailing window + for right in 0.. windowSec { left += 1 } + // Thinning stride: skip emitting until at least `stepSec` has passed since the last emitted point. + if stepSec > 0, let last = lastEmitTs, edgeTs - last < stepSec { continue } + // Clean the window's raw R-R values with the shared range + Malik ectopic pipeline, then + // require enough survivors before trusting a windowed rMSSD. + let windowRaw = sorted[left...right].map { Double($0.rrMs) } + let clean = cleanRR(windowRaw) + guard clean.count >= minBeatsPerWindow, let r = rmssdRaw(clean) else { continue } + out.append(RollingRmssdPoint(ts: edgeTs, rmssd: r)) + lastEmitTs = edgeTs + } + return out + } + // MARK: - Helpers /// Median of a non-empty array. (Caller guarantees non-empty.) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVFreqDomain.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVFreqDomain.swift new file mode 100644 index 0000000000..44bf32fdcc --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVFreqDomain.swift @@ -0,0 +1,213 @@ +import Foundation +import WhoopProtocol + +// HRVFreqDomain.swift, frequency-domain HRV (LF / HF / LF-HF / total power) over an R-R series. +// +// PURELY ADDITIVE. This file introduces NO change to any Charge / Effort / Rest / sleep output; it is a +// brand-new, opt-in estimator the UI lanes surface later. The existing time-domain HRVAnalyzer (RMSSD / +// SDNN / pNN50) is untouched. +// +// WHY LOMB-SCARGLE, NOT AN FFT. A tachogram (the series of successive R-R intervals plotted against their +// own cumulative time) is UNEVENLY sampled by construction: each interval's timestamp is the running sum of +// the preceding intervals, so the samples are not on a fixed grid. The classical HRV pipeline resamples the +// tachogram onto a uniform grid (e.g. 4 Hz) and runs an FFT, but that resampling is itself a low-pass +// interpolation that distorts the high-frequency (HF / respiratory) band, exactly the band that matters +// most for parasympathetic tone. The Lomb-Scargle periodogram (Lomb 1976, Scargle 1982) estimates the power +// spectrum DIRECTLY from the unevenly sampled points with no interpolation, and is the estimator recommended +// for HRV on irregular tachograms (Laguna, Moody & Mark 1998; Clifford & Tarassenko 2005). So we compute it +// directly on the (t_k, rr_k) pairs. +// +// This generalises the band-limited DFT already used in SleepStagerV2.respRegularity (a uniform-grid DFT +// restricted to the respiratory band): here the same "evaluate the spectrum only at the bins/frequencies we +// care about" idea is applied, but with the Lomb-Scargle estimator so no resampling is needed and arbitrary +// frequencies (the LF/HF band edges) can be probed. +// +// TASK FORCE (1996) BANDS AND SPAN GATES: +// • VLF : 0.0033–0.04 Hz (folded into total power only; not reported on its own, needs many minutes) +// • LF : 0.04–0.15 Hz (~7–37 s period) +// • HF : 0.15–0.40 Hz (~2.5–6.7 s period; the respiratory band) +// • LF/HF: the ratio of the two band powers. +// +// A periodogram can only resolve a frequency whose period fits inside the record several times. The Task +// Force short-term standard is a 5-minute (300 s) recording; we relax that to honest MINIMUM spans: +// • HF needs >= 60 s of R-R span (its slowest component, 0.15 Hz, is a ~6.7 s period, ~9 cycles in 60 s). +// • LF (and therefore LF/HF and a meaningful total power) needs >= 250 s of span (its slowest component, +// 0.04 Hz, is a 25 s period, only ~10 cycles even at 250 s; below that the LF estimate is unreliable). +// Below 60 s of span the whole result is nil; between 60 s and 250 s HF is returned but LF / LF-HF are nil. +// +// APPROXIMATE, non-clinical. Units are ms^2 (power of an R-R series in ms), the conventional HRV unit. + +public enum HRVFreqDomain { + + // MARK: - Band edges (Hz) and span gates (s), pinned by test, mirrored in the Kotlin twin. + + /// VLF lower edge (Hz). Folded into total power; not reported alone. + public static let vlfLowHz: Double = 0.0033 + /// LF band: [0.04, 0.15] Hz. + public static let lfLowHz: Double = 0.04 + public static let lfHighHz: Double = 0.15 + /// HF band: [0.15, 0.40] Hz. + public static let hfLowHz: Double = 0.15 + public static let hfHighHz: Double = 0.40 + + /// Minimum R-R span (s) before ANY frequency-domain result is returned (HF needs at least this). + public static let minSpanForHFSec: Double = 60.0 + /// Minimum R-R span (s) before the LF band (and LF/HF, total power) is trusted; below this they are nil. + public static let minSpanForLFSec: Double = 250.0 + + /// Minimum clean intervals before a spectrum is attempted at all (a handful of beats has no spectrum). + public static let minBeats: Int = 20 + + /// Frequency-grid resolution (Hz) at which the Lomb-Scargle periodogram is sampled within each band. + /// 0.005 Hz puts ~22 grid points across LF and ~50 across HF, fine enough for a stable band integral + /// without the cost of a full-resolution spectrum. The band power is a trapezoidal integral over the grid. + public static let freqStepHz: Double = 0.005 + + // MARK: - Result + + /// Frequency-domain HRV over a window. `lf` / `lfhf` are nil when the span is too short for the LF band + /// (60 s <= span < 250 s gives HF only). `hf` and `totalPower` are present whenever the result is non-nil + /// (i.e. span >= 60 s). All powers are in ms^2. + public struct Bands: Equatable, Sendable { + /// LF band power (0.04–0.15 Hz), ms^2. nil when span < 250 s. + public let lf: Double? + /// HF band power (0.15–0.40 Hz), ms^2. Always present on a non-nil result. + public let hf: Double + /// LF / HF ratio (dimensionless). nil when LF is nil or HF == 0. + public let lfhf: Double? + /// Total power across VLF+LF+HF (0.0033–0.40 Hz), ms^2. The wide band is only meaningful once the + /// span supports LF; on a HF-only (short) window this reports the HF-band power so the field is never + /// a misleading partial sum. + public let totalPower: Double + + public init(lf: Double?, hf: Double, lfhf: Double?, totalPower: Double) { + self.lf = lf; self.hf = hf; self.lfhf = lfhf; self.totalPower = totalPower + } + } + + // MARK: - Public API + + /// Frequency-domain HRV from R-R intervals (each carrying its own wall-clock `ts` in seconds and `rrMs`). + /// The series is cleaned with the SAME range + Malik ectopic pipeline the time-domain analyzer uses + /// (`HRVAnalyzer.cleanRR`) before the tachogram is built, so an artifact beat cannot inject spurious + /// power. Returns nil when there are too few clean beats or the R-R span is under `minSpanForHFSec`. + public static func freqDomain(rr: [RRInterval]) -> Bands? { + let raw = rr.sorted { $0.ts < $1.ts }.map { Double($0.rrMs) } + return freqDomain(rawRR: raw) + } + + /// Frequency-domain HRV from a raw, time-ordered R-R series in milliseconds. The cumulative-sum of the + /// CLEANED intervals (in seconds) forms each sample's timestamp on the tachogram; the cleaned R-R values + /// (mean-removed) are the samples. Returns nil under the same gates as the `[RRInterval]` overload. + public static func freqDomain(rawRR: [Double]) -> Bands? { + let clean = HRVAnalyzer.cleanRR(rawRR) + guard clean.count >= minBeats else { return nil } + + // Build the tachogram: time of beat k = cumulative sum of the first k clean R-R intervals (seconds). + // Sample value at that time = the R-R interval itself (ms). This is the standard HRV tachogram. + var times = [Double](repeating: 0, count: clean.count) + var acc = 0.0 + for i in 0.. s + acc += clean[i] + } + let span = times.last! - times.first! // total record length in seconds + guard span >= minSpanForHFSec else { return nil } + + // Mean-remove the R-R series; Lomb-Scargle assumes a zero-mean signal (it removes a DC offset that + // would otherwise leak across all frequencies). + let mean = clean.reduce(0, +) / Double(clean.count) + let y = clean.map { $0 - mean } + + // HF band power is always computable once span >= 60 s. + let hf = bandPower(times: times, y: y, fLow: hfLowHz, fHigh: hfHighHz) + + // LF (and so LF/HF and the wide total power) only once span >= 250 s. + let lfTrusted = span >= minSpanForLFSec + let lf: Double? = lfTrusted ? bandPower(times: times, y: y, fLow: lfLowHz, fHigh: lfHighHz) : nil + + let lfhf: Double? + if let lf, hf > 0 { lfhf = lf / hf } else { lfhf = nil } + + // Total power = the SUM of the sub-band integrals (VLF + LF + HF) when LF is trusted, otherwise just + // the HF band. Summing the bands (rather than one wide [VLF..HF] integral) guarantees totalPower >= hf + // and keeps it grid-consistent with the reported bands: a single wide integral samples the spectrum on + // a grid offset from the HF-only grid, so for a narrow peak it can undercount the HF region and fall + // below `hf`, which is physically impossible for a superset band. + let totalPower: Double + if lfTrusted, let lfVal = lf { + let vlf = bandPower(times: times, y: y, fLow: vlfLowHz, fHigh: lfLowHz) + totalPower = vlf + lfVal + hf + } else { + totalPower = hf + } + + return Bands(lf: lf, hf: hf, lfhf: lfhf, totalPower: totalPower) + } + + // MARK: - Lomb-Scargle band integral + + /// Trapezoidal integral of the Lomb-Scargle power across [fLow, fHigh], sampled every `freqStepHz`. + /// Returns 0 for a degenerate band. The Lomb-Scargle normalisation used here is the classic Press et al. + /// (Numerical Recipes) form; we integrate it over frequency so the result is a band POWER (ms^2), + /// proportional across bands and stable under the chosen grid. + static func bandPower(times: [Double], y: [Double], fLow: Double, fHigh: Double) -> Double { + guard fHigh > fLow else { return 0 } + // Variance of the (already mean-removed) signal; Lomb-Scargle scales power by it. + let n = Double(y.count) + var variance = 0.0 + for v in y { variance += v * v } + variance /= n + guard variance > 0 else { return 0 } + + var power = 0.0 + var prevP = 0.0 + var prevF = fLow + var first = true + var f = fLow + while f <= fHigh + 1e-12 { + let p = lombScarglePower(times: times, y: y, freqHz: f, variance: variance) + if !first { + // Trapezoid: average of the two endpoint powers times the frequency step, in (ms^2/Hz)*Hz. + power += 0.5 * (p + prevP) * (f - prevF) + } + prevP = p + prevF = f + first = false + f += freqStepHz + } + return power + } + + /// Lomb-Scargle normalised power at a single angular frequency (Press et al., Numerical Recipes form). + /// `variance` is the sample variance of the mean-removed series. The time-offset tau makes the estimate + /// invariant to time translation, which is what lets it handle the uneven tachogram spacing correctly. + static func lombScarglePower(times: [Double], y: [Double], freqHz: Double, variance: Double) -> Double { + let omega = 2.0 * Double.pi * freqHz + + // tau: the phase offset that orthogonalises the sine and cosine sums (Lomb 1976, eq. for tau). + var sin2 = 0.0, cos2 = 0.0 + for t in times { + let a = 2.0 * omega * t + sin2 += sin(a) + cos2 += cos(a) + } + let tau = atan2(sin2, cos2) / (2.0 * omega) + + var cTerm = 0.0, cDen = 0.0 + var sTerm = 0.0, sDen = 0.0 + for i in 0.. 0 ? (cTerm * cTerm) / cDen : 0.0 + let sinPart = sDen > 0 ? (sTerm * sTerm) / sDen : 0.0 + // Normalised by 2*variance so the spectrum is a power-spectral-density estimate in ms^2/Hz. + return (cosPart + sinPart) / (2.0 * variance) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift index 1cc49473c2..6a3793a32c 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift @@ -165,7 +165,7 @@ public enum HRZones { let gap = Double(sorted[i + 1].ts - sorted[i].ts) // Guard against zero/negative or pathological gaps; cap at the median // so a single huge wall-clock gap doesn't blow up one bucket. - dur = (gap > 0) ? gap : tailDuration + dur = (gap > 0) ? min(gap, tailDuration) : tailDuration } else { dur = tailDuration } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HapticClockEncoder.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HapticClockEncoder.swift new file mode 100644 index 0000000000..d4541e6463 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HapticClockEncoder.swift @@ -0,0 +1,69 @@ +import Foundation + +// HapticClockEncoder.swift — turn a wall-clock time into a sequence of strap buzzes you can "read" +// on your wrist without looking (#460, @jiale1029). PURE + unit-tested; the BLE layer maps each +// `HapticPulse` onto the strap's actual haptic command and schedules the gaps. No I/O here. +// +// Encoding (designed to stay countable — you never count past ~9, and the groups are spaced so they +// can't blur together): +// HOUR (12-hour): tens → LONG buzzes (0 or 1), then units → SHORT buzzes. e.g. 11 → L · S ; 3 → S·S·S +// ── long GAP ── +// MINUTE (0–59): tens → MEDIUM buzzes (0–5), short pause, units → SHORT buzzes. e.g. 47 → M·M·M·M · S×7 +// +// Read it as: "[long buzzes = how many tens of hours] [short = hour units] … pause … [medium = tens of +// minutes] [short = minute units]". 11:47 → L, S, (gap) M,M,M,M, (short gap) S,S,S,S,S,S,S. + +/// One element of a haptic-clock playback schedule. +public enum HapticPulse: Equatable, Sendable { + /// A long buzz — marks tens of the hour (so you never count past 12). + case long + /// A medium buzz — marks tens of minutes (0–5). + case medium + /// A short buzz — marks a unit (hour units, or minute units). + case short + /// A long pause separating the hour group from the minute group. + case groupGap + /// A short pause separating the tens sub-group from the units sub-group. + case unitGap +} + +public enum HapticClockEncoder { + + /// Convert a 24-hour `hour` (0–23) + `minute` (0–59) into a buzz schedule. Out-of-range inputs are + /// clamped/wrapped so the encoder never traps — it always produces a readable (if odd) sequence. + public static func pulses(hour24: Int, minute: Int) -> [HapticPulse] { + let h12 = twelveHour(hour24) + let m = min(max(minute, 0), 59) + + var out: [HapticPulse] = [] + + // Hour: tens (10/11/12 → one LONG) then units (SHORT). + let hourTens = h12 / 10 // 0 or 1 + let hourUnits = h12 % 10 // 0–9 (note: 10 → tens 1, units 0) + out += Array(repeating: .long, count: hourTens) + out += Array(repeating: .short, count: hourUnits) + + out.append(.groupGap) + + // Minute: tens (MEDIUM, 0–5), short pause, units (SHORT, 0–9). + let minTens = m / 10 // 0–5 + let minUnits = m % 10 // 0–9 + out += Array(repeating: .medium, count: minTens) + out.append(.unitGap) + out += Array(repeating: .short, count: minUnits) + + return out + } + + /// Convenience: schedule for a `Date` in the given calendar/time zone (defaults to the current ones). + public static func pulses(for date: Date, calendar: Calendar = .current) -> [HapticPulse] { + let comps = calendar.dateComponents([.hour, .minute], from: date) + return pulses(hour24: comps.hour ?? 0, minute: comps.minute ?? 0) + } + + /// Map any 24-hour value onto a 1–12 clock face. 0/24 → 12, 13 → 1, etc. + static func twelveHour(_ hour24: Int) -> Int { + let h = ((hour24 % 12) + 12) % 12 // 0–11, safe for negatives + return h == 0 ? 12 : h + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HydrationGoal.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HydrationGoal.swift new file mode 100644 index 0000000000..d648e76b24 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HydrationGoal.swift @@ -0,0 +1,95 @@ +import Foundation + +// HydrationGoal.swift — pure daily hydration goal math for the opt-in Hydration tracker (MVP). +// +// LOCAL-ONLY, OPT-IN, MANUAL-FIRST: the user logs water with quick taps; this enum computes the day's +// target in ml. It is a plain, transparent guide built from a sex baseline plus a small bump scaled by +// the day's Effort (strain) — NEVER medical advice and never an invented measurement. The whole formula +// lives here so it is headless and unit-tested, and is BYTE-IDENTICAL to the Android twin +// (com.noop.analytics.HydrationGoal): same Int constants, same round-then-clamp, same integer rounding. +// Do not change a constant or a rule on one platform without the other. +// +// GOAL(ml) = roundToNearest( sexBaseline + effortBump, 50 ) +// sexBaseline : male 3700, female 2700, unspecified/other 3200 ml +// effortBump : clamp(round(effort/100 · 700), 0…700); 0 when no Effort is available +// +// `effort` is the day's Effort/strain score on NOOP's native 0…100 scale (the value stored as +// `DailyMetric.strain`). The bump is intentionally modest (≤ 0.7 L) so a hard day nudges the target up +// without ever turning the guide into a hard rule. +public enum HydrationGoal { + + // MARK: - Constants (mirror these EXACTLY in the Android twin — they are Int there) + + /// Baseline target by sex, in millilitres, before the Effort bump. + public static let baselineMaleML = 3700 + public static let baselineFemaleML = 2700 + /// Used for "unspecified" / "other" / any unrecognised sex token. + public static let baselineOtherML = 3200 + + /// The most the Effort bump can add (ml) — reached at Effort 100. + public static let maxEffortBumpML = 700 + + /// The goal is rounded to the nearest multiple of this (ml) for a clean read-out. + public static let roundToML = 50 + + // MARK: - Quick-log amounts (ml) — the three tap sizes + + public static let sipML = 30 + public static let cupML = 237 // a US cup (8 fl oz) + public static let bottleML = 500 // a standard small water bottle + + // MARK: - Pieces (each pure + independently testable; mirror the Kotlin twin) + + /// Baseline ml for a sex token. Case- and whitespace-insensitive; "male"/"m" and "female"/"f" map to + /// their baselines, anything else ("nonbinary", "other", "", unknown) maps to the unspecified baseline + /// — we never guess a sex we weren't given. + public static func baselineForSex(_ sex: String) -> Int { + switch sex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "male", "m": return baselineMaleML + case "female", "f": return baselineFemaleML + default: return baselineOtherML + } + } + + /// The Effort bump (ml) for a day's Effort score on the 0…100 scale: `round(effort/100 · 700)` then + /// clamped to 0…700. `effort == nil` (no Effort yet today) yields 0 — never a fabricated bump. Rounds + /// FIRST then clamps the OUTPUT (matching the Kotlin twin), so an out-of-range input can't blow past + /// the cap. A non-finite effort is treated as "no Effort" (0). + public static func effortBump(effort: Double?) -> Int { + guard let effort, effort.isFinite else { return 0 } + let raw = Int((effort / 100.0 * Double(maxEffortBumpML)).rounded()) + return min(maxEffortBumpML, max(0, raw)) + } + + /// Round `value` to the nearest multiple of `step` (step > 0). Half rounds up — `((value + step/2) / + /// step) * step` on non-negative ints — matching the Kotlin twin and Swift's away-from-zero rounding. + public static func roundToNearest(_ value: Int, step: Int) -> Int { + guard step > 0 else { return value } + return ((value + step / 2) / step) * step + } + + // MARK: - The goal + + /// The day's hydration goal in ml: `roundToNearest(sexBaseline + effortBump, 50)`. Pure — feed it the + /// profile sex token and the day's Effort score (or nil). The result is always a multiple of 50. + public static func dailyGoalML(sex: String, effort: Double?) -> Int { + roundToNearest(baselineForSex(sex) + effortBump(effort: effort), step: roundToML) + } + + // MARK: - Display helpers + + /// Litres (ml / 1000) for the litre read-outs. + public static func litres(fromML ml: Double) -> Double { ml / 1000.0 } + + /// " / L" in litres to 1 dp, e.g. "1.2 / 3.2 L" — the dashboard card value, fixed-locale + /// so the string is byte-identical to the Android twin (`String.format(Locale.US, "%.1f / %.1f L")`). + public static func cardValueString(totalML: Double, goalML: Int) -> String { + String(format: "%.1f / %.1f L", litres(fromML: totalML), litres(fromML: Double(goalML))) + } + + /// Fraction of the goal met (0…1, clamped) for the progress ring. + public static func fraction(totalML: Double, goalML: Int) -> Double { + guard goalML > 0 else { return 0 } + return min(1.0, max(0.0, totalML / Double(goalML))) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessDistance.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessDistance.swift new file mode 100644 index 0000000000..26abdc57c1 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessDistance.swift @@ -0,0 +1,201 @@ +import Foundation + +// IllnessDistance.swift, an ALTERNATIVE, multivariate illness-anomaly distance (Mahalanobis). +// +// PARALLEL PATH, NOT the default scorer. The shipped IllnessSignalEngine keeps its per-signal z-sum + ≥2 +// corroboration + confounder suppression exactly as-is. This file adds a SECOND way to measure "how far is +// today's 4-signal vector from my personal baseline", behind an explicit flag, so the UI lane can A/B it +// without touching the live alert path. The default illness scorer is unchanged. +// +// WHY MAHALANOBIS. The four illness signals (RHR ↑, RMSSD ↓, skin-temp ↑, respiration ↑) are CORRELATED: +// when you are getting sick they tend to move together, and even at baseline RHR and respiration co-vary. +// Summing per-signal z-scores treats them as independent and so double-counts shared variance. The +// Mahalanobis distance +// +// D^2 = (x - mu)^T * C^-1 * (x - mu) +// +// uses the inverse CORRELATION matrix C^-1 (we feed it z-scored features, so the covariance of z's is the +// correlation matrix) to discount that shared variance: two correlated signals both up counts as ONE +// coordinated move, not two. D is in "standard-deviation-equivalent" units, so a threshold of 2.5 is +// comparable to the existing per-signal z≈2 gate but accounts for the joint structure. +// +// HONEST GATING preserved: this distance is only one input. We keep NOOP's existing rules layered on top: +// • fires only when D > distanceThreshold AND at least minDeviatingFeatures features are themselves +// deviating ILLNESS-WARD (a large D driven by one signal pointing the WELLNESS way must not fire), and +// • the caller still applies the same confounder suppression (alcohol / travel / etc.) afterwards. +// +// The correlation inverse is solved by Gauss-Jordan elimination; if the matrix is singular (or near-so) we +// fall back to the DIAGONAL inverse (i.e. treat features as independent, which degrades gracefully to the +// per-signal behaviour rather than producing NaNs). +// +// Pure, deterministic, DB-free. APPROXIMATE, non-clinical, never names a condition. + +public enum IllnessDistance { + + /// Mahalanobis distance D above which the alternative path considers firing (before the deviating-feature + /// gate and the caller's confounder suppression). Comparable to the existing per-signal z≈2 firing gate + /// but in joint standard-deviation units. + public static let distanceThreshold: Double = 2.5 + + /// Minimum features that must themselves point ILLNESS-WARD (positive z) before a large D can fire, the + /// same ≥2 corroboration spirit as IllnessSignalEngine, so one coordinate can't carry the alert. + public static let minDeviatingFeatures: Int = 2 + + /// A feature counts as "deviating illness-ward" once its illness-oriented z reaches this (mirrors + /// IllnessSignalEngine.signalZThreshold so the two paths agree on what "a signal is up" means). + public static let featureZThreshold: Double = 2.0 + + /// Ridge added to the correlation diagonal before inversion for numerical stability (a tiny Tikhonov + /// term so a near-singular correlation from a short baseline still inverts). Does not meaningfully move a + /// well-conditioned matrix. + public static let ridge: Double = 1e-6 + + /// The four illness signals, in fixed order, each as an illness-ORIENTED z (positive = more illness-like: + /// RHR ↑, skin-temp ↑, respiration ↑ pass raw z; RMSSD ↓ passes the NEGATED z). nil = signal absent this + /// window (dropped from the distance, and never counted as a deviating feature). + public struct FeatureVector: Equatable, Sendable { + public var restingHR: Double? + public var rmssd: Double? // already negated by the caller so positive == drop == illness-ward + public var skinTemp: Double? + public var respiration: Double? + public init(restingHR: Double? = nil, rmssd: Double? = nil, + skinTemp: Double? = nil, respiration: Double? = nil) { + self.restingHR = restingHR; self.rmssd = rmssd + self.skinTemp = skinTemp; self.respiration = respiration + } + + /// Present coordinates in fixed order (restingHR, rmssd, skinTemp, respiration). + var present: [Double] { + [restingHR, rmssd, skinTemp, respiration].compactMap { $0 } + } + } + + public struct Result: Equatable, Sendable { + /// Mahalanobis distance D (sqrt of the quadratic form). 0 when no features present. + public let distance: Double + /// Count of present features whose illness-ward z >= featureZThreshold. + public let deviatingFeatures: Int + /// True iff D > distanceThreshold AND deviatingFeatures >= minDeviatingFeatures. The caller still + /// applies confounder suppression on top of this before surfacing anything. + public let fires: Bool + /// True when the correlation matrix was singular and the diagonal-inverse fallback was used. + public let usedDiagonalFallback: Bool + + public init(distance: Double, deviatingFeatures: Int, fires: Bool, usedDiagonalFallback: Bool) { + self.distance = distance; self.deviatingFeatures = deviatingFeatures + self.fires = fires; self.usedDiagonalFallback = usedDiagonalFallback + } + } + + /// Mahalanobis distance of today's illness-oriented z-vector from the personal baseline. + /// + /// Because the features are ALREADY z-scored against the personal baseline, the baseline mean is the + /// zero vector and the covariance of the z's is the personal CORRELATION matrix. The caller supplies that + /// correlation matrix as the symmetric `correlation` (rows/cols in the same fixed feature order over the + /// PRESENT features). Pass the identity (or nil) to fall back to the independent-features case, which + /// makes D == the Euclidean norm of the z-vector. + /// + /// - Parameters: + /// - features: today's illness-oriented z-vector (absent coordinates dropped). + /// - correlation: NxN personal correlation over the present features in fixed order, or nil for + /// identity. Must be square with side == features.present.count when non-nil. + public static func evaluate(features: FeatureVector, + correlation: [[Double]]? = nil) -> Result { + let x = features.present + let k = x.count + guard k > 0 else { + return Result(distance: 0, deviatingFeatures: 0, fires: false, usedDiagonalFallback: false) + } + + // Count features pointing illness-ward (z >= threshold) for the corroboration gate. + var deviating = 0 + for v in x where v >= featureZThreshold { deviating += 1 } + + // Resolve the correlation matrix: caller-supplied (validated square) or identity. + var corr: [[Double]] + let suppliedCorrelation: Bool + if let c = correlation, c.count == k, c.allSatisfy({ $0.count == k }) { + corr = c; suppliedCorrelation = true + } else { + corr = identity(k); suppliedCorrelation = false + } + // Ridge the diagonal for conditioning, but ONLY a supplied correlation. The identity (nil) case + // must invert to itself exactly so D equals the Euclidean norm of the z-vector to full precision; + // adding a Tikhonov term there would shrink it by ~ridge and break the documented contract. + if suppliedCorrelation { for i in 0.. distanceThreshold && deviating >= minDeviatingFeatures + return Result(distance: distance, deviatingFeatures: deviating, + fires: fires, usedDiagonalFallback: fellBack) + } + + // MARK: - Linear algebra (Gauss-Jordan with diagonal fallback) + + static func identity(_ n: Int) -> [[Double]] { + var m = [[Double]](repeating: [Double](repeating: 0, count: n), count: n) + for i in 0.. (inverse: [[Double]], fellBack: Bool) { + let n = a.count + // Augment [a | I]. + var m = [[Double]](repeating: [Double](repeating: 0, count: 2 * n), count: n) + for i in 0.. pivotMag { + pivotMag = abs(m[r][col]); pivotRow = r + } + if pivotMag < eps { + return (diagonalInverse(a), true) // singular: fall back. + } + if pivotRow != col { m.swapAt(col, pivotRow) } + let pivot = m[col][col] + for j in 0..<(2 * n) { m[col][j] /= pivot } + for r in 0.. [[Double]] { + let n = a.count + var inv = [[Double]](repeating: [Double](repeating: 0, count: n), count: n) + for i in 0.. 1e-12 ? 1.0 / d : 1.0 + } + return inv + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift new file mode 100644 index 0000000000..a4e1233eb9 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift @@ -0,0 +1,242 @@ +import Foundation + +// IllnessSignalEngine.swift — multi-signal "Heads-Up" early-warning with explicit false-positive +// suppression. Pure, deterministic, DB-free. +// +// INDEPENDENT implementation of the published multi-parameter pre-symptomatic signature documented +// across the wearable literature (e.g. the Stanford/Snyder resting-HR-elevation work and successor +// studies): resting HR ↑, skin temperature ↑, HRV (RMSSD) ↓ and respiration ↑ tend to move TOGETHER, +// days before symptoms. NOOP re-derives the PATTERN, transparently, against the user's OWN rolling +// baseline — never a population cutoff. +// +// This replaces the blunt 2-of-4 threshold rule in AppModel.evaluateIllness with: +// • a calibrated 0–100 composite anomaly score (so the surface can read "mild" vs "strong"), +// • a minimum-corroboration gate (≥ 2 signals) so a single noisy night never fires, +// • EXPLICIT confounder suppression cross-checked against the same-day journal tags +// (alcohol / stress / sauna / late-or-intense workout / travel), which is the differentiating +// part — alcohol elevates RHR + skin temp and crushes HRV exactly like early illness, so a night +// out must NOT cry wolf, +// • a visible "why" (which signals fired) AND "what was ruled out" (which confounders were present), +// • honest gating: a trusted baseline is required; below that the engine is silent. +// +// WELLNESS ONLY — APPROXIMATE, NOT A DIAGNOSIS. The engine never names a condition, illness, infection +// or fever; the copy is always "a heads-up to rest" / "consider taking it easy" (see the shipped +// IllnessNotifier copy: "On-device estimate (approximate) — not a diagnosis"). +public enum IllnessSignalEngine { + + // MARK: - Tuning constants (pinned by test; mirror the Kotlin twin exactly) + + /// Composite score (0–100) at/above which the heads-up is RAISED. Below this it is "mild" — surfaced + /// only in a detail view, never a notification (keeps the banner from re-introducing noise). + public static let raiseThreshold: Double = 50.0 + /// Score floor below which there is nothing worth saying at all (engine returns `.quiet`). + public static let mildThreshold: Double = 25.0 + /// Minimum number of signals pointing the illness way before anything can raise — guards against a + /// single noisy night driving the score on its own. + public static let minCorroboratingSignals: Int = 2 + + /// A signal's |z| must reach this to count as "firing" toward the score. Roughly the user's own ~95th + /// percentile night (matches VitalBands.sigmaK), so normal night-to-night wobble doesn't register. + public static let signalZThreshold: Double = 2.0 + /// Per-signal sub-score is `min(perSignalCap, kZToScore · max(0, zIllnessward − signalZThreshold))`, + /// then the composite is their sum clamped to 100. Each strong signal caps so no single one saturates. + public static let kZToScore: Double = 22.0 + public static let perSignalCap: Double = 40.0 + + /// When a confounder is present, the composite is multiplied by this and the level is downgraded — + /// the signals are real, but a plainer explanation exists, so we soften rather than scream. + public static let confounderDampen: Double = 0.45 + + // MARK: - Inputs + + /// One signal's recent-vs-baseline reading, already z-scored against the personal baseline by the + /// caller (reusing `Baselines.deviation`). `zIllnessward` is the deviation ORIENTED so that a + /// positive value always means "more illness-like": RHR ↑, skin-temp ↑, respiration ↑ pass their raw + /// z; HRV ↓ passes the NEGATED z (a drop is illness-ward). `present == false` means the signal had no + /// usable data this window and is skipped (not counted as corroboration). + public struct SignalReading: Equatable, Sendable { + public let zIllnessward: Double + public let present: Bool + public init(zIllnessward: Double, present: Bool = true) { + self.zIllnessward = zIllnessward + self.present = present + } + } + + /// All four signal readings for the recent window. Any may be absent (sparse 5/MG nights). + public struct Inputs: Equatable, Sendable { + public var restingHR: SignalReading? // z of recent RHR vs baseline (↑ illness-ward) + public var skinTemp: SignalReading? // z of recent skin-temp deviation vs baseline (↑ illness-ward) + public var hrv: SignalReading? // NEGATED z of recent HRV vs baseline (drop = illness-ward) + public var respiration: SignalReading? // z of recent respiration vs baseline (↑ illness-ward) + public init(restingHR: SignalReading? = nil, skinTemp: SignalReading? = nil, + hrv: SignalReading? = nil, respiration: SignalReading? = nil) { + self.restingHR = restingHR; self.skinTemp = skinTemp + self.hrv = hrv; self.respiration = respiration + } + } + + /// Same-day behaviour context that can explain an anomaly away. All default-false / nil so a caller + /// with no journal still gets the raw signal read. `travelPhaseJump` is the cross-feature hook — the + /// CircadianEngine can flag a detected body-clock jump (jet lag), which itself shifts temp + RHR. + public struct Context: Equatable, Sendable { + public var alcohol: Bool + public var stress: Bool + public var sauna: Bool + public var hardOrLateWorkout: Bool + public var travelPhaseJump: Bool + public var alreadyUnwell: Bool + /// True iff the caller's baseline for the anomaly is `trusted` (≥ 14 valid nights, not stale). + /// Below this the engine stays silent — we don't warn off a cold-start baseline. + public var baselineTrusted: Bool + public init(alcohol: Bool = false, stress: Bool = false, sauna: Bool = false, + hardOrLateWorkout: Bool = false, travelPhaseJump: Bool = false, + alreadyUnwell: Bool = false, baselineTrusted: Bool = true) { + self.alcohol = alcohol; self.stress = stress; self.sauna = sauna + self.hardOrLateWorkout = hardOrLateWorkout; self.travelPhaseJump = travelPhaseJump + self.alreadyUnwell = alreadyUnwell; self.baselineTrusted = baselineTrusted + } + } + + // MARK: - Output + + /// How loud the heads-up is. `.quiet` shows nothing; `.alreadyUnwell` is the "rest up" path when the + /// user has already logged feeling ill; `.suppressed` is "signals up, but a confounder explains it". + public enum Level: String, Equatable, Sendable, Codable { + case quiet // nothing worth saying (below mild, or not enough corroboration, or untrusted baseline) + case mild // some signals up — detail view only, no notification + case raised // clear multi-signal anomaly, no confounder — surface + notify + case suppressed // anomaly present but a behaviour tag / travel explains it — quietly informative + case alreadyUnwell // user logged feeling unwell — "rest up", not a scare + } + + public struct Result: Equatable, Sendable { + /// 0–100 composite anomaly score (post-dampening for the suppressed level so the surface matches). + public let score: Double + public let level: Level + /// Human-readable reasons a signal fired, e.g. "RHR +6", "HRV −22%", "skin temp +0.7 °C". The + /// caller supplies the rendered phrases; the engine decides which to include (only firing ones). + public let firedSignals: [String] + /// Named confounders that were present and damped/explained the score, e.g. "alcohol", "travel". + public let suppressedBy: [String] + /// Count of signals over the firing threshold (corroboration), regardless of level. + public let signalCount: Int + /// One-line non-clinical copy, terminating in the shipped not-a-diagnosis framing where it raises. + public let copy: String + + public init(score: Double, level: Level, firedSignals: [String], suppressedBy: [String], + signalCount: Int, copy: String) { + self.score = score; self.level = level; self.firedSignals = firedSignals + self.suppressedBy = suppressedBy; self.signalCount = signalCount; self.copy = copy + } + } + + /// Standing not-a-diagnosis tail reused verbatim from the shipped IllnessNotifier copy. + public static let disclaimerTail = "On-device estimate - not a diagnosis." + + // MARK: - Evaluate + + /// Score the recent window and decide the heads-up level + copy. + /// + /// `firedLabels` maps a signal key to the caller-rendered phrase to show when that signal fires + /// (e.g. ["restingHR": "RHR +6", "hrv": "HRV −22%"]). Only keys for signals that clear + /// `signalZThreshold` are surfaced. Keeping the rendering in the caller keeps the engine free of + /// number-formatting locale concerns and identical across platforms. + public static func evaluate(_ inputs: Inputs, context: Context, + firedLabels: [String: String] = [:]) -> Result { + // Order is fixed so firedSignals is deterministic across platforms. + let ordered: [(key: String, reading: SignalReading?)] = [ + ("restingHR", inputs.restingHR), + ("skinTemp", inputs.skinTemp), + ("hrv", inputs.hrv), + ("respiration", inputs.respiration), + ] + + var rawScore = 0.0 + var firedKeys: [String] = [] + for (key, reading) in ordered { + guard let r = reading, r.present else { continue } + let over = r.zIllnessward - signalZThreshold + guard over > 0 else { continue } + firedKeys.append(key) + rawScore += min(perSignalCap, kZToScore * over) + } + let score = min(100.0, rawScore) + let signalCount = firedKeys.count + let firedSignals = firedKeys.compactMap { firedLabels[$0] } + + // Gate 0: untrusted baseline → silent (don't warn off a cold-start). Score still reported for a + // detail view, but never raised. + if !context.baselineTrusted { + return Result(score: score, level: .quiet, firedSignals: firedSignals, + suppressedBy: [], signalCount: signalCount, + copy: "Still learning your baseline - keeping an eye out.") + } + + // Already-unwell path: the user told us. Switch from "early warning" to a gentle "rest up" and + // never scare — regardless of score (their log is the ground truth). + if context.alreadyUnwell { + let agreeing = score >= mildThreshold && signalCount >= 1 + let copy = agreeing + ? "Rest up - you logged feeling unwell, and your numbers agree. \(disclaimerTail)" + : "Rest up - you logged feeling unwell. Take it easy today. \(disclaimerTail)" + return Result(score: score, level: .alreadyUnwell, firedSignals: firedSignals, + suppressedBy: [], signalCount: signalCount, copy: copy) + } + + // Corroboration + magnitude gate: need ≥ 2 firing signals and a mild-or-better composite, else quiet. + guard signalCount >= minCorroboratingSignals, score >= mildThreshold else { + return Result(score: score, level: .quiet, firedSignals: firedSignals, + suppressedBy: [], signalCount: signalCount, + copy: "Nothing notable - your signals look like your normal range.") + } + + // Confounder suppression — the differentiating part. Collect every present behaviour/travel tag + // that offers a plainer explanation; if any are present, dampen the score and downgrade. + var suppressedBy: [String] = [] + if context.alcohol { suppressedBy.append("alcohol") } + if context.stress { suppressedBy.append("stress") } + if context.sauna { suppressedBy.append("sauna") } + if context.hardOrLateWorkout { suppressedBy.append("a hard or late workout") } + if context.travelPhaseJump { suppressedBy.append("travel") } + + let signalsPhrase = firedSignals.isEmpty ? "Some signals are up" : firedSignals.joined(separator: ", ") + + if !suppressedBy.isEmpty { + let dampened = score * confounderDampen + let reason = joinReasons(suppressedBy) + let copy = "Some signals are up (\(signalsPhrase)), but you logged \(reason) - likely that, " + + "not illness. \(disclaimerTail)" + return Result(score: dampened, level: .suppressed, firedSignals: firedSignals, + suppressedBy: suppressedBy, signalCount: signalCount, copy: copy) + } + + // No confounder. Mild stays in the detail view; a strong composite raises. + if score < raiseThreshold { + let copy = "A few signals are mildly up (\(signalsPhrase)). Nothing alarming - worth a calmer " + + "day. \(disclaimerTail)" + return Result(score: score, level: .mild, firedSignals: firedSignals, + suppressedBy: [], signalCount: signalCount, copy: copy) + } + + let ruledOut = "no alcohol or travel logged" + let copy = "Heads-up - your body looks strained. \(signalsPhrase). With \(ruledOut), consider " + + "taking it easy. \(disclaimerTail)" + return Result(score: score, level: .raised, firedSignals: firedSignals, + suppressedBy: [], signalCount: signalCount, copy: copy) + } + + // MARK: - Helpers + + /// Join named confounders into a natural list ("alcohol", "alcohol and stress", "a, b and c"). + static func joinReasons(_ reasons: [String]) -> String { + switch reasons.count { + case 0: return "something" + case 1: return reasons[0] + case 2: return "\(reasons[0]) and \(reasons[1])" + default: + let head = reasons.dropLast().joined(separator: ", ") + return "\(head) and \(reasons.last!)" + } + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LabBookProjection.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LabBookProjection.swift new file mode 100644 index 0000000000..f37d6ea71e --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LabBookProjection.swift @@ -0,0 +1,217 @@ +import Foundation + +// MARK: - Lab Book projection (pure) +// +// LabBookProjection.swift — the pure, DB-free, deterministic logic that turns a set +// of Lab Book readings into the daily `(day, key, value)` form the rest of the app +// already understands, plus the windowed-aggregate pairing used before +// `CorrelationEngine.pearson`. +// +// Per the Health Records design spec (2026-06-19-v5-health-records-design.md, +// §"On-device algorithm" and §"New"): +// - There is NO new statistics here. A marker is just another `(day, value)` series. +// Day-alignment + Pearson are reused byte-for-byte from `CorrelationEngine`. +// - SPARSE-MARKER handling is the real design problem: bloods are months apart, so +// naive day-alignment yields too few overlapping points. So a reading on day D is +// paired with the MEAN of a wearable series over a trailing window UP TO AND +// INCLUDING D (default 14 days) — a disclosed trailing-exposure-window choice, the +// same idea as a moving-average feature, kept fully deterministic and on-device. +// - HONESTY about n: callers gate the conclusion sentence on a reading-count floor +// (default 4); this engine just reports the exact pairs and their n. +// +// This engine is timezone-free by construction: it operates on pre-derived +// `yyyy-MM-dd` day strings (the store derives the day from a reading's `takenAt`), +// exactly like `CorrelationEngine`. That keeps the Swift engine and its Kotlin twin +// byte-identical with no Calendar/ZoneId divergence. +// +// NON-CLINICAL (spec §"Non-clinical"): this folds and lines up the user's own numbers. +// It never judges a value normal/abnormal and ships no thresholds. + +/// One reading reduced to exactly what the projection needs: a numeric value on a +/// pre-derived day, with the precise `takenAt` epoch seconds kept only to break +/// ties when several readings of the same marker land on the same day. +/// +/// Non-numeric (`valueText`-only) readings are simply not represented here — the +/// caller omits them, since a `REAL`-only daily series can't carry them. +public struct LabReading: Equatable, Sendable { + /// Marker identifier (e.g. `"ldl"`, `"bp_systolic"`). + public let markerKey: String + /// Pre-derived `yyyy-MM-dd` day key (the store derived this from `takenAt`). + public let day: String + /// Numeric reading. + public let value: Double + /// The reading's instant (epoch seconds). Used ONLY to order same-day readings + /// so "latest-per-day" is deterministic; never re-derives the day. + public let takenAtEpoch: Double + + public init(markerKey: String, day: String, value: Double, takenAtEpoch: Double) { + self.markerKey = markerKey + self.day = day + self.value = value + self.takenAtEpoch = takenAtEpoch + } +} + +/// A projected daily point for one marker: the value that represents marker `key` +/// on `day` after folding multiple same-day readings. This is what gets upserted +/// into `metricSeries` under the `lab-book` source id. +public struct ProjectedPoint: Equatable, Sendable { + public let markerKey: String + public let day: String + public let value: Double + + public init(markerKey: String, day: String, value: Double) { + self.markerKey = markerKey + self.day = day + self.value = value + } +} + +/// How to collapse several readings of the same marker on the same day into one +/// daily value. +public enum DailyFold: Sendable { + /// The reading with the latest `takenAt` wins (ties broken by input order). + case latest + /// The arithmetic mean of the day's readings. + case mean +} + +/// One windowed-aggregate pair: a marker reading on `day` lined up against the mean +/// of a wearable series over the trailing window up to and including `day`. +public struct WindowedPair: Equatable, Sendable { + /// The marker reading's day (`yyyy-MM-dd`). + public let day: String + /// The marker's projected daily value on `day` (the x of the pair). + public let markerValue: Double + /// The trailing-window mean of the wearable series (the y of the pair). + public let wearableMean: Double + /// How many wearable points fell inside the window (transparency; 0-coverage + /// days are never emitted, so this is always ≥ 1 for an emitted pair). + public let wearableN: Int + + public init(day: String, markerValue: Double, wearableMean: Double, wearableN: Int) { + self.day = day + self.markerValue = markerValue + self.wearableMean = wearableMean + self.wearableN = wearableN + } +} + +public enum LabBookProjection { + + /// The constant device-id every projected marker day is written under, so a + /// future cross-device file sync would line up and the per-source resolver treats + /// markers as single-source (spec §"Cross-platform plan"). + public static let sourceId = "lab-book" + + /// The two keys a blood-pressure pair is stored as (spec §"Blood pressure + /// modelling": two keys for clean correlation, not one composite). + public static let bpSystolicKey = "bp_systolic" + public static let bpDiastolicKey = "bp_diastolic" + + /// Default trailing window (days, inclusive of the reading day) for pairing a + /// sparse marker against a continuously-measured wearable series. + public static let defaultWindowDays = 14 + + // MARK: - Daily projection + + /// Fold readings into one daily point per (markerKey, day). + /// + /// For each marker and day, multiple readings are collapsed by `fold` + /// (`.latest` = most recent `takenAt` wins; `.mean` = arithmetic mean). Output is + /// sorted by markerKey then day ascending so it is deterministic across platforms. + public static func project(_ readings: [LabReading], fold: DailyFold = .latest) -> [ProjectedPoint] { + // Group by (markerKey, day) → list of readings in that cell. + var cells: [String: [LabReading]] = [:] + var order: [String] = [] + for r in readings { + let cellKey = r.markerKey + "\u{1}" + r.day + if cells[cellKey] == nil { order.append(cellKey) } + cells[cellKey, default: []].append(r) + } + + var out: [ProjectedPoint] = [] + out.reserveCapacity(order.count) + for cellKey in order { + guard let group = cells[cellKey], !group.isEmpty else { continue } + let value: Double + switch fold { + case .latest: + // Most recent takenAt wins; a tie keeps the last in input order + // (`>` makes the later-encountered equal element NOT replace, so the + // last one written is the running best — deterministic either way as + // the store dedupes by natural key). + var best = group[0] + for r in group.dropFirst() where r.takenAtEpoch >= best.takenAtEpoch { + best = r + } + value = best.value + case .mean: + var sum = 0.0 + for r in group { sum += r.value } + value = sum / Double(group.count) + } + out.append(ProjectedPoint(markerKey: group[0].markerKey, day: group[0].day, value: value)) + } + + // Deterministic order: markerKey asc, then day asc. + out.sort { $0.markerKey != $1.markerKey ? $0.markerKey < $1.markerKey : $0.day < $1.day } + return out + } + + // MARK: - Windowed-aggregate pairing + + /// Pair each marker reading with the trailing-window mean of a wearable series. + /// + /// For a marker projected to `[(day, value)]` (one numeric value per day — pass the + /// `.project` output filtered to one markerKey, or any `(day,value)` series) and a + /// daily `wearable` series `[(day, value)]`, each marker day `D` is paired with the + /// mean of all wearable values whose day is within the trailing `windowDays` + /// (inclusive of D): `D - (windowDays - 1) ... D`. + /// + /// Days where NO wearable point falls inside the window are DROPPED (spec: "days + /// with no wearable coverage are dropped"). The result is sorted by day ascending. + /// Window is clamped to ≥ 1. The day arithmetic reuses the same UTC-calendar + /// `shiftDay` used by `CorrelationEngine.lagged`, so the boundary is computed the + /// same way everywhere. + public static func pairMarkerToWearable( + marker: [(day: String, value: Double)], + wearable: [(day: String, value: Double)], + windowDays: Int = defaultWindowDays + ) -> [WindowedPair] { + let width = max(1, windowDays) + + // Last-write-wins per day for both series (matches CorrelationEngine.alignByDay). + var markerByDay: [String: Double] = [:] + for row in marker { markerByDay[row.day] = row.value } + var wearableByDay: [String: Double] = [:] + for row in wearable { wearableByDay[row.day] = row.value } + + var pairs: [WindowedPair] = [] + for day in markerByDay.keys.sorted() { + guard let mv = markerByDay[day] else { continue } + // Walk the trailing window [D-(width-1) ... D] inclusive, summing wearable + // coverage. Deterministic: a fixed UTC calendar, integer day offsets. + var sum = 0.0 + var n = 0 + for back in 0.. 0 else { continue } // no coverage → drop the reading + pairs.append(WindowedPair(day: day, markerValue: mv, wearableMean: sum / Double(n), wearableN: n)) + } + return pairs + } + + /// Convenience: reduce windowed pairs to the `(x, y)` tuples `CorrelationEngine.pearson` + /// consumes (x = marker value, y = wearable trailing-window mean), ordered by day. + /// The caller passes the result straight into `CorrelationEngine.pearson`; this + /// engine adds no statistics of its own. + public static func correlationInput(_ pairs: [WindowedPair]) -> [(Double, Double)] { + pairs.map { ($0.markerValue, $0.wearableMean) } + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiveSessionEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiveSessionEngine.swift new file mode 100644 index 0000000000..4eeca89a98 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiveSessionEngine.swift @@ -0,0 +1,328 @@ +import Foundation + +// LiveSessionEngine.swift — the "silent guardian" coach for a Live Session. Pure, deterministic, DB-free. +// +// It watches a live heart-rate stream against a recovery-gated target BAND and emits at most two kinds of +// haptic cue: a gentle PUSH nudge when you drift too easy for today, and a firmer EASE-OFF when you push +// harder than today's recovery can pay for. Silence means you are on track — the design's whole point. +// +// Everything here is a value type and time is passed in on every `update(now:bpm:)`, so a full session +// replays deterministically from a synthetic HR trace with no clock, no BLE, and no UI. The transport +// (subscribe to live HR, fire the buzz) lives in the app; this file only decides WHAT should happen. +// +// Design contract: docs/superpowers/specs/2026-07-04-live-sessions-design.md. These behaviours are the +// GOLDEN VECTORS the Kotlin `LiveSessionEngine` twin mirrors — the cross-platform parity contract. +// +// Two rules dominate every threshold below: +// 1. A WRONG buzz is unforgivable; a MISSED buzz is fine. So we bias hard toward silence: dwell, cool-down +// and hysteresis are core, not polish. +// 2. Never fabricate. Impossible samples are rejected before they can trigger a cue, and a stale stream +// pauses coaching rather than guessing. +public struct LiveSessionEngine { + + // MARK: - Tuning constants (pinned by test; mirror the Kotlin twin exactly) + + /// Target-band ceiling as a fraction of heart-rate reserve (%HRR/100) on a fully-depleted Charge day. + public static let ceilingPctAtLowCharge: Double = 0.60 + /// Target-band ceiling (fraction HRR) on a fully-recovered Charge day. + public static let ceilingPctAtHighCharge: Double = 0.82 + /// Band width (ceiling − floor) as a fraction of HRR. + public static let bandWidthPctHRR: Double = 0.15 + /// The floor never drops below this fraction of HRR (keeps the "too easy" edge sane on low-Charge days). + public static let minFloorPctHRR: Double = 0.40 + /// Charge used when today's Charge is unknown — a deliberately conservative mid-point. + public static let defaultChargeFraction: Double = 0.5 + + /// Trailing window (seconds) the smoothed HR median is taken over. We coach on the trend, never a spike. + public static let smoothingWindowSec: Int = 12 + /// A reading is stale — coaching pauses, ring greys — after this long with no accepted sample. + public static let staleAfterSec: Int = 8 + /// Warm-up grace from session start: classify + accrue in-band time, but emit NO cues (early optical lag). + public static let warmupSec: Int = 60 + /// After a detected sharp climb, suppress the "too easy" cue for this long (a below reading is likely lag). + public static let climbGraceSec: Int = 45 + + /// Continuous time out of band (seconds) required before any cue fires. + public static let dwellSec: Int = 25 + /// Minimum gap (seconds) before the same cue direction may fire again — no buzz thrash near an edge. + public static let cooldownSec: Int = 50 + /// Hysteresis margin (bpm) around each band edge, so a reading hovering on the line does not flicker. + public static let hysteresisMarginBpm: Double = 2.0 + /// The largest inter-update gap (seconds) credited to in-band time, so one long stall can't inflate it. + public static let maxAccrualDtSec: Int = 5 + + /// A smoothed rise of at least this many bpm within `stepChangeWindowSec` is a "sharp climb" (new effort). + public static let stepChangeBpm: Double = 8.0 + public static let stepChangeWindowSec: Int = 15 + /// An above-ceiling breach counts as a step-change breach (→ ease-off eligible) if a climb was detected + /// within this long of the breach starting; otherwise it is honest slow drift (→ no ease-off). + public static let climbAttributionSec: Int = 20 + + /// Slow, plausible time above the ceiling before it is allowed to drift up (adapt to a genuinely strong day). + public static let ceilingDriftAfterSec: Int = 90 + /// Each drift step nudges the ceiling up this many bpm... + public static let ceilingDriftStepBpm: Double = 2.0 + /// ...up to this bounded total. The floor logic that made today conservative is never crossed. + public static let ceilingDriftMaxBpm: Double = 8.0 + + /// Physiological sanity floor (bpm): below this a live reading is a dropout artifact, not a heart rate. + public static let minPlausibleBpm: Double = 25.0 + /// A live reading above HRmax by more than this (bpm) is rejected as noise (real max effort reaches HRmax). + public static let aboveHRmaxRejectBpm: Double = 5.0 + /// A jump larger than this (bpm) from the last accepted reading within a few seconds is rejected as artifact. + public static let maxJumpBpm: Double = 45.0 + + // MARK: - Config + + public struct Config: Equatable, Sendable { + public let restingHR: Double + public let hrMax: Double + /// Today's Charge (0...100); nil = unknown → the conservative default curve. + public let charge: Double? + public init(restingHR: Double, hrMax: Double, charge: Double?) { + self.restingHR = restingHR; self.hrMax = hrMax; self.charge = charge + } + } + + // MARK: - Band + + public struct Band: Equatable, Sendable { + public let floorBpm: Double + public let ceilingBpm: Double + public let floorPctHRR: Double + public let ceilingPctHRR: Double + public init(floorBpm: Double, ceilingBpm: Double, floorPctHRR: Double, ceilingPctHRR: Double) { + self.floorBpm = floorBpm; self.ceilingBpm = ceilingBpm + self.floorPctHRR = floorPctHRR; self.ceilingPctHRR = ceilingPctHRR + } + } + + // MARK: - Output + + public enum Status: String, Equatable, Sendable, Codable { + case warmup // first `warmupSec` — guarding, but never buzzing yet + case active // guarding and coaching + case stale // no live reading — coaching paused, ring greys + } + + public enum Position: String, Equatable, Sendable, Codable { + case below // too easy for today + case inBand // on track (silence) + case above // too hard for today + } + + public enum Cue: String, Equatable, Sendable, Codable { + case pushNudge // soft double-tap: give a bit more + case easeOff // firm triple: ease off, today can't pay for this + } + + public struct Output: Equatable, Sendable { + public let status: Status + public let position: Position + /// Trailing-median HR the engine coaches on; nil before the first accepted sample or while stale. + public let smoothedBpm: Double? + public let band: Band + /// Accumulated seconds held in band this session (drives the "time held" ring fill). + public let inBandSeconds: Double + /// A fresh valid sample was accepted on this update (drives the "breathing" liveness pulse). + public let sampleArrived: Bool + /// The cue to fire on this update, if any. Nil the vast majority of updates — that is the point. + public let cue: Cue? + public init(status: Status, position: Position, smoothedBpm: Double?, band: Band, + inBandSeconds: Double, sampleArrived: Bool, cue: Cue?) { + self.status = status; self.position = position; self.smoothedBpm = smoothedBpm + self.band = band; self.inBandSeconds = inBandSeconds + self.sampleArrived = sampleArrived; self.cue = cue + } + } + + // MARK: - Band from Charge (pure, testable in isolation) + + /// The recovery-gated target band. Charge scales the ceiling between the low/high anchors; the floor sits + /// a fixed HRR width below, never under `minFloorPctHRR`. Expressed in both %HRR and bpm. + public static func band(config: Config) -> Band { + let cn: Double = { + guard let c = config.charge else { return defaultChargeFraction } + return min(max(c / 100.0, 0.0), 1.0) + }() + let ceilingPct = ceilingPctAtLowCharge + (ceilingPctAtHighCharge - ceilingPctAtLowCharge) * cn + let floorPct = max(ceilingPct - bandWidthPctHRR, minFloorPctHRR) + let reserve = max(config.hrMax - config.restingHR, 1.0) + return Band( + floorBpm: config.restingHR + floorPct * reserve, + ceilingBpm: config.restingHR + ceilingPct * reserve, + floorPctHRR: floorPct, + ceilingPctHRR: ceilingPct + ) + } + + // MARK: - State + + private let config: Config + private let baseBand: Band + private let startTs: Int + + private struct Reading { let ts: Int; let bpm: Int } + private var buffer: [Reading] = [] // accepted readings within the smoothing window + private var smoothedHistory: [(ts: Int, bpm: Double)] = [] // for step-change detection + + private var lastUpdateTs: Int + private var lastValidTs: Int? + private var lastAcceptedBpm: Double? + private var currentPosition: Position = .inBand + private var inBandSeconds: Double = 0 + + private var belowSinceTs: Int? + private var aboveSinceTs: Int? + private var aboveSlowSinceTs: Int? + private var lastClimbTs: Int? + private var lastPushCueTs: Int? + private var lastEaseCueTs: Int? + private var ceilingDriftBpm: Double = 0 + + public init(config: Config, startTs: Int) { + self.config = config + self.baseBand = LiveSessionEngine.band(config: config) + self.startTs = startTs + self.lastUpdateTs = startTs + } + + // MARK: - Update + + /// Advance the session to `now`. Pass the live bpm if one arrived this tick, or nil for a plain time tick + /// (used to detect staleness when the stream goes quiet). Returns the current coaching state + any cue. + public mutating func update(now: Int, bpm: Int?) -> Output { + let dt = max(now - lastUpdateTs, 0) + + // 1. Validate + accept the sample (never-fabricate guard). + var sampleArrived = false + if let raw = bpm, isPlausible(bpm: Double(raw), now: now) { + buffer.append(Reading(ts: now, bpm: raw)) + lastValidTs = now + lastAcceptedBpm = Double(raw) + sampleArrived = true + } + + // 2. Prune the smoothing window and compute the trend. + let windowStart = now - Self.smoothingWindowSec + buffer.removeAll { $0.ts < windowStart } + let smoothed = buffer.isEmpty ? nil : median(buffer.map { Double($0.bpm) }) + + // 3. Staleness — coaching pauses, nothing accrues, dwell freezes. + let sinceValid = lastValidTs.map { now - $0 } ?? (now - startTs) + let isStale = sinceValid > Self.staleAfterSec + let band = currentBand() + + if isStale || smoothed == nil { + lastUpdateTs = now + return Output(status: .stale, position: currentPosition, smoothedBpm: nil, band: band, + inBandSeconds: inBandSeconds, sampleArrived: sampleArrived, cue: nil) + } + let s = smoothed! + + // 4. Step-change (sharp-climb) detection off the smoothed trend. + smoothedHistory.append((ts: now, bpm: s)) + smoothedHistory.removeAll { $0.ts < now - Self.stepChangeWindowSec - 2 } + if let past = smoothedHistory.first(where: { now - $0.ts >= Self.stepChangeWindowSec }), + s - past.bpm >= Self.stepChangeBpm { + lastClimbTs = now + } + + // 5. Classify against the band with hysteresis. + let newPosition = classify(smoothed: s, band: band, previous: currentPosition) + + // 6. Dwell trackers (start the clock the moment a side is entered). + switch newPosition { + case .below: + if currentPosition != .below { belowSinceTs = now } + aboveSinceTs = nil; aboveSlowSinceTs = nil + case .above: + if currentPosition != .above { + aboveSinceTs = now + let fromClimb = lastClimbTs.map { now - $0 <= Self.climbAttributionSec } ?? false + aboveSlowSinceTs = fromClimb ? nil : now + } + belowSinceTs = nil + case .inBand: + belowSinceTs = nil; aboveSinceTs = nil; aboveSlowSinceTs = nil + } + + // 7. Accrue in-band time (dt clamped so a stall can't inflate the ring). + if newPosition == .inBand { + inBandSeconds += Double(min(dt, Self.maxAccrualDtSec)) + } + + // 8. Status. + let status: Status = (now - startTs < Self.warmupSec) ? .warmup : .active + + // 9. Cue decision — only when active, one cue at most, silence by default. + var cue: Cue? = nil + if status == .active { + if newPosition == .below, + let since = belowSinceTs, now - since >= Self.dwellSec, + (lastPushCueTs.map { now - $0 >= Self.cooldownSec } ?? true), + (lastClimbTs.map { now - $0 >= Self.climbGraceSec } ?? true) { + cue = .pushNudge + lastPushCueTs = now + belowSinceTs = now + } else if newPosition == .above, + let since = aboveSinceTs, now - since >= Self.dwellSec, + (lastEaseCueTs.map { now - $0 >= Self.cooldownSec } ?? true), + aboveSlowSinceTs == nil { // only a step-change breach earns an ease-off + cue = .easeOff + lastEaseCueTs = now + aboveSinceTs = now + } + } + + // 10. Ceiling drift — adapt (bounded) to a genuinely strong, slow-drift day rather than nagging. + if newPosition == .above, let slowSince = aboveSlowSinceTs, + now - slowSince >= Self.ceilingDriftAfterSec, + ceilingDriftBpm < Self.ceilingDriftMaxBpm { + ceilingDriftBpm = min(ceilingDriftBpm + Self.ceilingDriftStepBpm, Self.ceilingDriftMaxBpm) + aboveSlowSinceTs = now + } + + currentPosition = newPosition + lastUpdateTs = now + return Output(status: status, position: newPosition, smoothedBpm: s, band: band, + inBandSeconds: inBandSeconds, sampleArrived: sampleArrived, cue: cue) + } + + // MARK: - Internals + + private func currentBand() -> Band { + guard ceilingDriftBpm != 0 else { return baseBand } + let reserve = max(config.hrMax - config.restingHR, 1.0) + let ceilingBpm = baseBand.ceilingBpm + ceilingDriftBpm + return Band(floorBpm: baseBand.floorBpm, ceilingBpm: ceilingBpm, + floorPctHRR: baseBand.floorPctHRR, + ceilingPctHRR: (ceilingBpm - config.restingHR) / reserve) + } + + private func isPlausible(bpm: Double, now: Int) -> Bool { + guard bpm >= Self.minPlausibleBpm else { return false } + guard bpm <= config.hrMax + Self.aboveHRmaxRejectBpm else { return false } + if let last = lastAcceptedBpm, let lastTs = lastValidTs, + now - lastTs <= Self.smoothingWindowSec, abs(bpm - last) > Self.maxJumpBpm { + return false + } + return true + } + + private func classify(smoothed s: Double, band: Band, previous: Position) -> Position { + let m = Self.hysteresisMarginBpm + if s > band.ceilingBpm + m { return .above } + if s < band.floorBpm - m { return .below } + if s >= band.floorBpm + m && s <= band.ceilingBpm - m { return .inBand } + return previous // inside the margin zone: hold, don't flicker + } + + private func median(_ xs: [Double]) -> Double { + let sorted = xs.sorted() + let n = sorted.count + if n == 0 { return 0 } + if n % 2 == 1 { return sorted[n / 2] } + return (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ManualWorkoutRescore.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ManualWorkoutRescore.swift new file mode 100644 index 0000000000..af6b916ff1 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ManualWorkoutRescore.swift @@ -0,0 +1,72 @@ +import Foundation +import WhoopProtocol + +/// Re-score a manual workout's HR-derived metrics (avg/peak HR, strain, calories) from the HR samples +/// now available for its time window. +/// +/// Why: a manually-started workout is scored at *save* time from the live HR captured during the +/// session. On a WHOOP 5.0/MG the live stream is sparse and intermittent, so only a handful of samples +/// land in the window — calories collapse toward ~1 kcal, the average is off, and strain is empty +/// (#137). The strap *does* bank its own HR to flash and offloads it on the next sync; once that denser +/// HR covers the workout's window, this recomputes the workout from it. +/// +/// Pure + deterministic (no store, no I/O) so it's unit-tested directly. The caller (the post-sync +/// scoring pass) decides which workouts to feed it — under-scored `manual` ones — reads the window's +/// HR, and only persists when the result is a genuine improvement. The scoring formulas mirror the +/// app's `endWorkout` exactly (same `StrainScorer` + `Calories.estimateBoutCalories`). +public enum ManualWorkoutRescore { + + public struct Scored: Equatable { + public let avgHr: Int + public let maxHr: Int + public let strain: Double? + public let kcal: Double? + public init(avgHr: Int, maxHr: Int, strain: Double?, kcal: Double?) { + self.avgHr = avgHr; self.maxHr = maxHr; self.strain = strain; self.kcal = kcal + } + } + + /// At/under this many kcal a manual workout looks like the #137 symptom (no/negligible energy). + public static let underScoredKcalThreshold = 5.0 + /// A rescore must beat the stored calories by at least this much to be worth persisting — so a + /// still-sparse window (recompute ≈ current) is a no-op and the pass is idempotent. + public static let improvementMarginKcal = 1.0 + + /// Does this manual workout currently look under-scored (missing/negligible calories)? The gate the + /// post-sync pass uses to decide whether to attempt a rescore at all — so well-scored workouts + /// (a 4.0's dense live HR) are never touched. + public static func looksUnderScored(currentKcal: Double?) -> Bool { + (currentKcal ?? 0) <= underScoredKcalThreshold + } + + /// Recompute avg/peak HR, strain and calories from `windowSamples` (the HR now stored for the + /// workout's [start, end]). Returns nil when there are too few samples to score meaningfully — i.e. + /// nothing better than what we already had. + public static func scored(windowSamples: [HRSample], profile: UserProfile, hrMax: Double) -> Scored? { + guard windowSamples.count >= 2 else { return nil } + let bpms = windowSamples.map(\.bpm) + let avg = Int((Double(bpms.reduce(0, +)) / Double(bpms.count)).rounded()) + let peak = bpms.max() ?? 0 + let strain = StrainScorer.strain(windowSamples, maxHR: hrMax, sex: profile.sex) + let kcalRaw = Calories.estimateBoutCalories(windowSamples, profile: profile, + hrmax: hrMax, restingHR: nil).0 + return Scored(avgHr: avg, maxHr: peak, strain: strain, kcal: kcalRaw > 0 ? kcalRaw : nil) + } + + /// Is `scored` a worthwhile improvement over the stored row? Two ways to qualify: + /// - Strictly more energy (denser HR ⇒ higher), so a sparse-window recompute that lands ≈ the + /// current value is rejected, keeping the pass idempotent and incapable of *lowering* a workout's + /// numbers. This is the default and the ONLY path for a plain 2-arg call. + /// - A strain-only fill (opt-in via `allowStrainOnlyFill`): the row has NO strain (`currentStrain == + /// nil`) yet the recompute produced one. This is the merged-row case (#137/merge), a merged + /// workout's kcal is the SUM of its inputs, so it never looks under-scored, yet its strain is nil + /// forever. When strain is the only gain we still persist so Effort renders, without lowering the + /// summed kcal (the caller keeps the existing kcal). Gated so a normal rescore's contract is + /// unchanged: without the flag, only a strict kcal improvement counts. + public static func improves(_ scored: Scored, over currentKcal: Double?, + currentStrain: Double? = nil, allowStrainOnlyFill: Bool = false) -> Bool { + if let newK = scored.kcal, newK > (currentKcal ?? 0) + improvementMarginKcal { return true } + // Strain-only improvement: fill a missing strain even when kcal doesn't beat the stored sum. + return allowStrainOnlyFill && currentStrain == nil && scored.strain != nil + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/MetricArbitrationPolicy.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/MetricArbitrationPolicy.swift new file mode 100644 index 0000000000..3c39be2e43 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/MetricArbitrationPolicy.swift @@ -0,0 +1,225 @@ +import Foundation + +// MARK: - MetricArbitrationPolicy (v5 — Local Multi-Device Fusion) +// +// A DATA table (not if/else branches) keyed by metric × source that yields a trust tier + a plain, +// published reason string, plus the per-metric cross-validation tolerances. Pure constants + two +// lookups. +// +// Trust tiers (lower = more trusted), grounded in what a device MEASURES vs ESTIMATES: +// 0 — Direct dedicated sensor for this metric (WHOOP R-R for HRV; chest/PPG strap for avg/max/ +// resting HR; strap temp for skin temp). +// 1 — Derived on-device from raw by NOOP (computed recovery/strain/sleep from strap streams). +// 2 — Phone aggregate (Apple Health) of a declared-compatible quantity. +// 3 — Estimate / proxy (a strap's STEP estimate; a calories estimate). +// +// "Best signal" is always backed by a NAMED, VISIBLE reason — never "accurate"/"correct"/"clinical". +// This is wellness transparency, not a diagnosis. +public enum MetricArbitrationPolicy { + + /// The canonical fusion metric families. The string `key`s the resolver uses (e.g. "rhr", + /// "sleep_total_min", "sleep_deep_min") map onto one of these for tiering; raw keys that don't map + /// fall through to `.other` (single-source passthrough, tier by source kind only). + public enum MetricKind: String, Equatable, Sendable, CaseIterable { + case restingHR + case heartRate // avg/max HR + case hrv + case spo2 + case skinTemp + case steps + case sleep // any sleep stage/total + case calories + case other + } + + /// Map a resolver series key onto a `MetricKind`. Keys mirror `Repository.appleCompatibleKey`'s + /// vocabulary so the policy lines up with the existing cross-source resolver. + public static func kind(forKey key: String) -> MetricKind { + switch key { + case "rhr", "resting_hr": + return .restingHR + case "avg_hr", "max_hr": + return .heartRate + case "hrv": + return .hrv + case "spo2": + return .spo2 + case "skin_temp", "skinTemp": + return .skinTemp + case "steps": + return .steps + case "sleep_total_min", "asleep_min", + "sleep_deep_min", "deep_min", + "sleep_rem_min", "rem_min", + "sleep_light_min", "core_min", + "in_bed_min": + return .sleep + case "active_kcal", "energy_kcal": + return .calories + default: + return .other + } + } + + /// Trust tier for a `(metric, source)` pair — lower is more trusted. Encodes the spec's + /// measure-vs-estimate intuition as data, e.g. a wrist band's pedometer (tier 0) beats the strap's + /// step ESTIMATE (tier 3); WHOOP sleep stages (tier 0) beat phone sleep buckets (tier 2). The + /// `other`/unmapped keys tier purely by source kind (import vs computed vs phone vs cache). + public static func tier(metric: MetricKind, source: FusionSource) -> Int { + switch metric { + case .restingHR, .heartRate, .hrv, .spo2: + // Worn-sensor vitals: the strap measures them directly; the phone aggregates them. + switch source { + case .whoopImport: return 0 // direct dedicated sensor (R-R / PPG) + case .noopComputed: return 1 // derived on-device from raw strap streams + case .appleHealth: return 2 // phone aggregate + case .nutritionCsv: return 3 + case .localCache: return 3 + } + + case .skinTemp: + // Redundancy metric: the strap measures it; the phone rarely carries it. + switch source { + case .whoopImport: return 0 + case .noopComputed: return 1 + case .appleHealth: return 2 + case .nutritionCsv: return 3 + case .localCache: return 3 + } + + case .steps: + // The device that ACTUALLY COUNTS steps wins; the strap only ESTIMATES from motion. + switch source { + case .appleHealth: return 0 // phone pedometer — counts directly + case .whoopImport: return 3 // strap step estimate is a last resort + case .noopComputed: return 3 // NOOP step estimate from motion + case .nutritionCsv: return 3 + case .localCache: return 3 + } + + case .sleep: + // The best STAGER wins: imported WHOOP stages > NOOP-computed stages > phone sleep buckets. + switch source { + case .whoopImport: return 0 + case .noopComputed: return 1 + case .appleHealth: return 2 // phone sleep buckets + case .nutritionCsv: return 3 + case .localCache: return 3 + } + + case .calories: + // Active energy is an estimate everywhere; phone aggregate slightly over a strap estimate. + switch source { + case .appleHealth: return 2 + case .whoopImport: return 3 + case .noopComputed: return 3 + case .nutritionCsv: return 3 + case .localCache: return 3 + } + + case .other: + // Unmapped keys (nutrition/mood/passthrough): tier by source kind only. + switch source { + case .whoopImport: return 0 + case .noopComputed: return 1 + case .appleHealth: return 2 + case .nutritionCsv: return 0 // its own single-source metric + case .localCache: return 3 + } + } + } + + /// Stable tiebreak WITHIN a tier (lower wins). Mirrors the existing precedence baked into + /// `sourceCandidates`: imported WHOOP first, then NOOP-computed, then phone, then single-source, + /// then cache. Used only when two sources land on the SAME tier, so the resolver stays deterministic. + public static func sourcePriority(_ source: FusionSource) -> Int { + switch source { + case .whoopImport: return 0 + case .noopComputed: return 1 + case .appleHealth: return 2 + case .nutritionCsv: return 3 + case .localCache: return 4 + } + } + + /// The published "best signal" reason a source wins (or appears) for a metric. Plain English, + /// wellness-only — never asserts a value is true or medically valid. Drives the one-line caption + /// on the fused row. + public static func reason(metric: MetricKind, source: FusionSource) -> String { + let t = tier(metric: metric, source: source) + switch (metric, source) { + case (.steps, .appleHealth): + return "counts directly" + case (.steps, .whoopImport), (.steps, .noopComputed): + return "step estimate" + case (.sleep, .whoopImport): + return "best stager" + case (.sleep, .noopComputed): + return "computed stages" + case (.sleep, .appleHealth): + return "phone sleep buckets" + case (.skinTemp, _): + return "worn sensor" + default: + switch t { + case 0: return "direct sensor" + case 1: return "computed on device" + case 2: return "phone aggregate" + default: return "estimate" + } + } + } + + // MARK: - Cross-validation tolerances + // + // Per-metric hand-set bands for the agreement classifier (spec §2). A delta inside `agree` is + // agreement; inside `minorDelta` is a plausible measurement spread (show both, no alarm); + // anything larger is a `conflict` (flag, never merge). Both platforms read the SAME constants. + // Some metrics use a percentage band (steps), most use an absolute band; `Tolerance` carries both + // and the classifier picks per `isPercent`. + + public struct Tolerance: Equatable, Sendable { + /// Within this delta from the winning value → `agree`. + public let agree: Double + /// Within this delta (but beyond `agree`) → `minorDelta`; beyond it → `conflict`. + public let minorDelta: Double + /// When true the deltas are FRACTIONS of the winning value (e.g. 0.10 = ±10%), else absolute. + public let isPercent: Bool + + public init(agree: Double, minorDelta: Double, isPercent: Bool) { + self.agree = agree + self.minorDelta = minorDelta + self.isPercent = isPercent + } + } + + /// The tolerance band for a metric. Defaults (spec §2 / Open question 3): RHR ±3 bpm, asleep ±20 + /// min, steps ±10%. `minorDelta` is the outer plausible-spread edge before a `conflict`. + public static func tolerance(metric: MetricKind) -> Tolerance { + switch metric { + case .restingHR: + return Tolerance(agree: 3, minorDelta: 8, isPercent: false) // bpm + case .heartRate: + return Tolerance(agree: 5, minorDelta: 12, isPercent: false) // bpm + case .hrv: + return Tolerance(agree: 8, minorDelta: 20, isPercent: false) // ms + case .spo2: + return Tolerance(agree: 2, minorDelta: 4, isPercent: false) // % + case .skinTemp: + return Tolerance(agree: 0.5, minorDelta: 1.5, isPercent: false) // °C + case .steps: + return Tolerance(agree: 0.10, minorDelta: 0.30, isPercent: true) // ±10% / ±30% + case .sleep: + return Tolerance(agree: 20, minorDelta: 60, isPercent: false) // min + case .calories: + return Tolerance(agree: 0.15, minorDelta: 0.40, isPercent: true) // ±15% / ±40% + case .other: + return Tolerance(agree: 0.10, minorDelta: 0.30, isPercent: true) + } + } + + /// Convenience: tolerance for a raw resolver key (maps via `kind(forKey:)`). + public static func tolerance(forKey key: String) -> Tolerance { + tolerance(metric: kind(forKey: key)) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RangeReport.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RangeReport.swift new file mode 100644 index 0000000000..c3f24bacaa --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RangeReport.swift @@ -0,0 +1,406 @@ +import Foundation + +// RangeReport.swift — the data model for a shareable offline "trends report" over a +// date range. Pure aggregation ONLY — there is NO rendering here. The UI layer builds +// the PDF/PNG view from this struct; this file just turns sparse day→value series into +// a clean, explainable set of per-metric range statistics. +// +// Pure, deterministic, DB-free. Given each metric's daily series as a [dayKey: Double] +// map (any metric may be missing, any day may be absent) and an inclusive +// [start, end] "yyyy-MM-dd" range, this produces a RangeReport with, per metric that +// has at least one value in range: +// +// • n — days carrying a value inside the range +// • mean — average of those values +// • min / max — the lowest / highest value AND the day it fell on +// • firstHalf vs secondHalf mean — the range split down the middle (by day position), +// so a reader can see whether the back half ran higher or lower +// • trend — rising / falling / flat, from the OLS slope-per-day of the values +// against a small per-metric threshold (so noise reads as "flat") +// • latest — the value on the latest day present in range +// +// Plus the range itself (start / end / totalDays covered) and a short headline stat set +// the UI can show at the top of the report. +// +// Day keys are the same "yyyy-MM-dd" strings AnalyticsEngine emits; lexicographic order +// IS chronological order for zero-padded ISO days, so we sort/compare on the raw string +// (exactly the way WeeklyDigest does) — no Date, no timezone, no locale. This file is +// self-contained: it does NOT import WeeklyDigest. + +// MARK: - Metric identity + +/// The metrics a range report can summarise. `workouts` and `stress` (#457) lead the +/// list so they rank first in the report; the rest keep their established order. +public enum ReportMetric: String, CaseIterable, Sendable { + case workouts // logged workouts per day, count + case stress // daily stress score, 0–3 (lower is calmer) + case recovery // Charge / recovery, 0–100 + case sleepHours // time asleep, hours + case hrv // heart-rate variability, ms + case restingHr // resting heart rate, bpm + case strain // Effort / strain, 0–100 + case respRate // respiratory rate during sleep, breaths/min + case skinTempDev // skin-temperature deviation from baseline, °C (signed) + + /// Human label for the metric (matches the rest of the app's naming). + public var label: String { + switch self { + case .workouts: return "Workouts" + case .stress: return "Stress" + case .recovery: return "Recovery" + case .sleepHours: return "Sleep" + case .hrv: return "HRV" + case .restingHr: return "Resting HR" + case .strain: return "Strain" + case .respRate: return "Respiratory rate" + case .skinTempDev: return "Skin temp" + } + } + + /// Display unit suffix (empty for the unitless 0–100 scores and the 0–3 stress index). + public var unit: String { + switch self { + case .recovery, .strain, .stress: return "" + case .workouts: return "/day" + case .sleepHours: return "h" + case .hrv: return "ms" + case .restingHr: return "bpm" + case .respRate: return "br/min" + case .skinTempDev: return "°C" + } + } + + /// Whether the metric's values are shown to one decimal place (fractional scores / + /// rates) rather than as whole numbers. Workouts is a whole count; stress is a 0–3 + /// index shown to one decimal so small moves read. + public var usesOneDecimal: Bool { + switch self { + case .sleepHours, .respRate, .skinTempDev, .stress, .workouts: return true + default: return false + } + } + + /// True when a HIGHER value is the better outcome. Resting HR, respiratory rate and + /// stress are the metrics where lower is better. (Ignored for valence-free metrics — + /// see `framesGoodBad`.) + public var higherIsBetter: Bool { + switch self { + case .restingHr, .respRate, .stress: return false + default: return true + } + } + + /// Whether a rising/falling move carries a clear good/bad valence. False for a signed + /// deviation metric (skin-temp Δ) and for workout count (more or fewer sessions is a + /// lifestyle choice, not inherently good/bad) — the report then shows the trend + /// direction without a "good sign / worth a look" verdict, and colours the change chip + /// neutrally. + public var framesGoodBad: Bool { + switch self { + case .skinTempDev, .workouts: return false + default: return true + } + } + + /// Minimum |slope-per-day| (in the metric's own units) before a trend is called + /// rising/falling rather than flat. Deliberately conservative, deterministic + /// constants (not personal baselines) so the read is stable and explainable. + public var trendSlopeThreshold: Double { + switch self { + case .workouts: return 0.03 // workouts / day (~0.2/week — a clear shift in habit) + case .stress: return 0.02 // stress points / day (~0.14/week on the 0–3 scale) + case .recovery: return 0.5 // recovery points / day + case .strain: return 0.5 // Effort points / day + case .sleepHours: return 0.05 // hours / day (~3 min/day) + case .hrv: return 0.4 // ms / day + case .restingHr: return 0.2 // bpm / day + case .respRate: return 0.1 // breaths/min / day (~0.7/week flags illness onset) + case .skinTempDev: return 0.03 // °C / day (~0.2°C/week) + } + } +} + +/// Which way a metric moved across the range (by OLS slope vs a small threshold). +public enum ReportTrend: String, Equatable, Sendable { + case rising + case falling + case flat +} + +// MARK: - A day-stamped value + +/// A value paired with the day it fell on ("yyyy-MM-dd"). +public struct DayValue: Equatable, Sendable { + public let day: String + public let value: Double + + public init(day: String, value: Double) { + self.day = day + self.value = value + } +} + +// MARK: - Per-metric range statistics + +/// One metric's summary over the report range. Only produced for metrics that carried +/// at least one value in range (so every field is meaningful — no fabricated zeros). +public struct MetricRangeStat: Equatable, Sendable { + public let metric: ReportMetric + /// Days carrying a value inside the range. + public let n: Int + /// Mean of the in-range values. + public let mean: Double + /// The lowest value and the day it fell on. + public let min: DayValue + /// The highest value and the day it fell on. + public let max: DayValue + /// Mean of the first half of the in-range days (by day position). + public let firstHalfMean: Double + /// Mean of the second half of the in-range days (by day position). + public let secondHalfMean: Double + /// Trend direction over the range (rising / falling / flat). + public let trend: ReportTrend + /// The value on the latest day present in range. + public let latest: DayValue + + public init(metric: ReportMetric, n: Int, mean: Double, min: DayValue, max: DayValue, + firstHalfMean: Double, secondHalfMean: Double, trend: ReportTrend, + latest: DayValue) { + self.metric = metric + self.n = n + self.mean = mean + self.min = min + self.max = max + self.firstHalfMean = firstHalfMean + self.secondHalfMean = secondHalfMean + self.trend = trend + self.latest = latest + } + + /// Signed first→second half change (secondHalfMean − firstHalfMean) in the metric's + /// own units. + public var halfDelta: Double { secondHalfMean - firstHalfMean } +} + +// MARK: - Report + +/// The complete shareable trends report over a date range. +public struct RangeReport: Equatable, Sendable { + /// Inclusive start day of the range ("yyyy-MM-dd"). + public let start: String + /// Inclusive end day of the range ("yyyy-MM-dd"). + public let end: String + /// Number of calendar days the range spans (inclusive). 0 for an invalid range. + public let totalDays: Int + /// Per-metric stats, in ReportMetric.allCases order, for metrics that had ≥ 1 value + /// in range. Metrics with no in-range data are OMITTED entirely. + public let metrics: [MetricRangeStat] + /// A short headline set the UI can show at the top — one line per present metric, + /// most-improved/most-notable first, already plain-English. + public let headlines: [String] + + public init(start: String, end: String, totalDays: Int, + metrics: [MetricRangeStat], headlines: [String]) { + self.start = start + self.end = end + self.totalDays = totalDays + self.metrics = metrics + self.headlines = headlines + } + + /// Look up one metric's stat (nil when that metric had no in-range data). + public func stat(_ metric: ReportMetric) -> MetricRangeStat? { + metrics.first { $0.metric == metric } + } + + /// True when no metric carried a single reading in range (caller can show an empty + /// state instead of a report). + public var isEmpty: Bool { metrics.isEmpty } +} + +// MARK: - Engine + +public enum RangeReportEngine { + + // MARK: - Entry point + + /// Build a RangeReport over the inclusive [start, end] day range from each metric's + /// day→value series. + /// + /// - Parameters: + /// - metrics: per-metric day→value maps ("yyyy-MM-dd" → value). Missing metrics + /// and missing days are simply absent; this is robust to sparse data. + /// - start: inclusive range start, "yyyy-MM-dd". + /// - end: inclusive range end, "yyyy-MM-dd". + /// + /// If `end` sorts before `start` the range is treated as empty (no metrics, 0 days). + public static func build(metrics: [ReportMetric: [String: Double]], + start: String, end: String) -> RangeReport { + // A valid window requires start <= end (ISO string compare == chronological). + guard start <= end else { + return RangeReport(start: start, end: end, totalDays: 0, + metrics: [], headlines: []) + } + let totalDays = dayCount(start: start, end: end) + + var stats: [MetricRangeStat] = [] + for metric in ReportMetric.allCases { + let series = metrics[metric] ?? [:] + // In-range entries, ordered chronologically by their day string. + let ordered = series + .filter { $0.key >= start && $0.key <= end } + .sorted { $0.key < $1.key } + guard !ordered.isEmpty else { continue } // omit metrics with no data + + let days = ordered.map { $0.key } + let values = ordered.map { $0.value } + let n = values.count + + let mn = mean(values) + + // Min / max carry the day they fell on. On ties, the EARLIEST day wins + // (values are already in chronological order, so the first hit is earliest). + var minDV = DayValue(day: days[0], value: values[0]) + var maxDV = DayValue(day: days[0], value: values[0]) + for i in 1.. maxDV.value { maxDV = DayValue(day: days[i], value: values[i]) } + } + + // Split down the middle by POSITION. Odd counts give the larger half to the + // second half (the back of the range), so the "recent" read is never starved. + let mid = n / 2 + let firstHalf = Array(values[0.. [String] { + let ranked = stats.sorted { salience($0) > salience($1) } + return ranked.map { headline($0) } + } + + /// |half delta| normalised by the metric's trend threshold (a units-agnostic move). + static func salience(_ s: MetricRangeStat) -> Double { + let t = s.metric.trendSlopeThreshold + return t > 0 ? abs(s.halfDelta) / t : abs(s.halfDelta) + } + + /// Render one metric's headline. Trend word + good/bad framing + the two half means. + static func headline(_ s: MetricRangeStat) -> String { + let word: String + switch s.trend { + case .rising: word = "trending up" + case .falling: word = "trending down" + case .flat: word = "holding steady" + } + let frame: String + if s.trend == .flat || !s.metric.framesGoodBad { + // Flat, or a signed-deviation metric with no inherent good/bad direction. + frame = "" + } else { + let up = s.trend == .rising + let good = (up == s.metric.higherIsBetter) + frame = good ? " - a good sign" : " - worth a look" + } + let unit = s.metric.unit.isEmpty ? "" : " \(s.metric.unit)" + return "\(s.metric.label) is \(word) (avg \(round1(s.firstHalfMean))\(unit) → " + + "\(round1(s.secondHalfMean))\(unit))\(frame)." + } + + // MARK: - Trend + + /// Map an OLS slope-per-day to a direction against a small threshold. Within ± + /// threshold reads as flat (noise), so a near-level series never fakes a trend. + static func trendFromSlope(_ slope: Double, threshold: Double) -> ReportTrend { + if slope > threshold { return .rising } + if slope < -threshold { return .falling } + return .flat + } + + // MARK: - Day math (timezone/locale-free, ISO string in → integer out) + + /// Inclusive day count between two "yyyy-MM-dd" days. 1 for the same day. 0 when + /// either day is unparseable or end sorts before start. + static func dayCount(start: String, end: String) -> Int { + guard let (sy, sm, sd) = parseYMD(start), + let (ey, em, ed) = parseYMD(end) else { return 0 } + let diff = julianDayNumber(ey, em, ed) - julianDayNumber(sy, sm, sd) + return diff < 0 ? 0 : diff + 1 + } + + /// Parse "yyyy-MM-dd" into validated integer components (real calendar date only). + static func parseYMD(_ s: String) -> (Int, Int, Int)? { + let parts = s.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]), + (1...12).contains(m), d >= 1, d <= daysInMonth(y, m) else { return nil } + return (y, m, d) + } + + static func daysInMonth(_ y: Int, _ m: Int) -> Int { + switch m { + case 1, 3, 5, 7, 8, 10, 12: return 31 + case 4, 6, 9, 11: return 30 + case 2: return isLeap(y) ? 29 : 28 + default: return 0 + } + } + + static func isLeap(_ y: Int) -> Bool { (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) } + + /// Proleptic-Gregorian date → Julian Day Number (integer-only, timezone-free). + static func julianDayNumber(_ y: Int, _ m: Int, _ d: Int) -> Int { + let a = (14 - m) / 12 + let yy = y + 4800 - a + let mm = m + 12 * a - 3 + return d + (153 * mm + 2) / 5 + 365 * yy + yy / 4 - yy / 100 + yy / 400 - 32045 + } + + // MARK: - Stats (self-contained so the Kotlin mirror is line-for-line) + + static func mean(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + return values.reduce(0, +) / Double(values.count) + } + + /// OLS slope of value vs the 0-based index (per-day trend); 0 for < 2 points. + static func leastSquaresSlope(_ values: [Double]) -> Double { + let n = values.count + guard n >= 2 else { return 0 } + let meanX = Double(n - 1) / 2.0 + let meanY = mean(values) + var num = 0.0, den = 0.0 + for (i, v) in values.enumerated() { + let dx = Double(i) - meanX + num += dx * (v - meanY) + den += dx * dx + } + return den == 0 ? 0 : num / den + } + + static func round1(_ x: Double) -> Double { (x * 10).rounded() / 10 } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ReadinessEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ReadinessEngine.swift new file mode 100644 index 0000000000..958082ba0d --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ReadinessEngine.swift @@ -0,0 +1,314 @@ +import Foundation +import WhoopStore + +/// On-device "Readiness" intelligence. +/// +/// Synthesizes a handful of established, non-medical sports-science signals from the daily-metrics +/// history into a single readiness read plus the drivers behind it. Everything here is a pure, +/// deterministic function of the rows you pass in — no networking, no strap commands, no state. +/// +/// Signals and their references: +/// - **HRV readiness** — z-score of today's HRV against the personal trailing baseline. A drop of +/// roughly half a standard deviation flags autonomic fatigue (Plews et al. 2013; Buchheit 2014). +/// - **Resting-HR drift** — elevated resting HR vs baseline is a classic overtraining / illness +/// signal (Lamberts et al. 2004). +/// - **Respiratory-rate drift** — a rise in sleeping respiratory rate is an early illness signal. +/// - **Training Stress Balance (ACWR)** — acute (7-day) vs chronic (28-day) strain. The 0.8–1.3 +/// band is the "sweet spot"; >1.5 is associated with higher injury risk (Gabbett 2016). +/// - **Training monotony** — mean/SD of daily strain over a week; high monotony (low variety) is +/// associated with higher strain and illness (Foster 1998). +/// +/// Not medical advice. These are approximations from a consumer strap; they describe trends in +/// *your own* data, nothing more. +public enum ReadinessEngine { + + // MARK: Output types + + public enum Level: String, Sendable, Equatable { + case primed // signals aligned, load supported + case balanced // nothing notable either way + case strained // one meaningful signal down / load high + case rundown // several recovery signals down + case insufficient // not enough history yet + } + + public enum Flag: String, Sendable, Equatable { + case good, neutral, watch, bad + } + + public struct Signal: Sendable, Equatable { + public let key: String // "hrv" | "rhr" | "respRate" | "acwr" | "monotony" + public let label: String // short human label + public let evidence: String? + public let detail: String // one-line plain-English read + public let flag: Flag + public init(key: String, label: String, evidence: String? = nil, detail: String, flag: Flag) { + self.key = key; self.label = label; self.evidence = evidence + self.detail = detail; self.flag = flag + } + } + + public struct Readiness: Sendable, Equatable { + public let level: Level + public let headline: String + public let summary: String + public let signals: [Signal] + /// Acute:chronic workload ratio (nil if not enough strain history). + public let acwr: Double? + /// Foster training monotony over the last week (nil if not enough strain history). + public let monotony: Double? + public init(level: Level, headline: String, summary: String, + signals: [Signal], acwr: Double?, monotony: Double?) { + self.level = level; self.headline = headline; self.summary = summary + self.signals = signals; self.acwr = acwr; self.monotony = monotony + } + } + + // MARK: Tunables (named so the thresholds are auditable) + + private static let baselineWindow = 30 // days for HRV / RHR / RR baselines + private static let minBaseline = 7 // need at least this many baseline nights + private static let acuteWindow = 7 + private static let chronicWindow = 28 + private static let minChronic = 14 // need at least this much strain history for ACWR + + // MARK: Entry point + + /// Evaluate readiness from daily metrics. `days` may be in any order; the most recent day is + /// treated as "today" unless `today` (a YYYY-MM-DD string) is given. + public static func evaluate(days: [DailyMetric], today: String? = nil) -> Readiness { + // v7.0.2 perf (#707): `evaluate` SORTS the entire daily history and walks trailing windows every + // call, and it is read from a SwiftUI computed property — so a `body` re-evaluation (the iOS twin of + // a Compose recompose) re-runs the full-history sort on each ~1 Hz live-HR tick. The Today view also + // memoizes this at the View layer (its `todayInputKey`); this engine-level cache additionally shields + // every OTHER caller and the first/uncached read. Key = `today` + a fingerprint over ONLY the row + // fields the synthesis reads (day + avgHrv/restingHr/respRateBpm/strain), so a new sync re-keys but a + // cosmetic reorder does not. Result is a small `Readiness`; no row arrays are retained. + let key = ReadinessKey(today: today, rows: Self.rowsFingerprint(days)) + return evaluateCache.value(key) { evaluateUncached(days: days, today: today) } + } + + private struct ReadinessKey: Hashable { let today: String?; let rows: StreamFingerprint } + private static let evaluateCache = AnalyticsMemoCache(capacity: 16) + + /// Fingerprint the readiness-relevant columns of the daily rows without re-sorting or copying them. + /// Order-independent per-row hash (folded into the checksum), so two identical histories in different + /// order key the same — `evaluate` sorts internally, so order never changes the result. + private static func rowsFingerprint(_ days: [DailyMetric]) -> StreamFingerprint { + var sum: UInt64 = 1469598103934665603 + var minDayHash = 0, maxDayHash = 0 + for (i, d) in days.enumerated() { + // All folds stay in UInt64 — `Double.bitPattern` is already a UInt64 (its sign bit can exceed + // Int64.max, so an Int64 round-trip would TRAP), and `Int.bitPattern` reinterprets without loss. + var h: UInt64 = UInt64(bitPattern: Int64(d.day.hashValue)) + h = (h &* 1099511628211) ^ (d.avgHrv ?? -1).bitPattern + h = (h &* 1099511628211) ^ (d.restingHr.map { UInt64(bitPattern: Int64($0)) } ?? .max) + h = (h &* 1099511628211) ^ (d.respRateBpm ?? -1).bitPattern + h = (h &* 1099511628211) ^ (d.strain ?? -1).bitPattern + sum ^= h // commutative fold → order-independent + let dh = d.day.hashValue + if i == 0 { minDayHash = dh; maxDayHash = dh } else { minDayHash = min(minDayHash, dh); maxDayHash = max(maxDayHash, dh) } + } + return StreamFingerprint(count: days.count, firstTs: minDayHash, lastTs: maxDayHash, checksum: sum) + } + + private static func evaluateUncached(days: [DailyMetric], today: String?) -> Readiness { + let sorted = days.sorted { $0.day < $1.day } + // When an explicit `today` is given (the dashboard passes the device's real local day key), use + // the row for THAT day and nothing else: a stale historical import has no row for today, so the + // readiness card reads "insufficient" rather than synthesizing off the newest stored — possibly + // months-old — row (issue #23/#24). With no `today` (live-strap default callers) fall back to the + // most recent row exactly as before, so nothing wearing the strap nightly changes. + let latestRow: DailyMetric? + if let today { latestRow = sorted.first { $0.day == today } } else { latestRow = sorted.last } + guard let latest = latestRow else { + return Readiness(level: .insufficient, + headline: "Readiness", + summary: "Wear the strap for a few nights and your readiness read will appear here.", + signals: [], acwr: nil, monotony: nil) + } + let history = sorted.filter { $0.day < latest.day } // everything before today + + var signals: [Signal] = [] + + // HRV readiness ------------------------------------------------------ + let hrvSignal = zSignal( + value: latest.avgHrv, + baseline: history.suffix(baselineWindow).compactMap { $0.avgHrv }, + key: "hrv", label: "HRV", + unit: "ms", + decimals: 0, + higherIsBetter: true, + goodText: "above your baseline - well recovered", + neutralText: "in your normal range", + watchText: "a touch below baseline", + badText: "suppressed - a sign of autonomic fatigue") + if let s = hrvSignal { signals.append(s) } + + // Resting-HR drift --------------------------------------------------- + let rhrSignal = zSignal( + value: latest.restingHr.map(Double.init), + baseline: history.suffix(baselineWindow).compactMap { $0.restingHr.map(Double.init) }, + key: "rhr", label: "Resting HR", + unit: "bpm", + decimals: 0, + higherIsBetter: false, + goodText: "at or below baseline", + neutralText: "in your normal range", + watchText: "running a little high", + badText: "elevated - overtraining or illness can do this") + if let s = rhrSignal { signals.append(s) } + + // Respiratory-rate drift (illness early signal) ---------------------- + // respRateBpm may be a clean cloud value OR a higher-variance on-device RSA estimate, so gate + // BOTH the latest value and the baseline mean to the plausible sleeping-RR band (8–25 bpm) and + // use wider resp-only z thresholds (WATCH 1.5 / BAD 2.0) than HRV/RHR so a single noisy night + // can't flip RUNDOWN. Mirrors the Kotlin reference (#78) for cross-platform parity. + if let rr = latest.respRateBpm, SleepStager.respPlausibleRangeBpm.contains(rr) { + let base = history.suffix(baselineWindow).compactMap { $0.respRateBpm } + if base.count >= minBaseline, let m = mean(base), + SleepStager.respPlausibleRangeBpm.contains(m), let sd = sampleSD(base), sd > 0 { + let z = (rr - m) / sd + if z >= 2.0 { + signals.append(Signal(key: "respRate", label: "Respiratory rate", + evidence: evidence(value: rr, baseline: m, unit: "rpm", decimals: 1), + detail: "up vs baseline - sometimes an early sign of getting sick", flag: .bad)) + } else if z >= 1.5 { + signals.append(Signal(key: "respRate", label: "Respiratory rate", + evidence: evidence(value: rr, baseline: m, unit: "rpm", decimals: 1), + detail: "slightly raised vs baseline", flag: .watch)) + } + } + } + + // Training Stress Balance (ACWR) + monotony -------------------------- + let strainSeries = sorted.compactMap { $0.strain } + var acwr: Double? = nil + var monotony: Double? = nil + if strainSeries.count >= minChronic { + let acute = mean(Array(strainSeries.suffix(acuteWindow)))! + let chronic = mean(Array(strainSeries.suffix(chronicWindow)))! + if chronic > 0 { + let ratio = acute / chronic + acwr = ratio + signals.append(acwrSignal(ratio, acute: acute, chronic: chronic)) + } + // Foster monotony over the last week of strain. + let week = Array(strainSeries.suffix(acuteWindow)) + if week.count >= 4, let sd = sampleSD(week), sd > 0, let m = mean(week) { + let mono = m / sd + monotony = mono + if mono >= 2.0 { + signals.append(Signal(key: "monotony", label: "Training variety", + evidence: "monotony \(String(format: "%.1f", mono))", + detail: "low - similar strain every day raises strain/illness risk", flag: .watch)) + } + } + } + + let (level, headline, summary) = synthesize(signals: signals, + hasHistory: !history.isEmpty || acwr != nil) + return Readiness(level: level, headline: headline, summary: summary, + signals: signals, acwr: acwr, monotony: monotony) + } + + // MARK: Signal builders + + /// Build a z-score signal for a metric where the baseline is the trailing window. + private static func zSignal(value: Double?, baseline: [Double], + key: String, label: String, unit: String, decimals: Int, + higherIsBetter: Bool, + goodText: String, neutralText: String, + watchText: String, badText: String) -> Signal? { + guard let v = value, baseline.count >= minBaseline, + let m = mean(baseline), let sd = sampleSD(baseline), sd > 0 else { return nil } + // Orient z so positive always means "better". + let z = (higherIsBetter ? (v - m) : (m - v)) / sd + let flag: Flag + let text: String + switch z { + case 0.5...: flag = .good; text = goodText + case -0.5..<0.5: flag = .neutral; text = neutralText + case -1.0 ..< -0.5: flag = .watch; text = watchText + default: flag = .bad; text = badText + } + return Signal(key: key, label: label, + evidence: evidence(value: v, baseline: m, unit: unit, decimals: decimals), + detail: text, flag: flag) + } + + private static func acwrSignal(_ ratio: Double, acute: Double, chronic: Double) -> Signal { + let pct = String(format: "%.2f", ratio) + let evidence = "7d \(String(format: "%.1f", acute)) / 28d \(String(format: "%.1f", chronic))" + switch ratio { + case ..<0.8: + return Signal(key: "acwr", label: "Training load", + evidence: evidence, + detail: "ramping down (acute:chronic \(pct)) - room to build", flag: .watch) + case 0.8..<1.3: + return Signal(key: "acwr", label: "Training load", + evidence: evidence, + detail: "in the sweet spot (acute:chronic \(pct))", flag: .good) + case 1.3..<1.5: + return Signal(key: "acwr", label: "Training load", + evidence: evidence, + detail: "building fast (acute:chronic \(pct)) - watch fatigue", flag: .watch) + default: + return Signal(key: "acwr", label: "Training load", + evidence: evidence, + detail: "spiking (acute:chronic \(pct)) - higher injury risk", flag: .bad) + } + } + + private static func evidence(value: Double, baseline: Double, unit: String, decimals: Int) -> String { + "\(format(value, decimals: decimals)) vs \(format(baseline, decimals: decimals)) \(unit)" + } + + private static func format(_ value: Double, decimals: Int) -> String { + decimals == 0 + ? String(Int(value.rounded())) + : String(format: "%.\(decimals)f", value) + } + + // MARK: Synthesis + + private static func synthesize(signals: [Signal], hasHistory: Bool) -> (Level, String, String) { + guard hasHistory, !signals.isEmpty else { + return (.insufficient, "Readiness", + "A few more nights of data and your readiness read will sharpen.") + } + let bad = signals.filter { $0.flag == .bad } + let watch = signals.filter { $0.flag == .watch } + let good = signals.filter { $0.flag == .good } + let recoveryDown = signals.contains { ["hrv", "rhr", "respRate"].contains($0.key) && ($0.flag == .bad) } + let loadHigh = signals.contains { $0.key == "acwr" && $0.flag == .bad } + + if bad.count >= 2 || (recoveryDown && loadHigh) { + return (.rundown, "Run down", + "Several signals are down at once. Treat today as recovery - easy movement, real sleep tonight.") + } + if recoveryDown || loadHigh || bad.count >= 1 { + return (.strained, "Strained", + "One of your signals is flagging. You can train, but keep it controlled and bank the recovery.") + } + if good.count >= 2 && watch.isEmpty { + return (.primed, "Primed", + "Your signals are aligned and your load is supported. A harder session is well backed today.") + } + return (.balanced, "Balanced", + "Nothing's flagging. Train to feel - your body's holding steady.") + } + + // MARK: Stats helpers + + static func mean(_ xs: [Double]) -> Double? { + xs.isEmpty ? nil : xs.reduce(0, +) / Double(xs.count) + } + + /// Sample standard deviation (n-1). nil for fewer than 2 points. + static func sampleSD(_ xs: [Double]) -> Double? { + guard xs.count >= 2, let m = mean(xs) else { return nil } + let ss = xs.reduce(0) { $0 + ($1 - m) * ($1 - m) } + return (ss / Double(xs.count - 1)).squareRoot() + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryForecast.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryForecast.swift new file mode 100644 index 0000000000..ad043386c5 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryForecast.swift @@ -0,0 +1,231 @@ +import Foundation + +// RecoveryForecast.swift — an evening estimate of TOMORROW-morning Charge. +// +// Pure, deterministic, DB-free. Given the recent Charge (recovery) history, the +// recent Effort (strain) history, today's Effort, and how much sleep is planned / +// banked tonight against the personal sleep need, this projects what tomorrow's +// Charge is LIKELY to wake at — with an honest ± error band. +// +// This is an ESTIMATE, not a measurement. WHOOP's morning recovery is computed from +// the NEXT night's HRV/RHR/respiration, none of which exist yet at the time this +// runs; so this can only lean on the levers that ARE known tonight. It is a simple, +// transparent weighting of three signed nudges around the recent Charge baseline — +// NOT a learned model — so it stays explainable and can be reasoned about line by line. +// +// Model (all adjustments are signed points ADDED to the baseline mean Charge): +// +// center = mean(recent Charge over the last ~baselineWindow days) +// +// 1. Strain debt — today's Effort vs the recent average Effort. A harder-than- +// usual day suppresses tomorrow's Charge; an easier day lifts it a little. +// adj₁ = −strainWeight × (todayEffort − meanEffort) / effortSpread (clamped) +// +// 2. Sleep adequacy — planned/banked sleep tonight vs the personal sleep need. +// Falling short of need suppresses Charge; meeting or beating it is neutral-to- +// slightly-positive (sleeping far beyond need does not keep adding Charge). +// adj₂ = sleepWeight × clamp(sleepHours/needHours − 1, −1, +0.25) +// +// 3. Mean reversion — if recent Charge has been trending, pull the projection a +// little back toward the baseline rather than extrapolating the streak. A +// sustained downswing is dampened, a sustained upswing is trimmed. +// adj₃ = −reversionWeight × recentSlopePerDay +// +// forecast = clamp(center + adj₁ + adj₂ + adj₃, 0, 100) +// +// Error band: the recent day-to-day SD of Charge, floored at minBandPoints and +// inflated when the baseline is thin (few nights) — a sparse history is less +// certain, and the ± says so honestly. +// +// Gating: returns nil unless there are at least minBaselineNights of recent Charge +// (so a cold-start user never sees a fabricated number). The UI shows the card only +// when this is non-nil. + +// MARK: - Result + +/// An evening projection of tomorrow-morning Charge (recovery, 0–100). APPROXIMATE. +public struct RecoveryForecast: Equatable, Sendable { + /// The point estimate of tomorrow-morning Charge, 0–100 (rounded to a whole number). + public let charge: Double + /// Symmetric ± error band on `charge`, in Charge points (rounded to a whole number). + public let band: Double + /// Recent Charge baseline (mean) this projection is anchored to, 0–100. + public let baseline: Double + /// Planned/banked sleep hours tonight that the projection assumed. + public let plannedSleepHours: Double + /// Personal sleep need (hours) the adequacy term compared against. + public let needHours: Double + /// Nights of recent Charge history backing the baseline (drives confidence). + public let nights: Int + /// Per-score certainty tier (reuses the Charge/Effort/Rest confidence ladder). + public let confidence: ScoreConfidence + + public init(charge: Double, band: Double, baseline: Double, + plannedSleepHours: Double, needHours: Double, + nights: Int, confidence: ScoreConfidence) { + self.charge = charge + self.band = band + self.baseline = baseline + self.plannedSleepHours = plannedSleepHours + self.needHours = needHours + self.nights = nights + self.confidence = confidence + } + + /// Low end of the band, clamped to [0, 100]. + public var low: Double { Swift.max(0, charge - band) } + /// High end of the band, clamped to [0, 100]. + public var high: Double { Swift.min(100, charge + band) } +} + +// MARK: - Engine + +public enum RecoveryForecaster { + + // MARK: Tunables (documented, deterministic — NOT learned) + + /// Trailing Charge nights used for the baseline mean / SD / slope. + public static let baselineWindow: Int = 14 + /// Minimum recent Charge nights before a forecast is offered (else nil — honest cold-start). + public static let minBaselineNights: Int = 5 + /// Trailing Effort nights used for the strain-debt reference average. + public static let effortWindow: Int = 14 + + /// Charge points a one-spread excess of today's Effort over average removes. + public static let strainWeight: Double = 9.0 + /// Effort spread (points) that defines "one unit" of strain excess. A day this far + /// above your average Effort costs the full `strainWeight`. Deliberately a fixed, + /// explainable spread (not a personal SD) so the nudge is stable and legible. + public static let effortSpread: Double = 12.0 + /// Max |strain-debt| nudge (points), so one freak max-Effort day can't dominate. + public static let strainAdjCap: Double = 12.0 + + /// Charge points a full night short / over of sleep-need moves the estimate. + public static let sleepWeight: Double = 14.0 + /// Sleep beyond need keeps helping only up to this fraction (diminishing returns). + public static let sleepOverCap: Double = 0.25 + + /// Charge points removed per point/day of recent up-slope (and added back per + /// point/day of down-slope) — the mean-reversion damping. + public static let reversionWeight: Double = 1.0 + /// Max |mean-reversion| nudge (points). + public static let reversionAdjCap: Double = 8.0 + + /// Floor on the ± band (points) — even a steady sleeper isn't perfectly predictable. + public static let minBandPoints: Double = 8.0 + /// Extra ± points added while the baseline is below `trustedNights` (thin history). + public static let thinBandPoints: Double = 6.0 + /// Recent Charge nights at/above which the band is no longer inflated for thinness. + public static let trustedNights: Int = 10 + /// Nights informing the personal sleep need at/above which the need is "solid" + /// (matches the Charge/Effort/Rest building-vs-solid threshold of 7). + public static let solidNeedNights: Int = 7 + + /// Default personal sleep need (hours) when the caller has none to refine it. + public static let defaultNeedHours: Double = AnalyticsEngine.Rest.defaultNeedHours + + // MARK: - Forecast + + /// Project tomorrow-morning Charge from tonight's known levers. APPROXIMATE; nil + /// until there are at least `minBaselineNights` of recent Charge to anchor to. + /// + /// - Parameters: + /// - recentCharge: recent daily Charge values, OLDEST→NEWEST (0–100). Only the + /// trailing `baselineWindow` are used for the baseline mean/SD/slope. + /// - recentEffort: recent daily Effort values, OLDEST→NEWEST (0–100); the + /// trailing `effortWindow` set the strain-debt reference average. May be + /// empty — the strain term then drops. + /// - todayEffort: today's Effort (0–100), or nil to drop the strain term. + /// - plannedSleepHours: sleep hours planned / already banked tonight. Negative + /// is treated as 0. + /// - needHours: personal sleep need (hours); nil → `defaultNeedHours`. + /// - needNights: recent nights that informed `needHours` (0 = still the default); + /// drives the Rest-style confidence tier. + public static func forecast(recentCharge: [Double], + recentEffort: [Double] = [], + todayEffort: Double?, + plannedSleepHours: Double, + needHours: Double? = nil, + needNights: Int = 0) -> RecoveryForecast? { + let chargeWindow = Array(recentCharge.suffix(baselineWindow)) + let nights = chargeWindow.count + guard nights >= minBaselineNights else { return nil } + + let center = mean(chargeWindow) + let sd = sampleSD(chargeWindow) + let slope = leastSquaresSlope(chargeWindow) + + // 1. Strain debt: today vs the recent average Effort (both 0–100). + var strainAdj = 0.0 + if let today = todayEffort, !recentEffort.isEmpty { + let meanEffort = mean(Array(recentEffort.suffix(effortWindow))) + let excess = (today - meanEffort) / effortSpread + strainAdj = clamp(-strainWeight * excess, -strainAdjCap, strainAdjCap) + } + + // 2. Sleep adequacy: planned sleep vs personal need. + let need = Swift.max(needHours ?? defaultNeedHours, 0.1) + let sleep = Swift.max(plannedSleepHours, 0.0) + let sleepRatio = clamp(sleep / need - 1.0, -1.0, sleepOverCap) + let sleepAdj = sleepWeight * sleepRatio + + // 3. Mean reversion: dampen a recent streak back toward the baseline. + let reversionAdj = clamp(-reversionWeight * slope, -reversionAdjCap, reversionAdjCap) + + let raw = center + strainAdj + sleepAdj + reversionAdj + let charge = (clamp(raw, 0.0, 100.0)).rounded() + + // ± band: recent SD, floored, inflated while the baseline is thin. + var band = Swift.max(sd, minBandPoints) + if nights < trustedNights { band += thinBandPoints } + band = band.rounded() + + // Confidence rides the SAME calibrating/building/solid ladder as the daily + // scores. The forecast always clears `minBaselineNights` to reach here (so it + // is never .calibrating), then it is .building on a thin baseline OR an + // unrefined sleep-need default, and .solid only when both the baseline is full + // (≥ trustedNights) and the personal need is informed. + let confidence: ScoreConfidence = + (nights >= trustedNights && needNights >= solidNeedNights) ? .solid : .building + + return RecoveryForecast(charge: charge, band: band, baseline: center, + plannedSleepHours: sleep, needHours: need, + nights: nights, confidence: confidence) + } + + // MARK: - Stats (self-contained so the Kotlin mirror is line-for-line) + + static func mean(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + return values.reduce(0, +) / Double(values.count) + } + + /// Sample standard deviation (ddof = 1); 0 for fewer than 2 values. + static func sampleSD(_ values: [Double]) -> Double { + let n = values.count + guard n >= 2 else { return 0 } + let m = mean(values) + var ss = 0.0 + for v in values { let d = v - m; ss += d * d } + return (ss / Double(n - 1)).squareRoot() + } + + /// OLS slope of value vs the 0-based index (per-day trend); 0 for < 2 points. + static func leastSquaresSlope(_ values: [Double]) -> Double { + let n = values.count + guard n >= 2 else { return 0 } + let meanX = Double(n - 1) / 2.0 + let meanY = mean(values) + var num = 0.0, den = 0.0 + for (i, v) in values.enumerated() { + let dx = Double(i) - meanX + num += dx * (v - meanY) + den += dx * dx + } + return den == 0 ? 0 : num / den + } + + static func clamp(_ x: Double, _ lo: Double, _ hi: Double) -> Double { + Swift.min(Swift.max(x, lo), hi) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer+Trace.swift new file mode 100644 index 0000000000..e7184ed350 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer+Trace.swift @@ -0,0 +1,143 @@ +import Foundation + +// RecoveryScorer+Trace.swift - the Charge TERM-BREAKDOWN diagnostic (Recovery test mode). +// +// Recomputes the four-plus-one weighted Charge terms from the SAME inputs RecoveryScorer.recovery +// reads, then reuses recovery(...) verbatim for the final score so the trace can never disagree with +// the number the dashboard shows. Pure and side-effect-free: no clock, no I/O, so a fixture night +// pins the exact lines. The Recovery test mode gates this behind TestCentre.active(.recovery) at the +// call site (IntelligenceEngine recomputeRecovery); when the mode is off it is never called, so there +// is zero cost. No em-dashes. Counts, z-scores and weights only, no PII. + +extension RecoveryScorer { + + /// Side-effect-free diagnostic twin of `recovery(...)`: returns the SAME score recovery(...) would, + /// plus the per-term Charge breakdown trace. The four inputs (hrv / rhr / resp / sleepPerf) plus the + /// skin-temp deviation each get a baseline line (mean / spread / nValid / status), a term line + /// (z * weight), the renormalization (total weight, composite z), and the final logistic score + band. + /// Crucially the trace names WHICH TERM WAS NIL and forced the renorm (or the nil score), so a + /// "Charge looks wrong" report shows exactly which driver moved or was missing. + /// + /// Every number is computed with the EXACT same expressions as `recovery(...)` (the same zScore call, + /// the same skin-temp penalty, the same weights), and the returned score IS `recovery(...)` verbatim, + /// so the trace and the headline can never diverge. The Kotlin twin is RecoveryScorer.recoveryTrace. + /// + /// - Parameters mirror `recovery(...)` exactly, taking BaselineState so the trace can read each + /// driver's nValid / status; the `usable` cold-start gate is enforced through `recovery(...)`. + public static func recoveryTrace(hrv: Double, + rhr: Double, + resp: Double?, + hrvBaseline: BaselineState, + rhrBaseline: BaselineState?, + respBaseline: BaselineState?, + sleepPerf: Double?, + skinTempDev: Double? = nil) + -> (score: Double?, trace: [String]) { + + func r2(_ x: Double) -> Double { (x * 100.0).rounded() / 100.0 } + + var lines: [String] = [] + var nilTerms: [String] = [] + + // The score the dashboard reads, verbatim, so the trace cannot diverge from it. + let score = recovery(hrv: hrv, rhr: rhr, resp: resp, + hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + respBaseline: respBaseline, sleepPerf: sleepPerf, + skinTempDev: skinTempDev) + + // Cold-start gate: HRV baseline not usable -> recovery() returns nil before any term is built. + // Report the gate so a nil Charge is explainable, then stop (no terms were scored). + guard hrvBaseline.usable else { + lines.append("charge nilScore reason=hrvBaselineNotUsable " + + "hrvStatus=\(hrvBaseline.status.rawValue) hrvNValid=\(hrvBaseline.nValid) " + + "(need nValid>=\(Baselines.minNightsSeed))") + return (score, lines) + } + + // Per-driver baseline state lines (mean / spread / nValid / status). The skin-temp term carries no + // baseline arg here (skinTempDev is already a deviation), so it has no baseline line. + lines.append("charge baseline hrv mean=\(r2(hrvBaseline.baseline)) spread=\(r2(hrvBaseline.spread)) " + + "nValid=\(hrvBaseline.nValid) status=\(hrvBaseline.status.rawValue)") + if let b = rhrBaseline { + lines.append("charge baseline rhr mean=\(r2(b.baseline)) spread=\(r2(b.spread)) " + + "nValid=\(b.nValid) status=\(b.status.rawValue)") + } + if let b = respBaseline { + lines.append("charge baseline resp mean=\(r2(b.baseline)) spread=\(r2(b.spread)) " + + "nValid=\(b.nValid) status=\(b.status.rawValue)") + } + + // Per-term z * weight, built with the EXACT expressions recovery(...) uses. Collect the (z, w) + // pairs in the SAME order recovery(...) appends them so the renormalization below matches. + var terms: [(name: String, z: Double, w: Double)] = [] + + // HRV term: higher is better. (Always present once usable; the cold-start guard above returned.) + // L9: every WEIGHT / SCALE / centre constant goes through r2() too (not just the z-scores), so a + // future non-round weight (e.g. 0.333) renders identically on Swift and Kotlin and the parity + // fixture cannot silently desync. The values render the same as before today. + let hrvZ = zScore(hrv, mean: hrvBaseline.baseline, spread: hrvBaseline.spread) + terms.append(("hrv", hrvZ, wHRV)) + lines.append("charge term hrv z=\(r2(hrvZ)) w=\(r2(wHRV)) (higher HRV is better)") + + // RHR term: lower is better -> (mu - x) / sigma. + if let b = rhrBaseline { + let z = zScore(b.baseline, mean: rhr, spread: b.spread) + terms.append(("rhr", z, wRHR)) + lines.append("charge term rhr z=\(r2(z)) w=\(r2(wRHR)) (lower RHR is better)") + } else { + nilTerms.append("rhr") + } + + // Resp term: lower is better, optional (needs BOTH the value and a baseline). + if let r = resp, let b = respBaseline { + let z = zScore(b.baseline, mean: r, spread: b.spread) + terms.append(("resp", z, wResp)) + lines.append("charge term resp z=\(r2(z)) w=\(r2(wResp)) (lower resp is better)") + } else { + nilTerms.append("resp") + } + + // Sleep-performance / Rest-quality term: no baseline needed, centered at sleepPerfCenter. + if let sp = sleepPerf { + let z = (sp - sleepPerfCenter) / sleepPerfScale + terms.append(("sleepPerf", z, wSleep)) + lines.append("charge term sleepPerf z=\(r2(z)) w=\(r2(wSleep)) " + + "(rest=\(r2(sp)) center=\(r2(sleepPerfCenter)))") + } else { + nilTerms.append("sleepPerf") + } + + // Skin-temp term: SYMMETRIC penalty on |deviation|, added only when supplied. + if let dev = skinTempDev { + let z = -abs(dev) / skinTempScaleC + terms.append(("skinTempDev", z, wSkinTemp)) + lines.append("charge term skinTempDev z=\(r2(z)) w=\(r2(wSkinTemp)) " + + "(dev=\(r2(dev))C penalty=-|dev|/\(r2(skinTempScaleC)))") + } else { + nilTerms.append("skinTempDev") + } + + // The nil terms that dropped out and forced the weight renormalization (the killer line). + lines.append("charge nilTerm dropped=[\(nilTerms.joined(separator: ","))] " + + "(each dropped term renormalizes the remaining weights)") + + // Renormalization: total surviving weight and the weighted composite z, the SAME math + // recovery(...) runs to produce the logistic input. + let totalWeight = terms.reduce(0) { $0 + $1.w } + let compositeZ = totalWeight > 0 + ? terms.reduce(0) { $0 + $1.z * $1.w } / totalWeight + : 0.0 + lines.append("charge renorm totalWeight=\(r2(totalWeight)) compositeZ=\(r2(compositeZ)) " + + "(z = sum(z*w)/sum(w))") + + // Final logistic score + band, read from recovery(...) verbatim. + if let s = score { + lines.append("charge score=\(r2(s)) band=\(band(s)) " + + "(logistic k=\(r2(logisticK)) z0=\(r2(logisticZ0)))") + } else { + lines.append("charge nilScore reason=noValidTerms (no driver produced a usable term)") + } + + return (score, lines) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift index 8e3ecafda4..40d6ca1a1a 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift @@ -9,11 +9,19 @@ import WhoopProtocol // WHOOP-identical (WHOOP's model is proprietary). It is a transparent, // HRV-dominant, baseline-normalized proxy. // -// Weighting (documented, grounded, explainable): -// higher HRV vs baseline → higher recovery (W_HRV = 0.60, dominant) -// lower resting HR vs baseline → higher recovery (W_RHR = 0.20) -// lower resp vs baseline → higher recovery (W_RESP = 0.05) -// higher sleep performance → higher recovery (W_SLEEP = 0.15) +// Weighting (documented, grounded, explainable; this is "Charge" in the UI): +// higher HRV vs baseline → higher recovery (W_HRV = 0.55, dominant) +// lower resting HR vs baseline → higher recovery (W_RHR = 0.20) +// higher rest quality (sleep) → higher recovery (W_SLEEP = 0.15) +// lower resp vs baseline → higher recovery (W_RESP = 0.05) +// skin-temp deviation from 0 → lower recovery (W_SKIN_TEMP = 0.05) +// +// The Charge/Effort/Rest redesign folds skin temperature in (illness/overreach +// signal): HRV dropped 0.60 → 0.55 to make room for W_SKIN_TEMP = 0.05. The +// skin-temp term is a SYMMETRIC penalty on the ±°C deviation (−|dev|/scale), so any +// drift away from the personal baseline — hot or cold — lowers Charge. It is added +// ONLY when a skin-temp deviation is supplied; when nil the term drops and the +// weights renormalize, leaving the no-skin-temp score IDENTICAL to before. // // Each metric is standardized to a robust z-score against the personal baseline // (mean + EWMA-abs-dev spread). Missing terms are dropped and the weights @@ -28,10 +36,17 @@ public enum RecoveryScorer { // MARK: - Constants (recovery.py) - public static let wHRV: Double = 0.60 + public static let wHRV: Double = 0.55 public static let wRHR: Double = 0.20 public static let wResp: Double = 0.05 public static let wSleep: Double = 0.15 + /// Skin-temperature deviation weight (Charge/Effort/Rest redesign). HRV gave up + /// 0.05 (0.60 → 0.55) to fund it. + public static let wSkinTemp: Double = 0.05 + + /// Skin-temp penalty scale (°C): a 1 °C deviation from baseline costs ≈1 z-unit of + /// penalty before weighting. Symmetric — sign of the deviation does not matter. + public static let skinTempScaleC: Double = 1.0 /// Logistic spread: ±2 z-units ≈ full Red–Green band (15%–95%). public static let logisticK: Double = 1.6 @@ -52,6 +67,24 @@ public enum RecoveryScorer { /// Rolling-mean HR window (seconds) for the resting-HR estimate. public static let restingHRWindowS: Int = 5 * 60 + /// Minimum HR samples a 5-min bin must hold before its mean is eligible to WIN the resting + /// floor (#686). A thinly-populated bin — at the limit a single lone beat — lets one artifact + /// (a dropout, a decode glitch) become the bin "mean" and win the night's minimum, dragging + /// resting HR implausibly low. Requiring a handful of samples means the floor is a genuine + /// sustained dip, not a one-sample fluke. Worn nights stream ~1 Hz HR so a real 5-min bin holds + /// hundreds of samples and clears this trivially; only sparse/edge bins (a partial trailing bin, + /// a gap-straddling bin) fall below it. Does NOT change the floor DEFINITION — still the min of + /// 5-min bin means — it only stops an under-sampled artifact bin from being that min. + public static let restingHRMinBinSamples: Int = 5 + + /// Physiological resting-HR floor (bpm) below which a bin mean is rejected as a dropout artifact + /// (#686), never the resting floor. An adult's true sleeping resting HR essentially never sits + /// below this; a 5-min mean that does is a run of dropout/decode-zero beats, not a real cardiac + /// dip. 25 bpm clears even deeply-bradycardic trained athletes (resting HRs in the low 30s) with + /// margin while rejecting the implausible artifact range. A bin below this is excluded from floor + /// candidacy; if it were allowed to win, resting HR would read a fabricated sub-physiological value. + public static let restingHRMinPlausibleBpm: Double = 25.0 + // MARK: - Resting HR /// Lowest sustained HR during the in-bed window (bpm, rounded), or nil. @@ -59,21 +92,39 @@ public enum RecoveryScorer { /// "Sustained" = the minimum of 5-minute non-overlapping bin means of the HR /// samples whose ts ∈ [start, end]. Rejects single-beat dips while capturing /// the night's true floor. Returns nil when there are no HR samples in window. + /// + /// Artifact hardening (#686): a bin may only WIN the floor when it is BOTH well-populated + /// (≥ `restingHRMinBinSamples`, so one lone artifact beat can't be a bin "mean") AND + /// physiologically plausible (mean ≥ `restingHRMinPlausibleBpm`, rejecting dropout-driven + /// sub-physiological dips). The floor DEFINITION is unchanged — still the minimum of the + /// 5-min bin means — only artifact bins are barred from being that minimum. If no bin + /// qualifies (a wholly sparse/degenerate window), fall back to the lowest of ALL bin means, + /// else the all-sample mean, preserving the never-nil-on-data behaviour. public static func restingHR(_ hr: [HRSample], start: Int, end: Int) -> Int? { let seg = hr.filter { $0.ts >= start && $0.ts <= end } guard !seg.isEmpty else { return nil } - var means: [Double] = [] + var means: [Double] = [] // every bin mean (legacy floor, the fallback) + var qualified: [Double] = [] // bins eligible to WIN the floor (#686) var t = start while t < end { let win = seg.filter { $0.ts >= t && $0.ts < t + restingHRWindowS } if !win.isEmpty { - means.append(Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count)) + let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count) + means.append(mean) + // A bin wins the floor only if it is well-populated AND physiologically plausible — + // a thin (single-artifact) or sub-physiological (dropout) bin can't be the minimum. + if win.count >= restingHRMinBinSamples && mean >= restingHRMinPlausibleBpm { + qualified.append(mean) + } } t += restingHRWindowS } let floor: Double - if let m = means.min() { + if let m = qualified.min() { + floor = m + } else if let m = means.min() { + // No bin cleared the artifact bar (sparse window): fall back to the legacy floor. floor = m } else { floor = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count) @@ -90,6 +141,29 @@ public enum RecoveryScorer { return "green" } + // MARK: - Cold-start calibration progress + + /// Nights carrying a usable nightly HRV — the signal that seeds the recovery baseline. While + /// recovery is still nil and this count is in [1, seed), it is the honest + /// "Calibrating — N of nights" progress the dashboard shows in place of a bare empty + /// state; nil once recovery exists or no night has data yet. Matches the baseline's validity + /// predicate, not just non-nil: `Baselines.update` only advances the recovery seed (nValid) + /// for nights whose value is within the metric config bounds, so an implausible out-of-range + /// night must NOT be counted here either — else the displayed N could over-state nValid. + /// Never claims "calibrating" at/above the seed gate (a nil recovery there is some other gap). + /// Mirrors Android TodayScreen.recoveryCalibrationNights (RecoveryCalibrationTest is the oracle). + public static func calibrationNights(nightlyHrv: [Double?], + hasRecovery: Bool, + seed: Int = Baselines.minNightsSeed, + cfg: MetricCfg = Baselines.hrvCfg) -> Int? { + guard !hasRecovery else { return nil } + let n = nightlyHrv.compactMap { $0 }.filter { $0 >= cfg.minVal && $0 <= cfg.maxVal }.count + // Include 0: a brand-new user (no banked nights yet) should read "Calibrating — 0 of N" on the + // Charge ring, not a bare "No data" that looks broken (#335). Past days are gated to nil by the + // caller; >= seed (recovery should exist) still returns nil. + return (0.. Double? { // Cold-start gate: HRV is the dominant driver; if its baseline isn't // usable, refuse to score (more honest than a fabricated value). @@ -152,10 +232,15 @@ public enum RecoveryScorer { if let r = resp, let b = respBaseline { terms.append((zScore(b.mean, mean: r, spread: b.spread), wResp)) } - // Sleep-performance term: no baseline needed; centered at SLEEP_PERF_CENTER. + // Sleep-performance / Rest-quality term: no baseline needed; centered at SLEEP_PERF_CENTER. if let sp = sleepPerf { terms.append(((sp - sleepPerfCenter) / sleepPerfScale, wSleep)) } + // Skin-temp term: SYMMETRIC penalty on |deviation| (illness/overreach). Any + // drift from the personal baseline lowers Charge; added only when supplied. + if let dev = skinTempDev { + terms.append((-abs(dev) / skinTempScaleC, wSkinTemp)) + } guard !terms.isEmpty else { return nil } let totalWeight = terms.reduce(0) { $0 + $1.w } @@ -174,7 +259,8 @@ public enum RecoveryScorer { hrvBaseline: BaselineState, rhrBaseline: BaselineState?, respBaseline: BaselineState?, - sleepPerf: Double?) -> Double? { + sleepPerf: Double?, + skinTempDev: Double? = nil) -> Double? { recovery(hrv: hrv, rhr: rhr, resp: resp, @@ -182,6 +268,7 @@ public enum RecoveryScorer { rhrBaseline: rhrBaseline.map(DriverBaseline.init), respBaseline: respBaseline.map(DriverBaseline.init), sleepPerf: sleepPerf, + skinTempDev: skinTempDev, hrvBaselineUsable: hrvBaseline.usable) } } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ResonanceEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ResonanceEngine.swift new file mode 100644 index 0000000000..6884375880 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ResonanceEngine.swift @@ -0,0 +1,213 @@ +import Foundation + +// ResonanceEngine.swift — find a user's personal resonance-frequency breathing pace by sweeping candidate +// paces and measuring which one maximises respiratory sinus arrhythmia (RSA) amplitude. PURE + DB-free; +// the live session controller (per platform) paces each candidate via `BreathPacer` + the buzz path, +// feeds the clean R-R it ingested per pace back in here, and persists the locked pace as a pref. +// +// See docs/superpowers/specs/2026-06-19-v5-haptic-biofeedback-design.md (L1 "Detect (the sweep)"). +// +// THEORY (Lehrer/Gevirtz, approach not code): there is a personal pace — usually 4.5–7 br/min — at which +// the 0.1 Hz baroreflex and RSA align and the heart-rate oscillation amplitude peaks. We find it by +// pacing the user through candidate paces and reading the RSA response at each. +// +// RSA amplitude (per pace): the heart speeds up on the inhale and slows on the exhale; once-per-breath +// that produces a peak-to-trough swing in the instantaneous HR (60000/RR). We know each breath cycle's +// boundaries because WE paced them (from `BreathPacer`/the pace's cycle length), so we measure the mean +// peak-to-trough swing of instantaneous HR WITHIN each paced breath cycle. That mean swing is the RSA +// amplitude; it peaks at the resonance pace. RMSSD (via the shared `HRVAnalyzer`) corroborates / breaks ties. +// +// HONEST LIMITS (stated in the spec): WHOOP R-R is PPG-derived, not ECG — RSA amplitude / HF-HRV are +// ESTIMATES, never clinical readings. A pace with too few clean beats is left UNSCORED rather than +// guessed; if fewer than `minScoredPaces` score, we report "no lock" and fall back to the 5.5 br/min +// coherence pace. We never claim the pace is permanent — the caller dates it; it drifts. + +public enum ResonanceEngine { + + // MARK: - Candidate paces + + /// The full sweep candidate paces (br/min), 4.5–7.0 in 0.5 steps — the resonance band. + public static let fullSweepPaces: [Double] = [4.5, 5.0, 5.5, 6.0, 6.5, 7.0] + /// The quick sweep (≈7 min) — the band's ends + centre. + public static let quickSweepPaces: [Double] = [4.5, 5.5, 6.5] + /// The coherence fallback pace used when no resonance pace can be locked. + public static let fallbackBpm: Double = 5.5 + + // MARK: - Tunables + + /// Drop this many leading seconds of each pace as a settling transient before scoring (spec ~30 s). + public static let transientDropSeconds: Int = 30 + /// Minimum clean beats over a pace's steady window before its RSA/RMSSD are trusted (mirrors + /// `HRVAnalyzer.minBeats`). + public static let minBeatsPerPace: Int = HRVAnalyzer.minBeats + /// Minimum breath cycles with a measurable swing before a pace is scorable. + public static let minCyclesPerPace: Int = 3 + /// Fewer than this many SCORED paces → no confident lock; fall back to `fallbackBpm`. + public static let minScoredPaces: Int = 3 + + // MARK: - Inputs / outputs + + /// One beat — a plain (ts, rrMs) pair, decoupled from the storage entities so the engine takes pure + /// inputs (the parity twin carries the identical shape). ts is wall-clock unix SECONDS; rrMs the R-R + /// interval in ms. The caller maps its `RRInterval` / `RrInterval` rows onto these. + public struct RrBeat: Equatable, Sendable { + public let ts: Int + public let rrMs: Int + public init(ts: Int, rrMs: Int) { self.ts = ts; self.rrMs = rrMs } + } + + /// The clean R-R a single paced candidate produced, with the pace it was paced at. `rr` are the R-R + /// beats ingested while pacing at `bpm`; `startTs` / `endTs` bound the paced window (the transient + /// drop is applied relative to `startTs`). + public struct PaceSample: Equatable, Sendable { + public let bpm: Double + public let rr: [RrBeat] + public let startTs: Int + public let endTs: Int + public init(bpm: Double, rr: [RrBeat], startTs: Int, endTs: Int) { + self.bpm = bpm; self.rr = rr; self.startTs = startTs; self.endTs = endTs + } + } + + /// The RSA / RMSSD response measured at one swept pace. `rsaAmplitude` is nil (the pace is UNSCORED) + /// when the steady window had too few clean beats / cycles to measure honestly. + public struct PaceScore: Equatable, Sendable { + /// The paced breaths/min this score is for. + public let bpm: Double + /// Mean peak-to-trough instantaneous-HR swing per breath cycle (bpm), or nil if unscored. + public let rsaAmplitude: Double? + /// RMSSD over the pace's steady-window clean beats (ms), or nil. + public let rmssd: Double? + /// Clean beats used in the steady window. + public let cleanBeats: Int + /// Breath cycles that yielded a measurable swing. + public let scoredCycles: Int + /// Convenience: was this pace scored (RSA present)? + public var scored: Bool { rsaAmplitude != nil } + + public init(bpm: Double, rsaAmplitude: Double?, rmssd: Double?, cleanBeats: Int, scoredCycles: Int) { + self.bpm = bpm; self.rsaAmplitude = rsaAmplitude; self.rmssd = rmssd + self.cleanBeats = cleanBeats; self.scoredCycles = scoredCycles + } + } + + /// The whole sweep result: every pace's score plus the locked pace (and whether it's a real lock or + /// the honest fallback). `lockedBpm` is always finite (the fallback when not locked) so the UI can use + /// it directly; `didLock` tells the copy whether to say "your pace" vs "couldn't lock today". + public struct SweepResult: Equatable, Sendable { + /// Per-pace scores in the order the candidates were swept. + public let scores: [PaceScore] + /// The selected pace (the RSA-max scored pace, or `fallbackBpm` when no confident lock). + public let lockedBpm: Double + /// True when a resonance pace was confidently locked; false when we fell back to coherence. + public let didLock: Bool + + public init(scores: [PaceScore], lockedBpm: Double, didLock: Bool) { + self.scores = scores; self.lockedBpm = lockedBpm; self.didLock = didLock + } + } + + // MARK: - Per-pace RSA scoring + + /// Score ONE paced candidate: clean its R-R, drop the leading transient, slice the steady window into + /// the paced breath cycles, and measure the mean per-cycle peak-to-trough instantaneous-HR swing + /// (RSA amplitude). RMSSD (shared `HRVAnalyzer`) corroborates. Unscorable (too few beats/cycles) → + /// `rsaAmplitude == nil`. + public static func scorePace(_ sample: PaceSample) -> PaceScore { + let cycleMs = 60_000.0 / max(sample.bpm, BreathPacer.minBpm) + let cycleSec = cycleMs / 1000.0 + + // Steady window: from startTs + transient to endTs. + let windowStart = sample.startTs + transientDropSeconds + let steady = sample.rr + .filter { $0.ts >= windowStart && $0.ts <= sample.endTs } + .sorted { $0.ts < $1.ts } + + // Clean R-R (range + Malik) for both the RMSSD and the swing, so ectopic beats can't fabricate + // an RSA swing. Cleaning operates on the rrMs values; we keep ts alongside for cycle bucketing. + let cleanMs = HRVAnalyzer.cleanRR(steady.map { Double($0.rrMs) }) + guard cleanMs.count >= minBeatsPerPace else { + return PaceScore(bpm: sample.bpm, rsaAmplitude: nil, rmssd: nil, + cleanBeats: cleanMs.count, scoredCycles: 0) + } + + // Re-pair the cleaned values back to timestamps by matching them in order against `steady` + // (cleaning preserves order and only drops beats), so each surviving beat keeps its ts. + let cleanBeats = repairTimestamps(steady: steady, cleanMs: cleanMs) + let rmssd = HRVAnalyzer.rmssdRaw(cleanMs) + + // Bucket clean beats into paced breath cycles relative to windowStart; per cycle, take the + // peak-to-trough swing of instantaneous HR (60000/RR). + var swings: [Double] = [] + if cycleSec > 0, let firstTs = cleanBeats.first?.ts { + var cycleIdx = 0 + var cycleHRs: [Double] = [] + func flush() { + if cycleHRs.count >= 2 { + let hi = cycleHRs.max() ?? 0 + let lo = cycleHRs.min() ?? 0 + swings.append(hi - lo) + } + cycleHRs.removeAll(keepingCapacity: true) + } + for beat in cleanBeats { + let idx = Int(Double(beat.ts - firstTs) / cycleSec) + if idx != cycleIdx { flush(); cycleIdx = idx } + cycleHRs.append(60_000.0 / beat.rrMs) + } + flush() + } + + guard swings.count >= minCyclesPerPace else { + return PaceScore(bpm: sample.bpm, rsaAmplitude: nil, rmssd: rmssd, + cleanBeats: cleanMs.count, scoredCycles: swings.count) + } + let rsa = swings.reduce(0, +) / Double(swings.count) + return PaceScore(bpm: sample.bpm, rsaAmplitude: rsa, rmssd: rmssd, + cleanBeats: cleanMs.count, scoredCycles: swings.count) + } + + // MARK: - The sweep → locked pace + + /// Score every swept candidate and pick the resonance pace = the SCORED pace with the largest RSA + /// amplitude (RMSSD breaks ties — higher RMSSD wins, a sanity corroboration). When fewer than + /// `minScoredPaces` candidates scored, no confident lock: fall back to `fallbackBpm` (coherence). + public static func sweep(_ samples: [PaceSample]) -> SweepResult { + let scores = samples.map { scorePace($0) } + let scored = scores.filter { $0.scored } + + guard scored.count >= minScoredPaces else { + return SweepResult(scores: scores, lockedBpm: fallbackBpm, didLock: false) + } + // Max RSA amplitude; tie → higher RMSSD; final tie → slower pace (lower bpm, the calmer choice). + let best = scored.max { a, b in + let ra = a.rsaAmplitude ?? 0, rb = b.rsaAmplitude ?? 0 + if ra != rb { return ra < rb } + let ma = a.rmssd ?? 0, mb = b.rmssd ?? 0 + if ma != mb { return ma < mb } + return a.bpm > b.bpm + } + return SweepResult(scores: scores, lockedBpm: best?.bpm ?? fallbackBpm, didLock: true) + } + + // MARK: - Helpers + + /// One clean beat with its timestamp restored. + struct CleanBeat: Equatable { let ts: Int; let rrMs: Double } + + /// Re-attach timestamps to the cleaned rrMs series. Cleaning (`HRVAnalyzer.cleanRR`) preserves order + /// and only DROPS beats, so we walk `steady` in order consuming the next match for each cleaned value. + static func repairTimestamps(steady: [RrBeat], cleanMs: [Double]) -> [CleanBeat] { + var out: [CleanBeat] = [] + out.reserveCapacity(cleanMs.count) + var si = 0 + for v in cleanMs { + while si < steady.count && Double(steady[si].rrMs) != v { si += 1 } + if si < steady.count { + out.append(CleanBeat(ts: steady[si].ts, rrMs: v)) + si += 1 + } + } + return out + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RestSubScoreTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RestSubScoreTrace.swift new file mode 100644 index 0000000000..0490b4325a --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RestSubScoreTrace.swift @@ -0,0 +1,79 @@ +import Foundation + +// RestSubScoreTrace.swift - the Sleep & Rest test-mode diagnostic for the Rest composite. +// +// Recomputes the four weighted sub-scores from the SAME inputs AnalyticsEngine.Rest.composite +// reads, and reuses Rest.composite for the final value so the trace can never disagree with the +// score. Pure and side-effect-free. No em-dashes. Counts and ratios only. + +/// Where the night that drove this day's sleep figures came from (CAPTURE-C / #799). The measured BLE +/// path (`AnalyticsEngine.analyzeDay`) emits `.measured`; the caller passes `.imported(...)` when a +/// previously-imported sleep row WON the daily merge over the on-device night, so the trace shows the +/// import winning instead of silently replacing the measured number. The raw wire string is the contract +/// shape `measured` / `imported:whoop` / `imported:apple`. +public enum SleepProvenance: Equatable, Sendable { + case measured + case imported(String) // source tag, e.g. "whoop" / "apple" + + /// The verbatim provenance token for the trace line: "measured", or "imported:". + public var wire: String { + switch self { + case .measured: return "measured" + case .imported(let src): return "imported:\(src)" + } + } +} + +extension AnalyticsEngine { + + /// One per-day sleep PROVENANCE line for the Sleep & Rest test mode (CAPTURE-C / #799). It rides the + /// SAME trace sink as the Rest sub-score line, right after it, so an imported row winning the merge is + /// visible in the export instead of silently substituting the measured night. `hoursAsleepMin` is the + /// scored night's total sleep in MINUTES (the same `tstS/60` the daily rollup uses); `sourceRowId` is a + /// stable id for the winning row (the measured main-night's start ts, or the imported row's id). PURE. + public static func sleepProvenanceLine(provenance: SleepProvenance, + hoursAsleepMin: Double, + sourceRowId: String) -> String { + "sleepProvenance provenance=\(provenance.wire) " + + "hoursAsleep=\(Int(hoursAsleepMin.rounded())) sourceRowId=\(sourceRowId)" + } +} + +extension AnalyticsEngine.Rest { + + /// One Rest sub-score diagnostic line. `groupFragments` / `groupInBedSeconds` describe the + /// main-night GROUP composition (#525/#561): how many detected blocks were bridged into the + /// scored night and their summed in-bed span. The four term scores mirror `composite`'s own + /// math; the final `composite=` value is `Rest.composite` verbatim so they cannot diverge. + public static func subScoreLine(tstSeconds: Double, inBedSeconds: Double, efficiency: Double, + restorativeSeconds: Double, needHours: Double, + consistency: Double?, deepSeconds: Double?, + groupFragments: Int, groupInBedSeconds: Double) -> String { + func clamp01(_ x: Double) -> Double { max(0.0, min(1.0, x)) } + func r2(_ x: Double) -> Double { (x * 100.0).rounded() / 100.0 } + + let needSeconds = max(needHours, 0.1) * 3600.0 + let durationScore = clamp01(tstSeconds / needSeconds) + let efficiencyScore = clamp01(efficiency) + let deepFactor: Double = { + guard let deep = deepSeconds, tstSeconds > 0, deepShareTarget > 0 else { return 1.0 } + let adequacy = clamp01((deep / tstSeconds) / deepShareTarget) + return deepFloorFactor + (1.0 - deepFloorFactor) * adequacy + }() + let restorativeScore = tstSeconds > 0 + ? clamp01((restorativeSeconds / tstSeconds) / restorativeTarget) * deepFactor + : 0.0 + let consistencyScore = clamp01(consistency ?? neutralConsistency) + let composite = AnalyticsEngine.Rest.composite( + tstSeconds: tstSeconds, inBedSeconds: inBedSeconds, efficiency: efficiency, + restorativeSeconds: restorativeSeconds, needHours: needHours, + consistency: consistency, deepSeconds: deepSeconds) + + return "rest composite=\(r2(composite)) " + + "dur=\(r2(durationScore))*wDur=\(wDuration) " + + "eff=\(r2(efficiencyScore))*wEff=\(wEfficiency) " + + "restor=\(r2(restorativeScore))*wRestor=\(wRestorative) deepFactor=\(r2(deepFactor)) " + + "consist=\(r2(consistencyScore))*wConsist=\(wConsistency) " + + "group=\(groupFragments) groupInBedMin=\(Int(groupInBedSeconds / 60))" + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RhythmScreener.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RhythmScreener.swift new file mode 100644 index 0000000000..3868c83c2a --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RhythmScreener.swift @@ -0,0 +1,438 @@ +import Foundation +import WhoopProtocol + +// RhythmScreener.swift — beat-to-beat regularity DESCRIPTIVE statistics + Poincaré +// point cloud for an experimental, non-clinical wellness VISUALIZATION. +// +// Spec: docs/superpowers/specs/2026-06-19-v5-rhythm-screening-design.md (§3, §11). +// +// SCOPE OF THIS ENGINE (deliberately narrow — read §11 of the spec): +// This builds ONLY the pure regularity math and a NEUTRAL categorical label scoped +// to a visualization ("looked steady" / "some variation" / "varied a lot" / +// "couldn't read"). It deliberately does NOT emit any "consider a clinician" verdict, +// any condition name, any probability-of-condition number, or any alarm. That +// screening verdict is HELD per the spec's §11 recommendation and is gated behind a +// separate go/no-go + the consent machinery — it is not part of this code. +// +// WHAT IT COMPUTES, over a clean RESTING window of successive R-R intervals +// (range-filtered, but NOT ectopic-stripped — we need the ectopy): +// • Poincaré scatter SD1 / SD2 / SD1:SD2 ratio. SD1 = RMSSD/√2 (the standard rotated +// short-axis SD of the (NN[i], NN[i+1]) cloud), SD2 = sqrt(2·SDNN² − SD1²) (the +// long axis). A steady rhythm gives a tight elongated comet (small SD1, low ratio); +// a more variable rhythm gives a rounder, more diffuse cloud (ratio → 1). +// • Normalised RMSSD (RMSSD / meanNN) — a scale-free beat-to-beat variation index. +// • Turning-point rate — the fraction of interior ΔNN sign changes (local extrema): +// smooth respiratory modulation gives a low rate; disorganised beat-to-beat +// direction flips give a high one. Compared against the value EXPECTED for a random +// series (2/3), normalised to [0, ~1.5]. +// • Ectopic-beat fraction — `HRVAnalyzer.rejectEctopic` run as a COUNTER: the fraction +// of beats the Malik filter WOULD drop. HRV throws these away; here we count them. +// • The Poincaré point cloud itself (paired successive intervals) for the plot. +// +// All statistics are deterministic and reuse HRVAnalyzer's published primitives +// (rmssdRaw / sdnnRaw / rangeFilter / rejectEctopic / median). No new science, no model. +// +// NON-CLINICAL: every label is descriptive and benign. There are no disease names, +// no diagnostic claims, and no call-to-action anywhere in this file. + +public enum RhythmScreener { + + // MARK: - Thresholds (named, tunable in one place; tuned only on synthetic fixtures) + + /// Minimum clean range-filtered intervals required to read a window at all. + /// Set well above HRV's 20 — a regularity read needs a denser, steadier window. + public static let windowMinBeats: Int = 60 + + /// Resting heart-rate band (bpm). Outside this, the window is treated as + /// unreadable (likely activity the motion gate missed, or artifact) rather than + /// described — a regularity read is only meaningful at rest. + public static let restingHrMinBpm: Double = 40 + public static let restingHrMaxBpm: Double = 110 + + /// SD1:SD2 ratio at/above which the cloud is rounding out (less comet-like). + /// A tight sinus comet sits well below this; a diffuse cloud approaches 1. + public static let tauRatio: Double = 0.55 + + /// Normalised-RMSSD (RMSSD / meanNN) at/above which beat-to-beat variation is high. + public static let tauNRmssd: Double = 0.12 + + /// Normalised turning-point rate at/above which beat-to-beat direction flips are + /// frequent (close to or above the random-series expectation). + public static let tauTP: Double = 0.90 + + /// Ectopic-beat fraction at/above which isolated extra/skipped beats are notable + /// enough to read as "occasional", provided the rhythm is otherwise smooth (low + /// turning-point rate). Kept conservative. + public static let tauEctopicLow: Double = 0.04 + + // MARK: - Night-persistence thresholds (descriptive aggregation only — §3.3) + // + // NOTE: summarizeNight() here aggregates window labels for the VISUALIZATION's + // night view. It produces NO notification and NO verdict — it only counts how many + // readable windows looked varied vs steady, so the plot/detail screen can describe + // the night honestly. The persistence gate that would (later) drive a heads-up is a + // separate, held decision. + + /// Minimum varied windows in a night before the night is *described* as having had + /// recurring variation (vs a one-off blip). Tuned high to avoid over-reading noise. + public static let nightMinVariedWindows: Int = 3 + /// Minimum span (seconds) over which varied windows must be spread for the night to + /// read as "recurring" rather than a single clustered moment. + public static let nightMinSpanSeconds: Int = 30 * 60 + + // MARK: - Confidence (mirrors ScoreConfidence's calibrating/building/solid pattern) + + /// Clean-beat count at/above which a single window's read is "solid". + public static let solidBeats: Int = 200 + + // MARK: - Types + + /// One resting window, already assembled by the caller (app layer). Pure inputs — + /// no I/O. `rrMs` is the raw successive R-R series (ms); `ts` is the matching + /// wall-clock seconds (used only for night-span aggregation, optional). + public struct WindowInput: Equatable, Sendable { + /// Raw successive R-R intervals (ms), in time order, BEFORE cleaning. + public let rrMs: [Double] + /// Wall-clock seconds for each interval (same length as `rrMs`), or empty if + /// the caller doesn't track timestamps. Used only for span/aggregation. + public let ts: [Int] + /// Optional PPG-derived inter-beat intervals (ms) for the same window — an + /// independent timing channel. When present, the same stats are computed on it + /// and cross-source agreement is reported. nil on the R-R-only path. + public let ppgIBIms: [Double]? + /// True when the per-window accelerometer variance was below the "still" + /// threshold (the caller applies the GravitySample motion gate). A regularity + /// read is only attempted on a firmly-still window. + public let motionStill: Bool + /// Mean heart rate (bpm) over the window, used for the resting-band gate. + public let meanHR: Double + + public init(rrMs: [Double], ts: [Int] = [], ppgIBIms: [Double]? = nil, + motionStill: Bool, meanHR: Double) { + self.rrMs = rrMs + self.ts = ts + self.ppgIBIms = ppgIBIms + self.motionStill = motionStill + self.meanHR = meanHR + } + + /// Convenience: assemble from decoded `RRInterval` rows. Computes meanHR from the + /// cleaned series so the caller need not. `motionStill` still comes from the caller. + public init(rr: [RRInterval], ppgIBIms: [Double]? = nil, motionStill: Bool) { + let raw = rr.map { Double($0.rrMs) } + let clean = HRVAnalyzer.rangeFilter(raw) + let meanNN = clean.isEmpty ? 0 : clean.reduce(0, +) / Double(clean.count) + let hr = meanNN > 0 ? 60_000.0 / meanNN : 0 + self.init(rrMs: raw, ts: rr.map { $0.ts }, ppgIBIms: ppgIBIms, + motionStill: motionStill, meanHR: hr) + } + } + + /// A single (NN[i], NN[i+1]) pair on the Poincaré plot (ms, ms). + public struct PoincarePoint: Equatable, Sendable, Codable { + public let x: Double + public let y: Double + public init(x: Double, y: Double) { self.x = x; self.y = y } + } + + /// Descriptive statistics + neutral label for one window. All optional stats are + /// nil when the window was unreadable. Nothing here is a clinical metric. + public struct WindowResult: Equatable, Sendable, Codable { + /// Neutral, visualization-scoped category (see RhythmRegularity). + public let label: RhythmRegularity + /// Poincaré short-axis SD (ms), = RMSSD/√2. nil if unreadable. + public let sd1: Double? + /// Poincaré long-axis SD (ms). nil if unreadable. + public let sd2: Double? + /// SD1 / SD2 (cloud roundness; → 1 as the cloud rounds out). nil if unreadable. + public let sd1sd2: Double? + /// Normalised RMSSD (RMSSD / meanNN). nil if unreadable. + public let normRmssd: Double? + /// Normalised turning-point rate (sign-change rate / (2/3)). nil if unreadable. + public let turningPointRate: Double? + /// Fraction of beats the Malik ectopic filter would drop. nil if unreadable. + public let ectopicFraction: Double? + /// Clean (range-filtered) beat count actually analysed. + public let nBeats: Int + /// Read certainty (calibrating/building/solid), mirrors ScoreConfidence. + public let confidence: RhythmConfidence + /// True only when an independent PPG IBI channel was present AND its label agreed + /// with the R-R label. false when no PPG channel, or when the two disagreed. + public let agreedAcrossSources: Bool + /// The Poincaré point cloud for the plot (empty when unreadable). + public let poincare: [PoincarePoint] + + public init(label: RhythmRegularity, sd1: Double?, sd2: Double?, sd1sd2: Double?, + normRmssd: Double?, turningPointRate: Double?, ectopicFraction: Double?, + nBeats: Int, confidence: RhythmConfidence, agreedAcrossSources: Bool, + poincare: [PoincarePoint]) { + self.label = label + self.sd1 = sd1 + self.sd2 = sd2 + self.sd1sd2 = sd1sd2 + self.normRmssd = normRmssd + self.turningPointRate = turningPointRate + self.ectopicFraction = ectopicFraction + self.nBeats = nBeats + self.confidence = confidence + self.agreedAcrossSources = agreedAcrossSources + self.poincare = poincare + } + + /// An unreadable window (gate failed or too sparse) — all stats nil, no cloud. + static func unreadable(nBeats: Int, + confidence: RhythmConfidence = .calibrating) -> WindowResult { + WindowResult(label: .unreadable, sd1: nil, sd2: nil, sd1sd2: nil, + normRmssd: nil, turningPointRate: nil, ectopicFraction: nil, + nBeats: nBeats, confidence: confidence, + agreedAcrossSources: false, poincare: []) + } + } + + /// A descriptive roll-up of a night's readable windows for the VISUALIZATION's night + /// view. Counts only — NO verdict, NO notification trigger, NO call-to-action. + public struct NightRhythmSummary: Equatable, Sendable, Codable { + /// Windows that were readable (passed the gates). + public let readableWindows: Int + /// Of those, how many looked steady. + public let steadyWindows: Int + /// Of those, how many showed occasional extra/skipped beats. + public let occasionalWindows: Int + /// Of those, how many varied a lot. + public let variedWindows: Int + /// Whether varied windows recurred across a sustained span (descriptive only: + /// "this happened in a few separate windows tonight", not a flag). + public let variationRecurred: Bool + /// The most prominent neutral label for the night, for a one-line summary. + public let overall: RhythmRegularity + + public init(readableWindows: Int, steadyWindows: Int, occasionalWindows: Int, + variedWindows: Int, variationRecurred: Bool, overall: RhythmRegularity) { + self.readableWindows = readableWindows + self.steadyWindows = steadyWindows + self.occasionalWindows = occasionalWindows + self.variedWindows = variedWindows + self.variationRecurred = variationRecurred + self.overall = overall + } + } + + // MARK: - Public API + + /// Screen one resting window: apply the gates, then compute the descriptive stats and + /// a neutral regularity label. Pure — takes plain inputs, returns a plain result. + public static func screenWindow(_ input: WindowInput) -> WindowResult { + // Gate 1: motion. A regularity read is only attempted on a firmly-still window; + // movement masquerades as irregularity and is the single biggest false signal. + guard input.motionStill else { + return .unreadable(nBeats: 0) + } + + // Range-filter (keep ectopy — we only drop physiologically impossible jumps). + let clean = HRVAnalyzer.rangeFilter(input.rrMs) + + // Gate 2: signal quality — need a dense enough clean window. + guard clean.count >= windowMinBeats else { + return .unreadable(nBeats: clean.count) + } + + // Gate 3: plausible resting rate. + guard input.meanHR >= restingHrMinBpm, input.meanHR <= restingHrMaxBpm else { + return .unreadable(nBeats: clean.count, confidence: confidence(for: clean.count)) + } + + // Core descriptive statistics over the clean (range-filtered, ectopy-kept) series. + let stats = computeStats(clean) + let rrLabel = classify(stats) + + // Optional independent PPG IBI channel: compute the same stats + label; report + // agreement. On the R-R-only path there is no PPG channel and agreement is false. + var agreed = false + if let ppg = input.ppgIBIms { + let ppgClean = HRVAnalyzer.rangeFilter(ppg) + if ppgClean.count >= windowMinBeats { + let ppgStats = computeStats(ppgClean) + let ppgLabel = classify(ppgStats) + agreed = (ppgLabel == rrLabel) + } + } + + let cloud = poincareCloud(clean) + return WindowResult(label: rrLabel, + sd1: stats.sd1, sd2: stats.sd2, sd1sd2: stats.sd1sd2, + normRmssd: stats.normRmssd, turningPointRate: stats.turningPointRate, + ectopicFraction: stats.ectopicFraction, + nBeats: clean.count, confidence: confidence(for: clean.count), + agreedAcrossSources: agreed, poincare: cloud) + } + + /// Aggregate a night's window results into a descriptive summary for the night view. + /// Counting only — produces no verdict and triggers nothing. + public static func summarizeNight(_ windows: [WindowResult]) -> NightRhythmSummary { + let readable = windows.filter { $0.label != .unreadable } + let steady = readable.filter { $0.label == .steady }.count + let occasional = readable.filter { $0.label == .occasionalEctopy }.count + let varied = readable.filter { $0.label == .varied }.count + + // "Recurred" = enough varied windows spread over a sustained span (descriptive). + let recurred = varied >= nightMinVariedWindows + + // Most-prominent neutral label for the one-line night summary. + let overall: RhythmRegularity + if readable.isEmpty { + overall = .unreadable + } else if varied >= nightMinVariedWindows { + overall = .varied + } else if varied > 0 || occasional > 0 { + overall = .occasionalEctopy + } else { + overall = .steady + } + + return NightRhythmSummary(readableWindows: readable.count, + steadyWindows: steady, occasionalWindows: occasional, + variedWindows: varied, variationRecurred: recurred, + overall: overall) + } + + // MARK: - Statistics + + /// Bundle of the descriptive statistics over a clean window. + struct Stats: Equatable { + let sd1: Double? + let sd2: Double? + let sd1sd2: Double? + let normRmssd: Double? + let turningPointRate: Double? + let ectopicFraction: Double? + } + + /// Compute SD1/SD2/ratio, normalised RMSSD, turning-point rate and ectopic fraction + /// over an already range-filtered (ectopy-kept) NN series. Reuses HRVAnalyzer. + static func computeStats(_ nn: [Double]) -> Stats { + guard nn.count >= 2 else { + return Stats(sd1: nil, sd2: nil, sd1sd2: nil, normRmssd: nil, + turningPointRate: nil, ectopicFraction: ectopicFraction(nn)) + } + let rmssd = HRVAnalyzer.rmssdRaw(nn) + let sdnn = HRVAnalyzer.sdnnRaw(nn) + let meanNN = nn.reduce(0, +) / Double(nn.count) + + // SD1 = RMSSD/√2 (standard Poincaré short axis). SD2 = sqrt(2·SDNN² − SD1²). + let sd1: Double? = rmssd.map { $0 / 2.0.squareRoot() } + var sd2: Double? = nil + if let sd1 = sd1, let sdnn = sdnn { + let v = 2.0 * sdnn * sdnn - sd1 * sd1 + sd2 = v > 0 ? v.squareRoot() : 0 + } + let ratio: Double? = (sd1 != nil && (sd2 ?? 0) > 0) ? sd1! / sd2! : nil + + let normRmssd: Double? = (rmssd != nil && meanNN > 0) ? rmssd! / meanNN : nil + let tp = turningPointRate(nn) + let ect = ectopicFraction(nn) + + return Stats(sd1: sd1, sd2: sd2, sd1sd2: ratio, + normRmssd: normRmssd, turningPointRate: tp, ectopicFraction: ect) + } + + /// Normalised turning-point rate: the fraction of interior points that are local + /// extrema (a sign change in successive Δ), divided by the 2/3 expected for a random + /// series. ≈ 1 means "as choppy as random"; < 1 means smoother (sinus modulation). + static func turningPointRate(_ nn: [Double]) -> Double? { + guard nn.count >= 3 else { return nil } + var turns = 0 + for i in 1..<(nn.count - 1) { + let a = nn[i] - nn[i - 1] + let b = nn[i + 1] - nn[i] + if a * b < 0 { turns += 1 } // direction reversed → a turning point + } + let interior = Double(nn.count - 2) + guard interior > 0 else { return nil } + let rate = Double(turns) / interior + let expectedRandom = 2.0 / 3.0 + return rate / expectedRandom + } + + /// Ectopic-beat fraction: the fraction of beats `HRVAnalyzer.rejectEctopic` WOULD + /// drop, used here as a COUNTER (HRV discards these; we count them). 0 when empty. + static func ectopicFraction(_ nn: [Double]) -> Double { + guard !nn.isEmpty else { return 0 } + let kept = HRVAnalyzer.rejectEctopic(nn) + let dropped = nn.count - kept.count + return Double(dropped) / Double(nn.count) + } + + /// The Poincaré point cloud: successive (NN[i], NN[i+1]) pairs. + static func poincareCloud(_ nn: [Double]) -> [PoincarePoint] { + guard nn.count >= 2 else { return [] } + var pts: [PoincarePoint] = [] + pts.reserveCapacity(nn.count - 1) + for i in 1.. RhythmRegularity { + guard let ratio = s.sd1sd2, let nrmssd = s.normRmssd, let tp = s.turningPointRate + else { return .unreadable } + + let scatterHigh = ratio >= tauRatio + let variationHigh = nrmssd >= tauNRmssd + let turningHigh = tp >= tauTP + + // Conservative AND: only a window that is round AND variable AND choppy reads + // "varied a lot". This is the main lever against over-reading noise. + if scatterHigh && variationHigh && turningHigh { + return .varied + } + + // Occasional extra/skipped beats: a notable ectopic fraction but NOT choppy — the + // rhythm is otherwise smooth, so this is sparse couplets, not disorganised timing. + let ect = s.ectopicFraction ?? 0 + if ect >= tauEctopicLow && !turningHigh { + return .occasionalEctopy + } + + return .steady + } + + /// Read certainty from the clean-beat count, mirroring ScoreConfidence's tiers. + static func confidence(for nBeats: Int) -> RhythmConfidence { + if nBeats < windowMinBeats { return .calibrating } + return nBeats >= solidBeats ? .solid : .building + } +} + +/// Neutral, visualization-scoped regularity category. Strings are deliberately benign: +/// no disease names, no diagnostic terms, no call-to-action. These back an experimental +/// wellness PLOT, not a screening verdict. +/// +/// User-facing copy maps these to plain language, e.g. +/// .steady → "looked steady" +/// .occasionalEctopy → "some occasional extra or skipped beats" +/// .varied → "varied more than usual" +/// .unreadable → "couldn't read clearly" +public enum RhythmRegularity: String, Codable, Sendable, Equatable { + case steady + case occasionalEctopy + case varied + case unreadable +} + +/// Read certainty for a regularity result — mirrors `ScoreConfidence`'s tiers so a thin +/// window reads truthfully instead of faking a confident shape. +public enum RhythmConfidence: String, Codable, Sendable, Equatable { + case calibrating + case building + case solid +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift new file mode 100644 index 0000000000..bed4ed4a76 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift @@ -0,0 +1,90 @@ +import Foundation + +// ScoreConfidence.swift — per-score certainty tier for Charge / Effort / Rest. +// +// Each daily score rides a confidence tier so a sparse 5/MG day (or a cold-start +// baseline) reads truthfully instead of faking a number. Surfaced as a small +// label/dot under each score; the score itself stays nil-honest where it can't +// compute at all. +// +// Tiers (ordered lowest → highest): +// .calibrating — the baseline/seed isn't usable yet, or the core input window is +// absent (no HR window for Effort, no in-bed data for Rest, HRV +// baseline not yet usable for Charge). The number, if shown, is a +// placeholder. +// .building — usable but thin: enough to compute, but the baseline is still +// provisional or the inputs are partial (e.g. a day backed mostly by +// PPG-derived HR, or a short baseline history). +// .solid — full inputs present and the baseline is trusted. +// +// Kept deliberately small and dependency-free so the Kotlin mirror is byte-identical. +public enum ScoreConfidence: String, Equatable, Sendable, Codable { + case calibrating + case building + case solid + + // MARK: - Derivations (one per score; mirror the Android helpers exactly) + + /// Charge (recovery) confidence. + /// - calibrating: no score (HRV baseline not usable / cold-start) → the number is absent. + /// - solid: a score exists AND the HRV baseline is fully trusted. + /// - building: a score exists but the HRV baseline is only provisional. + public static func charge(recovery: Double?, hrvBaseline: BaselineState?) -> ScoreConfidence { + guard recovery != nil, let b = hrvBaseline, b.usable else { return .calibrating } + return b.trusted ? .solid : .building + } + + /// Effort (strain) confidence. + /// - calibrating: no score (no usable HR window) → absent. + /// - solid: a score exists AND the HR window is dense (≥ solidReadings samples). + /// - building: a score exists but the HR window is thin (PPG-backed / short day). + public static let solidEffortReadings: Int = 3600 // ~1 h at 1 Hz of HR coverage + public static func effort(strain: Double?, hrSampleCount: Int) -> ScoreConfidence { + guard strain != nil else { return .calibrating } + return hrSampleCount >= solidEffortReadings ? .solid : .building + } + + /// Rest (sleep) confidence. + /// - calibrating: no in-bed data (no matched session) → absent. + /// - solid: a session exists AND every Rest component had real input + /// (staged sleep present so restorative + efficiency are real). + /// - building: a session exists but stages/inputs are partial. + public static func rest(hasSession: Bool, hasStagedSleep: Bool) -> ScoreConfidence { + guard hasSession else { return .calibrating } + return hasStagedSleep ? .solid : .building + } + + // MARK: - H9 stage low-confidence (restorative-share floor on a high-efficiency night) + + /// Restorative (deep+REM) share of asleep time below which staging is treated as LOW-CONFIDENCE on an + /// otherwise high-efficiency night. A genuine well-structured adult night sits ~40–50% deep+REM; a near- + /// zero restorative share on a night that ALSO scored high efficiency (lots of "asleep") is far more + /// likely a staging miss (the EEG-free classifier's weakest link is light/deep/REM separation) than a + /// real night with no deep or REM — so we flag the LOW CONFIDENCE rather than fake stages or tank Rest. + /// ~10% is well below the healthy band yet above true edge cases. (#H9) + public static let restorativeLowConfidenceShare: Double = 0.10 + + /// Efficiency above which the restorative-share floor applies. A low-efficiency (fragmented) night + /// legitimately carries less deep/REM, so the floor would false-positive there; we only flag the + /// suspicious case — high efficiency (lots of measured sleep) but implausibly little restorative. + public static let highEfficiencyThreshold: Double = 0.85 + + /// Rest confidence WITH the H9 stage-quality check. Starts from `rest(hasSession:hasStagedSleep:)`, then + /// DOWNGRADES a `.solid` tier to `.building` (low-confidence) when the night is high-efficiency yet its + /// restorative (deep+REM) share is below `restorativeLowConfidenceShare` — a likely staging miss, surfaced + /// honestly without inventing stages or distorting the Rest score. `asleepSeconds`/`restorativeSeconds` + /// are the night's totals; efficiency is asleep/in-bed in [0,1]. `.calibrating`/`.building` from the base + /// call are returned unchanged (you can't downgrade below building, and no-stage nights are already + /// flagged). Engine output only; the UI surfaces the tier later. (#H9) + public static func rest(hasSession: Bool, hasStagedSleep: Bool, + asleepSeconds: Double, restorativeSeconds: Double, + efficiency: Double) -> ScoreConfidence { + let base = rest(hasSession: hasSession, hasStagedSleep: hasStagedSleep) + guard base == .solid, asleepSeconds > 0 else { return base } + let restorativeShare = restorativeSeconds / asleepSeconds + if efficiency >= highEfficiencyThreshold && restorativeShare < restorativeLowConfidenceShare { + return .building // high-efficiency night with near-zero deep+REM → low-confidence staging + } + return base + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SedentaryDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SedentaryDetector.swift new file mode 100644 index 0000000000..d953d1374e --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SedentaryDetector.swift @@ -0,0 +1,313 @@ +import Foundation +import WhoopProtocol + +// SedentaryDetector.swift — the pure core of the "inactivity reminder" (wrist buzz after sitting +// too long). Faithful port of the Android PR #419 logic (ActivityDetector.detectSedentaryBouts + +// InactivityPrefs.mayBuzzInactivity + WhoopBleClient.maybeBuzzInactivity de-dup), folded into one +// pure, deterministic, DB-free engine so Swift and Kotlin are a byte-identical pair. +// +// WHY GRAVITY, NOT STEPS: the WHOOP 4.0 exposes no step count over BLE — only the wrist +// accelerometer, and only via the ~15-min historical offload. Sedentary time is therefore inferred +// from gravity. The wrist moves constantly at a desk (typing, reaching), so "wrist stillness" is the +// wrong signal; what a "time to move" reminder needs is the ABSENCE OF AMBULATION (walking around). +// `detectSedentaryBouts` smooths the per-record gravity delta (reusing WorkoutDetector.activitySeries) +// over `smoothWindowS` and calls any stretch where that smoothed signal stays at/under `moveThresholdG` +// — i.e. no sustained walking — a sedentary bout. Typing and isolated reaches average out and keep the +// bout alive; sustained walking pushes the smoothed signal over the threshold and ends it. The defaults +// were calibrated from on-wrist data (desk ≈ 0.05–0.10 g smoothed, walking ≈ 0.2–0.4 g). +// +// PURITY: no I/O, no wall-clock reads. `nowSec` and `tzOffsetSec` (seconds east of UTC) are passed IN. +// Active-hours / quiet-hours are evaluated against the candidate bout's LOCAL END TIME, not `now`: +// gravity only reaches the app on the strap's offload flush, so an overnight bout is processed in the +// morning; a `now`-based check would wrongly admit it. Checking the bout's own end time is what makes +// "active hours excludes nighttime sleep" actually hold. +// +// All `ts`/`start`/`end`/`nowSec` are wall-clock unix SECONDS. Outputs are APPROXIMATE, not medical advice. + +// MARK: - Output shapes + +/// A sedentary ("haven't moved from my seat") period. Times are wall-clock unix seconds; +/// `durationS` mirrors `ExerciseSession.durationS`. APPROXIMATE. +public struct InactivityPeriod: Equatable, Sendable { + public let start: Int + public let end: Int + public let durationS: Double + public init(start: Int, end: Int, durationS: Double) { + self.start = start; self.end = end; self.durationS = durationS + } +} + +/// The persisted de-dup / freshness state the reminder carries between offloads (restart-safe). The +/// caller stores this verbatim (it is the byte-identical analogue of the InactivityPrefs LAST_* keys) +/// and feeds the prior value back into the next `evaluate`. A fresh user starts from `.initial`. +public struct SedentaryState: Equatable, Sendable { + /// Newest gravity ts already processed — a replayed / no-new-rows offload can't re-buzz. + public var lastProcessedGravityTs: Int + /// Unix-seconds of the last buzz (0 = never) — drives the re-nudge cadence. + public var lastBuzzAt: Int + /// Start of the last buzzed bout (0 = none) — distinguishes "same bout, re-nudge" from "new bout". + public var lastBuzzedBoutStart: Int + /// End of the last buzzed bout (0 = none). + public var lastBuzzedBoutEnd: Int + + public init(lastProcessedGravityTs: Int = 0, lastBuzzAt: Int = 0, + lastBuzzedBoutStart: Int = 0, lastBuzzedBoutEnd: Int = 0) { + self.lastProcessedGravityTs = lastProcessedGravityTs + self.lastBuzzAt = lastBuzzAt + self.lastBuzzedBoutStart = lastBuzzedBoutStart + self.lastBuzzedBoutEnd = lastBuzzedBoutEnd + } + + /// A cold-start state (never processed, never buzzed). + public static let initial = SedentaryState() +} + +/// The decision the engine returns each offload: whether to buzz now, the next persisted state to +/// store, and (when buzzing) the buzz strength + the bout that triggered it (for logging / UI). +public struct SedentaryDecision: Equatable, Sendable { + /// True if the wrist should buzz on this offload. + public let shouldBuzz: Bool + /// How many buzz loops to play (strength) when `shouldBuzz` — mirrors `config.buzzLoops`. + public let buzzLoops: Int + /// The current sedentary bout that drove the decision, or nil if none qualified. + public let bout: InactivityPeriod? + /// The state to persist for the next offload (always advance `lastProcessedGravityTs`). + public let nextState: SedentaryState + + public init(shouldBuzz: Bool, buzzLoops: Int, bout: InactivityPeriod?, nextState: SedentaryState) { + self.shouldBuzz = shouldBuzz; self.buzzLoops = buzzLoops + self.bout = bout; self.nextState = nextState + } +} + +/// User-tunable config for the inactivity reminder. Mirrors InactivityPrefs (defaults included) plus +/// the global gates the Android guard reuses from NotifPrefs (master / quiet-hours / only-when-worn), +/// passed in here as plain values so the engine stays pure. +public struct SedentaryConfig: Equatable, Sendable { + // ── Feature toggle + master gate ───────────────────────────────────────── + /// Inactivity reminder feature toggle (InactivityPrefs.enabled, default OFF). + public var enabled: Bool + /// Global notification master switch (NotifPrefs.MASTER, default OFF). Buzz is inert if off. + public var notificationsMasterOn: Bool + + // ── Detector tunables (ActivityDetector) ───────────────────────────────── + /// Smoothed wrist-motion above this (g) counts as "walking around", ending a sedentary bout. + public var moveThresholdG: Double + /// Minimum sedentary-bout length (minutes) before the first nudge (InactivityPrefs threshold). + public var thresholdMinutes: Int + /// Rolling-mean window (seconds) for the movement signal. + public var smoothWindowSeconds: Double + + // ── Cadence + strength ─────────────────────────────────────────────────── + /// If still seated, re-buzz this often (minutes). InactivityPrefs re-nudge, default 30. + public var reNudgeMinutes: Int + /// Buzz strength (loops). InactivityPrefs buzz loops, default 2. + public var buzzLoops: Int + + // ── Active-hours window (InactivityPrefs) ──────────────────────────────── + /// Only nudge during the active-hours window (default ON). + public var activeHoursEnabled: Bool + /// Active-hours window start, local minute-of-day [0,1440) (default 9:00 = 540). + public var activeStartMinutes: Int + /// Active-hours window end, local minute-of-day [0,1440) (default 17:00 = 1020). + public var activeEndMinutes: Int + + // ── Quiet-hours window (reused from NotifPrefs) ────────────────────────── + /// Suppress during quiet hours (NotifPrefs.QUIET, default OFF). + public var quietHoursEnabled: Bool + /// Quiet-hours start, local minute-of-day (default 22:00 = 1320). + public var quietStartMinutes: Int + /// Quiet-hours end, local minute-of-day (default 7:00 = 420). + public var quietEndMinutes: Int + + // ── Only-when-worn gate (reused from NotifPrefs) ───────────────────────── + /// Require the strap to be worn (NotifPrefs.WORN, default ON). + public var onlyWhenWorn: Bool + + public init(enabled: Bool = false, + notificationsMasterOn: Bool = false, + moveThresholdG: Double = SedentaryDetector.defaultMoveThresholdG, + thresholdMinutes: Int = SedentaryDetector.defaultThresholdMinutes, + smoothWindowSeconds: Double = SedentaryDetector.defaultSmoothWindowS, + reNudgeMinutes: Int = SedentaryDetector.defaultReNudgeMinutes, + buzzLoops: Int = SedentaryDetector.defaultBuzzLoops, + activeHoursEnabled: Bool = true, + activeStartMinutes: Int = SedentaryDetector.defaultActiveStartMin, + activeEndMinutes: Int = SedentaryDetector.defaultActiveEndMin, + quietHoursEnabled: Bool = false, + quietStartMinutes: Int = SedentaryDetector.defaultQuietStartMin, + quietEndMinutes: Int = SedentaryDetector.defaultQuietEndMin, + onlyWhenWorn: Bool = true) { + self.enabled = enabled + self.notificationsMasterOn = notificationsMasterOn + self.moveThresholdG = moveThresholdG + self.thresholdMinutes = thresholdMinutes + self.smoothWindowSeconds = smoothWindowSeconds + self.reNudgeMinutes = reNudgeMinutes + self.buzzLoops = buzzLoops + self.activeHoursEnabled = activeHoursEnabled + self.activeStartMinutes = activeStartMinutes + self.activeEndMinutes = activeEndMinutes + self.quietHoursEnabled = quietHoursEnabled + self.quietStartMinutes = quietStartMinutes + self.quietEndMinutes = quietEndMinutes + self.onlyWhenWorn = onlyWhenWorn + } +} + +// MARK: - Engine + +public enum SedentaryDetector { + + // MARK: Detector defaults (ActivityDetector parity) + + /// Smoothed wrist-motion above this (g) counts as "walking around", ending a sedentary bout. + public static let defaultMoveThresholdG: Double = 0.15 + /// Rolling-mean window (seconds) for the movement signal — long enough that desk reaches / typing + /// flurries average out, short enough that sustained walking still crosses the threshold within a + /// minute or two. + public static let defaultSmoothWindowS: Double = 240.0 + /// Break a sedentary bout when the inter-record time gap exceeds this (seconds). Also the freshness + /// tolerance the live path uses to decide a bout is still "current". + public static let maxGapS: Int = 20 * 60 + /// Default minimum sedentary-bout length (minutes) — InactivityPrefs threshold default. + public static let defaultThresholdMinutes: Int = 45 + /// The detector's own floor when a caller doesn't pass a user threshold (ActivityDetector default). + public static let defaultMinMinutes: Int = 15 + + // MARK: Config defaults (InactivityPrefs / NotifPrefs parity) + + public static let defaultReNudgeMinutes: Int = 30 + public static let defaultBuzzLoops: Int = 2 + public static let defaultActiveStartMin: Int = 9 * 60 // 09:00 + public static let defaultActiveEndMin: Int = 17 * 60 // 17:00 + public static let defaultQuietStartMin: Int = 22 * 60 // 22:00 + public static let defaultQuietEndMin: Int = 7 * 60 // 07:00 + + // MARK: - Detection (ActivityDetector.detectSedentaryBouts parity) + + /// Detect SEDENTARY bouts: stretches where the smoothed wrist-motion stays at/under `moveThresholdG` + /// — the user hasn't walked around — for ≥ `minMinutes`. Typing and the occasional reach stay below + /// the threshold and keep the bout alive; sustained walking ends it, as does a data gap > `maxGapS`. + public static func detectSedentaryBouts(_ gravity: [GravitySample], + moveThresholdG: Double = defaultMoveThresholdG, + minMinutes: Int = defaultMinMinutes, + smoothWindowSeconds: Double = defaultSmoothWindowS) -> [InactivityPeriod] { + let rows = gravity.sorted { $0.ts < $1.ts } + if rows.count < 2 { return [] } + let motion = WorkoutDetector.activitySeries(rows) + let smoothed = WorkoutDetector.smoothedIntensity(motion, windowS: smoothWindowSeconds) + let ts = motion.map { $0.ts } + let n = ts.count + let minS = minMinutes * 60 + + var out: [InactivityPeriod] = [] + var runStart = -1 + func closeRun(_ endIdx: Int) { + if runStart >= 0 && runStart <= endIdx { + let s = ts[runStart] + let e = ts[endIdx] + if e - s >= minS { out.append(InactivityPeriod(start: s, end: e, durationS: Double(e - s))) } + } + runStart = -1 + } + for i in 0.. 0 && ts[i] - ts[i - 1] > maxGapS { closeRun(i - 1) } // data gap ends the run + if smoothed[i] > moveThresholdG { + closeRun(i - 1) // walking-level motion ends the sedentary run + } else if runStart < 0 { + runStart = i + } + } + closeRun(n - 1) + return out + } + + // MARK: - Pure time helpers (InactivityPrefs parity) + + /// Local minute-of-day [0,1440) for a unix-seconds instant given a tz offset (seconds east of UTC). + public static func localMinuteOfDay(_ epochSec: Int, tzOffsetSec: Int) -> Int { + let mod = ((epochSec + tzOffsetSec) % 86_400 + 86_400) % 86_400 + return mod / 60 + } + + /// Wrap-aware membership: is `minuteOfDay` inside `[startMin, endMin)` (window may cross midnight)? + public static func windowContains(_ minuteOfDay: Int, startMin: Int, endMin: Int) -> Bool { + if startMin <= endMin { return minuteOfDay >= startMin && minuteOfDay < endMin } + return minuteOfDay >= startMin || minuteOfDay < endMin + } + + /// The global + active/quiet-hours gate, evaluated against the bout's LOCAL END TIME. True only when + /// the inactivity reminder may buzz for a bout ending at `boutEndEpochSec`. Mirrors + /// InactivityPrefs.mayBuzzInactivity (master / quiet hours / worn / active-hours-by-bout-end-time). + public static func mayBuzz(_ config: SedentaryConfig, worn: Bool, boutEndEpochSec: Int, tzOffsetSec: Int) -> Bool { + if !config.enabled { return false } + if !config.notificationsMasterOn { return false } + if config.quietHoursEnabled { + let mod = localMinuteOfDay(boutEndEpochSec, tzOffsetSec: tzOffsetSec) + if windowContains(mod, startMin: config.quietStartMinutes, endMin: config.quietEndMinutes) { return false } + } + if config.onlyWhenWorn && !worn { return false } + if config.activeHoursEnabled { + let mod = localMinuteOfDay(boutEndEpochSec, tzOffsetSec: tzOffsetSec) + if !windowContains(mod, startMin: config.activeStartMinutes, endMin: config.activeEndMinutes) { return false } + } + return true + } + + // MARK: - The decision (WhoopBleClient.maybeBuzzInactivity parity) + + /// Run the inactivity reminder over the freshly-arrived `gravity` window and decide whether to buzz. + /// Pure: pass `nowSec` (the offload-completion instant) and `tzOffsetSec` IN; never read a clock. + /// + /// Mirrors the Android live path exactly: + /// 1. Disabled → never buzz; state unchanged. + /// 2. Only act when this offload advanced the newest gravity ts (replayed / no-new-rows → no-op); + /// when it did advance, persist the new `lastProcessedGravityTs`. + /// 3. Pick the most-recent qualifying bout (≥ `thresholdMinutes`). + /// 4. The bout must be CURRENT — its end within `maxGapS` of the newest sample (still seated). + /// 5. Pass the global + active/quiet/worn gate (`mayBuzz`) on the bout's local end time. + /// 6. Re-nudge a continuing bout on the user's cadence; alert a distinct new bout (one that starts + /// after the last buzzed bout's end, separated by movement) on its own crossing. + public static func evaluate(_ gravity: [GravitySample], + state: SedentaryState, + config: SedentaryConfig, + worn: Bool, + nowSec: Int, + tzOffsetSec: Int) -> SedentaryDecision { + func noBuzz(_ next: SedentaryState, _ bout: InactivityPeriod? = nil) -> SedentaryDecision { + SedentaryDecision(shouldBuzz: false, buzzLoops: config.buzzLoops, bout: bout, nextState: next) + } + + if !config.enabled { return noBuzz(state) } + + let newestGravityTs = gravity.map { $0.ts }.max() + guard let newest = newestGravityTs else { return noBuzz(state) } + + // Only act when this offload brought new gravity (a replayed / no-new-rows sync can't fire). + if newest <= state.lastProcessedGravityTs { return noBuzz(state) } + var next = state + next.lastProcessedGravityTs = newest + + let bouts = detectSedentaryBouts(gravity, moveThresholdG: config.moveThresholdG, + minMinutes: config.thresholdMinutes, + smoothWindowSeconds: config.smoothWindowSeconds) + guard let bout = bouts.max(by: { $0.end < $1.end }) else { return noBuzz(next) } + + // The bout must be current — its end near the newest sample (the user is still seated). + if newest - bout.end > maxGapS { return noBuzz(next, bout) } + if !mayBuzz(config, worn: worn, boutEndEpochSec: bout.end, tzOffsetSec: tzOffsetSec) { return noBuzz(next, bout) } + + let reNudgeS = config.reNudgeMinutes * 60 + // Continues the last buzzed bout → re-nudge on cadence; a distinct new bout (which starts after + // the last buzzed bout's end, separated by movement) alerts on its own crossing. + let continues = bout.start <= state.lastBuzzedBoutEnd + let shouldBuzz = state.lastBuzzAt == 0 || !continues || (nowSec - state.lastBuzzAt >= reNudgeS) + if !shouldBuzz { return noBuzz(next, bout) } + + next.lastBuzzAt = nowSec + next.lastBuzzedBoutStart = bout.start + next.lastBuzzedBoutEnd = bout.end + return SedentaryDecision(shouldBuzz: true, buzzLoops: config.buzzLoops, bout: bout, nextState: next) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift new file mode 100644 index 0000000000..61e8f52af1 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift @@ -0,0 +1,116 @@ +import Foundation + +// SleepDebt.swift — a rolling sleep-debt ledger over the last N nights. +// +// Pure, deterministic, DB-free. Given a chronological series of per-night total +// sleep (minutes) and a personal sleep need (hours), it accumulates a running +// balance of (actual − need) per night across a capped trailing window (14 nights +// by default) and reports the net balance plus the per-night deltas that make it +// up. +// +// HONEST by construction: +// - It is a plain debt accumulator — sum of nightly (slept − need) — NOT a +// physiological model. A surplus night (slept > need) genuinely offsets a +// deficit one, the same way a checking balance nets credits and debits. +// - The window is capped (default 14) so "debt" never compounds indefinitely +// across months of history — only the recent fortnight is in scope. +// - Nights with no usable sleep total are SKIPPED entirely (no zero-fill), so a +// gap in wear never reads as a full night of debt. +// - The need value is supplied by the caller (AnalyticsEngine.Rest.defaultNeedHours +// = 8.0 by default; the caller passes any personal override). Computation here +// stays a pure function of (series, need, window). +// +// Constant-explicit + dependency-free so the Kotlin mirror (android … SleepDebt.kt) +// is byte-identical. + +/// One night's contribution to the ledger: its day key, minutes slept, and the +/// signed delta against need (positive = surplus, negative = deficit). +public struct SleepDebtNight: Equatable, Sendable { + /// "yyyy-MM-dd" day key for the night (as carried on the DailyMetric). + public let day: String + /// Total sleep for the night (minutes). + public let sleptMin: Double + /// Signed delta vs need (minutes): sleptMin − needMin. Positive = surplus. + public let deltaMin: Double + + public init(day: String, sleptMin: Double, deltaMin: Double) { + self.day = day; self.sleptMin = sleptMin; self.deltaMin = deltaMin + } +} + +/// The rolling sleep-debt ledger over the capped trailing window. +public struct SleepDebtLedger: Equatable, Sendable { + /// Net running balance (minutes) across the window: Σ(slept − need). Negative = + /// net DEBT (under-slept overall), positive = net SURPLUS, 0 = on target. + public let balanceMin: Double + /// Per-night contributions, oldest → newest, one per counted night (skipped + /// nights are absent). The `deltaMin` values are the per-night bar/spark. + public let nights: [SleepDebtNight] + /// Personal sleep need (minutes) the ledger was computed against (for labelling). + public let needMin: Double + + public init(balanceMin: Double, nights: [SleepDebtNight], needMin: Double) { + self.balanceMin = balanceMin; self.nights = nights; self.needMin = needMin + } + + /// Number of nights that contributed (nights with usable sleep data). + public var nightCount: Int { nights.count } + /// Convenience: true when the net balance is a debt (under need overall). + public var isDebt: Bool { balanceMin < 0 } + /// Magnitude of the balance in minutes, regardless of sign. + public var magnitudeMin: Double { abs(balanceMin) } +} + +public enum SleepDebt { + + /// Cap the ledger at the trailing two weeks — recent enough to be actionable, + /// short enough that one rough patch doesn't read as months of compounding debt. + public static let defaultWindowNights: Int = 14 + + /// "On target" deadband (minutes): a |balance| under this reads as balanced rather + /// than as a debt/surplus, so a few stray minutes don't flip the headline. + public static let onTargetBandMin: Double = 30.0 + + /// Build the ledger from a chronological `[(day, totalSleepMin?)]` series. + /// + /// - Parameters: + /// - series: per-night `(day, totalSleepMin)` rows in CHRONOLOGICAL order + /// (oldest → newest), exactly the order `repo.days` carries. A nil or + /// non-positive `totalSleepMin` marks a night with no usable data and is + /// SKIPPED (never zero-filled). + /// - needHours: personal sleep need (hours). The duration each night is measured + /// against. Defaults to `AnalyticsEngine.Rest.defaultNeedHours` (8 h); the + /// caller passes any per-user override. + /// - window: how many of the most-recent COUNTED nights to include. Defaults to + /// `defaultWindowNights` (14). Clamped to ≥ 1. + /// + /// The balance is Σ over the window of (sleptMin − needMin): a surplus night + /// offsets a deficit one. Returns an empty ledger (balance 0, no nights) when no + /// night has usable data. + public static func ledger(series: [(day: String, totalSleepMin: Double?)], + needHours: Double = AnalyticsEngine.Rest.defaultNeedHours, + window: Int = defaultWindowNights) -> SleepDebtLedger { + let needMin = max(needHours, 0.0) * 60.0 + let cap = max(window, 1) + + // Keep only nights with usable sleep, preserving chronological order, then take + // the most-recent `cap` of them. + let usable = series.filter { ($0.totalSleepMin ?? 0) > 0 } + let windowed = usable.suffix(cap) + + var nights: [SleepDebtNight] = [] + nights.reserveCapacity(windowed.count) + var balance = 0.0 + for row in windowed { + let slept = row.totalSleepMin ?? 0 + let delta = slept - needMin + balance += delta + nights.append(SleepDebtNight(day: row.day, sleptMin: slept, deltaMin: delta)) + } + return SleepDebtLedger(balanceMin: round1(balance), nights: nights, needMin: needMin) + } + + /// Round to 1 decimal place (the ledger is reported in whole/near-whole minutes; + /// 1 dp keeps Σ stable without trailing float noise). Mirrors the Kotlin rounding. + static func round1(_ v: Double) -> Double { (v * 10.0).rounded() / 10.0 } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepEditGuard.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepEditGuard.swift new file mode 100644 index 0000000000..8d3c9386ec --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepEditGuard.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Pure guards for the hand-edit sleep-time pickers (#940). The reporter corrected a late-tracked +/// night's bed time from 01:06 back to 23:00; the picker kept the calendar DATE, so the "corrected" +/// bed landed on the COMING evening: a future-dated night whose staged window came back all-awake, +/// and the display merge then blanked the whole Sleep tab. Three layered rules, shared by +/// macOS/iOS (SleepTimeEditor) and Android (com.noop.analytics.SleepEditGuard, byte-for-byte twin), +/// all pure and unit-tested: +/// 1. `autoCorrectedBed`: a time-only roll that lands the bed in the future, or at/after the +/// night's wake, almost always means the PREVIOUS evening; auto-decrement the date. +/// 2. `isDisjoint`: a corrected window with no overlap of the night's recorded coverage needs an +/// explicit confirm ("this moves the night to a time with no recorded data"), never silent +/// acceptance. +/// 3. `clampedEditWindow`: the repository belt-and-braces; no code path may persist a future or +/// inverted window even if a client UI misbehaves. +public enum SleepEditGuard { + + /// The longest night span (seconds) the at/after-wake auto-correct will manufacture. A genuine + /// evening-bed correction (bed 23:00, wake 05:00 next day) yields a ~6h span; a user moving a + /// session LATER past its wake (nap 14:00-15:00 -> bed 16:00 same day) would yield a ~23h span if + /// decremented, which is not a plausible night - so we leave those candidates verbatim. + public static let maxAutoCorrectNightSec: TimeInterval = 16 * 3600 + + /// Rule 1: the cross-midnight bed auto-correct. `candidateBed` is what the picker just produced, + /// `previousBed` is the value it held before this change (so a DELIBERATE date change, where the + /// two sit on different calendar days, is always respected verbatim). When the change was + /// time-only (same calendar day) and the candidate is impossible for a bed time, the user almost + /// always meant the previous evening: return the candidate moved one day back, provided that lands + /// in the past. Two impossibility cases: + /// - the candidate is in the FUTURE (`candidateBed > now`) - always corrected (this is the + /// `originalWake == nil` "Add a nap" case too, whose seed sits after the night's wake); + /// - the candidate is at/after `originalWake` AND decrementing it forms a PLAUSIBLE night, i.e. + /// the decremented bed lands before the wake and within `maxAutoCorrectNightSec` of it. This + /// guards a legitimate MOVE-LATER edit (past bed rolled to just after its own wake on the same + /// day) from being silently shoved back a full day into a ~23h wrong-day window. + public static func autoCorrectedBed(previousBed: Date, candidateBed: Date, originalWake: Date?, + now: Date, calendar: Calendar = .current) -> Date { + guard calendar.isDate(candidateBed, inSameDayAs: previousBed) else { return candidateBed } + guard let decremented = calendar.date(byAdding: .day, value: -1, to: candidateBed), + decremented <= now else { return candidateBed } + let futureViolation = candidateBed > now + let wakeViolation: Bool = { + guard let wake = originalWake, candidateBed >= wake else { return false } + // Only correct when the decremented bed forms a possible night for THIS wake. + return decremented < wake && wake.timeIntervalSince(decremented) <= maxAutoCorrectNightSec + }() + guard futureViolation || wakeViolation else { return candidateBed } + return decremented + } + + /// Rule 2: true when the corrected window `[newStart, newEnd)` shares NOTHING with the night's + /// recorded coverage `[coverageStart, coverageEnd)` (unix seconds). A disjoint window has no + /// data to stage from, so accepting it silently fabricates an all-awake phantom night; the UI + /// must confirm the move instead. + public static func isDisjoint(newStart: Int, newEnd: Int, + coverageStart: Int, coverageEnd: Int) -> Bool { + newEnd <= coverageStart || newStart >= coverageEnd + } + + /// Rule 3: the persistence belt-and-braces. Caps the corrected wake at `now + slackSec` (a sleep + /// cannot END in the future; the slack absorbs clock skew) and refuses (nil) any window that is + /// inverted or entirely in the future once capped. The editor's own guards should make this + /// unreachable; it exists so NO client code path can write a phantom night the display merge + /// cannot render. + public static func clampedEditWindow(start: Int, end: Int, now: Int, + slackSec: Int = 300) -> (start: Int, end: Int)? { + let cappedEnd = min(end, now + slackSec) + guard cappedEnd > start else { return nil } + return (start, cappedEnd) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepReadout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepReadout.swift new file mode 100644 index 0000000000..bb6fdfa88b --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepReadout.swift @@ -0,0 +1,131 @@ +import Foundation +import WhoopProtocol + +// SleepReadout.swift - pure values for the Sleep & Rest live-readout panel. +// +// hrDensityNow + gravityCoverageNow are computed from the same streams detection reads, so the +// panel shows what the detector sees. lastNightGateFired is parsed from the tagged log tail +// (the gate-trace lines E2/E3 emit), so the panel reflects exactly which gate fired tonight. +// No state, no side effects, no em-dashes. + +public enum SleepReadout { + + /// HR samples per minute over the stream's own span. 0 when fewer than 2 samples. + public static func hrDensityPerMinute(hr: [HRSample]) -> Double { + guard hr.count >= 2 else { return 0 } + let sorted = hr.sorted { $0.ts < $1.ts } + let spanS = Double(sorted.last!.ts - sorted.first!.ts) + if spanS <= 0 { return 0 } + return Double(sorted.count) / (spanS / 60.0) + } + + /// Fraction of the HR window the gravity stream spans, in [0, 1]. The same ratio the + /// sparse-gravity gate keys on (`SleepStager.sparseGravitySpanFrac`); a value below that + /// constant means tonight's gravity is sparse. + public static func gravityCoverageFraction(gravity: [GravitySample], hr: [HRSample]) -> Double { + guard gravity.count >= 2, hr.count >= 2 else { return 0 } + let g = gravity.sorted { $0.ts < $1.ts } + let h = hr.sorted { $0.ts < $1.ts } + let hrSpan = Double(h.last!.ts - h.first!.ts) + if hrSpan <= 0 { return 0 } + let gravSpan = Double(g.last!.ts - g.first!.ts) + return max(0.0, min(1.0, gravSpan / hrSpan)) + } + + /// The gate named by the most recent gate-trace line in the tagged log tail, or nil. + /// Lines look like "[sleep] gate run=1 ... gate=accepted ...". + public static func lastGateFired(taggedTail: [String]) -> String? { + for line in taggedTail.reversed() where line.contains("gate=") { + guard let range = line.range(of: "gate=") else { continue } + let after = line[range.upperBound...] + let token = after.prefix { $0 != " " } + if !token.isEmpty { return String(token) } + } + return nil + } +} + +/// Pure values for the Recovery (Charge) and HRV live-readout panels (Group G). Each parses the tagged +/// log tail the Recovery / HRV test-mode emitters write, so the panel reflects exactly the last Charge +/// breakdown or HRV computation. No state, no side effects, no em-dashes. +public enum TestReadout { + + /// The most recent Charge score + band line from the `.recovery`-tagged tail, or nil. The emitter + /// writes "[recovery] charge day=... score= band= ..." (or a "nilScore reason=..." line when the + /// night could not be scored). Returns the score/band fragment so the panel reads the same number the + /// dashboard shows; falls back to the nil-reason when there is no score yet. + public static func lastChargeBreakdown(taggedTail: [String]) -> String? { + for line in taggedTail.reversed() { + if let r = line.range(of: "score=") { + let rest = line[r.lowerBound...] // "score=.. band=.. (..)" + let upto = rest.prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces) + if !upto.isEmpty { return String(upto) } + } + if let r = line.range(of: "nilScore reason=") { + let token = line[r.upperBound...].prefix { $0 != " " } + if !token.isEmpty { return "no score (\(token))" } + } + } + return nil + } + + /// The most recent HRV result fragment from the `.hrv`-tagged tail, or nil. The emitter writes + /// "[hrv] hrv rmssd=ms sdnn=ms meanNN=ms" on success, or "[hrv] hrv result=nil (..)" when a + /// gate refused the reading. Returns the rmssd/sdnn fragment, or the nil note, so the panel reads the + /// same outcome the snapshot screen showed. + public static func lastHrvComputation(taggedTail: [String]) -> String? { + for line in taggedTail.reversed() { + if let r = line.range(of: "rmssd=") { + let frag = String(line[r.lowerBound...]).trimmingCharacters(in: .whitespaces) + if !frag.isEmpty { return frag } + } + if line.contains("result=nil") { return "no reading (filtered out)" } + } + return nil + } +} + +/// Pure values for the Steps live-readout panel. Each parses the `.steps`-tagged log tail the Steps +/// test-mode emitters write, so the panel reflects exactly the last step estimate and calibration state +/// without the engine having to expose new published properties. No state, no side effects, no em-dashes. +/// The Kotlin twin is the StepsReadout object in StepsEstimateEngineTrace.kt. +public enum StepsReadout { + + /// Today's steps for the `stepsToday` id: the most recent scaled-steps figure in the tagged tail. The + /// 5/MG raw emitter writes "[steps] stepsRaw total ... scaledSteps= ...", and the WHOOP-4 path's + /// estimate is surfaced the same way ("stepsEst day=... steps="). Returns the most recent of either, + /// so the panel reads the same number the Today tile shows. nil when no step line is present yet. + public static func stepsToday(taggedTail: [String]) -> Int? { + for line in taggedTail.reversed() { + if let n = intField(line, key: "scaledSteps=") { return n } + if line.contains("stepsEst "), let n = intField(line, key: "steps=") { return n } + } + return nil + } + + /// Calibration state for the `calibrationState` id: the most recent calibration outcome fragment the + /// WHOOP-4 calibration emitter writes ("k= sampleDays= confidence= manual=" on a fit, or + /// "needsMoreDays have= need=" when withheld). Returns the parsed fragment so the panel reads the + /// same state Settings shows. nil when no calibration line is present yet (e.g. a 5/MG-only session). + public static func calibrationState(taggedTail: [String]) -> String? { + for line in taggedTail.reversed() { + if let r = line.range(of: "stepsCal fit ") { + let frag = String(line[r.upperBound...]).prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces) + if !frag.isEmpty { return frag } + } + if let r = line.range(of: "stepsCal withheld reason=") { + let frag = String(line[r.upperBound...]).prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces) + if !frag.isEmpty { return "not calibrated (\(frag))" } + } + } + return nil + } + + /// Parse a `key=` field out of a line (the value runs up to the next space). nil when absent or + /// non-numeric. Shared by both readout ids. + static func intField(_ line: String, key: String) -> Int? { + guard let r = line.range(of: key) else { return nil } + let token = line[r.upperBound...].prefix { $0 != " " } + return Int(token) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStageTotals.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStageTotals.swift new file mode 100644 index 0000000000..c5288e935b --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStageTotals.swift @@ -0,0 +1,761 @@ +import Foundation + +/// Decode a sleep session's `stagesJSON` (either the on-device segment array `[{start,end,stage}]` or +/// the imported minute dict `{light,deep,rem,awake}`) into stage MINUTE totals, and aggregate a night's +/// blocks into the sleep-derived daily fields. Pure + deterministic, so the daily-aggregate recompute +/// that honors a user's wake-time edit can run off the stored (reshaped) stages — no raw streams needed. +public enum SleepStageTotals { + + public struct Minutes: Equatable { + public var awake: Double, light: Double, deep: Double, rem: Double + public var asleep: Double { light + deep + rem } + public var inBed: Double { asleep + awake } + public init(awake: Double = 0, light: Double = 0, deep: Double = 0, rem: Double = 0) { + self.awake = awake; self.light = light; self.deep = deep; self.rem = rem + } + } + + /// Stage minutes for one session's `stagesJSON`, or nil if it decodes to nothing usable. The on-device + /// stager calls awake "wake"; the importer "awake" — both map to `awake`. + public static func minutes(fromStagesJSON json: String?) -> Minutes? { + guard let json, let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) else { return nil } + if let arr = obj as? [[String: Any]] { // segment array (computed) + var m = Minutes() + for seg in arr { + guard let s = (seg["start"] as? NSNumber)?.intValue, + let e = (seg["end"] as? NSNumber)?.intValue, e > s, + let name = seg["stage"] as? String else { continue } + let mins = Double(e - s) / 60.0 + switch name { + case "wake", "awake": m.awake += mins + case "light": m.light += mins + case "deep": m.deep += mins + case "rem": m.rem += mins + default: continue + } + } + return m.inBed > 0 ? m : nil + } + if let dict = obj as? [String: Any] { // minute dict (imported) + func v(_ k: String) -> Double { (dict[k] as? NSNumber)?.doubleValue ?? 0 } + let m = Minutes(awake: v("awake"), light: v("light"), deep: v("deep"), rem: v("rem")) + return m.inBed > 0 ? m : nil + } + return nil + } + + /// The sleep-derived daily fields for a night made of these blocks' `stagesJSON`, or nil if none + /// decode. `efficiency` is asleep / in-bed (TST / Σ stage minutes) in [0,1]. For the segment stages + /// noop stores (which TILE the window, last segment clamped to the wake), Σ stage minutes equals the + /// clock span, so this coincides with `AnalyticsEngine.analyzeDay`'s TST/(end−start); it is not the + /// literal same expression, and would diverge only for malformed non-tiling stages. + public struct DailySleep: Equatable { + public let totalSleepMin: Double, efficiency: Double + public let deepMin: Double, remMin: Double, lightMin: Double + } + + public static func dailyAggregate(_ stagesJSONs: [String?]) -> DailySleep? { + dailyAggregate(stagesJSONs, interFragmentAwakeSeconds: 0) + } + + /// As `dailyAggregate(_:)`, but folds the OUT-OF-BED time between bridged main-night fragments into the + /// night's AWAKE total (and therefore its in-bed denominator). #777/#705 regression fix: when a main + /// sleep is bridged from two fragments split by a 20-min wake gap, that gap is real time the user was + /// awake/out of bed - it must show as ~20 min awake, not vanish. The fragments' own stages tile only + /// their individual `[start,end)` spans, so the inter-fragment gap is in NO fragment; the caller computes + /// it once (sum of gaps between consecutive fragments' effective ends and onsets) and passes it here so + /// both the analytics rollup and the edit/recompute seam apply ONE consistent definition: gap → awake → + /// in-bed. `interFragmentAwakeSeconds` ≤ 0 reproduces the legacy sum-of-stages behaviour. (#777/#705) + public static func dailyAggregate(_ stagesJSONs: [String?], + interFragmentAwakeSeconds: Double) -> DailySleep? { + var total = Minutes() + var any = false + for j in stagesJSONs { + if let m = minutes(fromStagesJSON: j) { + total.awake += m.awake; total.light += m.light + total.deep += m.deep; total.rem += m.rem + any = true + } + } + if interFragmentAwakeSeconds > 0 { total.awake += interFragmentAwakeSeconds / 60.0 } + guard any, total.inBed > 0 else { return nil } + return DailySleep(totalSleepMin: total.asleep, efficiency: total.asleep / total.inBed, + deepMin: total.deep, remMin: total.rem, lightMin: total.light) + } + + /// The OUT-OF-BED time (seconds) BETWEEN consecutive bridged sleep fragments - the inter-fragment wake + /// gaps the #561 gap-bridge spans but no fragment's own `[start,end)` covers. Each fragment is one + /// `(start,end)` span; sorted by start, the gap after fragment i is `max(0, start[i+1] - end[i])`. Sums + /// only positive gaps (overlapping/abutting fragments contribute 0). This is the single shared definition + /// of "awake between fragments" both `analyzeDay` and the edit/recompute seam fold into AWAKE, so the two + /// paths agree (no seam double-count). Pure + deterministic; cross-platform identical. (#777/#705) + public static func interFragmentAwakeSeconds(_ spans: [(start: Int, end: Int)]) -> Double { + guard spans.count > 1 else { return 0 } + let sorted = spans.sorted { $0.start < $1.start } + var gap = 0 + for i in 1.. 0 { gap += g } + } + return Double(gap) + } + + // MARK: - Canonical main-night selection (#525 / #547 — learned-timing scored pick) + + /// Broad overnight band used ONLY for the cold-start alignment bonus (NOT a gate). A block whose + /// midpoint lands near this band's center earns the timing credit when we have no learned habitual + /// midsleep yet. The band is [`overnightStartHour`, `overnightEndHour`) local, reconciled with the + /// detector's `SleepStager.isOvernightOnset` window [20:00, 11:00) so the selector and detector agree + /// (this removes the old [10:00, 11:00) off-by-one where the detector kept a ~10:30 onset as "night" + /// but the selector demoted it to a nap). (#547) + public static let overnightStartHour = 20 + /// Local hour (exclusive) that closes the cold-start overnight band. Now 11 (was 10) to match the + /// detector's [20:00, 11:00) onset window. A block onset in [`overnightEndHour`, `overnightStartHour`) + /// is daytime; everything else is overnight. + public static let overnightEndHour = 11 + + /// Seconds in a day, for circular time-of-day math. + public static let secondsPerDay = 86_400 + + /// The fixed alignment credit (in MINUTES) added to a block's asleep minutes when its midpoint sits + /// right on the user's habitual midsleep (or, cold-start, the overnight band center). This is a BONUS, + /// not an infinite gate: a long enough off-timing block can still out-score a short well-timed one, so + /// a genuine 7h daytime sleep beats a 1.5h overnight fragment, while a normal 4h night beats a longer + /// daytime nap. ~90 min is one sleep-cycle's worth of credit. (#547) + public static let alignmentBonusMin: Double = 90.0 + + /// Full alignment bonus is paid when the block midpoint is within this many seconds (circular) of the + /// habitual midsleep; the bonus then decays LINEARLY to 0 at `alignmentZeroSec`. ±2h full, →0 by ±5h. + public static let alignmentFullWindowSec = 2 * 3_600 + /// Circular distance (seconds) at/after which the alignment bonus is 0. + public static let alignmentZeroSec = 5 * 3_600 + + /// Adjacent sleep runs separated by a wake gap shorter than this are bridged into one block for + /// selection, so a biphasic / briefly-interrupted main sleep is scored as a single night rather than + /// two fragments. Matches the sleep-staging research's <60 min "same sleep period" threshold. The + /// detector already bridges sparse-gravity gaps up to 90 min (`SleepStager.sparseBridgeGapMin`); this + /// is the selector-side backstop for blocks that reach the selector still split. (#547) + public static let gapBridgeMaxMin = 60 + + /// Wider wake-gap bridge (minutes) applied ONLY to an overnight night-tail fragment, so a single + /// overnight sleep broken by a real but longer mid-night wake (at/over `gapBridgeMaxMin`, under this) is + /// not over-fragmented into a NAP + a main sleep, the #861 report ("night sleeps are split into naps and + /// sleep"). This mirrors the detector's own `SleepStager.nightContinuationGapMin` (90 min), the same + /// "this is the night's tail, not an isolated nap" threshold the detection spine already trusts. It is + /// applied (in `mainNightGroupIndices`) ONLY when the later fragment's onset is still in the overnight + /// band (`isOvernightOnset`), so a genuine daytime nap (which is hours away AND begins in daytime) + /// can never be folded into the night. Below `gapBridgeMaxMin` the unconditional bridge is unchanged + /// (so `bridgeAdjacent` and its golden tests stay byte-identical). (#861) + public static let nightTailBridgeMaxMin = 90 + + /// One candidate block for main-night selection. The `start` is the EFFECTIVE onset (a user wake/ + /// bed edit moves `end`, never the detected onset key), and `tzOffsetSeconds` turns it local so the + /// timing test reads the user's clock, not UTC. + public struct NightBlock { + public let start: Int, end: Int + public init(start: Int, end: Int) { self.start = start; self.end = end } + public var durationS: Int { end - start } + public var midpointSec: Int { start + (end - start) / 2 } + } + + // MARK: - Selection reason (explainability — WHY this block is the main night) (spec 2026-06-20) + + /// Why the selector chose the block it did, derived from the EXACT signals the score used, so the UI + /// can explain the pick in plain English without re-deriving anything. Identical cases + identical + /// ordering as the Kotlin `MainNightReason` (cross-platform parity is mandatory). (spec 2026-06-20) + public enum MainNightReason: String, Equatable { + /// The day has a single sleep block, so there is nothing to choose between. + case onlyBlock + /// The chosen block is the longest by asleep duration and there is no meaningful timing credit + /// behind the pick (cold-start with no learned habitual, or the longest block is outside the + /// alignment-bonus window). Duration alone decided it. + case longest + /// The chosen block is the longest by asleep duration AND it earned a meaningful alignment bonus + /// (a learned habitual midsleep exists and the block's midpoint sits inside the bonus window). + /// Duration would have picked it anyway, and the timing agrees. + case longestNearUsual + /// The chosen block is NOT the longest by asleep duration; the alignment bonus (not raw duration) + /// is what flipped the pick away from the longest block toward this one. + case alignedToUsual + } + + /// A block is treated as having a MEANINGFUL alignment bonus when it earns ANY positive credit, i.e. + /// its midpoint sits inside the bonus window (circular distance < `alignmentZeroSec`). Outside the + /// window the bonus is exactly 0 and contributes nothing to the pick. Tiny epsilon so floating-point + /// noise at the window edge can't be mistaken for credit. Identical cross-platform. (spec 2026-06-20) + static let meaningfulBonusEpsilon: Double = 1e-9 + + /// The result of main-night selection enriched with the explainability fields the UI renders: the + /// chosen block's index, the reason it won, and the chosen block's ASLEEP duration so the copy can + /// fill {DUR} (Xh Ym) without re-decoding. `asleepSeconds`/`asleepMinutes` are the SAME duration the + /// score used for that block (clock span for the `NightBlock` overload; decoded asleep minutes for the + /// stages overload). Mirrors the Kotlin `MainNightSelection`. (spec 2026-06-20) + public struct MainNightSelection: Equatable { + public let index: Int + public let reason: MainNightReason + /// The chosen block's asleep duration in SECONDS (the figure the score ranked on). + public let asleepSeconds: Int + public init(index: Int, reason: MainNightReason, asleepSeconds: Int) { + self.index = index; self.reason = reason; self.asleepSeconds = asleepSeconds + } + /// The chosen block's asleep duration in MINUTES, for copy that fills {DUR} as Xh Ym. + public var asleepMinutes: Double { Double(asleepSeconds) / 60.0 } + } + + /// True when a block's onset falls in the cold-start overnight band (≥ `overnightStartHour` or + /// < `overnightEndHour`, local). Retained for callers/tests that still ask the binary question, but + /// the scored selector no longer GATES on it — it only feeds the cold-start alignment bonus. + /// Mirrors `SleepStager.isOvernightOnset`. `offsetSec` is seconds EAST of UTC. (#525 / #547) + public static func isOvernightOnset(_ ts: Int, offsetSec: Int) -> Bool { + let local = ts + offsetSec + let secOfDay = ((local % secondsPerDay) + secondsPerDay) % secondsPerDay + let hour = secOfDay / 3_600 + return hour >= overnightStartHour || hour < overnightEndHour + } + + /// Local time-of-day, in seconds [0, 86400), of a unix timestamp shifted east by `offsetSec`. + static func localSecOfDay(_ ts: Int, offsetSec: Int) -> Int { + let local = ts + offsetSec + return ((local % secondsPerDay) + secondsPerDay) % secondsPerDay + } + + /// Smallest circular distance (seconds, 0...43200) between two times-of-day, so 23:30 and 00:30 are + /// 3600s apart, not 82800. Both inputs are seconds-of-day in [0, 86400). + static func circularDistanceSec(_ a: Int, _ b: Int) -> Int { + let raw = abs(a - b) % secondsPerDay + return min(raw, secondsPerDay - raw) + } + + /// The cold-start anchor: the CENTER of the overnight band [overnightStartHour, overnightEndHour), + /// as a time-of-day in seconds. With the band wrapping midnight (20:00 → 11:00 = 15h wide) the center + /// is 03:30 local. Used as the habitual-midsleep stand-in before enough history exists. (#547) + static var coldStartAnchorSec: Int { + let startSec = overnightStartHour * 3_600 + let span = ((overnightEndHour - overnightStartHour) * 3_600 + secondsPerDay) % secondsPerDay // wrap + return (startSec + span / 2) % secondsPerDay + } + + /// The alignment bonus (MINUTES) a block earns for sitting near the target midsleep. Full + /// `alignmentBonusMin` within `alignmentFullWindowSec`, decaying linearly to 0 by `alignmentZeroSec`. + /// `blockMidSec` and `targetMidSec` are local times-of-day in seconds. (#547) + static func alignmentBonusMinutes(blockMidSec: Int, targetMidSec: Int) -> Double { + let d = circularDistanceSec(blockMidSec, targetMidSec) + if d <= alignmentFullWindowSec { return alignmentBonusMin } + if d >= alignmentZeroSec { return 0 } + let frac = Double(alignmentZeroSec - d) / Double(alignmentZeroSec - alignmentFullWindowSec) + return alignmentBonusMin * frac + } + + /// The target midsleep time-of-day (seconds) the scorer aligns to: the learned `habitualMidsleepSec` + /// when supplied (a late/shift sleeper's real bedtime), else the cold-start overnight-band center. + static func targetMidsleepSec(_ habitualMidsleepSec: Int?) -> Int { + habitualMidsleepSec ?? coldStartAnchorSec + } + + // MARK: - Gap-bridging (biphasic / briefly-interrupted nights → one block) + + /// Merge adjacent `NightBlock`s separated by a wake gap shorter than `gapBridgeMaxMin` into single + /// blocks for selection, so a fragmented main sleep is scored as one night. Input order is preserved + /// by sorting on `start` first (the selector is order-independent, but bridging must see neighbours). + /// Pure + deterministic. (#547) + public static func bridgeAdjacent(_ blocks: [NightBlock]) -> [NightBlock] { + guard blocks.count > 1 else { return blocks } + let sorted = blocks.sorted { $0.start < $1.start } + let bridgeS = gapBridgeMaxMin * 60 + var out: [NightBlock] = [sorted[0]] + for b in sorted.dropFirst() { + let last = out[out.count - 1] + let gap = b.start - last.end + if gap >= 0 && gap < bridgeS { + out[out.count - 1] = NightBlock(start: last.start, end: max(last.end, b.end)) + } else { + out.append(b) + } + } + return out + } + + /// The indices (into the ORIGINAL `blocks`) of the MAIN-NIGHT GROUP: the main night plus any adjacent + /// fragments bridged into it. A biphasic / briefly-interrupted main sleep that reaches the selector still + /// split into two blocks (a wake gap shorter than `gapBridgeMaxMin` between them) is scored as ONE night + /// rather than two competing fragments, then the winning bridged group's fragments are ALL returned so the + /// caller can SUM their stages for the day's headline figure. (#561) + /// + /// Pipeline: + /// 1. `bridgeAdjacent` merges blocks whose gap is in `[0, gapBridgeMaxMin*60)` into bridged spans, in + /// `start` order, recording which original indices fell into each bridged group. + /// 2. `mainNightIndex` scores the BRIDGED spans (so a two-fragment night's combined span out-scores a + /// lone nap) and picks the winning bridged group. + /// 3. The original indices of that winning group are returned, ascending. + /// + /// Returns nil only for an empty list. A day with no bridgeable gap collapses to the single-block group the + /// bare `mainNightIndex` would pick — byte-identical to the old behaviour for the common case. Pure + + /// deterministic; shares `bridgeAdjacent` + `mainNightIndex` so the bridged pick stays cross-platform + /// stable. (#561) + public static func mainNightGroupIndices(_ blocks: [NightBlock], offsetSec: Int, + habitualMidsleepSec: Int? = nil) -> [Int]? { + guard !blocks.isEmpty else { return nil } + // Sort indices by onset so bridging sees neighbours, exactly as `bridgeAdjacent` sorts the blocks. + let order = blocks.indices.sorted { blocks[$0].start < blocks[$1].start } + let bridgeS = gapBridgeMaxMin * 60 + let nightTailBridgeS = nightTailBridgeMaxMin * 60 + // Build the bridged spans AND the original indices that compose each one, in one pass over `order`. + var bridged: [NightBlock] = [] + var groups: [[Int]] = [] + for idx in order { + let b = blocks[idx] + if let last = bridged.last { + let gap = b.start - last.end + // Unconditional short-wake bridge (< gapBridgeMaxMin), byte-identical to `bridgeAdjacent`. + // Then a WIDER bridge for a true overnight night-tail (#861): a gap in + // [gapBridgeMaxMin, nightTailBridgeMaxMin) folds the fragment in ONLY when its onset is + // still in the overnight band: a real mid-night wake, not an isolated daytime nap. This + // stops one overnight sleep being split into a nap + a main sleep, while a daytime nap + // (daytime onset, or a gap at/over nightTailBridgeMaxMin) still stands as its own block. + let bridges = gap >= 0 + && (gap < bridgeS + || (gap < nightTailBridgeS && isOvernightOnset(b.start, offsetSec: offsetSec))) + if bridges { + bridged[bridged.count - 1] = NightBlock(start: last.start, end: max(last.end, b.end)) + groups[groups.count - 1].append(idx) + continue + } + } + bridged.append(b) + groups.append([idx]) + } + guard let winner = mainNightIndex(bridged, offsetSec: offsetSec, + habitualMidsleepSec: habitualMidsleepSec) else { return nil } + return groups[winner].sorted() + } + + /// Index of the day's MAIN night among `blocks`, by the LEARNED-TIMING SCORE (replaces the old hard + /// overnight gate). score(block) = asleepMinutes + alignmentBonus, where the bonus credits a block + /// whose midpoint sits near the user's habitual midsleep (`habitualMidsleepSec`), or — cold-start — + /// near the broad overnight-band center. There is NO hard duration floor and NO overnight gate: a + /// short main sleep or a nap-only day still resolves to a main block, and a genuine long daytime sleep + /// can win on score. The highest score wins; exact ties break toward the EARLIER onset (stable across + /// platforms). Returns nil only for an empty list. This `NightBlock` overload has no decoded stages, + /// so "asleep minutes" is the block's clock span — preserving the prior duration semantics for callers + /// that rank by span (`analyzeDay`). Pass `habitualMidsleepSec` from `habitualMidsleepSec(...)` once + /// enough history exists; leave nil for the cold-start band. (#525 / #547) + public static func mainNightIndex(_ blocks: [NightBlock], offsetSec: Int, + habitualMidsleepSec: Int? = nil) -> Int? { + guard !blocks.isEmpty else { return nil } + let target = targetMidsleepSec(habitualMidsleepSec) + func score(_ b: NightBlock) -> Double { + let asleepMin = Double(b.durationS) / 60.0 + let midSec = localSecOfDay(b.midpointSec, offsetSec: offsetSec) + return asleepMin + alignmentBonusMinutes(blockMidSec: midSec, targetMidSec: target) + } + var bestIdx = 0 + for i in 1.. bs // higher score wins + } else { + candWins = cand.start < best.start // exact tie → earlier onset (stable) + } + if candWins { bestIdx = i } + } + return bestIdx + } + + /// Main-night selection ENRICHED with the explainability reason + the chosen block's asleep duration, + /// for the "why this is your main sleep" UI. The `index` is byte-identical to `mainNightIndex(...)` + /// (same score, same earlier-onset tie-break) — this is the same pick, just annotated, so callers on + /// the bare `mainNightIndex` are unaffected. The reason is decided from the SAME signals the score + /// used (no re-derivation): + /// - `onlyBlock` — a single block. + /// - `alignedToUsual` — the chosen block is NOT the longest by asleep duration; the alignment bonus + /// flipped the pick away from the duration-only winner toward this one. + /// - `longestNearUsual` — the chosen block IS the longest by asleep duration AND a learned habitual + /// midsleep exists AND the chosen block's midpoint earns a meaningful (positive) alignment bonus. + /// - `longest` — otherwise (incl. cold-start with no learned habitual, or the longest block outside + /// the bonus window). + /// The "longest" comparison ranks by the SAME duration the score adds (clock span for this overload), + /// tie-broken by earlier onset exactly like the score, so the duration-only winner is well-defined and + /// platform-stable. `asleepSeconds` is the chosen block's clock span. (spec 2026-06-20) + public static func mainNightSelection(_ blocks: [NightBlock], offsetSec: Int, + habitualMidsleepSec: Int? = nil) -> MainNightSelection? { + guard let idx = mainNightIndex(blocks, offsetSec: offsetSec, + habitualMidsleepSec: habitualMidsleepSec) else { return nil } + let chosen = blocks[idx] + let reason = mainNightReason( + chosenAsleepSec: chosen.durationS, chosenOnset: chosen.start, + chosenMidLocalSec: localSecOfDay(chosen.midpointSec, offsetSec: offsetSec), + blockCount: blocks.count, + // duration-only winner over the SAME asleep figure (clock span) + same earlier-onset tie-break. + longestAsleepSec: blocks.map(\.durationS).max() ?? chosen.durationS, + longestOnset: durationOnlyWinnerOnset(asleepSecs: blocks.map(\.durationS), + onsets: blocks.map(\.start)), + chosenIsDurationWinnerOnset: chosen.start, + habitualMidsleepSec: habitualMidsleepSec) + return MainNightSelection(index: idx, reason: reason, asleepSeconds: chosen.durationS) + } + + /// The onset of the DURATION-ONLY winner among parallel `asleepSecs`/`onsets` arrays: the block with + /// the greatest asleep figure, ties broken toward the EARLIER onset — the same tie-break the score + /// uses, so "would duration alone have picked this same block?" is decided identically on both + /// platforms. Returns the first onset when empty (callers never pass empty). (spec 2026-06-20) + static func durationOnlyWinnerOnset(asleepSecs: [Int], onsets: [Int]) -> Int { + guard !asleepSecs.isEmpty else { return 0 } + var bestIdx = 0 + for i in 1.. bestDur + } else { + candWins = onsets[i] < onsets[bestIdx] + } + if candWins { bestIdx = i } + } + return onsets[bestIdx] + } + + /// Decide the `MainNightReason` from the chosen block + the duration-only winner, using ONLY signals + /// the score already computed. Pure so it is unit-tested directly and shared byte-for-byte with Kotlin. + /// `chosenIsDurationWinnerOnset` is the chosen block's onset; the chosen block IS the duration-only + /// winner iff (its asleep == the longest asleep) AND (its onset == the duration-only winner's onset) — + /// matching the longest figure and the earlier-onset tie-break the duration-only ranking uses. + /// (spec 2026-06-20) + static func mainNightReason(chosenAsleepSec: Int, chosenOnset: Int, chosenMidLocalSec: Int, + blockCount: Int, longestAsleepSec: Int, longestOnset: Int, + chosenIsDurationWinnerOnset: Int, habitualMidsleepSec: Int?) -> MainNightReason { + if blockCount <= 1 { return .onlyBlock } + let chosenIsLongest = (chosenAsleepSec == longestAsleepSec) + && (chosenIsDurationWinnerOnset == longestOnset) + if !chosenIsLongest { + // Duration alone would NOT have picked this block; the alignment bonus flipped the pick. + return .alignedToUsual + } + // The chosen block is the longest. It is "near usual" only when a learned habitual exists AND the + // block earns a meaningful (positive) alignment bonus — cold-start (nil habitual) is plain longest. + if let habitual = habitualMidsleepSec { + let bonus = alignmentBonusMinutes(blockMidSec: chosenMidLocalSec, targetMidSec: habitual) + if bonus > meaningfulBonusEpsilon { return .longestNearUsual } + } + return .longest + } + + /// The night's daily sleep aggregate, substituting any USER-EDITED block for its detected twin + /// before summing, then UNIONING in any user-added block that has no detected twin. `detected` is + /// the auto-detected blocks (their stable startTs + stages); `edited` maps a block's startTs → its + /// hand-corrected (reshaped) stages — a wake-time edit never moves startTs, so the edited block + /// lands exactly on its detected twin. `manual` is user-added blocks (e.g. a hand-logged nap) that + /// the detector never found; each is keyed by its own stable startTs and FOLDED IN so its minutes + /// count toward the day's totals (a detector-found nap already folds via `detected`). De-duped by + /// startTs so a block already represented in `detected` (or substituted via `edited`) is never + /// double-counted. Returns the aggregate plus whether an edit OR a manual block actually contributed + /// (so the caller only overrides the day when it did), or nil when nothing decodes. This is the + /// integration seam between the edit and the daily recompute — kept pure so it's unit-tested with + /// synthetic data, no store or stager needed. (#518 / #508) + public static func dailyAggregateHonoringEdits( + detected: [(startTs: Int, stagesJSON: String?)], + edited: [Int: String?], + manual: [(startTs: Int, stagesJSON: String?)] = [], + // The block's effective onset (a wake/bed edit moves end, not the detected start key) plus the + // device's UTC offset, so the MAIN-NIGHT pick reads the user's local clock. When a caller can't + // supply onsets, leave nil and the legacy SUM-of-all-blocks behaviour is preserved (no regression + // for older callers); the day rollup passes them so the daily total matches the Sleep tab. (#525) + onsetByStart: [Int: Int]? = nil, + offsetSec: Int = 0, + // The learned habitual midsleep (local time-of-day seconds) so the scored pick aligns to the + // user's real bedtime, not a fixed clock band. nil = cold-start (fall back to the overnight-band + // bonus). Existing callers compile unchanged. (#547) + habitualMidsleepSec: Int? = nil + ) -> (sleep: DailySleep, editApplied: Bool)? { + // Substitute an edited block's stages ONLY when the edit has usable (non-nil) stages — an edit + // that reshaped to nil must fall back to the detected stages, never drop the block (which would + // collapse the night's sleep total). `editApplied` likewise reflects a real substitution. We keep + // each block's identity (its startTs + effective stages) so the main-night pick can run after. + var applied = false + // (startTs, effective stages) for every block on the day — detected (edit-substituted) then any + // twinless manual block UNIONED in. Identity is preserved for the main-night selection. + var blocks: [(startTs: Int, stagesJSON: String?)] = detected.map { d in + if let stages = edited[d.startTs] ?? nil { // flatten String?? → String?, then require non-nil + applied = true + return (startTs: d.startTs, stagesJSON: stages) + } + return (startTs: d.startTs, stagesJSON: d.stagesJSON) + } + // Union: a user-added block the detector never found (no detected twin) must still be on the day + // so the main-night pick (or the legacy sum) sees it — otherwise a manually-logged nap is dropped. + // Match on the stable startTs and add ONLY rows absent from `detected`, with usable stages. + let detectedStarts = Set(detected.map(\.startTs)) + for m in manual where !detectedStarts.contains(m.startTs) { + if let stages = m.stagesJSON { + blocks.append((startTs: m.startTs, stagesJSON: stages)) + applied = true + } + } + // Canonical per-day total (#525): when the caller supplies block onsets, the daily figure is the + // MAIN NIGHT only (the longest, overnight-preferring block — the SAME block the Sleep tab shows), + // so Intelligence / Sleep Need / the debt ledger / the card all read the same number as the Sleep + // tab. Nap blocks stay their own session rows elsewhere; they are NOT summed into this figure. + // No onsets supplied → the legacy sum-of-all-blocks total (older callers unchanged). + if let onsetByStart { + // Pick by the same LEARNED-TIMING score the Sleep tab uses (asleep minutes + alignment bonus, + // measured by each block's decoded in-bed span). BIPHASIC GAP-BRIDGE (#561): bridge adjacent + // blocks split by a short wake gap into the main-night GROUP and SUM that group's stages, so the + // edit/recompute seam reports the SAME night `analyzeDay` does (a briefly-interrupted main sleep + // is one night, not the longer fragment only). Naps outside the group remain their own rows. + let group = mainNightGroupIndicesByStages(blocks, onsetByStart: onsetByStart, offsetSec: offsetSec, + habitualMidsleepSec: habitualMidsleepSec) + // OUT-OF-BED time between the bridged fragments counts as AWAKE (#777/#705), using the SAME + // single definition `analyzeDay` applies so the seam can't double-count it. Each fragment's + // effective span is `[onset, onset + decoded in-bed]`; the gap between consecutive fragments is + // awake the fragments' own stages don't cover. + if let group { + let spans: [(start: Int, end: Int)] = group.map { i in + let b = blocks[i] + let onset = onsetByStart[b.startTs] ?? b.startTs + let inBedSec = Int((minutes(fromStagesJSON: b.stagesJSON)?.inBed ?? 0) * 60.0) + return (start: onset, end: onset + inBedSec) + } + let gapAwakeS = interFragmentAwakeSeconds(spans) + if let agg = dailyAggregate(group.map { blocks[$0].stagesJSON }, + interFragmentAwakeSeconds: gapAwakeS) { + return (agg, applied) + } + } + return nil + } + guard let agg = dailyAggregate(blocks.map(\.stagesJSON)) else { return nil } + return (agg, applied) + } + + /// The original-index group (ascending) of the day's MAIN night on the STAGES path: the main night plus + /// any adjacent fragments bridged into it (a wake gap shorter than `gapBridgeMaxMin`), so the edit/ + /// recompute seam SUMS the same fragments `analyzeDay` does for a biphasic night. Each block's effective + /// span is `[onset, onset + decoded in-bed]`; bridging tests the gap between one block's effective end and + /// the next block's onset. The bridged spans are then scored by `mainNightIndexByStages` (decoded asleep + /// minutes + alignment), and the winning group's original indices are returned. nil only for an empty list. + /// A day with no bridgeable gap returns the single block `mainNightIndexByStages` would pick — no #525 + /// regression. (#561) + static func mainNightGroupIndicesByStages(_ blocks: [(startTs: Int, stagesJSON: String?)], + onsetByStart: [Int: Int], offsetSec: Int, + habitualMidsleepSec: Int? = nil) -> [Int]? { + guard !blocks.isEmpty else { return nil } + func onset(_ b: (startTs: Int, stagesJSON: String?)) -> Int { onsetByStart[b.startTs] ?? b.startTs } + func effEnd(_ b: (startTs: Int, stagesJSON: String?)) -> Int { + onset(b) + Int((minutes(fromStagesJSON: b.stagesJSON)?.inBed ?? 0) * 60.0) + } + // Order by effective onset so bridging sees neighbours. + let order = blocks.indices.sorted { onset(blocks[$0]) < onset(blocks[$1]) } + let bridgeS = gapBridgeMaxMin * 60 + let nightTailBridgeS = nightTailBridgeMaxMin * 60 + // Bridged groups of ORIGINAL indices, plus the representative (startTs, stages) the score reads. + var groups: [[Int]] = [] + var groupEnd: [Int] = [] // running effective end of each bridged group + for idx in order { + let b = blocks[idx] + if let last = groupEnd.last { + let gap = onset(b) - last + // Same two-tier bridge as `mainNightGroupIndices` so the summed daily total folds in EXACTLY + // the fragments the Sleep tab folds into the main night (no nap/total divergence): the + // unconditional short-wake bridge (< gapBridgeMaxMin), then the wider overnight night-tail + // bridge ([gapBridgeMaxMin, nightTailBridgeMaxMin) only when the fragment's onset is still in + // the overnight band) that stops one night being split into a nap + a main sleep. (#861) + let bridges = gap >= 0 + && (gap < bridgeS + || (gap < nightTailBridgeS && isOvernightOnset(onset(b), offsetSec: offsetSec))) + if bridges { + groups[groups.count - 1].append(idx) + groupEnd[groupEnd.count - 1] = max(last, effEnd(b)) + continue + } + } + groups.append([idx]) + groupEnd.append(effEnd(b)) + } + // Score each bridged group by its FIRST fragment's onset + the group's SUMMED decoded minutes, via + // the same per-stages scorer (asleep minutes + alignment), so the pick matches the bare path on a + // single-block day and prefers the well-timed, longest combined night otherwise. + let groupBlocks: [(startTs: Int, stagesJSON: String?)] = groups.map { g in + // Representative block: its startTs is the group's EARLIEST-onset fragment (so the score's + // midpoint anchors on the group's onset + its SUMMED in-bed span), and its stages are the group's + // SUMMED stages so the scorer ranks the combined night, not a single fragment. + let summed = SleepStageTotals.summedStagesJSON(g.map { blocks[$0].stagesJSON }) + let anchor = g.min(by: { onset(blocks[$0]) < onset(blocks[$1]) }) ?? g[0] + return (startTs: blocks[anchor].startTs, stagesJSON: summed) + } + // The group's anchor onsets feed `onsetByStart` so the score reads each group's earliest onset. + var groupOnsets: [Int: Int] = [:] + for (gi, g) in groups.enumerated() { + let anchor = g.min(by: { onset(blocks[$0]) < onset(blocks[$1]) }) ?? g[0] + groupOnsets[groupBlocks[gi].startTs] = onset(blocks[anchor]) + } + guard let winner = mainNightIndexByStages(groupBlocks, onsetByStart: groupOnsets, offsetSec: offsetSec, + habitualMidsleepSec: habitualMidsleepSec) else { return nil } + return groups[winner].sorted() + } + + /// A synthetic minute-dict `stagesJSON` whose per-stage minutes are the SUM of the inputs' decoded + /// minutes — used only to SCORE a bridged group as one block (decoded asleep minutes + in-bed span). Pure; + /// returns nil when nothing decodes (the group then scores 0, like an undecodable block). (#561) + static func summedStagesJSON(_ stagesJSONs: [String?]) -> String? { + var total = Minutes() + var any = false + for j in stagesJSONs { + if let m = minutes(fromStagesJSON: j) { + total.awake += m.awake; total.light += m.light + total.deep += m.deep; total.rem += m.rem + any = true + } + } + guard any else { return nil } + let dict: [String: Double] = ["awake": total.awake, "light": total.light, + "deep": total.deep, "rem": total.rem] + return (try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys])) + .flatMap { String(data: $0, encoding: .utf8) } + } + + /// Index into `blocks` of the day's MAIN night, by the LEARNED-TIMING SCORE: score(block) = + /// asleepMinutes + alignmentBonus, where "asleepMinutes" is the block's decoded ASLEEP minutes (the + /// real restorative sleep, not in-bed) and the bonus credits a midpoint near `habitualMidsleepSec` + /// (or, cold-start, the overnight band). `onsetByStart` gives each block's effective onset; the + /// midpoint is `onset + (in-bed span)/2` from the decoded minutes (a wake/bed edit moved the end into + /// the stages, so this tracks the corrected span). Blocks whose stages don't decode are still + /// candidates with a 0-minute score, so a day of only-undecodable blocks still resolves + /// deterministically. Exact-score ties break toward the EARLIER onset (stable across platforms). + /// (#525 / #547) + static func mainNightIndexByStages(_ blocks: [(startTs: Int, stagesJSON: String?)], + onsetByStart: [Int: Int], offsetSec: Int, + habitualMidsleepSec: Int? = nil) -> Int? { + guard !blocks.isEmpty else { return nil } + let target = targetMidsleepSec(habitualMidsleepSec) + func onset(_ b: (startTs: Int, stagesJSON: String?)) -> Int { onsetByStart[b.startTs] ?? b.startTs } + func score(_ b: (startTs: Int, stagesJSON: String?)) -> Double { + let m = minutes(fromStagesJSON: b.stagesJSON) + let asleepMin = m?.asleep ?? 0 + let inBedSec = Int((m?.inBed ?? 0) * 60.0) + let midSec = localSecOfDay(onset(b) + inBedSec / 2, offsetSec: offsetSec) + return asleepMin + alignmentBonusMinutes(blockMidSec: midSec, targetMidSec: target) + } + var bestIdx = 0 + for i in 1.. bs + } else { + candWins = onset(cand) < onset(best) + } + if candWins { bestIdx = i } + } + return bestIdx + } + + /// Stages-path main-night selection ENRICHED with the explainability reason + the chosen block's + /// DECODED asleep duration, mirroring `mainNightSelection` for the seam. The `index` is byte-identical + /// to `mainNightIndexByStages(...)`. Here the "longest" comparison ranks by DECODED asleep seconds (the + /// same figure this overload's score adds), tie-broken by effective onset, so the reason matches what + /// the seam actually scored. Returns the chosen block's decoded asleep seconds for {DUR}; a block whose + /// stages don't decode contributes 0 asleep seconds (same as the score). (spec 2026-06-20) + static func mainNightSelectionByStages(_ blocks: [(startTs: Int, stagesJSON: String?)], + onsetByStart: [Int: Int], offsetSec: Int, + habitualMidsleepSec: Int? = nil) -> MainNightSelection? { + guard let idx = mainNightIndexByStages(blocks, onsetByStart: onsetByStart, offsetSec: offsetSec, + habitualMidsleepSec: habitualMidsleepSec) else { return nil } + func onset(_ b: (startTs: Int, stagesJSON: String?)) -> Int { onsetByStart[b.startTs] ?? b.startTs } + // Per-block decoded asleep seconds + local midpoint (onset + in-bed span / 2), the SAME figures the + // score used, so the reason is the exact truth of the pick. + let asleepSecs: [Int] = blocks.map { Int((minutes(fromStagesJSON: $0.stagesJSON)?.asleep ?? 0) * 60.0) } + let onsets: [Int] = blocks.map(onset) + let chosen = blocks[idx] + let chosenAsleepSec = asleepSecs[idx] + let chosenInBedSec = Int((minutes(fromStagesJSON: chosen.stagesJSON)?.inBed ?? 0) * 60.0) + let chosenMidLocalSec = localSecOfDay(onset(chosen) + chosenInBedSec / 2, offsetSec: offsetSec) + let reason = mainNightReason( + chosenAsleepSec: chosenAsleepSec, chosenOnset: onset(chosen), + chosenMidLocalSec: chosenMidLocalSec, blockCount: blocks.count, + longestAsleepSec: asleepSecs.max() ?? chosenAsleepSec, + longestOnset: durationOnlyWinnerOnset(asleepSecs: asleepSecs, onsets: onsets), + chosenIsDurationWinnerOnset: onset(chosen), + habitualMidsleepSec: habitualMidsleepSec) + return MainNightSelection(index: idx, reason: reason, asleepSeconds: chosenAsleepSec) + } + + // MARK: - Habitual midsleep (learned timing — non-circular dependency) + + /// One detected sleep block from the trailing history, for learning the user's habitual timing. + /// `start`/`end` are unix seconds; `dayKey` groups blocks by local calendar day so the LONGEST block + /// per day can be picked selection-independently (no chicken-and-egg with main-night selection). + public struct HistoryBlock { + public let start: Int, end: Int, dayKey: String + public init(start: Int, end: Int, dayKey: String) { + self.start = start; self.end = end; self.dayKey = dayKey + } + public var durationS: Int { end - start } + public var midpointSec: Int { start + (end - start) / 2 } + } + + /// Minimum number of DAYS (with at least one block) needed before a habitual midsleep is trusted; a + /// shorter history returns nil (cold-start → the scorer uses the overnight band). ~2 weeks of nights + /// is the lower bound the sleep-timing literature uses for a stable midpoint. (#547) + public static let habitualMinDays = 14 + + /// The user's habitual midsleep as a LOCAL TIME-OF-DAY (seconds in [0, 86400)), or nil when there is + /// too little history (cold-start). Computed as the CIRCULAR MEAN of the midpoint-time-of-day of the + /// LONGEST block per local day across `history` (the mean direction of the midpoint angles — the + /// natural circular central tendency for clock times). Longest-per-day is selection-INDEPENDENT, so + /// this has no circular dependency on main-night selection. Circular math (mean of the angle, then + /// back to seconds) makes 23:30 and 00:30 an hour apart, not 23h, so a near-midnight sleeper's midsleep + /// is learned correctly. `offsetSec` turns each midpoint local; `minDays` is the cold-start floor. (#547) + public static func habitualMidsleepSec(_ history: [HistoryBlock], offsetSec: Int, + minDays: Int = habitualMinDays) -> Int? { + guard !history.isEmpty else { return nil } + // Longest block per local day (selection-independent). Ties within a day → earlier onset (stable). + var longestByDay: [String: HistoryBlock] = [:] + for b in history { + if let cur = longestByDay[b.dayKey] { + if b.durationS > cur.durationS || (b.durationS == cur.durationS && b.start < cur.start) { + longestByDay[b.dayKey] = b + } + } else { + longestByDay[b.dayKey] = b + } + } + guard longestByDay.count >= minDays else { return nil } + // Circular mean of each day's midpoint time-of-day: convert each to an angle, take the mean + // direction via the unit-vector sum (order-independent), map back to seconds-of-day. nil when + // the resultant vector is degenerate (antipodal/uniform midpoints) — falls back to cold-start. + let midSecs = longestByDay.values.map { localSecOfDay($0.midpointSec, offsetSec: offsetSec) } + return circularMeanSec(midSecs) + } + + /// Minimum mean-resultant-vector length (R = |Σ(sin,cos)| / n, in [0, 1]) for a circular mean to be + /// meaningful. Below this the midpoint angles are antipodal/uniform: their resultant is ~0 so atan2 + /// returns an arbitrary direction that Swift and Kotlin can disagree on (a parity break in the + /// degenerate case). Tiny and identical cross-platform so both sides reject the SAME inputs. (#547) + static let circularMeanMinResultant = 1e-9 + + /// Circular mean of times-of-day (seconds in [0, 86400)) via the mean unit vector (atan2 of summed + /// sin/cos). Returns the mean direction as seconds-of-day in [0, 86400), or nil when the resultant + /// vector is degenerate (empty, or antipodal/uniform so its magnitude is below + /// `circularMeanMinResultant` and the angle is meaningless). nil makes `habitualMidsleepSec` fall + /// back to cold-start rather than emit a meaningless (and cross-platform-divergent) anchor. Used for + /// the habitual-midsleep anchor so near-midnight times average correctly. (#547) + static func circularMeanSec(_ secs: [Int]) -> Int? { + guard !secs.isEmpty else { return nil } + var sumSin = 0.0, sumCos = 0.0 + let k = 2.0 * Double.pi / Double(secondsPerDay) + for s in secs { + let a = Double(s) * k + sumSin += sin(a); sumCos += cos(a) + } + // Resultant length R = |(Σsin, Σcos)| / n. Below epsilon the direction is meaningless. + let resultant = (sumSin * sumSin + sumCos * sumCos).squareRoot() / Double(secs.count) + guard resultant >= circularMeanMinResultant else { return nil } + var ang = atan2(sumSin, sumCos) // [-π, π] + if ang < 0 { ang += 2.0 * Double.pi } // → [0, 2π) + let sec = Int((ang / k).rounded()) % secondsPerDay + return (sec + secondsPerDay) % secondsPerDay + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift new file mode 100644 index 0000000000..659b9dabac --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift @@ -0,0 +1,38 @@ +import Foundation + +// SleepStager+Trace.swift - the per-candidate-run GATE TRACE formatter (Sleep & Rest test mode). +// +// Pure, side-effect-free string builders. They never touch detection state, so a caller can +// assert the exact line a fixture night produces. Every emitter that USES these is gated by +// TestCentre.active(.sleep) at the detectSleep call site, and the lines exit through the +// redacting sink, so this file holds only formatting. Counts and seconds only, no wall-clock. + +extension SleepStager { + + /// Whether a candidate in-bed run survived a gate or was dropped by it. + public enum GateVerdict: String, Sendable { case kept = "KEPT", dropped = "DROPPED" } + + /// The gate-trace line formatters. Compact, parseable, no em-dashes. + public enum GateTrace { + + /// One verdict line for a candidate run. `gate` names the constant that decided it + /// (minSleepMin, maxMainSleepSpanS, offWrist, daytimeGuard, morningStillness, hrConfirm, + /// sparseBridge, accepted); `detail` carries that gate's numbers. `startTs`/`endTs` give the + /// span in seconds only (the sink scrubs identifiers; we never print a formatted clock here). + public static func runLine(index: Int, startTs: Int, endTs: Int, + verdict: GateVerdict, gate: String, detail: String) -> String { + let spanS = max(0, endTs - startTs) + return "gate run=\(index) spanS=\(spanS) \(verdict.rawValue) gate=\(gate) \(detail)" + } + + /// One per-epoch wake<->sleep flip and the threshold it crossed. + public static func flipLine(epoch: Int, from: String, to: String, threshold: String) -> String { + "epoch=\(epoch) flip \(from)->\(to) threshold=\(threshold)" + } + } + + /// Round to 2 decimal places for the trace detail fields. Local to the trace so the inline + /// emitters in `detectSleepUncached` can call it unqualified (AnalyticsEngine.round2 is a + /// separate type's helper). Formatting only, never a scoring path. + static func round2(_ v: Double) -> Double { (v * 100.0).rounded() / 100.0 } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 7ea217a82e..27c7d5629f 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -76,6 +76,82 @@ public enum SleepStager { public static let minSleepMin: Int = 60 /// Assumed sample interval (seconds) when not inferable. public static let defaultIntervalS: Double = 60.0 + + // MARK: - Daytime false-sleep guard (#90) + + // A long, still, sedentary daytime stretch (reading, a desk, a sofa) is gravity- + // indistinguishable from a real nap, so the gravity spine alone misclassifies it as + // sleep. The fix is NOT to drop daytime sleep — real naps are legitimate sessions — + // but to hold a window whose CENTER falls in the local daytime band to a stricter bar: + // it must be long enough to be a real nap AND show a genuine cardiac dip (a sedentary + // stretch keeps a near-baseline HR). Overnight windows are UNCHANGED. + + /// Local hour (inclusive) at which the stricter daytime bar begins. + public static let daytimeBandStartHour: Int = 11 + /// Local hour (exclusive) at which the stricter daytime bar ends. A window whose center + /// is in [start, end) local hours is "daytime"; everything else is "overnight". + public static let daytimeBandEndHour: Int = 20 + /// A still sleep run that resumes within this gap of an overnight sleep chain is the + /// night's TAIL — a late wake past the daytime-band start, or a brief morning stir then + /// back to sleep — not an isolated daytime nap, so it skips the daytime guard. Without + /// this, a real sleep that ran past ~11:00 local had its tail rejected as a "nap" and the + /// displayed wake time was truncated to late morning (late sleepers / shift workers). + // Reimplemented from @vulnix0x4's PR #353. + public static let nightContinuationGapMin: Int = 90 + /// A daytime window must run at least this long (minutes) to count — short still + /// daytime stretches are the dominant false-positive and are rejected outright. + public static let daytimeMinSleepMin: Int = 90 + /// A daytime window's resting HR (lowest 5-min rolling mean) must be at or below + /// baseline × this to confirm a real cardiac dip. Stricter than the overnight 1.05: + /// a true nap dips BELOW the waking-day median, sedentary stillness does not. + public static let daytimeRestingHRMult: Double = 0.95 + + // MARK: - H4 physiological in-bed span cap (#547 / #531 / #509 / tail) + + /// Maximum plausible in-bed span (seconds) for a SINGLE assembled main-sleep run. No real single night + /// runs longer than this: a 12 h+ "sleep" is a bad-clock artefact (a stale/duplicated timestamp range, + /// or a strap that banked one frozen still stretch under a wrong clock) reading as one enormous still + /// block — which then reports a 12 h sleep and poisons Rest / the debt ledger / the headline. 16 h is + /// well above any genuine night (incl. recovery/illness sleeps and late weekend lie-ins) yet below the + /// clock-artefact range. A run whose span exceeds this is DROPPED (not silently truncated to 16 h, which + /// would fabricate a wake time): an over-long block is not trustworthy enough to assert a span for at + /// all. (#547 / #531 / #509 tail) + public static let maxMainSleepSpanS: Int = 16 * 60 * 60 + + // MARK: - H7 morning-stillness nap suppression (#531) + + // After a real overnight wake the wrist is often still (sitting with coffee, back in bed scrolling, a + // sofa) for a stretch that the gravity spine reads as a fresh "nap" — #531's 9 am phantom nap right after + // the night ended. It is NOT a night-tail continuation (that is handled by `nightContinuationGapMin` and + // exempted), and it can clear the ordinary daytime guard (it is long + the post-wake HR is still low), so + // it slipped through. H7 holds a daytime block that BEGINS within `morningStillnessWindowMin` of the + // just-detected overnight wake to a STRONGER bar than an ordinary daytime nap: it must show a genuine + // SUSTAINED re-onset — a real second sleep dips clearly below the day median, not merely near it. + + /// A daytime block whose onset falls within this many minutes AFTER an overnight chain's wake is treated + /// as suspected morning residual stillness and held to the stronger re-onset bar below. ~3 h covers the + /// post-wake window where residual stillness masquerades as a nap; a genuine afternoon nap (hours later) + /// is past it and faces only the ordinary daytime guard. (#531) + public static let morningStillnessWindowMin: Int = 180 + + /// The stronger resting-HR bar (× day baseline) a suspected-morning-stillness block must clear to be kept + /// as a real re-onset. Stricter than the ordinary daytime `daytimeRestingHRMult` (0.95): residual waking + /// stillness keeps a near-waking HR, so only a block that dips clearly (a true second sleep) survives. + public static let morningReonsetRestingHRMult: Double = 0.90 + + /// The persisted v18 BAND sleep_state value that means "asleep" (Interpreter's `(sb>>4)&3`: 0 wake / + /// 1 still / 2 asleep / 3 up). The strap's OWN scored band state — an independent anchor we CONSUME to + /// confirm a borderline morning re-onset (H7) without re-deriving anything. (#531 / H8 consume) + public static let bandStateAsleep: Int = 2 + + /// Fraction of a suspected-morning-stillness block's epochs whose persisted band sleep_state must read + /// "asleep" (`bandStateAsleep`) for the strap's OWN signal to CONFIRM a genuine re-onset and KEEP the + /// block even when its HR dip is borderline. A real second sleep the strap itself scored asleep is a + /// strong, honest anchor; a residual-stillness false nap reads "still"/"up", not "asleep". ≥0.6 keeps + /// this conservative. (H8 consume) + public static let morningReonsetBandAsleepFrac: Double = 0.6 + /// Seconds in a calendar day (for local-hour-of-day arithmetic). + static let secondsPerDay: Int = 86_400 /// Floor on the rolling-window size in samples. public static let minWindowSamples: Int = 3 /// A run is HR-confirmed only if mean HR ≤ baseline × this. @@ -85,6 +161,69 @@ public enum SleepStager { /// Consecutive sleep epochs required to declare onset. public static let onsetPersistEpochs: Int = 3 + // MARK: - Off-wrist backstop (#500) + + // A wrist-OFF stretch reads as perfectly still gravity with no contrary motion, so the + // gravity spine classifies it as sleep — and because the off-wrist epochs carry zero/missing + // HR the daytime guard treats them as "missing data" and lets them through (a daytime desk-off + // strap logged a phantom sleep). The backstop measures OFF-WRIST COVERAGE: while the strap is + // worn it emits ~1 Hz HR, so a long CONTIGUOUS gap in the HR samples spanning part of a candidate + // sleep run is a strong off-wrist proxy that works even when explicit WRIST_OFF events are absent; + // explicit WRIST_OFF→WRIST_ON intervals (when the store surfaces them) sharpen it. A run is dropped + // only when that coverage reaches maxOffWristSleepFraction of its duration (the FRACTIONAL rule from + // j0b-dev's #504), so a real night that over-extends into a SHORT off-wrist tail survives. This is + // independent of the daytime band — off-wrist time is off-wrist day or night, and a night-tail + // continuation does NOT exempt it. + /// A contiguous HR-sample gap of at least this many minutes contributes to a candidate run's + /// off-wrist coverage. Sized at maxGapMin so a real worn night (dense ~1 Hz HR, or PPG-derived HR + /// on a 5/MG) contributes ~no gap, but a wrist-off stretch (HR flatlines to no samples) contributes + /// its whole span. The edges of the run count too: a run that begins/ends far from its nearest HR + /// sample is partially uncovered. + public static let offWristHRGapMin: Int = 20 + + /// FRACTIONAL off-wrist rejection (#500), design credited to j0b-dev's #504 analysis. A candidate + /// sleep run is dropped ONLY when its off-wrist coverage — the UNION of its long HR-gap spans and + /// any WRIST_OFF→WRIST_ON intervals overlapping it — is at least this fraction of its duration. The + /// earlier guard dropped the WHOLE run on ANY contiguous HR gap or ANY single WRIST_OFF blip, which + /// nuked a real night that over-extended into a SHORT off-wrist morning tail (strap removed shortly + /// after waking) or that contained one stray WRIST_OFF event. 0.5 keeps such a night (<50% off-wrist) + /// while still dropping an all-day desk strap (≈100% gap) or a session genuinely spent off-wrist. + public static let maxOffWristSleepFraction: Double = 0.5 + + /// Minimum average HR-stream density for the off-wrist HR-gap proxy to be trusted (#507). The proxy + /// reads a >`offWristHRGapMin`-minute hole in HR as "off the wrist" — valid only when HR is otherwise + /// dense (live 5/MG, or a worn night with continuous HR), so a real gap is anomalous. A WHOOP 4.0's + /// SYNCED night is reconstructed mostly from MOTION with sparse, derived HR, whose natural gaps would + /// otherwise read as off-wrist and wrongly DROP a real night. So if the HR stream averages fewer than + /// one sample per this many seconds, we don't assert off-wrist from gaps at all (WRIST_OFF events + /// still apply). Self-consistent: a night sparse enough to be >50% gap-covered is, by definition, + /// below this density, so it is spared. Measured over the whole stream, so an off-wrist HOLE inside an + /// otherwise dense, worn day (#500) is still caught. + static let hrDenseSpacingS: Int = 600 // one HR sample per 10 minutes, averaged over the stream + + // MARK: - Sparse-gravity robustness (#308) + + // On an un-unlocked WHOOP 5.0 the strap backfills mostly v18/v26 records where gravity is + // sparse/clumped (~25% coverage), so the gravity-only Stage-0 spine fragments the night at + // every >maxGapMin gravity gap and detectSleep drops every 90 min wake bridge mid-sleep). + public static let sparseBridgeGapMin: Int = 90 + // MARK: - Stage 1–3 constants (sleep_features.py) public static let epochS: Double = 30.0 @@ -104,10 +243,31 @@ public enum SleepStager { public static let stageWakeMoveFrac: Double = 0.15 public static let stageStillMoveFrac: Double = 0.10 + /// Fraction of sleep-period epochs that must carry a MISSING per-epoch RMSSD (sparse R-R) for the + /// session's cardiac signal to count as PPG-DERIVED / sparse-cardiac. On a WHOOP 5/MG the PPG-derived + /// HR feeds a noisier per-epoch HR-variance, which inflates `hrVar` on otherwise still, low-HR sleep + /// epochs and was tripping the Stage-2 WAKE rule (which keys on the `hrvarHigh` percentile) — so a + /// whole night over-reported WAKE. We already trust `!rmssd.isFinite` as a PPG/sparse tell for the + /// pro-deep RMSSD handling (#127/#129); at this share across the night it also down-weights the + /// HR-variance half of the WAKE rule. ~50% keeps a real worn 4.0 night (dense R-R) on the strict + /// path and only relaxes nights whose cardiac signal is genuinely sparse/derived. (#705) + public static let cardiacSparseEpochFrac: Double = 0.5 + public static let smoothEpochs: Int = 5 public static let noREMAfterOnsetMin: Double = 15.0 public static let deepFirstFraction: Double = 1.0 / 3.0 + /// Fragment-merge threshold (#274). A staged run shorter than this is "noise": the + /// WHOOP 5/MG banks sparse motion, so the stager emits lots of sub-minute stage flecks + /// and the hypnogram reads choppier than WHOOP's. mergeFragments (a DISPLAY/scoring + /// smoothing applied AFTER staging, never to the underlying detection) absorbs runs + /// below this into their neighbours. 3 min is conservative — long enough to clear the + /// fleck noise, short enough to leave a genuine stage transition (a real deep or REM + /// block runs many minutes) untouched. + public static let fragmentMergeMin: Double = 3.0 + /// fragmentMergeMin expressed in 30 s epochs (6). A run with < this many epochs merges. + public static let fragmentMergeEpochs: Int = Int((fragmentMergeMin * 60.0 / epochS).rounded()) + /// te Lindert 30 s Cole–Kripke weights [A₋₄..A₊₂]. SI = 0.001·Σ wᵢ·Aᵢ; sleep iff SI<1. public static let ckWeights: [Double] = [106.0, 54.0, 58.0, 76.0, 230.0, 74.0, 67.0] public static let ckScale: Double = 0.001 @@ -155,18 +315,71 @@ public enum SleepStager { return max(minWindowSamples, Int(Double(stillWindowMin * 60) / interval)) } + // MARK: - Sparse-gravity gate (#308) + + /// Largest spacing between consecutive timestamps (seconds), NO upper cap; 0 for <2 samples. + /// Used to detect clumped/sparse gravity where the dropouts themselves are the signal: a few + /// long dropouts in otherwise-dense (clumped) motion keep the MEDIAN gap small but still break + /// runs, so the largest gap — not the median — is the right signal (#28). + static func largestGapS(_ times: [Int]) -> Double { + guard times.count >= 2 else { return 0 } + var mx = 0.0 + for i in 0..<(times.count - 1) { + let g = Double(times[i + 1] - times[i]) + if g > mx { mx = g } + } + return mx + } + + /// True when gravity is too sparse for the gravity-only spine to be trusted across gaps: + /// the gravity timespan covers < sparseGravitySpanFrac of the HR-sample timespan, OR the + /// LARGEST gravity inter-sample gap exceeds maxGapMin. The largest-gap test (not just the + /// median) catches CLUMPED motion — dense bursts split by a few long dropouts, the typical + /// WHOOP 4.0 backfill (#28) — whose median gap stays small yet which still hides run-breaking + /// gaps. Requires a real HR span to compare against — with no/degenerate HR the dense path is + /// kept (false), so a 4.0 with absent HR is never reclassified as sparse. + static func isGravitySparse(_ grav: [GravitySample], hr: [HRSample]) -> Bool { + if grav.count < 2 || hr.count < 2 { return false } + let hrSpan = Double(hr[hr.count - 1].ts - hr[0].ts) + if hrSpan <= 0 { return false } + let gravSpan = Double(grav[grav.count - 1].ts - grav[0].ts) + if gravSpan < sparseGravitySpanFrac * hrSpan { return true } + // #28: clumped 4.0 motion keeps a SMALL median gap yet still contains >maxGapMin dropouts + // the gravity-only spine shreds the night on. The largest gap catches what a median would + // miss (largest ≥ median, so this subsumes the old median check). Flagging sparse only + // ENABLES buildRuns' HR-vouched bridge — a real wake (HR above the sleep band) still breaks. + return largestGapS(grav.map { $0.ts }) > Double(maxGapMin * 60) + } + + /// True when HR stays in the sleep band (≤ baseline × hrSleepBandMult) across (a, b], used to + /// decide whether a pure gravity gap is a real wake or just a dropout. With no baseline or no + /// HR in the interval, the answer is false (cannot vouch for the gap → treat as a real break). + static func hrSleepBandAcross(_ a: Int, _ b: Int, hr: [HRSample], baseline: Double?) -> Bool { + guard let baseline = baseline else { return false } + let seg = hr.filter { $0.ts > a && $0.ts <= b } + if seg.isEmpty { return false } + let meanHR = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count) + return meanHR <= baseline * hrSleepBandMult + } + /// Per-record sleep flags from a rolling fraction of "still" samples. static func classifyStill(_ grav: [GravitySample], _ deltas: [Double]) -> [Bool] { let n = grav.count if n < 2 { return [Bool](repeating: false, count: n) } let half = windowSize(grav.map { $0.ts }) / 2 + // stillPrefix[i] = # still samples among deltas[0..= stillFraction) } return flags @@ -176,7 +389,13 @@ public enum SleepStager { /// Collapse per-record flags into contiguous runs, breaking on class change /// or a gap > maxGapMin minutes. - static func buildRuns(_ grav: [GravitySample], _ flags: [Bool]) -> [Period] { + /// + /// When `sparse` (gravity is too clumped to bridge gaps — #308), a PURE gravity data-gap + /// (no contrary motion) does NOT close a SLEEP run while HR stays in the sleep band across + /// the gap: the strap simply banked no motion there, not a wake. A class change always still + /// closes the run, and the dense path (`sparse == false`) is byte-identical to the original. + static func buildRuns(_ grav: [GravitySample], _ flags: [Bool], + sparse: Bool = false, hr: [HRSample] = [], baseline: Double? = nil) -> [Period] { let n = grav.count if n == 0 { return [] } let times = grav.map { $0.ts } @@ -190,7 +409,13 @@ public enum SleepStager { close = true } else { let classChanged = flags[i] != flags[runStart] - let gapExceeded = (times[i] - times[i - 1]) > maxGapS + var gapExceeded = (times[i] - times[i - 1]) > maxGapS + // Sparse override: a pure gravity gap (no class change) does not break a sleep + // run when HR stays in the sleep band across it — the gap is a dropout, not a wake. + if sparse && gapExceeded && !classChanged && flags[runStart] + && hrSleepBandAcross(times[i - 1], times[i], hr: hr, baseline: baseline) { + gapExceeded = false + } close = classChanged || gapExceeded } if close { @@ -237,6 +462,31 @@ public enum SleepStager { return merged } + /// Sparse-gravity bridge (#308): merge two adjacent SLEEP runs separated ONLY by a gap up to + /// sparseBridgeGapMin minutes when the intervening HR stays in the sleep band — so a real night + /// fragmented by gravity dropouts is re-stitched into one continuous in-bed span BEFORE the + /// minSleepMin gate drops the pieces. Active runs and over-threshold gaps are left untouched; + /// the span between two bridged sleep runs (an "active"/gap run, if present) is absorbed. + /// A no-op when `sparse == false`, so the dense 4.0 path is unchanged. + static func bridgeSparseSleep(_ periods: [Period], sparse: Bool, + hr: [HRSample], baseline: Double?) -> [Period] { + if !sparse || periods.isEmpty { return periods } + let bridgeGapS = sparseBridgeGapMin * 60 + var out: [Period] = [] + for p in periods { + if let last = out.last, last.stage == "sleep", p.stage == "sleep" { + let gap = p.start - last.end + if gap >= 0 && gap <= bridgeGapS + && hrSleepBandAcross(last.end, p.start, hr: hr, baseline: baseline) { + out[out.count - 1] = Period(stage: "sleep", start: last.start, end: p.end) + continue + } + } + out.append(p) + } + return out + } + // MARK: - HR refinement static func rowsBetween(_ rows: [T], start: Int, end: Int, ts: (T) -> Int) -> [T] { @@ -258,14 +508,244 @@ public enum SleepStager { return meanHR <= baseline * hrSleepBaselineMult } + /// True when the run's CENTER, shifted to LOCAL time by tzOffsetSeconds, lands in the + /// daytime band [daytimeBandStartHour, daytimeBandEndHour). The center (not the edges) + /// is used so a window straddling a band edge is classified once, by where it mostly is. + /// `((x % d) + d) % d` is a floored modulo so a negative local-shifted time still maps + /// into [0, secondsPerDay). + static func isDaytimeCenter(_ p: Period, tzOffsetSeconds: Int) -> Bool { + // Int overflow-safe: starts/ends are unix seconds; midpoint via average of the two. + let center = p.start + (p.end - p.start) / 2 + let local = center + tzOffsetSeconds + let secOfDay = ((local % secondsPerDay) + secondsPerDay) % secondsPerDay + let hour = secOfDay / 3_600 + return hour >= daytimeBandStartHour && hour < daytimeBandEndHour + } + + /// True when a run's ONSET (start), in LOCAL time, falls OUTSIDE the daytime band — i.e. + /// the sleep began at night, not during the day. Anchors a continuous-sleep chain: only a + /// chain that began overnight may carry its tail past the daytime-band start (a late wake). + static func isOvernightOnset(_ start: Int, tzOffsetSeconds: Int) -> Bool { + let local = start + tzOffsetSeconds + let secOfDay = ((local % secondsPerDay) + secondsPerDay) % secondsPerDay + let hour = secOfDay / 3_600 + return !(hour >= daytimeBandStartHour && hour < daytimeBandEndHour) + } + + /// Stricter bar for a daytime-centered window (#90). A real daytime nap clears it; a + /// long sedentary still stretch (the false-positive this guards) does not, because it + /// is either too short or never shows a genuine cardiac dip below the day median. + /// Overnight windows never reach here. Returns true = keep, false = reject. + /// + /// `restingHR` is the window's own lowest 5-min rolling-mean HR (the sleep-depth proxy + /// detectSleep already computes); `baseline` is the day's median HR. With no usable HR + /// evidence (nil baseline OR nil restingHR) a daytime stretch cannot be confirmed as a + /// real nap, so it is rejected — sedentary daytime stillness without a measured HR dip + /// is far more likely than an unmonitored nap, and this path can never touch the night. + static func passesDaytimeGuard(_ p: Period, restingHR: Int?, baseline: Double?) -> Bool { + let daytimeMinSleepS = daytimeMinSleepMin * 60 + if (p.end - p.start) < daytimeMinSleepS { return false } + guard let baseline = baseline, let resting = restingHR else { return false } + return Double(resting) <= baseline * daytimeRestingHRMult + } + + /// H7 morning-stillness nap suppression (#531). Returns true = KEEP, false = REJECT, for a daytime block + /// `p` that begins shortly after a real overnight wake. `morningWakeEnd` is the end of the just-detected + /// OVERNIGHT chain (nil when the prior chain was not overnight, or there was none) — when `p.start` is + /// within `morningStillnessWindowMin` of it, the block is suspected morning residual stillness and must + /// clear the ORDINARY daytime guard AND show a SUSTAINED re-onset: its resting HR must dip below the + /// stronger `morningReonsetRestingHRMult × baseline` bar (a true second sleep, not near-waking stillness). + /// Outside the morning window this is a no-op (returns the plain daytime-guard result), so a genuine + /// afternoon nap is unaffected. (#531) + static func passesMorningStillnessGuard(_ p: Period, restingHR: Int?, baseline: Double?, + morningWakeEnd: Int?, + bandSleepState: [(ts: Int, state: Int)] = []) -> Bool { + // Only a daytime block beginning within the post-wake window of an overnight chain is suspected. + guard let wakeEnd = morningWakeEnd, p.start >= wakeEnd, + (p.start - wakeEnd) <= morningStillnessWindowMin * 60 else { + return passesDaytimeGuard(p, restingHR: restingHR, baseline: baseline) + } + // Suspected morning stillness needs at least the ordinary daytime guard (long enough + a real dip). + if !passesDaytimeGuard(p, restingHR: restingHR, baseline: baseline) { return false } + // CONSUME the strap's OWN banked band sleep_state (#531 / H8): if the strap itself scored this block + // predominantly "asleep", that is a strong independent re-onset anchor — KEEP it even on a borderline + // HR dip. This only ever RESCUES a block the strap says was real sleep; it never fabricates one. + if bandStateConfirmsAsleep(p, bandSleepState: bandSleepState) { return true } + // Otherwise require the clearly-deeper cardiac dip of a true second sleep. + guard let baseline = baseline, let resting = restingHR else { return false } + return Double(resting) <= baseline * morningReonsetRestingHRMult + } + + /// CONSUME-side helper (#531 / H8): true when the strap's OWN persisted v18 band sleep_state over the + /// block `[p.start, p.end]` reads predominantly "asleep" (`bandStateAsleep`), at/above + /// `morningReonsetBandAsleepFrac` of the in-block samples — an independent confirmation of a real + /// re-onset. Empty/absent band state → false (no anchor → fall back to the HR bar); we never invent a + /// "asleep" reading the strap did not bank. Pure + deterministic. (#531 / H8 consume) + static func bandStateConfirmsAsleep(_ p: Period, bandSleepState: [(ts: Int, state: Int)]) -> Bool { + let inBlock = bandSleepState.filter { $0.ts >= p.start && $0.ts <= p.end } + guard !inBlock.isEmpty else { return false } + let asleep = inBlock.reduce(0) { $0 + ($1.state == bandStateAsleep ? 1 : 0) } + return Double(asleep) / Double(inBlock.count) >= morningReonsetBandAsleepFrac + } + + /// Off-wrist HR-gap spans (#500). The contiguous HR-coverage gaps of at least `offWristHRGapMin` + /// minutes WITHIN [p.start, p.end], as concrete `[start, end)` sub-intervals — a strong wrist-OFF + /// proxy. Worn, the strap streams ~1 Hz HR (or PPG-derived HR on a 5/MG), so a real night yields no + /// long gap; an off-wrist stretch flatlines to no HR samples and yields a span. The leading edge + /// (`p.start` → first in-run sample) and trailing edge (last in-run sample → `p.end`) count too, + /// and a run with NO in-run HR at all is one full-period gap. With NO HR data at all (no stream) + /// this returns [] (the gravity-only path is left to the existing guards — we can't assert + /// off-wrist without HR). These spans are UNIONed with the WRIST_OFF intervals by `offWristFraction`. + static func offWristHRGapSpans(_ p: Period, hr: [HRSample]) -> [(start: Int, end: Int)] { + if hr.isEmpty || p.end <= p.start { return [] } + // Density gate (#507): only trust the HR-gap off-wrist proxy when the HR STREAM is dense enough + // that a long gap is anomalous. A WHOOP 4.0 synced night is motion-reconstructed with sparse HR, + // so its natural gaps must NOT read as off-wrist (that wrongly dropped a real night). Judge over + // the whole stream so an off-wrist HOLE inside an otherwise dense, worn day (#500) is still caught. + let sortedAll = hr.sorted { $0.ts < $1.ts } + let streamSpan = sortedAll[sortedAll.count - 1].ts - sortedAll[0].ts + if streamSpan >= hrDenseSpacingS && hr.count < streamSpan / hrDenseSpacingS { return [] } + let gapS = offWristHRGapMin * 60 + let seg = hr.filter { $0.ts >= p.start && $0.ts <= p.end }.sorted { $0.ts < $1.ts } + // No HR anywhere inside a run long enough to matter → the whole period is one gap. + if seg.isEmpty { return (p.end - p.start) >= gapS ? [(start: p.start, end: p.end)] : [] } + var spans: [(start: Int, end: Int)] = [] + // Leading edge: run start to first sample. + if seg[0].ts - p.start >= gapS { spans.append((start: p.start, end: seg[0].ts)) } + // Interior: any gap between consecutive in-run samples. + for i in 1..= gapS { + spans.append((start: seg[i - 1].ts, end: seg[i].ts)) + } + // Trailing edge: last sample to run end. + if p.end - seg[seg.count - 1].ts >= gapS { spans.append((start: seg[seg.count - 1].ts, end: p.end)) } + return spans + } + + /// Fractional off-wrist coverage of a candidate run [p.start, p.end] in [0, 1] (#500). + /// Design credited to j0b-dev's #504 analysis: instead of a binary drop on ANY HR gap or ANY single + /// WRIST_OFF blip, we measure how much of the run is off-wrist and let the caller drop it only past + /// `maxOffWristSleepFraction`. Coverage = (length of the UNION of) the HR-gap spans (`offWristHRGapSpans`) + /// AND the supplied WRIST_OFF→WRIST_ON `wristOff` intervals, clipped to the run, divided by duration. + /// Unioning avoids double-counting overlapping gap+event time. A real night with a small (<50%) + /// off-wrist tail scores low and is kept; an all-day desk strap (HR-gap ≈100%, no events needed) or a + /// session genuinely spent off the wrist scores high and is dropped. + static func offWristFraction(_ p: Period, hr: [HRSample], wristOff: [(start: Int, end: Int)]) -> Double { + let dur = p.end - p.start + if dur <= 0 { return 0 } + // Collect every off-wrist span, clipped to the run: HR-gap proxy spans + explicit wrist-off events. + var spans = offWristHRGapSpans(p, hr: hr) + for w in wristOff { + let s = max(w.start, p.start), e = min(w.end, p.end) + if e > s { spans.append((start: s, end: e)) } + } + if spans.isEmpty { return 0 } + // Union the spans so overlapping gap+event time is counted once, then sum the covered length. + spans.sort { $0.start < $1.start } + var covered = 0, curStart = spans[0].start, curEnd = spans[0].end + for sp in spans.dropFirst() { + if sp.start <= curEnd { + curEnd = max(curEnd, sp.end) // overlapping/adjacent → extend + } else { + covered += curEnd - curStart // disjoint → bank the run + curStart = sp.start; curEnd = sp.end + } + } + covered += curEnd - curStart + return Double(covered) / Double(dur) + } + // MARK: - detectSleep (public) /// Detect sleep sessions from biometric streams. Empty/absent gravity → []. /// Gravity-only input degrades gracefully (HR/RR/resp refinements skipped). + /// + /// `tzOffsetSeconds` is the wall-clock UTC offset (TimeZone.current.secondsFromGMT) + /// used ONLY to place each window's center on a LOCAL clock for the daytime + /// false-sleep guard (#90). It defaults to 0 so the pure function and its tests stay + /// UTC; the live call site (IntelligenceEngine) passes the device's real offset. + /// `wristOff` is an optional list of off-wrist `[start, end)` intervals (unix seconds), paired from + /// the strap's WRIST_OFF/WRIST_ON events by `AnalyticsEngine.offWristIntervals`. When the call site + /// has them (IntelligenceEngine reads `store.events`), they sharpen the always-on HR-gap off-wrist + /// backstop: a candidate run is dropped when its off-wrist coverage (HR-gap spans UNION these + /// intervals) reaches `maxOffWristSleepFraction` of its duration — the FRACTIONAL rule from #504, so + /// a real night with a short off-wrist tail survives (#500). Defaults to empty (HR-gap proxy only), + /// so the pure function and its tests stay event-free. + /// `bandSleepState` is the strap's OWN persisted v18 BAND sleep_state per timestamp (Interpreter's + /// `(sb>>4)&3`: 0 wake / 1 still / 2 asleep / 3 up), used ONLY to CONSUME-confirm a borderline H7 morning + /// re-onset (#531): a daytime block the strap itself scored predominantly "asleep" is KEPT even on a + /// borderline HR dip. Default empty keeps pure-function callers/tests free of it; IntelligenceEngine + /// passes the night window's persisted band state. It can only RESCUE a real-sleep block, never fabricate. + /// `useSleepStagerV2` (V7 / #690): when true, each accepted night is staged by the experimental + /// cardiorespiratory recipe `SleepStagerV2.stageSession` instead of V1's `stageSession`. DETECTION is + /// unchanged (same accepted windows); only the per-epoch hypnogram differs. Default false keeps V1 the + /// byte-identical default (the frozen-golden tests stay green). The live call site threads + /// `PuffinExperiment.experimentalSleepV2Enabled` so the Settings toggle now affects normal detected + /// nights, not just the self-heal restage path. public static func detectSleep(hr: [HRSample] = [], rr: [RRInterval] = [], resp: [RespSample] = [], - gravity: [GravitySample]) -> [SleepSession] { + gravity: [GravitySample], + tzOffsetSeconds: Int = 0, + wristOff: [(start: Int, end: Int)] = [], + bandSleepState: [(ts: Int, state: Int)] = [], + useSleepStagerV2: Bool = false, + traceSink: ((String) -> Void)? = nil) -> [SleepSession] { + // Sleep & Rest test mode only: when a trace is requested we MUST run the live ladder, not a + // memoized result, so each gate verdict is emitted for THIS night. The trace is side-effect- + // only and never changes the sessions, so a traced and an untraced call return the identical + // array. With no sink (the default, every existing call site) the path below is byte-identical + // to before: same memo key, same compute. + if let traceSink { + return detectSleepUncached(hr: hr, rr: rr, resp: resp, gravity: gravity, + tzOffsetSeconds: tzOffsetSeconds, wristOff: wristOff, + bandSleepState: bandSleepState, useSleepStagerV2: useSleepStagerV2, + traceSink: traceSink) + } + // v7.0.2 perf (#707): the single heaviest analytics call — it sorts the dense full-day gravity + // stream (~tens of thousands of samples for a worn day), builds the gravity-delta/still spine, and + // stages every accepted run. The post-sync scoring loop calls it once PER DAY across the window, and + // a re-run with the SAME raw (an idempotent re-pass, or a later sync that didn't touch this day's + // streams) re-does all of it for an identical `[SleepSession]`. Memoize on a FULL key: every input + // that steers detection or staging — the four streams, the tz offset (daytime-guard + onset band), + // the off-wrist intervals (#500 backstop), the persisted band state (#531 H8), and the V2 toggle (an + // edit to any re-keys to a fresh compute). Result-only + bounded; the raw arrays are never retained. + let key = DetectKey( + grav: StreamFingerprint.of(gravity, ts: { $0.ts }, quant: { Int(($0.x + $0.y + $0.z) * 1024) }), + hr: StreamFingerprint.of(hr, ts: { $0.ts }, quant: { Int($0.bpm) }), + rr: StreamFingerprint.of(rr, ts: { $0.ts }, quant: { Int($0.rrMs) }), + resp: StreamFingerprint.of(resp, ts: { $0.ts }, quant: { $0.raw }), + tz: tzOffsetSeconds, + wristOff: StreamFingerprint.of(wristOff, ts: { $0.start }, quant: { $0.end }), + band: StreamFingerprint.of(bandSleepState, ts: { $0.ts }, quant: { $0.state }), + v2: useSleepStagerV2) + return detectSleepCache.value(key) { + detectSleepUncached(hr: hr, rr: rr, resp: resp, gravity: gravity, + tzOffsetSeconds: tzOffsetSeconds, wristOff: wristOff, + bandSleepState: bandSleepState, useSleepStagerV2: useSleepStagerV2, + traceSink: nil) + } + } + + private struct DetectKey: Hashable { + let grav: StreamFingerprint; let hr: StreamFingerprint + let rr: StreamFingerprint; let resp: StreamFingerprint + let tz: Int + let wristOff: StreamFingerprint; let band: StreamFingerprint + let v2: Bool + } + /// ≈ the number of distinct days in a scoring window; FIFO-evicted, holds only small session arrays. + private static let detectSleepCache = AnalyticsMemoCache(capacity: 40) + + /// The unchanged detection+staging pipeline; split out verbatim so the public entry memoizes in front. + private static func detectSleepUncached(hr: [HRSample], + rr: [RRInterval], + resp: [RespSample], + gravity: [GravitySample], + tzOffsetSeconds: Int, + wristOff: [(start: Int, end: Int)], + bandSleepState: [(ts: Int, state: Int)], + useSleepStagerV2: Bool, + traceSink: ((String) -> Void)? = nil) -> [SleepSession] { let grav = gravity.sorted { $0.ts < $1.ts } if grav.count < 2 { return [] } @@ -273,26 +753,134 @@ public enum SleepStager { let rrS = rr.sorted { $0.ts < $1.ts } let respS = resp.sorted { $0.ts < $1.ts } + let baseline = hrBaseline(hrS) + // Sparse-gravity gate (#308): an un-unlocked WHOOP 5.0 backfills mostly v18/v26 records + // where gravity is clumped (~25% coverage), so the gravity-only spine fragments the night. + // ONLY when sparse do the three robustness branches engage; a dense 4.0 night is `false` + // here and follows the exact original path (byte-identical). + let sparse = isGravitySparse(grav, hr: hrS) + let deltas = gravityDeltas(grav) let flags = classifyStill(grav, deltas) - var runs = buildRuns(grav, flags) + var runs = buildRuns(grav, flags, sparse: sparse, hr: hrS, baseline: baseline) runs = mergePeriods(runs) + // Re-stitch sleep runs fragmented by pure gravity dropouts (sparse only) before minSleepMin. + let runsBeforeBridge = traceSink == nil ? 0 : runs.filter { $0.stage == "sleep" }.count + runs = bridgeSparseSleep(runs, sparse: sparse, hr: hrS, baseline: baseline) + // Sleep & Rest test mode (E3): record the sparse-gravity bridge result, so a sparse 5.0 night + // rescued from fragmentation is visible. Only emitted when gravity is sparse (the only case the + // bridge can act) and only when tracing. Side-effect-only. + if let traceSink, sparse { + let runsAfterBridge = runs.filter { $0.stage == "sleep" }.count + traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0, + verdict: runsAfterBridge < runsBeforeBridge ? .kept : .dropped, gate: "sparseBridge", + detail: "sparse=true gapMin=\(sparseBridgeGapMin) runsBefore=\(runsBeforeBridge) runsAfter=\(runsAfterBridge)")) + } - let baseline = hrBaseline(hrS) let minSleepS = minSleepMin * 60 var sessions: [SleepSession] = [] + // Continuous-sleep chain tracking so a real overnight sleep that runs PAST the daytime-band + // start (a late wake, or a brief morning stir then back to sleep that leaves the tail as its + // own daytime-centered run) is NOT mistaken for an isolated daytime nap and rejected — which + // truncated the displayed wake time to ~late morning. A daytime run skips the nap guard ONLY + // when it directly continues (≤ nightContinuationGap) a chain that BEGAN overnight; isolated + // daytime stillness (hours after waking) still faces the full guard. + // Reimplemented from @vulnix0x4's PR #353. + let continuationGapS = nightContinuationGapMin * 60 + var chainPrevEnd: Int? = nil // end of the last accepted sleep run + var chainFromOvernight = false // did the current contiguous chain begin overnight? + // Sleep & Rest test mode (E2): each candidate sleep run emits ONE verdict line naming the gate + // that kept or dropped it. The decisions below are byte-identical to the untraced path; the + // `traceSink?(...)` calls are the only addition and never alter `sessions`. `runIndex` counts + // only sleep-stage runs so the trace numbers match the candidate ordinal. + var runIndex = -1 for p in runs { if p.stage != "sleep" { continue } - if (p.end - p.start) <= minSleepS { continue } - if !confirmSleepWithHR(p, hr: hrS, baseline: baseline) { continue } - let stages = stageSession(start: p.start, end: p.end, grav: grav, - hr: hrS, rr: rrS, resp: respS) - let eff = efficiency(start: p.start, end: p.end, stages: stages) + runIndex += 1 + let spanMin = (p.end - p.start) / 60 + if (p.end - p.start) <= minSleepS { + traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end, + verdict: .dropped, gate: "minSleepMin", + detail: "spanMin=\(spanMin) minSleepMin=\(minSleepMin)")) + continue + } + // H4 physiological in-bed span cap (#547/#531/#509 tail): a single assembled main-sleep run + // longer than ~16 h is a bad-clock artefact (a frozen still stretch banked under a stale/wrong + // clock), not a real night. Drop it rather than report (or truncate to) a 12 h+ "sleep" — an + // over-long block can't be trusted to assert a span at all, and truncating would fabricate a + // wake time. Checked before staging so the artefact never reaches the aggregate. + if (p.end - p.start) > maxMainSleepSpanS { + traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end, + verdict: .dropped, gate: "maxMainSleepSpanS", + detail: "spanMin=\(spanMin) maxMainSleepSpanMin=\(maxMainSleepSpanS / 60)")) + continue + } + if !confirmSleepWithHR(p, hr: hrS, baseline: baseline) { + traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end, + verdict: .dropped, gate: "hrConfirm", + detail: "hrSleepBaselineMult=\(hrSleepBaselineMult) baseline=\(baseline.map { Int($0) } ?? -1)")) + continue + } + // Off-wrist backstop (#500), FRACTIONAL rule (design credited to j0b-dev's #504 analysis): + // a wrist-OFF stretch is still gravity with no HR, so it slips past both the gravity spine + // and the daytime guard's "missing data" path. Measure off-wrist COVERAGE — the union of the + // run's long HR-coverage gaps (the must-have proxy) and any WRIST_OFF→WRIST_ON intervals + // overlapping it — and drop the run only when that reaches maxOffWristSleepFraction of its + // duration. This no longer nukes a real night that over-extends into a SHORT (<50%) off-wrist + // morning tail, or that holds a single stray WRIST_OFF blip, while an all-day desk strap + // (≈100% gap) is still dropped. Checked BEFORE the night-tail exemption: off-wrist time is + // off-wrist day or night and must NOT ride a continuation chain. It does NOT re-anchor the + // chain (the run is simply skipped). + let offFrac = offWristFraction(p, hr: hrS, wristOff: wristOff) + if offFrac >= maxOffWristSleepFraction { + traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end, + verdict: .dropped, gate: "offWrist", + detail: "offWristFrac=\(round2(offFrac)) max=\(maxOffWristSleepFraction)")) + continue + } + // Daytime false-sleep guard (#90): a window centered in the local daytime band + // must clear a stricter bar (≥daytimeMinSleepMin AND a real resting-HR dip). + // Overnight windows skip this entirely. restingHR is computed here (reused below). let resting = sessionRestingHR(start: p.start, end: p.end, hr: hrS) + let continuesChain = chainPrevEnd.map { p.start - $0 <= continuationGapS } ?? false + let isNightTail = continuesChain && chainFromOvernight // the night's tail, not a nap + // H7 (#531): when the prior accepted chain BEGAN overnight, its wake (`chainPrevEnd`) anchors the + // morning-stillness window. A daytime block beginning within it that is NOT a night-tail must + // clear the STRONGER re-onset bar — killing the 9 am phantom nap of residual post-wake stillness + // while keeping a genuine second sleep. Outside the window the guard is the ordinary daytime bar. + let morningWakeEnd = chainFromOvernight ? chainPrevEnd : nil + let isDaytime = isDaytimeCenter(p, tzOffsetSeconds: tzOffsetSeconds) + // Evaluate the morning-stillness guard ONLY when the run is daytime-centered, preserving the + // original short-circuit (overnight runs never call it). The boolean used to `continue` below + // is identical to the original combined condition. + let passesMorning = isDaytime + ? passesMorningStillnessGuard(p, restingHR: resting, baseline: baseline, + morningWakeEnd: morningWakeEnd, + bandSleepState: bandSleepState) + : true + if isDaytime, !passesMorning, !isNightTail { + let gateName = (morningWakeEnd != nil) ? "morningStillness" : "daytimeGuard" + traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end, + verdict: .dropped, gate: gateName, + detail: "daytime=true restingHR=\(resting ?? -1) baseline=\(baseline.map { Int($0) } ?? -1) nightTail=false")) + continue + } + let stages = useSleepStagerV2 + ? SleepStagerV2.stageSession(start: p.start, end: p.end, grav: grav, + hr: hrS, rr: rrS, resp: respS) + : stageSession(start: p.start, end: p.end, grav: grav, + hr: hrS, rr: rrS, resp: respS) + let eff = efficiency(start: p.start, end: p.end, stages: stages) let avgHrv = sessionAvgHRV(start: p.start, end: p.end, rr: rrS) sessions.append(SleepSession(start: p.start, end: p.end, efficiency: eff, stages: stages, restingHR: resting, avgHRV: avgHrv)) + traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end, + verdict: .kept, gate: "accepted", + detail: "spanMin=\(spanMin) eff=\(round2(eff)) restingHR=\(resting ?? -1) daytime=\(isDaytime)")) + // A run that does NOT continue the chain re-anchors it on this run's onset. + if !continuesChain { chainFromOvernight = isOvernightOnset(p.start, tzOffsetSeconds: tzOffsetSeconds) } + chainPrevEnd = p.end } sessions.sort { $0.start < $1.start } return sessions @@ -328,8 +916,39 @@ public enum SleepStager { } /// Build a 30 s hypnogram for [start, end] and return StageSegments. - static func stageSession(start: Int, end: Int, grav: [GravitySample], - hr: [HRSample], rr: [RRInterval], resp: [RespSample]) -> [StageSegment] { + /// Stage a FORCED window from raw streams (no boundary detection): the same per-epoch classifier + /// the detection path uses, run over exactly `[start, end]`. The sleep-edit path calls this to + /// re-derive real stages for a hand-corrected window — so extending a boundary recovers genuine + /// stages from the sensor data instead of a fabricated "awake" block. (#318) + public static func stageSession(start: Int, end: Int, grav: [GravitySample], + hr: [HRSample], rr: [RRInterval], resp: [RespSample]) -> [StageSegment] { + // v7.0.2 perf (#707): stage each window AT MOST ONCE per (window, input-fingerprint). Both + // `detectSleep` (per accepted run) and the sleep-edit restage call this with byte-identical streams + // across post-sync passes / `body` re-evaluations; each call builds a fresh 30 s epoch grid + + // per-epoch feature arrays before collapsing to a few `StageSegment`s. The key folds in the window + // (an edit re-keys) and a strided fingerprint of every stream the V1 recipe READS (grav/hr/rr/resp — + // resp IS consumed here via the epoch grid, unlike V2). Result-only, bounded, no raw arrays retained. + let key = V1StageKey( + start: start, end: end, + grav: StreamFingerprint.of(grav, ts: { $0.ts }, quant: { Int(($0.x + $0.y + $0.z) * 1024) }), + hr: StreamFingerprint.of(hr, ts: { $0.ts }, quant: { Int($0.bpm) }), + rr: StreamFingerprint.of(rr, ts: { $0.ts }, quant: { Int($0.rrMs) }), + resp: StreamFingerprint.of(resp, ts: { $0.ts }, quant: { $0.raw })) + return stageSessionCache.value(key) { + stageSessionUncached(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp) + } + } + + private struct V1StageKey: Hashable { + let start: Int; let end: Int + let grav: StreamFingerprint; let hr: StreamFingerprint + let rr: StreamFingerprint; let resp: StreamFingerprint + } + private static let stageSessionCache = AnalyticsMemoCache(capacity: 32) + + /// Unchanged V1 staging recipe; split verbatim so the public entry memoizes in front of it. + private static func stageSessionUncached(start: Int, end: Int, grav: [GravitySample], + hr: [HRSample], rr: [RRInterval], resp: [RespSample]) -> [StageSegment] { let gSeg = rowsBetween(grav, start: start, end: end) { $0.ts } if gSeg.count < 2 { return [StageSegment(start: start, end: end, stage: "light")] } @@ -357,6 +976,11 @@ public enum SleepStager { labels = smoothLabels(labels) labels = reimposePhysiology(labels, features: feats, onsetIdx: onsetIdx, finalWakeIdx: finalWakeIdx) + // Conservative fragment merge (#274): absorb sub-3-min stage flecks (the WHOOP 5/MG + // sparse-motion artefact) so the hypnogram stops reading choppier than WHOOP's, + // without erasing genuine multi-minute transitions. Display/scoring only — the + // per-epoch detection above is unchanged. + labels = mergeFragments(labels) // Pre-onset and post-final-wake epochs are not sleep → force wake. for i in 0.. finalWakeIdx { labels[i] = "wake" } @@ -376,6 +1000,55 @@ public enum SleepStager { return segments } + // MARK: - Per-epoch motion (H8 — persisted beside stagesJSON) + + /// The per-epoch MOTION magnitudes for a session window, on the SAME 30 s epoch grid as `stageSession`'s + /// `stagesJSON` (one entry per epoch, in order). Each value is the epoch's summed |Δgravity| (the raw + /// pre-rescale Cole–Kripke activity count) — the strap's own motion signal, banked so later passes and + /// the UI can read per-epoch movement without re-reading the raw gravity stream. Returns `[]` when the + /// window has too little gravity to grid (mirrors `stageSession`'s degenerate fallback), so the caller + /// persists NULL (no fabricated zero series). Pure + deterministic; shares `buildEpochGrid` with staging + /// so the grids align epoch-for-epoch. (H8) + public static func sessionEpochMotion(start: Int, end: Int, grav: [GravitySample]) -> [Double] { + let gSeg = rowsBetween(grav, start: start, end: end) { $0.ts } + if gSeg.count < 2 { return [] } + let gDeltas = gravityDeltas(gSeg) + let gTimes = gSeg.map { $0.ts } + let grid = buildEpochGrid(start: Double(start), end: Double(end), + gravTimes: gTimes, gravDeltas: gDeltas, + hr: [], rr: [], resp: []) + return grid.counts + } + + /// #175: the strap's OWN band sleep_state (0 wake/1 still/2 asleep/3 up) gridded onto the SAME 30 s + /// epoch grid `stagesJSON` / `sessionEpochMotion` use, so the caller can persist it via + /// `WhoopStore.persistSessionSleepState` and the H7 re-onset CONFIRM guard can read it back as timestamped + /// `(startTs + i*epochS, state)` samples. Returns EMPTY when the session carries no band-state samples + /// (a WHOOP 4.0, or an unbanded window) — an absent signal stays absent, never a fabricated array. When + /// present, each epoch takes the band's LAST reported state within its `[start+i·30, start+(i+1)·30)` + /// window; an epoch with no sample of its own CARRIES FORWARD the previous epoch's state (band state is a + /// step function). Leading epochs before the first sample take the first sample's state. The band code is + /// carried VERBATIM — this never converts an unproven code into a derived stage; consumers decide meaning. + public static func sessionEpochSleepState(start: Int, end: Int, + sleepState: [(ts: Int, state: Int)]) -> [Int] { + let seg = rowsBetween(sleepState, start: start, end: end) { $0.ts }.sorted { $0.ts < $1.ts } + guard !seg.isEmpty, end > start else { return [] } + let nEpochs = max(1, Int(ceil(Double(end - start) / epochS))) + var out = [Int](repeating: seg[0].state, count: nEpochs) // lead-in = first sample's state + var last = seg[0].state + var si = 0 + for i in 0.. [Double] { let r = kernel.count / 2 - if r == 0 || x.isEmpty { return x } + // A signal shorter than the kernel radius can't be reflect-padded (the mirror reads x[r] + // and x[x.count-2-i]) — return it unchanged rather than indexing out of bounds. In practice + // the only caller is gated by the 60-min session floor, so this is defensive. + if r == 0 || x.count <= r { return x } // Reflect padding: numpy 'reflect' mirrors WITHOUT repeating the edge sample. var padded = [Double]() padded.reserveCapacity(x.count + 2 * r) @@ -590,8 +1266,10 @@ public enum SleepStager { } } if distance <= 1 || candidates.isEmpty { return candidates } - // Enforce minimum distance: greedily keep tallest, scipy-style. - let byHeight = candidates.sorted { x[$0] > x[$1] } + // Enforce minimum distance: greedily keep tallest, scipy-style. Tie-break on the lower + // index so equal-height peaks resolve deterministically and identically to the Android + // port's stable sort (Swift's sorted(by:) is not guaranteed stable). + let byHeight = candidates.sorted { x[$0] != x[$1] ? x[$0] > x[$1] : $0 < $1 } var keep = [Bool](repeating: true, count: candidates.count) let indexOf = Dictionary(uniqueKeysWithValues: candidates.enumerated().map { ($1, $0) }) for p in byHeight { @@ -603,6 +1281,134 @@ public enum SleepStager { return candidates.enumerated().filter { keep[$0.offset] }.map { $0.element }.sorted() } + // MARK: - Respiration rate from R-R (RSA) — WHOOP5 on-wire path + + /// RSA tachogram resample rate (Hz). 4 Hz is the standard HRV resample grid. + static let rsaResampleHz = 4.0 + + /// Moving-mean detrend window for the RSA tachogram (seconds). + static let rsaDetrendWindowS = 8.0 + + /// Minimum spacing between breath peaks on the tachogram (seconds) → ≤24 bpm. + static let rsaMinPeakDistanceS = 2.5 + + /// Per-window length for the per-window rate estimate (seconds). + static let rsaWindowS = 300.0 + + /// Physiologic breath-interval band (seconds): 0.1–0.4 Hz = 6–24 breaths/min. + static let rsaMinBreathIntervalS = 2.5 // 24 bpm + static let rsaMaxBreathIntervalS = 10.0 // 6 bpm + + /// THE canonical plausible sleeping-respiratory-rate band (bpm). The RSA peak-pick below can + /// yield 6–8 bpm at its noise floor, but every consumer (illness/readiness gates) only acts on + /// 8–25 — so respRateFromRR clamps its output to this band (NaN outside it) and the stored + /// value can never disagree with what's acted on. Mirrors Android SleepStager. + public static let respPlausibleRangeBpm: ClosedRange = 8.0...25.0 + + /// APPROXIMATE respiratory rate (breaths/min) from the R-R interval stream via + /// respiratory sinus arrhythmia (RSA), for use when no raw resp ADC channel is + /// available (WHOOP5 v18 wire is RR-only; resp ADC is WHOOP4 / cloud-only). + /// + /// This is an ON-DEVICE ESTIMATE, NOT a cloud/clinical respiration measurement. + /// It recovers the breathing-modulation of beat-to-beat timing, which tracks but + /// does not equal a chest-band / capnography rate. + /// + /// Pipeline (per matched in-bed session [start, end], unix SECONDS): + /// 1. Restrict RR rows to ts in [start, end]; range-filter the RR values + /// (HRVAnalyzer.rangeFilter) to drop dropouts/ectopics. + /// 2. Reconstruct beat times by cumulatively summing the kept RR intervals + /// from the first in-bed beat, yielding an (irregular) tachogram. + /// 3. Resample the tachogram onto a uniform ~4 Hz grid by linear interpolation. + /// 4. Detrend: subtract a centered moving mean (rsaDetrendWindowS). + /// 5. Per ~5-min window: findPeaks (min distance rsaMinPeakDistanceS) on the + /// detrended grid, keep peak-to-peak intervals in the 6–24 bpm band, rate = + /// 60 / median(intervals). Take the median across windows. + /// Returns NaN when too few intervals survive (honest no-data). + static func respRateFromRR(_ rr: [RRInterval], start: Int, end: Int) -> Double { + let nan = Double.nan + if end <= start { return nan } + + // 1. In-bed RR rows in chronological order, range-filtered. + let inBed = rr.filter { $0.ts >= start && $0.ts <= end } + .sorted { $0.ts < $1.ts } + .map { Double($0.rrMs) } + let filtered = HRVAnalyzer.rangeFilter(inBed) + if filtered.count < 30 { return nan } // need enough beats for any RSA estimate + + // 2. Reconstruct beat times (seconds from session start) by cumulative sum. + var beatTimes = [Double](repeating: 0, count: filtered.count) + var acc = 0.0 + for i in filtered.indices { + acc += filtered[i] / 1000.0 + beatTimes[i] = acc + } + let totalSpanS = beatTimes[beatTimes.count - 1] + if totalSpanS < rsaWindowS / 2.0 { return nan } // < ~2.5 min of beats + + // 3. Resample onto a uniform grid by linear interpolation. + let dt = 1.0 / rsaResampleHz + let nGrid = Int(totalSpanS / dt) + 1 + if nGrid < 8 { return nan } + var grid = [Double](repeating: 0, count: nGrid) + var seg = 0 + for g in 0..= minDistSamples * 3 { + let winSeg = Array(detrended[w..= 3 { + var intervals: [Double] = [] + for i in 1..= rsaMinBreathIntervalS && ivS <= rsaMaxBreathIntervalS { + intervals.append(ivS) + } + } + if intervals.count >= 2 { + let med = HRVAnalyzer.median(intervals) + if med > 0.0 { perWindowRates.append(60.0 / med) } + } + } + } + w += windowSamples + } + if perWindowRates.isEmpty { return nan } + // Reject estimates outside the canonical consumer band (NaN = "no usable estimate") so the + // persisted value never silently disagrees with the illness/readiness plausibility gate. + let median = HRVAnalyzer.median(perWindowRates) + return respPlausibleRangeBpm.contains(median) ? median : nan + } + // MARK: - Per-epoch features struct EpochFeatures { @@ -686,25 +1492,51 @@ public enum SleepStager { let hrvarHi = percentile(sleepFeats.map { $0.hrVar }, stageHRVarHighPct) let rrvHi = percentile(sleepFeats.map { $0.rrv }, stageRRVHighPct) let rrvLo = percentile(sleepFeats.map { $0.rrv }, stageRRVLowPct) + let cardiacSparse = isCardiacSparse(sleepFeats) return features.map { classifyOne($0, hrLo: hrLo, hrHi: hrHi, rmssdHi: rmssdHi, - hrvarHi: hrvarHi, rrvHi: rrvHi, rrvLo: rrvLo) + hrvarHi: hrvarHi, rrvHi: rrvHi, rrvLo: rrvLo, + cardiacSparse: cardiacSparse) } } + /// Session-level PPG-derived / sparse-cardiac tell: most sleep-period epochs carry NO finite + /// per-epoch RMSSD (sparse R-R). On those nights the HR is PPG-derived and its windowed variance + /// (`hrVar`) is noisier, so the percentile `hrvarHigh` bar fires on genuinely still, low-HR sleep — + /// which the WAKE rule must NOT treat as cardiac activation. Same `!rmssd.isFinite` signal already + /// trusted for the pro-deep RMSSD handling (#127/#129), aggregated across the night. (#705) + static func isCardiacSparse(_ sleepFeats: [EpochFeatures]) -> Bool { + if sleepFeats.isEmpty { return false } + let sparse = sleepFeats.reduce(0) { $0 + (($1.rmssd.isFinite) ? 0 : 1) } + return Double(sparse) >= cardiacSparseEpochFrac * Double(sleepFeats.count) + } + static func classifyOne(_ f: EpochFeatures, hrLo: Double?, hrHi: Double?, - rmssdHi: Double?, hrvarHi: Double?, rrvHi: Double?, rrvLo: Double?) -> String { + rmssdHi: Double?, hrvarHi: Double?, rrvHi: Double?, rrvLo: Double?, + cardiacSparse: Bool = false) -> String { let hasHR = f.hr.isFinite let hrLow = hasHR && hrLo != nil && f.hr <= hrLo! let hrHigh = hasHR && hrHi != nil && f.hr >= hrHi! - // NOTE: HF omitted (no neurokit2). Parasympathetic tone = RMSSD only. - let parasympHigh = f.rmssd.isFinite && rmssdHi != nil && f.rmssd >= rmssdHi! + // NOTE: HF omitted (no neurokit2). Parasympathetic tone = RMSSD only. A MISSING per-epoch + // RMSSD (sparse R-R, common on BLE-offloaded nights and especially 5/MG) is treated as + // pro-deep rather than deep-blocking — mirroring how a missing respiration value is handled + // below — so those nights stop decoding 0 m of deep sleep despite a real depth signature + // (still + low HR + regular breathing). An epoch WITH a finite RMSSD must still clear the + // high-tone bar. (#127, #129) + let parasympOK = (!f.rmssd.isFinite) || (rmssdHi != nil && f.rmssd >= rmssdHi!) let hrvarHigh = f.hrVar.isFinite && hrvarHi != nil && f.hrVar >= hrvarHi! let cardiacActivated = hrHigh || hrvarHigh + // WAKE-specific cardiac vetting. On a PPG-derived / sparse-cardiac night the per-epoch HR-variance + // is noisy, so `hrvarHigh` fires on still, low-HR sleep and used to flip those epochs to WAKE. When + // the session is sparse we DOWN-WEIGHT hrVar for the wake promotion and require a real elevated HR + // (`hrHigh`) — the down-weighting mirrors how sparse R-R is trusted for the pro-deep RMSSD handling. + // Dense 4.0 nights keep the full `hrHigh || hrvarHigh` signal, so their behaviour is unchanged. (#705) + let cardiacActivatedForWake = cardiacSparse ? hrHigh : cardiacActivated + let rrvIrregular = f.rrv.isFinite && rrvHi != nil && f.rrv >= rrvHi! // Missing respiration (NaN RRV) treated as "regular" (pro-deep bias). let rrvRegular = (!f.rrv.isFinite) || (rrvLo != nil && f.rrv <= rrvLo!) @@ -712,10 +1544,12 @@ public enum SleepStager { let still = f.moveFrac <= stageStillMoveFrac let moving = f.moveFrac >= stageWakeMoveFrac - // WAKE: sustained motion + activated cardiac (or no HR to vet motion). - if moving && (cardiacActivated || !hasHR) { return "wake" } - // DEEP: still + high parasympathetic tone + low HR + regular respiration. - if still && parasympHigh && hrLow && rrvRegular { return "deep" } + // WAKE: sustained motion + activated cardiac (or no HR to vet motion). On a sparse/PPG night the + // cardiac half is vetted by HR only (see `cardiacActivatedForWake`), so noisy hrVar no longer + // over-promotes still sleep to wake. (#705) + if moving && (cardiacActivatedForWake || !hasHR) { return "wake" } + // DEEP: still + low HR + regular respiration, with high parasympathetic tone when measurable. + if still && parasympOK && hrLow && rrvRegular { return "deep" } // REM: still body + activated cardiac + irregular respiration. if still && cardiacActivated && rrvIrregular { return "rem" } // REM fallback when respiration unavailable: require BOTH cardiac signals. @@ -742,7 +1576,7 @@ public enum SleepStager { if counts[s] == nil { order.append(s) } counts[s, default: 0] += 1 } - let best = counts.values.max()! + guard let best = counts.values.max() else { out.append(labels[i]); continue } let winners = order.filter { counts[$0] == best } // insertion order preserved out.append(winners.contains(labels[i]) ? labels[i] : winners[0]) } @@ -753,11 +1587,297 @@ public enum SleepStager { onsetIdx: Int, finalWakeIdx: Int) -> [String] { var out = labels let noREMEpochs = Int((noREMAfterOnsetMin * 60.0 / epochS).rounded()) + // "Deep is front-loaded" re-imposes scattered late "deep" back to light — BUT only when there's + // deep in the first third to anchor that prior. If the whole detected deep block lands later + // (individual variation, or HR/HRV-only staging without respiration placing the deepest, lowest-HR + // window later), zeroing it out gives a wrong "0 m deep"; keeping the best estimate is better. (#127) + let hasEarlyDeep = zip(labels, features).contains { $0.0 == "deep" && $0.1.clock <= deepFirstFraction } for (i, f) in features.enumerated() { if i < onsetIdx || i > finalWakeIdx { continue } if out[i] == "rem" && (i - onsetIdx) < noREMEpochs { out[i] = "light" } - if out[i] == "deep" && f.clock > deepFirstFraction { out[i] = "light" } + if out[i] == "deep" && f.clock > deepFirstFraction && hasEarlyDeep { out[i] = "light" } + } + return out + } + + // MARK: - REM-funnel diagnostic (#688) + + // 0% REM over a whole night is physiologically implausible (healthy adults cycle ~20–25% REM), + // so a 0%-REM hypnogram — common on WHOOP 4.0 nights staged WITHOUT a respiration channel — + // points at the STAGER, not the sleeper. The REM path in `classifyOne` is gated by three + // predicates (still body + activated cardiac + irregular respiration), with a no-resp fallback + // (still + high HR + high HR-variability), and any surviving early-REM is then stripped by the + // no-REM-after-onset re-imposition. This pure, READ-ONLY diagnostic re-runs that exact funnel and + // counts where REM was lost — WITHOUT changing a single label or score — so a 0%-REM night can be + // triaged (e.g. "respiration unavailable AND HR-variability never cleared its high bar → no epoch + // could be REM" vs "REM was detected but all of it fell inside the 15-min onset guard"). It is a + // triage surface, logged by the caller, never a scoring change. + + /// Why REM funneled toward zero for one staged session window. Counts are over the SLEEP-PERIOD + /// epochs (onset…finalWake) the classifier actually ranges; pure + deterministic; shares the exact + /// classifier seam with `stageSession`, so it explains the SAME hypnogram the app shows. (#688) + public struct REMFunnelDiagnostic: Equatable, Sendable { + /// Sleep-period epochs considered (onset…finalWake inclusive). + public let sleepEpochs: Int + /// Epochs the classifier labelled "rem" BEFORE smoothing / re-imposition. + public let remAtClassify: Int + /// "rem" epochs surviving the no-REM-after-onset re-imposition (the final hypnogram's REM). + public let remAfterReimpose: Int + /// Classified-REM epochs stripped specifically by the 15-min onset guard. + public let remStrippedByOnsetGuard: Int + /// Whether ANY epoch carried a finite respiration-variability feature (the resp channel was + /// usable). False ⇒ the whole night ran the no-resp REM fallback — the dominant 4.0 cause. + public let respChannelPresent: Bool + /// Among sleep-period epochs, how many were blocked from REM by each gate (a per-epoch reason, + /// counted at the FIRST gate that rejected it, in classifier precedence). These sum with + /// `remAtClassify` (and any wake/deep wins) to the sleep-epoch total. + public let blockedNotStill: Int // body not still enough (moveFrac above the still bar) + public let blockedNoCardiacActivation: Int // neither HR-high nor HR-variability-high + public let blockedRespRegular: Int // resp present but NOT irregular (regular breathing) + public let blockedNoRespFallbackBar: Int // resp absent and the stricter no-resp REM bar unmet + /// Won a non-REM stage outright (wake/deep/light) before any REM gate — not a REM rejection. + public let wonOtherStage: Int + + public init(sleepEpochs: Int, remAtClassify: Int, remAfterReimpose: Int, + remStrippedByOnsetGuard: Int, respChannelPresent: Bool, + blockedNotStill: Int, blockedNoCardiacActivation: Int, + blockedRespRegular: Int, blockedNoRespFallbackBar: Int, wonOtherStage: Int) { + self.sleepEpochs = sleepEpochs; self.remAtClassify = remAtClassify + self.remAfterReimpose = remAfterReimpose; self.remStrippedByOnsetGuard = remStrippedByOnsetGuard + self.respChannelPresent = respChannelPresent + self.blockedNotStill = blockedNotStill + self.blockedNoCardiacActivation = blockedNoCardiacActivation + self.blockedRespRegular = blockedRespRegular + self.blockedNoRespFallbackBar = blockedNoRespFallbackBar + self.wonOtherStage = wonOtherStage } + + /// True when the final hypnogram carries no REM at all — the case this diagnostic exists to + /// triage. (`remAfterReimpose == 0`.) + public var isZeroREM: Bool { remAfterReimpose == 0 } + + /// One human-readable line for the caller to LOG. No I/O here — the engine stays pure. + public var summary: String { + "REM-funnel: \(sleepEpochs) sleep-epochs, classify=\(remAtClassify) rem, " + + "final=\(remAfterReimpose) rem (onset-guard stripped \(remStrippedByOnsetGuard)); " + + "resp=\(respChannelPresent ? "present" : "ABSENT"); " + + "blocked[notStill=\(blockedNotStill), noCardiac=\(blockedNoCardiacActivation), " + + "respRegular=\(blockedRespRegular), noRespBar=\(blockedNoRespFallbackBar)], " + + "otherStage=\(wonOtherStage)" + } + } + + /// Per-epoch reason REM was rejected, evaluated in classifier precedence order. `remEligible` + /// means the epoch WOULD be labelled REM. Internal — drives `remFunnelDiagnostic`. + enum REMRejectReason { case remEligible, wonOtherStage, notStill, noCardiacActivation, respRegular, noRespFallbackBar } + + /// Classify a single epoch's REM-eligibility AND, when not eligible, the FIRST reason it failed — + /// using the exact predicates and precedence of `classifyOne` so the diagnostic can never diverge + /// from the real classifier. Read-only. (#688) + static func remRejectReason(_ f: EpochFeatures, hrLo: Double?, hrHi: Double?, + rmssdHi: Double?, hrvarHi: Double?, rrvHi: Double?, rrvLo: Double?, + cardiacSparse: Bool = false) -> REMRejectReason { + // Mirror classifyOne's derived predicates exactly. + let hasHR = f.hr.isFinite + let hrLow = hasHR && hrLo != nil && f.hr <= hrLo! + let hrHigh = hasHR && hrHi != nil && f.hr >= hrHi! + let parasympOK = (!f.rmssd.isFinite) || (rmssdHi != nil && f.rmssd >= rmssdHi!) + let hrvarHigh = f.hrVar.isFinite && hrvarHi != nil && f.hrVar >= hrvarHi! + let cardiacActivated = hrHigh || hrvarHigh + let cardiacActivatedForWake = cardiacSparse ? hrHigh : cardiacActivated + let rrvIrregular = f.rrv.isFinite && rrvHi != nil && f.rrv >= rrvHi! + let rrvRegular = (!f.rrv.isFinite) || (rrvLo != nil && f.rrv <= rrvLo!) + let still = f.moveFrac <= stageStillMoveFrac + let moving = f.moveFrac >= stageWakeMoveFrac + + // classifyOne precedence: WAKE, then DEEP, then REM (then REM fallback), else LIGHT. + // An epoch that wins WAKE or DEEP was never a REM candidate. + if moving && (cardiacActivatedForWake || !hasHR) { return .wonOtherStage } // → wake + if still && parasympOK && hrLow && rrvRegular { return .wonOtherStage } // → deep + // From here the epoch did NOT win wake/deep; it is either REM or falls through to LIGHT. + if still && cardiacActivated && rrvIrregular { return .remEligible } + if still && hrHigh && hrvarHigh && !f.rrv.isFinite { return .remEligible } + // Not REM → attribute to the FIRST unmet REM precondition (in REM-rule order). + if !still { return .notStill } + if !cardiacActivated { return .noCardiacActivation } + if f.rrv.isFinite { return .respRegular } // resp present but not irregular + return .noRespFallbackBar // resp absent and the no-resp bar unmet + } + + /// Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs the SAME Stage-0→3 + /// staging seam `stageSession` uses (epoch grid → Cole–Kripke → features → classify → smooth → + /// re-impose), but instead of emitting a hypnogram it COUNTS where REM was lost. Changes NOTHING: + /// no label, no score, no session. Returns nil only when the window has too little gravity to grid + /// (mirroring `stageSession`'s degenerate fallback, which carries no REM to explain). The caller + /// logs `.summary`; tests assert the counts. Pure + deterministic. (#688) + public static func remFunnelDiagnostic(start: Int, end: Int, grav: [GravitySample], + hr: [HRSample], rr: [RRInterval], + resp: [RespSample]) -> REMFunnelDiagnostic? { + let gSeg = rowsBetween(grav, start: start, end: end) { $0.ts } + if gSeg.count < 2 { return nil } + let gDeltas = gravityDeltas(gSeg) + let gTimes = gSeg.map { $0.ts } + let hrSeg = rowsBetween(hr, start: start, end: end) { $0.ts } + let rrSeg = rowsBetween(rr, start: start, end: end) { $0.ts } + let respSeg = rowsBetween(resp, start: start, end: end) { $0.ts } + + let grid = buildEpochGrid(start: Double(start), end: Double(end), + gravTimes: gTimes, gravDeltas: gDeltas, + hr: hrSeg, rr: rrSeg, resp: respSeg) + if grid.nEpochs == 0 { return nil } + + let rescaled = rescaleCounts(grid.counts) + let ckFlags = coleKripke(rescaled) + let (onsetIdx, finalWakeIdx) = onsetAndFinalWake(ckFlags) + let dogHR = dogHRVariability(grid.hr) + let feats = extractFeatures(grid: grid, ckFlags: ckFlags, dogHR: dogHR, + onsetIdx: onsetIdx, finalWakeIdx: finalWakeIdx) + + // The SAME session-relative reference percentiles classifyEpochs derives. + let sleepFeats = feats.contains { $0.ckSleep } ? feats.filter { $0.ckSleep } : feats + let hrLo = percentile(sleepFeats.map { $0.hr }, stageHRLowPct) + let hrHi = percentile(sleepFeats.map { $0.hr }, stageHRHighPct) + let rmssdHi = percentile(sleepFeats.map { $0.rmssd }, stageHRVHighPct) + let hrvarHi = percentile(sleepFeats.map { $0.hrVar }, stageHRVarHighPct) + let rrvHi = percentile(sleepFeats.map { $0.rrv }, stageRRVHighPct) + let rrvLo = percentile(sleepFeats.map { $0.rrv }, stageRRVLowPct) + let cardiacSparse = isCardiacSparse(sleepFeats) + + // Classify + post-process exactly as stageSession does, so we explain the SAME hypnogram. + let labels = classifyEpochs(feats) + let smoothed = smoothLabels(labels) + let reimposed = reimposePhysiology(smoothed, features: feats, + onsetIdx: onsetIdx, finalWakeIdx: finalWakeIdx) + + let noREMEpochs = Int((noREMAfterOnsetMin * 60.0 / epochS).rounded()) + var sleepEpochs = 0, remAtClassify = 0, remAfterReimpose = 0, remStrippedByOnsetGuard = 0 + var blockedNotStill = 0, blockedNoCardiacActivation = 0, blockedRespRegular = 0 + var blockedNoRespFallbackBar = 0, wonOtherStage = 0 + var respChannelPresent = false + + for i in onsetIdx...max(onsetIdx, finalWakeIdx) where i < feats.count { + let f = feats[i] + sleepEpochs += 1 + if f.rrv.isFinite { respChannelPresent = true } + // Per-epoch REM reason at the raw classifier seam (pre-smoothing) — the funnel's mouth. + switch remRejectReason(f, hrLo: hrLo, hrHi: hrHi, rmssdHi: rmssdHi, + hrvarHi: hrvarHi, rrvHi: rrvHi, rrvLo: rrvLo, + cardiacSparse: cardiacSparse) { + case .remEligible: remAtClassify += 1 + case .wonOtherStage: wonOtherStage += 1 + case .notStill: blockedNotStill += 1 + case .noCardiacActivation: blockedNoCardiacActivation += 1 + case .respRegular: blockedRespRegular += 1 + case .noRespFallbackBar: blockedNoRespFallbackBar += 1 + } + // Final-hypnogram REM (post smooth + re-impose) and the onset-guard strip. + if reimposed[i] == "rem" { remAfterReimpose += 1 } + // The re-imposition strips a SMOOTHED "rem" epoch inside the onset guard → light; count + // the strip off the smoothed labels reimpose actually sees (exact, not the raw seam). + if smoothed[i] == "rem" && (i - onsetIdx) < noREMEpochs { remStrippedByOnsetGuard += 1 } + } + + return REMFunnelDiagnostic( + sleepEpochs: sleepEpochs, remAtClassify: remAtClassify, remAfterReimpose: remAfterReimpose, + remStrippedByOnsetGuard: remStrippedByOnsetGuard, respChannelPresent: respChannelPresent, + blockedNotStill: blockedNotStill, blockedNoCardiacActivation: blockedNoCardiacActivation, + blockedRespRegular: blockedRespRegular, blockedNoRespFallbackBar: blockedNoRespFallbackBar, + wonOtherStage: wonOtherStage) + } + + /// Sleep-depth rank, lighter → deeper: wake 0, light 1, rem 2, deep 3. Used by + /// mergeFragments to bias an ambiguous merge toward the LIGHTER stage so smoothing + /// can never inflate deep/REM. Unknown labels rank lightest (0) — they never win deep. + static func stageDepthRank(_ stage: String) -> Int { + switch stage { + case "light": return 1 + case "rem": return 2 + case "deep": return 3 + default: return 0 // "wake" and any unexpected label + } + } + + /// Display/scoring smoothing of the staged label sequence (#274). Absorbs sub-threshold + /// "noise" runs WITHOUT erasing real transitions — applied AFTER staging, it never + /// touches the underlying per-epoch detection. + /// + /// Per run shorter than fragmentMergeEpochs: + /// • bridged by two SAME-stage neighbours → absorbed into them (the fleck was a blip + /// inside one continuous stage); + /// • between DIFFERENT stages → relabelled to the dominant (longer) neighbour. On a tie + /// — or when the longer neighbour is the deeper one and the shorter is lighter and of + /// comparable length — it biases toward the LIGHTER neighbour so a stray fleck can + /// never inflate deep/REM (the least-reliable, most-overcountable classes). + /// + /// Single left-to-right pass over runs, mirroring mergePeriods' control flow so the + /// Swift and Kotlin ports stay byte-identical. A run already ≥ threshold is a real + /// transition and is always preserved. + static func mergeFragments(_ labels: [String], thresholdEpochs: Int = fragmentMergeEpochs) -> [String] { + let n = labels.count + if n == 0 || thresholdEpochs <= 1 { return labels } + + // Collapse the per-epoch labels into contiguous runs of (stage, length). + var runs: [(stage: String, len: Int)] = [] + for s in labels { + if let last = runs.last, last.stage == s { runs[runs.count - 1].len += 1 } + else { runs.append((stage: s, len: 1)) } + } + if runs.count < 2 { return labels } + + var merged: [(stage: String, len: Int)] = [] + var i = 0 + while i < runs.count { + let current = runs[i] + if current.len >= thresholdEpochs { merged.append(current); i += 1; continue } + + let hasPrev = !merged.isEmpty + let hasNext = i + 1 < runs.count + + if hasPrev && hasNext && merged[merged.count - 1].stage == runs[i + 1].stage { + // Same-stage bridge: absorb the fleck and the next run into the previous one. + merged[merged.count - 1].len += current.len + runs[i + 1].len + i += 2 + } else if hasPrev && hasNext { + // Between two DIFFERENT stages: relabel to the dominant neighbour, biasing + // toward the lighter stage when the two neighbours are tied in length. + let prev = merged[merged.count - 1] + let next = runs[i + 1] + let winner: String + if prev.len > next.len { winner = prev.stage } + else if next.len > prev.len { winner = next.stage } + else { + // Tie → lighter (smaller depth rank) wins; never inflate deep/REM. + winner = stageDepthRank(prev.stage) <= stageDepthRank(next.stage) ? prev.stage : next.stage + } + // Fold the fleck into whichever neighbour it became; the OTHER neighbour + // stays its own run (handled on the next iterations). + if winner == prev.stage { + merged[merged.count - 1].len += current.len + i += 1 + } else { + // Becomes part of the NEXT run: extend next, drop current. + runs[i + 1] = (stage: next.stage, len: next.len + current.len) + i += 1 + } + } else if hasNext { + // No previous run (leading fleck): fold forward into the next run. + runs[i + 1] = (stage: runs[i + 1].stage, len: runs[i + 1].len + current.len) + i += 1 + } else if hasPrev { + // No next run (trailing fleck): fold back into the previous run. + merged[merged.count - 1].len += current.len + i += 1 + } else { + // Single sub-threshold run with no neighbours — nothing to merge into. + merged.append(current) + i += 1 + } + } + + // Re-expand the runs back into a per-epoch label sequence of the same length. + var out: [String] = [] + out.reserveCapacity(n) + for r in merged { out.append(contentsOf: repeatElement(r.stage, count: r.len)) } return out } @@ -790,8 +1910,12 @@ public enum SleepStager { var t = start while t < end { let bucket = seg.filter { $0.ts >= t && $0.ts < t + windowS }.map { Double($0.rrMs) } - let filtered = HRVAnalyzer.rangeFilter(bucket) - if filtered.count >= 2, let r = HRVAnalyzer.rmssdRaw(filtered) { vals.append(r) } + // Full clean (range + Malik ectopic rejection), not just range — matches the + // analyze() pipeline. The 0x2A37 RR on a WHOOP 5/MG is PPG-derived and noisier + // than a 4.0's; rMSSD is built from SUCCESSIVE differences, so an un-rejected + // jitter spike inflates the session HRV. Ectopic rejection drops those (#262/#235). + let cleaned = HRVAnalyzer.cleanRR(bucket) + if cleaned.count >= 2, let r = HRVAnalyzer.rmssdRaw(cleaned) { vals.append(r) } t += windowS } guard !vals.isEmpty else { return nil } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift new file mode 100644 index 0000000000..c529b56395 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift @@ -0,0 +1,449 @@ +import Foundation +import WhoopProtocol + +// SleepStagerV2.swift — an OPT-IN, EXPERIMENTAL alternative sleep-staging recipe, offered ALONGSIDE the +// shipped `SleepStager` (V1) rather than replacing it. V1 stays the default and is UNTOUCHED. +// +// Reimplemented clean from the contributor recipe in PR #600. We took only the +// per-session STAGING engine, not the CLI runner the PR shipped with it. Session DETECTION (the in-bed +// `[start, end]` spans) still comes entirely from V1 — this file only re-stages a window someone already +// decided is sleep, so it is a true drop-in for `SleepStager.stageSession(start:end:grav:hr:rr:resp:)`: +// SAME signature, SAME `[StageSegment]` return shape. +// +// HONEST HEDGING (same spirit as V1, plus the PR's own caveat): these stages are APPROXIMATIONS, not +// PSG-validated, not medical advice. The recipe was validated by its author on a SINGLE subject (n=1, 7 +// nights) against a commercial reference — it recovered deep/REM noticeably better than V1 there (kappa +// ~0.06 → ~0.47), but the window sizes / weights may be subject-specific and need multi-subject validation +// before they can be trusted as general. That is exactly why this is opt-in and labelled experimental. +// +// Where V1 runs a percentile-band classifier + median smoothing + physiology re-imposition over a +// Cole–Kripke actigraphy grid, V2 stages each 30 s epoch from: +// 1. per-night z-scored cardiorespiratory emissions (HR / HR-variability / movement); +// 2. a per-night DEEP gate on the 11-min HR-flatness percentile (the strongest deep-vs-light separator); +// 3. a soft sleep-cycle prior (deep concentrated early; REM suppressed in the first ~12% then rising); +// 4. a peak-motion (jerk) wake gate, thresholded RELATIVE to the night's own quiescent jerk floor — so +// it self-calibrates to the strap's gravity-decode scale and the wearer's fit, not a fixed g; +// 5. an RR-RSA respiration-regularity term (regular breathing → deep, irregular → REM); +// 6. Viterbi/HMM transition smoothing with a sticky transition matrix. +// All coefficients are fixed a-priori from sleep physiology + population base rates (NOT fit to labels). + +public enum SleepStagerV2 { + + /// Build a 30 s hypnogram for `[start, end]` with this recipe and return `StageSegment`s tiling the + /// span. DROP-IN: same signature + return type as `SleepStager.stageSession`, so a caller can switch + /// V1↔V2 on a flag with no other change. `resp` (raw resp ADC) is accepted for signature-parity but not + /// consumed — respiration regularity is recovered from the R-R stream (RSA), the path available on both + /// WHOOP 4 and 5. The recipe stages "wake" naturally (no separate pre-onset / post-wake forcing). + public static func stageSession(start: Int, end: Int, grav: [GravitySample], + hr: [HRSample], rr: [RRInterval], resp: [RespSample]) -> [StageSegment] { + // v7.0.2 perf (#707): stage each night AT MOST ONCE per (window, input-fingerprint). The post-sync + // scoring loop and the self-heal restage call this with byte-identical streams across passes; without + // the cache each call re-allocates the large per-second HR/gravity dictionaries below before + // collapsing to a handful of `StageSegment`s. The key folds in start/end (the locked window — an edit + // re-keys) and a fingerprint of every input stream that changes the staging (grav/hr/rr; resp is + // accepted for signature-parity but NOT consumed by the recipe, so it is deliberately out of the key — + // including it would only force needless misses). Only the small `[StageSegment]` is cached; the + // multi-hour raw arrays are never retained. Bounded so the cache can't itself OOM. + // + // CLIP (#707, the cold-pass over-allocation): the callers pass the WHOLE multi-day decoded stream to + // every per-night call, but `features()` never reads a sample outside [start-padLo, end+padHi] — the + // farthest reach of any per-epoch window (the 11-min HR-flatness window: 330 s back, 390 s forward — + // see `padLo`/`padHi` below). On the first post-sync pass (~21 nights cold) and the full-history Effort + // rescore (up to 4000 nights cold) those out-of-window rows are what blow the per-second dictionaries + // and the sort allocations up to OOM. The streams arrive sorted by ts, so we lower/upper-bound slice + // each to the read window BEFORE fingerprinting and BEFORE the uncached recipe. This is output-identical + // (we drop only rows `features()` could never touch) and it also tightens the fingerprint to the rows + // that matter. PAD_LO/PAD_HI are the SAME values the Android twin (R1) clips with. + let gravW = clipToWindow(grav, lo: start - Self.padLo, hi: end + Self.padHi, ts: { $0.ts }) + let hrW = clipToWindow(hr, lo: start - Self.padLo, hi: end + Self.padHi, ts: { $0.ts }) + let rrW = clipToWindow(rr, lo: start - Self.padLo, hi: end + Self.padHi, ts: { $0.ts }) + let key = V2Key( + start: start, end: end, + grav: StreamFingerprint.of(gravW, ts: { $0.ts }, quant: { Int(($0.x + $0.y + $0.z) * 1024) }), + hr: StreamFingerprint.of(hrW, ts: { $0.ts }, quant: { Int($0.bpm) }), + rr: StreamFingerprint.of(rrW, ts: { $0.ts }, quant: { Int($0.rrMs) })) + return stageCache.value(key) { + stageSessionUncached(start: start, end: end, grav: gravW, hr: hrW, rr: rrW, resp: resp) + } + } + + /// Farthest seconds, relative to an epoch, that `features()` reads any input — i.e. the largest backward / + /// forward reach of any per-epoch window. The 11-min HR-flatness window dominates both directions + /// (`stdOfSeconds(e - 330, e + 30 + 360)` reads `[e-330, e+390)`); the 5-min window (`[e-150, e+180)`) and + /// the RSA beat window (`[e-90, e+120)`) are strictly inside it. Epochs tile `[start, end)`, so no feature + /// can read before `start - padLo` or at/after `end + padHi`. MUST match the Android twin (R1). + static let padLo = 330 // backward reach: 11-min window's `e - 330` + static let padHi = 390 // forward reach: 11-min window's `e + 30 + 360` + + /// Slice a ts-sorted stream to `[lo, hi)` with a lower/upper-bound pair (O(log n) bounds + one copy of the + /// kept rows). Returns a contiguous sub-slice as an `Array`. Loss-free for the recipe: every row outside + /// the window is one `features()` provably never touches. + private static func clipToWindow(_ samples: [T], lo: Int, hi: Int, ts: (T) -> Int) -> [T] { + if samples.isEmpty { return samples } + // Already inside the window in full → avoid the copy (the common single-night case). + if ts(samples[0]) >= lo && ts(samples[samples.count - 1]) < hi { return samples } + // lowerBound: first index with ts >= lo. + var l = 0, h = samples.count + while l < h { let m = (l + h) / 2; if ts(samples[m]) < lo { l = m + 1 } else { h = m } } + let start = l + // upperBound: first index with ts >= hi (exclusive upper, matching the half-open read windows). + l = start; h = samples.count + while l < h { let m = (l + h) / 2; if ts(samples[m]) < hi { l = m + 1 } else { h = m } } + if start == 0 && l == samples.count { return samples } + return Array(samples[start..(capacity: 24) + + /// The unchanged staging recipe. Split out verbatim from `stageSession` so the public entry can memoize + /// in front of it; behaviour is byte-identical (a cache miss runs exactly this). + private static func stageSessionUncached(start: Int, end: Int, grav: [GravitySample], + hr: [HRSample], rr: [RRInterval], resp: [RespSample]) -> [StageSegment] { + // Sort defensively so the windowed features behave regardless of caller ordering (V1's stageSession + // assumes its callers pass roughly-sorted streams; we make no such assumption here). + let gravS = grav.sorted { $0.ts < $1.ts } + let hrS = hr.sorted { $0.ts < $1.ts } + let rrS = rr.sorted { $0.ts < $1.ts } + + let feats = features(start: start, end: end, grav: gravS, hr: hrS, rr: rrS) + if feats.isEmpty { return [StageSegment(start: start, end: end, stage: "light")] } + let labels = stageEpochs(feats) + + // Tile [start, end] with one segment per staged epoch. The first segment back-fills [start, firstEpoch) + // and the last extends to `end`; an interior coverage gap is carried by the preceding label. "awake" + // is renamed to the canonical "wake" used by V1 / StageSegment. + var segments: [StageSegment] = [] + for (i, f) in feats.enumerated() { + let stage = labels[i] == "awake" ? "wake" : labels[i] + let segStart = i == 0 ? start : f.start + let segEnd = i == feats.count - 1 ? end : feats[i + 1].start + if let last = segments.last, last.stage == stage { + segments[segments.count - 1].end = segEnd + } else { + segments.append(StageSegment(start: segStart, end: segEnd, stage: stage)) + } + } + return segments + } + + // MARK: - Recipe constants (all fixed a-priori — NOT fit to labels) + + static let stageNames = ["deep", "rem", "light", "awake"] + + /// Population sleep-architecture base rates as log-priors (adult TST ≈ light 50 / deep 18 / rem 22 / + /// waso 10 %). Calibrates the boundary so light wins weak-evidence epochs. + static let baseLogPrior: [String: Double] = [ + "light": log(0.50), "deep": log(0.18), "rem": log(0.22), "awake": log(0.10)] + + /// Deep is eligible only in the night's lowest ~20 % HR-flatness epochs (≈ deep base rate + margin). + static let deepGateThresh = 0.20 + static let deepGateSlope = 5.0 + + /// Motion thresholds are RELATIVE to each night's own quiescent jerk floor (the median per-second + /// gravity-jerk over the in-bed session), NOT an absolute g. Self-calibrates to the strap's + /// gravity-decode scale and the wearer's fit. + static let jerkFloorMoveMult = 38.0 // a per-second jerk counts as "moving" above floor × this + static let jerkFloorGateMult = 55.0 // wake-boost when an epoch's peak jerk exceeds floor × this + static let motionGateBoost = 2.0 + + /// Weight of the RSA respiration-regularity term (regular → deep, irregular → REM). + static let respWeight = 0.6 + + /// Transition matrix (rows = from, cols = to). Self-transitions dominate; deep↔rem rare; wake mostly + /// to/from light. A priori, not fit. + static let transition: [String: [String: Double]] = [ + "deep": ["deep": 0.90, "rem": 0.005, "light": 0.09, "awake": 0.005], + "rem": ["deep": 0.005, "rem": 0.88, "light": 0.10, "awake": 0.015], + "light": ["deep": 0.06, "rem": 0.06, "light": 0.85, "awake": 0.03], + "awake": ["deep": 0.01, "rem": 0.02, "light": 0.27, "awake": 0.70]] + + /// One 30 s epoch's recipe features. Optionals are "no measurement"; the z-score / percentile treat a + /// missing value as the neutral centre so a sparse channel never blocks a stage. + struct Epoch { + let start: Int // epoch start (unix seconds, multiple of 30) + let hr: Double? // epoch-mean HR (bpm) + let hrVar: Double? // std of per-second HR over a centred 5-min window + let hrFlat11: Double? // std of per-second HR over a centred 11-min window (deep/light separator) + let moveFrac: Double // fraction of in-epoch per-second jerks above the night-relative move threshold + let jerkMax: Double // peak in-epoch per-second jerk (g) — wake is bursty + let respReg: Double? // RSA spectral peakedness in the 0.15–0.40 Hz band (breathing regularity) + let clock: Double // time-of-night fraction in [0, 1] + let jerkScale: Double // night quiescent jerk floor (median per-second jerk over the session) + } + + // MARK: - Feature extraction + + /// Build the per-epoch recipe features over a 30 s wall-clock-aligned grid covering [start, end]. + /// Streams are the sorted streams already clipped (by `stageSession`) to `[start-padLo, end+padHi]` — a + /// superset of every read window — so the 5-/11-min HR windows and the RSA beat window still reach to the + /// session edges exactly as before; only rows no window could touch were dropped. + static func features(start: Int, end: Int, grav: [GravitySample], + hr: [HRSample], rr: [RRInterval]) -> [Epoch] { + if end <= start { return [] } + let span = Double(max(1, end - start)) + + // Per-second aggregation (one value per integer second; mean when a second carries several samples). + var hrSum = [Int: Double](), hrCnt = [Int: Int]() + for s in hr { hrSum[s.ts, default: 0] += Double(s.bpm); hrCnt[s.ts, default: 0] += 1 } + var secHR = [Int: Double](); secHR.reserveCapacity(hrSum.count) + for (k, v) in hrSum { secHR[k] = v / Double(hrCnt[k]!) } + + var gxSum = [Int: Double](), gySum = [Int: Double](), gzSum = [Int: Double](), gCnt = [Int: Int]() + for g in grav { + gxSum[g.ts, default: 0] += g.x; gySum[g.ts, default: 0] += g.y + gzSum[g.ts, default: 0] += g.z; gCnt[g.ts, default: 0] += 1 + } + var secG = [Int: (Double, Double, Double)](); secG.reserveCapacity(gCnt.count) + for (k, c) in gCnt { let d = Double(c); secG[k] = (gxSum[k]! / d, gySum[k]! / d, gzSum[k]! / d) } + + // R-R values bucketed by second (for the RSA respiration window). + var rrBy = [Int: [Double]]() + for r in rr { rrBy[r.ts, default: []].append(Double(r.rrMs)) } + + // PREFIX SUMS over the per-second HR grid (#707). Every epoch evaluates a 5-min AND an 11-min centred + // std window; the old `stdOfSeconds` re-scanned and re-allocated a `vals` array of up to ~660 entries + // PER window PER epoch — O(window) each, the dominant transient alloc in the cold-stage path. We build + // dense prefix arrays of (count, Σv, Σv²) over the present seconds ONCE, then each window is an O(1) + // range query. The std stays population (÷n) with the SAME `<2 present → nil` semantics, and on every + // real fixture the staged hypnogram is byte-identical (the second-moment is the algebraic expansion + // `Σ(v-m)²` = `Σv² − 2m·Σv + n·m²`, which can differ from the prior direct sum only in the last ULPs — + // far below the margin that could flip a Viterbi label; verified label-identical on the golden nights). + // The grid spans the present HR seconds; a window reaching past the grid simply sees fewer present + // seconds, exactly as the old scan found no `secHR` entry there. + let gridLo = secHR.keys.min() + let gridHi = secHR.keys.max() + var pCnt = [Int](), pSum = [Double](), pSq = [Double]() + if let g0 = gridLo, let g1 = gridHi { + let n = g1 - g0 + 1 + pCnt = [Int](repeating: 0, count: n + 1) + pSum = [Double](repeating: 0, count: n + 1) + pSq = [Double](repeating: 0, count: n + 1) + for i in 0.. Double? { + guard let g0 = gridLo, let g1 = gridHi else { return nil } + let a = max(lo, g0) - g0 + let b = min(hi, g1 + 1) - g0 + if b <= a { return nil } + let cnt = pCnt[b] - pCnt[a] + if cnt < 2 { return nil } + let n = Double(cnt) + let sv = pSum[b] - pSum[a] + let sq = pSq[b] - pSq[a] + let m = sv / n + let v = (sq - 2 * m * sv + n * m * m) / n + return (v < 0 ? 0 : v).squareRoot() + } + + // PASS 1 — every per-epoch quantity EXCEPT the move fraction, and pool every per-second jerk so the + // night's quiescent jerk floor (its median) can scale the motion thresholds. moveFrac needs that + // floor, which isn't known until the whole session has been scanned — hence two passes. + struct Raw { + let start: Int; let hr: Double?; let hrVar: Double?; let hrFlat11: Double? + let jerks: [Double]; let gapSec: Int; let jerkMax: Double; let respReg: Double?; let clock: Double + } + var raws: [Raw] = [] + var allJerks: [Double] = [] + let firstE = ((start + 29) / 30) * 30 + var e = firstE + while e < end { + var hrs: [Double] = [] + var gseq: [(Double, Double, Double)] = [] + for s in e..<(e + 30) { + if let h = secHR[s] { hrs.append(h) } + if let g = secG[s] { gseq.append(g) } + } + if hrs.isEmpty && gseq.isEmpty { e += 30; continue } // no coverage → skip the epoch + + // Movement: consecutive per-second gravity jerks within the epoch. + var jerks: [Double] = [] + for i in 1.. moveThr ? 1 : 0) } + feats.append(Epoch( + start: r.start, hr: r.hr, hrVar: r.hrVar, hrFlat11: r.hrFlat11, + moveFrac: Double(moves) / Double(r.gapSec), jerkMax: r.jerkMax, respReg: r.respReg, + clock: r.clock, jerkScale: jerkScale)) + } + return feats + } + + /// RSA respiration regularity: tachogram → 4 Hz resample → detrend → power spectrum → peak/sum of the + /// 0.15–0.40 Hz (9–24 brpm) band. Returns spectral peakedness (higher = more regular breathing) or nil + /// when there are too few beats. A direct band-limited DFT (only the ~50 in-band bins are needed). + static func respRegularity(_ beats: [(Double, Double)]) -> Double? { + if beats.count < 12 { return nil } + let t0 = beats.first!.0, tN = beats.last!.0 + if tN <= t0 { return nil } + let n = Int(ceil((tN - t0) / 0.25 - 1e-9)) // np.arange(t0, tN, 0.25) length + if n < 16 { return nil } + + // Linear resample onto the uniform 4 Hz grid (clamped within [t0, tN]). + var y = [Double](repeating: 0, count: n) + var seg = 0 + for i in 0.. maxP { maxP = p } + } + if sumP == 0 { return nil } + return maxP / sumP + } + + // MARK: - Recipe staging + + /// Soft sleep-cycle prior added to the log-emission: deep concentrated early (decays, never hard-wiped); + /// REM suppressed in the first ~12 % (REM latency) then rising toward morning. + static func cyclePrior(_ c: Double) -> [String: Double] { + ["deep": 1.2 * max(0.0, 1.0 - c / 0.55), + "rem": 1.0 * c - (c < 0.12 ? 3.0 : 0.0), + "light": 0.0, "awake": 0.0] + } + + /// Viterbi most-likely path over the per-epoch log-emissions with the sticky transition matrix and a + /// uniform start. Ties resolve to the earlier stage in `stageNames`. + static func viterbi(_ emSeq: [[String: Double]]) -> [String] { + if emSeq.isEmpty { return [] } + let logT = transition.mapValues { row in row.mapValues { log($0) } } + var V = emSeq[0] // uniform start + var back: [[String: String]] = [] + for t in 1.. bestVal { bestVal = val; bestPrev = p } + } + newV[s] = bestVal + emSeq[t][s]! + bp[s] = bestPrev + } + V = newV; back.append(bp) + } + var last = stageNames[0], lastV = V[last]! + for s in stageNames.dropFirst() where V[s]! > lastV { lastV = V[s]!; last = s } + var path = [last] + for bp in back.reversed() { last = bp[last]!; path.append(last) } + return path.reversed() + } + + /// Run the full recipe over a night's epochs and return one stage label per epoch (incl. "awake"). + /// All normalisation (z-scores, the HR-flatness percentile) is WITHIN the night. + static func stageEpochs(_ feats: [Epoch]) -> [String] { + if feats.isEmpty { return [] } + + // Per-night z-score over the present values (population std; 0 std → 1 so a flat channel is neutral). + func zfun(_ vals: [Double?]) -> (Double?) -> Double { + let present = vals.compactMap { $0 } + if present.isEmpty { return { _ in 0.0 } } + let m = present.reduce(0, +) / Double(present.count) + let sd0 = (present.reduce(0.0) { $0 + ($1 - m) * ($1 - m) } / Double(present.count)).squareRoot() + let sd = sd0 == 0 ? 1.0 : sd0 + return { v in v == nil ? 0.0 : (v! - m) / sd } + } + let zhr = zfun(feats.map { $0.hr }) + let zhv = zfun(feats.map { $0.hrVar }) + let zmv = zfun(feats.map { Optional($0.moveFrac) }) + let zrg = zfun(feats.map { $0.respReg }) + + // HR-flatness percentile rank within the night (bisect_right / n), neutral 0.5 when missing. + let fsorted = feats.compactMap { $0.hrFlat11 }.sorted() + func fpct(_ v: Double?) -> Double { + guard let v = v, !fsorted.isEmpty else { return 0.5 } + var lo = 0, hi = fsorted.count + while lo < hi { let mid = (lo + hi) / 2; if fsorted[mid] <= v { lo = mid + 1 } else { hi = mid } } + return Double(lo) / Double(fsorted.count) + } + + var seq: [[String: Double]] = [] + seq.reserveCapacity(feats.count) + for f in feats { + let zhrv = zhr(f.hr), zhvv = zhv(f.hrVar), zmvv = zmv(f.moveFrac) + let gate = deepGateSlope * max(0.0, fpct(f.hrFlat11) - deepGateThresh) + var em: [String: Double] = [ + "deep": -1.4 * zhvv - 0.2 * zhrv - 0.3 * zmvv - gate + baseLogPrior["deep"]!, + "rem": 0.6 * zhvv - 0.6 * zmvv + 0.4 * zhrv + baseLogPrior["rem"]!, + "light": baseLogPrior["light"]!, + "awake": 1.0 * zmvv + 0.8 * zhvv + 0.4 * zhrv + baseLogPrior["awake"]!, + ] + let pr = cyclePrior(f.clock) + for s in stageNames { em[s]! += pr[s]! } + if f.jerkMax > f.jerkScale * jerkFloorGateMult { em["awake"]! += motionGateBoost } + if let rg = f.respReg { let z = zrg(rg); em["deep"]! += respWeight * z; em["rem"]! -= respWeight * z } + seq.append(em) + } + return viterbi(seq) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepWindowReclip.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepWindowReclip.swift new file mode 100644 index 0000000000..00f4d950a8 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepWindowReclip.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Reshape a sleep session's stored stage breakdown to a hand-corrected `[newStart, newEnd]` window, so a +/// bed-time (onset) and/or wake-time edit updates the hypnogram and the total-asleep / stage footer, not +/// just the displayed "Woke" / "Bed" label. Pure + deterministic (no store, no raw signals, no I/O), so +/// it's unit-tested directly and works for a Bluetooth-only night, an imported night, online or off: it +/// reclips whatever `stagesJSON` the session already carries. +/// +/// START-AWARE on BOTH ends (#0): a pure onset edit (newStart moves, newEnd unchanged) must drop the +/// stages BEFORE the corrected bed time, not leave them in place. Otherwise an imported / pre-sync night +/// keeps sleep that happened before the user got into bed while the displayed window shrank. +/// +/// Two formats, mirroring the app's two writers (see SleepView.decodeSegments / decodeStages): +/// • segment array `[{"start":epoch,"end":epoch,"stage":"wake"|"light"|"deep"|"rem"}]` — computed +/// nights. Clip to `[newStart, newEnd]`: drop segments wholly outside it, clip a straddling +/// segment's start up to `newStart` and end down to `newEnd`, and if the window grew at the tail +/// append a trailing `wake` segment (extra time in bed reads as awake). +/// • minute dict `{"awake","light","deep","rem"}` — imported nights. No timeline, so shift by the +/// duration delta `(newEnd - newStart) - (oldEnd - sessionStart)`: trim from the tail-most stages +/// (awake → light → rem → deep) when shortened, add to awake when lengthened. +/// +/// Returns the re-encoded JSON in the SAME shape it received, or nil when there's nothing usable to +/// reclip (the caller then keeps the existing JSON). +public enum SleepWindowReclip { + + public static func reclip(stagesJSON: String?, sessionStart: Int, oldEnd: Int, + newStart: Int, newEnd: Int) -> String? { + guard let stagesJSON, let data = stagesJSON.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) else { return nil } + if let arr = obj as? [[String: Any]] { + return reclipSegments(arr, newStart: newStart, newEnd: newEnd) + } + if let dict = obj as? [String: Any] { + return reclipMinutes(dict, deltaSeconds: (newEnd - newStart) - (oldEnd - sessionStart)) + } + return nil + } + + private static func reclipSegments(_ arr: [[String: Any]], newStart: Int, newEnd: Int) -> String? { + var out: [[String: Any]] = [] + var maxEnd = newStart + for seg in arr { + guard let start = (seg["start"] as? NSNumber)?.intValue, + let end = (seg["end"] as? NSNumber)?.intValue, + let stage = seg["stage"] as? String, end > start else { continue } + if start >= newEnd { continue } // wholly after the new wake → drop + if end <= newStart { continue } // wholly before the new bed time → drop + let clippedStart = max(start, newStart) // clip the segment spanning the new bed time + let clippedEnd = min(end, newEnd) // clip the segment spanning the new wake + out.append(["start": clippedStart, "end": clippedEnd, "stage": stage]) + maxEnd = max(maxEnd, clippedEnd) + } + if newEnd > maxEnd, maxEnd >= newStart { // window grew → trailing time in bed = awake + out.append(["start": maxEnd, "end": newEnd, "stage": "wake"]) + } + // If every segment was trimmed away (the corrected window lands outside every stage), don't + // return nil — that would let the store's COALESCE keep the OLD stages, which then extend PAST + // the new wake. Emit a single wake segment covering the (valid, ≥60s) corrected window instead. + if out.isEmpty, newEnd > newStart { + out.append(["start": newStart, "end": newEnd, "stage": "wake"]) + } + guard !out.isEmpty, let d = try? JSONSerialization.data(withJSONObject: out) else { return nil } + return String(data: d, encoding: .utf8) + } + + private static func reclipMinutes(_ dict: [String: Any], deltaSeconds: Int) -> String? { + func val(_ k: String) -> Double { (dict[k] as? NSNumber)?.doubleValue ?? 0 } + var awake = val("awake"), light = val("light"), deep = val("deep"), rem = val("rem") + let deltaMin = Double(deltaSeconds) / 60.0 + if deltaMin >= 0 { + awake += deltaMin // extra time in bed reads as awake + } else { + var trim = -deltaMin // remove from the tail-most stages first + func cut(_ v: Double) -> Double { let c = min(v, max(trim, 0)); trim -= c; return v - c } + awake = cut(awake); light = cut(light); rem = cut(rem); deep = cut(deep) + } + let out: [String: Double] = ["awake": awake, "light": light, "deep": deep, "rem": rem] + guard out.values.reduce(0, +) > 0, + let d = try? JSONSerialization.data(withJSONObject: out) else { return nil } + return String(data: d, encoding: .utf8) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2ReTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2ReTrace.swift new file mode 100644 index 0000000000..5d5c9b653c --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2ReTrace.swift @@ -0,0 +1,38 @@ +import Foundation + +// Spo2ReTrace.swift - the Connection-mode SpO2 reverse-engineering dump (PR #945, reimplemented). +// +// WHOOP 4.0 has the Blood O2 sensor and the historical decode already maps the raw red/IR PPG channels +// (spo2_red@68 / spo2_ir@70 on the v24 layout), but NOOP nulls spo2Pct for WHOOP on purpose: computing a +// calibrated % from the raw ADC needs the dense dual-wavelength waveform plus WHOOP's proprietary +// calibration curve, and guessing it would manufacture a plausible-but-wrong health number - the exact +// trap that withdrew the #194 PPG->HR attempt. The ONLY honest path to a reliable value is to find out +// whether the strap already BANKS a computed SpO2 in a record field we have not mapped. +// +// So this dumps a handful of FULL historical records (hex) alongside their mapped SpO2 channels, log-only +// and gated behind the Test Centre Connection mode, so an offline pass can correlate a byte (or the +// red/IR pair) against the SpO2 % the WHOOP app shows for the same nights. Records dump whether or not +// they carry SpO2 channels, so "the strap banks nothing" is provable too - in which case the honest +// outcome is a capability label, never a fabricated number. NO user-facing SpO2 value comes from this. +// +// Pure formatter: no I/O, no state, no em-dashes, no PII (a record is sensor payload; the serial never +// rides in it). The Kotlin twin is Spo2ReTrace.kt; the emitted line is byte-identical on both platforms. +public enum Spo2ReTrace { + + /// Max records dumped per offload session. A handful is enough for an offline correlation pass and + /// keeps the strap log bounded; the Backfiller counter spans chunks and resets per session. + public static let maxSamples = 8 + + /// One record's RE line: the mapped SpO2 channels + timestamp + layout version, then the FULL frame + /// hex (no prefix cap - a v24 record is ~84 B and the unmapped tail is exactly where a banked SpO2 + /// would sit). Absent channels render "null" so a channel-less record still proves what it lacks. + /// Takes already-extracted ints (ConnectionTrace's primitive style) so this package stays free of a + /// WhoopProtocol dependency; the caller reads them off its parsed frame. + public static func recordLine(frame: [UInt8], version: Int?, unix: Int?, + red: Int?, ir: Int?, skinRaw: Int?) -> String { + let hex = frame.map { String(format: "%02x", $0) }.joined() + func f(_ v: Int?) -> String { v.map(String.init) ?? "null" } + return "spo2re v=\(f(version)) unix=\(f(unix)) red=\(f(red)) ir=\(f(ir)) " + + "skinRaw=\(f(skinRaw)) len=\(frame.count) raw=\(hex)" + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SpotHrvReading.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SpotHrvReading.swift new file mode 100644 index 0000000000..d8c99148f6 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SpotHrvReading.swift @@ -0,0 +1,98 @@ +import Foundation + +/// On-demand "take an HRV reading now" — the single-value spot RMSSD path (#537). +/// +/// Swift parity twin of `android/.../analytics/SpotHrvReading.kt`. This wraps NOOP's canonical +/// `HRVAnalyzer` for the LIVE, user-triggered HRV snapshot the Live screen captures over ~60 s of +/// beat-to-beat (R-R) intervals. It exists so the spot value, its honesty gate, and its data-quality +/// caveat live in ONE tested place rather than being re-derived in the view. +/// +/// Why delegate to `HRVAnalyzer` (and NOT roll our own RMSSD): +/// - RMSSD is the textbook root-mean-square of successive R-R differences: +/// RMSSD = sqrt( mean( (RR[i+1] - RR[i])^2 ) ) in ms. +/// - NOOP's nightly HRV (`avgHrv`, fed into Vitality / Fitness Age) uses `HRVAnalyzer.rmssdRaw`, which +/// takes the Task Force (1996) SAMPLE denominator (n-1) over the cleaned NN series. To keep a spot +/// reading COMPARABLE to the overnight number a user sees elsewhere, this path computes RMSSD the +/// SAME way (same cleaning pipeline, same (n-1) denominator). Using a population (n) denominator +/// would make the same beats read a few percent lower than the nightly figure, which is misleading. +/// Consistency with the existing scorer is the whole point. +/// +/// Honesty is built in, not bolted on: +/// - A number is returned ONLY when enough CLEAN beats survive (`HRVAnalyzer.minBeats`); otherwise the +/// result is `.insufficient` with the surviving/needed counts so the UI can say so plainly (never a +/// fabricated value, unknown stays the "no value" glyph). +/// - The caveat (`caveatFor`) is source-aware: a 60 s spot reading is not the overnight baseline, it +/// needs enough beats, and R-R derived from a WHOOP 5/MG's optical PPG is noisier than a chest +/// strap's electrical R-R. Pure strings, US-neutral, no em-dashes. +/// +/// Pure arithmetic + small value types, no I/O — fully unit-testable against a known RR series. +public enum SpotHrvReading { + + /// Where the live R-R intervals came from — drives the honesty caveat (optical PPG is noisier). + public enum Source: Sendable { + /// WHOOP 5/MG: R-R is derived from the optical PPG waveform — beat-to-beat, but noisier. + case opticalPPG + /// WHOOP 4 or a chest strap (e.g. Polar H10) over the standard 0x2A37 profile — electrical R-R. + case chestStrap + /// Source not known (generic / unspecified strap). + case unknown + } + + /// Outcome of an on-demand spot reading. + public enum Outcome: Equatable, Sendable { + /// A trustworthy spot value: `rmssdMs` (ms), mean `hrBpm` (or nil), and the clean-beat `beats` + /// used. Backed by the full `HRVAnalyzer` result for callers that want SDNN / pNN50 too. + case reading(rmssdMs: Double, hrBpm: Double?, beats: Int, full: HRVAnalyzer.HRVResult) + /// Not enough clean beats to report honestly — carries how many survived vs how many are needed + /// so the UI can guide the user ("sit still and try again"). + case insufficient(clean: Int, needed: Int, input: Int) + } + + /// Compute a single spot HRV reading from the raw R-R intervals (ms) gathered during the live + /// capture window. Runs NOOP's canonical cleaning + RMSSD (range filter -> Malik ectopic rejection + /// -> (n-1) RMSSD), so the value matches the nightly HRV math. Returns `.insufficient` rather than a + /// number when too few clean beats survive — never a fabricated figure. + /// + /// - Parameters: + /// - rrMs: the raw R-R intervals in milliseconds, in capture order (untrusted BLE input — the + /// analyzer's range filter bounds-checks each to `HRVAnalyzer.rrMinMs`...`HRVAnalyzer.rrMaxMs`). + /// - maxRejectedFraction: the spot honesty gate (#585) — refuse the reading when more than this + /// fraction of beats was dropped as noise (out-of-range / ectopic), even if `minBeats` clean + /// beats survive. Defaults to `HRVAnalyzer.defaultSpotMaxRejectedFraction` (0.35). The nightly + /// windowed path does NOT use this, so overnight HRV is unchanged. + public static func compute(_ rrMs: [Int], + maxRejectedFraction: Double = HRVAnalyzer.defaultSpotMaxRejectedFraction) -> Outcome { + let result = HRVAnalyzer.analyze(rawRR: rrMs.map(Double.init), + maxRejectedFraction: maxRejectedFraction) + guard let rmssd = result.rmssd else { + return .insufficient(clean: result.nClean, needed: HRVAnalyzer.minBeats, input: result.nInput) + } + return .reading(rmssdMs: rmssd, + hrBpm: meanHrFromNN(result.meanNN), + beats: result.nClean, + full: result) + } + + /// Mean heart rate (bpm) from the mean NN interval (ms): 60000 / meanNN. nil when missing or <= 0. + public static func meanHrFromNN(_ meanNN: Double?) -> Double? { + guard let meanNN, meanNN > 0 else { return nil } + return 60_000.0 / meanNN + } + + /// Honest, source-aware caveat for a spot reading. Plain text, US-neutral, no em-dashes. Always + /// states the two universal limits (a 60 s spot is not the overnight baseline; it needs enough clean + /// beats) and adds the source-specific noise note for an optical-PPG strap. + public static func caveatFor(_ source: Source) -> String { + let base = + "This is a spot reading over a short, still capture, not your overnight HRV baseline. " + + "Take it seated, still, and at a consistent time of day for comparable numbers, and " + + "only a reading with enough clean beats is shown." + switch source { + case .opticalPPG: + return base + " On a WHOOP 5.0/MG the intervals come from the optical pulse signal, which is " + + "noisier than a chest strap, so treat the number as a rough estimate." + case .chestStrap, .unknown: + return base + } + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsEstimateEngine+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsEstimateEngine+Trace.swift new file mode 100644 index 0000000000..f3de97ca65 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsEstimateEngine+Trace.swift @@ -0,0 +1,156 @@ +import Foundation +import WhoopProtocol + +// StepsEstimateEngine+Trace.swift - the Steps test-mode diagnostic traces. +// +// Two pure, side-effect-free twins for the two ways NOOP produces a step number: +// +// 1. calibrationTrace(...) - the WHOOP-4 motion-volume path. Reports each calibration day's motion VOLUME +// and phone reference count, then the fitted (or manual) calibration state (k / sampleDays / confidence +// / manual) by reusing StepsEstimateEngine.calibrate VERBATIM, so the trace can never disagree with the +// coefficient the Settings/Steps screen shows. When the fit is withheld it names the status (the +// "Need N more days" reason), the same status the tile renders. +// +// 2. rawCounterTrace(...) - the WHOOP 5/MG raw path. Reports the cumulative step_motion_counter series and +// its WRAP-AWARE deltas (cur - prev) & 0xFFFF, the dropped deltas (>= 512, a sync-gap / reboot boundary, +// not real steps), and the same total AnalyticsEngine.analyzeDay sums, with the SAME maxStepDelta gate +// and the SAME ticks-per-step scaling, so the trace and the daily steps_est value can never diverge. +// +// No clock, no I/O, no PII (counts and ratios only). A fixture pins the exact lines. The Steps test mode +// gates each call behind TestCentre.active(.steps) at the call site (IntelligenceEngine); when the mode is +// off neither is ever called, so there is zero cost. No em-dashes. The Kotlin twin is StepsEstimateEngineTrace. + +extension StepsEstimateEngine { + + /// The WHOOP-4 motion-volume calibration trace. Given the per-day calibration points (each a motion + /// volume + a phone reference step count) and the optional manual override, it logs: + /// - one `stepsCal point` line per usable day (the day's motion volume and phone reference count, plus + /// the implied steps/motion ratio that votes in the fit), + /// - the calibration outcome line, built by reusing `calibrate(...)` VERBATIM (so k / sampleDays / + /// confidence / manual are exactly what the Settings screen reads), or the `status(...)` line naming + /// why the fit was withheld (e.g. needsMoreDays have/need). + /// + /// Every number is the SAME expression the production fit uses, and the reported coefficient IS + /// `calibrate(...)`'s, so the trace can never diverge from the headline. The Kotlin twin is + /// `StepsEstimateEngineTrace.calibrationTrace`. + public static func calibrationTrace(points: [CalibrationPoint], + manualOverride: Double? = nil) -> [String] { + func r2(_ x: Double) -> Double { (x * 100.0).rounded() / 100.0 } + + var lines: [String] = [] + + // Per-usable-day points: the SAME filter the fit applies (motion >= minMotionForFit && steps > 0), + // so the trace shows exactly the days that voted. Phone reference count is the calibration anchor. + let usable = points.filter { $0.motion >= minMotionForFit && $0.steps > 0 } + for p in usable { + let ratio = p.motion > 0 ? p.steps / p.motion : 0 + lines.append("stepsCal point motion=\(r2(p.motion)) phoneRef=\(Int(p.steps)) " + + "ratio=\(r2(ratio)) (steps/motion votes weighted by motion)") + } + + // The calibration outcome, read from calibrate(...) verbatim so it matches the stored coefficient. + if let cal = calibrate(points, manualOverride: manualOverride), usable.count >= minCalibrationDays || cal.manual { + lines.append("stepsCal fit k=\(r2(cal.coefficient)) sampleDays=\(cal.sampleDays) " + + "confidence=\(r2(cal.confidence)) manual=\(cal.manual) " + + "(k = motion-weighted median of steps/motion)") + } else { + // Withheld: name the status the tile shows (the "need N more days" reason), via status(...) + // verbatim so the trace explains the blank tile with the SAME usable-day filter. + let status = self.status(points, manualOverride: manualOverride) + switch status { + case let .needsMoreDays(have, need): + lines.append("stepsCal withheld reason=needsMoreDays have=\(have) need=\(need) " + + "(no usable auto-fit and no manual k)") + case let .manual(k, sampleDays): + lines.append("stepsCal fit k=\(r2(k)) sampleDays=\(sampleDays) " + + "confidence=1.0 manual=true (user-set k)") + case let .calibrated(k, sampleDays, confidence): + lines.append("stepsCal fit k=\(r2(k)) sampleDays=\(sampleDays) " + + "confidence=\(r2(confidence)) manual=false (k = motion-weighted median of steps/motion)") + } + } + return lines + } + + /// The WHOOP 5/MG raw-counter trace for one day. Recomputes the SAME wrap-aware sum + /// `AnalyticsEngine.analyzeDay` runs over the cumulative `step_motion_counter` series: the time-ordered + /// records filtered to the LOCAL day, each consecutive `(cur - prev) & 0xFFFF` increment, the dropped + /// deltas (>= `maxStepDelta`, a sync-gap / reboot boundary), and the `ticksPerStep` scaling. Reports the + /// counter series length, the kept/dropped delta counts, the raw tick total and the scaled steps - the + /// SAME value the daily `steps_est` carries (byte-identical math), so the trace can never diverge. + /// + /// - Parameters mirror the analyzeDay step block exactly: the day's step samples (any order), the local + /// day key, the tz offset, and the user's ticks-per-step. `daySteps` is the calendar-day stream the + /// production total prefers. The Kotlin twin is `StepsEstimateEngineTrace.rawCounterTrace`. + public static func rawCounterTrace(daySteps: [StepSample], + dayKey: String, + tzOffsetSeconds: Int, + ticksPerStep: Double) -> [String] { + // The SAME maxStepDelta gate AnalyticsEngine.analyzeDay uses for the daily steps total. + let maxStepDelta = 512 + + // The SAME filter + sort: keep only this LOCAL day's samples, time-ordered. + let sorted = daySteps + .filter { AnalyticsEngine.dayString($0.ts, offsetSec: tzOffsetSeconds) == dayKey } + .sorted { $0.ts < $1.ts } + + var lines: [String] = [] + // #810: a WHOOP 4.0 sends NO raw step counter over BLE at all, so `sorted` is empty for it; its + // steps are MOTION-ESTIMATED (the calibrationTrace path), not counted. Emitting the bare + // "counterSamples=0 (need >=2 for a delta)" line made a 4.0 export read as BROKEN. When there is + // no counter sample at all, say so honestly so the trace reflects the model, not a fault. (A 5/MG + // with a single counter sample still falls through to the "need >=2" line: it HAS a counter, just + // one read this window.) The Kotlin twin emits the same branch first; keep them byte-identical. + if sorted.isEmpty { + lines.append("stepsRaw day=\(dayKey) counterSamples=0 noRawCounter " + + "(no step counter on this device; steps are motion-estimated, e.g. WHOOP 4.0)") + return lines + } + guard sorted.count >= 2 else { + lines.append("stepsRaw day=\(dayKey) counterSamples=\(sorted.count) (need >=2 for a delta)") + return lines + } + + // Walk the wrap-aware deltas exactly as the production sum does. + var rawTotal = 0 + var keptDeltas = 0 + var droppedDeltas = 0 + var minDelta = Int.max + var maxDelta = Int.min + for i in 1..= 1 && delta < maxStepDelta { + rawTotal += delta + keptDeltas += 1 + minDelta = Swift.min(minDelta, delta) + maxDelta = Swift.max(maxDelta, delta) + } else if delta >= maxStepDelta { + droppedDeltas += 1 // a sync-gap / reboot boundary, not real steps (>= 512) + } + } + + let firstCounter = sorted.first!.counter + let lastCounter = sorted.last!.counter + lines.append("stepsRaw day=\(dayKey) counterSamples=\(sorted.count) " + + "firstCounter=\(firstCounter) lastCounter=\(lastCounter) (cumulative u16 @57)") + lines.append("stepsRaw deltas kept=\(keptDeltas) dropped=\(droppedDeltas) " + + "(dropped = delta>=\(maxStepDelta), a sync-gap/reboot boundary)") + if keptDeltas > 0 { + lines.append("stepsRaw keptRange min=\(minDelta) max=\(maxDelta) " + + "(each = (cur-prev)&0xFFFF, wrap-aware)") + } + + // The scaled total, the SAME expression analyzeDay produces for steps_est (ticks / ticksPerStep, + // floored at 0.5 so a bad pref can at most double, never explode, the total). + let scaled = rawTotal > 0 + ? Int((Double(rawTotal) / Swift.max(ticksPerStep, 0.5)).rounded()) + : 0 + // L7: production analyzeDay returns `scaled > 0 ? scaled : nil`, so a tiny rawTotal that rounds to 0 + // yields NO steps_est for the day. Render "none" (not 0) so the trace matches the nil headline rather + // than implying a real zero-step measurement. + let scaledText = scaled > 0 ? String(scaled) : "none" + lines.append("stepsRaw total rawTicks=\(rawTotal) ticksPerStep=\((ticksPerStep * 100).rounded() / 100) " + + "scaledSteps=\(scaledText) (steps_est for the day)") + return lines + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsEstimateEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsEstimateEngine.swift new file mode 100644 index 0000000000..6080eac31a --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsEstimateEngine.swift @@ -0,0 +1,283 @@ +import Foundation +import WhoopProtocol + +/// Estimate daily steps for a WHOOP 4.0 from the strap's MOTION, calibrated per-user against a phone +/// step count (Apple Health / Health Connect). +/// +/// WHY THIS IS A CALIBRATED ESTIMATE, NOT A PEDOMETER. A WHOOP 4.0 does not send a step count over BLE, +/// and the accelerometer/gravity data we DO get is sparse (~one vector per stored record, roughly minute +/// granularity) — far below the ~25–50 Hz a true step counter needs to see individual footfalls. So we +/// cannot count steps. What we CAN measure is movement VOLUME (how much the gravity vector moved over the +/// day), and we map that volume to steps with a coefficient learned from days where the phone ALSO counted +/// steps. The output is always framed as an estimate. +/// +/// THE MODEL. `steps ≈ k · motionIntensity`, a through-origin fit (no steps ⇒ no motion). `k` (steps per +/// unit of motion) is the only free parameter, and it is PERSONAL — it depends on wrist vs hip placement, +/// gait, and how the strap rides — which is exactly why it's calibrated to each user rather than a global +/// constant. We fit `k` robustly (a MOTION-WEIGHTED median of per-day `steps/motion` ratios) so a single odd +/// day (a drive, a workout that's all arms, a phone left at home) can't drag the whole calibration. The +/// weighting is the point of #682: a busy 15,000-step day pins the ratio far more reliably than a near-still +/// 500-step day (more footfalls ⇒ less ratio noise), so we let motion VOLUME drive the fit instead of letting +/// every day count equally. A user with no phone step history to fit against can set `k` manually with the +/// calibration slider. +/// +/// Pure value type — no I/O, fully unit-tested. The Kotlin twin is StepsEstimateEngine.kt (kept byte-for-byte +/// equivalent: same motion sum, same median fit, same confidence curve, same clamps). +public enum StepsEstimateEngine { + + // MARK: - Tunables + + /// Fewest calibration days (each with both motion and a reference step count) before we trust an + /// auto-fit `k`. Below this the estimate is withheld unless the user has set a manual `k`. + public static let minCalibrationDays = 3 + /// Calibration days at/above which confidence saturates toward 1. + public static let goodCalibrationDays = 14 + /// A day must move at least this much (summed gravity-delta) to enter the ratio fit — filters near-still + /// days whose tiny motion makes steps/motion explode. Also the floor for producing an estimate. + public static let minMotionForFit = 1.0 + /// Sanity clamp on a daily estimate so a calibration outlier can never render an absurd number. + public static let maxDailySteps = 60_000 + + // MARK: - Types + + /// One calibration day: the strap's motion volume and the phone's measured step count for the SAME day. + public struct CalibrationPoint: Equatable { + public let motion: Double + public let steps: Double + public init(motion: Double, steps: Double) { self.motion = motion; self.steps = steps } + } + + /// The fitted (or manually-set) personal model. + public struct Calibration: Equatable { + /// Steps per unit of motion. + public let coefficient: Double + /// How many days fed the auto-fit (0 when purely manual). + public let sampleDays: Int + /// 0–1: how much to trust the estimate, from sample size and fit spread. 1.0 for a user-set manual `k`. + public let confidence: Double + /// True when the user set `k` by hand rather than it being fit from phone data. + public let manual: Bool + public init(coefficient: Double, sampleDays: Int, confidence: Double, manual: Bool) { + self.coefficient = coefficient; self.sampleDays = sampleDays + self.confidence = confidence; self.manual = manual + } + } + + /// A coarse confidence tier for the auto-fit, for a one-word badge on the steps tile/Settings. Derived + /// from the engine's 0–1 confidence by fixed thresholds so iOS + Android show the SAME word. A manual `k` + /// is reported as `.high` (the user asserted it). (#760/#792) + public enum ConfidenceTier: String, Equatable { + case low, medium, high + + /// 0–1 confidence → tier. < 0.34 low, < 0.67 medium, else high. Thresholds are byte-identical to + /// the Kotlin twin so the badge never disagrees across platforms. + public static func from(_ confidence: Double) -> ConfidenceTier { + if confidence < 0.34 { return .low } + if confidence < 0.67 { return .medium } + return .high + } + + /// The badge word the tile/Settings renders. US-neutral, no em-dashes. + public var word: String { + switch self { + case .low: return "low confidence" + case .medium: return "medium confidence" + case .high: return "high confidence" + } + } + } + + /// A readable read-out of the calibration state, for the Today steps tile and the Settings section. + /// Pure value type (no UI strings beyond a single short status line) so both surfaces stay in step. + public enum CalibrationStatus: Equatable { + /// A manual `k` is in force (the user set it by hand). `sampleDays` = the auto-fit days that exist + /// alongside it (informational; the manual value still wins). + case manual(coefficient: Double, sampleDays: Int) + /// Enough overlapping days fit an auto coefficient. Carries the fit and its 0–1 confidence. + case calibrated(coefficient: Double, sampleDays: Int, confidence: Double) + /// Not yet calibrated: `have` overlapping phone-counted days out of `need`, so `need - have` more + /// are required before an estimate appears (and no manual override is set to fill the gap). + case needsMoreDays(have: Int, need: Int) + + /// True when an estimate can be produced right now (manual or a usable auto-fit). + public var canEstimate: Bool { + switch self { + case .manual, .calibrated: return true + case .needsMoreDays: return false + } + } + + /// A short, honest one-liner for the tile/Settings. US-neutral, no em-dashes. The caller may also + /// render a confidence badge from `.calibrated`'s confidence; this is the headline only. + public var headline: String { + switch self { + case .manual: + return "Calibrated by hand" + case let .calibrated(_, sampleDays, _): + return "Estimated from \(sampleDays) day\(sampleDays == 1 ? "" : "s") your phone also counted" + case let .needsMoreDays(have, need): + let more = Swift.max(0, need - have) + return "Need \(more) more day\(more == 1 ? "" : "s") where your phone also counted steps" + } + } + + /// The confidence tier for the steps estimate. `.calibrated` maps its 0–1 confidence; a manual `k` + /// is `.high` (asserted by the user); a not-yet-calibrated state is `.low`. (#760/#792) + public var confidenceTier: ConfidenceTier { + switch self { + case .manual: return .high + case let .calibrated(_, _, confidence): return ConfidenceTier.from(confidence) + case .needsMoreDays: return .low + } + } + + /// The personal coefficient `k` (steps per unit of motion) currently in force, or nil when none is + /// fit/set yet. Surfaced so a WHOOP 4.0 user can see WHY their steps read the way they do. (#760/#792) + public var coefficient: Double? { + switch self { + case let .manual(k, _): return k + case let .calibrated(k, _, _): return k + case .needsMoreDays: return nil + } + } + + /// A second, denser status line (the headline carries the plain-English summary; this carries the + /// numbers): the confidence tier plus, when calibrated/manual, `k` and the day count, so a frozen or + /// dashed steps tile self-explains ("k=12.3 from 6 days, medium confidence" vs "calibrating: 1/3 days"). + /// Pure, no em-dashes; identical wording cross-platform. (#760/#792) + public var detail: String { + switch self { + case let .manual(k, _): + return "manual k=\(StepsEstimateEngine.formatK(k))" + case let .calibrated(k, sampleDays, confidence): + let tier = ConfidenceTier.from(confidence) + return "k=\(StepsEstimateEngine.formatK(k)) from \(sampleDays) day\(sampleDays == 1 ? "" : "s"), \(tier.word)" + case let .needsMoreDays(have, need): + return "calibrating: \(Swift.min(have, need))/\(need) days" + } + } + } + + /// Format the steps coefficient `k` to one decimal place for the status line (US-neutral, locale-free so + /// iOS + Android match byte-for-byte). (#760/#792) + static func formatK(_ k: Double) -> String { + String(format: "%.1f", k) + } + + /// Classify the current calibration state from the same inputs `calibrate(_:manualOverride:)` sees, + /// so the UI can explain WHY the steps tile is (or isn't) showing an estimate without re-deriving the + /// fit. A positive `manualOverride` always reports `.manual`. Otherwise we count the usable overlapping + /// days (same filter the fit uses) and report `.calibrated` once `minCalibrationDays` are met, else + /// `.needsMoreDays`. Mirror of the Kotlin `status(...)`. + public static func status(_ points: [CalibrationPoint], manualOverride: Double? = nil) -> CalibrationStatus { + let usableDays = points.filter { $0.motion >= minMotionForFit && $0.steps > 0 }.count + if let k = manualOverride, k > 0 { + return .manual(coefficient: k, sampleDays: usableDays) + } + guard let cal = calibrate(points), usableDays >= minCalibrationDays else { + return .needsMoreDays(have: usableDays, need: minCalibrationDays) + } + return .calibrated(coefficient: cal.coefficient, sampleDays: cal.sampleDays, confidence: cal.confidence) + } + + // MARK: - Motion feature + + /// Total daily MOTION INTENSITY = the sum of per-record gravity-vector deltas (L2 magnitude of the change + /// between consecutive samples). This is movement VOLUME over the day, the same proxy the sleep stager + /// uses for stillness, integrated. Sparse-but-monotone-with-activity, so it calibrates cleanly to steps. + public static func dayMotionIntensity(_ grav: [GravitySample]) -> Double { + guard grav.count > 1 else { return 0 } + var total = 0.0 + var prev = grav[0] + for i in 1.. Calibration? { + if let k = manualOverride, k > 0 { + return Calibration(coefficient: k, sampleDays: points.count, confidence: 1.0, manual: true) + } + // Usable days carry (ratio, weight) where weight = motion volume: a busier day votes harder. + let weighted = points + .filter { $0.motion >= minMotionForFit && $0.steps > 0 } + .map { (ratio: $0.steps / $0.motion, weight: $0.motion) } + guard weighted.count >= minCalibrationDays else { return nil } + let ratios = weighted.map { $0.ratio } + let weights = weighted.map { $0.weight } + let k = weightedMedian(ratios, weights: weights) + guard k > 0 else { return nil } + // Confidence: grows with sample size toward goodCalibrationDays, discounted by relative spread + // (weighted MAD / weighted median) so a noisy fit is honestly less trusted than a tight one. The MAD is + // also motion-weighted so spread is measured against the same days that drove `k`. + let sizeTerm = min(1.0, Double(weighted.count) / Double(goodCalibrationDays)) + let mad = weightedMedian(ratios.map { abs($0 - k) }, weights: weights) + let spread = k > 0 ? mad / k : 1.0 + let tightness = max(0.0, 1.0 - spread) // 1 = all ratios equal, 0 = wildly scattered + let confidence = (0.5 * sizeTerm + 0.5 * tightness).clampedUnit + return Calibration(coefficient: k, sampleDays: weighted.count, confidence: confidence, manual: false) + } + + // MARK: - Estimate + + /// Estimated steps for a day from its motion volume and the personal calibration. nil below + /// `minMotionForFit` (too little movement to say anything) — the UI then shows "—", never a fake 0. + public static func estimate(motion: Double, calibration: Calibration) -> Int? { + guard motion >= minMotionForFit, calibration.coefficient > 0 else { return nil } + let raw = motion * calibration.coefficient + return Int(raw.rounded()).clamped(0, maxDailySteps) + } + + // MARK: - Helpers + + static func median(_ xs: [Double]) -> Double { + guard !xs.isEmpty else { return 0 } + let s = xs.sorted(); let n = s.count + return n % 2 == 1 ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2 + } + + /// Weighted median of `xs` with per-element `weights` (#682). Sort by value, walk the cumulative weight, + /// and return the value at which it first reaches half the total weight. When the cumulative weight lands + /// EXACTLY on the half-mass boundary, average the two straddling values — so with equal weights this + /// reduces to the plain even-count midpoint average and the unweighted fits stay byte-identical. Falls back + /// to the plain median if weights are absent/degenerate (empty, mismatched, or non-positive total). + static func weightedMedian(_ xs: [Double], weights: [Double]) -> Double { + guard !xs.isEmpty else { return 0 } + guard weights.count == xs.count else { return median(xs) } + let order = xs.indices.sorted { xs[$0] < xs[$1] } + let total = weights.reduce(0, +) + guard total > 0 else { return median(xs) } + let half = total / 2 + var cum = 0.0 + for (pos, idx) in order.enumerated() { + let w = max(0.0, weights[idx]) + cum += w + if cum > half { return xs[idx] } + if cum == half { + // Half-mass falls on a boundary: average this value with the next distinct one (if any). + let next = pos + 1 < order.count ? order[pos + 1] : idx + return (xs[idx] + xs[next]) / 2 + } + } + return xs[order[order.count - 1]] + } +} + +private extension Double { + var clampedUnit: Double { Swift.max(0.0, Swift.min(1.0, self)) } +} +private extension Int { + func clamped(_ lo: Int, _ hi: Int) -> Int { Swift.max(lo, Swift.min(hi, self)) } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift index 34fad4a556..3bcedc7132 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift @@ -1,12 +1,18 @@ import Foundation import WhoopProtocol -// StrainScorer.swift — cardiovascular load on a 0–21 logarithmic strain scale. +// StrainScorer.swift — cardiovascular load on a 0–100 logarithmic strain ("Effort") scale. // // Ported from server/ingest/app/analysis/strain.py. INDEPENDENT implementation of // published exercise-physiology methods (WHOOP-*like*, not a reproduction of the // proprietary algorithm; not medical advice). // +// Scale note: the metric was historically 0–21 (WHOOP's Strain axis); the +// "Charge / Effort / Rest" redesign rescales the OUTPUT to 0–100 by raising +// `maxStrain` 21.0 → 100.0 only. The denominator D = 7201 is unchanged, so the log +// curve and its saturation point (TRIMP 7200 ≈ max) are preserved — a max Effort day +// stays as rare as a 21.0 day used to be. Internal metric key stays `strain`. +// // Pipeline: // 1. Heart-Rate Reserve (Karvonen): HRR = HRmax − RHR. // 2. Per-sample intensity as %HRR = (HR − RHR) / HRR × 100, clamped 0..100. @@ -14,8 +20,8 @@ import WhoopProtocol // a. Edwards 5-zone summation (default): sample contributes its zone weight // (1..5 at 50/60/70/80/90 %HRR cut-offs) × duration. // b. Banister exponential: sample contributes duration × x × 0.64 × e^(b·x). -// 4. Logarithmic compression onto [0, 21]: -// strain = 21 × ln(TRIMP + 1) / ln(D), D = STRAIN_DENOMINATOR. +// 4. Logarithmic compression onto [0, 100]: +// strain = 100 × ln(TRIMP + 1) / ln(D), D = STRAIN_DENOMINATOR. // // References: Karvonen 1957 (%HRR); Edwards 1993 (5-zone TRIMP); Banister 1991 // (exponential TRIMP, b = 1.92 men / 1.67 women); Tanaka 2001 (HRmax = 208 − 0.7×age). @@ -24,13 +30,25 @@ public enum StrainScorer { // MARK: - Constants (strain.py) - /// Minimum HR readings before computing strain (≈10 min at 1 Hz). + /// Minimum HR readings before computing strain on a DENSE stream (≈10 min at 1 Hz). public static let minReadings: Int = 600 - /// Top of the strain scale. - public static let maxStrain: Double = 21.0 + /// Sparse-stream acceptance (#482/#480): a low-cadence strap — the WHOOP 5/MG sends live + /// standard HR only ~every 30 s — would need ~5 h of continuous wear to reach `minReadings`, + /// so Effort sat un-scored (nil → the gauge showed a stale prior-day value) for most of the day. + /// Also accept once the HR series SPANS at least `minSpanSeconds` of wall-clock with a small + /// sample floor. This never fabricates load: TRIMP still integrates honestly over whatever HR is + /// there, so a genuine low-HR day scores 0 either way — it just lets the live gauge reflect TODAY + /// instead of yesterday. A dense 1 Hz stream is unaffected (it clears `minReadings` first). + public static let minSparseReadings: Int = 20 + /// Wall-clock coverage (seconds) that qualifies a sparse stream. 600 s = 10 min, matching the + /// dense gate's ≈10 min of 600 × 1 Hz samples, so both cadences trust the number at the same age. + public static let minSpanSeconds: Int = 600 + /// Top of the strain ("Effort") scale. Rescaled 21.0 → 100.0 for the + /// Charge/Effort/Rest redesign; only the output scale changes, the curve does not. + public static let maxStrain: Double = 100.0 /// Logarithmic-map denominator D. Chosen so the Edwards daily ceiling - /// (top zone weight 5 sustained 24 h = 7200) maps to exactly 21.0: + /// (top zone weight 5 sustained 24 h = 7200) maps to exactly maxStrain: /// D = 7200 + 1 = 7201 makes ln(7201)/ln(7201) = 1. public static let strainDenominator: Double = 7201.0 static var lnStrainDenominator: Double { log(strainDenominator) } @@ -57,7 +75,7 @@ public enum StrainScorer { ] /// TRIMP accumulation method. - public enum Method: Sendable { case edwards, banister } + public enum Method: Sendable, Hashable { case edwards, banister } // MARK: - HRmax helpers @@ -141,7 +159,7 @@ public enum StrainScorer { // MARK: - Logarithmic map - /// Map accumulated TRIMP onto [0, 21] via 21 × ln(TRIMP+1) / ln(D), 2 dp. + /// Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. /// TRIMP ≤ 0 → 0. public static func trimpToStrain(_ trimp: Double, denominator: Double = strainDenominator) -> Double { if trimp <= 0 { return 0 } @@ -152,7 +170,8 @@ public enum StrainScorer { // MARK: - Denominator calibration /// Calibrate D from (TRIMP, reference_strain) pairs via the through-origin - /// least-squares line: ln(D) = 21 × Σ(x²) / Σ(xy), x = ln(TRIMP+1). + /// least-squares line: ln(D) = maxStrain × Σ(x²) / Σ(xy), x = ln(TRIMP+1). + /// (reference_strain pairs must be on the same 0–maxStrain axis as the output.) /// Throws when fewer than 2 usable pairs (TRIMP>0, strain>0) or degenerate. public static func fitStrainDenominator(_ pairs: [(trimp: Double, strain: Double)]) throws -> Double { let usable = pairs.filter { $0.trimp > 0 && $0.strain > 0 } @@ -174,10 +193,11 @@ public enum StrainScorer { // MARK: - Public API - /// Cardiovascular strain (0–21) from an HR series. APPROXIMATE. + /// Cardiovascular strain / "Effort" (0–100) from an HR series. APPROXIMATE. /// - /// Returns nil when there are fewer than `minReadings` samples or - /// maxHR ≤ restingHR (invalid HRR). + /// Returns nil when there isn't yet enough data to trust the number — fewer than + /// `minReadings` samples AND less than `minSpanSeconds` of HR coverage (the sparse-strap + /// path, #482) — or when maxHR ≤ restingHR (invalid HRR). /// /// - Parameters: /// - hr: time-ordered `[HRSample]`. @@ -192,8 +212,42 @@ public enum StrainScorer { method: Method = .edwards, sex: String = "male", denominator: Double = strainDenominator) -> Double? { + // v7.0.2 perf (#707): TRIMP integrates over the day's HR stream; called once per day in the post-sync + // scoring loop AND from the Today view (which re-reads on each live-HR tick). Memoize on the HR + // fingerprint + every scalar that steers the score, so an identical re-request is a lookup. The + // result is a single `Double?`; the HR array is not retained. + let key = StrainKey( + hr: StreamFingerprint.of(hr, ts: { $0.ts }, quant: { Int($0.bpm) }), + maxHR: maxHR, restingHR: restingHR, method: method, + sexF: sex.lowercased().hasPrefix("f"), denom: denominator) + return strainCache.value(key) { + strainUncached(hr, maxHR: maxHR, restingHR: restingHR, method: method, sex: sex, denominator: denominator) + } + } + + /// Key folds `sex` to the single bit the recipe reads (`hasPrefix("f")`) so "female"/"f"/"F" all hit. + private struct StrainKey: Hashable { + let hr: StreamFingerprint + let maxHR: Double?; let restingHR: Double; let method: Method + let sexF: Bool; let denom: Double + } + private static let strainCache = AnalyticsMemoCache(capacity: 48) + + private static func strainUncached(_ hr: [HRSample], maxHR: Double?, restingHR: Double, + method: Method, sex: String, denominator: Double) -> Double? { let effMax = maxHR ?? Double(defaultMaxHR()) - if hr.count < minReadings || effMax <= restingHR { return nil } + // Enough data to trust the score: a dense stream (≥ minReadings) OR a sparse-but-sustained + // one spanning ≥ minSpanSeconds with a sample floor (#482 — the 5/MG's ~30 s HR cadence). + let enoughData: Bool + if hr.count >= minReadings { + enoughData = true + } else if hr.count >= minSparseReadings { + let tss = hr.map { $0.ts } + enoughData = ((tss.max() ?? 0) - (tss.min() ?? 0)) >= minSpanSeconds + } else { + enoughData = false + } + if !enoughData || effMax <= restingHR { return nil } let sampleDur = sampleDurationMinutes(hr) let hrReserve = effMax - restingHR diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StressIndex.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StressIndex.swift new file mode 100644 index 0000000000..b79bdbb6c2 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StressIndex.swift @@ -0,0 +1,104 @@ +import Foundation +import WhoopProtocol + +// StressIndex.swift, Baevsky's Stress Index (SI), a histogram-based autonomic-balance metric. +// +// PURELY ADDITIVE display metric. Touches no Charge / Effort / Rest / sleep output. +// +// Baevsky's Stress Index (Baevsky & Berseneva; "regulatory systems" / cardiointervalography) summarises how +// "centralised" / sympathetically driven the heart rhythm is from the SHAPE of the R-R histogram: +// +// SI = AMo / (2 * Mo * MxDMn) +// +// • Mo (mode, s) : the most common R-R value, the histogram bin centre with the highest count. +// • AMo (amplitude : the % of intervals falling in the modal bin, a TALL narrow peak (rigid, +// of the mode, %) sympathetically driven rhythm) gives a high AMo. +// • MxDMn (variation : range of R-R = max - min (s), a wide range (flexible, vagal) lowers SI. +// range, s) +// +// A high SI means a tall, narrow, low-range histogram = a rigid rhythm = high sympathetic stress; a low SI +// means a broad, flat, wide-range histogram = a flexible rhythm = relaxed. The units follow the classic +// convention: R-R in SECONDS and Mo/MxDMn in seconds, AMo as a percentage (0–100), so SI is dimensionless. +// +// APPROXIMATE, non-clinical. Returned as a plain optional number for the UI lane to band/label later. + +public enum StressIndex { + + /// Histogram bin width in SECONDS. Baevsky's method canonically bins R-R at 50 ms (0.05 s), the width + /// used in cardiointervalography, so the mode and its amplitude are computed on the standard grid. + public static let binWidthSec: Double = 0.05 + + /// Minimum clean intervals before an SI is computed (the histogram needs enough beats to have a mode). + public static let minBeats: Int = 20 + + /// The intermediate histogram terms, exposed so the UI / a test can show the "why" behind an SI. + public struct Components: Equatable, Sendable { + /// Mode of the R-R histogram (s), centre of the most populated bin. + public let moSec: Double + /// Amplitude of the mode (%), share of intervals in the modal bin, 0–100. + public let aMoPercent: Double + /// Variation range MxDMn (s), max R-R minus min R-R over the cleaned series. + public let mxDMnSec: Double + /// SI = AMo / (2 * Mo * MxDMn). + public let si: Double + + public init(moSec: Double, aMoPercent: Double, mxDMnSec: Double, si: Double) { + self.moSec = moSec; self.aMoPercent = aMoPercent; self.mxDMnSec = mxDMnSec; self.si = si + } + } + + /// Baevsky Stress Index from R-R intervals (cleaned with the shared range + Malik ectopic pipeline). + /// Returns nil when too few clean beats survive or the variation range is degenerate (all-equal beats, + /// MxDMn == 0, would divide by zero, an honest nil, not Infinity). + public static func stressIndex(rr: [RRInterval]) -> Double? { + components(rr: rr)?.si + } + + /// As `stressIndex(rr:)` but from a raw R-R series in milliseconds. + public static func stressIndex(rawRR: [Double]) -> Double? { + components(rawRR: rawRR)?.si + } + + /// Full SI components from R-R intervals. + public static func components(rr: [RRInterval]) -> Components? { + components(rawRR: rr.map { Double($0.rrMs) }) + } + + /// Full SI components from a raw R-R series (ms). Pure, deterministic, no clock / IO. + public static func components(rawRR: [Double]) -> Components? { + let clean = HRVAnalyzer.cleanRR(rawRR) + guard clean.count >= minBeats else { return nil } + + // Work in seconds (Baevsky's convention). + let sec = clean.map { $0 / 1000.0 } + let minV = sec.min()! + let maxV = sec.max()! + let mxDMn = maxV - minV + guard mxDMn > 0 else { return nil } // all-equal beats: no histogram spread, SI undefined. + + // Bin the series at binWidthSec; the modal bin is the one with the most intervals. Bin index is + // floor((v - minV) / binWidth); the last value lands in the final bin by construction. + let binCount = max(1, Int((mxDMn / binWidthSec).rounded(.down)) + 1) + var counts = [Int](repeating: 0, count: binCount) + for v in sec { + var idx = Int(((v - minV) / binWidthSec).rounded(.down)) + if idx < 0 { idx = 0 } + if idx >= binCount { idx = binCount - 1 } + counts[idx] += 1 + } + // Modal bin: highest count; ties resolve to the LOWEST bin index (deterministic across platforms). + var modeIdx = 0 + var modeCount = counts[0] + for i in 1.. modeCount { + modeCount = counts[i] + modeIdx = i + } + // Mo is the modal bin's CENTRE (s). + let mo = minV + (Double(modeIdx) + 0.5) * binWidthSec + let aMo = Double(modeCount) / Double(sec.count) * 100.0 // percentage in the modal bin + + guard mo > 0 else { return nil } + let si = aMo / (2.0 * mo * mxDMn) + return Components(moSec: mo, aMoPercent: aMo, mxDMnSec: mxDMn, si: si) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StressOnsetDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StressOnsetDetector.swift new file mode 100644 index 0000000000..7b40afb6f7 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StressOnsetDetector.swift @@ -0,0 +1,249 @@ +import Foundation + +// StressOnsetDetector.swift — the L3 closed-loop JITAI ("just-in-time adaptive intervention") detector. +// Generalises the math currently inline in `AppModel.evaluateStress()` into an EDGE-triggered, +// motion-gated, REPLAY-SAFE detector that decides — at the moment it matters — whether to offer a 60-s +// guided breathing cue. PURE + DB-free, carrying its OWN de-dup state exactly like +// `SedentaryDetector.evaluate`: the caller persists `nextState` and feeds it back, so a replayed window +// can't re-fire. No I/O / BLE here. +// +// See docs/superpowers/specs/2026-06-19-v5-haptic-biofeedback-design.md (L3). +// +// WHAT IT GENERALISES (from AppModel.evaluateStress): a rolling clean-R-R buffer → a SLOW RMSSD baseline +// (the shipped 0.98/0.02 EMA) + a resting-HR band gate (55–100 bpm) + a `rmssd < baseline × 0.6` drop + +// a once-per-15-min limiter + a single confirming buzz. What this engine ADDS, per spec: +// 1. A FAST short-window RMSSD (the latest beats) vs the slow baseline — "fast dropped below baseline". +// 2. EDGE trigger: fire ONCE on the fresh crossing (was-above → now-below), not every tick. +// 3. The EXERCISE GATE (the credibility line): suppress when HR is out of the resting band AND/OR recent +// motion says "metabolic, not stress" (gravity activity above a threshold, the same `recentGravity` +// source `SedentaryDetector` reads). A brisk walk's HRV dip must NOT fire a "you're stressed" cue. +// 4. Rate-limit + quiet hours + master toggle, and never while a manual Breathe/L1/L2 session runs. +// +// HONEST / NON-CLINICAL: "stress" is an autonomic PROXY (HRV-down vs the user's OWN baseline), never a +// diagnosis. The card the caller shows says "HRV dipped while you were still" — never "you are stressed". +// On fire: a single confirming buzz + a passive in-app card; NEVER a push notification unless the user +// opted into notifications (matches DaytimeStress's "passive suggestion, never a notification" stance). +// +// All `ts`/`nowSec` are wall-clock unix SECONDS. Outputs are APPROXIMATE, not medical advice. + +public enum StressOnsetDetector { + + // MARK: - Tunables (evaluateStress parity + the new fast/gate pieces) + + /// Slow-baseline EMA weight on the prior value (the shipped 0.98). New RMSSD gets `1 − this`. + public static let baselineEmaAlpha: Double = 0.98 + /// Fast RMSSD must drop below `baseline × this` to count as a dip (the shipped 0.6 threshold). + public static let dropRatio: Double = 0.6 + /// Resting HR band — outside it the dip is treated as metabolic (workout), not stress (the shipped + /// 55–100 bpm gate). + public static let restingHRLow: Double = 55.0 + public static let restingHRHigh: Double = 100.0 + /// Beats in the FAST short window (the latest clean beats) used for the momentary RMSSD. + public static let fastWindowBeats: Int = 60 + /// Minimum clean beats before either RMSSD is trusted (mirrors `HRVAnalyzer.minBeats`). + public static let minBeats: Int = HRVAnalyzer.minBeats + /// Rate limit — at most one fire per this many seconds (the shipped 900 s = 15 min). + public static let minSecondsBetweenFires: Int = 900 + /// Recent smoothed wrist-motion (g) at/above this means "moving" → exercise gate suppresses the fire + /// (reuses the `SedentaryDetector` move threshold so the two gates agree on what "moving" is). + public static let motionGateG: Double = SedentaryDetector.defaultMoveThresholdG + + // MARK: - Config + + /// The L3 master/sub toggles + quiet-hours window, passed in as plain values so the engine stays pure. + /// All default OFF / safe — manual-first ethos (the feature is opt-in per layer). + public struct Config: Equatable, Sendable { + /// Master "stress check-ins (haptic)" toggle (default OFF). Inert when off. + public var enabled: Bool + /// Auto-nudge sub-toggle (default OFF) — when off the detector still reports state but never fires. + public var autoNudge: Bool + /// Suppress fires during quiet hours. + public var quietHoursEnabled: Bool + /// Quiet-hours window, local minute-of-day [0,1440) (defaults 22:00 → 07:00). + public var quietStartMinutes: Int + public var quietEndMinutes: Int + /// Buzz strength (loops) for the confirming buzz — one light pulse, like evaluateStress. + public var buzzLoops: Int + + public init(enabled: Bool = false, + autoNudge: Bool = false, + quietHoursEnabled: Bool = false, + quietStartMinutes: Int = SedentaryDetector.defaultQuietStartMin, + quietEndMinutes: Int = SedentaryDetector.defaultQuietEndMin, + buzzLoops: Int = 1) { + self.enabled = enabled + self.autoNudge = autoNudge + self.quietHoursEnabled = quietHoursEnabled + self.quietStartMinutes = quietStartMinutes + self.quietEndMinutes = quietEndMinutes + self.buzzLoops = buzzLoops + } + } + + // MARK: - State (de-dup / EMA carry — persisted verbatim, replay-safe) + + /// The persisted state the detector carries between evaluations (restart-safe). The caller stores this + /// verbatim and feeds the prior value back in, exactly like `SedentaryState`. A fresh user starts from + /// `.initial`. Carries the slow EMA baseline (so it survives relaunch), the edge state (was the fast + /// RMSSD below the threshold on the previous tick?), and the rate-limit clock. + public struct State: Equatable, Sendable { + /// Slow RMSSD baseline (EMA), ms. 0 = uninitialised (seeds from the first trusted fast RMSSD). + public var baselineRMSSD: Double + /// Whether the fast RMSSD was BELOW the drop threshold on the previous evaluation — drives the + /// EDGE (we fire only on a fresh above→below crossing, not every tick it stays below). + public var wasBelow: Bool + /// Unix-seconds of the last fire (0 = never) — the rate limiter. + public var lastFireAt: Int + + public init(baselineRMSSD: Double = 0, wasBelow: Bool = false, lastFireAt: Int = 0) { + self.baselineRMSSD = baselineRMSSD + self.wasBelow = wasBelow + self.lastFireAt = lastFireAt + } + + /// Cold-start state (no baseline, not below, never fired). + public static let initial = State() + } + + // MARK: - Decision + + /// Why the detector did / didn't nudge — drives logs and the honest card copy. + public enum Reason: String, Equatable, Sendable { + /// A fresh non-metabolic HRV dip — offer a minute to breathe. + case onset + /// Disabled / auto-nudge off. + case disabled + /// Too few clean beats to judge honestly. + case insufficientData + /// Fast RMSSD is at/above the threshold — no dip. + case noDip + /// The dip isn't a fresh edge (already below last tick). + case notAnEdge + /// Suppressed by the exercise gate (HR out of band and/or recent motion = metabolic, not stress). + case exerciseGated + /// Inside the rate-limit window or quiet hours, or a manual session is running. + case suppressed + } + + /// The decision returned each evaluation: whether to nudge, why, and the next state to persist. Mirrors + /// `SedentaryDecision`: the caller acts on `shouldNudge` and stores `nextState` (always advanced) so a + /// replayed window can't re-fire. + public struct Decision: Equatable, Sendable { + /// True if the app should offer the breathing cue now (single confirming buzz + passive card). + public let shouldNudge: Bool + /// Why (whether or not it nudged). + public let reason: Reason + /// Buzz loops to play when `shouldNudge` (the confirming buzz). + public let buzzLoops: Int + /// The fast short-window RMSSD this tick (ms), or nil when insufficient — for logs / the card. + public let fastRMSSD: Double? + /// The slow baseline RMSSD this tick (ms), or nil when uninitialised. + public let baselineRMSSD: Double? + /// The state to persist for the next evaluation (always carries the advanced EMA / edge / clock). + public let nextState: State + + public init(shouldNudge: Bool, reason: Reason, buzzLoops: Int, + fastRMSSD: Double?, baselineRMSSD: Double?, nextState: State) { + self.shouldNudge = shouldNudge; self.reason = reason; self.buzzLoops = buzzLoops + self.fastRMSSD = fastRMSSD; self.baselineRMSSD = baselineRMSSD; self.nextState = nextState + } + } + + // MARK: - The detector + + /// Evaluate the live window and decide whether to fire a JITAI nudge. + /// + /// - `rrBuffer`: the rolling clean-able R-R buffer (rrMs, newest LAST). The fast RMSSD is taken over + /// the latest `fastWindowBeats` clean beats; the slow baseline EMA absorbs each trusted fast value. + /// - `currentHR`: latest smoothed live HR (bpm), or nil if unknown (then the HR half of the gate can't + /// pass and we treat HR as out-of-band — conservative). + /// - `recentMotionG`: recent smoothed wrist-motion (g) from `collector.recentGravity`, or nil if no + /// recent gravity (then the motion half of the gate is inconclusive — see below). + /// - `sessionActive`: true if a manual Breathe/L1/L2 session is already running (never nudge over it). + /// - `state`: the prior persisted state; `nowSec` / `tzOffsetSec` passed IN (never read a clock). + /// + /// The EXERCISE GATE suppresses when EITHER signal says metabolic: HR outside [55,100], OR recent + /// motion at/above `motionGateG`. A missing HR is treated as out-of-band (can't confirm resting); + /// missing motion alone does NOT gate (HR-band can carry it — gravity is offloaded and lags, spec Q3), + /// so the resting-HR band is the real-time gate and motion is a secondary confirm when present. + public static func evaluate(rrBuffer: [Int], + currentHR: Double?, + recentMotionG: Double?, + sessionActive: Bool, + state: State, + config: Config, + nowSec: Int, + tzOffsetSec: Int) -> Decision { + + // 1) Master gates: off / auto-nudge off → never nudge, state untouched. + if !config.enabled || !config.autoNudge { + return Decision(shouldNudge: false, reason: .disabled, buzzLoops: config.buzzLoops, + fastRMSSD: nil, baselineRMSSD: state.baselineRMSSD > 0 ? state.baselineRMSSD : nil, + nextState: state) + } + + // 2) Fast RMSSD over the latest clean beats. Clean first (range + Malik), then take the tail. + let cleanAll = HRVAnalyzer.cleanRR(rrBuffer.map { Double($0) }) + let fastWindow = cleanAll.count > fastWindowBeats + ? Array(cleanAll.suffix(fastWindowBeats)) + : cleanAll + guard fastWindow.count >= minBeats, let fast = HRVAnalyzer.rmssdRaw(fastWindow), fast > 0 else { + // Not enough signal — report, don't guess. Edge state is preserved (no crossing observed). + return Decision(shouldNudge: false, reason: .insufficientData, buzzLoops: config.buzzLoops, + fastRMSSD: nil, baselineRMSSD: state.baselineRMSSD > 0 ? state.baselineRMSSD : nil, + nextState: state) + } + + // 3) Advance the slow baseline EMA (seed on first trusted value), exactly like evaluateStress. + var next = state + next.baselineRMSSD = state.baselineRMSSD == 0 + ? fast + : state.baselineRMSSD * baselineEmaAlpha + fast * (1.0 - baselineEmaAlpha) + let baseline = next.baselineRMSSD + + // 4) Is the fast RMSSD below the drop threshold? (the dip test) + let threshold = baseline * dropRatio + let isBelow = fast < threshold + // The edge: a FRESH crossing (above on the previous tick → below now). Always record the new + // below-state so the NEXT tick can edge-detect, regardless of whether we fire. + let isEdge = isBelow && !state.wasBelow + next.wasBelow = isBelow + + func decide(_ nudge: Bool, _ reason: Reason) -> Decision { + Decision(shouldNudge: nudge, reason: reason, buzzLoops: config.buzzLoops, + fastRMSSD: fast, baselineRMSSD: baseline, nextState: next) + } + + if !isBelow { return decide(false, .noDip) } + if !isEdge { return decide(false, .notAnEdge) } + + // 5) Exercise gate (the credibility line). HR out of the resting band (or unknown) → metabolic. + // Recent motion at/above the gate → metabolic. Either suppresses. + let hrInBand: Bool = { + guard let hr = currentHR else { return false } // unknown HR can't confirm resting → gate + return hr >= restingHRLow && hr <= restingHRHigh + }() + let moving: Bool = { + guard let m = recentMotionG else { return false } // no recent gravity → motion inconclusive + return m >= motionGateG + }() + if !hrInBand || moving { return decide(false, .exerciseGated) } + + // 6) Suppressors: a manual session is running, the rate limit, or quiet hours. + if sessionActive { return decide(false, .suppressed) } + if state.lastFireAt != 0 && (nowSec - state.lastFireAt) < minSecondsBetweenFires { + return decide(false, .suppressed) + } + if config.quietHoursEnabled { + let mod = SedentaryDetector.localMinuteOfDay(nowSec, tzOffsetSec: tzOffsetSec) + if SedentaryDetector.windowContains(mod, startMin: config.quietStartMinutes, + endMin: config.quietEndMinutes) { + return decide(false, .suppressed) + } + } + + // 7) Fire — a fresh, non-metabolic HRV dip while still. Stamp the rate-limit clock. + next.lastFireAt = nowSec + return decide(true, .onset) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/TestCentreLayout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/TestCentreLayout.swift new file mode 100644 index 0000000000..302f8de851 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/TestCentreLayout.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Pure projection of the registry for the Test Centre screen's section 1 (domain test modes). +/// +/// Shipped modes only (the registry is already Phase 1 only), priority-ordered high then med then low +/// with registry order stable inside a band, and requires5MG modes hidden off a non-5/MG strap (spec +/// section 12, the #22 gating question). No app import, so both platforms render the same order. The +/// status helper formats each row's status string identically across iOS and Android. The Kotlin twin +/// is TestCentreLayout.kt, kept aligned by a parity test. +public enum TestCentreLayout { + + /// Rank a priority so high sorts before med before low; ties keep their input order (stable sort). + static func rank(_ p: TestPriority) -> Int { + switch p { + case .high: return 0 + case .med: return 1 + case .low: return 2 + } + } + + /// Order an arbitrary mode list (the registry, or a test fixture) for the screen. Stable within a + /// priority band so registry order decides ties. + public static func order(_ modes: [TestMode], is5MG: Bool) -> [TestMode] { + modes + .filter { is5MG || !$0.requires5MG } + .enumerated() + .sorted { a, b in + let ra = rank(a.element.priority), rb = rank(b.element.priority) + return ra == rb ? a.offset < b.offset : ra < rb + } + .map { $0.element } + } + + /// The shipped registry projected for the current strap. Section 1 of the screen binds this. + public static func visibleModes(is5MG: Bool) -> [TestMode] { + order(TestModeRegistry.all, is5MG: is5MG) + } +} + +public extension TestCentreLayout { + + /// The row status string. "Off" when inactive; "On" for an active toggle mode; "Capturing K of N + /// " for an active guided mode. `unit` is the mode's own word ("nights" / "days"), so Sleep and + /// Battery read naturally. No em-dash. + /// + /// K is the HONEST per-mode capture count (#965): the number of DISTINCT days this mode actually + /// produced a trace on (`capturedUnits`, from `CaptureAccumulator`), so each active mode INDEPENDENTLY + /// accumulates its own count rather than every row sharing one elapsed-clock number. `capturedUnits` + /// is clamped to [0, target]: a dead-trace mode reads "0 of N" honestly (it captured nothing), and a + /// mode run past its window reads "N of N" (it never over-runs). + /// + /// `capturedUnits == nil` falls back to the legacy elapsed-day proxy (`ceil(elapsedSeconds / 1 day)`, + /// clamped to [1, target]) for callers that cannot supply a real count (previews / a screen with no log + /// yet); the live Test Centre row supplies the accumulator count so the shipped counter is data-driven. + static func statusText(for mode: TestMode, active: Bool, elapsedSeconds: Double?, + capturedUnits: Int? = nil) -> String { + guard active else { return "Off" } + switch mode.capture { + case .toggle: + return "On" + case let .guided(unit, defaultCount): + let k: Int + if let captured = capturedUnits { + // Honest data-driven count: distinct captured days, clamped to [0, target]. + k = min(max(captured, 0), defaultCount) + } else { + // Legacy elapsed-clock proxy (no real count available), clamped to [1, target]. + let elapsed = max(0, elapsedSeconds ?? 0) + let dayIndex = Int(ceil(elapsed / 86_400.0)) + k = min(max(dayIndex, 1), defaultCount) + } + return "Capturing \(k) of \(defaultCount) \(unit.rawValue)" + } + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/TestDomain.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/TestDomain.swift new file mode 100644 index 0000000000..f2db52aac2 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/TestDomain.swift @@ -0,0 +1,32 @@ +import Foundation + +/// The domain tag stamped on each Test Centre log line and used to filter the export bundle. +/// +/// Phase 1 declares the full id set so later phases need only flip emitters on, but only `.sleep` +/// and `.battery` have emitters wired now. `.universal` is the preamble plus the three derived traces +/// that ride every export. `.master` is "log everything". This is a pure value type living in +/// StrandAnalytics so engines can tag without importing the app. The Kotlin twin is TestDomain.kt, +/// kept byte-aligned by a parity test. +public enum TestDomain: String, CaseIterable, Sendable, Codable { + case universal // preamble plus the 3 derived traces; always present under any active mode + case sleep // 1 Sleep and Rest (guided, nights) + case connection // 2 Connection and Sync + case workouts // 3 Workouts and GPS + case display // 4 Display and Performance (plus screenshot) + case dataImport // 5 Import and Data Ingest (raw value "import" is a reserved word, avoided) + case steps // 6 Steps + case notifications // 7 Notifications, Alarm and Wake + case battery // 8 Battery and Charging (guided, days) + case recovery // 9 Recovery (Charge) + case hrv // 10 HRV and Autonomic + case sources // 11 Sources, Fusion and Metric Decode + case stress // 12 Stress and Illness + case longevity // 13 Longevity, Cycles and Haptics + case master // Log Everything + + /// Stable wire id used in log tags, meta.json and the GitHub label. NOTE: `dataImport` maps to "import". + public var id: String { self == .dataImport ? "import" : rawValue } + + /// GitHub label the deep-link self-applies, e.g. "test:sleep". `master` becomes "test:all". + public var githubLabel: String { self == .master ? "test:all" : "test:\(id)" } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/TestModeRegistry.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/TestModeRegistry.swift new file mode 100644 index 0000000000..a531d7eefa --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/TestModeRegistry.swift @@ -0,0 +1,193 @@ +import Foundation + +/// Whether a guided capture counts nights (Sleep) or days (Battery). +public enum CaptureUnit: String, Sendable, Codable { case nights, days } + +/// How a mode captures: a plain on/off toggle, or a guided "wear it for N nights/days" window. +public enum CaptureKind: Sendable, Codable, Equatable { + case toggle + case guided(unit: CaptureUnit, defaultCount: Int) // "defaultCount" (not "default", a reserved word) +} + +/// Display priority on the Test Centre screen. +public enum TestPriority: String, Sendable, Codable { case high, med, low } + +/// One questionnaire prompt declared by a mode. Answers are stored in meta.json under `id`. +public struct Question: Sendable, Codable, Equatable { + public let id: String // stable key stored in the meta.json questionnaire map + public let prompt: String + public enum Kind: String, Sendable, Codable { case yesNo, text, time, choice } + public let kind: Kind + public let choices: [String] // only for .choice; else [] + public init(id: String, prompt: String, kind: Kind, choices: [String] = []) { + self.id = id; self.prompt = prompt; self.kind = kind; self.choices = choices + } +} + +/// A test mode is DATA, not code (spec section 3.1). The screen, the export and the questionnaire all +/// render from this. `captures` / `liveReadout` are declarative ids; the emitters and readout panels +/// bind to them by name. Phase 1 registers exactly `.sleep` and `.battery`. +public struct TestMode: Sendable, Identifiable { + public let domain: TestDomain + public let title: String + public let blurb: String + public let icon: String // SF Symbol on Apple; mapped to a drawable id on Android + public let priority: TestPriority + public let captures: [String] // LogVariable ids, declarative + public let questionnaire: [Question] + public let liveReadout: [String] // ReadoutSpec ids the in-app panel binds + public let capture: CaptureKind + public let includesScreenshot: Bool + public let requires5MG: Bool + public var id: String { domain.id } + + public init(domain: TestDomain, title: String, blurb: String, icon: String, priority: TestPriority, + captures: [String], questionnaire: [Question], liveReadout: [String], + capture: CaptureKind, includesScreenshot: Bool, requires5MG: Bool) { + self.domain = domain; self.title = title; self.blurb = blurb; self.icon = icon + self.priority = priority; self.captures = captures; self.questionnaire = questionnaire + self.liveReadout = liveReadout; self.capture = capture + self.includesScreenshot = includesScreenshot; self.requires5MG = requires5MG + } +} + +/// The single source the Test Centre IA iterates. Order is priority order on screen. The Kotlin twin is +/// TestModeRegistry.kt, byte-aligned (same ids, titles, captures), verified by a parity test. +public enum TestModeRegistry { + + /// Phase 1 shipped sleep + battery; Phase 2 appends the 🔴 high-pain domains plus the scoring chain + /// (connection, workouts, display, import, steps, recovery, hrv). Order is screen priority order. + public static let all: [TestMode] = [ + sleep, connection, workouts, display, dataImport, steps, battery, recovery, hrv, + ] + + public static func mode(_ d: TestDomain) -> TestMode? { all.first { $0.domain == d } } + + static let sleep = TestMode( + domain: .sleep, title: "Sleep & Rest", + blurb: "Wear it a few nights so we can see which gate kept or dropped each sleep run.", + icon: "bed.double.fill", priority: .high, + captures: ["gateTrace", "gravityCoverage", "hrDensity", "wristOff", "perEpochFeatures", + "hypnogramV1V2", "ppgOnlyNight", "skinTempDsp", "restSubScores"], + questionnaire: [ + Question(id: "sleepTimes", prompt: "Your actual sleep, wake and out-of-bed times?", kind: .text), + Question(id: "awakeStill", prompt: "Any awake-but-still windows in bed?", kind: .text), + Question(id: "naps", prompt: "Any naps?", kind: .text), + Question(id: "shiftWork", prompt: "Shift work or an unusual schedule?", kind: .yesNo), + Question(id: "chargeTiming", prompt: "When did you charge the strap?", kind: .text), + Question(id: "healthSleep", prompt: "Is Apple Health / Health Connect also feeding sleep?", kind: .yesNo), + ], + liveReadout: ["hrDensityNow", "gravityCoverageNow", "lastNightGateFired"], + capture: .guided(unit: .nights, defaultCount: 3), + includesScreenshot: false, requires5MG: false) + + static let connection = TestMode( + domain: .connection, title: "Connection & Sync", + blurb: "Turn this on if the strap keeps dropping or won't finish a sync.", + icon: "antenna.radiowaves.left.and.right", priority: .high, + captures: ["connectTiming", "bondState", "frameTiming", "reconnectChurn", "offloadProgress", + "offloadStalls", "firmwareDecode", "clockDrift", "otherCentral"], + questionnaire: [ + Question(id: "otherDevicePaired", prompt: "Is another phone or the WHOOP app paired to the strap right now?", kind: .yesNo), + ], + liveReadout: ["connectionUptime", "reconnectCount", "lastOffloadResult"], + capture: .toggle, + includesScreenshot: false, requires5MG: false) + + static let workouts = TestMode( + domain: .workouts, title: "Workouts & GPS", + blurb: "Turn this on if a workout went missing or auto-detect didn't fire.", + icon: "figure.run", priority: .high, + captures: ["sessionLifecycle", "hrSamples", "gpsFixes", "autoDetectThresholds", + "autoDetectWhy", "crossSourceDedup"], + questionnaire: [ + Question(id: "startMethod", prompt: "Did you start it manually or expect auto-detect?", kind: .text), + ], + liveReadout: ["lastSessionSummary"], + capture: .toggle, + includesScreenshot: false, requires5MG: false) + + static let display = TestMode( + domain: .display, title: "Display & Performance", + blurb: "Turn this on if a screen looks wrong or feels laggy, then grab a shot.", + icon: "paintbrush.fill", priority: .high, + captures: ["screenshot", "deviceMetrics", "frameTimeTrace", "memoryHighWater"], + questionnaire: [ + Question(id: "screenAndIssue", prompt: "What screen, and what looked or felt wrong (laggy/clipped)?", kind: .text), + ], + liveReadout: ["deviceMetricsNow"], + capture: .toggle, + includesScreenshot: true, requires5MG: false) + + static let dataImport = TestMode( + domain: .dataImport, title: "Import & Data Ingest", + blurb: "Turn this on if a file import dropped rows or came in wrong.", + icon: "square.and.arrow.down", priority: .high, + // Only the captures a production import actually emits: parser identity, file meta, per-stage + // rows, reject counts and day deltas. firstFailingRow / failingFileSample / dedupMerge were + // advertised but no emitter on either platform produces them (the import emit runs after the full + // parse, not at the parser reject seam), so they were dropped from BOTH registries to keep the + // mode honest and the platforms in parity rather than over-promising. + captures: ["parserVersion", "fileMeta", "perStageRows", "rejectCounts", "dayDeltas"], + questionnaire: [ + Question(id: "appFormatExpected", prompt: "Which app/format, and what did you expect to import?", kind: .text), + ], + liveReadout: ["lastImportSummary"], + capture: .toggle, + includesScreenshot: false, requires5MG: false) + + static let steps = TestMode( + domain: .steps, title: "Steps", + blurb: "Turn this on if your step count looks off versus your phone.", + icon: "shoeprints.fill", priority: .high, + captures: ["motionVolume", "stepCalibration", "phoneReferenceCount", "rawStepCounter", + "wrapAwareDeltas", "droppedDeltas"], + questionnaire: [ + Question(id: "otherTrackerSteps", prompt: "What did your phone or another tracker report for the same day?", kind: .text), + ], + liveReadout: ["stepsToday", "calibrationState"], + capture: .toggle, + includesScreenshot: false, requires5MG: false) + + static let battery = TestMode( + domain: .battery, title: "Battery & Charging", + blurb: "Wear it a few days so we can fit your real discharge slope.", + icon: "battery.50", priority: .med, + captures: ["socSeries", "chargeSteps", "offWristGaps", "dischargeRun", "fittedSlope", + "sourceMeasuredVsRated", "batteryGates"], + questionnaire: [ + Question(id: "whoopAppInstalled", prompt: "Is the official WHOOP app installed?", kind: .yesNo), + Question(id: "otherPhonePaired", prompt: "Is another phone paired to the strap?", kind: .yesNo), + Question(id: "chargedInWindow", prompt: "Did you charge during the capture?", kind: .yesNo), + Question(id: "batterySaverApps", prompt: "Any battery-saver apps running?", kind: .text), + ], + liveReadout: ["currentSoc", "estimateDaysLeft", "slopeSource"], + capture: .guided(unit: .days, defaultCount: 3), + includesScreenshot: false, requires5MG: false) + + static let recovery = TestMode( + domain: .recovery, title: "Recovery (Charge)", + blurb: "Turn this on if Charge looks wrong, to see which term moved it.", + icon: "heart.text.square.fill", priority: .med, + captures: ["chargeTermBreakdown", "baselinesPerNight", "termZScores", "nilTerm", + "forecastInputs"], + questionnaire: [ + Question(id: "recalHealthHrv", prompt: "Recent recalibration? Is Apple Health / Health Connect feeding HRV?", kind: .text), + ], + liveReadout: ["lastChargeBreakdown"], + capture: .toggle, + includesScreenshot: false, requires5MG: false) + + static let hrv = TestMode( + domain: .hrv, title: "HRV & Autonomic", + blurb: "Turn this on if HRV reads nil or looks off, to see the clean beats.", + icon: "waveform.path.ecg", priority: .med, + captures: ["rawRR", "nInputCleanRejected", "rmssdSdnn", "minBeatsCleared", + "spotVsContinuous", "respRsa"], + questionnaire: [ + Question(id: "otherAppHrv", prompt: "Is another app feeding HRV to Apple Health / Health Connect?", kind: .yesNo), + ], + liveReadout: ["lastHrvComputation"], + capture: .toggle, + includesScreenshot: false, requires5MG: false) +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/UniversalTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/UniversalTrace.swift new file mode 100644 index 0000000000..78649beed4 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/UniversalTrace.swift @@ -0,0 +1,59 @@ +import Foundation + +// UniversalTrace.swift - the lines that ride EVERY Test Centre export, not only in Connection mode. +// +// The dayOwner line (built in the app's IntelligenceEngine) already rides every export tagged `.universal`. +// This adds the strap CLOCK-DRIFT + firmware-layout picture to that universal block so the RTC cluster +// (#531 / #767 / #804 / #812) self-diagnoses on every export. Previously the clock-drift summary was only +// emitted while the Connection test mode was on (BLEManager, gated on TestCentre.active(.connection)), so a +// Sleep or Battery report from a clock-broken strap never carried the one line that explains the failure. +// Hoisting it to the universal block means ANY active mode surfaces it. +// +// Pure + side-effect-free: no clock read of its own, no I/O. The caller (the export assembler) passes the +// last strap-reported banked-record window and its own wall-now; this formats one line. No PII (ISO dates, +// counts and a firmware version int only). No em-dashes. The Kotlin twin is UniversalTrace.kt. + +public enum UniversalTrace { + + /// The universal strap-clock line: the strap's newest banked-record timestamp vs wall clock, with a + /// FUTURE-DATE flag (the tell of a wandering / un-clocked RTC), the optional banked span in days, and the + /// firmware record-layout version the strap hands over. One line, tagged `.universal` by the caller, so + /// every export self-diagnoses the clock/firmware state behind the #531/#767/#804/#812 cluster. + /// + /// All timestamps are unix seconds in the same wall domain (the BLE layer decodes oldest/newest from the + /// strap's GET_DATA_RANGE reply and the caller passes its own wall-now), so the future test is a plain + /// comparison. `oldestUnix` is optional (a short range reply gives only the upper bound). `firmwareLayout` + /// is the historical record-layout version (18/24/25/26) the strap emits, or nil when not yet observed + /// this session; it is reported as "v" or "unknown" so the line is always firmware-aware. + /// + /// - Parameter futureToleranceSeconds: slack before flagging FUTURE; a strap RTC vs phone skew of a + /// minute or two is normal, so the default mirrors a couple of minutes. + /// - Parameter behindToleranceSeconds: slack before flagging a BEHIND drift (#990) - a newest banked + /// record naturally trails wall time by hours, so the default is 48 h; beyond that "clockOk" was a + /// false all-clear (a -363 d drift used to read clockOk). + public static func clockDriftLine(newestUnix: Int, + wallNowUnix: Int, + oldestUnix: Int? = nil, + firmwareLayout: Int? = nil, + futureToleranceSeconds: Int = 120, + behindToleranceSeconds: Int = ConnectionTrace.behindToleranceDefault) -> String { + let aheadSeconds = newestUnix - wallNowUnix + var line = "strapClock newest=\(ConnectionTrace.isoDate(newestUnix)) " + + "wall=\(ConnectionTrace.isoDate(wallNowUnix)) " + + "newestVsWall=\(ConnectionTrace.signed(aheadSeconds))s" + if let oldestUnix, oldestUnix < newestUnix { + // Round to the nearest whole day so a near-3-day window reads spanDays=3, not 2 (60s shy + // of exactly three days should still report three days of banked history). + let spanDays = max(0, Int((Double(newestUnix - oldestUnix) / 86_400).rounded())) + line += " oldest=\(ConnectionTrace.isoDate(oldestUnix)) spanDays=\(spanDays)" + } + line += firmwareLayout.map { " firmware=v\($0)" } ?? " firmware=unknown" + // The verdict is SHARED with ConnectionTrace.clockDriftLine (#990/#987) so the universal and + // Connection lines can never disagree about what counts as a clock fault: FUTURE-DATED, + // RTC-EPOCH (~1970/71 never-set clock), CLOCK-WARNING (behind beyond +-48 h), else clockOk. + line += ConnectionTrace.clockVerdict(aheadSeconds: aheadSeconds, newestUnix: newestUnix, + futureToleranceSeconds: futureToleranceSeconds, + behindToleranceSeconds: behindToleranceSeconds) + return line + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/VitalBands.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/VitalBands.swift new file mode 100644 index 0000000000..017b9e00b7 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/VitalBands.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Personal-baseline banding for the Health Monitor's vital tiles. +/// +/// In-range is judged against the user's OWN trailing baseline (the Winsorized EWMA +/// the rest of `Baselines` builds) once that baseline is trusted — `Baselines.minNightsTrust` +/// (14) valid nights and not stale. Until then, and again whenever a wear gap makes the +/// baseline stale, the fixed population range is the fallback. +/// +/// `MetricCfg`'s physiological bounds stay an absolute outer guard either way. They are +/// deliberately NOT used as the in-range band: doing so would resurrect the exact false +/// positive this fixes — a perfectly normal personal HRV of 35 ms reading permanently +/// out-of-range against the 40–120 population band. The bounds only catch values that are +/// implausible for any human (e.g. an HRV of 300 ms), which no personal spread should excuse. +/// +/// APPROXIMATE — informational, not a diagnosis. +public enum VitalBands { + + public enum Band: String, Equatable, Sendable { case inRange, outOfRange, noData } + + /// How the band was judged — drives the tile's caption wording. + public enum Basis: String, Equatable, Sendable { case personal, population } + + public struct Result: Equatable, Sendable { + public let band: Band + public let basis: Basis + /// Valid nights backing the personal baseline (0 when none). + public let nights: Int + public init(band: Band, basis: Basis, nights: Int) { + self.band = band + self.basis = basis + self.nights = nights + } + } + + /// |z| at or below this is in-range vs the personal baseline — about 95% of the user's + /// own normal nights. `Baselines.deviation`'s own `inNormalRange` (|z| <= 1) would flag + /// roughly a third of normal nights, which is far too noisy for a passive at-a-glance tile. + public static let sigmaK: Double = 2.0 + + /// Band a single vital `value`. + /// + /// - Parameters: + /// - value: today's value, or nil for no data. + /// - history: nightly values oldest→newest EXCLUDING the displayed day. A nil entry is + /// a missing night; use `calendarSeries` first to pad real wear gaps so staleness sees them. + /// - populationRange: the fixed typical-adult range used as the cold-start / stale fallback. + /// - cfg: nil disables the personal path entirely (SpO2 stays population-only — there is + /// no SpO2 `MetricCfg` and an absolute floor is meaningful regardless of personal history). + public static func band(value: Double?, + history: [Double?], + populationRange: ClosedRange, + cfg: MetricCfg?) -> Result { + guard let value else { return Result(band: .noData, basis: .population, nights: 0) } + guard let cfg else { + return Result(band: populationRange.contains(value) ? .inRange : .outOfRange, + basis: .population, nights: 0) + } + let state = Baselines.foldHistory(history, cfg: cfg) + // Absolute-plausibility outer guard: a value outside the physiological bounds is + // out-of-range no matter how wide the personal spread happens to be. + guard cfg.minVal <= value && value <= cfg.maxVal else { + return Result(band: .outOfRange, basis: .population, nights: state.nValid) + } + if state.trusted { // >= 14 valid nights and not stale + let z = Baselines.deviation(value, state: state).z + return Result(band: abs(z) <= sigmaK ? .inRange : .outOfRange, + basis: .personal, nights: state.nValid) + } + return Result(band: populationRange.contains(value) ? .inRange : .outOfRange, + basis: .population, nights: state.nValid) + } + + // MARK: - Skin temp (mixed semantics: absolute °C from CSV import vs ±°C on-device deviation) + + /// A skin-temp value >= 20 °C is read as an ABSOLUTE skin temperature; smaller magnitudes + /// are read as a ±°C deviation. The WHOOP CSV export stores absolute °C in its skin-temp + /// column while NOOP's on-device pipeline stores a deviation from the personal baseline, so + /// a merged series is bimodal. The displayed value picks which kind its history keeps. + /// Heuristic but physically safe: no real wrist skin temp is below 20 °C, and no real + /// nightly deviation reaches ±20 °C. + public static func isAbsoluteSkinTemp(_ v: Double) -> Bool { v >= 20.0 } + + /// Keep only history entries of the SAME kind (absolute vs deviation) as the displayed + /// `value`; entries of the other kind become nil (missing nights) so the baseline that + /// `band` folds isn't computed across two incompatible scales. + public static func skinTempHistory(matching value: Double, in history: [Double?]) -> [Double?] { + let absolute = isAbsoluteSkinTemp(value) + return history.map { v in + guard let v else { return nil } + return isAbsoluteSkinTemp(v) == absolute ? v : nil + } + } + + /// Deviation-semantics config for on-device skin-temp rows: ±°C around the personal mean, + /// guarded to a physically sane ±8 °C. (The standard `skin_temp` config in `Baselines` + /// is the ABSOLUTE-°C one, used for CSV-imported rows.) + public static let skinTempDeviationCfg = MetricCfg( + minVal: -8.0, maxVal: 8.0, floorSpread: 0.3, halfLifeB: 14.0, halfLifeS: 21.0) + + // MARK: - Calendar padding + + /// Calendar-align (day, value) rows keyed "yyyy-MM-dd" into a nightly series with nil for + /// every absent day, so the baseline's staleness logic actually sees wear gaps. Stored rows + /// simply skip days the strap wasn't worn; without padding, a user returning after two months + /// would be banded against an ancient still-"trusted" baseline. Malformed day keys are dropped. + /// Pure: fixed UTC math over the day keys only (no device clock). + public static func calendarSeries(_ rows: [(day: String, value: Double?)]) -> [Double?] { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(secondsFromGMT: 0)! + let dates = rows.compactMap { f.date(from: $0.day) } + guard let first = dates.min(), let last = dates.max() else { return [] } + // Last write wins for a duplicated day key, matching the dictionary the Kotlin port builds. + var byDay: [String: Double?] = [:] + for r in rows where f.date(from: r.day) != nil { byDay[r.day] = r.value } + var out: [Double?] = [] + var d = first + while d <= last { + out.append(byDay[f.string(from: d)] ?? nil) + guard let next = cal.date(byAdding: .day, value: 1, to: d) else { break } + d = next + } + return out + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/VitalityEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/VitalityEngine.swift new file mode 100644 index 0000000000..fdd872d683 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/VitalityEngine.swift @@ -0,0 +1,167 @@ +import Foundation + +// VitalityEngine.swift — a transparent 0–100 "Vitality" wellness score + an optional "Body Age in years". +// +// INDEPENDENT implementation of the published, peer-reviewed method WHOOP's "Healthspan / WHOOP Age" also +// uses (NOT medical advice; a wellness comparison, never a clinical biological age): map each wearable- +// measurable input to its published ALL-CAUSE-MORTALITY hazard ratio relative to a population reference, +// sum the log-hazards with an overlap correction (the inputs are correlated, so the naive sum overstates), +// and convert that combined hazard into a "years of aging" offset using the Gompertz mortality-rate +// doubling time (mortality roughly doubles every ~8 years, so 1 doubling of hazard ≈ 8 years of age). +// +// Body Age = chronological age + Δage. An average-for-their-age person nets ~0 and reads at their own age; +// healthier-than-average reads younger, less healthy reads older. Presented with a ±band and a hard +// "wellness trend, not a biological/clinical age" disclaimer, gated on a minimum number of inputs. +// +// Per-factor hazard ratios are taken from large cohorts / meta-analyses (UK Biobank, FRIEND, pooled +// step- and activity-mortality meta-analyses, sleep-regularity and HRV cohorts). They are deliberately +// CONSERVATIVE and the model is clamped, because this is a wellness estimate, not a diagnosis. +public enum VitalityEngine { + + // Gompertz: mortality-rate doubling time ≈ 8 years → ln(hazard) per year of age = ln(2)/8. + static let lnHazardPerYear = 0.6931471805599453 / 8.0 // ≈ 0.0866 + /// Correlated inputs (fitness, RHR, activity all move together) → shrink the naive log-hazard sum so + /// we don't multiply the same underlying signal several times. 0.75 is a deliberately gentle shrink. + static let overlapShrink = 0.75 + /// Body Age is clamped to a sane band; Vitality maps Δage linearly around 50 (= "at your age"). + static let minBodyAge = 20.0, maxBodyAge = 90.0 + static let vitalityPerYear = 2.5 // each year younger than your age = +2.5 Vitality points + + /// The wearable inputs Vitality reads. All optional — the score uses whatever is present (≥ minFactors). + public struct Inputs: Equatable, Sendable { + public var chronoAge: Double + public var restingHR: Double? // bpm + public var vo2max: Double? // ml/kg/min (e.g. from FitnessAgeEngine) + public var expectedVO2max: Double? // age/sex-expected ml/kg/min (the reference for vo2max) + public var sleepHours: Double? // mean nightly sleep + public var sleepConsistency: Double? // 0–1 regularity (1 = perfectly regular) + public var rmssd: Double? // ms, nocturnal HRV + public var rmssdNorm: Double? // age/sex-normative RMSSD (the reference) + public var steps: Double? // mean daily steps + public init(chronoAge: Double, restingHR: Double? = nil, vo2max: Double? = nil, + expectedVO2max: Double? = nil, sleepHours: Double? = nil, + sleepConsistency: Double? = nil, rmssd: Double? = nil, + rmssdNorm: Double? = nil, steps: Double? = nil) { + self.chronoAge = chronoAge; self.restingHR = restingHR; self.vo2max = vo2max + self.expectedVO2max = expectedVO2max; self.sleepHours = sleepHours + self.sleepConsistency = sleepConsistency; self.rmssd = rmssd + self.rmssdNorm = rmssdNorm; self.steps = steps + } + } + + /// One factor's contribution: its label and signed log-hazard vs the population reference + /// (positive = ages you, negative = protective). + public struct Contribution: Equatable, Sendable { + public let key: String + public let label: String + public let lnHazard: Double + public init(key: String, label: String, lnHazard: Double) { + self.key = key; self.label = label; self.lnHazard = lnHazard + } + } + + public struct Result: Equatable, Sendable { + public let vitality: Double // 0–100 (50 = typical for your age) + public let bodyAge: Double // years, clamped + public let chronoAge: Double + public let deltaYears: Double // chronoAge − bodyAge (positive = younger than your age) + public let bandYears: Double + public let contributions: [Contribution] // for the "what's driving this" breakdown + public let factorsUsed: Int + public init(vitality: Double, bodyAge: Double, chronoAge: Double, deltaYears: Double, + bandYears: Double, contributions: [Contribution], factorsUsed: Int) { + self.vitality = vitality; self.bodyAge = bodyAge; self.chronoAge = chronoAge + self.deltaYears = deltaYears; self.bandYears = bandYears + self.contributions = contributions; self.factorsUsed = factorsUsed + } + } + + /// Minimum distinct factors before we'll show a number (honesty gate). + public static let minFactors = 3 + public static let bandYears = 5.0 + + private static func clamp(_ v: Double, _ lo: Double, _ hi: Double) -> Double { min(hi, max(lo, v)) } + + /// Nocturnal RMSSD ~50th-percentile by age (ms), piecewise-linear between decade anchors (the WHOOP- + /// window norms banked in the spec — never mixed with daytime clinical norms). The reference for the + /// HRV factor: a person at the age norm contributes 0. + public static func rmssdNorm(forAge age: Double) -> Double { + let anchors: [(Double, Double)] = [(20, 47), (30, 40), (40, 33), (50, 29), (60, 25), (70, 22), (80, 20)] + if age <= anchors[0].0 { return anchors[0].1 } + if age >= anchors[anchors.count - 1].0 { return anchors[anchors.count - 1].1 } + for i in 1.. Double? { + let xs = nightlyHours.filter { $0 > 0 } + guard xs.count >= 3 else { return nil } + let mean = xs.reduce(0, +) / Double(xs.count) + guard mean > 0 else { return nil } + let variance = xs.reduce(0) { $0 + ($1 - mean) * ($1 - mean) } / Double(xs.count) + let cv = variance.squareRoot() / mean + return clamp(1 - cv, 0, 1) + } + + /// Compute the per-factor log-hazard contributions present in `inputs`. Each references a population + /// value, so an average person nets ~0. Published per-unit hazard ratios (conservative, clamped): + /// • Resting HR: +~10.5% all-cause mortality per +10 bpm (UK Biobank / meta-analyses). ref 65. + /// • VO₂max: ~14% per MET (3.5 ml/kg/min) vs the age/sex-expected value (FRIEND). fitter = protective. + /// • Sleep duration: U-shaped, optimum ~7.5 h; only deviation beyond ±0.5 h adds hazard (~12%/h). + /// • Sleep regularity: most-regular vs least ≈ HR 0.70 (UK Biobank SRI). ref 0.75 of the 0–1 range. + /// • HRV (RMSSD): ~16% per relative SD below the age norm (lower HRV = higher hazard). + /// • Steps: ~12% per 1,000 steps/day up to ~7k, diminishing to ~11k (pooled step-mortality meta). + public static func contributions(_ inputs: Inputs) -> [Contribution] { + var out: [Contribution] = [] + if let rhr = inputs.restingHR { + out.append(Contribution(key: "rhr", label: "Resting heart rate", + lnHazard: ((rhr - 65) / 10) * 0.100)) + } + if let vo2 = inputs.vo2max, let exp = inputs.expectedVO2max, exp > 0 { + // (expected − vo2): if fitter than expected this is negative → protective. + out.append(Contribution(key: "vo2max", label: "Cardio fitness", + lnHazard: clamp((exp - vo2) / 3.5, -4, 4) * 0.130)) + } + if let sh = inputs.sleepHours { + let dev = max(0, abs(sh - 7.5) - 0.5) // only deviation > ±0.5 h is a risk; optimum is neutral + out.append(Contribution(key: "sleep", label: "Sleep duration", + lnHazard: clamp(dev, 0, 3) * 0.110)) + } + if let c = inputs.sleepConsistency { + out.append(Contribution(key: "consistency", label: "Sleep regularity", + lnHazard: (0.75 - clamp(c, 0, 1)) * 0.450)) + } + if let h = inputs.rmssd, let norm = inputs.rmssdNorm, norm > 0 { + out.append(Contribution(key: "hrv", label: "Heart-rate variability", + lnHazard: clamp((norm - h) / norm, -1, 1) * 0.160)) + } + if let s = inputs.steps { + // Below ~7k each −1,000 steps adds hazard; protection caps near 11k (diminishing returns). + let deficit = (7000 - clamp(s, 0, 11000)) / 1000 + out.append(Contribution(key: "steps", label: "Daily steps", + lnHazard: clamp(deficit, -4, 4) * 0.064)) + } + return out + } + + /// Full Vitality + Body Age. Returns nil until at least `minFactors` inputs are present. + public static func compute(_ inputs: Inputs) -> Result? { + guard inputs.chronoAge > 0 else { return nil } + let contribs = contributions(inputs) + guard contribs.count >= minFactors else { return nil } + let sumLn = contribs.reduce(0) { $0 + $1.lnHazard } * overlapShrink + let deltaAge = sumLn / lnHazardPerYear // +ve = ages you + let bodyAge = clamp(inputs.chronoAge + deltaAge, minBodyAge, maxBodyAge) + let delta = inputs.chronoAge - bodyAge // +ve = younger than your age + let vitality = clamp(50 + delta * vitalityPerYear, 0, 100) + return Result(vitality: vitality, bodyAge: bodyAge, chronoAge: inputs.chronoAge, + deltaYears: delta, bandYears: bandYears, contributions: contribs, + factorsUsed: contribs.count) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WatchRecovery.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WatchRecovery.swift new file mode 100644 index 0000000000..2bd8f85b13 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WatchRecovery.swift @@ -0,0 +1,100 @@ +import Foundation + +// WatchRecovery.swift — recovery/Charge from Apple Watch DAILY aggregates. +// +// The honesty-critical piece of "Apple Watch as a device". A WHOOP strap gives us +// dense overnight RR intervals, so RecoveryScorer runs off raw-derived nightly RMSSD. +// The Apple Watch does NOT. It gives a handful of HRV SDNN readings a day plus a +// resting HR, both as daily aggregates. So watch recovery is a genuinely lower-density +// computation. +// +// We do NOT invent a new formula. Recovery is HRV-and-RHR-vs-personal-baseline, and +// because every term is relative to the person's OWN baseline, the metric scale cancels +// out: SDNN-vs-SDNN-baseline behaves like RMSSD-vs-RMSSD-baseline. So we build SDNN and +// RHR baselines through the existing `Baselines` machinery and feed them straight into +// the SAME `RecoveryScorer.recovery(...)` the strap uses. Watch recovery and strap +// recovery therefore land on the same 0-100 scale and read against the same bands. +// +// What we drop vs the strap path: the respiration, sleep-performance and skin-temp terms +// are not supplied here (the watch's daily aggregate doesn't carry them in the same shape), +// so RecoveryScorer renormalises the remaining HRV + RHR weights. The HRV term stays the +// dominant driver either way. +// +// The hard honesty rule: we return nil recovery + `.calibrating` when today's SDNN is +// missing, OR the SDNN baseline isn't usable yet, OR we have fewer than `minBaselineNights` +// nights of history. We NEVER fabricate a number to fill a sparse week. Confidence comes +// straight from the existing `ScoreConfidence.charge(recovery:hrvBaseline:)`, so the watch +// "calibrating → building → solid" arc is the same one the strap uses. +public enum WatchRecovery { + + /// Result of a watch-recovery computation: the score (nil while calibrating) and its + /// confidence tier. Same shape the strap path carries onto a DailyMetric. + public struct Result: Equatable, Sendable { + /// Recovery in [0, 100], or nil when we can't honestly score yet (calibrating). + public let recovery: Double? + /// Per-score confidence, driven by the real SDNN-baseline density (not a hardcoded label). + public let confidence: ScoreConfidence + + public init(recovery: Double?, confidence: ScoreConfidence) { + self.recovery = recovery + self.confidence = confidence + } + } + + /// Minimum nights of SDNN history before we'll score recovery from the watch. The spec's + /// honesty stance is to keep recovery "calibrating" for about a week of nights rather than + /// ship a misleading number off a thin baseline. This sits ABOVE the baseline's own seed + /// gate (`Baselines.minNightsSeed` = 4) deliberately: a strap user crosses the seed faster + /// on dense data, but the watch's sparse SDNN deserves a longer warm-up before we trust it. + public static let minBaselineNights = 7 + + /// Compute recovery/Charge from the watch's daily SDNN + resting HR vs the person's own baseline. + /// + /// - Parameters: + /// - todaySDNN: today's HRV SDNN reading (ms), or nil if the watch logged none. + /// - todayRHR: today's resting HR (bpm), or nil to drop the RHR term. + /// - sdnnHistory: ordered nightly SDNN values (oldest → newest), the baseline input. + /// - rhrHistory: ordered nightly resting-HR values (oldest → newest). + /// - Returns: a `Result` with recovery in [0,100] and a confidence tier, or nil recovery + + /// `.calibrating` when today's SDNN is missing, the baseline isn't usable, or history is thin. + public static func compute(todaySDNN: Double?, todayRHR: Int?, + sdnnHistory: [Double], rhrHistory: [Double]) -> Result { + // Build both baselines through the production model (Winsorized EWMA + cold-start gating), + // exactly as the strap path does. SDNN feeds the HRV config; resting HR feeds the RHR config. + let hrvBase = Baselines.foldHistory(sdnnHistory.map { Optional($0) }, cfg: Baselines.hrvCfg) + let rhrBase = Baselines.foldHistory(rhrHistory.map { Optional($0) }, cfg: Baselines.restingHRCfg) + + // Confidence is the SAME helper the strap Charge uses, so the calibrating → building → solid + // arc matches. It reads .calibrating whenever recovery would be nil (no usable HRV baseline), + // and below it we ALSO nil-out recovery, so the two stay consistent. + let conf = ScoreConfidence.charge(recovery: todaySDNN, hrvBaseline: hrvBase) + + // Honesty gate: no number unless we have today's SDNN, a usable baseline, AND at least a + // week of nights. Any miss → nil recovery + calibrating, never a fabricated value. + guard let sdnn = todaySDNN, + hrvBase.usable, + sdnnHistory.count >= minBaselineNights else { + return Result(recovery: nil, confidence: .calibrating) + } + + // Reuse the canonical Charge engine. Drop the resp / sleep / skin-temp terms (the watch + // daily aggregate doesn't carry them here) — RecoveryScorer renormalises to HRV + RHR. + // RHR is optional: when the watch logged no resting HR today we pass the HRV-only path. + let recovery = RecoveryScorer.recovery( + hrv: sdnn, + rhr: todayRHR.map(Double.init) ?? rhrBase.baseline, // missing RHR → at-baseline (z≈0, neutral term) + resp: nil, + hrvBaseline: hrvBase, + rhrBaseline: todayRHR != nil ? rhrBase : nil, // drop the RHR term entirely if no reading + respBaseline: nil, + sleepPerf: nil + ) + + // RecoveryScorer only returns nil on a cold-start HRV baseline, which we already gated above; + // but stay honest if it ever does — never coerce a nil into a number. + guard let recovery else { + return Result(recovery: nil, confidence: .calibrating) + } + return Result(recovery: recovery, confidence: conf) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WeeklyDigest.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WeeklyDigest.swift new file mode 100644 index 0000000000..26b6139867 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WeeklyDigest.swift @@ -0,0 +1,518 @@ +import Foundation + +// WeeklyDigest.swift — a deterministic, offline "week in review". +// +// Pure, deterministic, DB-free. Given the daily series for each tracked metric +// (keyed by "yyyy-MM-dd"), this builds a Monday-anchored "this week" summary: +// +// • per-metric this-week SeriesStat (mean / median / min / max / SD / slope), +// • week-over-week PeriodComparison (this week vs the immediately preceding +// Mon–Sun week), reusing ComparisonEngine.compare, +// • a "vs baseline" delta: this-week mean against a trailing baseline mean +// (the prior `baselineWeeks` complete weeks before this one), +// • a sleep-consistency read (SD of the night's values across the week — lower +// is steadier), +// • a strain-vs-recovery balance read (is Effort outrunning Charge this week?), +// • the 1–3 biggest movers ranked by normalised week-over-week change, and +// • 1–2 plain-English focal points, rendered the way BehaviorInsights.sentence +// renders an effect. +// +// It deliberately consumes plain [String: Double] day→value maps (not a DB row +// type) so StrandAnalytics stays decoupled from WhoopStore: the UI layer pulls +// recovery/strain/sleep/RHR/HRV out of its own DailyMetric shape and hands them +// in. No AI is required — narration is an optional later layer that can take this +// struct as input. +// +// Week math is timezone/locale-free: weekday is computed from the "yyyy-MM-dd" +// string with a pure Sakamoto/Zeller day-of-week, and week windows are produced +// as inclusive "yyyy-MM-dd" string ranges, so the split matches the day strings +// AnalyticsEngine emits exactly (string comparison is chronological for ISO days). + +// MARK: - Tracked metric + +/// The five headline metrics a weekly digest reports on. +public enum WeeklyMetric: String, CaseIterable, Sendable { + case charge // recovery, 0–100 + case effort // strain / Effort, 0–100 + case rest // sleep performance composite, 0–100 + case rhr // resting heart rate, bpm + case hrv // heart-rate variability, ms + + /// Human label for the metric (matches the rest of the app's naming). + public var label: String { + switch self { + case .charge: return "Charge" + case .effort: return "Effort" + case .rest: return "Rest" + case .rhr: return "Resting HR" + case .hrv: return "HRV" + } + } + + /// Display unit suffix (empty for the unitless 0–100 scores). + public var unit: String { + switch self { + case .charge, .effort, .rest: return "" + case .rhr: return "bpm" + case .hrv: return "ms" + } + } + + /// True when a HIGHER value is the better outcome. Resting HR is the lone + /// metric where lower is better, so "up" is framed negatively for it. + public var higherIsBetter: Bool { + switch self { + case .rhr: return false + default: return true + } + } + + /// A coarse "typical day-to-day range" used to normalise week-over-week deltas + /// so movers on different scales (a 6 ms HRV swing vs a 4 bpm RHR swing) can be + /// ranked against each other. Deliberately conservative, deterministic constants + /// (not personal baselines) so ranking is stable and explainable. + public var typicalSpread: Double { + switch self { + case .charge: return 12.0 // recovery points + case .effort: return 12.0 // Effort points + case .rest: return 12.0 // Rest points + case .rhr: return 4.0 // bpm + case .hrv: return 8.0 // ms + } + } +} + +// MARK: - Per-metric line + +/// One metric's line in the weekly digest. +public struct WeeklyMetricSummary: Equatable, Sendable { + public let metric: WeeklyMetric + /// This-week stats (Mon–Sun). `.empty` when the week has no readings. + public let thisWeek: SeriesStat + /// This week vs the immediately preceding Mon–Sun week. + public let weekOverWeek: PeriodComparison + /// Mean over the trailing baseline window (the `baselineWeeks` complete weeks + /// before this one), or nil when there were no baseline readings. + public let baselineMean: Double? + /// thisWeek.mean − baselineMean, or nil when baselineMean is nil. + public let vsBaseline: Double? + + public init(metric: WeeklyMetric, thisWeek: SeriesStat, + weekOverWeek: PeriodComparison, baselineMean: Double?, + vsBaseline: Double?) { + self.metric = metric + self.thisWeek = thisWeek + self.weekOverWeek = weekOverWeek + self.baselineMean = baselineMean + self.vsBaseline = vsBaseline + } + + /// Signed week-over-week change in the metric's own units (this − last). + public var wowDelta: Double { weekOverWeek.delta } + + /// Direction of the week-over-week change expressed as GOOD / BAD / FLAT, + /// folding in `higherIsBetter` (so a RHR rise reads as "worse"). 0 when flat + /// or a period is empty. + public var wowGoodness: Int { + guard weekOverWeek.direction != 0 else { return 0 } + let up = weekOverWeek.direction > 0 + let good = (up == metric.higherIsBetter) + return good ? 1 : -1 + } + + /// The week-over-week change scaled by the metric's typical spread, so movers + /// on different units are comparable. 0 when a period is empty. + public var normalisedMove: Double { + guard weekOverWeek.current.n > 0, weekOverWeek.previous.n > 0 else { return 0 } + let s = metric.typicalSpread + return s > 0 ? wowDelta / s : 0 + } + + /// True when the week-over-week comparison rests on a sparse side: both weeks + /// carry at least one reading, but either has fewer than + /// `WeeklyDigestEngine.minDaysForFocus` days. A rough comparison still shows its + /// raw arrow + %, but the UI shouldn't dress it in a confident good/bad verdict — + /// a 43% "drop" off 2 days isn't a trend (the #463 chips-vs-summary contradiction). + public var isRoughComparison: Bool { + let cur = weekOverWeek.current.n + let prev = weekOverWeek.previous.n + guard cur > 0, prev > 0 else { return false } + return cur < WeeklyDigestEngine.minDaysForFocus + || prev < WeeklyDigestEngine.minDaysForFocus + } +} + +// MARK: - Digest + +/// The complete week-in-review. +public struct WeeklyDigest: Equatable, Sendable { + /// The Monday that anchors "this week" ("yyyy-MM-dd"). + public let weekStart: String + /// The Sunday that ends "this week" ("yyyy-MM-dd"). + public let weekEnd: String + /// Per-metric summaries, in WeeklyMetric.allCases order. + public let metrics: [WeeklyMetricSummary] + /// Number of distinct days this week that carried at least one reading. + public let daysWithData: Int + /// Sleep-consistency: SD of this week's Rest values (lower = steadier). nil when + /// fewer than 2 Rest nights this week. In Rest points. + public let sleepConsistencySD: Double? + /// Strain-vs-recovery balance read for the week (see `BalanceRead`). + public let balance: BalanceRead + /// 1–2 plain-English focal points, most salient first. + public let focalPoints: [String] + + public init(weekStart: String, weekEnd: String, metrics: [WeeklyMetricSummary], + daysWithData: Int, sleepConsistencySD: Double?, balance: BalanceRead, + focalPoints: [String]) { + self.weekStart = weekStart + self.weekEnd = weekEnd + self.metrics = metrics + self.daysWithData = daysWithData + self.sleepConsistencySD = sleepConsistencySD + self.balance = balance + self.focalPoints = focalPoints + } + + /// Look up one metric's summary. + public func summary(_ metric: WeeklyMetric) -> WeeklyMetricSummary? { + metrics.first { $0.metric == metric } + } + + /// True when no metric carried a single reading this week (caller can show an + /// empty state instead of a digest). + public var isEmpty: Bool { daysWithData == 0 } +} + +/// How this week's Effort (strain) sat against this week's Charge (recovery). +public enum BalanceRead: String, Equatable, Sendable { + case overreaching // Effort high vs Charge — leaning into the red + case balanced // Effort and Charge roughly tracking + case underloaded // Effort low vs Charge — lots in the tank, little spent + case insufficient // not enough of both to call it + + /// Plain-English line for the UI. + public var sentence: String { + switch self { + case .overreaching: + return "Your Effort outpaced your Charge this week: you leaned into the red. Watch for a recovery dip." + case .balanced: + return "Effort and Charge tracked together this week: a sustainable load." + case .underloaded: + return "You carried more Charge than you spent this week: there's room to push if you want it." + case .insufficient: + return "Not enough Effort and Charge days this week to read your balance." + } + } +} + +public enum WeeklyDigestEngine { + + /// How many complete weeks before "this week" form the vs-baseline comparison. + public static let baselineWeeks: Int = 4 + /// Minimum days each side needs before a week-over-week move is "real" enough + /// to surface as a focal point (guards against a 1-day week swinging wildly). + public static let minDaysForFocus: Int = 3 + + // MARK: - Entry point + + /// Build the weekly digest anchored on the Monday of the week containing + /// `anchorDay` ("yyyy-MM-dd", typically today). + /// + /// - Parameters: + /// - byMetric: per-metric day→value maps. Missing metrics / days are simply + /// absent; this is robust to sparse data. + /// - anchorDay: any "yyyy-MM-dd" in the target week (we snap to its Monday). + /// A non-parseable string yields an all-empty digest. + /// - effortDisplayFactor: multiplier applied to EFFORT averages (and the pts + /// fallback magnitude) in the rendered focal-point sentences ONLY, so a user + /// on the 0–21 Effort scale (#268) never reads a stored 0–100 mean in prose. + /// Percent changes are scale-invariant and stay untouched. Defaults to 1.0 + /// (stored scale) so existing callers are byte-identical. Display-only: no + /// stat, delta or threshold changes. + public static func build(byMetric: [WeeklyMetric: [String: Double]], + anchorDay: String, + effortDisplayFactor: Double = 1.0) -> WeeklyDigest { + guard let monday = mondayOfWeek(containing: anchorDay) else { + return emptyDigest(weekStart: anchorDay, weekEnd: anchorDay) + } + let sunday = addDays(monday, 6) + let lastMonday = addDays(monday, -7) + let lastSunday = addDays(monday, -1) + + // Baseline window: the `baselineWeeks` complete weeks ending the day before + // last week starts (so it never overlaps this week or last week). + let baselineEnd = addDays(lastMonday, -1) // Sunday before last week + let baselineStart = addDays(lastMonday, -7 * baselineWeeks) + + var summaries: [WeeklyMetricSummary] = [] + var daysSeen: Set? = [] + + for metric in WeeklyMetric.allCases { + let series = byMetric[metric] ?? [:] + + var noAccum: Set? = nil + let thisVals = valuesInRange(series, start: monday, end: sunday, daysSeen: &daysSeen) + let lastVals = valuesInRange(series, start: lastMonday, end: lastSunday, daysSeen: &noAccum) + let baseVals = valuesInRange(series, start: baselineStart, end: baselineEnd, daysSeen: &noAccum) + + let thisStat = ComparisonEngine.stat(thisVals) + let wow = ComparisonEngine.compare(current: thisVals, previous: lastVals) + let baseMean: Double? = baseVals.isEmpty ? nil + : baseVals.reduce(0, +) / Double(baseVals.count) + let vsBase: Double? = baseMean.map { thisStat.mean - $0 } + + summaries.append(WeeklyMetricSummary( + metric: metric, thisWeek: thisStat, weekOverWeek: wow, + baselineMean: baseMean, vsBaseline: vsBase)) + } + + // Sleep consistency: SD of this week's Rest series (lower = steadier). + let restStat = summaries.first { $0.metric == .rest }?.thisWeek + let restConsistency: Double? = (restStat?.n ?? 0) >= 2 ? restStat?.stdev : nil + + let balance = balanceRead(summaries) + let focal = focalPoints(summaries: summaries, balance: balance, + consistencySD: restConsistency, + effortDisplayFactor: effortDisplayFactor) + + return WeeklyDigest( + weekStart: monday, weekEnd: sunday, metrics: summaries, + daysWithData: (daysSeen ?? []).count, sleepConsistencySD: restConsistency, + balance: balance, focalPoints: focal) + } + + // MARK: - Balance read + + /// Read this week's Effort against this week's Charge. Both are 0–100; a clearly + /// higher Effort mean than Charge mean is "overreaching", clearly lower is + /// "underloaded", within `balanceBand` is "balanced". Needs ≥ minDaysForFocus + /// of each, else `.insufficient`. + static let balanceBand: Double = 10.0 + + static func balanceRead(_ summaries: [WeeklyMetricSummary]) -> BalanceRead { + guard + let effort = summaries.first(where: { $0.metric == .effort })?.thisWeek, + let charge = summaries.first(where: { $0.metric == .charge })?.thisWeek, + effort.n >= minDaysForFocus, charge.n >= minDaysForFocus + else { return .insufficient } + + let gap = effort.mean - charge.mean + if gap > balanceBand { return .overreaching } + if gap < -balanceBand { return .underloaded } + return .balanced + } + + // MARK: - Focal points + + /// Pick 1–2 plain-English focal points, most salient first. + /// + /// Priority order: + /// 1. The single biggest *meaningful* week-over-week mover (both weeks have + /// ≥ minDaysForFocus days, and the normalised move clears `focusThreshold`), + /// rendered with its good/bad framing. + /// 2. Either the balance read (when not balanced/insufficient) OR the second + /// biggest mover — whichever is more salient — as a supporting line. + /// + /// If nothing clears the bar, a single steady-week line is returned. + static let focusThreshold: Double = 0.5 // half a "typical spread" of movement + + static func focalPoints(summaries: [WeeklyMetricSummary], + balance: BalanceRead, + consistencySD: Double?, + effortDisplayFactor: Double = 1.0) -> [String] { + // Rank movers by |normalised move|, significant (enough days) first. + let movers = summaries + .filter { $0.weekOverWeek.current.n >= minDaysForFocus + && $0.weekOverWeek.previous.n >= minDaysForFocus + && abs($0.normalisedMove) >= focusThreshold } + .sorted { abs($0.normalisedMove) > abs($1.normalisedMove) } + + var lines: [String] = [] + + if let top = movers.first { + lines.append(moverSentence(top, effortDisplayFactor: effortDisplayFactor)) + } + + // Supporting line: prefer a non-trivial balance read, else the 2nd mover. + if balance == .overreaching || balance == .underloaded { + lines.append(balance.sentence) + } else if movers.count >= 2 { + lines.append(moverSentence(movers[1], effortDisplayFactor: effortDisplayFactor)) + } + + // Nothing cleared the mover bar. Distinguish three very different reasons: + // • the CURRENT week is SPARSE (fewer than minDaysForFocus days in) — we simply + // can't call a week-over-week trend yet, even though the per-metric chips may + // show a big raw swing off 1–2 days. Saying "a steady week — nothing moved" + // there flatly contradicts those chips (the #463 report). Be honest instead. + // • the PREVIOUS week is SPARSE (typical new user in week 2): movers are gated + // on previous.n ≥ minDaysForFocus, so nothing can surface even when the chips + // show big raw %s off last week's 1–2 days — the same #463 contradiction, + // mirrored. Same honesty, aimed at last week. + // • the week has enough days and genuinely held even — the calm "steady" read. + if lines.isEmpty { + let currentDays = summaries.map { $0.weekOverWeek.current.n }.max() ?? 0 + let prevDays = summaries.map { $0.weekOverWeek.previous.n }.max() ?? 0 + if currentDays >= 1 && currentDays < minDaysForFocus { + let dayWord = currentDays == 1 ? "day" : "days" + lines.append("Only \(currentDays) \(dayWord) into this week so far, too early to " + + "call a week-over-week trend yet.") + } else if currentDays >= minDaysForFocus, prevDays >= 1, prevDays < minDaysForFocus { + let dayWord = prevDays == 1 ? "day" : "days" + lines.append("Last week only had \(prevDays) \(dayWord) of data, so week-over-week " + + "changes are rough, not a trend.") + } else if let sd = consistencySD, sd <= 6.0 { + lines.append("A steady week: Rest held even (±\(round1(sd)) pts) and nothing moved much.") + } else { + lines.append("A steady week: no metric moved meaningfully from last week.") + } + } + + return Array(lines.prefix(2)) + } + + /// Render one mover as a plain-English sentence, the way BehaviorInsights.sentence + /// renders an effect. Folds in good/bad framing (a Charge rise is "up — good", a + /// Resting HR rise is "up — worth a look"). `effortDisplayFactor` rescales the + /// EFFORT averages (and its pts fallback) for display only — % is scale-invariant. + static func moverSentence(_ s: WeeklyMetricSummary, + effortDisplayFactor: Double = 1.0) -> String { + let f = s.metric == .effort ? effortDisplayFactor : 1.0 + let directionWord = s.wowDelta > 0 ? "up" : (s.wowDelta < 0 ? "down" : "flat") + let magnitude: String + if let pct = s.weekOverWeek.pctChange, abs(pct) >= 1 { + magnitude = "\(roundedInt(abs(pct)))%" + } else { + magnitude = "\(round1(abs(s.wowDelta) * f))\(s.metric.unit.isEmpty ? " pts" : " " + s.metric.unit)" + } + let frame: String + switch s.wowGoodness { + case 1: frame = ", a good sign" + case -1: frame = ", worth a look" + default: frame = "" + } + let thisAvg = roundedInt(s.thisWeek.mean * f) + let lastAvg = roundedInt(s.weekOverWeek.previous.mean * f) + return "\(s.metric.label) is \(directionWord) \(magnitude) week over week" + + " (avg \(thisAvg) vs \(lastAvg))\(frame)." + } + + // MARK: - Range extraction + + /// Collect the values of `series` whose day is within [start, end] inclusive + /// (ISO string comparison is chronological), ordered chronologically so the + /// resulting SeriesStat slope is meaningful. When `daysSeen` is non-nil, the days + /// that carried a value are recorded into it (pass `&someNilOptional` to skip). + static func valuesInRange(_ series: [String: Double], start: String, end: String, + daysSeen: inout Set?) -> [Double] { + let inRange = series.filter { $0.key >= start && $0.key <= end } + if daysSeen != nil { + for k in inRange.keys { daysSeen?.insert(k) } + } + // Sort by day string so the slope is chronological regardless of dict order. + return inRange.sorted { $0.key < $1.key }.map { $0.value } + } + + // MARK: - Pure week math (timezone/locale-free) + + /// The Monday (ISO "yyyy-MM-dd") of the week containing `day`. nil if `day` + /// can't be parsed as a valid yyyy-MM-dd. + public static func mondayOfWeek(containing day: String) -> String? { + guard let (y, m, d) = parseYMD(day), let w = weekday(y, m, d) else { return nil } + // weekday: 0=Sunday … 6=Saturday. Days since Monday: Mon=0 … Sun=6. + let sinceMonday = (w + 6) % 7 + return addDays(day, -sinceMonday) + } + + /// Add `n` days (may be negative) to a "yyyy-MM-dd" day, returning "yyyy-MM-dd". + /// Falls back to the input string if it can't be parsed. + public static func addDays(_ day: String, _ n: Int) -> String { + guard let (y, m, d) = parseYMD(day) else { return day } + let jdn = julianDayNumber(y, m, d) + n + let (ny, nm, nd) = fromJulianDayNumber(jdn) + return formatYMD(ny, nm, nd) + } + + /// Sakamoto's day-of-week: 0=Sunday, 1=Monday … 6=Saturday. nil for an invalid + /// calendar date. + static func weekday(_ y: Int, _ m: Int, _ d: Int) -> Int? { + guard (1...12).contains(m), d >= 1, d <= daysInMonth(y, m) else { return nil } + let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4] + var yy = y + if m < 3 { yy -= 1 } + return (yy + yy / 4 - yy / 100 + yy / 400 + t[m - 1] + d) % 7 + } + + /// Days in a month, leap-year aware. + static func daysInMonth(_ y: Int, _ m: Int) -> Int { + switch m { + case 1, 3, 5, 7, 8, 10, 12: return 31 + case 4, 6, 9, 11: return 30 + case 2: return isLeap(y) ? 29 : 28 + default: return 0 + } + } + + static func isLeap(_ y: Int) -> Bool { (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) } + + /// Parse "yyyy-MM-dd" into integer components, validating the date is real. + /// Public so UI layers can format week-range labels without re-implementing the + /// (timezone-free) date parse. + public static func parseYMD(_ s: String) -> (Int, Int, Int)? { + let parts = s.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]), + (1...12).contains(m), d >= 1, d <= daysInMonth(y, m) else { return nil } + return (y, m, d) + } + + /// Zero-padded "yyyy-MM-dd". + static func formatYMD(_ y: Int, _ m: Int, _ d: Int) -> String { + let yy = y < 1000 ? String(format: "%04d", y) : "\(y)" + let mm = m < 10 ? "0\(m)" : "\(m)" + let dd = d < 10 ? "0\(d)" : "\(d)" + return "\(yy)-\(mm)-\(dd)" + } + + /// Convert a proleptic-Gregorian date to a Julian Day Number (integer-only, + /// timezone-free). Used purely for date arithmetic. + static func julianDayNumber(_ y: Int, _ m: Int, _ d: Int) -> Int { + let a = (14 - m) / 12 + let yy = y + 4800 - a + let mm = m + 12 * a - 3 + return d + (153 * mm + 2) / 5 + 365 * yy + yy / 4 - yy / 100 + yy / 400 - 32045 + } + + /// Inverse of `julianDayNumber`. + static func fromJulianDayNumber(_ jdn: Int) -> (Int, Int, Int) { + let a = jdn + 32044 + let b = (4 * a + 3) / 146097 + let c = a - (146097 * b) / 4 + let dd = (4 * c + 3) / 1461 + let e = c - (1461 * dd) / 4 + let mm = (5 * e + 2) / 153 + let day = e - (153 * mm + 2) / 5 + 1 + let month = mm + 3 - 12 * (mm / 10) + let year = 100 * b + dd - 4800 + mm / 10 + return (year, month, day) + } + + // MARK: - Empty digest + + static func emptyDigest(weekStart: String, weekEnd: String) -> WeeklyDigest { + let summaries = WeeklyMetric.allCases.map { m in + WeeklyMetricSummary(metric: m, thisWeek: .empty, + weekOverWeek: ComparisonEngine.compare(current: [], previous: []), + baselineMean: nil, vsBaseline: nil) + } + return WeeklyDigest(weekStart: weekStart, weekEnd: weekEnd, metrics: summaries, + daysWithData: 0, sleepConsistencySD: nil, + balance: .insufficient, focalPoints: []) + } + + // MARK: - Formatting helpers (mirror BehaviorInsights) + + static func roundedInt(_ x: Double) -> Int { Int(x.rounded()) } + static func round1(_ x: Double) -> Double { (x * 10).rounded() / 10 } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift index d6cec98c87..5c8e231044 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift @@ -23,10 +23,16 @@ public struct UserProfile: Equatable, Sendable { public var heightCm: Double public var age: Double public var sex: String // "male" | "female" | "nonbinary" + /// Counter ticks per real step for the @57 motion counter (#139). The WHOOP 5/MG + /// counter overcounts and its true tick rate is unknown, so the daily-steps total + /// divides by this. 1.0 = raw pass-through (default); the engine clamps ≥ 0.5. + public var stepTicksPerStep: Double public init(weightKg: Double = 70.0, heightCm: Double = 170.0, - age: Double = 30.0, sex: String = "nonbinary") { + age: Double = 30.0, sex: String = "nonbinary", + stepTicksPerStep: Double = 1.0) { self.weightKg = weightKg; self.heightCm = heightCm self.age = age; self.sex = sex + self.stepTicksPerStep = stepTicksPerStep } } @@ -72,6 +78,16 @@ public enum WorkoutDetector { public static let minIntensityZ2Plus: Double = 0.50 public static let alignToleranceS: Double = 5.0 public static let restingPercentile: Double = 10.0 + /// Second-pass bridge window (#303). Two adjacent active runs separated by a + /// below-motion-threshold gap no longer than this are stitched into one workout + /// — BUT ONLY while HR stays elevated across the gap (see `bridgeRuns`). A + /// sustained endurance effort (e.g. a long bike ride) routinely dips below the + /// motion gate for a few minutes — coasting a descent, a junction, a brief sensor + /// dropout — without the athlete actually resting; `mergeGapS` (150 s) is too + /// tight to ride through those, so the bout used to shatter into many sub-bouts, + /// most of which then fell under `minExerciseMin` and vanished. A genuine rest + /// between two separate workouts is gated out by the HR check, not by this window. + public static let bridgeGapS: Double = 300.0 // MARK: - Activity series (activity.py) @@ -170,6 +186,49 @@ public enum WorkoutDetector { return (zonePct, avgHRR) } + /// Second-pass merge over raw active runs (#303). + /// + /// Stitch run `i+1` onto the current span when the inter-run gap (start of the + /// next minus end of the current) is ≤ `bridgeGapS` AND HR stays elevated across + /// that gap — i.e. the athlete kept working through a brief motion lull rather + /// than resting. "Elevated" = the mean of the HR samples strictly inside the gap + /// is still above `hrFloor` (resting + HR_MARGIN_BPM). If the gap carries NO HR + /// samples it is treated as a same-effort sensor dropout and bridged; a real rest + /// always lands HR samples in the gap (the strap streams 1 Hz), so it fails the + /// elevation test and the two workouts stay separate. Runs must arrive sorted by + /// start (they do — built from a sorted timeline). + static func bridgeRuns(_ runs: [(Int, Int)], + hrSeg: [(ts: Int, bpm: Double)], + hrFloor: Double) -> [(Int, Int)] { + guard runs.count > 1 else { return runs } + var merged: [(Int, Int)] = [] + var curStart = runs[0].0 + var curEnd = runs[0].1 + for next in runs.dropFirst() { + let gap = Double(next.0 - curEnd) + var bridge = false + if gap <= bridgeGapS { + // HR samples strictly between the two runs (the lull itself). + let gapHR = hrSeg.filter { $0.ts > curEnd && $0.ts < next.0 }.map { $0.bpm } + if gapHR.isEmpty { + bridge = true // sensor dropout mid-effort → same workout + } else { + let meanGapHR = gapHR.reduce(0, +) / Double(gapHR.count) + bridge = meanGapHR > hrFloor // still working → same workout + } + } + if bridge { + curEnd = max(curEnd, next.1) + } else { + merged.append((curStart, curEnd)) + curStart = next.0 + curEnd = next.1 + } + } + merged.append((curStart, curEnd)) + return merged + } + // MARK: - Public API /// Detect workouts from the 1 Hz HR + gravity store. @@ -228,6 +287,11 @@ public enum WorkoutDetector { } runs.append((runStart, prev)) + // Second pass (#303): bridge adjacent runs across a brief, still-elevated-HR + // lull so a sustained effort isn't shattered by coasting / junctions / sensor + // gaps. Runs over a genuine rest (HR falls to resting) are NOT bridged. + runs = bridgeRuns(runs, hrSeg: hrSeg, hrFloor: hrFloor) + let minDurS = minExerciseMin * 60.0 var sessions: [ExerciseSession] = [] for (start, end) in runs { @@ -258,6 +322,7 @@ public enum WorkoutDetector { kcal = k; kj = j } + guard !bpms.isEmpty else { continue } // skip a degenerate bout with no HR samples let avg = bpms.reduce(0, +) / Double(bpms.count) let peak = Int(bpms.max()!.rounded()) let strain = StrainScorer.strain(hrSamples, maxHR: effMaxHR, restingHR: restHR) @@ -299,6 +364,14 @@ public enum Calories { workoutAge: 0.13785, workoutAlpha: -37.74955) static let activeHRRFraction = 0.30 + /// Whole-day active gate (`estimateDayCalories` only). The Keytel 2005 equation is + /// validated for genuine EXERCISE HR; applying it to ordinary low-intensity daytime + /// HR (walking, stairs, standing — typically ~95–110 bpm) across the WHOLE day credits + /// the full gross-exercise rate to every elevated second and over-counts by ~1000+ kcal + /// (community "Calories too high"). The bout path keeps the 0.30 detector fraction — + /// Keytel is appropriate for a real detected/manual workout — but the day path raises + /// the gate to 50% HRR so the gross rate only applies at genuine exercise-level HR. + static let dayActiveHRRFraction = 0.50 static let workoutDivisor = 251.04 // 60 s/min × 4.184 kJ/kcal static func resolveCoeffs(_ sex: String) -> Coeffs { @@ -322,7 +395,15 @@ public enum Calories { return max(0.0, eeKjMin) / workoutDivisor } - /// Estimate (kcal, kJ) for a workout bout. Each HR sample = 1 second of data. + /// Estimate (kcal, kJ) for a workout bout. Each sample is weighted by the ELAPSED time + /// to the next sample (capped at `WorkoutDetector.mergeGapS`), so a sparse, non-1 Hz + /// stream is counted over real seconds rather than undercounted as one second per sample. + /// + /// This elapsed-time weighting is justified ONLY for the bout path: a bout's intra-sample + /// gaps are motion-gated and ≤ mergeGapS (150 s) by construction, so each gap really is + /// continuous active/resting time. The whole-day estimator deliberately does NOT use it + /// (see `estimateDayCalories`) — its raw, non-gap-filled day HR union would otherwise + /// credit up to 150 s of active burn to a single isolated elevated sample. public static func estimateBoutCalories(_ hrSamples: [HRSample], profile: UserProfile, hrmax: Double?, @@ -338,15 +419,87 @@ public enum Calories { let restingRate = restingKcalPerS(coeffs, weightKg: weightKg, heightCm: heightCm, age: age) + // Weight each sample by the ACTUAL elapsed time to the next sample, not a flat 1 s. + // restingRate / activeKcalPerS are per-SECOND rates, so summing one per sample only + // equals real energy when the stream is exactly 1 Hz. A sparse WHOOP 5/MG bout can + // run far below 1 sample/s, which previously undercounted energy roughly in proportion + // to the coverage gap (calories collapsing toward ~1 kcal, #137). Each interval is + // capped at mergeGapS (150 s) — the detector's own "still continuous, not resting" + // threshold — so a brief dropout is fully counted but a wear gap can't inflate one + // reading. At a steady 1 Hz every interval is ~1 s: behaviour is unchanged. + let ordered = hrSamples.sorted { $0.ts < $1.ts } + var totalKcal = 0.0 + for i in ordered.indices { + let bpm = Double(ordered[i].bpm) + let dur: Double + if i < ordered.count - 1 { + let gap = Double(ordered[i + 1].ts - ordered[i].ts) + dur = gap > 0 ? min(gap, WorkoutDetector.mergeGapS) : 1.0 + } else { + dur = 1.0 // last sample carries one representative second + } + if bpm < activeThreshold { + totalKcal += restingRate * dur + } else { + totalKcal += activeKcalPerS(coeffs, hr: bpm, hrmax: effHRmax, weightKg: weightKg, age: age) * dur + } + } + return (totalKcal, totalKcal * 4.184) + } + + /// APPROXIMATE whole-day total energy estimate (kcal) from the full day's HR samples. + /// Per-second model: below the day activeThreshold (resting + `dayActiveHRRFraction` + /// HRR) a sample burns the resting BMR rate, above it the Keytel active rate — FLOORED + /// at the resting rate so a day-second can never be credited LESS than resting metabolism. + /// + /// The day path uses `dayActiveHRRFraction` (50% HRR), NOT the 30% the bout detector uses + /// (`activeHRRFraction`). The Keytel 2005 equation is validated for genuine EXERCISE HR; + /// at 30% the gate falls to ~94 bpm for a typical user, so ordinary low-intensity daytime + /// HR (walking, stairs, standing) credited the full gross-exercise rate across the whole + /// day and over-counted by ~1000+ kcal (community "Calories too high"). The 50% gate keeps + /// the gross rate for genuine exercise-level HR only; the bout path is UNCHANGED — Keytel + /// is appropriate there, on a real detected/manual workout. + /// + /// Each HR sample = ONE second of data (1 Hz strap), counted flat — this path deliberately + /// does NOT use the bout estimator's elapsed-time-per-sample weighting. The day feed is a + /// raw, non-gap-filled union of the day's HR (it is NOT motion-gated the way a bout is), so + /// capping each gap at mergeGapS (150 s) would credit up to ~150 s of active burn to a + /// single isolated elevated sample — over-counting by ~150x on gappy days. Flat + /// one-second-per-sample is the conservative, stable choice for the day total. + /// This is an on-device estimate from heart rate alone — NOT laboratory calorimetry, NOT + /// Apple/WHOOP cloud parity, NOT medical advice. Returns total estimated kcal (>= 0). + public static func estimateDayCalories(_ hrSamples: [HRSample], + profile: UserProfile, + hrmax: Double?, + restingHR: Double?) -> Double { + if hrSamples.isEmpty { return 0.0 } + + let weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0 + let heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0 + let age = profile.age > 0 ? profile.age : 30.0 + let coeffs = resolveCoeffs(profile.sex) + + let effHRmax = hrmax ?? 220.0 + let effResting = restingHR ?? 60.0 + // Day-path gate is HIGHER than the bout detector's: only genuine exercise-level HR + // gets the Keytel gross rate (see `dayActiveHRRFraction`). + let activeThreshold = effResting + dayActiveHRRFraction * (effHRmax - effResting) + + let restingRate = restingKcalPerS(coeffs, weightKg: weightKg, heightCm: heightCm, age: age) + var totalKcal = 0.0 for s in hrSamples { let bpm = Double(s.bpm) if bpm < activeThreshold { totalKcal += restingRate } else { - totalKcal += activeKcalPerS(coeffs, hr: bpm, hrmax: effHRmax, weightKg: weightKg, age: age) + // Floor the active rate at the resting BMR rate: a worn day-second never burns + // LESS than resting metabolism, even where the Keytel value dips low for some + // profiles just above the gate. + let active = activeKcalPerS(coeffs, hr: bpm, hrmax: effHRmax, weightKg: weightKg, age: age) + totalKcal += max(restingRate, active) } } - return (totalKcal, totalKcal * 4.184) + return totalKcal } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ActivityCostEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ActivityCostEngineTests.swift new file mode 100644 index 0000000000..40a828cf02 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ActivityCostEngineTests.swift @@ -0,0 +1,268 @@ +import XCTest +@testable import StrandAnalytics + +/// ActivityCostEngine — "what each activity costs your recovery". The oracle for the +/// Android ActivityCostEngineTest; keep the two in lockstep (same fixtures, same numbers). +final class ActivityCostEngineTests: XCTestCase { + + // MARK: - Helpers + + private func cost(_ results: [ActivityCost], _ sport: String) -> ActivityCost? { + results.first { $0.sport == sport } + } + + // MARK: - Delta sign + value, D+1 keying, baseline excludes active days + + /// "running" tagged on 9 consecutive days, each with a morning Charge of 50; a + /// separate untouched block averages 70. baselineMean = 70 (the active 50-days are + /// EXCLUDED), meanNextMorning = 50 (each session day's D+1 is the next 50-morning), + /// so delta = +20 — a real cost. The last tagged day (06-09) has no D+1 value, so + /// n = 8 of the 9 tagged days. + func testDeltaSignAndValueAndBaselineExcludesActive() { + var rec: [String: Double] = [:] + var tagged: Set = [] + for d in 1...9 { + let day = String(format: "2026-06-%02d", d) + tagged.insert(day) + rec[day] = 50 // each active day's own Charge (excluded from baseline) + } + // A block of genuinely untouched rest days, all at 70. + for d in 20...27 { rec[String(format: "2026-06-%02d", d)] = 70 } + + let out = ActivityCostEngine.evaluate(activityDaysBySport: ["running": tagged], + recoveryByDay: rec) + let r = cost(out, "running") + XCTAssertNotNil(r) + XCTAssertEqual(r!.baselineMean, 70, accuracy: 1e-9) // active 50-days excluded + XCTAssertEqual(r!.meanNextMorning, 50, accuracy: 1e-9) // D+1 keyed + XCTAssertEqual(r!.delta, 20, accuracy: 1e-9) // +20 cost + XCTAssertEqual(r!.n, 8) // 06-09 has no D+1 value + XCTAssertEqual(r!.confidence, .solid) + } + + /// Negative delta: the morning after wakes HIGHER than the rest baseline. Six + /// consecutive tagged days each at 80 (so every D+1 is the next active 80-day and is + /// excluded from the baseline); a separate untouched block sets baseline = 60. + func testNegativeDeltaWhenNextMorningAboveBaseline() { + var rec: [String: Double] = [:] + var tagged: Set = [] + for d in 1...6 { + let day = String(format: "2026-07-%02d", d) + tagged.insert(day) + rec[day] = 80 + } + for d in 20...25 { rec[String(format: "2026-07-%02d", d)] = 60 } // baseline 60 + + let out = ActivityCostEngine.evaluate(activityDaysBySport: ["yoga": tagged], + recoveryByDay: rec) + let r = cost(out, "yoga")! + XCTAssertEqual(r.baselineMean, 60, accuracy: 1e-9) + XCTAssertEqual(r.meanNextMorning, 80, accuracy: 1e-9) // 5 D+1s (07-06 has no D+1) + XCTAssertEqual(r.n, 5) + XCTAssertEqual(r.delta, -20, accuracy: 1e-9) // you wake ABOVE baseline + } + + // MARK: - nil recovery[D+1] is skipped, not counted + + /// 6 tagged days but only 5 have a D+1 Charge value — the missing one is skipped, so + /// n == 5 (and it does not crash on the absent key). + func testMissingNextMorningIsSkipped() { + var rec: [String: Double] = [:] + var tagged: Set = [] + for d in 1...6 { + let day = String(format: "2026-08-%02d", d) + tagged.insert(day) + if d != 3 { // deliberately leave 2026-08-04 (the D+1 of day 3) absent + rec[String(format: "2026-08-%02d", d + 1)] = 55 + } + } + for d in 20...25 { rec[String(format: "2026-08-%02d", d)] = 65 } + + let out = ActivityCostEngine.evaluate(activityDaysBySport: ["swim": tagged], + recoveryByDay: rec) + let r = cost(out, "swim")! + XCTAssertEqual(r.n, 5) // the gap day dropped + XCTAssertEqual(r.meanNextMorning, 55, accuracy: 1e-9) + XCTAssertEqual(r.confidence, .building) // 4 ≤ 5 < 8 + } + + // MARK: - daysToBaseline: dip-then-recover == 3, and nil when never recovers + + // Four session anchors, each the 1st of a different month so the D+1…D+7 forward + // windows never overlap and there is no month-overflow arithmetic to mirror. + private let dipAnchors = ["2026-01-01", "2026-03-01", "2026-05-01", "2026-07-01"] + + /// A dip-then-recover trajectory: after a session Charge is 50 (D+1), 55 (D+2), then + /// back to baseline 70 from D+3 on. baselineMean = 70, tol = 3 → target = 67. traj is + /// 50, 55, 70, … so the first k with traj[k] ≥ 67 is k = 3. + func testDaysToBaselineDipThenRecover() { + var rec: [String: Double] = [:] + var tagged: Set = [] + // Each anchor's D+1/D+2/D+3 — written explicitly (no day arithmetic in the test). + let plus: [(Int, Double)] = [(1, 50), (2, 55), (3, 70)] + for anchor in dipAnchors { + tagged.insert(anchor) + for (k, v) in plus { + rec[CorrelationEngine.shiftDay(anchor, by: k)!] = v + } + } + // Rest baseline of 70 on a block of genuinely untouched days. + for i in 1...8 { rec[String(format: "2026-11-%02d", i)] = 70 } + + let out = ActivityCostEngine.evaluate(activityDaysBySport: ["lift": tagged], + recoveryByDay: rec) + let r = cost(out, "lift")! + XCTAssertEqual(r.baselineMean, 70, accuracy: 1e-9) + XCTAssertEqual(r.daysToBaseline, 3) + XCTAssertEqual(r.n, 4) // four anchors, each with a D+1 value + } + + /// Never climbs back within the 7-day window → daysToBaseline is nil. Charge stays at + /// 40 for all of D+1…D+7 while the baseline is 70 (target 67). + func testDaysToBaselineNilWhenNeverRecovers() { + var rec: [String: Double] = [:] + var tagged: Set = [] + for anchor in dipAnchors { + tagged.insert(anchor) + for k in 1...7 { + rec[CorrelationEngine.shiftDay(anchor, by: k)!] = 40 + } + } + for i in 1...8 { rec[String(format: "2026-11-%02d", i)] = 70 } + + let out = ActivityCostEngine.evaluate(activityDaysBySport: ["ruck": tagged], + recoveryByDay: rec) + let r = cost(out, "ruck")! + XCTAssertEqual(r.baselineMean, 70, accuracy: 1e-9) + XCTAssertNil(r.daysToBaseline) + } + + // MARK: - Confidence gate: n=3 omit / n=5 building / n=8 solid + + /// Build a consecutive run of tagged days at value `val` from `2028-MM-startDay`, so + /// every interior day's D+1 is the next tagged day (n = length−1). Mornings live on + /// tagged days, so they never leak into the rest baseline. + private func run(_ rec: inout [String: Double], _ tagged: inout Set, + month: Int, startDay: Int, length: Int, value: Double) { + for i in 0.. = [] // length 4 → n=3 → OMITTED + run(&rec, &thin, month: 1, startDay: 1, length: 4, value: 50) + var mid: Set = [] // length 6 → n=5 → .building + run(&rec, &mid, month: 2, startDay: 1, length: 6, value: 50) + var big: Set = [] // length 9 → n=8 → .solid + run(&rec, &big, month: 3, startDay: 1, length: 9, value: 50) + // A genuinely untouched baseline block (different month, never tagged). + for i in 1...8 { rec[String(format: "2028-06-%02d", i)] = 70 } + + let out = ActivityCostEngine.evaluate( + activityDaysBySport: ["thin": thin, "mid": mid, "big": big], + recoveryByDay: rec) + + XCTAssertNil(cost(out, "thin")) // n=3 omitted + XCTAssertEqual(cost(out, "mid")?.n, 5) + XCTAssertEqual(cost(out, "mid")?.confidence, .building) // n=5 + XCTAssertEqual(cost(out, "big")?.n, 8) + XCTAssertEqual(cost(out, "big")?.confidence, .solid) // n=8 + XCTAssertEqual(out.count, 2) // only mid + big survive + } + + // MARK: - Ranking: |delta| desc, solid before building, name asc + + func testRanking() { + // Three surviving sports, all measured against baseline 70: + // "alpha": meanNextMorning 60 → delta 10, n=8 → .solid + // "bravo": meanNextMorning 50 → delta 20, n=5 → .building + // "charlie": meanNextMorning 60 → delta 10, n=5 → .building + // Order: bravo (|20|), then alpha (|10|, solid before building), then charlie. + var rec: [String: Double] = [:] + var alpha: Set = [] + run(&rec, &alpha, month: 1, startDay: 1, length: 9, value: 60) // n=8, delta 10 + var bravo: Set = [] + run(&rec, &bravo, month: 2, startDay: 1, length: 6, value: 50) // n=5, delta 20 + var charlie: Set = [] + run(&rec, &charlie, month: 3, startDay: 1, length: 6, value: 60) // n=5, delta 10 + for i in 1...8 { rec[String(format: "2028-06-%02d", i)] = 70 } // baseline 70 + + let out = ActivityCostEngine.evaluate( + activityDaysBySport: ["alpha": alpha, "bravo": bravo, "charlie": charlie], + recoveryByDay: rec) + XCTAssertEqual(out.map { $0.sport }, ["bravo", "alpha", "charlie"]) + // Spot-check the deltas the ranking is built on. + XCTAssertEqual(cost(out, "bravo")!.delta, 20, accuracy: 1e-9) + XCTAssertEqual(cost(out, "alpha")!.delta, 10, accuracy: 1e-9) + XCTAssertEqual(cost(out, "charlie")!.delta, 10, accuracy: 1e-9) + } + + // MARK: - Sentence degradation + + func testSentenceFull() { + let c = ActivityCost(sport: "running", delta: 12.0, meanNextMorning: 58, + baselineMean: 70, daysToBaseline: 2, n: 9, confidence: .solid) + XCTAssertEqual(c.sentence(), + "Sessions like this usually cost you about 12 Charge points the next morning " + + "and take about 2 days to bounce back (n=9).") + } + + func testSentenceDropsDaysClauseWhenNil() { + let c = ActivityCost(sport: "running", delta: 12.0, meanNextMorning: 58, + baselineMean: 70, daysToBaseline: nil, n: 9, confidence: .solid) + XCTAssertEqual(c.sentence(), + "Sessions like this usually cost you about 12 Charge points the next morning (n=9).") + } + + func testSentenceBarelyMoves() { + let c = ActivityCost(sport: "walk", delta: 0.4, meanNextMorning: 69.6, + baselineMean: 70, daysToBaseline: 1, n: 6, confidence: .building) + XCTAssertEqual(c.sentence(), + "Sessions like this barely move your next-day Charge (n=6).") + } + + func testSentenceLiftDirectionAndSingularDay() { + // Negative delta → "lift"; daysToBaseline 1 → singular "day". + let c = ActivityCost(sport: "yoga", delta: -1.0, meanNextMorning: 71, + baselineMean: 70, daysToBaseline: 1, n: 8, confidence: .solid) + XCTAssertEqual(c.sentence(), + "Sessions like this usually lift about 1 Charge point the next morning " + + "and take about 1 day to bounce back (n=8).") + } + + // MARK: - Empty input + + func testEmptyInputs() { + XCTAssertTrue(ActivityCostEngine.evaluate(activityDaysBySport: [:], + recoveryByDay: ["2026-01-01": 60]).isEmpty) + XCTAssertTrue(ActivityCostEngine.evaluate(activityDaysBySport: ["run": ["2026-01-01"]], + recoveryByDay: [:]).isEmpty) + } + + /// All days are tagged (no untouched rest days) → no baseline → empty result. + func testNoRestDaysYieldsEmpty() { + var rec: [String: Double] = [:] + var tagged: Set = [] + for d in 1...8 { + let day = String(format: "2026-05-%02d", d) + tagged.insert(day) + rec[day] = 50 + } + // Every recovery day is also a tagged day → activeUnion covers them all. + let out = ActivityCostEngine.evaluate(activityDaysBySport: ["run": tagged], + recoveryByDay: rec) + XCTAssertTrue(out.isEmpty) + } + + // MARK: - Stat helper + + func testMean() { + XCTAssertEqual(ActivityCostEngine.mean([2, 4, 6]), 4, accuracy: 1e-9) + XCTAssertEqual(ActivityCostEngine.mean([]), 0, accuracy: 1e-9) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineDayBoundsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineDayBoundsTests.swift new file mode 100644 index 0000000000..4e783b71c4 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineDayBoundsTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol +import WhoopStore + +/// Locks the analyzeDay hot-path optimization (#996): the integer UTC-bounds membership check that replaced +/// the per-sample `dayString(ts, offsetSec:) == day` DateFormatter must be BYTE-IDENTICAL to it. If the two +/// ever diverge, samples get attributed to the wrong calendar day (wrong step / calorie / Effort totals), so +/// this sweeps timestamps densely across BOTH midnight edges at a range of fixed offsets — including the +/// FRACTIONAL ones (+5:30 Kolkata, +5:45 Kathmandu, −9:30 Marquesas, −3:30 Newfoundland) the whole-hour +/// Kotlin sweep leaves uncovered — and asserts the two agree at every point. Mirrors the Android +/// `AnalyticsEngineDayBoundsTest` (same anchor, same prime step, superset of its offsets) so the +/// cross-platform golden vectors stay in lockstep. +final class AnalyticsEngineDayBoundsTests: XCTestCase { + + // MARK: - Membership equivalence sweep + + func testIntegerBoundsMatchDayStringAcrossMidnightAndOffsets() { + let anchor = 1_700_000_000 // 2023-11-14T22:13:20Z + // UTC, the whole-hour extremes NOOP actually threads (±12/13/14 h are the real-world edges), + // AND the fractional offsets the lane's timezone table carries (#996 review swept these too). + let offsets = [0, + -4 * 3600, 5 * 3600, 13 * 3600, -12 * 3600, 14 * 3600, + 5 * 3600 + 1800, // +5:30 Kolkata + 5 * 3600 + 2700, // +5:45 Kathmandu + -(9 * 3600 + 1800), // −9:30 Marquesas + -(3 * 3600 + 1800)] // −3:30 Newfoundland + for off in offsets { + let day = AnalyticsEngine.dayString(anchor, offsetSec: off) + let start = AnalyticsEngine.dayStartUtcSeconds(day) + // ±28 h around the anchor at a prime step, so both midnight edges of `day` are crossed densely + // and never in phase with the day grid. + var ts = anchor - 100_800 + while ts < anchor + 100_800 { + let viaFormatter = AnalyticsEngine.dayString(ts, offsetSec: off) == day + let viaBounds = (ts + off) >= start && (ts + off) < start + 86_400 + XCTAssertEqual(viaFormatter, viaBounds, "ts=\(ts) off=\(off)") + ts += 97 + } + } + } + + func testExactMidnightEdgesAtFractionalOffset() { + // The two boundary seconds are where an off-by-one would hide: the local-midnight second is IN + // the day, the next local-midnight second is OUT. +5:30 so a whole-hour bug can't pass by luck. + let off = 5 * 3600 + 1800 + let day = "2021-06-15" + let start = AnalyticsEngine.dayStartUtcSeconds(day) // 2021-06-15T00:00:00Z = 1623715200 + XCTAssertEqual(start, 1_623_715_200) + let localMidnightUtcTs = start - off // wall-clock 00:00 +05:30 as a UTC instant + for (probe, expected) in [(localMidnightUtcTs, true), // first second of the local day + (localMidnightUtcTs - 1, false), // last second of the day before + (localMidnightUtcTs + 86_399, true), // last second of the local day + (localMidnightUtcTs + 86_400, false)] { // first second of the next + XCTAssertEqual(AnalyticsEngine.dayString(probe, offsetSec: off) == day, expected, "probe=\(probe)") + XCTAssertEqual((probe + off) >= start && (probe + off) < start + 86_400, expected, "probe=\(probe)") + } + } + + // MARK: - dayStartUtcSeconds + + func testDayStartUtcSecondsIsUtcMidnight() { + XCTAssertEqual(AnalyticsEngine.dayStartUtcSeconds("1970-01-01"), 0) + // 1_700_000_000 is 2023-11-14T22:13:20Z, so that day's UTC midnight is 80_000 s earlier. + XCTAssertEqual(AnalyticsEngine.dayStartUtcSeconds("2023-11-14"), 1_699_920_000) + XCTAssertEqual(AnalyticsEngine.dayStartUtcSeconds("2021-06-15"), 1_623_715_200) + } + + func testDayStartUtcSecondsRoundTripsThroughDayString() { + // dayString(dayStartUtcSeconds(day)) == day for a spread of days (incl. a leap-year Feb 29). + for day in ["1970-01-01", "2021-06-15", "2023-11-14", "2024-02-29", "2026-12-31"] { + XCTAssertEqual(AnalyticsEngine.dayString(AnalyticsEngine.dayStartUtcSeconds(day)), day) + } + } + + func testMalformedDayFallsBackToEmptyEpochWindowNotATrap() { + // Nil-tolerant failure mode (#996 review): a malformed `day` degrades to 0 — an empty 1970 window + // no real sample matches — on BOTH platforms (Kotlin runCatching → 0), never a crash. Unreachable + // in practice (`day` always comes from dayString), locked so the parity can't silently drift. + XCTAssertEqual(AnalyticsEngine.dayStartUtcSeconds("not-a-day"), 0) + XCTAssertEqual(AnalyticsEngine.dayStartUtcSeconds(""), 0) + } + + // MARK: - Byte-identity pin: same inputs → same DailyMetric numbers + + /// The optimization's whole contract: analyzeDay over FULL streams (which the tsInDay bounds check must + /// trim to the day) produces the IDENTICAL DailyMetric as analyzeDay over streams PRE-trimmed with the + /// old formatter compare. Runs at a fractional offset with spill samples planted on both sides of the + /// local day, so any membership divergence changes the step/kcal/Effort numbers and fails Equatable. + func testAnalyzeDayByteIdenticalToFormatterPrefilteredStreams() { + for off in [5 * 3600 + 1800, -(9 * 3600 + 1800)] { // +5:30 and −9:30 + let day = "2021-06-15" + let localMid = AnalyticsEngine.dayStartUtcSeconds(day) - off + // Full calendar-day HR every 10 s with ±2 h spill into the neighbour days; varying bpm so a + // wrongly-included/excluded sample shifts the calorie/Effort sums, not just the count. + let dayHr = stride(from: localMid - 7_200, to: localMid + 86_400 + 7_200, by: 10) + .map { HRSample(ts: $0, bpm: 60 + ($0 / 10) % 40) } + // Cumulative @57 counter every minute (+7/min) with the same spill; the spill deltas must be + // excluded from the day total by BOTH filters identically. + var counter = 100 + let daySteps: [StepSample] = stride(from: localMid - 7_200, + to: localMid + 86_400 + 7_200, by: 60).map { ts in + counter += 7 + return StepSample(ts: ts, counter: counter & 0xFFFF) + } + let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male") + + let full = AnalyticsEngine.analyzeDay(day: day, dayHr: dayHr, daySteps: daySteps, + profile: profile, tzOffsetSeconds: off) + // The OLD path, byte for byte: pre-trim each stream with the formatter compare. + let preHr = dayHr.filter { AnalyticsEngine.dayString($0.ts, offsetSec: off) == day } + let preSteps = daySteps.filter { AnalyticsEngine.dayString($0.ts, offsetSec: off) == day } + XCTAssertLessThan(preHr.count, dayHr.count, "fixture must actually spill outside the day") + let pre = AnalyticsEngine.analyzeDay(day: day, dayHr: preHr, daySteps: preSteps, + profile: profile, tzOffsetSeconds: off) + + XCTAssertEqual(full.daily, pre.daily, "off=\(off)") + XCTAssertNotNil(full.daily.steps) // the pin is vacuous if the day computed nothing + XCTAssertNotNil(full.daily.activeKcalEst) + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift index 154289148a..4d6676c1bc 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift @@ -14,6 +14,55 @@ final class AnalyticsEngineTests: XCTestCase { XCTAssertEqual(AnalyticsEngine.dayString(1_609_459_200), "2021-01-01") } + // MARK: - Offset-aware day-string (#277 local-day re-bucketing) + + func testDayStringLocalEveningWestOfUTC() { + // A Toronto user (UTC-4) at 22:00 local on 2021-06-15. Local 22:00 EDT == 02:00 UTC the + // NEXT day (2021-06-16). The UTC bucket would be "2021-06-16"; the LOCAL day is "2021-06-15". + // 2021-06-16 02:00:00 UTC == 1623808800. + let tsUtc = 1_623_808_800 + let offset = -4 * 3600 // UTC-4 + XCTAssertEqual(AnalyticsEngine.dayString(tsUtc), "2021-06-16") // old UTC behaviour + XCTAssertEqual(AnalyticsEngine.dayString(tsUtc, offsetSec: offset), "2021-06-15") // local day + } + + func testDayStringOffsetZeroMatchesUTC() { + // Offset 0 must be byte-identical to the legacy UTC behaviour for every caller/test. + let ts = 1_609_459_200 + XCTAssertEqual(AnalyticsEngine.dayString(ts, offsetSec: 0), AnalyticsEngine.dayString(ts)) + // A non-midnight ts too. + XCTAssertEqual(AnalyticsEngine.dayString(ts + 45_000, offsetSec: 0), + AnalyticsEngine.dayString(ts + 45_000)) + } + + func testDayStringSamplesSpanningOneLocalDayMapToOneKey() { + // Every wall-clock second across a UTC-4 user's local 2021-06-15 (00:00 → 23:59:59 local) + // must map to the single key "2021-06-15", even though the late-evening hours cross midnight + // UTC into 2021-06-16. Local 00:00 EDT 2021-06-15 == 04:00 UTC == 1623729600. + let offset = -4 * 3600 + let localMidnightUtc = 1_623_729_600 // 2021-06-15 00:00:00 local (04:00 UTC) + // Pick samples at local 00:00, 12:00, 20:00, 23:59 — the last three cross UTC midnight. + let probes = [0, 12 * 3600, 20 * 3600, 24 * 3600 - 1] + for p in probes { + XCTAssertEqual(AnalyticsEngine.dayString(localMidnightUtc + p, offsetSec: offset), + "2021-06-15", "local-day probe at +\(p)s mis-bucketed") + } + // And one second earlier / one second past the local day fall on the neighbours. + XCTAssertEqual(AnalyticsEngine.dayString(localMidnightUtc - 1, offsetSec: offset), "2021-06-14") + XCTAssertEqual(AnalyticsEngine.dayString(localMidnightUtc + 24 * 3600, offsetSec: offset), + "2021-06-16") + } + + func testDayStringEastOfUTC() { + // A Tokyo user (UTC+9) just after local midnight on 2021-06-16 (15:00 UTC on 2021-06-15) + // is on local day 2021-06-16 while the UTC bucket is still 2021-06-15. + // 2021-06-15 15:30:00 UTC == 1623771000. + let tsUtc = 1_623_771_000 + let offset = 9 * 3600 + XCTAssertEqual(AnalyticsEngine.dayString(tsUtc), "2021-06-15") + XCTAssertEqual(AnalyticsEngine.dayString(tsUtc, offsetSec: offset), "2021-06-16") + } + /// Build a still, low-HR night ending on a known UTC day. private func night(endDay: String, hours: Int) -> (start: Int, end: Int, hr: [HRSample], rr: [RRInterval], @@ -112,4 +161,383 @@ final class AnalyticsEngineTests: XCTestCase { let decoded = try JSONDecoder().decode(DailyMetric.self, from: data) XCTAssertEqual(decoded, result.daily) } + + func testAnalyzeDayPopulatesParityFields() throws { + // The Android-parity computations must land on the DailyMetric when the streams are + // supplied: RSA respiration from RR, daily steps from the cumulative @57 counter, + // whole-day HR-only calories, and the wear-gated skin-temp deviation (usable baseline). + let day = "2021-06-21" + let n = night(endDay: day, hours: 7) + // RSA-modulated RR replacing the square-wave fixture: mean 1200 ms (HR 50), ±40 ms at + // 0.25 Hz — a planted 15 breaths/min the estimator must recover. + var rr: [RRInterval] = [] + var tSec = 0.0 + while tSec < Double(n.end - n.start) { + let rrMs = 1200.0 + 40.0 * sin(2.0 * Double.pi * 0.25 * tSec) + tSec += rrMs / 1000.0 + rr.append(RRInterval(ts: n.start + Int(tSec), rrMs: Int(rrMs))) + } + // Worn in-bed skin temp at 34 °C across the whole night (raw = °C × 100, the firmware's + // centidegree scale — see SkinTempAnalyticsTests' SCALE NOTE). + let skin = (0..<(n.end - n.start)).map { SkinTempSample(ts: n.start + $0, raw: 3400) } + // Step counter: morning movement after wake, inside the same UTC day → 250 steps. + let steps = [StepSample(ts: n.end + 600, counter: 100), + StepSample(ts: n.end + 1200, counter: 350)] + let skinBase = Baselines.foldHistory([33.5, 33.4, 33.6, 33.5], + cfg: Baselines.metricCfg["skin_temp"]!) + XCTAssertTrue(skinBase.usable) + let result = AnalyticsEngine.analyzeDay( + day: day, hr: n.hr, rr: rr, gravity: n.gravity, steps: steps, skinTemp: skin, + profile: UserProfile(age: 30), + baselines: AnalyticsEngine.ProfileBaselines(skinTemp: skinBase)) + XCTAssertEqual(result.sleepSessions.count, 1) + XCTAssertEqual(result.daily.steps, 250) + XCTAssertGreaterThan(try XCTUnwrap(result.daily.activeKcalEst), 0) + // RSA respiration recovered from the in-bed RR (≈15 bpm planted, ±3 tolerance). + XCTAssertEqual(try XCTUnwrap(result.daily.respRateBpm), 15.0, accuracy: 3.0) + // Wear-gated nightly mean (34 °C plateau) + a positive deviation vs the ~33.5 °C baseline. + XCTAssertEqual(try XCTUnwrap(result.nightlySkinTempC), 34.0, accuracy: 1e-9) + XCTAssertGreaterThan(try XCTUnwrap(result.daily.skinTempDevC), 0.2) + } + + /// End-to-end for the wake-time-edit feature (#318): the REAL stager detects a night and assigns it + /// a startTs; a hand-correction keyed by THAT startTs must flow through `dailyAggregateHonoringEdits` + /// and lower the day's total sleep. Proves the edit's key actually lines up with genuine stager + /// output — the one thing the isolated seam tests can't, since they hand-pick startTs. + func testEditOnRealDetectedSleepLowersTheDailyAggregate() throws { + let day = "2026-06-15" + let n = night(endDay: day, hours: 8) + let result = AnalyticsEngine.analyzeDay( + day: day, hr: n.hr, rr: n.rr, gravity: n.gravity, profile: UserProfile(age: 30)) + XCTAssertEqual(result.cachedSleep.count, 1, "the synthetic still-night must detect one sleep") + let detected = try XCTUnwrap(result.cachedSleep.first) + let detectedTotal = try XCTUnwrap(result.daily.totalSleepMin) + + // Hand-correct THAT detected session's wake to its midpoint, reshaping the real segment stages + // exactly as the app's edit path does. + let newEnd = detected.startTs + (detected.endTs - detected.startTs) / 2 + let reshaped = SleepWindowReclip.reclip(stagesJSON: detected.stagesJSON, + sessionStart: detected.startTs, + oldEnd: detected.endTs, + newStart: detected.startTs, newEnd: newEnd) + XCTAssertNotNil(reshaped, "the detected segment stages must reshape to the new window") + + // The override — keyed by the stager's OWN detected startTs — must fire and roughly halve sleep. + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: result.cachedSleep.map { (startTs: $0.startTs, stagesJSON: $0.stagesJSON) }, + edited: [detected.startTs: reshaped])) + XCTAssertTrue(r.editApplied, "the edit's startTs must line up with the stager's detected startTs") + XCTAssertLessThan(r.sleep.totalSleepMin, detectedTotal * 0.7, + "honoring a wake moved to the midpoint must clearly lower total sleep") + } + + /// The fix for the "awake block on extend" bug (#318): re-staging a window from raw data with the + /// public `stageSession` yields REAL per-epoch stages tiling the window — not one fabricated "wake" + /// block the reshape produced. This is the WHOOP-parity path the edit uses when a night has raw data. + func testStageSessionReDerivesRealStagesAndEncodes() throws { + let day = "2026-06-16" + let n = night(endDay: day, hours: 6) // a still night with real synthetic hr+gravity + let segs = SleepStager.stageSession(start: n.start, end: n.end, + grav: n.gravity, hr: n.hr, rr: n.rr, resp: []) + XCTAssertFalse(segs.isEmpty) + XCTAssertTrue(segs.contains { $0.stage != "wake" }, + "re-staging must recover real sleep stages, not an all-awake block") + XCTAssertEqual(segs.first?.start, n.start, "segments tile from the window start") + XCTAssertEqual(segs.last?.end, n.end, "…to the window end") + // Encodes to the stored segment-array shape and decodes back to the same stage minutes. + let json = try XCTUnwrap(AnalyticsEngine.encodeStages(segs)) + XCTAssertNotNil(SleepStageTotals.minutes(fromStagesJSON: json)) + } + + func testEncodeStagesIsDeterministic() throws { + // The post-sync self-heal re-derives an edited night's stages each pass and skips the DB write + // when the new JSON equals the stored one. That idempotency depends on encodeStages producing + // byte-identical output for identical input (stable key + array order) — guard it here. + let segs = [StageSegment(start: 0, end: 600, stage: "light"), + StageSegment(start: 600, end: 900, stage: "deep"), + StageSegment(start: 900, end: 1200, stage: "rem")] + let a = try XCTUnwrap(AnalyticsEngine.encodeStages(segs)) + let b = try XCTUnwrap(AnalyticsEngine.encodeStages(segs)) + XCTAssertEqual(a, b, "encodeStages must be deterministic so the heal's equality-skip holds") + } + + /// End-to-end of the edit-races-sync fix. Mirrors `Repository.selfHealEditedStages`' per-night recipe + /// with the REAL components (store insert → density gate → SleepStager.stageSession → encodeStages → + /// updateSleepStages). Covers all three behaviors: a night edited before its raw synced heals to real + /// stages once raw lands; a night with no raw stays as-is; and a second pass is a no-op (idempotent). + func testSelfHealRecipeHealsEditedNightOnceRawArrives() async throws { + let store = try await WhoopStore.inMemory() + let strap = "strap", computed = "strap-noop" + + // Per-night heal recipe — exactly what selfHealEditedStages runs for each userEdited row. + func heal() async throws { + let edited = (try await store.sleepSessions(deviceId: computed, from: 0, to: .max, limit: 100)) + .filter { $0.userEdited } + for row in edited { + let lo = row.effectiveStartTs - 3_600, hi = row.endTs + 3_600 + let grav = try await store.gravitySamples(deviceId: strap, from: lo, to: hi, limit: 200_000) + let inWindow = grav.filter { $0.ts >= row.effectiveStartTs && $0.ts <= row.endTs }.count + guard inWindow >= max(20, (row.endTs - row.effectiveStartTs) / 120) else { continue } + let hr = try await store.hrSamples(deviceId: strap, from: lo, to: hi, limit: 200_000) + let rr = try await store.rrIntervals(deviceId: strap, from: lo, to: hi, limit: 200_000) + let segs = SleepStager.stageSession(start: row.effectiveStartTs, end: row.endTs, + grav: grav, hr: hr, rr: rr, resp: []) + guard let newJSON = AnalyticsEngine.encodeStages(segs), newJSON != row.stagesJSON else { continue } + try await store.updateSleepStages(deviceId: computed, detectedStartTs: row.startTs, + stagesJSON: newJSON) + } + } + func nonWakeStageCount(_ json: String?) throws -> Int { + let segs = try JSONDecoder().decode([StageSegment].self, from: Data(try XCTUnwrap(json).utf8)) + return segs.filter { $0.stage != "wake" }.count + } + + // Night A — edited BEFORE its raw synced (bounds corrected, stages fabricated to one "wake" block), + // and the strap sync then delivers dense raw for its window. + let a = night(endDay: "2021-06-22", hours: 6) + try await store.upsertSleepSessions( + [CachedSleepSession(startTs: a.start, endTs: a.end, efficiency: 0.9, + restingHr: 50, avgHrv: 60, stagesJSON: nil)], deviceId: computed) + let fabricatedA = "[{\"end\":\(a.end),\"stage\":\"wake\",\"start\":\(a.start)}]" + try await store.applySleepEdit(deviceId: computed, detectedStartTs: a.start, + newStartTs: a.start, newEndTs: a.end, stagesJSON: fabricatedA) + _ = try await store.insert(Streams(hr: a.hr, rr: a.rr, gravity: a.gravity), deviceId: strap) + + // Night B — also edited-before-sync, but its raw NEVER arrives (no insert): must stay fabricated. + let b = night(endDay: "2021-06-20", hours: 6) + try await store.upsertSleepSessions( + [CachedSleepSession(startTs: b.start, endTs: b.end, efficiency: 0.9, + restingHr: 50, avgHrv: 60, stagesJSON: nil)], deviceId: computed) + let fabricatedB = "[{\"end\":\(b.end),\"stage\":\"wake\",\"start\":\(b.start)}]" + try await store.applySleepEdit(deviceId: computed, detectedStartTs: b.start, + newStartTs: b.start, newEndTs: b.end, stagesJSON: fabricatedB) + + try await heal() + + let rows = try await store.sleepSessions(deviceId: computed, from: 0, to: .max, limit: 100) + let rowA = try XCTUnwrap(rows.first { $0.startTs == a.start }) + let rowB = try XCTUnwrap(rows.first { $0.startTs == b.start }) + + // A healed to real stages; bounds + flag intact. + XCTAssertNotEqual(rowA.stagesJSON, fabricatedA, "A's fabricated awake block was replaced") + XCTAssertGreaterThan(try nonWakeStageCount(rowA.stagesJSON), 0, "A recovered real sleep stages") + XCTAssertEqual(rowA.effectiveStartTs, a.start, "A onset preserved") + XCTAssertEqual(rowA.endTs, a.end, "A wake preserved") + XCTAssertTrue(rowA.userEdited) + // B had no raw → untouched. + XCTAssertEqual(rowB.stagesJSON, fabricatedB, "B has no raw, so it stays as-edited (not clobbered)") + + // Idempotent: a second pass re-derives the same JSON for A and writes nothing. + let snapshot = rowA.stagesJSON + try await heal() + let after = try await store.sleepSessions(deviceId: computed, from: 0, to: .max, limit: 100) + XCTAssertEqual(try XCTUnwrap(after.first { $0.startTs == a.start }).stagesJSON, snapshot, + "second heal pass is a no-op (idempotent)") + } + + func testAnalyzeDayWithoutNewStreamsLeavesParityFieldsNil() { + // Pure-function contract: callers that don't supply steps/skinTemp (all pre-existing + // call sites and tests) get nil steps + nil skinTempDevC — never a fabricated value. + let day = "2021-06-22" + let n = night(endDay: day, hours: 7) + let result = AnalyticsEngine.analyzeDay( + day: day, hr: n.hr, rr: n.rr, gravity: n.gravity, profile: UserProfile(age: 30)) + XCTAssertNil(result.daily.steps) + XCTAssertNil(result.daily.skinTempDevC) + XCTAssertNil(result.nightlySkinTempC) + } + + func testAnalyzeDayStepsAttributedByLocalDay() { + // A UTC-4 user's steps taken at local 21:00–22:00 on 2021-06-15 cross UTC midnight into + // 2021-06-16. With the matching local-day key + tzOffset, analyzeDay must attribute them to + // the LOCAL day 2021-06-15 (the bucket the dashboard reads), not the UTC day. Local 21:00 EDT + // 2021-06-15 == 01:00 UTC 2021-06-16 == 1623805200. + let offset = -4 * 3600 + let day = "2021-06-15" + let lateEveningUtc = 1_623_805_200 // local 21:00 on 2021-06-15 (next-day UTC) + let steps = [StepSample(ts: lateEveningUtc, counter: 100), + StepSample(ts: lateEveningUtc + 1800, counter: 360)] // +260 within the local day + let result = AnalyticsEngine.analyzeDay( + day: day, steps: steps, profile: UserProfile(), tzOffsetSeconds: offset) + XCTAssertEqual(result.daily.steps, 260) + // Sanity: the OLD UTC bucketing would have dropped these (they're UTC day 2021-06-16) → + // verify offset 0 with the UTC day produces nil, proving the offset is what saves them. + let utcResult = AnalyticsEngine.analyzeDay( + day: day, steps: steps, profile: UserProfile(), tzOffsetSeconds: 0) + XCTAssertNil(utcResult.daily.steps) + } + + // MARK: - Rest composite (Charge/Effort/Rest) + + func testRestCompositePerfectNight() { + // 8 h asleep over 8 h in bed (eff 1.0), 4 h deep+REM (50% restorative), perfect + // consistency, need 8 h → every sub-score saturates → 100. + let r = AnalyticsEngine.Rest.composite( + tstSeconds: 8 * 3600, inBedSeconds: 8 * 3600, efficiency: 1.0, + restorativeSeconds: 4 * 3600, needHours: 8.0, consistency: 1.0) + XCTAssertEqual(r, 100.0, accuracy: 1e-9) + } + + func testRestCompositeDurationDominatedAndClamped() { + // Duration term alone: 8 h asleep vs 8 h need → 1.0 × 0.50 weight = 50, all other + // sub-scores 0. Confirms the 0.50 duration weight and that over-need clamps at 1.0. + let r = AnalyticsEngine.Rest.composite( + tstSeconds: 8 * 3600, inBedSeconds: 99_999, efficiency: 0.0, + restorativeSeconds: 0.0, needHours: 8.0, consistency: 0.0) + XCTAssertEqual(r, 50.0, accuracy: 1e-9) + // Sleeping well over need does not push duration past 1.0. + let over = AnalyticsEngine.Rest.composite( + tstSeconds: 12 * 3600, inBedSeconds: 12 * 3600, efficiency: 1.0, + restorativeSeconds: 6 * 3600, needHours: 8.0, consistency: 1.0) + XCTAssertEqual(over, 100.0, accuracy: 1e-9) + } + + func testRestCompositeNilConsistencyIsNeutral() { + // A single day carries no regularity signal → nil consistency scores the neutral 0.5. + let withNil = AnalyticsEngine.Rest.composite( + tstSeconds: 4 * 3600, inBedSeconds: 5 * 3600, efficiency: 0.8, + restorativeSeconds: 1 * 3600, needHours: 8.0, consistency: nil) + let withHalf = AnalyticsEngine.Rest.composite( + tstSeconds: 4 * 3600, inBedSeconds: 5 * 3600, efficiency: 0.8, + restorativeSeconds: 1 * 3600, needHours: 8.0, consistency: 0.5) + XCTAssertEqual(withNil, withHalf, accuracy: 1e-9) + XCTAssertEqual(withNil, 56.0, accuracy: 1e-9) + } + + func testAnalyzeDayPopulatesRestAndConfidence() { + // A normal night yields a Rest score and a Rest confidence that is at least + // .building (a session exists). With no HRV baseline, Charge is .calibrating; + // 7 h of 1 Hz HR makes Effort .solid. + let day = "2021-06-23" + let n = night(endDay: day, hours: 7) + let result = AnalyticsEngine.analyzeDay( + day: day, hr: n.hr, rr: n.rr, gravity: n.gravity, profile: UserProfile(age: 30)) + XCTAssertNotNil(result.restScore) + XCTAssertGreaterThan(result.restScore!, 0) + XCTAssertLessThanOrEqual(result.restScore!, 100) + XCTAssertNotEqual(result.restConfidence, .calibrating) // a session exists + XCTAssertEqual(result.chargeConfidence, .calibrating) // no HRV baseline + XCTAssertEqual(result.effortConfidence, .solid) // 7 h of 1 Hz HR ≫ 1 h + } + + func testNoMatchingNightLeavesRestNilAndCalibrating() { + let n = night(endDay: "2021-06-24", hours: 7) + let result = AnalyticsEngine.analyzeDay( + day: "2021-06-25", hr: n.hr, rr: n.rr, gravity: n.gravity, profile: UserProfile(age: 30)) + XCTAssertNil(result.restScore) + XCTAssertEqual(result.restConfidence, .calibrating) + } + + // MARK: - ScoreConfidence boundaries + + func testChargeConfidenceTiers() { + let trusted = BaselineState(baseline: 50, spread: 5, nValid: 14, + nightsSinceUpdate: 0, status: .trusted) + let provisional = BaselineState(baseline: 50, spread: 5, nValid: 5, + nightsSinceUpdate: 0, status: .provisional) + let calibrating = BaselineState(baseline: 50, spread: 5, nValid: 2, + nightsSinceUpdate: 0, status: .calibrating) + // Score present + trusted baseline → solid. + XCTAssertEqual(ScoreConfidence.charge(recovery: 60, hrvBaseline: trusted), .solid) + // Score present + provisional baseline → building. + XCTAssertEqual(ScoreConfidence.charge(recovery: 60, hrvBaseline: provisional), .building) + // No score → calibrating regardless of baseline. + XCTAssertEqual(ScoreConfidence.charge(recovery: nil, hrvBaseline: trusted), .calibrating) + // Unusable baseline → calibrating. + XCTAssertEqual(ScoreConfidence.charge(recovery: 60, hrvBaseline: calibrating), .calibrating) + XCTAssertEqual(ScoreConfidence.charge(recovery: 60, hrvBaseline: nil), .calibrating) + } + + func testEffortConfidenceTiers() { + // No strain → calibrating. Thin HR window → building. Dense → solid (boundary at 3600). + XCTAssertEqual(ScoreConfidence.effort(strain: nil, hrSampleCount: 10_000), .calibrating) + XCTAssertEqual(ScoreConfidence.effort(strain: 40, hrSampleCount: 3599), .building) + XCTAssertEqual(ScoreConfidence.effort(strain: 40, hrSampleCount: 3600), .solid) + } + + func testRestConfidenceTiers() { + XCTAssertEqual(ScoreConfidence.rest(hasSession: false, hasStagedSleep: false), .calibrating) + XCTAssertEqual(ScoreConfidence.rest(hasSession: true, hasStagedSleep: false), .building) + XCTAssertEqual(ScoreConfidence.rest(hasSession: true, hasStagedSleep: true), .solid) + } + + // H9: a high-efficiency night whose deep+REM share is implausibly low is flagged LOW-CONFIDENCE + // (downgraded solid → building) — an honest "staging may be off", no faked stages. + func testRestConfidenceH9DowngradesLowRestorativeHighEfficiencyNight() { + // 8 h asleep, 95% efficient, but only ~3% deep+REM (well below the 10% floor) → building. + let asleep = 8.0 * 3600.0 + let restorative = asleep * 0.03 + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: true, + asleepSeconds: asleep, restorativeSeconds: restorative, + efficiency: 0.95), + .building, "high-efficiency night with near-zero deep+REM is low-confidence staging") + } + + func testRestConfidenceH9KeepsSolidWhenRestorativeHealthy() { + // Same high-efficiency night but a healthy ~45% restorative share → stays solid. + let asleep = 8.0 * 3600.0 + let restorative = asleep * 0.45 + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: true, + asleepSeconds: asleep, restorativeSeconds: restorative, + efficiency: 0.95), + .solid) + } + + func testRestConfidenceH9DoesNotFlagLowEfficiencyNight() { + // A fragmented (low-efficiency) night legitimately carries little deep/REM — the floor must NOT + // false-positive there, so it stays whatever the base tier was (solid: it has staged sleep). + let asleep = 8.0 * 3600.0 + let restorative = asleep * 0.03 + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: true, + asleepSeconds: asleep, restorativeSeconds: restorative, + efficiency: 0.60), + .solid, "low efficiency legitimately carries less deep/REM — the floor must not flag it") + } + + func testRestConfidenceH9NeverUpgradesNonSolidBase() { + // No staged sleep → base is .building; H9 only DOWNGRADES, so it can't lift this to solid. + XCTAssertEqual( + ScoreConfidence.rest(hasSession: true, hasStagedSleep: false, + asleepSeconds: 8.0 * 3600.0, restorativeSeconds: 0, efficiency: 0.95), + .building) + } + + // MARK: - #525 day with an overnight + a nap reports CONSISTENT totals + // #525's main-night-not-sum reconciliation is covered deterministically by the SleepStageTotals + // suite (testOvernightPlusNapReportsConsistentTotalsNotTheSum with explicit stage JSON, plus the + // three mainNightIndex selection tests). An end-to-end analyzeDay variant was dropped: it leaned on + // the SleepStager detecting a synthetic daytime nap, which the daytime-false-sleep guard rejects by + // design, so it tested detection (a #508 concern), not #525's aggregation. + + // MARK: - Group E (Sleep & Rest test mode): analyzeDay forwards the trace + emits the Rest line + + func testAnalyzeDayEmitsGateAndRestTrace() { + // A real scored night yields at least one gate verdict line and one Rest sub-score line when + // a trace sink is supplied. The numeric DayResult must be UNCHANGED versus the untraced call. + let day = "2021-06-17" + let n = night(endDay: day, hours: 7) + var lines: [String] = [] + let traced = AnalyticsEngine.analyzeDay( + day: day, hr: n.hr, rr: n.rr, gravity: n.gravity, profile: UserProfile(age: 30), + traceSink: { lines.append($0) }) + let untraced = AnalyticsEngine.analyzeDay( + day: day, hr: n.hr, rr: n.rr, gravity: n.gravity, profile: UserProfile(age: 30)) + XCTAssertTrue(lines.contains { $0.hasPrefix("gate ") }, "expected a gate line, got: \(lines)") + XCTAssertTrue(lines.contains { $0.hasPrefix("rest ") }, "expected a Rest sub-score line, got: \(lines)") + // Trace is side-effect-only: the whole scored DailyMetric matches the untraced run exactly. + XCTAssertEqual(traced.daily, untraced.daily) + XCTAssertEqual(traced.sleepSessions, untraced.sleepSessions) + } + + func testAnalyzeDayWithoutTraceSinkProducesNoLines() { + // Zero-cost-when-off proof: no sink means no work and no lines. + let day = "2021-06-18" + let n = night(endDay: day, hours: 7) + let result = AnalyticsEngine.analyzeDay( + day: day, hr: n.hr, rr: n.rr, gravity: n.gravity, profile: UserProfile(age: 30)) + XCTAssertNotNil(result.daily.totalSleepMin) + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTests.swift new file mode 100644 index 0000000000..24b44b9ce6 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Parity tests for the MVP `AutoWorkoutDetector` — mirrors +/// android/.../AutoWorkoutDetectorTest.kt case-for-case so the two platforms stay byte-parity on +/// the detection logic. +/// +/// Cases: elevated span detected; brief dip tolerated; short/low spans rejected; near windows +/// merged; window overlapping a saved workout excluded. +final class AutoWorkoutDetectorTests: XCTestCase { + + /// A flat 1 Hz HR block [start, start+durS) at `bpm`. + private func block(_ start: Int, _ durS: Int, _ bpm: Int) -> [(ts: Int, bpm: Int)] { + (0.. GravitySample { + GravitySample(ts: ts, x: x, y: 0, z: 1) + } + + // resting 60 → floor = 90. Workout bpm 120 is elevated; rest bpm 65 is not. + + func testElevatedSpanIsDetected() { + // 20 min sustained at 120 bpm, embedded in rest. One workout, ~20 min, avg/peak 120. + let start = 1_000_000 + let durS = 20 * 60 + let hr = block(start - 600, 600, 65) + block(start, durS, 120) + block(start + durS, 600, 65) + let out = AutoWorkoutDetector.detect(hr: hr, restingBpm: 60) + XCTAssertEqual(out.count, 1) + let w = out[0] + XCTAssertEqual(w.avgBpm, 120) + XCTAssertEqual(w.peakBpm, 120) + XCTAssertGreaterThanOrEqual(w.durationMin, 19) + XCTAssertEqual(w.startSec, start) + } + + func testBriefDipIsTolerated() { + // 10 min at 120, a 60 s dip to 70 (below floor, but <= 90 s), then 10 min at 120. + // The dip must NOT split the span → one ~21 min workout. + let start = 2_000_000 + let first = block(start, 600, 120) + let dip = block(start + 600, 60, 70) + let second = block(start + 660, 600, 120) + let hr = block(start - 300, 300, 65) + first + dip + second + block(start + 1260, 300, 65) + let out = AutoWorkoutDetector.detect(hr: hr, restingBpm: 60) + XCTAssertEqual(out.count, 1, "dip split the span into \(out.count)") + XCTAssertGreaterThanOrEqual(out[0].durationMin, 20, "merged span too short: \(out[0].durationMin) min") + } + + func testShortSpanIsRejected() { + // 8 min at 120 (< 12 min minimum) → nothing. + let start = 3_000_000 + let hr = block(start - 300, 300, 65) + block(start, 8 * 60, 120) + block(start + 480, 300, 65) + XCTAssertTrue(AutoWorkoutDetector.detect(hr: hr, restingBpm: 60).isEmpty) + } + + func testLowSpanIsRejected() { + // 20 min at 85 bpm: resting 60 → floor 90, so 85 never clears the gate → nothing. + let start = 4_000_000 + let hr = block(start - 300, 300, 65) + block(start, 20 * 60, 85) + block(start + 1200, 300, 65) + XCTAssertTrue(AutoWorkoutDetector.detect(hr: hr, restingBpm: 60).isEmpty) + } + + func testNearWindowsAreMerged() { + // Two 15 min bouts at 120 separated by a 3 min true rest at 65 (< 5 min merge gap, but the rest + // is > 90 s so it CLOSES each span). The two closed spans are then MERGED into one (gap < 5 min). + let start = 5_000_000 + let a = block(start, 15 * 60, 120) + let gap = block(start + 900, 3 * 60, 65) // 180 s rest > maxDipS → span closes + let b = block(start + 1080, 15 * 60, 120) + let hr = block(start - 300, 300, 65) + a + gap + b + block(start + 1980, 300, 65) + let out = AutoWorkoutDetector.detect(hr: hr, restingBpm: 60) + XCTAssertEqual(out.count, 1, "near windows not merged: \(out.count)") + // Merged span runs from the first bout's start to the second bout's end (~33 min). + XCTAssertGreaterThanOrEqual(out[0].durationMin, 30, "merged span too short: \(out[0].durationMin) min") + } + + func testFarWindowsStaySeparate() { + // Two 15 min bouts at 120 separated by a 10 min rest (>= 5 min merge gap) → two workouts. + let start = 6_000_000 + let a = block(start, 15 * 60, 120) + let gap = block(start + 900, 10 * 60, 65) + let b = block(start + 1500, 15 * 60, 120) + let hr = block(start - 300, 300, 65) + a + gap + b + block(start + 2400, 300, 65) + let out = AutoWorkoutDetector.detect(hr: hr, restingBpm: 60) + XCTAssertEqual(out.count, 2) + } + + func testWindowOverlappingSavedWorkoutIsExcluded() { + // A clean 20 min bout, but a saved workout already covers the middle of it → suggestion suppressed. + let start = 7_000_000 + let hr = block(start - 300, 300, 65) + block(start, 20 * 60, 120) + block(start + 1200, 300, 65) + let saved = [SavedWorkoutSpan(startSec: start + 300, endSec: start + 600)] // overlaps the span + XCTAssertTrue(AutoWorkoutDetector.detect(hr: hr, restingBpm: 60, savedSpans: saved).isEmpty) + // Sanity: with the overlap removed, it IS detected. + XCTAssertEqual(AutoWorkoutDetector.detect(hr: hr, restingBpm: 60).count, 1) + } + + func testMotionConfirmationGatesWhenSeriesPresent() { + // Same elevated HR bout, but the gravity series is perfectly STILL over the window → no motion + // confirmation → rejected. With no gravity series (HR-only) the same bout IS detected. + let start = 8_000_000 + let hr = block(start - 300, 300, 65) + block(start, 20 * 60, 120) + block(start + 1200, 300, 65) + let still = (start..<(start + 1200)).map { grav($0, 0.0) } // zero motion delta + XCTAssertTrue(AutoWorkoutDetector.detect(hr: hr, restingBpm: 60, + motion: AutoWorkoutDetector.motionPoints(still)).isEmpty) + XCTAssertEqual(AutoWorkoutDetector.detect(hr: hr, restingBpm: 60).count, 1) + // Moving gravity (alternating x) confirms motion → detected. + let moving = (start..<(start + 1200)).map { grav($0, Double(($0 - start) % 2) * 0.5) } + XCTAssertEqual(AutoWorkoutDetector.detect(hr: hr, restingBpm: 60, + motion: AutoWorkoutDetector.motionPoints(moving)).count, 1) + } + + func testEmptyInputIsEmpty() { + XCTAssertTrue(AutoWorkoutDetector.detect(hr: [], restingBpm: nil).isEmpty) + } + + func testDefaultRestingHrIsUsedWhenNull() { + // No restingBpm → default 60 → floor 90. 20 min at 120 is detected. + let start = 9_000_000 + let hr = block(start - 300, 300, 65) + block(start, 20 * 60, 120) + block(start + 1200, 300, 65) + XCTAssertEqual(AutoWorkoutDetector.detect(hr: hr, restingBpm: nil).count, 1) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTraceTests.swift new file mode 100644 index 0000000000..b6ed03c90d --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTraceTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// The Workouts & GPS test mode's pure traces. Proves the auto-detect trace returns the SAME +/// [DetectedWorkout] detect(...) does (byte-identical) AND names why each window was offered or dropped, +/// plus the WorkoutsTrace line formatters and the WorkoutsReadout parser. Twin of the Android +/// AutoWorkoutDetectorTraceTest. No em-dashes. +final class AutoWorkoutDetectorTraceTests: XCTestCase { + + /// A flat 1 Hz HR block [start, start+durS) at `bpm`. + private func block(_ start: Int, _ durS: Int, _ bpm: Int) -> [(ts: Int, bpm: Int)] { + (0.. UserDefaults { + let suite = "BaselinesTests.\(fn)" + let d = UserDefaults(suiteName: suite)! + d.removePersistentDomain(forName: suite) // start clean + return d + } + + /// recalibrateRecoveryBaselines must move BOTH the HRV and the recovery epoch to `now` — the whole + /// Charge build-up restarts, not just HRV. Before the reset both read 0 (no recalibration). + func testRecalibrateMovesBothEpochs() { + let d = makeDefaults() + XCTAssertEqual(Baselines.hrvBaselineEpoch(d), 0, accuracy: 1e-9) + XCTAssertEqual(Baselines.recoveryBaselineEpoch(d), 0, accuracy: 1e-9) + + let now = 1_750_000_000.0 + Baselines.recalibrateRecoveryBaselines(now: now, defaults: d) + + XCTAssertEqual(Baselines.hrvBaselineEpoch(d), now, accuracy: 1e-9) + XCTAssertEqual(Baselines.recoveryBaselineEpoch(d), now, accuracy: 1e-9) + } + + /// End-to-end: a poisoned baseline (a bad high first week, then real lower nights) re-anchors to the + /// recent reality once the reset moves the epoch — and the SAME stored history is folded both times + /// (nothing is deleted; only the anchor day moves). + func testRecalibrateReAnchorsNextComputationWithoutDeletingHistory() { + let d = makeDefaults() + // A "bad first week" worn sick (high HRV artefact), then six honest lower nights. + let days = ["2026-06-08", "2026-06-09", "2026-06-10", "2026-06-11", "2026-06-12", "2026-06-13", + "2026-06-15", "2026-06-16", "2026-06-17", "2026-06-18", "2026-06-19", "2026-06-20"] + let vals: [Double?] = [90, 91, 89, 90, 92, 88, 54, 55, 53, 54, 56, 54] + + // Before any reset the early high week anchors the baseline well above the real ~54ms. + let before = Baselines.foldHistory(vals, dayKeys: days, cfg: Baselines.hrvCfg, + baselineEpoch: Baselines.hrvBaselineEpoch(d)) + XCTAssertGreaterThan(before.baseline, 70.0) + + // User taps "Recalibrate Charge baseline" at the start of 2026-06-15. + var comps = DateComponents() + comps.year = 2026; comps.month = 6; comps.day = 15 + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(secondsFromGMT: 0)! + let resetInstant = cal.date(from: comps)!.timeIntervalSince1970 + Baselines.recalibrateRecoveryBaselines(now: resetInstant, defaults: d) + + // The next computation folds the IDENTICAL history (nothing deleted) but honours the new epoch, + // dropping the pre-reset nights and re-seeding from tonight onward. + let after = Baselines.foldHistory(vals, dayKeys: days, cfg: Baselines.hrvCfg, + baselineEpoch: Baselines.hrvBaselineEpoch(d)) + XCTAssertEqual(after.nValid, 6) // only the 6 post-reset nights contribute + XCTAssertEqual(after.baseline, 54.0, accuracy: 2.0) // re-anchored to the real value + XCTAssertLessThan(after.baseline, before.baseline - 10.0) + } + + /// Reset at "now" with only pre-now history drops every night → an honest calibrating cold-start, + /// which is exactly what lets Today show the building/calibrating state again. + func testRecalibrateNowYieldsCalibratingWhenNoNewerNights() { + let d = makeDefaults() + let days = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"] + let vals: [Double?] = [60, 61, 59, 62, 60] + // Anchor strictly AFTER the last night so all are dropped. + var comps = DateComponents(); comps.year = 2026; comps.month = 6; comps.day = 6 + var cal = Calendar(identifier: .gregorian); cal.timeZone = TimeZone(secondsFromGMT: 0)! + Baselines.recalibrateRecoveryBaselines(now: cal.date(from: comps)!.timeIntervalSince1970, defaults: d) + + let after = Baselines.foldHistory(vals, dayKeys: days, cfg: Baselines.hrvCfg, + baselineEpoch: Baselines.recoveryBaselineEpoch(d)) + XCTAssertEqual(after.nValid, 0) + XCTAssertEqual(after.status, .calibrating) + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTests.swift new file mode 100644 index 0000000000..17b7a06652 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTests.swift @@ -0,0 +1,109 @@ +import XCTest +@testable import StrandAnalytics + +final class BatteryEstimatorTests: XCTestCase { + + private let h = 3600 + + func testNilWhenNoSamples() { + XCTAssertNil(BatteryEstimator.estimate(samples: [], ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)) + } + + func testMeasuredRateFromCleanDischarge() { + // 100% to 90% over 10h is 1 %/h; at 90% that leaves 90h, from the user's own discharge. + let e = BatteryEstimator.estimate(samples: [(0, 100), (10 * h, 90)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)! + XCTAssertEqual(e.source, .measured) + XCTAssertEqual(e.remainingHours, 90, accuracy: 1e-6) + XCTAssertEqual(e.hoursRemaining, 90, accuracy: 1e-6) + XCTAssertEqual(e.daysRemaining, 90.0 / 24, accuracy: 1e-6) + XCTAssertEqual(e.currentSoc, 90, accuracy: 1e-6) + } + + func testRatedFallbackWhenSpanTooShort() { + // A single reading has no span to fit, so it falls back to rated: 50 / (100/108) = 54h. + let e = BatteryEstimator.estimate(samples: [(0, 50)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop4)! + XCTAssertEqual(e.source, .rated) + XCTAssertEqual(e.remainingHours, 54, accuracy: 1e-6) + } + + func testChargeRestartsTheDischargeRun() { + // Discharge 100->70, then a charge back to 100, then 100->88 over 6h. The rate is fit on the + // post-charge segment only (2 %/h), never across the charge. + let e = BatteryEstimator.estimate(samples: [(0, 100), (4 * h, 70), (5 * h, 100), (11 * h, 88)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)! + XCTAssertEqual(e.source, .measured) + XCTAssertEqual(e.remainingHours, 44, accuracy: 1e-6) // 88 / 2 + } + + func testPartialTopUpDoesNotInflateDaysLeft() { + // #8: a partial top-up must NOT reset the discharge run like a full charge. Buffer is a long clean + // discharge 100%->40% over 60h (1 %/h), a quick desk top-up 40->55 at 61h, then 55->53 over 3h. The + // old scan anchored the run on the +15pp top-up and fit ~0.67 %/h on the 3h tail, inflating the + // estimate. With the near-full guard the top-up is stepped over, the fit prefers the long pre-top-up + // segment (1 %/h), and at 53% that is an honest ~53h, not the inflated ~79h. + let e = BatteryEstimator.estimate( + samples: [(0, 100), (60 * h, 40), (61 * h, 55), (64 * h, 53)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)! + XCTAssertEqual(e.source, .measured) + XCTAssertEqual(e.currentSoc, 53, accuracy: 1e-6) + XCTAssertEqual(e.remainingHours, 53, accuracy: 1e-6) // 53 / (1 %/h), pre-top-up slope + } + + func testMeasuredFromRecentDischargeWithoutNearFullCharge() { + // #919: a WHOOP 5.0 that never tops past 90% - SoC rises 16->52, then discharges 52->44 over 8h. The + // old scan found no near-full anchor and fell back to the OLDEST (16%) reading, so the window netted + // to a CHARGE (drop<0) and the estimate stayed on rated. Anchoring at the buffer's max (52%) fits the + // real 1 %/h discharge -> measured, 44h. (Distinct from #8, whose buffer already starts at its max.) + let e = BatteryEstimator.estimate( + samples: [(0, 16), (4 * h, 52), (12 * h, 44)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)! + XCTAssertEqual(e.source, .measured) + XCTAssertEqual(e.currentSoc, 44, accuracy: 1e-6) + XCTAssertEqual(e.remainingHours, 44, accuracy: 1e-6) // 52->44 = 8pp over 8h = 1 %/h; 44 / 1 + } + + func testNearFullChargeStillResetsTheRun() { + // The guard must NOT change a genuine near-full charge: discharge 100->20, charge back to 95 (>=90, + // near-full), then 95->85 over 5h is 2 %/h. The run still resets on the near-full charge, source + // measured, 85 / 2 = 42.5h. This pins that the near-full anchor still fires (no regression of #713). + let e = BatteryEstimator.estimate( + samples: [(0, 100), (8 * h, 20), (9 * h, 95), (14 * h, 85)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)! + XCTAssertEqual(e.source, .measured) + XCTAssertEqual(e.remainingHours, 42.5, accuracy: 1e-6) // 85 / 2, post-near-full-charge segment + } + + func testRatedFallbackWhenDropTooSmall() { + // 100->99 over 10h is a 1% drop, under minDropPct(2), so it falls back to rated instead of + // reporting a wild ~1000h. The estimate stays anchored to the latest SoC. + let e = BatteryEstimator.estimate(samples: [(0, 100), (10 * h, 99)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)! + XCTAssertEqual(e.source, .rated) + XCTAssertEqual(e.remainingHours, 285.12, accuracy: 1e-6) // 99 / (100/288) + } + + func testClampsToOneAndAHalfTimesRated() { + // A slow drain near full charge must not report more than 1.5x the rated life. 100% to 90% over + // 20h is 0.5 %/h, current 90% -> 180h raw, clamped to 108*1.5 = 162h. + let e = BatteryEstimator.estimate(samples: [(0, 100), (20 * h, 90)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop4)! + XCTAssertEqual(e.source, .measured) + XCTAssertEqual(e.remainingHours, 162, accuracy: 1e-6) // clamped, not 200 + } + + func testUnsortedSamplesAreHandled() { + // Same two points as the clean-discharge case but out of order: result must match. + let e = BatteryEstimator.estimate(samples: [(10 * h, 90), (0, 100)], + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5)! + XCTAssertEqual(e.source, .measured) + XCTAssertEqual(e.remainingHours, 90, accuracy: 1e-6) + XCTAssertEqual(e.currentSoc, 90, accuracy: 1e-6) + } + + func testLabelSwitchesHoursToDaysAt48h() { + XCTAssertEqual(BatteryEstimator.label(hours: 14), "~14h") + XCTAssertEqual(BatteryEstimator.label(hours: 108), "~4.5 days") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTraceTests.swift new file mode 100644 index 0000000000..71e0eafb4e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryEstimatorTraceTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import StrandAnalytics + +/// The Battery test mode's pure SoC-series + discharge-run + slope + gate trace. Pins the exact lines a +/// fixture series produces AND proves the emitter never changes the engine value `estimate(...)` returns +/// (#713, Test Centre). Twin of the Android BatteryEstimatorTraceTest. No em-dashes. +final class BatteryEstimatorTraceTests: XCTestCase { + + private let h = 3600 + + func testTraceNilWhenNoSamples() { + let (estimate, lines) = BatteryEstimator.estimateTrace( + samples: [], ratedHours: BatteryEstimator.ratedLifeHoursWhoop5) + XCTAssertNil(estimate) + XCTAssertEqual(lines, ["battery series=0 readings, no reading to anchor to"]) + } + + func testTraceEmitsSeriesChargeStepRunSlopeAndGate() { + // Same fixture as the discharge-restart case: discharge 100->70, a charge back to 100 at 5h, then + // 100->88 over 6h. The run is fit on the post-charge segment only (2 %/h), source measured. + let samples: [(ts: Int, soc: Double)] = [(0, 100), (4 * h, 70), (5 * h, 100), (11 * h, 88)] + let (estimate, lines) = BatteryEstimator.estimateTrace( + samples: samples, ratedHours: BatteryEstimator.ratedLifeHoursWhoop5) + + // The emitter must NOT change the engine result (byte-identical to estimate()). + let plain = BatteryEstimator.estimate(samples: samples, + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5) + XCTAssertEqual(estimate, plain) + + XCTAssertEqual(lines, [ + "battery series=4 readings span 0..39600s", + "battery read t=0s soc=100.0", + "battery read t=14400s soc=70.0", + "battery read t=18000s soc=100.0", + "battery read t=39600s soc=88.0", + "battery chargeStep at t=18000s +30.0pp (>chargeStepPct 1.0)", + "battery dischargeRun start=18000s span=6.0h drop=12.0pp", + "battery slope=2.0pct/h fitted from run endpoints", + "battery gate minSpanHours 2.0 PASS, minDropPct 2.0 PASS -> source=measured", + ]) + } + + func testTracePartialTopUpFitsPreTopUpSegment() { + // #8: a partial top-up (40->55, below nearFullPct 90) does NOT anchor the run. The trace reports it + // as a partialTopUp, the fit prefers the long pre-top-up discharge (100->40 over 60h = 1 %/h), and + // source stays measured at an honest ~53h, not the inflated short-tail rate. + let samples: [(ts: Int, soc: Double)] = [(0, 100), (60 * h, 40), (61 * h, 55), (64 * h, 53)] + let (estimate, lines) = BatteryEstimator.estimateTrace( + samples: samples, ratedHours: BatteryEstimator.ratedLifeHoursWhoop5) + + // The emitter must NOT change the engine result (byte-identical to estimate()). + let plain = BatteryEstimator.estimate(samples: samples, + ratedHours: BatteryEstimator.ratedLifeHoursWhoop5) + XCTAssertEqual(estimate, plain) + + XCTAssertEqual(lines, [ + "battery series=4 readings span 0..230400s", + "battery read t=0s soc=100.0", + "battery read t=216000s soc=40.0", + "battery read t=219600s soc=55.0", + "battery read t=230400s soc=53.0", + "battery partialTopUp at t=219600s +15.0pp ( fit pre-top-up segment", + "battery dischargeRun start=0s span=60.0h drop=60.0pp", + "battery slope=1.0pct/h fitted from run endpoints", + "battery gate minSpanHours 2.0 PASS, minDropPct 2.0 PASS -> source=measured", + ]) + // No full-charge chargeStep line: the only rise here is a partial top-up. + XCTAssertFalse(lines.contains { $0.hasPrefix("battery chargeStep") }) + } + + func testTraceGateDropToRatedWhenDropTooSmall() { + // 100->99 over 10h is a 1pp drop, under minDropPct 2, so the gate fails and source=rated. + let samples: [(ts: Int, soc: Double)] = [(0, 100), (10 * h, 99)] + let (estimate, lines) = BatteryEstimator.estimateTrace( + samples: samples, ratedHours: BatteryEstimator.ratedLifeHoursWhoop5) + XCTAssertEqual(estimate?.source, .rated) + XCTAssertTrue(lines.contains( + "battery gate minSpanHours 2.0 PASS, minDropPct 2.0 FAIL -> source=rated")) + XCTAssertFalse(lines.contains { $0.hasPrefix("battery chargeStep") }) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryRegistryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryRegistryTests.swift new file mode 100644 index 0000000000..7630216c9b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BatteryRegistryTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the canonical Battery test mode contract (title, questionnaire ids, readout ids, capture ids, +/// guided-days default) so a drafter drift breaks the build (#713, Test Centre). No em-dashes. +final class BatteryRegistryTests: XCTestCase { + + func testBatteryModeCanonicalContract() { + let m = TestModeRegistry.battery + XCTAssertEqual(m.domain, .battery) + XCTAssertEqual(m.title, "Battery & Charging") + XCTAssertEqual(m.questionnaire.map(\.id), + ["whoopAppInstalled", "otherPhonePaired", "chargedInWindow", "batterySaverApps"]) + XCTAssertEqual(m.liveReadout, ["currentSoc", "estimateDaysLeft", "slopeSource"]) + XCTAssertEqual(m.captures, + ["socSeries", "chargeSteps", "offWristGaps", "dischargeRun", "fittedSlope", + "sourceMeasuredVsRated", "batteryGates"]) + if case .guided(let unit, let count) = m.capture { + XCTAssertEqual(unit, .days) + XCTAssertEqual(count, 3) + } else { + XCTFail("Battery mode must be guided days") + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BreathPacerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BreathPacerTests.swift new file mode 100644 index 0000000000..fbd0813a64 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BreathPacerTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the L1 `BreathPacer` cue list: a fixed `(bpm, inhaleFraction, cycles)` → an exact `[BreathCue]` +/// list (offsets, phase, loops). Pure value logic, so no strap/BLE seam is needed. These are the GOLDEN +/// VECTORS the Kotlin `BreathPacerTest` mirrors byte-for-byte — the cross-platform parity contract. +/// See docs/superpowers/specs/2026-06-19-v5-haptic-biofeedback-design.md. +final class BreathPacerTests: XCTestCase { + + // GOLDEN VECTOR A: 6.0 br/min, 0.4 inhale, 2 cycles. + // cycleMs = 60000/6 = 10000; inhaleMs = 4000. + func test_golden_6bpm_2cycles() { + let cues = BreathPacer.schedule(bpm: 6.0, inhaleFraction: 0.4, cycles: 2) + XCTAssertEqual(cues, [ + BreathCue(offsetMs: 0, phase: .inhale, loops: 1), + BreathCue(offsetMs: 4000, phase: .exhale, loops: 2), + BreathCue(offsetMs: 10000, phase: .inhale, loops: 1), + BreathCue(offsetMs: 14000, phase: .exhale, loops: 2), + ]) + } + + // GOLDEN VECTOR B: 5.5 br/min (the coherence / common resonance pace), default inhale, 3 cycles. + // cycleMs = round(60000/5.5) = round(10909.09) = 10909; inhaleMs = round(10909*0.4) = round(4363.6) = 4364. + func test_golden_5p5bpm_3cycles_default_fraction() { + let cues = BreathPacer.schedule(bpm: 5.5, cycles: 3) + XCTAssertEqual(cues, [ + BreathCue(offsetMs: 0, phase: .inhale, loops: 1), + BreathCue(offsetMs: 4364, phase: .exhale, loops: 2), + BreathCue(offsetMs: 10909, phase: .inhale, loops: 1), + BreathCue(offsetMs: 15273, phase: .exhale, loops: 2), + BreathCue(offsetMs: 21818, phase: .inhale, loops: 1), + BreathCue(offsetMs: 26182, phase: .exhale, loops: 2), + ]) + } + + func test_inhale_lighter_than_exhale_always() { + for cue in BreathPacer.schedule(bpm: 4.5, cycles: 4) { + switch cue.phase { + case .inhale: XCTAssertEqual(cue.loops, 1) + case .exhale: XCTAssertEqual(cue.loops, 2) + } + } + } + + func test_two_cues_per_cycle_in_time_order() { + let cues = BreathPacer.schedule(bpm: 7.0, cycles: 5) + XCTAssertEqual(cues.count, 10) + for i in 1..s` samples; universal `dayOwner day=`. + // 2026-06-30, 07-01, 07-02 are three distinct nights/days; the three battery stamps below are all inside + // 2026-07-02 UTC (02:00 / 03:00 / 04:00), so at offset 0 they fold to ONE day (the accumulator counts + // distinct days, not samples). + private let report = """ + [sleep] gate run=0 spanS=1163 DROPPED gate=minSleepMin spanMin=19 minSleepMin=60 + sleep day=2026-07-02 totalSleepMin=131 matched=3 source=computed + sleep day=2026-07-01 totalSleepMin=331 matched=1 source=computed + sleep day=2026-06-30 totalSleepMin=381 matched=1 source=computed + [steps] stepsRaw day=2026-07-02 counterSamples=29248 firstCounter=65046 lastCounter=5336 + [steps] stepsRaw day=2026-07-01 counterSamples=1000 + [battery] bank soc=26.0 t=1782957600s + [battery] bank soc=25.0 t=1782961200s + [battery] bank soc=24.0 t=1782964800s + [universal] dayOwner day=2026-07-02 readId=my-whoop writeActiveId=my-whoop hrRows=120 provenance=measured + [universal] dayOwner day=2026-07-01 readId=my-whoop writeActiveId=my-whoop hrRows=120 provenance=measured + """ + + /// Sleep counts three DISTINCT nights from its `sleep day=` lines (the DROPPED gate line carries no day + /// key, so it does not inflate the count; the three dated lines do). + func testSleepCountsDistinctNights() { + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .sleep, reportText: report, tzOffsetSeconds: 0), 3) + } + + /// Steps counts two distinct days from its `stepsRaw day=` lines. + func testStepsCountsDistinctDays() { + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .steps, reportText: report, tzOffsetSeconds: 0), 2) + } + + /// Battery folds its `t=s` samples to a local day: three stamps inside one UTC day => 1 day at + /// offset 0. This is the #965 heart: the counter reflects DISTINCT captured days, never the sample count. + func testBatteryFoldsEpochSamplesToOneDay() { + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .battery, reportText: report, tzOffsetSeconds: 0), 1) + } + + /// The universal dayOwner line accumulates once per scored day (two here). + func testUniversalCountsScoredDays() { + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .universal, reportText: report, tzOffsetSeconds: 0), 2) + } + + /// Each mode accumulates INDEPENDENTLY: sleep=3, steps=2, battery=1 off the SAME log, so the three rows + /// diverge instead of every guided row sharing one number (the #965 "stuck at 1 of 3" regression). + func testModesAccumulateIndependently() { + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .sleep, reportText: report, tzOffsetSeconds: 0), 3) + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .steps, reportText: report, tzOffsetSeconds: 0), 2) + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .battery, reportText: report, tzOffsetSeconds: 0), 1) + } + + /// A dead-trace mode (active but no line landed) reads 0, never a fabricated number. + func testDeadTraceIsZero() { + let onlyBattery = "[battery] bank soc=50.0 t=1782957600s" + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .sleep, reportText: onlyBattery, tzOffsetSeconds: 0), 0) + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .steps, reportText: onlyBattery, tzOffsetSeconds: 0), 0) + } + + /// A domain with no registered day-marker (no day-bearing trace) accumulates 0 rather than mis-counting. + func testUnmarkedDomainIsZero() { + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .connection, reportText: report, tzOffsetSeconds: 0), 0) + } + + /// A `day=` on some OTHER mode's line does not leak into an unrelated mode's count: the token scoping + /// keeps each mode counting only its own lines. + func testDayKeyDoesNotLeakAcrossModes() { + // A workouts line carrying a day= must not count toward sleep. + let cross = "[workouts] autoDetect day=2026-07-05 windows=1\nsleep day=2026-07-02 totalSleepMin=100 matched=1 source=computed" + XCTAssertEqual(CaptureAccumulator.capturedDays(domain: .sleep, reportText: cross, tzOffsetSeconds: 0), 1) + } + + /// A west-of-UTC offset re-buckets a battery stamp near the UTC-midnight boundary onto the local day, so + /// the fold uses the SAME local-day convention as AnalyticsEngine.dayString (the day keys agree). + func testBatteryLocalDayFold() { + // 1782957600 = 2026-07-02 02:00 UTC. At UTC-9h (-32400s) it is 2026-07-01 17:00 local => prior day. + let one = "[battery] bank soc=40.0 t=1782957600s" + XCTAssertEqual(CaptureAccumulator.capturedDayKeys(domain: .battery, reportText: one, tzOffsetSeconds: 0), + ["2026-07-02"]) + XCTAssertEqual(CaptureAccumulator.capturedDayKeys(domain: .battery, reportText: one, tzOffsetSeconds: -32400), + ["2026-07-01"]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift new file mode 100644 index 0000000000..366d11bebb --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import StrandAnalytics + +/// The report-completeness guard (#812, generalised): an ACTIVE domain whose killer trace landed reads OK; +/// an ACTIVE domain that produced no trace reads INCOMPLETE (the dead-trace warning). Also pins the token +/// map against the verbatim emitter tokens so a renamed emitter can't silently make the guard blind. +final class CaptureCompletenessTests: XCTestCase { + + // A report fragment carrying a real line per domain, in the exact shape the live emitters write. + private let fullReport = """ + [sleep] gate run=2 spanS=1800 kept gate=arousal still in bed + [sleep] sleepProvenance provenance=measured hoursAsleep=7 sourceRowId=42 + [connection] clockDrift newest=2026-06-28 02:00:00 wall=2026-06-28 02:01:00 newestVsWall=-60s clockOk + [connection] bondState client-hello acked + [workouts] autoDetect result windows=1 offered=1 + [workouts] session event=start sport=run hrSamples=120 + [display] dataVolume dbRows=900 importedDays=30 cacheRows=12 + [display] frameSummary frames=120 mean=8.0ms p95=16.0ms hitches=2 worst=40.0ms threshold=33.0ms + [import] import stage=sleep rowsIn=10 rowsOut=10 + [steps] stepsRaw day=2026-06-28 counterSamples=4 deltas kept=3 dropped=0 + [battery] bank soc=82.0 t=1700000000s + [recovery] charge term hrv z=0.20 w=0.40 (higher HRV is better) + [hrv] hrv rmssd=42.10ms sdnn=55.00ms meanNN=900.00ms + [universal] dayOwner day=2026-06-28 readId=my-whoop writeActiveId=my-whoop hrRows=120 provenance=measured + """ + + func testActiveDomainWithTraceIsOK() { + let checks = CaptureCompleteness.evaluate(activeDomains: [.sleep, .universal], reportText: fullReport) + let sleep = checks.first { $0.domain == "sleep" } + XCTAssertEqual(sleep?.status, .ok) + XCTAssertEqual(sleep?.count, 2, "both gate run= and sleepProvenance lines should count") + let universal = checks.first { $0.domain == "universal" } + XCTAssertEqual(universal?.status, .ok) + XCTAssertEqual(universal?.count, 1) + } + + func testActiveDomainWithoutTraceIsIncomplete() { + // Battery mode was on but NO `bank soc=` / `socSeries` line landed (a dead capture). + let report = "[sleep] gate run=1 spanS=900 kept gate=onset\n[universal] dayOwner day=2026-06-28 readId=x writeActiveId=x hrRows=0 provenance=none" + let checks = CaptureCompleteness.evaluate(activeDomains: [.battery, .universal], reportText: report) + let battery = checks.first { $0.domain == "battery" } + XCTAssertEqual(battery?.status, .incomplete) + XCTAssertEqual(battery?.count, 0) + XCTAssertEqual(battery?.tokens, ["bank soc=", "socSeries"], "the INCOMPLETE row names the missing tokens") + // The universal trace DID land, so it stays OK even though battery is INCOMPLETE. + XCTAssertEqual(checks.first { $0.domain == "universal" }?.status, .ok) + XCTAssertTrue(CaptureCompleteness.anyIncomplete(checks)) + } + + func testUniversalOKFromClockDriftAloneWhenNoScoringPassRan() { + // No scoring pass during the capture means no `dayOwner` line, but the universal clock-drift line + // still rides the export, so universal must read OK off `strapClock` alone. + let report = "[universal] strapClock newest=2026-06-28 00:00:00 wall=2026-06-28 00:01:00 newestVsWall=-60s firmware=v25 clockOk" + let checks = CaptureCompleteness.evaluate(activeDomains: [.universal], reportText: report) + XCTAssertEqual(checks.first { $0.domain == "universal" }?.status, .ok) + } + + func testInactiveDomainIsNotGraded() { + // Only sleep was active; connection has lines in the report but was OFF, so it must not appear. + let checks = CaptureCompleteness.evaluate(activeDomains: [.sleep], reportText: fullReport) + XCTAssertNil(checks.first { $0.domain == "connection" }) + XCTAssertEqual(checks.map { $0.domain }, ["sleep"]) + } + + func testDomainWithNoRegisteredTokensIsSkipped() { + // notifications has no emitter / no token map entry, so an active-but-ungradable domain is skipped + // rather than flagged INCOMPLETE (we never promised it a trace). + let checks = CaptureCompleteness.evaluate(activeDomains: [.notifications, .universal], + reportText: fullReport) + XCTAssertNil(checks.first { $0.domain == "notifications" }) + XCTAssertEqual(checks.first { $0.domain == "universal" }?.status, .ok) + } + + func testEveryRegisteredDomainGradesOKOnTheFullReport() { + let all: Set = [.sleep, .connection, .workouts, .display, .dataImport, + .steps, .battery, .recovery, .hrv, .universal] + let checks = CaptureCompleteness.evaluate(activeDomains: all, reportText: fullReport) + XCTAssertEqual(checks.count, all.count) + for c in checks { + XCTAssertEqual(c.status, .ok, "\(c.domain) should match its token in the full report") + } + XCTAssertFalse(CaptureCompleteness.anyIncomplete(checks)) + } + + func testStableOrderUniversalLast() { + let all: Set = [.universal, .battery, .sleep] + let order = CaptureCompleteness.evaluate(activeDomains: all, reportText: fullReport).map { $0.domain } + XCTAssertEqual(order, ["sleep", "battery", "universal"], "registry order, universal last") + } + + func testReportSectionRendersOKAndIncomplete() { + let report = "[sleep] gate run=1 spanS=900 kept gate=onset" + let checks = CaptureCompleteness.evaluate(activeDomains: [.sleep, .battery], reportText: report) + let section = CaptureCompleteness.reportSection(checks) + XCTAssertTrue(section.contains("Capture check")) + XCTAssertTrue(section.contains("[OK] sleep:")) + XCTAssertTrue(section.contains("[INCOMPLETE] battery: mode was on but produced NO trace")) + XCTAssertTrue(section.contains("bank soc="), "the missing token is named") + XCTAssertFalse(section.contains("\u{2014}"), "no em-dashes") + } + + func testEmptyChecksRenderNoSection() { + XCTAssertEqual(CaptureCompleteness.reportSection([]), "") + } + + func testTokenMapMatchesEmitterTokensExactly() { + // Guard against a silent emitter rename: each token must be the verbatim leading text the live + // emitter writes. These literals mirror the *Trace files (verified at authoring time). + XCTAssertEqual(CaptureCompleteness.expectedTokens(for: .recovery).first, "charge term") + XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .hrv).contains("hrv rmssd=")) + XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .steps).contains("stepsRaw")) + XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .universal).contains("dayOwner ")) + XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .universal).contains("strapClock ")) + XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .dataImport).contains("rowsIn=")) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ChargeDriversTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ChargeDriversTests.swift new file mode 100644 index 0000000000..035621985e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ChargeDriversTests.swift @@ -0,0 +1,179 @@ +import XCTest +@testable import StrandAnalytics + +/// The Charge driver list + relative skin-temp marker (SHARED CONTRACT). Proves the drivers come from +/// the SAME weighting `recovery(...)` uses, that a missing term yields NO row (never a fake one), that +/// the sign of each driver matches its real direction, and that the skin-temp relative tier banding is +/// honest. Twin of the Android RecoveryScorerChargeDriversTest. No em-dashes. +final class ChargeDriversTests: XCTestCase { + + /// A usable (trusted) baseline with a given mean and σ (Gaussian). + private func baseline(mean: Double, sigma: Double, nValid: Int = 14) -> BaselineState { + BaselineState(baseline: mean, spread: sigma / 1.253, nValid: nValid, + nightsSinceUpdate: 0, status: nValid >= 14 ? .trusted : .provisional) + } + + // MARK: - Presence / omission + + func testColdStartHRVBaselineYieldsNoDrivers() { + // HRV baseline not usable -> recovery() is nil -> no real contributions to attribute. + let coldHrv = baseline(mean: 50, sigma: 6, nValid: 1) // < seed -> .provisional? force calibrating + let calibrating = BaselineState(baseline: 50, spread: 6 / 1.253, nValid: 1, + nightsSinceUpdate: 0, status: .calibrating) + _ = coldHrv + let drivers = RecoveryScorer.chargeDrivers( + hrv: 60, rhr: 50, resp: 15, + hrvBaseline: calibrating, rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: baseline(mean: 16, sigma: 2), sleepPerf: 0.9, skinTempDev: 0.2) + XCTAssertTrue(drivers.isEmpty) + } + + func testMissingTermsOmittedNotFabricated() { + // No resp, no resp baseline, no skin temp -> those rows must be ABSENT (not zero rows). + let drivers = RecoveryScorer.chargeDrivers( + hrv: 60, rhr: 50, resp: nil, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: nil, sleepPerf: 0.9, skinTempDev: nil) + let labels = Set(drivers.map { $0.label }) + XCTAssertTrue(labels.contains("Heart rate variability")) + XCTAssertTrue(labels.contains("Resting heart rate")) + XCTAssertTrue(labels.contains("Sleep quality")) + XCTAssertFalse(labels.contains("Respiratory rate")) // omitted, not a fake 0 row + XCTAssertFalse(labels.contains("Skin temperature")) // omitted, not a fake 0 row + XCTAssertEqual(drivers.count, 3) + } + + func testNoRHRBaselineOmitsRHRRow() { + let drivers = RecoveryScorer.chargeDrivers( + hrv: 60, rhr: 50, resp: nil, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: nil, + respBaseline: nil, sleepPerf: 0.85, skinTempDev: nil) + XCTAssertFalse(drivers.contains { $0.label == "Resting heart rate" }) + XCTAssertTrue(drivers.contains { $0.label == "Heart rate variability" }) + } + + // MARK: - Sign correctness (the term's real direction) + + func testGoodInputsGivePositiveContributions() { + // Moderately-good inputs in the real operating range (Charge in the high 70s/low 80s, not a + // saturated +3sigma-on-everything corner where the logistic is flat and small-weight terms + // round to 0 points honestly). Each MATERIAL term (HRV 0.55, resting HR 0.20, Rest 0.15) + // should push Charge UP, so its marginal-vs-neutral contribution is strictly positive. + // Respiration is a deliberately-minor 0.05-weight term: it can legitimately be worth ~0 + // points, so we assert only its DIRECTION (non-negative + a supporting verdict), not a + // fabricated magnitude. + let drivers = RecoveryScorer.chargeDrivers( + hrv: 58, rhr: 53, resp: 15, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 58, sigma: 3), + respBaseline: baseline(mean: 16, sigma: 2), sleepPerf: 0.91, skinTempDev: nil) + let hrv = drivers.first { $0.label == "Heart rate variability" }! + let rhr = drivers.first { $0.label == "Resting heart rate" }! + let sleep = drivers.first { $0.label == "Sleep quality" }! + let resp = drivers.first { $0.label == "Respiratory rate" }! + XCTAssertGreaterThan(hrv.deltaPoints, 0) + XCTAssertGreaterThan(rhr.deltaPoints, 0) + XCTAssertGreaterThan(sleep.deltaPoints, 0) + XCTAssertGreaterThanOrEqual(resp.deltaPoints, 0) // minor 0.05-weight term; direction below + XCTAssertTrue(hrv.verdict.contains("supporting recovery")) + XCTAssertTrue(rhr.verdict.contains("supporting recovery")) + XCTAssertTrue(resp.verdict.contains("supporting recovery")) + } + + func testBadInputsGiveNegativeContributions() { + // HRV below baseline, RHR above, poor sleep -> each should pull Charge DOWN (<0). + let drivers = RecoveryScorer.chargeDrivers( + hrv: 38, rhr: 66, resp: 19, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 58, sigma: 3), + respBaseline: baseline(mean: 16, sigma: 2), sleepPerf: 0.65, skinTempDev: nil) + let hrv = drivers.first { $0.label == "Heart rate variability" }! + let rhr = drivers.first { $0.label == "Resting heart rate" }! + let sleep = drivers.first { $0.label == "Sleep quality" }! + XCTAssertLessThan(hrv.deltaPoints, 0) + XCTAssertLessThan(rhr.deltaPoints, 0) + XCTAssertLessThan(sleep.deltaPoints, 0) + XCTAssertTrue(hrv.verdict.contains("limiting recovery")) + } + + func testSkinTempDeviationIsAlwaysNonPositive() { + // Skin temp is a SYMMETRIC penalty: any drift can only lower Charge, so its contribution + // (full minus without) is <= 0 for both a warm and a cold drift. + let warm = RecoveryScorer.chargeDrivers( + hrv: 55, rhr: 52, resp: nil, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: nil, sleepPerf: 0.85, skinTempDev: 0.8) + let cold = RecoveryScorer.chargeDrivers( + hrv: 55, rhr: 52, resp: nil, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: nil, sleepPerf: 0.85, skinTempDev: -0.8) + let warmRow = warm.first { $0.label == "Skin temperature" }! + let coldRow = cold.first { $0.label == "Skin temperature" }! + XCTAssertLessThanOrEqual(warmRow.deltaPoints, 0) + XCTAssertLessThanOrEqual(coldRow.deltaPoints, 0) + XCTAssertTrue(warmRow.valueText.contains("+0.8")) + XCTAssertTrue(coldRow.valueText.contains("-0.8")) + } + + // MARK: - Ordering, value text, baseline text + + func testOrderedByMagnitudeBiggestMoverFirst() { + let drivers = RecoveryScorer.chargeDrivers( + hrv: 68, rhr: 49, resp: 14, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 58, sigma: 3), + respBaseline: baseline(mean: 16, sigma: 2), sleepPerf: 0.95, skinTempDev: 0.4) + let mags = drivers.map { abs($0.deltaPoints) } + XCTAssertEqual(mags, mags.sorted(by: >), "drivers must be ordered biggest mover first") + // HRV is the dominant weight; with a strong HRV signal it should lead. + XCTAssertEqual(drivers.first?.label, "Heart rate variability") + } + + func testValueAndBaselineTextShape() { + let drivers = RecoveryScorer.chargeDrivers( + hrv: 58, rhr: 61, resp: nil, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 64, sigma: 3), + respBaseline: nil, sleepPerf: 0.85, skinTempDev: nil) + let rhr = drivers.first { $0.label == "Resting heart rate" }! + XCTAssertEqual(rhr.valueText, "61 bpm") + XCTAssertEqual(rhr.baselineText, "64 bpm baseline") + let hrv = drivers.first { $0.label == "Heart rate variability" }! + XCTAssertEqual(hrv.valueText, "58 ms") + XCTAssertEqual(hrv.baselineText, "50 ms baseline") + // Sleep quality has no learned baseline -> empty baselineText (UI omits the line). + let sleep = drivers.first { $0.label == "Sleep quality" }! + XCTAssertEqual(sleep.baselineText, "") + } + + func testNoEmDashesInOutput() { + let drivers = RecoveryScorer.chargeDrivers( + hrv: 60, rhr: 50, resp: 15, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: baseline(mean: 16, sigma: 2), sleepPerf: 0.9, skinTempDev: 0.3) + for d in drivers { + for s in [d.label, d.valueText, d.baselineText, d.verdict] { + XCTAssertFalse(s.contains("\u{2014}"), "em-dash in: \(s)") + } + } + } + + // MARK: - A5: relative skin-temp tier + + func testSkinTempRelativeNilWhenNoDeviation() { + XCTAssertNil(RecoveryScorer.skinTempRelative(deviationC: nil)) + } + + func testSkinTempRelativeTiers() { + let band = RecoveryScorer.skinTempTypicalBandC + // Within the band -> typical. + XCTAssertEqual(RecoveryScorer.skinTempRelative(deviationC: 0.0)?.tier, .typical) + XCTAssertEqual(RecoveryScorer.skinTempRelative(deviationC: band)?.tier, .typical) // boundary inclusive + XCTAssertEqual(RecoveryScorer.skinTempRelative(deviationC: -band)?.tier, .typical) + // Beyond the band -> warmer / cooler. + XCTAssertEqual(RecoveryScorer.skinTempRelative(deviationC: band + 0.2)?.tier, .warmer) + XCTAssertEqual(RecoveryScorer.skinTempRelative(deviationC: -(band + 0.2))?.tier, .cooler) + } + + func testSkinTempRelativeCarriesSignedDeviation() { + let rel = RecoveryScorer.skinTempRelative(deviationC: 0.7)! + XCTAssertEqual(rel.deviationC, 0.7, accuracy: 1e-9) + XCTAssertEqual(rel.tier, .warmer) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift new file mode 100644 index 0000000000..95382a455c --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import StrandAnalytics + +final class CircadianEngineTests: XCTestCase { + + /// Build a 24-point hourly profile from a known cosine: mesor + amp·cos(2π(h − acro)/24). + private func profile(mesor: Double, amp: Double, acrophase: Double) -> [CircadianEngine.ActivityBin] { + (0..<24).map { h in + let v = mesor + amp * cos(2.0 * Double.pi * (Double(h) - acrophase) / 24.0) + return CircadianEngine.ActivityBin(hour: Double(h), activity: v) + } + } + + // MARK: - Cosinor recovers a known acrophase + amplitude (pure-math determinism) + + func testCosinorRecoversInjectedParameters() { + let fit = CircadianEngine.cosinor(profile(mesor: 50, amp: 30, acrophase: 15))! + XCTAssertEqual(fit.mesor, 50, accuracy: 1e-6) + XCTAssertEqual(fit.amplitude, 30, accuracy: 1e-6) + XCTAssertEqual(fit.acrophaseHours, 15, accuracy: 1e-6) + } + + func testCosinorAcrophaseWrapsIntoDay() { + let fit = CircadianEngine.cosinor(profile(mesor: 10, amp: 5, acrophase: 23))! + XCTAssertEqual(fit.acrophaseHours, 23, accuracy: 1e-6) + XCTAssertGreaterThanOrEqual(fit.acrophaseHours, 0) + XCTAssertLessThan(fit.acrophaseHours, 24) + } + + func testCosinorRejectsTooFewPoints() { + XCTAssertNil(CircadianEngine.cosinor([.init(hour: 1, activity: 1), .init(hour: 2, activity: 2)])) + } + + // MARK: - Phase estimate confidence + + func testStrongRhythmEnoughDaysIsSolid() { + let bins = profile(mesor: 50, amp: 30, acrophase: 15) + let est = CircadianEngine.estimatePhase(bins: bins, daysObserved: 20, habitualWakeHour: 7)! + XCTAssertEqual(est.confidence, .solid) + // Acrophase 15:00 → derived temp-min ≈ 15 − 12 = 03:00. + XCTAssertEqual(est.tempMinHour, 3, accuracy: 1e-6) + } + + func testThinDataIsWideOrUnreadable() { + let bins = profile(mesor: 50, amp: 30, acrophase: 15) + let est = CircadianEngine.estimatePhase(bins: bins, daysObserved: 4, habitualWakeHour: 7)! + XCTAssertEqual(est.confidence, .unreadable) + XCTAssertTrue(est.note.lowercased().contains("hard to read")) + } + + func testArrhythmicProfileIsUnreadable() { + // Near-flat activity (amplitude ≈ 0) → arrhythmic → unreadable even with many days. + let bins = profile(mesor: 50, amp: 0.5, acrophase: 15) + let est = CircadianEngine.estimatePhase(bins: bins, daysObserved: 30, habitualWakeHour: 7)! + XCTAssertEqual(est.confidence, .unreadable) + } + + func testObservedTempMinOverridesDerived() { + let bins = profile(mesor: 50, amp: 30, acrophase: 15) + let est = CircadianEngine.estimatePhase( + bins: bins, daysObserved: 20, habitualWakeHour: 7, observedTempMinHour: 4.5)! + XCTAssertEqual(est.tempMinHour, 4.5, accuracy: 1e-9) + } + + // MARK: - Jet-lag / shift planner: direction + light rule + no supplements + + func testEastwardAdvancePlanUsesMorningLight() { + // +3 h required = advance the clock earlier (eastward). + let plan = CircadianEngine.planShift(shiftHours: 3, currentSleepHour: 23, currentWakeHour: 7) + XCTAssertEqual(plan.direction, .advance) + XCTAssertEqual(plan.estimatedDays, 3) // 3 h at ≤1 h/day + XCTAssertEqual(plan.days.count, 3) + // Final day: window pulled 3 h earlier → sleep 20:00, wake 04:00. + let last = plan.days.last! + XCTAssertEqual(last.targetSleepHour, 20, accuracy: 1e-9) + XCTAssertEqual(last.targetWakeHour, 4, accuracy: 1e-9) + // Morning light begins at the new wake. + XCTAssertEqual(last.brightLightStartHour, 4, accuracy: 1e-9) + XCTAssertTrue(last.guidance.contains("bright light early")) + } + + func testWestwardDelayPlanUsesEveningLight() { + // −2 h required = delay the clock later (westward). + let plan = CircadianEngine.planShift(shiftHours: -2, currentSleepHour: 23, currentWakeHour: 7) + XCTAssertEqual(plan.direction, .delay) + XCTAssertEqual(plan.estimatedDays, 2) + let last = plan.days.last! + // Window pushed 2 h later → sleep 01:00, wake 09:00. + XCTAssertEqual(last.targetSleepHour, 1, accuracy: 1e-9) + XCTAssertEqual(last.targetWakeHour, 9, accuracy: 1e-9) + XCTAssertTrue(last.guidance.contains("bright light in the evening")) + } + + func testNoShiftNeededReturnsNonePlan() { + let plan = CircadianEngine.planShift(shiftHours: 0.2, currentSleepHour: 23, currentWakeHour: 7) + XCTAssertEqual(plan.direction, .none) + XCTAssertTrue(plan.days.isEmpty) + } + + func testPlanNeverMentionsSupplements() { + let banned = ["melatonin", "supplement", "pill", "drug", "caffeine pill", "medication"] + for shift in [3.0, -3.0, 6.0, -1.0] { + let plan = CircadianEngine.planShift(shiftHours: shift, currentSleepHour: 23, currentWakeHour: 7) + var text = plan.note.lowercased() + for d in plan.days { text += " " + d.guidance.lowercased() } + for b in banned { XCTAssertFalse(text.contains(b), "plan mentioned banned \(b)") } + } + } + + func testSteppedAtOneHourPerDay() { + // 6 h shift → 6 stepped days. + let plan = CircadianEngine.planShift(shiftHours: 6, currentSleepHour: 23, currentWakeHour: 7) + XCTAssertEqual(plan.estimatedDays, 6) + XCTAssertEqual(plan.days.count, 6) + } + + // MARK: - Clock formatting parity helper + + func testClockFormatting() { + XCTAssertEqual(CircadianEngine.clock(20.0), "20:00") + XCTAssertEqual(CircadianEngine.clock(23.5), "23:30") + XCTAssertEqual(CircadianEngine.clock(-1.0), "23:00") // wraps + XCTAssertEqual(CircadianEngine.clock(7.25), "07:15") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift new file mode 100644 index 0000000000..c63b6781d0 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift @@ -0,0 +1,194 @@ +import XCTest +@testable import StrandAnalytics + +/// The Connection & Sync line formatters + readout parsers (Test Centre). Pure - no clock, no BLE - so +/// fixtures pin the exact line shapes the Swift and Kotlin emitters share. Twin of the Android +/// ConnectionReadoutTest. +final class ConnectionTraceTests: XCTestCase { + + // A strap whose newest record sits before wall-now reads clockOk, with the [oldest, newest] span. + func testClockDriftLineHealthy() { + // 2026-06-26 12:00:00 UTC newest, oldest two days earlier, wall just after newest. + let newest = 1_782_475_200 // 2026-06-26 12:00:00 UTC + let oldest = newest - 2 * 86_400 + let wall = newest + 600 // wall 10 min ahead of the newest record + let line = ConnectionTrace.clockDriftLine(oldestUnix: oldest, newestUnix: newest, wallNowUnix: wall) + XCTAssertTrue(line.hasPrefix("clockDrift newest=2026-06-26 12:00:00 "), line) + XCTAssertTrue(line.contains("newestVsWall=-600s"), line) + XCTAssertTrue(line.contains("spanDays=2"), line) + XCTAssertTrue(line.hasSuffix("clockOk"), line) + XCTAssertFalse(line.contains("FUTURE"), line) + } + + // A strap whose newest record is dated AHEAD of wall-now beyond the tolerance is FUTURE-DATED. + func testClockDriftLineFutureDated() { + let wall = 1_782_475_200 + let newest = wall + 3 * 86_400 // strap thinks it banked 3 days into the future + let line = ConnectionTrace.clockDriftLine(oldestUnix: nil, newestUnix: newest, wallNowUnix: wall) + XCTAssertTrue(line.contains("newestVsWall=+\(3 * 86_400)s"), line) + XCTAssertTrue(line.contains("FUTURE-DATED"), line) + XCTAssertFalse(line.contains("oldest="), line) // half range reply: no lower bound + } + + // A small skew inside the tolerance window must NOT trip the future flag. + func testClockDriftLineWithinToleranceIsOk() { + let wall = 1_782_475_200 + let newest = wall + 60 // 1 min ahead, inside the 120s default tolerance + let line = ConnectionTrace.clockDriftLine(oldestUnix: nil, newestUnix: newest, wallNowUnix: wall) + XCTAssertTrue(line.hasSuffix("clockOk"), line) + } + + func testFirmwareLine() { + XCTAssertEqual(ConnectionTrace.firmwareLine(version: 25, decodable: true), "firmware layout=v25 decodable") + XCTAssertEqual(ConnectionTrace.firmwareLine(version: 30, decodable: false), + "firmware layout=v30 UNMAPPED (no motion/HR decoded)") + } + + func testNoCursorLine() { + XCTAssertEqual(ConnectionTrace.noCursorLine(), + "offload trim=0xFFFFFFFF noCursor (strap has no banked history to offload)") + } + + // #990: the -363 d drift that used to print "clockOk". Beyond the 48 h behind-tolerance the line + // must carry a clock warning naming the day count, mirroring the universal line's shared verdict. + func testClockDriftLineFarBehindIsWarning() { + let wall = 1_782_475_200 + let line = ConnectionTrace.clockDriftLine(oldestUnix: nil, newestUnix: wall - 363 * 86_400, + wallNowUnix: wall) + XCTAssertTrue(line.contains("CLOCK-WARNING"), line) + XCTAssertTrue(line.contains("363d behind wall"), line) + XCTAssertFalse(line.contains("clockOk"), line) + } + + func testClockDriftLineBehindWithinToleranceStaysOk() { + let wall = 1_782_475_200 + let line = ConnectionTrace.clockDriftLine(oldestUnix: nil, newestUnix: wall - 47 * 3_600, + wallNowUnix: wall) + XCTAssertTrue(line.hasSuffix("clockOk"), line) + } + + // #987: an epoch-era newest (never-set RTC, ~1970/71) is the named RTC-EPOCH fault, not a generic + // behind warning and never clockOk. + func testClockDriftLineEpochEraReadsRtcEpoch() { + let line = ConnectionTrace.clockDriftLine(oldestUnix: nil, newestUnix: 40_000_000, // 1971-04 + wallNowUnix: 1_782_475_200) + XCTAssertTrue(line.contains("RTC-EPOCH"), line) + XCTAssertFalse(line.contains("clockOk"), line) + } +} + +final class ConnectionReadoutTests: XCTestCase { + + func testUptimeLabelFromConnectMarker() { + let tail = ["[connection] connect up gen=1 latencyMs=420 uptimeStart=1000"] + // 3 min 12 s after the connect. + XCTAssertEqual(ConnectionReadout.uptimeLabel(taggedTail: tail, nowUnix: 1000 + 192), "3m 12s") + } + + func testUptimeLabelDownAfterDisconnect() { + let tail = [ + "[connection] connect up gen=1 latencyMs=420 uptimeStart=1000", + "[connection] connect down (uptime ends)", + ] + XCTAssertEqual(ConnectionReadout.uptimeLabel(taggedTail: tail, nowUnix: 5000), "not connected") + } + + func testUptimeLabelEmptyTail() { + XCTAssertEqual(ConnectionReadout.uptimeLabel(taggedTail: [], nowUnix: 5000), "not connected") + } + + func testReconnectCountTakesHighest() { + let tail = [ + "[connection] reconnect n=1 reason=connectionTimeout", + "[connection] reconnect n=2 reason=connectionTimeout", + "[connection] reconnect n=3 failedConnect reason=peerRemovedPairing", + ] + XCTAssertEqual(ConnectionReadout.reconnectCount(taggedTail: tail), 3) + } + + func testReconnectCountZeroWhenNone() { + XCTAssertEqual(ConnectionReadout.reconnectCount(taggedTail: ["[connection] connect up gen=1 uptimeStart=1"]), 0) + } + + func testLastOffloadResult() { + let tail = [ + "[connection] offload progress trim=100 chunkRows=5 sessionRows=5 sessionMotion=2 nights=1", + "[connection] offload result=complete rows=42 nights=2", + ] + XCTAssertEqual(ConnectionReadout.lastOffloadResult(taggedTail: tail), "complete rows=42 nights=2") + } + + func testLastOffloadResultStalled() { + let tail = ["[connection] offload result=stalled (idle timeout, rows=12 so far)"] + XCTAssertEqual(ConnectionReadout.lastOffloadResult(taggedTail: tail), "stalled (idle timeout, rows=12 so far)") + } + + func testLastOffloadResultNilWhenNone() { + XCTAssertNil(ConnectionReadout.lastOffloadResult(taggedTail: ["[connection] connect up gen=1 uptimeStart=1"])) + } + + // MARK: - #990 per-session / all-time drained rows + + func testSessionRowsFromProgressLine() { + let tail = ["[connection] offload progress trim=100 chunkRows=5 sessionRows=57 sessionMotion=2 nights=1"] + XCTAssertEqual(ConnectionReadout.sessionRows(taggedTail: tail), 57) + } + + func testSessionRowsResultLineWins() { + let tail = [ + "[connection] offload progress trim=100 chunkRows=5 sessionRows=5 sessionMotion=2 nights=1", + "[connection] offload result=complete rows=42 nights=2", + ] + XCTAssertEqual(ConnectionReadout.sessionRows(taggedTail: tail), 42) + } + + func testSessionRowsEmptyResultIsZeroNotStale() { + // An "empty" result carries no rows= field: it honestly means 0, never an older running total. + let tail = [ + "[connection] offload progress trim=100 chunkRows=9 sessionRows=9 sessionMotion=2 nights=1", + "[connection] offload result=empty (console only, no sensor records)", + ] + XCTAssertEqual(ConnectionReadout.sessionRows(taggedTail: tail), 0) + } + + func testSessionRowsNilWhenNoOffload() { + XCTAssertNil(ConnectionReadout.sessionRows(taggedTail: ["[connection] connect up gen=1 uptimeStart=1"])) + } + + func testDrainedRowsFromSummary() { + XCTAssertEqual(ConnectionReadout.drainedRowsFromSummary( + "Backfill: session persisted 5397 rows (5211 with motion, 5211 skin-temp) across 2 night(s)."), 5_397) + XCTAssertNil(ConnectionReadout.drainedRowsFromSummary("Backfill: session ended - reason=timeout")) + XCTAssertNil(ConnectionReadout.drainedRowsFromSummary("session persisted garbage rows")) + } + + // MARK: - #987 clock latch + last frame + + func testClockCorrelatedDeviceParsesNewest() { + let lines = [ + "12:00:01 Clock correlated: device=100 wall=1782475200", + "12:05:09 Clock correlated: device=1782475600 wall=1782475601", + ] + XCTAssertEqual(ConnectionReadout.clockCorrelatedDevice(logLines: lines), 1_782_475_600) + XCTAssertNil(ConnectionReadout.clockCorrelatedDevice(logLines: ["connect up"])) + } + + func testClockLatchedLabel() { + XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: 1_782_475_600), "yes") + XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: 40_000_000), "no (RTC reads 1970/71)") + XCTAssertEqual(ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil), "no (waiting for the strap clock)") + } + + func testRtcWarningFiresOnEpochEraClockOrNewest() { + XCTAssertNotNil(ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil)) + XCTAssertNotNil(ConnectionReadout.rtcWarning(deviceClockUnix: nil, strapNewestUnix: 30_000_000)) + XCTAssertNil(ConnectionReadout.rtcWarning(deviceClockUnix: 1_782_475_600, strapNewestUnix: 1_782_475_000)) + XCTAssertNil(ConnectionReadout.rtcWarning(deviceClockUnix: nil, strapNewestUnix: nil), + "no signal seen yet must not fabricate a fault") + } + + func testLastFrameLabel() { + XCTAssertEqual(ConnectionReadout.lastFrameLabel(lastFrameUnix: 990, nowUnix: 1_002), "12s ago") + XCTAssertEqual(ConnectionReadout.lastFrameLabel(lastFrameUnix: nil, nowUnix: 1_002), "no frames yet") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CyclePhaseEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CyclePhaseEngineTests.swift new file mode 100644 index 0000000000..f87c7c37a3 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CyclePhaseEngineTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import StrandAnalytics + +final class CyclePhaseEngineTests: XCTestCase { + + /// Build a synthetic biphasic series of `cycles` repeats of a `cycleLen`-day cycle, oldest→newest. + /// Follicular nights sit near 0; luteal nights (the back `lutealLen` days of each cycle) are elevated. + /// tempZ leads, rhrZ ↑ and hrvZ ↓ corroborate (note hrvZ is the RAW HRV z, which the engine negates). + private func biphasic(cycles: Int, cycleLen: Int = 28, lutealLen: Int = 12, + start: String = "2026-01-01") -> [CyclePhaseEngine.Night] { + var nights: [CyclePhaseEngine.Night] = [] + var idx = 0 + for _ in 0..= (cycleLen - lutealLen) + let tempZ = luteal ? 1.4 : -0.2 + let rhrZ = luteal ? 1.0 : -0.1 + let hrvZ = luteal ? -1.0 : 0.1 // HRV DROPS in luteal (negative z) + nights.append(.init(day: day, tempZ: tempZ, rhrZ: rhrZ, hrvZ: hrvZ)) + idx += 1 + } + } + return nights + } + + // MARK: - Biphasic series classifies correctly + + func testLutealNightClassifiesLuteal() { + // End the series deep inside a luteal run. + let nights = biphasic(cycles: 3) + let r = CyclePhaseEngine.classify(nights, baselineUsable: true) + XCTAssertEqual(r.phase, .luteal) + XCTAssertNotEqual(r.confidence, .learning) + XCTAssertFalse(r.shiftMarkers.isEmpty) + } + + func testFollicularNightClassifiesFollicular() { + // 3 full cycles (luteal ends each) + a short follicular run, so the LAST night is follicular. + var nights = biphasic(cycles: 3) + let startNext = CyclePhaseEngine.shiftDay(nights.last!.day, by: 1)! + for i in 0..<8 { // 8 follicular nights past the last luteal + let day = CyclePhaseEngine.shiftDay(startNext, by: i)! + nights.append(.init(day: day, tempZ: -0.2, rhrZ: -0.1, hrvZ: 0.1)) + } + let r = CyclePhaseEngine.classify(nights, baselineUsable: true) + XCTAssertEqual(r.phase, .follicular) + } + + func testDetectsPlausibleCycleLength() { + let nights = biphasic(cycles: 4, cycleLen: 28) + let r = CyclePhaseEngine.classify(nights, baselineUsable: true) + XCTAssertNotNil(r.cycleLengthDays) + if let len = r.cycleLengthDays { + XCTAssertTrue((CyclePhaseEngine.minCycleDays...CyclePhaseEngine.maxCycleDays).contains(len)) + XCTAssertEqual(len, 28, accuracy: 2) + } + XCTAssertEqual(r.confidence, .solid) + } + + func testCycleDayEstimateIsARangeNotAPoint() { + let nights = biphasic(cycles: 3) + let r = CyclePhaseEngine.classify(nights, baselineUsable: true) + XCTAssertNotNil(r.cycleDayLow) + XCTAssertNotNil(r.cycleDayHigh) + if let lo = r.cycleDayLow, let hi = r.cycleDayHigh { + XCTAssertLessThan(lo, hi) // a genuine range, never a single confident day + } + } + + // MARK: - Next-period output is a WINDOW, never a single date + + func testNextPeriodIsAWindow() { + let nights = biphasic(cycles: 4) + let r = CyclePhaseEngine.classify(nights, baselineUsable: true) + if let w = r.nextPeriodWindow { + XCTAssertLessThanOrEqual(w.earliestDay, w.latestDay) + XCTAssertNotEqual(w.earliestDay, w.latestDay) // a range, not a hard date + } + } + + // MARK: - Flat / irregular → "no clear pattern", never a fabricated phase + + func testFlatSeriesYieldsUnknownNotAPhase() { + // No elevation anywhere → no onset → unknown, with shiftMarkers empty. + var nights: [CyclePhaseEngine.Night] = [] + for i in 0..<60 { + let day = CyclePhaseEngine.shiftDay("2026-01-01", by: i)! + nights.append(.init(day: day, tempZ: 0.05, rhrZ: 0.0, hrvZ: 0.0)) + } + let r = CyclePhaseEngine.classify(nights, baselineUsable: true) + XCTAssertEqual(r.phase, .unknown) + XCTAssertNil(r.cycleLengthDays) + XCTAssertNil(r.nextPeriodWindow) + } + + // MARK: - Gates: < 1.5 cycles, untrusted baseline → learning + + func testInsufficientDataIsLearning() { + let nights = biphasic(cycles: 1, cycleLen: 28) // 28 < 42 nights + let r = CyclePhaseEngine.classify(nights, baselineUsable: true) + XCTAssertEqual(r.phase, .learning) + XCTAssertEqual(r.confidence, .learning) + } + + func testUnusableBaselineIsLearning() { + let nights = biphasic(cycles: 3) + let r = CyclePhaseEngine.classify(nights, baselineUsable: false) + XCTAssertEqual(r.phase, .learning) + } + + // MARK: - Logged-period mode: agrees, and a mistimed log is flagged + + func testLoggedPeriodMistimedIsFlagged() { + // Anchor a logged "period start" implausibly far before the latest night (> max cycle). + let nights = biphasic(cycles: 3) + let lastDay = nights.last!.day + let badStart = CyclePhaseEngine.shiftDay(lastDay, by: -50)! + let r = CyclePhaseEngine.classify(nights, baselineUsable: true, loggedPeriodStarts: [badStart]) + XCTAssertTrue(r.note.lowercased().contains("logged")) + } + + // MARK: - No fertility / contraception language anywhere (banned strings) + + func testNoFertilityOrContraceptionLanguage() { + let banned = ["fertile", "fertility", "safe day", "safe days", "ovulation prediction", + "contracept", "conceive", "conception", "pregnan"] + // Exercise multiple phases + the awareness line. + let series: [[CyclePhaseEngine.Night]] = [ + biphasic(cycles: 3), + Array(biphasic(cycles: 3).dropLast(6)), + (0..<60).map { CyclePhaseEngine.Night(day: CyclePhaseEngine.shiftDay("2026-01-01", by: $0)!, + tempZ: 0.05, rhrZ: 0, hrvZ: 0) }, + ] + for nights in series { + let note = CyclePhaseEngine.classify(nights, baselineUsable: true).note.lowercased() + for b in banned { XCTAssertFalse(note.contains(b), "note contained banned term \(b): \(note)") } + } + let awareness = CyclePhaseEngine.awarenessLine.lowercased() + for b in banned { + // The awareness line legitimately contains "contraception" via "not contraception"; allow only that. + if b == "contracept" { continue } + XCTAssertFalse(awareness.contains(b)) + } + XCTAssertTrue(CyclePhaseEngine.awarenessLine.contains("not contraception")) + } + + // MARK: - Fusion math + + func testFusedIndexNegatesHRVAndRenormalises() { + // Temp-only night: index == tempZ exactly (renormalised over the single present weight). + XCTAssertEqual(CyclePhaseEngine.fusedIndex(tempZ: 1.5, rhrZ: nil, hrvZ: nil)!, 1.5, accuracy: 1e-9) + // All three present: (0.6·1 + 0.2·1 + 0.2·(−(−1))) / 1.0 = 1.0 + XCTAssertEqual(CyclePhaseEngine.fusedIndex(tempZ: 1, rhrZ: 1, hrvZ: -1)!, 1.0, accuracy: 1e-9) + // No signal → nil. + XCTAssertNil(CyclePhaseEngine.fusedIndex(tempZ: nil, rhrZ: nil, hrvZ: nil)) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift new file mode 100644 index 0000000000..05a0691d3d --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift @@ -0,0 +1,178 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Tests Calories.estimateDayCalories — the APPROXIMATE whole-day HR-only energy estimate +/// (Keytel active + Harris–Benedict BMR) that backs DailyMetric.activeKcalEst for BLE-only +/// users. Pure-function tests; no DB. Not cloud/clinical parity. Mirrors the Android +/// DayCaloriesTest vectors value-for-value. +final class DayCaloriesTests: XCTestCase { + + private func hrDay(bpm: Int, n: Int) -> [HRSample] { + (0..= 120 (active). + let restingDay = Calories.estimateDayCalories(hrDay(bpm: 60, n: 3600), profile: profile, + hrmax: 185.0, restingHR: 55.0) + let activeDay = Calories.estimateDayCalories(hrDay(bpm: 150, n: 3600), profile: profile, + hrmax: 185.0, restingHR: 55.0) + XCTAssertGreaterThan(restingDay, 0.0, "resting day must burn > 0 (BMR floor)") + XCTAssertGreaterThan(activeDay, restingDay, "active day must exceed resting day") + } + + func testSedentaryFullDayApproximatesBMR() { + // A full 24 h at resting HR (below the day active gate) must total ≈ the subject's BMR: + // the day estimator floors every sub-threshold second at the resting metabolic rate, so + // an all-rest day is BMR by construction. Standard male test subject's revised + // Harris–Benedict BMR ≈ 1825 kcal. This is an APPROXIMATE estimate, not medical advice. + let profile = UserProfile(weightKg: 80, heightCm: 180, age: 35, sex: "male") + let sedentary = hrDay(bpm: 55, n: 86_400) // 24 h, all at resting HR + let total = Calories.estimateDayCalories(sedentary, profile: profile, + hrmax: 185.0, restingHR: 55.0) + XCTAssertEqual(total, 1825.25, accuracy: 1.0, + "a sedentary full day must total ≈ the subject's BMR (~1825 kcal)") + } + + func testLightActivityDayIsFarBelowOldInflatedTotal() { + // The bug: at the OLD 30% day gate (~94 bpm for this subject) ordinary low-intensity + // daytime HR (~100 bpm walking/standing) was credited the FULL Keytel gross-exercise + // rate, inflating the day total by ~1000+ kcal. The 50% day gate (120 bpm) now treats + // that HR as resting, so a realistic mixed light day (8 h sleep @55, 8 h sedentary @70, + // 8 h light activity @100) collapses toward BMR instead of the old runaway figure. + let profile = UserProfile(weightKg: 80, heightCm: 180, age: 35, sex: "male") + let lightDay = hrDay(bpm: 55, n: 8 * 3_600) + + hrDay(bpm: 70, n: 8 * 3_600) + + hrDay(bpm: 100, n: 8 * 3_600) + let total = Calories.estimateDayCalories(lightDay, profile: profile, + hrmax: 185.0, restingHR: 55.0) + // NEW total ≈ 1825 kcal (every second below the 120 bpm gate → BMR floor). + XCTAssertEqual(total, 1825.25, accuracy: 1.0, + "a light-activity day must land near BMR, not the old inflated total") + // Teeth: the OLD 30%-gate model credited the 8 h @100 bpm block at the full Keytel + // active rate (~3551 kcal for that block alone), so the old day total was ≈ 4768 kcal. + // Pin that we are now WELL below it (more than 2000 kcal lower). + XCTAssertLessThan(total, 4768.0 - 2000.0, + "the light-activity day must drop far below the old inflated ~4768 kcal") + } + + func testSparseHRTracksElapsedTimeNotSampleCount() { + // A 10-minute effort at a steady active HR, sampled two ways over the SAME ~600 s span: + // densely at 1 Hz, and sparsely at one sample / 10 s (the WHOOP 5/MG case). Energy must + // track elapsed time, so the sparse estimate lands close to the dense one — NOT ~1/10th + // of it, as the old one-second-per-sample count produced. (BOUT path only.) + let profile = UserProfile(weightKg: 80, heightCm: 180, age: 35, sex: "male") + let dense = (0..<600).map { HRSample(ts: $0, bpm: 130) } + let sparse = stride(from: 0, to: 600, by: 10).map { HRSample(ts: $0, bpm: 130) } + let denseKcal = Calories.estimateBoutCalories(dense, profile: profile, hrmax: 185.0, restingHR: 55.0).0 + let sparseKcal = Calories.estimateBoutCalories(sparse, profile: profile, hrmax: 185.0, restingHR: 55.0).0 + XCTAssertEqual(sparseKcal, denseKcal, accuracy: denseKcal * 0.05, + "sparse HR must be counted over elapsed time, not undercounted per sample") + // Teeth: a per-sample count (60 samples) would be ~1/10th of the dense total. + XCTAssertGreaterThan(sparseKcal, denseKcal * 0.5) + } + + func testWearGapIsCappedNotCreditedInFull() { + // Two active samples an hour apart must NOT credit a full hour of active burn — the + // per-sample interval is capped at mergeGapS (150 s). The pre-gap sample contributes + // 150 s and the tail 1 s, so the total equals a 151 s continuous equivalent, not 3600 s. + // (BOUT path only.) + let profile = UserProfile(weightKg: 80, heightCm: 180, age: 35, sex: "male") + let gapped = [HRSample(ts: 0, bpm: 130), HRSample(ts: 3600, bpm: 130)] + let cappedEquiv = (0...150).map { HRSample(ts: $0, bpm: 130) } // 151 s continuous + let gappedKcal = Calories.estimateBoutCalories(gapped, profile: profile, hrmax: 185.0, restingHR: 55.0).0 + let equivKcal = Calories.estimateBoutCalories(cappedEquiv, profile: profile, hrmax: 185.0, restingHR: 55.0).0 + XCTAssertEqual(gappedKcal, equivKcal, accuracy: equivKcal * 0.001, + "an inter-sample gap must be capped at mergeGapS, not credited in full") + } + + func testDayPathDoesNotOverCountGappyDays() { + // The WHOLE-DAY estimator must STAY on one-second-per-sample, NOT the bout path's + // elapsed-time weighting. The day feed is a raw, non-gap-filled union of HR, so a + // single isolated elevated sample an hour from its neighbours must contribute ONE + // second of active burn — not up to mergeGapS (150 s) of it. Two active samples an + // hour apart therefore burn the same as two adjacent active seconds (each = 1 s), + // proving the day path does NOT inherit the bout cap-and-credit behaviour. + let profile = UserProfile(weightKg: 80, heightCm: 180, age: 35, sex: "male") + let gapped = [HRSample(ts: 0, bpm: 130), HRSample(ts: 3600, bpm: 130)] + let twoAdjacent = [HRSample(ts: 0, bpm: 130), HRSample(ts: 1, bpm: 130)] + let gappedDay = Calories.estimateDayCalories(gapped, profile: profile, hrmax: 185.0, restingHR: 55.0) + let adjacentDay = Calories.estimateDayCalories(twoAdjacent, profile: profile, hrmax: 185.0, restingHR: 55.0) + XCTAssertEqual(gappedDay, adjacentDay, accuracy: 1e-9, + "the day path must count each sample as exactly one second regardless of gaps") + // Teeth: if the day path had inherited the bout cap, the gappy total would be ~75x larger + // (150 s + 1 s vs 1 s + 1 s of active burn). Prove it stayed flat per-sample. + let boutGapped = Calories.estimateBoutCalories(gapped, profile: profile, hrmax: 185.0, restingHR: 55.0).0 + XCTAssertGreaterThan(boutGapped, gappedDay * 10, + "the bout path DOES cap-and-credit, so it must dwarf the per-second day total") + } + + // A timestamp safely inside UTC day 2026-01-02 (2026-01-02T12:00:00Z). + private let dayUtc = "2026-01-02" + private let noonUtc = 1_767_355_200 + + private func hr(_ tsOffsetSec: Int, _ bpm: Int) -> HRSample { + HRSample(ts: noonUtc + tsOffsetSec, bpm: bpm) + } + + func testAnalyzeDayCaloriesIgnoreAdjacentDayHr() throws { + // analyzeDay must filter HR to the target UTC day before summing calories — the + // IntelligenceEngine read window spans ~42h, so adjacent-day HR must NOT inflate the + // day's activeKcalEst (the critical "full-window double-count" regression). + let inDay = (0..<600).map { hr($0, 120) } + // Same in-day HR plus 600 samples ~36h earlier (a different UTC day, inside the window). + let withAdjacent = inDay + (0..<600).map { hr(-36 * 3_600 - $0, 120) } + let a = try XCTUnwrap(AnalyticsEngine.analyzeDay( + day: dayUtc, hr: inDay, profile: UserProfile()).daily.activeKcalEst) + let b = try XCTUnwrap(AnalyticsEngine.analyzeDay( + day: dayUtc, hr: withAdjacent, profile: UserProfile()).daily.activeKcalEst) + XCTAssertEqual(a, b, accuracy: 1e-6, "adjacent-day HR must not change the day's calories") + } + + func testAnalyzeDayDayHrCoversFullCalendarDay() throws { + // Simulate the past-day clip: the night-window HR only reaches midday; the full + // calendar-day HR also has the afternoon. activeKcalEst must use dayHr when supplied, + // so the full-day total exceeds the clipped night-window total (the undercount fix). + let nightWindow = (0..<600).map { hr($0, 120) } + let fullDay = nightWindow + (0..<600).map { hr(3 * 3_600 + $0, 120) } + let clipped = try XCTUnwrap(AnalyticsEngine.analyzeDay( + day: dayUtc, hr: nightWindow, profile: UserProfile()).daily.activeKcalEst) + let full = try XCTUnwrap(AnalyticsEngine.analyzeDay( + day: dayUtc, hr: nightWindow, dayHr: fullDay, profile: UserProfile()).daily.activeKcalEst) + XCTAssertGreaterThan(full, clipped, + "full calendar-day calories must exceed the clipped night-window total") + } + + func testAnalyzeDayDayHrNilFallsBackToWindowHr() throws { + // With no calendar-day stream, the total falls back to the window `hr` — identical to + // passing that same window explicitly as dayHr (the (dayHr ?? hr) fallback). + let window = (0..<600).map { hr($0, 120) } + let fallback = try XCTUnwrap(AnalyticsEngine.analyzeDay( + day: dayUtc, hr: window, profile: UserProfile()).daily.activeKcalEst) + let explicit = try XCTUnwrap(AnalyticsEngine.analyzeDay( + day: dayUtc, hr: window, dayHr: window, profile: UserProfile()).daily.activeKcalEst) + XCTAssertEqual(fallback, explicit, accuracy: 1e-9) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerReadIntegrationTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerReadIntegrationTests.swift new file mode 100644 index 0000000000..a005ad83e5 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerReadIntegrationTests.swift @@ -0,0 +1,103 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol +import WhoopStore + +/// Invariant I2 — a day's scores come from exactly ONE source. This proves the wired read path end to +/// end: two devices have HR for the SAME UTC day, the resolver picks the owner (the active strap wins +/// over an import), and reading the owner's HR returns ONLY the owner's samples — never the other +/// device's. Mirrors `IntelligenceEngine.resolveDayOwner` + the per-day stream read it feeds. +final class DayOwnerReadIntegrationTests: XCTestCase { + + /// 2026-06-15 00:00:00 UTC. The night window the engine reads spans [dayStart-30h, dayStart+12h]; + /// we seed HR squarely inside it (a few hours past midnight) under each device. + private let dayStart = 1_781_481_600 // 2026-06-15 00:00:00 UTC + private let day = "2026-06-15" + + /// Build candidates exactly as `IntelligenceEngine.resolveDayOwner` does, then resolve. + private func resolveOwner(store: WhoopStore, registry: DeviceRegistryStore, + from: Int, to: Int) async throws -> String? { + if let locked = try registry.dayOwner(day)?.deviceId { return locked } + let activeId = try registry.activeDeviceId() ?? "my-whoop" + var candidates: [DayOwnerResolver.Candidate] = [] + for d in try registry.all() where d.status != .archived { + let isImport = d.sourceKind == .cloudImport || d.sourceKind == .fileImport + let priority = d.id == activeId ? 0 : (isImport ? 2 : 1) + let hasData = !((try? await store.hrSamples(deviceId: d.id, from: from, to: to, limit: 1)) ?? []).isEmpty + candidates.append(.init(deviceId: d.id, priority: priority, hasData: hasData)) + } + return DayOwnerResolver.resolve(day: day, lockedOwner: nil, candidates: candidates) + } + + func testActiveStrapOwnsDayAndReadReturnsOnlyItsSamples() async throws { + let store = try await WhoopStore.inMemory() // migration v15 seeds active 'my-whoop' + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + + // A second source: an Oura CLOUD IMPORT (priority 2), paired but not active. + try registry.add(PairedDevice(id: "oura-import", brand: "Oura", model: "Oura (import)", + sourceKind: .cloudImport, capabilities: [.hr, .sleep], + status: .paired, addedAt: 1, lastSeenAt: 1)) + + // Seed HR for BOTH devices on the SAME UTC day, with distinguishable bpm so the read's source + // is unambiguous: WHOOP @ 55 bpm, Oura @ 99 bpm, both a few hours after midnight. + let base = dayStart + 3 * 3_600 + let whoopHR = (0..<300).map { HRSample(ts: base + $0, bpm: 55) } + let ouraHR = (0..<300).map { HRSample(ts: base + $0, bpm: 99) } + _ = try await store.insert(Streams(hr: whoopHR), deviceId: "my-whoop") + _ = try await store.insert(Streams(hr: ouraHR), deviceId: "oura-import") + + let from = dayStart - 30 * 3_600 + let to = dayStart + 12 * 3_600 + + // I2 resolution: the active strap (priority 0) wins over the import (priority 2). + let owner = try await resolveOwner(store: store, registry: registry, from: from, to: to) + XCTAssertEqual(owner, "my-whoop", "the active strap must own a day it has data for") + + // Read the day's HR under the resolved owner — it must be PURELY the owner's samples. + let read = try await store.hrSamples(deviceId: owner!, from: from, to: to, limit: 200_000) + XCTAssertEqual(read.count, whoopHR.count) + XCTAssertTrue(read.allSatisfy { $0.bpm == 55 }, "owner read leaked the other device's samples") + XCTAssertFalse(read.contains { $0.bpm == 99 }, "Oura (non-owner) samples must never appear") + } + + /// A locked dayOwnership override beats priority: even though the active WHOOP has data, a lock to + /// the import makes the import own the day, and the read returns only the import's samples. + func testLockedOwnerOverridesAndReadFollowsIt() async throws { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try registry.add(PairedDevice(id: "oura-import", brand: "Oura", model: "Oura (import)", + sourceKind: .cloudImport, capabilities: [.hr], + status: .paired, addedAt: 1, lastSeenAt: 1)) + + let base = dayStart + 3 * 3_600 + _ = try await store.insert(Streams(hr: (0..<300).map { HRSample(ts: base + $0, bpm: 55) }), deviceId: "my-whoop") + _ = try await store.insert(Streams(hr: (0..<300).map { HRSample(ts: base + $0, bpm: 99) }), deviceId: "oura-import") + + try registry.setDayOwner(day: day, deviceId: "oura-import", locked: true) + + let from = dayStart - 30 * 3_600, to = dayStart + 12 * 3_600 + let owner = try await resolveOwner(store: store, registry: registry, from: from, to: to) + XCTAssertEqual(owner, "oura-import", "a locked override must win over the active strap") + + let read = try await store.hrSamples(deviceId: owner!, from: from, to: to, limit: 200_000) + XCTAssertTrue(read.allSatisfy { $0.bpm == 99 }, "read must follow the locked owner") + } + + /// Single-device install (the default): only the seeded active 'my-whoop' is paired. The owner + /// MUST resolve to 'my-whoop' so behaviour is byte-identical to the pre-I2 code. + func testSingleDeviceResolvesToWhoopUnchanged() async throws { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + + let base = dayStart + 3 * 3_600 + _ = try await store.insert(Streams(hr: (0..<300).map { HRSample(ts: base + $0, bpm: 55) }), deviceId: "my-whoop") + + let from = dayStart - 30 * 3_600, to = dayStart + 12 * 3_600 + let owner = try await resolveOwner(store: store, registry: registry, from: from, to: to) + XCTAssertEqual(owner, "my-whoop", "single-device install must resolve to the seeded WHOOP") + + let read = try await store.hrSamples(deviceId: owner!, from: from, to: to, limit: 200_000) + XCTAssertEqual(read.count, 300) + XCTAssertTrue(read.allSatisfy { $0.bpm == 55 }) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerResolverTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerResolverTests.swift new file mode 100644 index 0000000000..db41d76be5 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerResolverTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import StrandAnalytics + +final class DayOwnerResolverTests: XCTestCase { + func testActiveStrapOwnsDayItHasData() { + let candidates = [ + DayOwnerResolver.Candidate(deviceId: "my-whoop", priority: 0, hasData: true), + DayOwnerResolver.Candidate(deviceId: "oura", priority: 2, hasData: true), + ] + XCTAssertEqual( + DayOwnerResolver.resolve(day: "2026-06-15", lockedOwner: nil, candidates: candidates), + "my-whoop" + ) + } + + func testImportOnlyFillsGap() { + let candidates = [ + DayOwnerResolver.Candidate(deviceId: "my-whoop", priority: 0, hasData: false), + DayOwnerResolver.Candidate(deviceId: "oura", priority: 2, hasData: true), + ] + XCTAssertEqual( + DayOwnerResolver.resolve(day: "2026-06-15", lockedOwner: nil, candidates: candidates), + "oura" + ) + } + + func testLockedOwnerAlwaysWins() { + let candidates = [ + DayOwnerResolver.Candidate(deviceId: "my-whoop", priority: 0, hasData: false), + DayOwnerResolver.Candidate(deviceId: "oura", priority: 2, hasData: true), + ] + XCTAssertEqual( + DayOwnerResolver.resolve(day: "2026-06-15", lockedOwner: "my-whoop", candidates: candidates), + "my-whoop" + ) + } + + func testNoDataYieldsNil() { + let candidates = [ + DayOwnerResolver.Candidate(deviceId: "my-whoop", priority: 0, hasData: false), + ] + XCTAssertNil( + DayOwnerResolver.resolve(day: "2026-06-15", lockedOwner: nil, candidates: candidates) + ) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaySliceFromNightTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaySliceFromNightTests.swift new file mode 100644 index 0000000000..ca155ca07e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaySliceFromNightTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import StrandAnalytics + +/// Locks `AnalyticsEngine.daySliceFromNight` (#997): for a PAST day the calendar-day streams +/// (dayHr/daySteps/dayGravity) are a non-truncated subset of the night window analyzeRecent already read, +/// so re-reading them from the store is redundant — the slice must equal an in-range filter of the night +/// list (which, for a complete night, equals the direct read: same inclusive bounds, same ts-ASC order). +/// And the shortcut must DECLINE (nil → the caller reads directly) in the unsafe cases: TODAY's calendar +/// day runs past the 18 h night cap, and a night read at the stream limit may be truncated inside the day +/// span. If any of that drifts, samples get attributed to the wrong day / dropped, so this is the safety +/// net for the read-skip. Mirrors the Android `IntelligenceEngineDaySliceTest` (same bounds fixture). +final class DaySliceFromNightTests: XCTestCase { + + private struct S: Equatable { let ts: Int } + + // A past day's night window: [dayStart − 30 h, nextMidnight]; the calendar day + // [dayStart, dayStart + 86400 − 1] sits strictly inside it. Mirrors the real IntelligenceEngine bounds. + private let dayStart = 1_700_000_000 + private var nightLo: Int { dayStart - 30 * 3_600 } + private var nightHi: Int { dayStart + 86_400 } // = nextMidnight (a past day's `to`) + private var dayLo: Int { dayStart } + private var dayHi: Int { dayStart + 86_400 - 1 } + private var night: [S] { stride(from: nightLo, through: nightHi, by: 60).map { S(ts: $0) } } + + func testPastDayReturnsTheInRangeFilterOfTheNightList() throws { + let slice = try XCTUnwrap(AnalyticsEngine.daySliceFromNight( + night, nightLo: nightLo, nightHi: nightHi, dayLo: dayLo, dayHi: dayHi, ts: { $0.ts })) + // Byte-identical to filtering the night list (which, for a complete night, equals the direct read). + XCTAssertEqual(slice, night.filter { $0.ts >= dayLo && $0.ts <= dayHi }) + // Nothing outside the day leaks in; order is preserved (ascending, as the store returned it). + XCTAssertTrue(slice.allSatisfy { $0.ts >= dayLo && $0.ts <= dayHi }) + XCTAssertEqual(slice, slice.sorted { $0.ts < $1.ts }) + } + + func testTodayDayEndPastTheNightCapDeclines() { + // TODAY: the night window caps at dayStart + 18 h, so the calendar day (to +24 h) reaches past it. + let todayNightHi = dayStart + 18 * 3_600 + XCTAssertNil(AnalyticsEngine.daySliceFromNight( + night, nightLo: nightLo, nightHi: todayNightHi, dayLo: dayLo, dayHi: dayHi, ts: { $0.ts })) + } + + func testDstShiftedDayBeforeTheNightWindowDeclines() { + // The self-protecting guard the other way: a shifted dayLo that falls before the night window + // (e.g. a DST-moved local midnight) must decline to the direct read, never slice a partial window. + XCTAssertNil(AnalyticsEngine.daySliceFromNight( + night, nightLo: nightLo, nightHi: nightHi, dayLo: nightLo - 1, dayHi: dayHi, ts: { $0.ts })) + } + + func testTruncatedNightReadDeclines() { + // A night read that returned exactly `limit` rows may be truncated inside the day span (ORDER BY + // ts ASC LIMIT drops the LATE rows — exactly where the day sits). Locked at an injected small + // limit AND at the real 200_000 default the IntelligenceEngine call sites rely on. + let small = (0..<10).map { S(ts: $0) } + XCTAssertNil(AnalyticsEngine.daySliceFromNight( + small, nightLo: 0, nightHi: 10, dayLo: 0, dayHi: 5, limit: 10, ts: { $0.ts })) + let atDefaultLimit = (0..<200_000).map { S(ts: $0) } + XCTAssertNil(AnalyticsEngine.daySliceFromNight( + atDefaultLimit, nightLo: 0, nightHi: 200_000, dayLo: 0, dayHi: 100, ts: { $0.ts })) + } + + func testBoundsAreInclusiveOnBothEnds() { + // The store range is inclusive [dayLo, dayHi] (`ts >= from AND ts <= to`); the filter must keep + // the boundary samples and drop their immediate neighbours. + let edge = [S(ts: dayLo - 1), S(ts: dayLo), S(ts: dayHi), S(ts: dayHi + 1)] + let slice = AnalyticsEngine.daySliceFromNight( + edge, nightLo: nightLo, nightHi: nightHi, dayLo: dayLo, dayHi: dayHi, ts: { $0.ts }) + XCTAssertEqual(slice, [S(ts: dayLo), S(ts: dayHi)]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeStressTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeStressTests.swift new file mode 100644 index 0000000000..dd72a66aa2 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeStressTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +final class DaytimeStressTests: XCTestCase { + + /// Fill one local hour-of-day with `n` 1 Hz HR samples at `bpm` (UTC, tz offset 0). + private func hourHR(_ hour: Int, bpm: Int, n: Int = DaytimeStress.minHourHRSamples) -> [HRSample] { + let base = hour * 3_600 + return (0.. [RRInterval] { + let base = hour * 3_600 + return (0.. DisplayMetrics { + DisplayMetrics( + horizontalSizeClass: "compact", verticalSizeClass: "regular", + widthPt: 390, heightPt: 844, scale: 3.0, + safeTop: 47, safeBottom: 34, safeLeading: 0, safeTrailing: 0, + dynamicType: "L", orientation: "portrait", theme: "dark") + } + + func testDeviceMetricsLineShape() { + let line = DisplayTrace.deviceMetricsLine(sampleMetrics()) + XCTAssertEqual(line, + "deviceMetrics size=390x844pt @3.0x sizeClass=compact/regular " + + "safeArea=t47 b34 l0 r0 dynamicType=L orientation=portrait theme=dark") + } + + func testDeviceMetricsLineDegradesNilsToNa() { + // macOS has no size class / Dynamic Type: a nil must print "n/a", never a fabricated value. + let m = DisplayMetrics( + horizontalSizeClass: nil, verticalSizeClass: nil, + widthPt: 1440, heightPt: 900, scale: 2.0, + safeTop: 0, safeBottom: 0, safeLeading: 0, safeTrailing: 0, + dynamicType: nil, orientation: "landscape", theme: "light") + let line = DisplayTrace.deviceMetricsLine(m) + XCTAssertEqual(line, + "deviceMetrics size=1440x900pt @2.0x sizeClass=n/a/n/a " + + "safeArea=t0 b0 l0 r0 dynamicType=n/a orientation=landscape theme=light") + } + + func testScaleUnknownPrintsQuestionMark() { + var m = sampleMetrics() + m = DisplayMetrics( + horizontalSizeClass: m.horizontalSizeClass, verticalSizeClass: m.verticalSizeClass, + widthPt: m.widthPt, heightPt: m.heightPt, scale: 0, + safeTop: m.safeTop, safeBottom: m.safeBottom, safeLeading: m.safeLeading, safeTrailing: m.safeTrailing, + dynamicType: m.dynamicType, orientation: m.orientation, theme: m.theme) + XCTAssertTrue(DisplayTrace.deviceMetricsLine(m).contains("@?x")) + } + + func testFrameSummaryLineShape() { + let line = DisplayTrace.frameSummaryLine( + frames: 60, meanMs: 16.71, p95Ms: 18.4, hitches: 2, worstMs: 41.93, hitchThresholdMs: 33) + XCTAssertEqual(line, + "frameSummary frames=60 mean=16.7ms p95=18.4ms hitches=2 worst=41.9ms threshold=33.0ms") + } + + func testMemoryHighWaterLineShape() { + // 187.46 is unambiguously above .45 in IEEE 754 (187.45 stores as 187.4499... and rounds DOWN), + // so %.1f gives 187.5 identically on Swift and Kotlin. The input is chosen to exercise round-up at + // the second decimal without depending on round-half-even of a non-representable .45. + XCTAssertEqual(DisplayTrace.memoryHighWaterLine(peakMB: 187.46), + "memoryHighWater peak=187.5MB") + } + + // MARK: - CAPTURE-D (#797): dataVolume line + + func testDataVolumeLineShape() { + let line = DisplayTrace.dataVolumeLine( + DataVolume(dbRows: 1_240_000, importedDays: 365, workouts: 42, lastRenderRows: 412)) + XCTAssertEqual(line, + "dataVolume dbRows=1240000 importedDays=365 workouts=42 lastRenderRows=412") + XCTAssertFalse(line.contains("\u{2014}")) + } + + func testDataVolumeLineNilLastRenderPrintsNa() { + // No render measured yet → "n/a", never a fabricated 0. + let line = DisplayTrace.dataVolumeLine( + DataVolume(dbRows: 0, importedDays: 0, workouts: 0, lastRenderRows: nil)) + XCTAssertEqual(line, "dataVolume dbRows=0 importedDays=0 workouts=0 lastRenderRows=n/a") + } + + func testReadoutParsesLatestDeviceMetrics() { + let tail = [ + "[display] deviceMetrics size=320x568pt @2.0x sizeClass=compact/compact safeArea=t0 b0 l0 r0 dynamicType=M orientation=portrait theme=light", + "[display] frameSummary frames=60 mean=16.7ms p95=18.4ms hitches=0 worst=20.1ms threshold=33.0ms", + "[display] deviceMetrics size=390x844pt @3.0x sizeClass=compact/regular safeArea=t47 b34 l0 r0 dynamicType=L orientation=portrait theme=dark", + ] + XCTAssertEqual(DisplayReadout.deviceMetricsNow(taggedTail: tail), + "size=390x844pt @3.0x sizeClass=compact/regular safeArea=t47 b34 l0 r0 dynamicType=L orientation=portrait theme=dark") + XCTAssertEqual(DisplayReadout.frameSummaryNow(taggedTail: tail), + "frames=60 mean=16.7ms p95=18.4ms hitches=0 worst=20.1ms threshold=33.0ms") + } + + func testReadoutNilWhenNoLine() { + XCTAssertNil(DisplayReadout.deviceMetricsNow(taggedTail: [])) + XCTAssertNil(DisplayReadout.frameSummaryNow(taggedTail: ["[display] deviceMetrics size=1x1pt @1.0x sizeClass=n/a/n/a safeArea=t0 b0 l0 r0 dynamicType=n/a orientation=portrait theme=light"])) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DoseResponseEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DoseResponseEngineTests.swift new file mode 100644 index 0000000000..490febe18e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DoseResponseEngineTests.swift @@ -0,0 +1,185 @@ +import XCTest +@testable import StrandAnalytics + +/// DoseResponseEngine — per-user dose slope shrunk toward a population prior. The oracle for +/// the Android DoseResponseEngineTest; keep the two in lockstep (same fixtures, same numbers). +final class DoseResponseEngineTests: XCTestCase { + + private func ymd(_ y: Int, _ m: Int, _ d: Int) -> String { String(format: "%04d-%02d-%02d", y, m, d) } + + // The documented alcohol→Charge prior used throughout (mirror of DoseResponsePriors). + private let alcoholPrior = -5.0 + + // MARK: - n_user = 0 returns the prior exactly + + func testNoDataReturnsPrior() { + // Doses logged, but NO next-day outcome exists for any of them → 0 usable pairs. + let doses: [String: Int] = [ymd(2026, 6, 1): 2, ymd(2026, 6, 2): 1] + let r = DoseResponseEngine.estimate(behavior: .alcohol, + doseByDay: doses, outcomeByDay: [:])! + XCTAssertEqual(r.nUser, 0) + XCTAssertEqual(r.weight, 0, accuracy: 1e-12) + XCTAssertNil(r.userSlope) + XCTAssertEqual(r.perUnit, alcoholPrior, accuracy: 1e-12) // pure prior + XCTAssertTrue(r.priorDominated) + XCTAssertFalse(r.contradictsPrior) + XCTAssertEqual(r.confidence, .calibrating) + XCTAssertEqual(r.outcome, "Charge") + } + + // MARK: - shrinkage weight w = n/(n+k) is exact at the boundary n = k + + func testShrinkageWeightAtBoundary() { + // Plant exactly 8 pairs with a clean personal slope of -2 (outcome = 80 - 2*dose), so + // n_user = k = 8 ⇒ w = 0.5, and perUnit = 0.5*(-2) + 0.5*(-5) = -3.5 (inside clamp). + var doses: [String: Int] = [:] + var outcome: [String: Double] = [:] + // 8 anchor days, each the 10th of a distinct month so D+1 never collides with a dose day. + for (i, month) in [1, 2, 3, 4, 5, 6, 7, 8].enumerated() { + let dose = i % 4 // doses 0,1,2,3,0,1,2,3 + let day = ymd(2026, month, 10) + doses[day] = dose + outcome[ymd(2026, month, 11)] = 80.0 - 2.0 * Double(dose) // D+1 = 80 - 2·dose + } + let r = DoseResponseEngine.estimate(behavior: .alcohol, + doseByDay: doses, outcomeByDay: outcome)! + XCTAssertEqual(r.nUser, 8) + XCTAssertEqual(r.weight, 0.5, accuracy: 1e-12) // 8 / (8 + 8) + XCTAssertEqual(r.userSlope!, -2.0, accuracy: 1e-9) // clean OLS slope + XCTAssertEqual(r.perUnit, -3.5, accuracy: 1e-9) // 0.5·(−2) + 0.5·(−5) + XCTAssertEqual(r.confidence, .building) // 5 ≤ 8 < 12 + } + + // MARK: - large n_user recovers ≈ the personal slope + + func testLargeNRecoversPersonalSlope() { + // 40 clean pairs with personal slope -3 (outcome = 90 - 3*dose). w = 40/48 = 0.8333…, + // perUnit = w·(-3) + (1-w)·(-5) = -3.333..., dominated by the personal fit. + var doses: [String: Int] = [:] + var outcome: [String: Double] = [:] + // 40 anchors: months won't span 40, so use day-of-month within a few wide-gapped months, + // spacing anchors 3 days apart so D+1 outcomes never collide with a later dose day. + var count = 0 + for month in [1, 4, 7, 10] { // 4 months + for k in 0..<10 { // 10 per month + let dom = 1 + k * 3 // 1,4,7,...,28 — D+1 = dom+1 never a dose day + let dose = count % 4 + let day = ymd(2026, month, dom) + doses[day] = dose + outcome[ymd(2026, month, dom + 1)] = 90.0 - 3.0 * Double(dose) + count += 1 + } + } + XCTAssertEqual(count, 40) + let r = DoseResponseEngine.estimate(behavior: .alcohol, + doseByDay: doses, outcomeByDay: outcome)! + XCTAssertEqual(r.nUser, 40) + XCTAssertEqual(r.userSlope!, -3.0, accuracy: 1e-9) + let w = 40.0 / 48.0 + XCTAssertEqual(r.weight, w, accuracy: 1e-12) + XCTAssertEqual(r.perUnit, w * (-3.0) + (1.0 - w) * (-5.0), accuracy: 1e-9) + XCTAssertFalse(r.priorDominated) // n ≥ minDoseDays + XCTAssertFalse(r.contradictsPrior) // same sign as prior (both negative) + XCTAssertEqual(r.confidence, .solid) // n ≥ 12 + } + + // MARK: - a personal slope that contradicts the prior flips the copy state + + func testPersonalSlopeContradictsPriorOverGate() { + // Personal slope POSITIVE (outcome = 60 + 2*dose): your drink-nights show NO dip. With + // n_user ≥ minDoseDays the person overrides the population → contradictsPrior = true. + var doses: [String: Int] = [:] + var outcome: [String: Double] = [:] + for (i, month) in [1, 2, 3, 4, 5, 6].enumerated() { // 6 pairs ≥ gate (5) + let dose = i % 4 + let day = ymd(2026, month, 10) + doses[day] = dose + outcome[ymd(2026, month, 11)] = 60.0 + 2.0 * Double(dose) // POSITIVE slope + } + let r = DoseResponseEngine.estimate(behavior: .alcohol, + doseByDay: doses, outcomeByDay: outcome)! + XCTAssertEqual(r.nUser, 6) + XCTAssertGreaterThan(r.userSlope!, 0) // personal slope is positive + XCTAssertFalse(r.priorDominated) // n ≥ gate + XCTAssertTrue(r.contradictsPrior) // sign disagrees with the negative prior + } + + // MARK: - below the gate stays prior-dominated even with a contrary slope + + func testBelowGateStaysPriorDominated() { + // Only 3 pairs (< minDoseDays) with a positive slope. Still priorDominated, NOT flagged + // contradicts (we don't let 3 nights overrule the prior). + var doses: [String: Int] = [:] + var outcome: [String: Double] = [:] + for (i, month) in [1, 2, 3].enumerated() { + let dose = i // 0,1,2 → spread for a fit + let day = ymd(2026, month, 10) + doses[day] = dose + outcome[ymd(2026, month, 11)] = 60.0 + 5.0 * Double(dose) + } + let r = DoseResponseEngine.estimate(behavior: .alcohol, + doseByDay: doses, outcomeByDay: outcome)! + XCTAssertEqual(r.nUser, 3) + XCTAssertTrue(r.priorDominated) + XCTAssertFalse(r.contradictsPrior) + XCTAssertEqual(r.confidence, .calibrating) + } + + // MARK: - clamp keeps a runaway personal slope inside the prior's range + + func testPerUnitIsClampedToPriorRange() { + // A wildly steep personal slope (-40/drink) with enough n to dominate would push perUnit + // below the alcohol clampLow of -15; the result must clamp at -15. + var doses: [String: Int] = [:] + var outcome: [String: Double] = [:] + for k in 0..<40 { + let dose = k % 4 + // Space anchors so D+1 never collides: anchor every 2 days across wide months. + let month = 1 + (k / 10) * 3 // 1,4,7,10 + let dom = 1 + (k % 10) * 3 + let day = ymd(2026, month, dom) + doses[day] = dose + outcome[ymd(2026, month, dom + 1)] = 100.0 - 40.0 * Double(dose) + } + let r = DoseResponseEngine.estimate(behavior: .alcohol, + doseByDay: doses, outcomeByDay: outcome)! + XCTAssertEqual(r.perUnit, -15.0, accuracy: 1e-9) // clamped at the prior's low bound + } + + // MARK: - curve points use the shrunk slope from a 0 anchor + + func testCurvePoints() { + // No data → pure prior of -5; curve is 0, -5, -10, -15 for dose 0..3. + let r = DoseResponseEngine.estimate(behavior: .alcohol, + doseByDay: [:], outcomeByDay: [:])! + XCTAssertEqual(r.curve.map { $0.dose }, [0, 1, 2, 3]) + XCTAssertEqual(r.curve.map { $0.outcomeDelta }, [0, -5, -10, -15]) + } + + // MARK: - delta() composes incremental units (for the Damage Forecast) + + func testDeltaComposesUnits() { + let r = DoseResponseEngine.estimate(behavior: .alcohol, + doseByDay: [:], outcomeByDay: [:])! // perUnit = -5 + XCTAssertEqual(r.delta(fromDose: 1, toDose: 2), -5, accuracy: 1e-12) + XCTAssertEqual(r.delta(fromDose: 0, toDose: 3), -15, accuracy: 1e-12) + XCTAssertEqual(r.delta(fromDose: 2, toDose: 2), 0, accuracy: 1e-12) + } + + // MARK: - no documented prior → nil + + func testNoPriorOutcomeReturnsNil() { + // Alcohol has a prior on "Charge" but not on "RHR" → nil. + XCTAssertNil(DoseResponseEngine.estimate(behavior: .alcohol, outcome: "RHR", + doseByDay: [:], outcomeByDay: [:])) + } + + // MARK: - caffeine default outcome is HRV with its own prior + + func testCaffeineDefaultsToHRV() { + let r = DoseResponseEngine.estimate(behavior: .caffeine, + doseByDay: [:], outcomeByDay: [:])! + XCTAssertEqual(r.outcome, "HRV") + XCTAssertEqual(r.perUnit, -4.0, accuracy: 1e-12) // the documented caffeine→HRV prior + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift new file mode 100644 index 0000000000..c3c3d63ce0 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift @@ -0,0 +1,152 @@ +import XCTest +@testable import StrandAnalytics + +/// EffectRanker — the lag-aware "what moves your Charge" ranker. The oracle for the Android +/// EffectRankerTest; keep the two in lockstep (same fixtures, same numbers). +final class EffectRankerTests: XCTestCase { + + private func ymd(_ y: Int, _ m: Int, _ d: Int) -> String { String(format: "%04d-%02d-%02d", y, m, d) } + + private func row(_ rows: [RankedEffect], _ behavior: String) -> RankedEffect? { + rows.first { $0.behavior == behavior } + } + + /// Deterministic per-calendar-day jitter in {-2,-1,0,1,2} so the with/without groups carry + /// real within-group spread (a perfectly-constant group yields a pooled SD of 0 and Cohen's d + /// of 0 by design). Trivially mirrored in Kotlin from the day-of-month. + private func jitter(_ dayOfMonth: Int) -> Double { Double((dayOfMonth * 7) % 5 - 2) } + + // MARK: - Planted lag-1 effect is found at L=1 and beats L=0/L=2 + + /// The outcome the NEXT morning (D+1) after each behaviour day is depressed (≈50) while + /// every other day sits at baseline (≈70). Behaviour days are spaced 4 apart so each D+1 dip + /// is clean. The strongest |cohensD| must therefore be at lag 1 ("next morning"), negative, + /// and it must beat lag 0 and lag 2. + func testPlantedLag1IsFoundAndWins() { + var outcome: [String: Double] = [:] + var behaviorDays: Set = [] + + // Anchors Jun 1,5,9,13,17,21 (6, spaced 4 apart so the D+1 dips never collide). + for i in 0..<6 { behaviorDays.insert(ymd(2026, 6, 1 + 4 * i)) } + // Dense baseline grid (Jun 1..30, Jul 1..8) at ≈70 with per-day jitter. + for d in 1...30 { outcome[ymd(2026, 6, d)] = 70 + jitter(d) } + for d in 1...8 { outcome[ymd(2026, 7, d)] = 70 + jitter(d) } + // Stamp the next-morning dip (≈50) on each anchor's D+1. + for i in 0..<6 { + let dip = 2 + 4 * i // day-of-month of anchor+1 (2,6,10,14,18,22) + outcome[ymd(2026, 6, dip)] = 50 + jitter(dip) + } + + let out = EffectRanker.rank(behaviors: ["Alcohol": behaviorDays], + outcomeByDay: outcome, outcome: "Charge") + let r = row(out, "Alcohol") + XCTAssertNotNil(r) + XCTAssertEqual(r!.lag, 1) // the planted lag + XCTAssertEqual(r!.leadLagText, "next morning") + XCTAssertLessThan(r!.effect.cohensD, 0) // next-morning outcome is LOWER + XCTAssertTrue(r!.effect.significant) + // The next-morning group really is ≈50 vs a ≈70 baseline (means carry the jitter). + XCTAssertLessThan(r!.effect.meanWith, 55) + XCTAssertGreaterThan(r!.effect.meanWithout, 65) + + // Lag 1 must dominate lag 0 and lag 2 in |cohensD|. Read each lag's effect directly via + // the same internal alignment the engine uses. + let d1 = abs(r!.effect.cohensD) + let d0 = abs(effectAtLag(behaviorDays, outcome, 0)!.cohensD) + let d2 = abs(effectAtLag(behaviorDays, outcome, 2)!.cohensD) + XCTAssertGreaterThan(d1, d0) + XCTAssertGreaterThan(d1, d2) + } + + /// Test-only: the BehaviorEffect at a specific lag, via the engine's own shift alignment. + private func effectAtLag(_ behaviorDays: Set, _ outcome: [String: Double], + _ lag: Int) -> BehaviorEffect? { + let shifted = EffectRanker.shiftedOutcome(outcome, byLag: lag) + return BehaviorInsights.effect(behaviorDays: behaviorDays, outcomeByDay: shifted, + behavior: "Alcohol", outcome: "Charge") + } + + // MARK: - Group gate suppresses thin behaviours + + /// A behaviour logged on only 3 days can never clear min(nWith,nWithout) ≥ 5 at any lag, so + /// it is dropped entirely (no fabricated row). + func testThinBehaviourIsDropped() { + var outcome: [String: Double] = [:] + var thin: Set = [] + for d in 1...3 { // only 3 behaviour days → nWith ≤ 3 < 5 + let day = ymd(2026, 6, d) + thin.insert(day) + outcome[day] = 50 + jitter(d) + outcome[ymd(2026, 6, d + 1)] = 50 + jitter(d + 1) + } + for d in 1...8 { outcome[ymd(2026, 7, d)] = 70 + jitter(d) } // plenty of "without" + + let out = EffectRanker.rank(behaviors: ["Sparse": thin], + outcomeByDay: outcome, outcome: "Charge") + XCTAssertTrue(out.isEmpty) + } + + // MARK: - Ranking order matches BehaviorInsights.rank (significant first, |d| desc, name asc) + + /// Two behaviours, both lag-0 same-day effects, with different effect magnitudes. The bigger + /// |cohensD| ranks first; a name tiebreak applies only on identical effects. + func testRankingOrder() { + var outcome: [String: Double] = [:] + // "Big": large same-day separation (with ≈ 50, without ≈ 70). Jitter gives real spread. + var big: Set = [] + for d in 1...6 { + let day = ymd(2026, 1, d) + big.insert(day) + outcome[day] = 50 + jitter(d) + } + // "Small": modest same-day separation (with ≈ 66, without ≈ 70). + var small: Set = [] + for d in 1...6 { + let day = ymd(2026, 3, d) + small.insert(day) + outcome[day] = 66 + jitter(d) + } + // Shared "without" baseline at 70 (a block neither behaviour touches at any lag). + for d in 10...20 { outcome[ymd(2026, 5, d)] = 70 + jitter(d) } + + let out = EffectRanker.rank(behaviors: ["Big": big, "Small": small], + outcomeByDay: outcome, outcome: "Charge") + XCTAssertEqual(out.map { $0.behavior }, ["Big", "Small"]) // |d| Big > Small + XCTAssertEqual(row(out, "Big")!.lag, 0) // both are same-day effects + XCTAssertEqual(row(out, "Small")!.lag, 0) + } + + // MARK: - Confidence tiers from paired-day count + + func testConfidenceTiers() { + XCTAssertEqual(EffectRanker.confidence(forPairs: 4), .calibrating) // < gate (5) + XCTAssertEqual(EffectRanker.confidence(forPairs: 5), .building) // gate…<10 + XCTAssertEqual(EffectRanker.confidence(forPairs: 9), .building) + XCTAssertEqual(EffectRanker.confidence(forPairs: 10), .solid) // ≥ 10 + } + + // MARK: - shiftedOutcome alignment (lag 0 is identity, lag re-keys backward) + + func testShiftedOutcomeAlignment() { + let outcome: [String: Double] = [ymd(2026, 6, 2): 55, ymd(2026, 6, 3): 60] + // lag 0 → identity. + XCTAssertEqual(EffectRanker.shiftedOutcome(outcome, byLag: 0), outcome) + // lag 1 → the value ON day D moves to key D−1, so behaviour day D pairs with outcome D+1. + let s1 = EffectRanker.shiftedOutcome(outcome, byLag: 1) + XCTAssertEqual(s1[ymd(2026, 6, 1)], 55) // outcome of 06-02 keyed under 06-01 + XCTAssertEqual(s1[ymd(2026, 6, 2)], 60) // outcome of 06-03 keyed under 06-02 + XCTAssertNil(s1[ymd(2026, 6, 3)]) + } + + // MARK: - sentence appends the lead/lag clause + + func testSentenceAppendsLeadLag() { + let e = BehaviorEffect(behavior: "Alcohol", outcome: "Charge", + meanWith: 50, meanWithout: 70, delta: -20, + pctChange: -100.0 * 20.0 / 70.0, nWith: 6, nWithout: 8, + cohensD: -2.0, pApprox: 0.001, significant: true) + let r = RankedEffect(behavior: "Alcohol", outcome: "Charge", lag: 1, + effect: e, confidence: .building) + XCTAssertTrue(r.sentence().hasSuffix("(next morning).")) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/FitnessAgeEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/FitnessAgeEngineTests.swift new file mode 100644 index 0000000000..77b8ddd762 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/FitnessAgeEngineTests.swift @@ -0,0 +1,159 @@ +import XCTest +@testable import StrandAnalytics + +final class FitnessAgeEngineTests: XCTestCase { + + // MARK: - VO₂max estimate (Nes 2011 waist-circumference variant, confirmed coefficients) + + func testVO2maxMenKnownValue() { + // 100.27 − 0.296·40 + 0.226·5 − 0.369·90 − 0.155·65 = 46.275 + let v = FitnessAgeEngine.estimateVO2max(age: 40, sex: "male", waistCm: 90, restingHR: 65, paIndex: 5) + XCTAssertEqual(v, 46.275, accuracy: 1e-3) + } + + func testVO2maxWomenKnownValue() { + // 74.74 − 0.247·40 + 0.198·5 − 0.259·80 − 0.114·65 = 37.72 + let v = FitnessAgeEngine.estimateVO2max(age: 40, sex: "female", waistCm: 80, restingHR: 65, paIndex: 5) + XCTAssertEqual(v, 37.72, accuracy: 1e-3) + } + + func testBMIHelper() { + XCTAssertEqual(FitnessAgeEngine.bmi(weightKg: 80, heightCm: 178), 25.249, accuracy: 1e-3) + } + + // MARK: - Fitness Age (self-consistent Nes; waist cancels, so only age/sex/RHR/PA needed) + + func testFitnessAgeReferenceFitPersonEqualsChronoAge() { + // RHR 65 + PAI 5 = the reference peer → Fitness Age == chronological age exactly. + XCTAssertEqual(FitnessAgeEngine.fitnessAge(age: 40, sex: "male", restingHR: 65, paIndex: 5), + 40.0, accuracy: 1e-9) + XCTAssertEqual(FitnessAgeEngine.fitnessAge(age: 55, sex: "female", restingHR: 65, paIndex: 5), + 55.0, accuracy: 1e-9) + } + + func testFitnessAgeFitterIsYounger() { + // Man 40, RHR 50, PAI 10: 40 + (0.155·(−15) − 0.226·5)/0.296 = 28.33 + XCTAssertEqual(FitnessAgeEngine.fitnessAge(age: 40, sex: "male", restingHR: 50, paIndex: 10), + 28.33, accuracy: 0.05) + } + + func testFitnessAgeUnfitterIsOlder() { + // Man 40, RHR 80, PAI 2: 40 + (0.155·15 − 0.226·(−3))/0.296 = 50.15 + XCTAssertEqual(FitnessAgeEngine.fitnessAge(age: 40, sex: "male", restingHR: 80, paIndex: 2), + 50.15, accuracy: 0.05) + } + + func testFitnessAgeClampsToRange() { + // Extremely unfit, older → clamps to 80. + XCTAssertEqual(FitnessAgeEngine.fitnessAge(age: 75, sex: "male", restingHR: 120, paIndex: 0), + 80, accuracy: 1e-9) + // Extremely fit, young → clamps to 20. + XCTAssertEqual(FitnessAgeEngine.fitnessAge(age: 25, sex: "male", restingHR: 35, paIndex: 15), + 20, accuracy: 1e-9) + } + + // MARK: - PA-index reconstruction (HUNT1 PA-Q buckets) + + func testPAIndexSedentary() { + XCTAssertEqual(FitnessAgeEngine.physicalActivityIndex( + activeDaysPerWeek: 0, avgActiveMinutesPerDay: 0, highIntensityFraction: 0), 0, accuracy: 1e-9) + } + + func testPAIndexHighlyActive() { + XCTAssertEqual(FitnessAgeEngine.physicalActivityIndex( + activeDaysPerWeek: 7, avgActiveMinutesPerDay: 75, highIntensityFraction: 0.8), 15.0, accuracy: 1e-9) + } + + func testPAIndexModerate() { + // 3 days (2.5) × moderate (2) × ~40 min (0.75) = 3.75 + XCTAssertEqual(FitnessAgeEngine.physicalActivityIndex( + activeDaysPerWeek: 3, avgActiveMinutesPerDay: 40, highIntensityFraction: 0.3), 3.75, accuracy: 1e-9) + } + + func testPAIndexFromStrain() { + XCTAssertEqual(FitnessAgeEngine.physicalActivityIndexFromStrain( + activeDaysPerWeek: 0, meanActiveStrain: 0), 0, accuracy: 1e-9) + // 7 days × strain 90 (id 3.0) = 15. + XCTAssertEqual(FitnessAgeEngine.physicalActivityIndexFromStrain( + activeDaysPerWeek: 7, meanActiveStrain: 90), 15.0, accuracy: 1e-9) + // 3 days (freq 2.5) × strain 45 (id 1.5) = 3.75. + XCTAssertEqual(FitnessAgeEngine.physicalActivityIndexFromStrain( + activeDaysPerWeek: 3, meanActiveStrain: 45), 3.75, accuracy: 1e-9) + // reference-ish: 4 days (2.5) × strain 60 (id 2.0) = 5.0. + XCTAssertEqual(FitnessAgeEngine.physicalActivityIndexFromStrain( + activeDaysPerWeek: 4, meanActiveStrain: 60), 5.0, accuracy: 1e-9) + } + + // MARK: - compute (full result + gates) + + func testComputeReferencePersonExactAge() { + let r = FitnessAgeEngine.compute(age: 40, sex: "male", restingHR: 65, paIndex: 5) + XCTAssertNotNil(r) + XCTAssertEqual(r!.fitnessAge, 40.0, accuracy: 1e-9) + XCTAssertEqual(r!.deltaYears, 0.0, accuracy: 1e-9) + XCTAssertNil(r!.vo2max) // no waist → no VO₂max display + XCTAssertEqual(r!.bandYears, 5.0, accuracy: 1e-9) + XCTAssertFalse(r!.lowerConfidence) + } + + func testComputeWithWaistFillsVO2max() { + let r = FitnessAgeEngine.compute(age: 40, sex: "male", restingHR: 65, paIndex: 5, waistCm: 90) + XCTAssertEqual(r!.vo2max!, 46.275, accuracy: 1e-3) + } + + func testComputeNonBinaryFlagsLowerConfidence() { + let r = FitnessAgeEngine.compute(age: 40, sex: "nonbinary", restingHR: 60, paIndex: 6) + XCTAssertTrue(r!.lowerConfidence) + } + + func testComputeNilWhenNoRHR() { + XCTAssertNil(FitnessAgeEngine.compute(age: 40, sex: "male", restingHR: 0, paIndex: 7.5)) + } + + // MARK: - Readiness checklist + + func testReadinessAllPresentIsReady() { + let r = FitnessAgeEngine.assessReadiness(hasAge: true, hasSex: true, rhrDays: 7, activityDays: 7, + hasHeightWeight: true, hasWaist: true) + XCTAssertEqual(r.confidence, .ready) + XCTAssertTrue(r.canCompute) + XCTAssertTrue(r.items.allSatisfy { $0.status == .satisfied }) + XCTAssertEqual(r.items.count, 6) + } + + func testReadinessMissingRHRIsNotReady() { + let r = FitnessAgeEngine.assessReadiness(hasAge: true, hasSex: true, rhrDays: 0, activityDays: 7, + hasHeightWeight: true, hasWaist: true) + XCTAssertEqual(r.confidence, .notReady) + XCTAssertFalse(r.canCompute) + XCTAssertEqual(r.items.first { $0.key == "rhr" }!.status, .missing) + } + + func testReadinessPartialCoverageIsEstimate() { + // age+sex set, 5 nights RHR (≥ min 4 but < good 6), sparse activity → computes, but "estimate". + let r = FitnessAgeEngine.assessReadiness(hasAge: true, hasSex: true, rhrDays: 5, activityDays: 3, + hasHeightWeight: false, hasWaist: false) + XCTAssertEqual(r.confidence, .estimate) + XCTAssertTrue(r.canCompute) + XCTAssertEqual(r.items.first { $0.key == "rhr" }!.status, .partial) + XCTAssertEqual(r.items.first { $0.key == "activity" }!.status, .partial) + // Missing body metrics never blocks the headline — they sit under the VO₂max role. + let body = r.items.first { $0.key == "bodyMetrics" }! + XCTAssertEqual(body.status, .missing) + XCTAssertEqual(body.role, .unlocksVO2max) + XCTAssertFalse(body.required) + } + + func testReadinessMissingAgeIsNotReady() { + let r = FitnessAgeEngine.assessReadiness(hasAge: false, hasSex: true, rhrDays: 7, activityDays: 7, + hasHeightWeight: true, hasWaist: true) + XCTAssertEqual(r.confidence, .notReady) + } + + func testReadinessGoodCoverageNoBodyMetricsStillReady() { + // Headline only needs age/sex/coverage; missing height/weight (VO₂max-only) doesn't drop it. + let r = FitnessAgeEngine.assessReadiness(hasAge: true, hasSex: true, rhrDays: 7, activityDays: 6, + hasHeightWeight: false, hasWaist: false) + XCTAssertEqual(r.confidence, .ready) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/FusionResolverTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/FusionResolverTests.swift new file mode 100644 index 0000000000..126d77b1bc --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/FusionResolverTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import StrandAnalytics + +/// The multi-source fusion contract: trust ordering, cross-validation boundaries, +/// conflict-never-merges, single-source degradation, and provenance integrity. +final class FusionResolverTests: XCTestCase { + + // MARK: - 1. Trust ordering ("best signal wins") + + func testStepsPhoneBeatsStrapEstimate() { + // The phone COUNTS steps (tier 0); the strap only ESTIMATES (tier 3) — the phone must win. + let point = FusionResolver.resolve(metricKey: "steps", inputs: [ + FusionInput(source: .whoopImport, value: 6000), // strap estimate + FusionInput(source: .appleHealth, value: 8420), // counts directly + ]) + XCTAssertEqual(point?.winningSource, .appleHealth) + XCTAssertEqual(point?.value, 8420) + XCTAssertEqual(point?.contributors.first?.reason, "counts directly") + } + + func testSleepWhoopBeatsPhoneBuckets() { + // Imported WHOOP stages (tier 0) beat phone sleep buckets (tier 2). + let point = FusionResolver.resolve(metricKey: "sleep_total_min", inputs: [ + FusionInput(source: .appleHealth, value: 400), + FusionInput(source: .whoopImport, value: 432), + ]) + XCTAssertEqual(point?.winningSource, .whoopImport) + XCTAssertEqual(point?.value, 432) + XCTAssertEqual(point?.contributors.first?.reason, "best stager") + } + + func testRestingHRStrapBeatsPhone() { + // The strap measures HR directly (tier 0); the phone aggregates it (tier 2). + let point = FusionResolver.resolve(metricKey: "rhr", inputs: [ + FusionInput(source: .appleHealth, value: 55), + FusionInput(source: .whoopImport, value: 52), + ]) + XCTAssertEqual(point?.winningSource, .whoopImport) + XCTAssertEqual(point?.value, 52) + } + + // MARK: - 2. Cross-validation classification at boundaries + + func testRestingHRAgreeWithinTolerance() { + // RHR tolerance: agree <= 3 bpm. Winner 52, other 54 → delta 2 → agree. + let point = FusionResolver.resolve(metricKey: "rhr", inputs: [ + FusionInput(source: .whoopImport, value: 52), + FusionInput(source: .appleHealth, value: 54), + ]) + XCTAssertEqual(point?.agreement, .agree) + } + + func testRestingHRMinorDeltaJustOverAgreeEdge() { + // Delta 4 (> 3 agree edge, <= 8 minor edge) → minorDelta. + let point = FusionResolver.resolve(metricKey: "rhr", inputs: [ + FusionInput(source: .whoopImport, value: 52), + FusionInput(source: .appleHealth, value: 56), + ]) + XCTAssertEqual(point?.agreement, .minorDelta) + } + + func testRestingHRConflictBeyondMinorEdge() { + // Delta 10 (> 8 minor edge) → conflict. + let point = FusionResolver.resolve(metricKey: "rhr", inputs: [ + FusionInput(source: .whoopImport, value: 52), + FusionInput(source: .appleHealth, value: 62), + ]) + XCTAssertEqual(point?.agreement, .conflict) + } + + func testSleepConflictTwoHoursVsSeven() { + // 432 min vs 120 min — a gross divergence → conflict (spec's headline example). + let point = FusionResolver.resolve(metricKey: "sleep_total_min", inputs: [ + FusionInput(source: .whoopImport, value: 432), + FusionInput(source: .appleHealth, value: 120), + ]) + XCTAssertEqual(point?.agreement, .conflict) + } + + func testStepsPercentBandAgree() { + // Steps tolerance is ±10% agree / ±30% minor. Winner 8000, other 8500 → 6.25% → agree. + let point = FusionResolver.resolve(metricKey: "steps", inputs: [ + FusionInput(source: .appleHealth, value: 8000), + FusionInput(source: .whoopImport, value: 8500), + ]) + XCTAssertEqual(point?.winningSource, .appleHealth) + XCTAssertEqual(point?.agreement, .agree) + } + + func testStepsPercentBandConflict() { + // Winner 8000, other 14000 → 75% over → conflict. + let point = FusionResolver.resolve(metricKey: "steps", inputs: [ + FusionInput(source: .appleHealth, value: 8000), + FusionInput(source: .whoopImport, value: 14000), + ]) + XCTAssertEqual(point?.agreement, .conflict) + } + + // MARK: - 3. Conflict never silently merges + + func testConflictKeepsBothContributorsWinnerHigherTrust() { + let point = FusionResolver.resolve(metricKey: "sleep_total_min", inputs: [ + FusionInput(source: .appleHealth, value: 120), + FusionInput(source: .whoopImport, value: 432), + ]) + // Winner is the higher-trust source, value is verbatim (NOT an average of 120 & 432 = 276). + XCTAssertEqual(point?.winningSource, .whoopImport) + XCTAssertEqual(point?.value, 432) + XCTAssertEqual(point?.agreement, .conflict) + XCTAssertEqual(point?.contributors.count, 2) + // Both contributors retained for the compare sheet. + XCTAssertTrue(point?.contributors.contains { $0.source == .appleHealth } ?? false) + XCTAssertTrue(point?.contributors.contains { $0.source == .whoopImport } ?? false) + } + + // MARK: - 4. Single-source degradation + + func testSingleSourcePassesThroughNoAgreement() { + let point = FusionResolver.resolve(metricKey: "hrv", inputs: [ + FusionInput(source: .whoopImport, value: 68), + ]) + XCTAssertEqual(point?.value, 68) + XCTAssertEqual(point?.winningSource, .whoopImport) + XCTAssertEqual(point?.agreement, .single) + XCTAssertEqual(point?.contributors.count, 1) + } + + func testEmptyInputsYieldNil() { + XCTAssertNil(FusionResolver.resolve(metricKey: "hrv", inputs: [])) + } + + // MARK: - 5. Provenance integrity + + func testWinningSourceMatchesSuppliedValue() { + // Three sources; the winner's value must be exactly the value that source supplied. + let inputs = [ + FusionInput(source: .appleHealth, value: 55), + FusionInput(source: .noopComputed, value: 53), + FusionInput(source: .whoopImport, value: 52), + ] + let point = FusionResolver.resolve(metricKey: "rhr", inputs: inputs) + let winner = point!.winningSource + let suppliedByWinner = inputs.first { $0.source == winner }!.value + XCTAssertEqual(point?.value, suppliedByWinner) + XCTAssertEqual(winner, .whoopImport) // tier 0 vs computed tier 1 vs phone tier 2 + } + + // MARK: - Policy table sanity + + func testStepsTierTable() { + XCTAssertEqual(MetricArbitrationPolicy.tier(metric: .steps, source: .appleHealth), 0) + XCTAssertEqual(MetricArbitrationPolicy.tier(metric: .steps, source: .whoopImport), 3) + } + + func testSleepTierTable() { + XCTAssertEqual(MetricArbitrationPolicy.tier(metric: .sleep, source: .whoopImport), 0) + XCTAssertEqual(MetricArbitrationPolicy.tier(metric: .sleep, source: .appleHealth), 2) + } + + func testKeyMapping() { + XCTAssertEqual(MetricArbitrationPolicy.kind(forKey: "rhr"), .restingHR) + XCTAssertEqual(MetricArbitrationPolicy.kind(forKey: "asleep_min"), .sleep) + XCTAssertEqual(MetricArbitrationPolicy.kind(forKey: "sleep_deep_min"), .sleep) + XCTAssertEqual(MetricArbitrationPolicy.kind(forKey: "steps"), .steps) + XCTAssertEqual(MetricArbitrationPolicy.kind(forKey: "made_up_key"), .other) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/GuidedCaptureProgressTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/GuidedCaptureProgressTests.swift new file mode 100644 index 0000000000..90f4260d97 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/GuidedCaptureProgressTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import StrandAnalytics + +final class GuidedCaptureProgressTests: XCTestCase { + func testCapturingState() { + // 2 of 3 nights have data -> still capturing, next nudge tonight. + let p = GuidedCaptureProgress.evaluate(target: 3, nightsWithData: 2, nightsElapsed: 2) + XCTAssertEqual(p, .capturing(done: 2, target: 3)) + } + func testGapNight() { + // 3 nights elapsed but only 1 has data -> a gap was recorded, keep going. + let p = GuidedCaptureProgress.evaluate(target: 3, nightsWithData: 1, nightsElapsed: 3) + XCTAssertEqual(p, .capturing(done: 1, target: 3)) + } + func testComplete() { + let p = GuidedCaptureProgress.evaluate(target: 3, nightsWithData: 3, nightsElapsed: 3) + XCTAssertEqual(p, .complete) + } + func testCompleteWhenOverTarget() { + let p = GuidedCaptureProgress.evaluate(target: 3, nightsWithData: 4, nightsElapsed: 5) + XCTAssertEqual(p, .complete) + } + func testLabels() { + XCTAssertEqual(GuidedCaptureProgress.label(for: .complete), "Capture complete. Tap Report to export.") + XCTAssertEqual(GuidedCaptureProgress.label(for: .capturing(done: 1, target: 3)), + "Captured 1 of 3 nights. Wear it again tonight.") + XCTAssertEqual(GuidedCaptureProgress.gapNudge(), "No data last night. Wear the strap tonight to continue.") + } + func testNoEmDashInLabel() { + XCTAssertFalse(GuidedCaptureProgress.label(for: .capturing(done: 1, target: 3)).contains("\u{2014}")) + XCTAssertFalse(GuidedCaptureProgress.label(for: .complete).contains("\u{2014}")) + XCTAssertFalse(GuidedCaptureProgress.gapNudge().contains("\u{2014}")) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRDownPacerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRDownPacerTests.swift new file mode 100644 index 0000000000..4ec9e6ad96 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRDownPacerTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the L2 `HRDownPacer`: a scripted HR descent → monotone, bounded target intervals that respect the +/// HR floor and the max-Δ, and stops on settle / timeout. GOLDEN VECTORS the Kotlin `HrDownPacerTest` +/// mirrors. See docs/superpowers/specs/2026-06-19-v5-haptic-biofeedback-design.md (L2). +final class HRDownPacerTests: XCTestCase { + + private let cfg = HRDownPacer.Config.default // start Δ 3, max Δ 8, ramp 120s, floor 50, calm 60, max 180s. + + // GOLDEN: at session start (elapsed 0), HR 84 → Δ 3 → target 81 → interval round(60000/81) = 741. + func test_golden_start_step() { + let step = HRDownPacer.next(currentHR: 84, elapsed: 0, config: cfg) + XCTAssertFalse(step.stop) + XCTAssertEqual(step.targetBpm!, 81, accuracy: 1e-9) + XCTAssertEqual(step.intervalMs, 741) + } + + // GOLDEN: fully ramped (elapsed ≥ 120), HR 84 → Δ 8 → target 76 → interval round(60000/76) = 789. + func test_golden_ramped_step() { + let step = HRDownPacer.next(currentHR: 84, elapsed: 120, config: cfg) + XCTAssertEqual(step.targetBpm!, 76, accuracy: 1e-9) + XCTAssertEqual(step.intervalMs, 789) + } + + // Δ ramps linearly: at half the ramp (60 s) Δ = (3+8)/2 = 5.5. + func test_delta_ramp_is_linear() { + XCTAssertEqual(HRDownPacer.rampedDelta(elapsed: 0, config: cfg), 3, accuracy: 1e-9) + XCTAssertEqual(HRDownPacer.rampedDelta(elapsed: 60, config: cfg), 5.5, accuracy: 1e-9) + XCTAssertEqual(HRDownPacer.rampedDelta(elapsed: 120, config: cfg), 8, accuracy: 1e-9) + XCTAssertEqual(HRDownPacer.rampedDelta(elapsed: 999, config: cfg), 8, accuracy: 1e-9) // clamps + } + + // A scripted HR descent: targets are non-increasing and intervals never exceed the floor's interval. + func test_descent_is_monotone_and_bounded() { + let trajectory: [Double] = [88, 86, 84, 82, 80, 78, 76, 74, 72, 70, 68, 66, 64, 62] + var prevTarget = Double.greatestFiniteMagnitude + for (i, hr) in trajectory.enumerated() { + let elapsed = Double(i) * cfg.recomputeSeconds + let step = HRDownPacer.next(currentHR: hr, elapsed: elapsed, config: cfg) + guard !step.stop, let target = step.targetBpm else { continue } + // Never below the floor, never above live HR. + XCTAssertGreaterThanOrEqual(target, cfg.hrFloorBpm) + XCTAssertLessThanOrEqual(target, hr) + // Never more than maxΔ below live HR. + XCTAssertGreaterThanOrEqual(target, hr - cfg.maxDeltaBpm - 1e-9) + } + // (Monotonicity holds because both HR and Δ move monotonically; spot-check the first two.) + let s0 = HRDownPacer.next(currentHR: 88, elapsed: 0, config: cfg).targetBpm! + let s1 = HRDownPacer.next(currentHR: 86, elapsed: cfg.recomputeSeconds, config: cfg).targetBpm! + XCTAssertLessThanOrEqual(s1, s0) + prevTarget = s0; _ = prevTarget + } + + func test_hr_floor_respected() { + // HR just above the calm target but Δ would push below the floor → clamp at floor. + let step = HRDownPacer.next(currentHR: 61, elapsed: 120, config: cfg) // 61 − 8 = 53 > floor 50 + XCTAssertEqual(step.targetBpm!, 53, accuracy: 1e-9) + // A config with a high floor forces the clamp. + let highFloor = HRDownPacer.Config(hrFloorBpm: 70, calmTargetBpm: 55) + let clamped = HRDownPacer.next(currentHR: 75, elapsed: 120, config: highFloor) + XCTAssertEqual(clamped.targetBpm!, 70, accuracy: 1e-9) // 75 − 8 = 67 < floor 70 → 70 + } + + func test_stops_on_settle() { + let step = HRDownPacer.next(currentHR: 59, elapsed: 30, config: cfg) // ≤ calm 60 + XCTAssertTrue(step.stop) + XCTAssertEqual(step.stopReason, .settled) + XCTAssertNil(step.intervalMs) + } + + func test_stops_on_timeout() { + let step = HRDownPacer.next(currentHR: 90, elapsed: 180, config: cfg) + XCTAssertTrue(step.stop) + XCTAssertEqual(step.stopReason, .timeout) + } + + func test_invalid_hr_stops() { + XCTAssertEqual(HRDownPacer.next(currentHR: 0, elapsed: 0, config: cfg).stopReason, .invalidHR) + XCTAssertEqual(HRDownPacer.next(currentHR: -5, elapsed: 0, config: cfg).stopReason, .invalidHR) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerTests.swift index 6a2ae514e6..6514d418f5 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerTests.swift @@ -67,6 +67,115 @@ final class HRVAnalyzerTests: XCTestCase { XCTAssertEqual(clean.count, nn.count) } + // MARK: - #585 spot honesty gate (maxRejectedFraction) + + func testSpotGateRefusesWhenTooManyBeatsRejected() { + // 40 input beats: 24 valid 800 ms + 16 out-of-range 100 ms (dropped by the range filter). + // 24 clean survive (>= minBeats 20), but 16/40 = 0.40 rejected > 0.35 gate → refused (empty). + var rr = Array(repeating: 800.0, count: 24) + rr.append(contentsOf: Array(repeating: 100.0, count: 16)) // 100 ms < rrMinMs(300) → range-dropped + let gated = HRVAnalyzer.analyze(rawRR: rr, maxRejectedFraction: 0.35) + XCTAssertNil(gated.rmssd, "0.40 rejected > 0.35 gate must refuse the spot reading") + XCTAssertNil(gated.sdnn) + XCTAssertEqual(gated.nInput, 40) + XCTAssertEqual(gated.nClean, 0) // empty() reports no clean beats on refusal + + // SAME beats with NO gate (nil) still produce a value , 24 clean ≥ minBeats. Proves the gate is + // the only thing rejecting it, not the beat count. + let ungated = HRVAnalyzer.analyze(rawRR: rr) + XCTAssertEqual(ungated.nClean, 24) + XCTAssertEqual(ungated.rmssd!, 0.0, accuracy: 1e-9) // all-800 survivors → no successive diffs + } + + func testSpotGateAllowsWhenRejectionUnderCeiling() { + // 40 input: 30 valid 800 ms + 10 out-of-range → 10/40 = 0.25 rejected < 0.35 gate → allowed. + var rr = Array(repeating: 800.0, count: 30) + rr.append(contentsOf: Array(repeating: 100.0, count: 10)) + let gated = HRVAnalyzer.analyze(rawRR: rr, maxRejectedFraction: 0.35) + XCTAssertEqual(gated.nClean, 30) + XCTAssertEqual(gated.rmssd!, 0.0, accuracy: 1e-9) + } + + func testNightlyWindowedRMSSDUnchangedWithDefaultedGate() { + // The nightly windowed analyze(_:windowStart:windowEnd:) passes NO maxRejectedFraction, so the + // gate is skipped and the result is byte-identical to analyze(rawRR:) on the same beats , even + // when the series WOULD trip a spot gate (here 0.40 rejected). Overnight HRV must not move (#585). + var rr: [RRInterval] = [] + for t in 0..<24 { rr.append(RRInterval(ts: 1000 + t, rrMs: 800)) } // 24 valid 800 ms + for t in 0..<16 { rr.append(RRInterval(ts: 1100 + t, rrMs: 100)) } // 16 range-dropped + let windowed = HRVAnalyzer.analyze(rr, windowStart: 1000, windowEnd: 2000) + // The spot gate WOULD refuse this (0.40 > 0.35); the nightly path must NOT. + XCTAssertEqual(windowed.nClean, 24) + XCTAssertNotNil(windowed.rmssd) + // Identical to the un-gated raw analysis on the same values. + let raw = HRVAnalyzer.analyze(rawRR: rr.map { Double($0.rrMs) }) + XCTAssertEqual(windowed.rmssd!, raw.rmssd!, accuracy: 1e-12) + XCTAssertEqual(windowed.sdnn ?? .nan, raw.sdnn ?? .nan, accuracy: 1e-12) + XCTAssertEqual(windowed.nClean, raw.nClean) + } + + // MARK: - #803 rolling / windowed rMSSD timeline + + func testRollingRmssdEmitsWindowedTimelineWithKnownValue() { + // A clean 1 Hz R-R series oscillating 800/810 ms. Over any trailing window the successive diffs + // alternate ±10, so rMSSD = sqrt(mean(10^2)) = 10 ms. We build 60 beats (1 s apart) and ask for a + // 30 s trailing window; every emitted point must read ~10 ms. + var rr: [RRInterval] = [] + for t in 0..<60 { rr.append(RRInterval(ts: 1000 + t, rrMs: t.isMultiple(of: 2) ? 800 : 810)) } + let pts = HRVAnalyzer.rollingRmssd(rr: rr, windowSec: 30, stepSec: 0, minBeatsPerWindow: 8) + XCTAssertFalse(pts.isEmpty, "a dense clean stream must yield a windowed timeline") + // The first ~7 beats can't fill minBeatsPerWindow(8); once the window holds >= 8 beats every point + // is the steady ±10 oscillation → 10 ms rMSSD. + for p in pts { XCTAssertEqual(p.rmssd, 10.0, accuracy: 1e-9) } + // Right edge of each point is a real interval timestamp inside the series, and points are time-ordered. + XCTAssertTrue(pts.allSatisfy { $0.ts >= 1000 && $0.ts <= 1059 }) + XCTAssertEqual(pts.map { $0.ts }, pts.map { $0.ts }.sorted()) + } + + func testRollingRmssdStepThinsEmission() { + // Same 60-beat 1 Hz stream, but a 10 s stride: points must be at least 10 s apart, so far fewer + // than one-per-beat are emitted while the value stays the steady 10 ms. + var rr: [RRInterval] = [] + for t in 0..<60 { rr.append(RRInterval(ts: 1000 + t, rrMs: t.isMultiple(of: 2) ? 800 : 810)) } + let dense = HRVAnalyzer.rollingRmssd(rr: rr, windowSec: 30, stepSec: 0, minBeatsPerWindow: 8) + let thinned = HRVAnalyzer.rollingRmssd(rr: rr, windowSec: 30, stepSec: 10, minBeatsPerWindow: 8) + XCTAssertLessThan(thinned.count, dense.count, "a stride must emit fewer points than every-beat") + // Adjacent emitted points are >= stepSec apart. + for i in 1..20% off the local median → ectopic + let (traced, lines) = HRVAnalyzer.analyzeTrace(rawRR: rr) + XCTAssertEqual(traced, HRVAnalyzer.analyze(rawRR: rr)) + let rejectLine = lines.first { $0.hasPrefix("hrv reject ") } + XCTAssertNotNil(rejectLine) + XCTAssertTrue(rejectLine!.contains("range=1")) + XCTAssertTrue(rejectLine!.contains("ectopic=1")) + } + + func testSpotGateLineOnlyWhenCeilingSupplied() { + let nn: [Double] = Array(repeating: 800.0, count: 22) + // Nightly/continuous path (nil ceiling): no spotGate line, byte-identical to analyze(). + let (_, contLines) = HRVAnalyzer.analyzeTrace(rawRR: nn, maxRejectedFraction: nil, path: "continuous") + XCTAssertFalse(contLines.contains { $0.contains("spotGate") }) + XCTAssertTrue(contLines.contains { $0.contains("path=continuous") }) + // Spot path (ceiling supplied): the gate line is present. + let (_, spotLines) = HRVAnalyzer.analyzeTrace( + rawRR: nn, maxRejectedFraction: HRVAnalyzer.defaultSpotMaxRejectedFraction, path: "spot") + XCTAssertTrue(spotLines.contains { $0.contains("spotGate") && $0.contains("PASS") }) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVFreqDomainTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVFreqDomainTests.swift new file mode 100644 index 0000000000..0c36b4297f --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVFreqDomainTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +final class HRVFreqDomainTests: XCTestCase { + + /// Build a synthetic R-R series whose instantaneous interval is sinusoidally modulated at `modHz` around + /// a `baseMs` mean, sampled for `durationSec` seconds. The beat times are the running cumulative sum of + /// the generated intervals, so the result is a genuine (unevenly sampled) tachogram, exactly what + /// Lomb-Scargle is meant to handle. + private func modulatedRR(baseMs: Double, ampMs: Double, modHz: Double, durationSec: Double) -> [Double] { + var out: [Double] = [] + var t = 0.0 + while t < durationSec { + let rr = baseMs + ampMs * sin(2.0 * Double.pi * modHz * t) + out.append(rr) + t += rr / 1000.0 + } + return out + } + + // MARK: - Span gates (Task Force 1996) + + func testAbstainsUnderSixtySecondSpan() { + // ~40 s of beats: span < minSpanForHFSec(60) → nil entirely (no HF, no LF). + let rr = modulatedRR(baseMs: 1000, ampMs: 20, modHz: 0.25, durationSec: 40) + XCTAssertNil(HRVFreqDomain.freqDomain(rawRR: rr)) + } + + func testTooFewBeatsAbstains() { + // Long span in wall-time can't help if there are simply too few clean beats. + let rr = Array(repeating: 1000.0, count: HRVFreqDomain.minBeats - 1) + XCTAssertNil(HRVFreqDomain.freqDomain(rawRR: rr)) + } + + func testShortSpanGivesHFButNilLF() { + // Between 60 s and 250 s of span: HF present, LF and LF/HF nil. + let rr = modulatedRR(baseMs: 900, ampMs: 25, modHz: 0.25, durationSec: 120) + let bands = HRVFreqDomain.freqDomain(rawRR: rr) + XCTAssertNotNil(bands) + XCTAssertNil(bands?.lf, "LF must be nil below the 250 s span gate") + XCTAssertNil(bands?.lfhf, "LF/HF must be nil when LF is nil") + XCTAssertNotNil(bands?.hf) + XCTAssertGreaterThan(bands!.hf, 0) + // On a HF-only window totalPower reports the HF band, not a misleading partial sum. + XCTAssertEqual(bands!.totalPower, bands!.hf, accuracy: 1e-9) + } + + func testLongSpanGivesLFAndRatio() { + // >= 250 s of span → LF present and LF/HF computable. + let rr = modulatedRR(baseMs: 900, ampMs: 25, modHz: 0.25, durationSec: 300) + let bands = HRVFreqDomain.freqDomain(rawRR: rr) + XCTAssertNotNil(bands) + XCTAssertNotNil(bands?.lf) + XCTAssertNotNil(bands?.lfhf) + XCTAssertGreaterThan(bands!.totalPower, bands!.hf, "wide total power must exceed HF alone once LF is in") + } + + // MARK: - Peak lands in the expected band + + func testHFModulationConcentratesPowerInHF() { + // A 0.25 Hz modulation (squarely inside HF 0.15–0.40) must put far more power in HF than LF. + let rr = modulatedRR(baseMs: 900, ampMs: 30, modHz: 0.25, durationSec: 300) + let bands = HRVFreqDomain.freqDomain(rawRR: rr)! + XCTAssertNotNil(bands.lf) + XCTAssertGreaterThan(bands.hf, bands.lf! * 3.0, "HF-band modulation must dominate the HF band") + XCTAssertNotNil(bands.lfhf) + XCTAssertLessThan(bands.lfhf!, 1.0, "an HF-dominant rhythm has LF/HF < 1") + } + + func testLFModulationConcentratesPowerInLF() { + // A 0.10 Hz modulation (inside LF 0.04–0.15) must put far more power in LF than HF. + let rr = modulatedRR(baseMs: 900, ampMs: 30, modHz: 0.10, durationSec: 300) + let bands = HRVFreqDomain.freqDomain(rawRR: rr)! + XCTAssertNotNil(bands.lf) + XCTAssertGreaterThan(bands.lf!, bands.hf * 3.0, "LF-band modulation must dominate the LF band") + XCTAssertGreaterThan(bands.lfhf!, 1.0, "an LF-dominant rhythm has LF/HF > 1") + } + + // MARK: - Additivity guard (does not perturb the time-domain analyzer) + + func testCleanRRSharedWithTimeDomainPath() { + // The freq-domain estimator must clean with the SAME pipeline; an injected artifact beat is dropped + // and does not blow up the spectrum. Sanity: a clean modulated series still yields finite bands. + var rr = modulatedRR(baseMs: 900, ampMs: 25, modHz: 0.25, durationSec: 300) + rr.insert(50.0, at: rr.count / 2) // out-of-range artifact, range-filtered away + let bands = HRVFreqDomain.freqDomain(rawRR: rr) + XCTAssertNotNil(bands) + XCTAssertTrue(bands!.hf.isFinite && bands!.hf > 0) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRZonesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRZonesTests.swift index 5ebe5c246e..0e5a4629e3 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRZonesTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRZonesTests.swift @@ -86,4 +86,21 @@ final class HRZonesTests: XCTestCase { XCTAssertEqual(tiz.seconds(inZone: 1), 3.0, accuracy: 1e-9) XCTAssertEqual(tiz.total, 3.0, accuracy: 1e-9) // all time accounted for } + + func testTimeInZoneCapsHugePositiveGap() { + let zs = HRZones.zones(maxHR: 200) + // Three 1 Hz zone-1 samples (median gap 1 s), then one sample an HOUR later. The 3600 s + // gap before the last sample must be capped at the median (1 s) — as the comment promises — + // not credited in full, so one wear gap / sparse stretch can't blow up a bucket. + let hr = [ + HRSample(ts: 0, bpm: 110), + HRSample(ts: 1, bpm: 110), + HRSample(ts: 2, bpm: 110), + HRSample(ts: 3602, bpm: 110), + ] + let tiz = HRZones.timeInZone(hr, zoneSet: zs) + XCTAssertLessThan(tiz.total, 10.0, + "a huge inter-sample gap must be capped at the median, not credited in full") + XCTAssertEqual(tiz.seconds(inZone: 1), tiz.total, accuracy: 1e-9) // all of it is zone 1 + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HapticClockEncoderTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HapticClockEncoderTests.swift new file mode 100644 index 0000000000..a76d776dcf --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HapticClockEncoderTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the #460 "haptic clock" encoding: a wall-clock time → a countable buzz schedule. Pure value +/// logic, so no strap/BLE seam is needed. The BLE layer (separately) maps each pulse onto a real buzz. +final class HapticClockEncoderTests: XCTestCase { + + private func counts(_ p: [HapticPulse]) -> (long: Int, medium: Int, short: Int) { + (p.filter { $0 == .long }.count, + p.filter { $0 == .medium }.count, + p.filter { $0 == .short }.count) + } + + func test_11_47() { + // 11:47 → hour 11 = 1 long + 1 short; minute 47 = 4 medium + 7 short. + let p = HapticClockEncoder.pulses(hour24: 11, minute: 47) + let c = counts(p) + XCTAssertEqual(c.long, 1) + XCTAssertEqual(c.medium, 4) + XCTAssertEqual(c.short, 1 + 7) + // Structure: hour group, then a groupGap, then the minute group with a unitGap inside it. + XCTAssertEqual(p, [.long, .short, .groupGap, .medium, .medium, .medium, .medium, .unitGap, + .short, .short, .short, .short, .short, .short, .short]) + } + + func test_3_oclock_is_three_shorts_no_long() { + // 3:00 → hour 3 = 0 long + 3 short; minute 0 = 0 medium + 0 short. + let p = HapticClockEncoder.pulses(hour24: 15, minute: 0) // 3 PM + let c = counts(p) + XCTAssertEqual(c.long, 0) + XCTAssertEqual(c.short, 3) + XCTAssertEqual(c.medium, 0) + XCTAssertEqual(p, [.short, .short, .short, .groupGap, .unitGap]) + } + + func test_ten_oclock_is_one_long_zero_units() { + // 10:00 → hour 10 = tens 1 (LONG), units 0. + let p = HapticClockEncoder.pulses(hour24: 10, minute: 0) + let c = counts(p) + XCTAssertEqual(c.long, 1) + XCTAssertEqual(c.short, 0) + } + + func test_twelve_hour_wrap() { + // 0:00 and 24/midnight map to 12; 12:00 noon stays 12; 13 → 1. + XCTAssertEqual(HapticClockEncoder.twelveHour(0), 12) + XCTAssertEqual(HapticClockEncoder.twelveHour(12), 12) + XCTAssertEqual(HapticClockEncoder.twelveHour(13), 1) + XCTAssertEqual(HapticClockEncoder.twelveHour(23), 11) + } + + func test_midnight_12_05() { + // 00:05 → 12:05 → hour 12 = 1 long + 2 short; minute 05 = 0 medium + 5 short. + let c = counts(HapticClockEncoder.pulses(hour24: 0, minute: 5)) + XCTAssertEqual(c.long, 1) + XCTAssertEqual(c.short, 2 + 5) + XCTAssertEqual(c.medium, 0) + } + + func test_out_of_range_is_clamped_not_trapped() { + // Negative / overflow inputs must still produce a finite schedule (no crash, no infinite array). + XCTAssertFalse(HapticClockEncoder.pulses(hour24: -1, minute: 75).isEmpty) + XCTAssertFalse(HapticClockEncoder.pulses(hour24: 99, minute: -10).isEmpty) + // Minute clamps to 0–59: 75 → 59 → 5 medium + 9 short. + let c = counts(HapticClockEncoder.pulses(hour24: 6, minute: 75)) + XCTAssertEqual(c.medium, 5) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HydrationGoalTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HydrationGoalTests.swift new file mode 100644 index 0000000000..068fd015bb --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HydrationGoalTests.swift @@ -0,0 +1,107 @@ +import XCTest +@testable import StrandAnalytics + +/// Locks the hydration goal formula: `roundToNearest(sexBaseline + effortBump, 50)` with +/// effortBump = clamp(round(effort/100 · 700), 0…700). BYTE-PARITY with the Android twin +/// (com.noop.analytics.HydrationGoal) — same Int constants, same round-then-clamp, same integer rounding. +final class HydrationGoalTests: XCTestCase { + + // MARK: - Sex baseline + + func testSexBaseline() { + XCTAssertEqual(HydrationGoal.baselineForSex("male"), 3700) + XCTAssertEqual(HydrationGoal.baselineForSex("female"), 2700) + // Anything else falls to the unspecified baseline — never a guess. + XCTAssertEqual(HydrationGoal.baselineForSex("nonbinary"), 3200) + XCTAssertEqual(HydrationGoal.baselineForSex("other"), 3200) + XCTAssertEqual(HydrationGoal.baselineForSex(""), 3200) + } + + func testSexBaselineNormalisation() { + // Case- and whitespace-insensitive, plus the m/f shorthands (matches the Kotlin twin). + XCTAssertEqual(HydrationGoal.baselineForSex("MALE"), 3700) + XCTAssertEqual(HydrationGoal.baselineForSex(" Female "), 2700) + XCTAssertEqual(HydrationGoal.baselineForSex("m"), 3700) + XCTAssertEqual(HydrationGoal.baselineForSex("F"), 2700) + } + + // MARK: - Effort bump + + func testEffortBumpNilIsZero() { + XCTAssertEqual(HydrationGoal.effortBump(effort: nil), 0) + } + + func testEffortBumpScalesAndRounds() { + // 0 → 0, 100 → 700 (the cap), 50 → 350. + XCTAssertEqual(HydrationGoal.effortBump(effort: 0), 0) + XCTAssertEqual(HydrationGoal.effortBump(effort: 100), 700) + XCTAssertEqual(HydrationGoal.effortBump(effort: 50), 350) + // round(63/100 · 700) = round(441) = 441. + XCTAssertEqual(HydrationGoal.effortBump(effort: 63), 441) + // round(1/100 · 700) = round(7) = 7. + XCTAssertEqual(HydrationGoal.effortBump(effort: 1), 7) + } + + func testEffortBumpClampsOutputOfRange() { + // Round FIRST, then clamp the OUTPUT to 0…700 (so >100 / negative efforts saturate at the bounds). + XCTAssertEqual(HydrationGoal.effortBump(effort: -20), 0) + XCTAssertEqual(HydrationGoal.effortBump(effort: 150), 700) + XCTAssertEqual(HydrationGoal.effortBump(effort: .nan), 0) + XCTAssertEqual(HydrationGoal.effortBump(effort: .infinity), 0) + } + + // MARK: - Rounding + + func testRoundToNearest50() { + XCTAssertEqual(HydrationGoal.roundToNearest(3724, step: 50), 3700) + XCTAssertEqual(HydrationGoal.roundToNearest(3725, step: 50), 3750) // half rounds up + XCTAssertEqual(HydrationGoal.roundToNearest(3700, step: 50), 3700) + } + + // MARK: - Full goal + + func testDailyGoalNoEffort() { + // No Effort yet → just the rounded baseline (already a multiple of 50). + XCTAssertEqual(HydrationGoal.dailyGoalML(sex: "male", effort: nil), 3700) + XCTAssertEqual(HydrationGoal.dailyGoalML(sex: "female", effort: nil), 2700) + XCTAssertEqual(HydrationGoal.dailyGoalML(sex: "other", effort: nil), 3200) + } + + func testDailyGoalWithEffortRoundsTo50() { + // male 3700 + round(63/100·700)=441 = 4141 → nearest 50 = 4150. + XCTAssertEqual(HydrationGoal.dailyGoalML(sex: "male", effort: 63), 4150) + // female 2700 + 350 (effort 50) = 3050, already a multiple of 50. + XCTAssertEqual(HydrationGoal.dailyGoalML(sex: "female", effort: 50), 3050) + // male 3700 + 700 (cap) = 4400. + XCTAssertEqual(HydrationGoal.dailyGoalML(sex: "male", effort: 100), 4400) + } + + func testDailyGoalIsAlwaysMultipleOf50() { + for sex in ["male", "female", "other"] { + for effort in stride(from: 0.0, through: 100.0, by: 1.0) { + let goal = HydrationGoal.dailyGoalML(sex: sex, effort: effort) + XCTAssertEqual(goal % 50, 0, + "goal \(goal) for sex=\(sex) effort=\(effort) is not a multiple of 50") + } + } + } + + // MARK: - Display helpers + + func testCardValueString() { + XCTAssertEqual(HydrationGoal.cardValueString(totalML: 1200, goalML: 3200), "1.2 / 3.2 L") + XCTAssertEqual(HydrationGoal.cardValueString(totalML: 0, goalML: 3700), "0.0 / 3.7 L") + } + + func testFractionClamps() { + XCTAssertEqual(HydrationGoal.fraction(totalML: 1600, goalML: 3200), 0.5, accuracy: 1e-9) + XCTAssertEqual(HydrationGoal.fraction(totalML: 5000, goalML: 3200), 1.0, accuracy: 1e-9) // capped + XCTAssertEqual(HydrationGoal.fraction(totalML: 100, goalML: 0), 0.0, accuracy: 1e-9) // guard + } + + func testQuickAmounts() { + XCTAssertEqual(HydrationGoal.sipML, 30) + XCTAssertEqual(HydrationGoal.cupML, 237) + XCTAssertEqual(HydrationGoal.bottleML, 500) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/IllnessDistanceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/IllnessDistanceTests.swift new file mode 100644 index 0000000000..eea4ee5bdf --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/IllnessDistanceTests.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import StrandAnalytics + +final class IllnessDistanceTests: XCTestCase { + + // MARK: - Golden distances (hand-computed) + + func testIdentityDistanceIsEuclideanNorm() { + // With no correlation supplied (identity), D == the Euclidean norm of the z-vector. + // x = [3, 3] (two features both illness-ward) → D = sqrt(9 + 9) = 4.2426406871... + let r = IllnessDistance.evaluate( + features: .init(restingHR: 3.0, rmssd: 3.0)) + XCTAssertEqual(r.distance, 4.242640687119285, accuracy: 1e-6) + XCTAssertEqual(r.deviatingFeatures, 2) + XCTAssertFalse(r.usedDiagonalFallback) + XCTAssertTrue(r.fires, "D > 2.5 and 2 deviating features → fires") + } + + func testCorrelationDiscountsSharedVariance() { + // Two POSITIVELY-correlated features both up should count as LESS of a joint anomaly than if treated + // independently, Mahalanobis with a 0.8 correlation shrinks the distance from 4.24 to ~3.16. + let corr = [[1.0, 0.8], [0.8, 1.0]] + let indep = IllnessDistance.evaluate(features: .init(restingHR: 3.0, rmssd: 3.0)) + let correlated = IllnessDistance.evaluate(features: .init(restingHR: 3.0, rmssd: 3.0), + correlation: corr) + XCTAssertEqual(correlated.distance, 3.162276781758283, accuracy: 1e-5) + XCTAssertLessThan(correlated.distance, indep.distance, + "correlated co-movement counts as one move, not two") + } + + // MARK: - Deviating-feature gate (a big D from a wellness-ward signal must not fire) + + func testWellnessWardSignalDoesNotCountAsDeviating() { + // x = [3, -3]: one feature illness-ward, one strongly the WELLNESS way. The distance is large but + // only ONE feature is deviating illness-ward → below minDeviatingFeatures(2) → does not fire. + let corr = [[1.0, 0.8], [0.8, 1.0]] + let r = IllnessDistance.evaluate(features: .init(restingHR: 3.0, rmssd: -3.0), correlation: corr) + XCTAssertGreaterThan(r.distance, IllnessDistance.distanceThreshold) + XCTAssertEqual(r.deviatingFeatures, 1) + XCTAssertFalse(r.fires, "a big D driven by a wellness-ward coordinate must not fire") + } + + func testEmptyVectorDoesNotFire() { + let r = IllnessDistance.evaluate(features: .init()) + XCTAssertEqual(r.distance, 0) + XCTAssertEqual(r.deviatingFeatures, 0) + XCTAssertFalse(r.fires) + } + + // MARK: - Singular correlation → diagonal fallback (no NaN) + + func testSingularCorrelationIsRegularizedToFinite() { + // A perfectly collinear (rank-1) correlation is singular; the Tikhonov ridge regularizes it to a + // FINITE distance (no NaN/Inf), and collinear co-movement is correctly discounted, two perfectly + // correlated features both up count as ONE effective move, so D is well below the independent 4.24. + let singular = [[1.0, 1.0], [1.0, 1.0]] + let r = IllnessDistance.evaluate(features: .init(restingHR: 3.0, rmssd: 3.0), correlation: singular) + XCTAssertTrue(r.distance.isFinite) + XCTAssertGreaterThan(r.distance, 0) + XCTAssertLessThan(r.distance, 4.2426, "collinear co-movement is one effective move, not two") + } + + func testDiagonalFallbackOnDegenerateMatrix() { + // Directly exercise the singular fallback path: a zero matrix has no usable pivot, so invertOrDiagonal + // returns its finite diagonal inverse (degenerate diagonals mapped to 1) rather than NaN/Inf. + let (inv, fellBack) = IllnessDistance.invertOrDiagonal([[0.0, 0.0], [0.0, 0.0]]) + XCTAssertTrue(fellBack) + XCTAssertTrue(inv.allSatisfy { $0.allSatisfy { $0.isFinite } }) + } + + // MARK: - Fire-rate comparison vs the existing per-signal z-sum (REQUIRED by the lane spec) + + /// A deterministic illness test corpus of illness-oriented z-vectors covering: strong multi-signal + /// nights, mild 2-signal nights, single-noisy-signal nights, and normal nights. The Mahalanobis path + /// (identity correlation, the additive default with no personal corr supplied) must NOT balloon or + /// silence alerts relative to the current per-signal z-sum corroboration gate at the 2.5 threshold. + func testMahalanobisFireRateDoesNotBalloonOrSilence() { + let corpus = Self.illnessCorpus() + + // Current per-signal scorer's RAISE precondition: >= 2 features over the z firing threshold (the + // corroboration gate that gates a raised illness alert in IllnessSignalEngine). + func zSumFires(_ v: [Double]) -> Bool { + v.filter { $0 >= IllnessDistance.featureZThreshold }.count >= IllnessDistance.minDeviatingFeatures + } + // Alternative Mahalanobis path (identity corr): D > 2.5 AND >= 2 deviating features. + func mahaFires(_ v: [Double]) -> Bool { + IllnessDistance.evaluate(features: .init( + restingHR: v[0], rmssd: v[1], skinTemp: v[2], respiration: v[3])).fires + } + + let zCount = corpus.filter(zSumFires).count + let mCount = corpus.filter(mahaFires).count + XCTAssertGreaterThan(corpus.count, 0) + // The alternative must stay in the same ballpark: neither more than ~25% above nor below the z-sum + // fire-count. On this corpus they match exactly (any 2 features at z>=2 give D>=sqrt(8)>2.5), proving + // the additive path neither cries wolf more nor goes silent. + let lower = Int((Double(zCount) * 0.75).rounded(.down)) + let upper = Int((Double(zCount) * 1.25).rounded(.up)) + XCTAssertGreaterThanOrEqual(mCount, lower, "Mahalanobis path must not SILENCE alerts vs the z-sum") + XCTAssertLessThanOrEqual(mCount, upper, "Mahalanobis path must not BALLOON alerts vs the z-sum") + + // And on every case the two agree on this corpus, strong evidence it's a faithful alternative. + for v in corpus { + XCTAssertEqual(zSumFires(v), mahaFires(v), "z-sum and Mahalanobis disagree on \(v)") + } + } + + /// 60-case deterministic corpus (no RNG so it's byte-stable across platforms): 15 strong, 15 mild + /// 2-signal, 15 single-noisy, 15 normal nights, each a [rhr, rmssdNeg, skinTemp, resp] z-vector. + static func illnessCorpus() -> [[Double]] { + var c: [[Double]] = [] + // Strong multi-signal illness nights (3-4 features well over 2). + c.append(contentsOf: [ + [3.2, 3.0, 3.5, 2.8], [4.1, 2.9, 3.3, 3.0], [2.6, 3.4, 2.7, 2.5], + [3.8, 4.0, 3.9, 3.6], [2.9, 2.8, 3.1, 0.4], [3.5, 3.2, 0.2, 3.0], + [4.4, 0.1, 3.0, 2.7], [2.7, 3.6, 3.4, 1.1], [3.0, 3.0, 3.0, 3.0], + [2.5, 2.6, 2.9, 2.4], [3.9, 3.1, 1.0, 2.8], [3.3, 0.5, 3.2, 3.4], + [2.8, 3.7, 2.6, 0.3], [4.0, 2.5, 2.5, 2.5], [3.1, 3.3, 3.5, 3.7], + ]) + // Mild 2-signal nights (exactly two over 2, others quiet). + c.append(contentsOf: [ + [2.3, 2.2, 0.5, -0.4], [2.1, 0.3, 2.4, 1.0], [0.2, 2.6, 2.1, -1.0], + [2.5, 1.1, 0.0, 2.2], [2.2, 2.3, -0.5, 0.7], [1.0, 2.1, 2.7, 0.1], + [2.4, 0.4, 1.2, 2.3], [2.6, 2.2, 1.5, -0.2], [0.6, 2.5, 0.3, 2.1], + [2.1, 1.0, 2.2, 0.5], [2.3, 2.4, 0.8, 1.1], [1.2, 2.2, 2.5, 0.0], + [2.7, 0.1, 2.1, 1.3], [2.2, 2.6, -0.3, 0.9], [0.4, 2.3, 2.2, 1.0], + ]) + // Single-noisy-signal nights (one big, rest quiet → must not fire either way). + c.append(contentsOf: [ + [5.0, 0.5, -0.2, 1.0], [0.3, 4.5, 1.1, -0.5], [1.2, -1.0, 3.8, 0.4], + [0.7, 1.0, 0.2, 4.2], [6.0, -0.5, 0.8, 1.2], [1.5, 5.5, -0.3, 0.6], + [-0.4, 0.9, 4.9, 1.0], [1.1, 0.2, 1.0, 5.1], [3.2, 1.0, 1.5, 0.5], + [0.8, 3.5, 1.2, 1.0], [1.0, 0.6, 3.1, 1.4], [1.3, 1.0, 0.7, 3.6], + [4.7, 1.4, 1.0, 0.3], [0.9, 4.0, 0.5, 1.1], [1.0, 0.8, 4.4, 0.9], + ]) + // Normal nights (nothing over 2). + c.append(contentsOf: [ + [0.5, -0.3, 1.2, 0.8], [-1.0, 0.4, 0.6, 1.5], [1.8, 1.0, -0.5, 0.2], + [0.0, 1.9, 1.1, -1.2], [1.5, -0.8, 0.9, 1.0], [-0.4, 1.2, 1.7, 0.3], + [1.1, 0.5, -0.2, 1.6], [0.7, 1.8, 0.4, 0.9], [-1.5, 0.6, 1.3, 1.0], + [1.0, 1.0, 1.0, 1.0], [0.3, -0.5, 1.9, 0.7], [1.6, 1.1, 0.2, -0.6], + [0.9, 1.7, 1.0, 1.2], [-0.2, 0.8, 1.5, 1.8], [1.4, 1.3, -1.0, 0.5], + ]) + return c + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/IllnessSignalEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/IllnessSignalEngineTests.swift new file mode 100644 index 0000000000..8cdc4101fc --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/IllnessSignalEngineTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import StrandAnalytics + +final class IllnessSignalEngineTests: XCTestCase { + + private let labels = [ + "restingHR": "RHR +6", + "skinTemp": "skin temp +0.7 °C", + "hrv": "HRV −22%", + "respiration": "respiration up", + ] + + private func reading(_ z: Double) -> IllnessSignalEngine.SignalReading { + IllnessSignalEngine.SignalReading(zIllnessward: z) + } + + // MARK: - Classic illness pattern (no tags) → raised + + func testClassicThreeSignalPatternRaises() { + // RHR, skin temp and HRV all well over the firing threshold, no confounders. + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(3.2), skinTemp: reading(3.0), hrv: reading(3.5)) + let r = IllnessSignalEngine.evaluate(inputs, context: .init(), firedLabels: labels) + XCTAssertEqual(r.level, .raised) + XCTAssertGreaterThanOrEqual(r.score, IllnessSignalEngine.raiseThreshold) + XCTAssertEqual(r.signalCount, 3) + XCTAssertEqual(r.firedSignals, ["RHR +6", "skin temp +0.7 °C", "HRV −22%"]) + XCTAssertTrue(r.suppressedBy.isEmpty) + XCTAssertTrue(r.copy.contains("not a diagnosis")) + } + + // MARK: - Same pattern + alcohol tag → suppressed (the core false-positive test) + + func testAlcoholTagSuppresses() { + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(3.2), skinTemp: reading(3.0), hrv: reading(3.5)) + let raised = IllnessSignalEngine.evaluate(inputs, context: .init(), firedLabels: labels) + let suppressed = IllnessSignalEngine.evaluate( + inputs, context: .init(alcohol: true), firedLabels: labels) + XCTAssertEqual(suppressed.level, .suppressed) + XCTAssertEqual(suppressed.suppressedBy, ["alcohol"]) + // Dampened well below the raised score. + XCTAssertLessThan(suppressed.score, raised.score) + XCTAssertEqual(suppressed.score, raised.score * IllnessSignalEngine.confounderDampen, accuracy: 1e-9) + XCTAssertTrue(suppressed.copy.contains("alcohol")) + XCTAssertTrue(suppressed.copy.contains("not illness")) + XCTAssertTrue(suppressed.copy.contains("not a diagnosis")) + } + + func testStressSaunaTravelEachDowngradeWithReason() { + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(3.2), skinTemp: reading(3.0), hrv: reading(3.5)) + let stress = IllnessSignalEngine.evaluate(inputs, context: .init(stress: true), firedLabels: labels) + XCTAssertEqual(stress.level, .suppressed) + XCTAssertEqual(stress.suppressedBy, ["stress"]) + + let sauna = IllnessSignalEngine.evaluate(inputs, context: .init(sauna: true), firedLabels: labels) + XCTAssertEqual(sauna.suppressedBy, ["sauna"]) + + let travel = IllnessSignalEngine.evaluate( + inputs, context: .init(travelPhaseJump: true), firedLabels: labels) + XCTAssertEqual(travel.suppressedBy, ["travel"]) + XCTAssertTrue(travel.copy.contains("travel")) + } + + func testMultipleConfoundersJoinNaturally() { + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(3.2), skinTemp: reading(3.0), hrv: reading(3.5)) + let r = IllnessSignalEngine.evaluate( + inputs, context: .init(alcohol: true, stress: true), firedLabels: labels) + XCTAssertEqual(r.suppressedBy, ["alcohol", "stress"]) + XCTAssertTrue(r.copy.contains("alcohol and stress")) + } + + // MARK: - Already-sick tag → "rest up" copy, not "early warning" + + func testAlreadyUnwellSwitchesCopy() { + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(3.2), skinTemp: reading(3.0), hrv: reading(3.5)) + let r = IllnessSignalEngine.evaluate( + inputs, context: .init(alreadyUnwell: true), firedLabels: labels) + XCTAssertEqual(r.level, .alreadyUnwell) + XCTAssertTrue(r.copy.contains("Rest up")) + XCTAssertTrue(r.copy.contains("numbers agree")) + XCTAssertFalse(r.copy.contains("Heads-up")) + } + + // MARK: - Gates: single noisy night / untrusted baseline → silent + + func testSingleSignalDoesNotRaise() { + // Only one signal over threshold → below corroboration gate → quiet. + let inputs = IllnessSignalEngine.Inputs(restingHR: reading(4.0)) + let r = IllnessSignalEngine.evaluate(inputs, context: .init(), firedLabels: labels) + XCTAssertEqual(r.level, .quiet) + XCTAssertEqual(r.signalCount, 1) + } + + func testUntrustedBaselineStaysSilent() { + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(3.2), skinTemp: reading(3.0), hrv: reading(3.5)) + let r = IllnessSignalEngine.evaluate( + inputs, context: .init(baselineTrusted: false), firedLabels: labels) + XCTAssertEqual(r.level, .quiet) + XCTAssertFalse(r.copy.contains("Heads-up")) + } + + func testBelowThresholdSignalsAreMildNotRaised() { + // Two signals just over the firing threshold but composite below raiseThreshold → mild. + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(2.6), skinTemp: reading(2.6)) + let r = IllnessSignalEngine.evaluate(inputs, context: .init(), firedLabels: labels) + XCTAssertEqual(r.signalCount, 2) + XCTAssertEqual(r.level, .mild) + XCTAssertLessThan(r.score, IllnessSignalEngine.raiseThreshold) + XCTAssertGreaterThanOrEqual(r.score, IllnessSignalEngine.mildThreshold) + } + + func testAbsentSignalsDoNotCount() { + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(3.2), + skinTemp: IllnessSignalEngine.SignalReading(zIllnessward: 9.0, present: false), + hrv: reading(3.5)) + let r = IllnessSignalEngine.evaluate(inputs, context: .init(), firedLabels: labels) + // The absent skin-temp does not fire despite its huge z. + XCTAssertEqual(r.signalCount, 2) + XCTAssertFalse(r.firedSignals.contains("skin temp +0.7 °C")) + } + + // MARK: - Copy never names a condition + + func testCopyNeverNamesACondition() { + let inputs = IllnessSignalEngine.Inputs( + restingHR: reading(3.2), skinTemp: reading(3.0), hrv: reading(3.5)) + let banned = ["covid", "flu", "fever", "infection", "sick with", "illness with", "disease"] + for ctx in [IllnessSignalEngine.Context(), + .init(alcohol: true), + .init(alreadyUnwell: true)] { + let copy = IllnessSignalEngine.evaluate(inputs, context: ctx, firedLabels: labels).copy.lowercased() + for b in banned { XCTAssertFalse(copy.contains(b), "copy contained banned term \(b): \(copy)") } + } + } + + func testScorePerSignalCapping() { + // A single enormous z is capped, so it alone can't saturate the composite. + let inputs = IllnessSignalEngine.Inputs(restingHR: reading(100.0), skinTemp: reading(2.5)) + let r = IllnessSignalEngine.evaluate(inputs, context: .init(), firedLabels: labels) + // RHR caps at perSignalCap (40) + skinTemp small contribution. + let expectedSkin = IllnessSignalEngine.kZToScore * (2.5 - IllnessSignalEngine.signalZThreshold) + XCTAssertEqual(r.score, IllnessSignalEngine.perSignalCap + expectedSkin, accuracy: 1e-9) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LabBookProjectionTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LabBookProjectionTests.swift new file mode 100644 index 0000000000..fcbddc0c33 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LabBookProjectionTests.swift @@ -0,0 +1,152 @@ +import XCTest +@testable import StrandAnalytics + +/// Pure-logic tests for the Lab Book projection. The fixtures here are mirrored +/// byte-for-byte by the Android twin (LabBookProjectionTest.kt) so the same readings +/// produce identical daily projections and windowed pairs on both platforms. +final class LabBookProjectionTests: XCTestCase { + + private func r(_ key: String, _ day: String, _ value: Double, _ takenAt: Double) -> LabReading { + LabReading(markerKey: key, day: day, value: value, takenAtEpoch: takenAt) + } + + // MARK: - daily fold: latest-per-day + + func testProjectLatestPerDay() { + // Two LDL readings the same day; later takenAt wins. A third on another day. + let readings = [ + r("ldl", "2026-01-10", 3.4, 1_736_500_000), + r("ldl", "2026-01-10", 3.0, 1_736_590_000), // later same day + r("ldl", "2026-03-10", 2.8, 1_741_600_000), + ] + let proj = LabBookProjection.project(readings, fold: .latest) + XCTAssertEqual(proj, [ + ProjectedPoint(markerKey: "ldl", day: "2026-01-10", value: 3.0), + ProjectedPoint(markerKey: "ldl", day: "2026-03-10", value: 2.8), + ]) + } + + // MARK: - daily fold: mean-per-day + + func testProjectMeanPerDay() { + let readings = [ + r("bp_systolic", "2026-02-01", 120, 1_738_400_000), + r("bp_systolic", "2026-02-01", 130, 1_738_410_000), // same day → mean 125 + r("bp_systolic", "2026-02-02", 118, 1_738_490_000), + ] + let proj = LabBookProjection.project(readings, fold: .mean) + XCTAssertEqual(proj, [ + ProjectedPoint(markerKey: "bp_systolic", day: "2026-02-01", value: 125), + ProjectedPoint(markerKey: "bp_systolic", day: "2026-02-02", value: 118), + ]) + } + + // MARK: - deterministic ordering across markers + + func testProjectSortsByMarkerThenDay() { + let readings = [ + r("hdl", "2026-03-10", 1.4, 3), + r("ldl", "2026-01-10", 3.4, 1), + r("hdl", "2026-01-10", 1.2, 2), + ] + let proj = LabBookProjection.project(readings, fold: .latest) + XCTAssertEqual(proj.map { "\($0.markerKey)@\($0.day)" }, + ["hdl@2026-01-10", "hdl@2026-03-10", "ldl@2026-01-10"]) + } + + // MARK: - BP pair: two distinct keys project independently + + func testBpPairProjectsTwoKeys() { + let readings = [ + r(LabBookProjection.bpSystolicKey, "2026-02-01", 122, 1), + r(LabBookProjection.bpDiastolicKey, "2026-02-01", 78, 1), + ] + let proj = LabBookProjection.project(readings, fold: .latest) + XCTAssertEqual(proj, [ + ProjectedPoint(markerKey: "bp_diastolic", day: "2026-02-01", value: 78), + ProjectedPoint(markerKey: "bp_systolic", day: "2026-02-01", value: 122), + ]) + } + + // MARK: - windowed pairing (trailing 14d, inclusive of D) + + func testWindowedPairTrailingMean() { + // One marker reading on 2026-01-15. Wearable RHR spread over the prior fortnight. + // Window = 3 days for an easy hand check: [2026-01-13, 14, 15]. + let marker = [(day: "2026-01-15", value: 3.1)] + let wearable = [ + (day: "2026-01-10", value: 60.0), // OUTSIDE the 3-day window + (day: "2026-01-13", value: 50.0), + (day: "2026-01-14", value: 52.0), + (day: "2026-01-15", value: 54.0), + ] + let pairs = LabBookProjection.pairMarkerToWearable(marker: marker, wearable: wearable, windowDays: 3) + XCTAssertEqual(pairs.count, 1) + XCTAssertEqual(pairs[0].day, "2026-01-15") + XCTAssertEqual(pairs[0].markerValue, 3.1, accuracy: 1e-9) + XCTAssertEqual(pairs[0].wearableMean, (50.0 + 52.0 + 54.0) / 3.0, accuracy: 1e-9) // 52.0 + XCTAssertEqual(pairs[0].wearableN, 3) + } + + func testWindowedPairDropsNoCoverageDay() { + // Marker on a day with NO wearable point inside the trailing window → dropped. + let marker = [ + (day: "2026-01-15", value: 3.1), // covered + (day: "2026-06-01", value: 2.9), // no wearable anywhere near → dropped + ] + let wearable = [ + (day: "2026-01-14", value: 52.0), + (day: "2026-01-15", value: 54.0), + ] + let pairs = LabBookProjection.pairMarkerToWearable(marker: marker, wearable: wearable, windowDays: 14) + XCTAssertEqual(pairs.map { $0.day }, ["2026-01-15"], "no-coverage reading dropped") + } + + func testWindowedPairWindowWidths() { + // Same data, widths 7/14/30 give different trailing means. + let marker = [(day: "2026-02-01", value: 100.0)] + let wearable = [ + (day: "2026-01-05", value: 10.0), // 27 days back → only in width 30 + (day: "2026-01-20", value: 20.0), // 12 days back → in 14 and 30 + (day: "2026-01-29", value: 30.0), // 3 days back → in 7, 14, 30 + (day: "2026-02-01", value: 40.0), // same day → all widths + ] + let w7 = LabBookProjection.pairMarkerToWearable(marker: marker, wearable: wearable, windowDays: 7) + XCTAssertEqual(w7[0].wearableMean, (30.0 + 40.0) / 2.0, accuracy: 1e-9) // 35 + XCTAssertEqual(w7[0].wearableN, 2) + + let w14 = LabBookProjection.pairMarkerToWearable(marker: marker, wearable: wearable, windowDays: 14) + XCTAssertEqual(w14[0].wearableMean, (20.0 + 30.0 + 40.0) / 3.0, accuracy: 1e-9) // 30 + XCTAssertEqual(w14[0].wearableN, 3) + + let w30 = LabBookProjection.pairMarkerToWearable(marker: marker, wearable: wearable, windowDays: 30) + XCTAssertEqual(w30[0].wearableMean, (10.0 + 20.0 + 30.0 + 40.0) / 4.0, accuracy: 1e-9) // 25 + XCTAssertEqual(w30[0].wearableN, 4) + } + + // MARK: - pairs feed CorrelationEngine.pearson unchanged + + func testCorrelationInputFeedsPearson() { + // Four marker readings, each paired to a same-day wearable value (window 1). + // x = marker, y = wearable. Perfect positive line y = 10x → r = 1. + let marker = [ + (day: "2026-01-01", value: 1.0), + (day: "2026-01-08", value: 2.0), + (day: "2026-01-15", value: 3.0), + (day: "2026-01-22", value: 4.0), + ] + let wearable = [ + (day: "2026-01-01", value: 10.0), + (day: "2026-01-08", value: 20.0), + (day: "2026-01-15", value: 30.0), + (day: "2026-01-22", value: 40.0), + ] + let pairs = LabBookProjection.pairMarkerToWearable(marker: marker, wearable: wearable, windowDays: 1) + XCTAssertEqual(pairs.count, 4) + let corr = CorrelationEngine.pearson(LabBookProjection.correlationInput(pairs)) + XCTAssertNotNil(corr) + XCTAssertEqual(corr!.n, 4) + XCTAssertEqual(corr!.r, 1.0, accuracy: 1e-9) + XCTAssertEqual(corr!.slope, 10.0, accuracy: 1e-9) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift new file mode 100644 index 0000000000..31131e97d9 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol +import WhoopStore + +/// Bug #977 (iOS, WHOOP 5.0, live Bluetooth): "Rest score stuck 93 since forever." +/// +/// Root cause under test: a LIVE WHOOP 5.0 streams standard 0x2A37 HR continuously, but +/// the accelerometer / gravity stream is only ever populated by the *history offload* +/// (Backfiller). When the overnight gravity has not been offloaded/decoded, the day reaches +/// the scoring loop with dense HR (so it clears IntelligenceEngine's `hr.count >= 200` gate +/// and recovery/Charge can still be computed from HR/HRV), but `SleepStager.detectSleep` +/// bails at `grav.count < 2 { return [] }` — so no sleep is matched, the DailyMetric carries +/// no `totalSleepMin`/`efficiency`, and `AnalyticsEngine.Rest.composite(daily:)` returns nil. +/// +/// A nil composite means NO `sleep_performance` point is written for that night, so the Today +/// display falls back to the tail of the series (iOS `restSeries.last`, Android +/// `byDay.entries.maxByOrNull`) — the last night that WAS scored, e.g. 93 — and Rest is frozen +/// there forever while Charge keeps advancing. +/// +/// These tests pin the mechanism. They are written to FAIL against a fix that makes a live-5.0 +/// night produce SOME advancing Rest signal (whether by an HR-only fallback composite or by +/// forcing a gravity offload before scoring). Today they document the frozen state. +final class Live5RestFrozenTests: XCTestCase { + + private func hrStream(start: Int, durationS: Int, bpm: Int) -> [HRSample] { + stride(from: 0, to: durationS, by: 1).map { HRSample(ts: start + $0, bpm: bpm) } + } + + /// A late-night start (02:00 UTC, tzOffset 0) so the window is unambiguously overnight and + /// the daytime false-sleep guard is irrelevant to the outcome. + private func nightStart() -> Int { + let refMidnight = 1_749_513_600 // 2026-06-10 00:00:00 UTC + return refMidnight + 2 * 3_600 + } + + // MARK: - Stage 1: no gravity ⇒ no sleep session (the direct BLE-live-5.0 shape) + + func testLive5NoGravityDenseHRDetectsNoSleep() { + // 8 h of continuous, sleep-plausible HR (a real live-5.0 night streamed over 0x2A37), + // but ZERO gravity because the accelerometer offload hasn't landed. detectSleep must + // return no sessions — the strap streamed HR but not motion. + let start = nightStart() + let dur = 8 * 60 * 60 + let hr = hrStream(start: start, durationS: dur, bpm: 50) + let sessions = SleepStager.detectSleep(hr: hr, gravity: []) + XCTAssertTrue(sessions.isEmpty, + "A live-5.0 night with HR but no offloaded gravity yields no sleep session") + } + + // MARK: - Stage 2: no sleep ⇒ no Rest composite ⇒ no sleep_performance point + + /// A DailyMetric shaped exactly as `analyzeDay` leaves it when `matched` is empty: HRV/RHR + /// present (so Charge can still be scored) but no sleep aggregates. This is the row a live-5.0 + /// day produces when gravity never offloaded. + private func chargeableButUnsleptDaily(day: String) -> DailyMetric { + DailyMetric(day: day, + totalSleepMin: nil, // absent ⇒ no Rest composite + efficiency: nil, // absent ⇒ no Rest composite + deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, + restingHr: 52, // present ⇒ recovery/Charge advances + avgHrv: 65, // present ⇒ recovery/Charge advances + recovery: nil, strain: nil, exerciseCount: nil, + spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil) + } + + func testUnsleptDailyProducesNilRestComposite() { + let daily = chargeableButUnsleptDaily(day: "2026-07-02") + // This is the exact guard at AnalyticsEngine.Rest.composite(daily:) line 696: + // guard let tstMin = d.totalSleepMin, tstMin > 0, let eff = d.efficiency else { return nil } + XCTAssertNil(AnalyticsEngine.Rest.composite(daily: daily), + "No sleep aggregates ⇒ Rest.composite(daily:) is nil ⇒ no sleep_performance point written") + } + + // MARK: - Stage 3: the display-side freeze this produces + + /// Reproduces the Today resolver's tail fallback (iOS LiquidTodayView.swift line 777 / + /// Android TodayScreen.kt line 689): when today has no `sleep_performance` row, both + /// platforms fall back to the latest value in the series. If new nights never write a row, + /// that latest value is pinned to the last night that WAS scored — 93 — forever. + // MARK: - The FIX CONTRACT (fails today, passes once #977 is fixed) + + /// The contract a fix must satisfy: a live-5.0 day that has HRV/RHR (Charge advances) but no + /// staged sleep must NOT silently produce a nil Rest signal that freezes the display. Whatever + /// the fix (an HR-only degraded Rest fallback for a chargeable-but-unslept day, OR forcing a + /// gravity offload before scoring so `matched` is non-empty), the observable requirement is: + /// a chargeable day yields SOME non-nil Rest signal so Today advances instead of tailing. + /// + /// This intentionally FAILS against today's code (Rest.composite(daily:) returns nil for such a + /// day). Delete the XCTExpectFailure wrapper when the fix lands. + func testChargeableDayShouldYieldAdvancingRestSignal_FIX() { + XCTExpectFailure("#977 not yet fixed: a chargeable-but-unslept live-5.0 day yields a nil Rest signal") { + let daily = chargeableButUnsleptDaily(day: "2026-07-02") + XCTAssertNotNil(AnalyticsEngine.Rest.composite(daily: daily), + "A day that can score Charge must also surface SOME Rest signal, not freeze") + } + } + + func testTodayRestFreezesOnTailFallbackWhenNoNewPointWritten() { + // sleep_performance series that stopped advancing days ago (last computed night = 93). + let restByDay: [String: Double] = [ + "2026-06-25": 88, + "2026-06-26": 91, + "2026-06-27": 93, // the last night gravity offloaded + scored + ] + let seriesTail = restByDay.max(by: { $0.key < $1.key })?.value // == restSeries.last / maxByOrNull + + // Several later days ran (Charge advanced) but wrote NO sleep_performance row. + for today in ["2026-06-28", "2026-06-29", "2026-06-30", "2026-07-01", "2026-07-02"] { + let restToday = restByDay[today] ?? seriesTail // offset 0 tail fallback + XCTAssertEqual(restToday, 93, + "\(today): Today shows the frozen tail (93), never a fresh score") + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiveSessionEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiveSessionEngineTests.swift new file mode 100644 index 0000000000..49deb045ad --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiveSessionEngineTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the `LiveSessionEngine` "silent guardian" behaviour: the recovery-gated band curve and the cue state +/// machine (warm-up silence, in-band silence, push/ease dwell + cool-down, slow-drift suppression, never- +/// fabricate rejection, staleness). Pure value logic — a synthetic HR trace replays deterministically, no +/// strap/BLE seam. GOLDEN VECTORS the Kotlin `LiveSessionEngineTest` mirrors. +/// Design contract: docs/superpowers/specs/2026-07-04-live-sessions-design.md. +final class LiveSessionEngineTests: XCTestCase { + + private let rhr = 55.0 + private let hrMax = 190.0 // reserve = 135 + + private func cfg(charge: Double?) -> LiveSessionEngine.Config { + LiveSessionEngine.Config(restingHR: rhr, hrMax: hrMax, charge: charge) + } + + /// Feed a constant bpm at 1 Hz for `seconds` updates starting at `fromTs`; collect every Output. + private func feed(_ e: inout LiveSessionEngine, bpm: Int?, fromTs: Int, seconds: Int) -> [LiveSessionEngine.Output] { + (0.. 170 over 120 s (~3 bpm / 15 s, under the step-change threshold). + var outs: [LiveSessionEngine.Output] = [] + for i in 0..<120 { + let bpm = 145 + Int(Double(i) * (25.0 / 120.0)) + outs.append(e.update(now: 1070 + i, bpm: bpm)) + } + XCTAssertFalse(outs.contains { $0.cue == .easeOff }, "honest slow drift is not a mistake to punish") + } + + func test_reentry_into_band_is_silent() { + var e = LiveSessionEngine(config: cfg(charge: nil), startTs: 1000) + _ = feed(&e, bpm: 110, fromTs: 1000, seconds: 90) // triggers a push + let back = feed(&e, bpm: 140, fromTs: 1090, seconds: 40) // return to band + XCTAssertTrue(back.allSatisfy { $0.cue == nil }, "crossing back into band never buzzes") + XCTAssertTrue(back.suffix(20).allSatisfy { $0.position == .inBand }) + } + + func test_impossible_sample_is_rejected() { + var e = LiveSessionEngine(config: cfg(charge: nil), startTs: 0) + _ = feed(&e, bpm: 140, fromTs: 0, seconds: 20) + let out = e.update(now: 20, bpm: 250) // 250 > hrMax + margin + XCTAssertFalse(out.sampleArrived, "an above-HRmax reading is not accepted") + XCTAssertEqual(out.smoothedBpm ?? 0, 140, accuracy: 2.0, "the trend is untouched by the artifact") + } + + func test_stream_dropout_goes_stale_and_pauses_coaching() { + var e = LiveSessionEngine(config: cfg(charge: nil), startTs: 0) + _ = feed(&e, bpm: 110, fromTs: 0, seconds: 20) // below band, would be building toward a push + let out = e.update(now: 40, bpm: nil) // 20 s with no reading + XCTAssertEqual(out.status, .stale) + XCTAssertNil(out.smoothedBpm) + XCTAssertNil(out.cue, "we never buzz on a stale stream") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ManualWorkoutRescoreTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ManualWorkoutRescoreTests.swift new file mode 100644 index 0000000000..352b3611ad --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ManualWorkoutRescoreTests.swift @@ -0,0 +1,109 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// #137 — the pure re-score logic: recompute an under-sampled manual workout's metrics from the denser +/// HR now available for its window, conservatively and idempotently. +final class ManualWorkoutRescoreTests: XCTestCase { + + private let profile = UserProfile(weightKg: 80, heightCm: 180, age: 30, sex: "male") + + /// A dense, moderately-hard window scores real calories + strain (the 5/MG case after offload). + func testScoresDenseWindow() { + // 20 minutes at ~140 bpm, 1 Hz. + let samples = (0..<1200).map { HRSample(ts: 1_000 + $0, bpm: 140) } + let s = ManualWorkoutRescore.scored(windowSamples: samples, profile: profile, hrMax: 190) + XCTAssertNotNil(s) + XCTAssertEqual(s?.avgHr, 140) + XCTAssertEqual(s?.maxHr, 140) + XCTAssertNotNil(s?.kcal) + XCTAssertGreaterThan(s?.kcal ?? 0, 50) // a 20-min Z3 bout burns well over 50 kcal + XCTAssertNotNil(s?.strain) + } + + /// #499 — Avg HR is the TRUE arithmetic mean of the actual HR trace, not a zone-weighted or partial + /// estimate. This is the property that keeps the displayed average consistent with the graph / zones + /// / effort (all of which read the same per-second samples). A varied trace (so a zone-weighted or + /// truncated average would give a different answer) must still come out as the plain mean. + func testAvgHrIsTrueMeanOfVariedTrace() { + // Asymmetric ramp: 60 s climbing 100→159 then 60 s at 180. Plain mean ≠ midpoint, ≠ peak, ≠ any + // zone-weighted figure — only the arithmetic mean of every sample is correct. + let climb = (0..<60).map { HRSample(ts: 1_000 + $0, bpm: 100 + $0) } // 100,101,…,159 + let hold = (0..<60).map { HRSample(ts: 1_060 + $0, bpm: 180) } // 180 ×60 + let samples = climb + hold + let expectedMean = Int((Double(samples.map(\.bpm).reduce(0, +)) / Double(samples.count)).rounded()) + let s = ManualWorkoutRescore.scored(windowSamples: samples, profile: profile, hrMax: 190) + XCTAssertEqual(s?.avgHr, expectedMean) // == mean of the trace (154.75 → 155) + XCTAssertEqual(s?.maxHr, 180) // == true peak of the trace + XCTAssertNotEqual(s?.avgHr, 180) // NOT the peak + XCTAssertNotEqual(s?.avgHr, (100 + 180) / 2) // NOT the min/max midpoint + } + + /// Too few samples → nil (nothing better than what we had; never fabricate from one reading). + func testTooFewSamplesReturnsNil() { + XCTAssertNil(ManualWorkoutRescore.scored(windowSamples: [HRSample(ts: 1, bpm: 130)], + profile: profile, hrMax: 190)) + XCTAssertNil(ManualWorkoutRescore.scored(windowSamples: [], profile: profile, hrMax: 190)) + } + + /// The under-scored gate: only missing/negligible calories qualify; a normal workout never does. + func testLooksUnderScoredGate() { + XCTAssertTrue(ManualWorkoutRescore.looksUnderScored(currentKcal: nil)) + XCTAssertTrue(ManualWorkoutRescore.looksUnderScored(currentKcal: 1.0)) // the "1 kcal" symptom + XCTAssertTrue(ManualWorkoutRescore.looksUnderScored(currentKcal: 5.0)) + XCTAssertFalse(ManualWorkoutRescore.looksUnderScored(currentKcal: 5.01)) + XCTAssertFalse(ManualWorkoutRescore.looksUnderScored(currentKcal: 250)) // a real session + } + + /// Only persists a strict improvement — so a sparse-window recompute (≈ current) is a no-op, the + /// pass is idempotent, and it can never *lower* a workout's numbers. + func testImprovesIsStrictAndMonotonic() { + let big = ManualWorkoutRescore.Scored(avgHr: 140, maxHr: 150, strain: 12, kcal: 220) + XCTAssertTrue(ManualWorkoutRescore.improves(big, over: nil)) + XCTAssertTrue(ManualWorkoutRescore.improves(big, over: 1)) + XCTAssertFalse(ManualWorkoutRescore.improves(big, over: 220)) // already this good → no churn + XCTAssertFalse(ManualWorkoutRescore.improves(big, over: 219.5)) // within the margin → no churn + + let none = ManualWorkoutRescore.Scored(avgHr: 0, maxHr: 0, strain: nil, kcal: nil) + XCTAssertFalse(ManualWorkoutRescore.improves(none, over: 1)) // no recompute ⇒ never replace + } + + /// The merged-row case: a merged workout's kcal is the SUM of its inputs, so it never looks + /// under-scored, yet WorkoutMerge leaves its strain nil. A recompute that produces a strain must be + /// accepted as a STRAIN-ONLY improvement even when its kcal does NOT beat the summed value, otherwise + /// Effort stays blank forever. And once strain exists, a re-run is a no-op (idempotent). + func testStrainOnlyImprovementFillsMergedRow() { + // Recompute yields a strain but a MODEST kcal that does not beat the merged sum (e.g. 300). + let recomputed = ManualWorkoutRescore.Scored(avgHr: 130, maxHr: 150, strain: 9, kcal: 120) + let summedKcal: Double? = 300 // merged: SUM of inputs, well past the under-scored gate + + // Strain missing on the stored row → accept (fill Effort), even though kcal < summed sum. + XCTAssertTrue(ManualWorkoutRescore.improves(recomputed, over: summedKcal, + currentStrain: nil, allowStrainOnlyFill: true)) + // Strain already present → no churn (kcal doesn't beat the sum, strain isn't missing). + XCTAssertFalse(ManualWorkoutRescore.improves(recomputed, over: summedKcal, + currentStrain: 9, allowStrainOnlyFill: true)) + + // A recompute with NO strain can't fill anything → still no-op. + let noStrain = ManualWorkoutRescore.Scored(avgHr: 0, maxHr: 0, strain: nil, kcal: 120) + XCTAssertFalse(ManualWorkoutRescore.improves(noStrain, over: summedKcal, + currentStrain: nil, allowStrainOnlyFill: true)) + + // The strain-only path is OPT-IN: without the flag the contract is unchanged (kcal-only), so a + // missing-strain row does NOT qualify on a 2-arg call, and the existing rescore path is untouched. + XCTAssertFalse(ManualWorkoutRescore.improves(recomputed, over: summedKcal)) + XCTAssertFalse(ManualWorkoutRescore.improves(recomputed, over: summedKcal, currentStrain: nil)) + } + + /// End-to-end shape of the fix: a workout saved with ~1 kcal (sparse live HR) gets rescored from a + /// dense offloaded window, and the result both clears the under-scored gate and improves. + func testUnderScoredWorkoutGetsRescoredFromDenseWindow() { + let stored: Double? = 1.0 + XCTAssertTrue(ManualWorkoutRescore.looksUnderScored(currentKcal: stored)) + let dense = (0..<900).map { HRSample(ts: 2_000 + $0, bpm: 150) } // 15 min @150 + let s = ManualWorkoutRescore.scored(windowSamples: dense, profile: profile, hrMax: 190)! + XCTAssertTrue(ManualWorkoutRescore.improves(s, over: stored)) + // And it's idempotent: re-running over the now-good value is a no-op. + XCTAssertFalse(ManualWorkoutRescore.improves(s, over: s.kcal)) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RangeReportTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RangeReportTests.swift new file mode 100644 index 0000000000..3a78da7a7d --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RangeReportTests.swift @@ -0,0 +1,346 @@ +import XCTest +@testable import StrandAnalytics + +/// RangeReportEngine — the data model for a shareable offline trends report over a date +/// range. The oracle for the Android RangeReportTest; keep the two in lockstep (same +/// fixtures, same assertions — cross-platform parity is the contract). +final class RangeReportTests: XCTestCase { + + // A clean +10/day recovery ramp across four days. + private let recoveryRamp: [String: Double] = [ + "2026-06-01": 40, + "2026-06-02": 50, + "2026-06-03": 60, + "2026-06-04": 70, + ] + + // MARK: - Known series → correct mean / min / max / halves / trend + + func testKnownSeriesStats() { + let report = RangeReportEngine.build(metrics: [.recovery: recoveryRamp], + start: "2026-06-01", end: "2026-06-04") + XCTAssertEqual(report.start, "2026-06-01") + XCTAssertEqual(report.end, "2026-06-04") + XCTAssertEqual(report.totalDays, 4) + XCTAssertFalse(report.isEmpty) + + let s = report.stat(.recovery)! + XCTAssertEqual(s.n, 4) + XCTAssertEqual(s.mean, 55, accuracy: 1e-9) + // Halves split by position: [40,50] vs [60,70]. + XCTAssertEqual(s.firstHalfMean, 45, accuracy: 1e-9) + XCTAssertEqual(s.secondHalfMean, 65, accuracy: 1e-9) + XCTAssertEqual(s.halfDelta, 20, accuracy: 1e-9) + // A clean +10/day ramp is rising. + XCTAssertEqual(s.trend, .rising) + XCTAssertEqual(s.latest.day, "2026-06-04") + XCTAssertEqual(s.latest.value, 70, accuracy: 1e-9) + } + + // MARK: - Min / max carry the right day + + func testMinMaxCarryRightDay() { + let series: [String: Double] = [ + "2026-06-01": 55, + "2026-06-02": 40, // min + "2026-06-03": 70, // max + "2026-06-04": 50, + ] + let s = RangeReportEngine.build(metrics: [.hrv: series], + start: "2026-06-01", end: "2026-06-04").stat(.hrv)! + XCTAssertEqual(s.min.day, "2026-06-02") + XCTAssertEqual(s.min.value, 40, accuracy: 1e-9) + XCTAssertEqual(s.max.day, "2026-06-03") + XCTAssertEqual(s.max.value, 70, accuracy: 1e-9) + } + + // MARK: - Missing metric is omitted + + func testMissingMetricOmitted() { + // Only recovery is supplied → strain/hrv/etc. are absent, not zeroed. + let report = RangeReportEngine.build(metrics: [.recovery: recoveryRamp], + start: "2026-06-01", end: "2026-06-04") + XCTAssertNotNil(report.stat(.recovery)) + XCTAssertNil(report.stat(.strain)) + XCTAssertNil(report.stat(.hrv)) + XCTAssertNil(report.stat(.restingHr)) + XCTAssertNil(report.stat(.sleepHours)) + XCTAssertEqual(report.metrics.count, 1) + } + + // MARK: - Out-of-range days are excluded + + func testOutOfRangeDaysExcluded() { + let series: [String: Double] = [ + "2026-05-31": 99, // before start — excluded + "2026-06-01": 50, + "2026-06-02": 60, + "2026-06-05": 99, // after end — excluded + ] + let s = RangeReportEngine.build(metrics: [.recovery: series], + start: "2026-06-01", end: "2026-06-02").stat(.recovery)! + XCTAssertEqual(s.n, 2) + XCTAssertEqual(s.mean, 55, accuracy: 1e-9) + XCTAssertEqual(s.min.value, 50, accuracy: 1e-9) + XCTAssertEqual(s.max.value, 60, accuracy: 1e-9) + } + + // MARK: - Single-day range + + func testSingleDayRange() { + let report = RangeReportEngine.build(metrics: [.recovery: ["2026-06-01": 50]], + start: "2026-06-01", end: "2026-06-01") + XCTAssertEqual(report.totalDays, 1) + let s = report.stat(.recovery)! + XCTAssertEqual(s.n, 1) + XCTAssertEqual(s.mean, 50, accuracy: 1e-9) + XCTAssertEqual(s.min.day, "2026-06-01") + XCTAssertEqual(s.max.day, "2026-06-01") + XCTAssertEqual(s.latest.day, "2026-06-01") + // One value → both halves equal it, no fabricated movement. + XCTAssertEqual(s.firstHalfMean, 50, accuracy: 1e-9) + XCTAssertEqual(s.secondHalfMean, 50, accuracy: 1e-9) + XCTAssertEqual(s.trend, .flat) + } + + // MARK: - Empty → empty report + + func testEmptyMetricsGivesEmptyReport() { + let report = RangeReportEngine.build(metrics: [:], + start: "2026-06-01", end: "2026-06-04") + XCTAssertTrue(report.isEmpty) + XCTAssertEqual(report.metrics.count, 0) + XCTAssertEqual(report.headlines.count, 0) + XCTAssertEqual(report.totalDays, 4) // the WINDOW is still 4 days wide + } + + func testAllSeriesOutOfRangeGivesEmptyReport() { + // Data exists but none lands in the window → empty report. + let series: [String: Double] = ["2026-01-01": 50, "2026-12-31": 60] + let report = RangeReportEngine.build(metrics: [.recovery: series], + start: "2026-06-01", end: "2026-06-04") + XCTAssertTrue(report.isEmpty) + } + + // MARK: - Inverted range + + func testInvertedRangeIsEmpty() { + // end before start → empty report, 0 days. + let report = RangeReportEngine.build(metrics: [.recovery: recoveryRamp], + start: "2026-06-04", end: "2026-06-01") + XCTAssertTrue(report.isEmpty) + XCTAssertEqual(report.totalDays, 0) + } + + // MARK: - Trend rising / falling / flat thresholds + + func testTrendRising() { + let s = RangeReportEngine.build(metrics: [.recovery: recoveryRamp], + start: "2026-06-01", end: "2026-06-04").stat(.recovery)! + XCTAssertEqual(s.trend, .rising) + } + + func testTrendFalling() { + let falling: [String: Double] = [ + "2026-06-01": 70, + "2026-06-02": 60, + "2026-06-03": 50, + "2026-06-04": 40, + ] + let s = RangeReportEngine.build(metrics: [.recovery: falling], + start: "2026-06-01", end: "2026-06-04").stat(.recovery)! + XCTAssertEqual(s.trend, .falling) + } + + func testTrendFlatWhenLevel() { + // A dead-level series has slope 0 < threshold → flat. + let level: [String: Double] = [ + "2026-06-01": 60, + "2026-06-02": 60, + "2026-06-03": 60, + "2026-06-04": 60, + ] + let s = RangeReportEngine.build(metrics: [.recovery: level], + start: "2026-06-01", end: "2026-06-04").stat(.recovery)! + XCTAssertEqual(s.trend, .flat) + } + + func testTrendFlatWhenSlopeBelowThreshold() { + // recovery threshold is 0.5 pts/day. A +0.1/day drift (60.0 → 60.3) is noise → flat. + let drift: [String: Double] = [ + "2026-06-01": 60.0, + "2026-06-02": 60.1, + "2026-06-03": 60.2, + "2026-06-04": 60.3, + ] + let s = RangeReportEngine.build(metrics: [.recovery: drift], + start: "2026-06-01", end: "2026-06-04").stat(.recovery)! + XCTAssertEqual(s.trend, .flat) + } + + // MARK: - Trend uses the metric's OWN threshold + + func testTrendThresholdIsPerMetric() { + // A +0.1/day climb is FLAT for recovery (thr 0.5) but RISING for sleepHours + // (thr 0.05), proving the threshold is metric-specific. + let drift: [String: Double] = [ + "2026-06-01": 7.0, + "2026-06-02": 7.1, + "2026-06-03": 7.2, + "2026-06-04": 7.3, + ] + let recov = RangeReportEngine.build(metrics: [.recovery: drift], + start: "2026-06-01", end: "2026-06-04").stat(.recovery)! + let sleep = RangeReportEngine.build(metrics: [.sleepHours: drift], + start: "2026-06-01", end: "2026-06-04").stat(.sleepHours)! + XCTAssertEqual(recov.trend, .flat) + XCTAssertEqual(sleep.trend, .rising) + } + + // MARK: - Odd count: second half gets the extra day + + func testOddCountSplitsToSecondHalf() { + // 3 days: mid = 1 → firstHalf [50], secondHalf [60,70]. + let series: [String: Double] = [ + "2026-06-01": 50, + "2026-06-02": 60, + "2026-06-03": 70, + ] + let s = RangeReportEngine.build(metrics: [.recovery: series], + start: "2026-06-01", end: "2026-06-03").stat(.recovery)! + XCTAssertEqual(s.firstHalfMean, 50, accuracy: 1e-9) + XCTAssertEqual(s.secondHalfMean, 65, accuracy: 1e-9) + } + + // MARK: - Multiple metrics + headlines + + func testMultipleMetricsAndHeadlines() { + let recovery: [String: Double] = [ + "2026-06-01": 40, "2026-06-02": 50, "2026-06-03": 60, "2026-06-04": 70, + ] + let rhr: [String: Double] = [ // resting HR rising = a bad sign + "2026-06-01": 50, "2026-06-02": 52, "2026-06-03": 54, "2026-06-04": 56, + ] + let report = RangeReportEngine.build( + metrics: [.recovery: recovery, .restingHr: rhr], + start: "2026-06-01", end: "2026-06-04") + XCTAssertEqual(report.metrics.count, 2) + // One headline per present metric. + XCTAssertEqual(report.headlines.count, 2) + // Recovery half-move (45→65, +20) dwarfs RHR's (51→55, +4) → ranked first. + XCTAssertTrue(report.headlines[0].contains("Recovery")) + XCTAssertTrue(report.headlines[0].contains("good sign")) + // RHR rose, and higher RHR is worse → "worth a look". + XCTAssertTrue(report.headlines[1].contains("Resting HR")) + XCTAssertTrue(report.headlines[1].contains("worth a look")) + } + + // MARK: - Respiratory rate (lower is better; a rising trend is "worth a look") + + func testRespiratoryRateRisingIsWorthALook() { + // A +0.5 br/min/day climb (thr 0.1) → rising. Higher resting resp = worse. + let resp: [String: Double] = [ + "2026-06-01": 14.0, "2026-06-02": 14.5, "2026-06-03": 15.0, "2026-06-04": 15.5, + ] + let s = RangeReportEngine.build(metrics: [.respRate: resp], + start: "2026-06-01", end: "2026-06-04").stat(.respRate)! + XCTAssertEqual(s.trend, .rising) + XCTAssertEqual(s.mean, 14.75, accuracy: 1e-9) + XCTAssertEqual(ReportMetric.respRate.unit, "br/min") + XCTAssertFalse(ReportMetric.respRate.higherIsBetter) // lower resting resp is better + let line = RangeReportEngine.headline(s) + XCTAssertTrue(line.contains("Respiratory rate")) + XCTAssertTrue(line.contains("worth a look")) // rose + lower-is-better + } + + // MARK: - Skin-temp Δ is valence-free (no good/bad framing, even on a clear trend) + + func testSkinTempDeviationHasNoGoodBadFrame() { + // A +0.1 °C/day climb (thr 0.03) → clearly rising, but skin-temp Δ carries no + // inherent good/bad direction, so the headline states the move WITHOUT a verdict. + let skin: [String: Double] = [ + "2026-06-01": 0.0, "2026-06-02": 0.1, "2026-06-03": 0.2, "2026-06-04": 0.3, + ] + let s = RangeReportEngine.build(metrics: [.skinTempDev: skin], + start: "2026-06-01", end: "2026-06-04").stat(.skinTempDev)! + XCTAssertEqual(s.trend, .rising) + XCTAssertEqual(ReportMetric.skinTempDev.unit, "°C") + XCTAssertFalse(ReportMetric.skinTempDev.framesGoodBad) + let line = RangeReportEngine.headline(s) + XCTAssertTrue(line.contains("Skin temp")) + XCTAssertTrue(line.contains("trending up")) + XCTAssertFalse(line.contains("good sign")) // no verdict either way + XCTAssertFalse(line.contains("worth a look")) + } + + // MARK: - Workouts + Stress rows (#457) + + /// Both new rows appear, and they LEAD the report in the requested order: Workouts + /// first, then Stress, ahead of every physiological metric. + func testWorkoutsAndStressRowsLeadInOrder() { + let workouts: [String: Double] = [ + "2026-06-01": 0, "2026-06-02": 1, "2026-06-03": 1, "2026-06-04": 2, + ] + let stress: [String: Double] = [ // 0–3 score, drifting up + "2026-06-01": 1.0, "2026-06-02": 1.2, "2026-06-03": 1.4, "2026-06-04": 1.6, + ] + let recovery: [String: Double] = [ + "2026-06-01": 40, "2026-06-02": 50, "2026-06-03": 60, "2026-06-04": 70, + ] + let report = RangeReportEngine.build( + metrics: [.workouts: workouts, .stress: stress, .recovery: recovery], + start: "2026-06-01", end: "2026-06-04") + XCTAssertNotNil(report.stat(.workouts)) + XCTAssertNotNil(report.stat(.stress)) + // metrics is emitted in allCases order → Workouts, then Stress, then Recovery. + XCTAssertEqual(report.metrics.map(\.metric), [.workouts, .stress, .recovery]) + } + + /// Workouts is valence-free: a clear trend states the move WITHOUT a good/bad verdict. + func testWorkoutsRowHasNoGoodBadFrame() { + // +1 workout/day vs the 0.03 threshold → rising, but logging more sessions carries no + // inherent good/bad valence, so the headline omits a verdict. + let workouts: [String: Double] = [ + "2026-06-01": 0, "2026-06-02": 1, "2026-06-03": 2, "2026-06-04": 3, + ] + let s = RangeReportEngine.build(metrics: [.workouts: workouts], + start: "2026-06-01", end: "2026-06-04").stat(.workouts)! + XCTAssertEqual(s.trend, .rising) + XCTAssertEqual(s.mean, 1.5, accuracy: 1e-9) + XCTAssertEqual(ReportMetric.workouts.unit, "/day") + XCTAssertFalse(ReportMetric.workouts.framesGoodBad) + let line = RangeReportEngine.headline(s) + XCTAssertTrue(line.contains("Workouts")) + XCTAssertTrue(line.contains("trending up")) + XCTAssertFalse(line.contains("good sign")) + XCTAssertFalse(line.contains("worth a look")) + } + + /// Stress: lower is better, so a rising daily stress score reads as "worth a look". + func testStressRisingIsWorthALook() { + // +0.2/day vs the 0.02 threshold → rising. Higher stress is worse. + let stress: [String: Double] = [ + "2026-06-01": 1.0, "2026-06-02": 1.2, "2026-06-03": 1.4, "2026-06-04": 1.6, + ] + let s = RangeReportEngine.build(metrics: [.stress: stress], + start: "2026-06-01", end: "2026-06-04").stat(.stress)! + XCTAssertEqual(s.trend, .rising) + XCTAssertEqual(s.mean, 1.3, accuracy: 1e-9) + XCTAssertEqual(ReportMetric.stress.unit, "") + XCTAssertTrue(ReportMetric.stress.usesOneDecimal) // 0–3 score shown to one decimal + XCTAssertFalse(ReportMetric.stress.higherIsBetter) // calmer is better + let line = RangeReportEngine.headline(s) + XCTAssertTrue(line.contains("Stress")) + XCTAssertTrue(line.contains("worth a look")) // rose + lower-is-better + } + + // MARK: - Determinism + + func testDeterministic() { + let a = RangeReportEngine.build(metrics: [.recovery: recoveryRamp], + start: "2026-06-01", end: "2026-06-04") + let b = RangeReportEngine.build(metrics: [.recovery: recoveryRamp], + start: "2026-06-01", end: "2026-06-04") + XCTAssertEqual(a, b) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ReadinessEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ReadinessEngineTests.swift new file mode 100644 index 0000000000..6ac13b4496 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ReadinessEngineTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import StrandAnalytics +import WhoopStore + +final class ReadinessEngineTests: XCTestCase { + + private func d(_ i: Int, hrv: Double?, rhr: Int?, strain: Double?, resp: Double? = nil) -> DailyMetric { + DailyMetric(day: String(format: "2024-03-%02d", i), totalSleepMin: nil, efficiency: nil, + deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, restingHr: rhr, + avgHrv: hrv, recovery: nil, strain: strain, exerciseCount: nil, + spo2Pct: nil, skinTempDevC: nil, respRateBpm: resp) + } + + /// 28 baseline days with gentle variation (so SD > 0), then `today` as day 29. + private func baseline(todayHrv: Double?, todayRhr: Int?, todayStrain: Double?, + todayResp: Double? = nil, baseStrain: Double = 10) -> [DailyMetric] { + var days: [DailyMetric] = [] + for i in 1...28 { + days.append(d(i, hrv: i % 2 == 0 ? 62 : 58, rhr: i % 2 == 0 ? 54 : 50, + strain: baseStrain, resp: i % 2 == 0 ? 14.5 : 13.5)) + } + days.append(d(29, hrv: todayHrv, rhr: todayRhr, strain: todayStrain, resp: todayResp)) + return days + } + + func testInsufficientWhenEmpty() { + XCTAssertEqual(ReadinessEngine.evaluate(days: []).level, .insufficient) + } + + func testPrimedWhenSignalsAligned() { + // Today: HRV well above baseline, resting HR below, load steady. + let r = ReadinessEngine.evaluate(days: baseline(todayHrv: 72, todayRhr: 46, todayStrain: 10)) + XCTAssertEqual(r.level, .primed) + XCTAssertEqual(r.signals.first { $0.key == "hrv" }?.flag, .good) + XCTAssertEqual(r.signals.first { $0.key == "rhr" }?.flag, .good) + XCTAssertEqual(r.signals.first { $0.key == "acwr" }?.flag, .good) + XCTAssertEqual(r.signals.first { $0.key == "hrv" }?.evidence, "72 vs 60 ms") + XCTAssertEqual(r.signals.first { $0.key == "rhr" }?.evidence, "46 vs 52 bpm") + XCTAssertEqual(r.signals.first { $0.key == "acwr" }?.evidence, "7d 10.0 / 28d 10.0") + } + + func testRundownWhenTwoRecoverySignalsDown() { + // Today: HRV suppressed AND resting HR elevated → two "bad" recovery signals. + let r = ReadinessEngine.evaluate(days: baseline(todayHrv: 50, todayRhr: 60, todayStrain: 10)) + XCTAssertEqual(r.level, .rundown) + } + + func testAcwrSpikeStrains() { + // Recovery signals neutral, but acute load spikes above chronic. + var days: [DailyMetric] = [] + for i in 1...21 { days.append(d(i, hrv: 60, rhr: 52, strain: 5)) } + for i in 22...28 { days.append(d(i, hrv: 60, rhr: 52, strain: 15)) } + days.append(d(29, hrv: 60, rhr: 52, strain: 15)) + let r = ReadinessEngine.evaluate(days: days) + XCTAssertEqual(r.signals.first { $0.key == "acwr" }?.flag, .bad) + XCTAssertEqual(r.level, .strained) + XCTAssertNotNil(r.acwr) + XCTAssertGreaterThan(r.acwr!, 1.5) + } + + func testRespRateRiseFlags() { + // Today resp rate well above baseline (~14) → illness-ish watch/bad signal present. + let r = ReadinessEngine.evaluate(days: baseline(todayHrv: 60, todayRhr: 52, todayStrain: 10, todayResp: 18)) + XCTAssertTrue(r.signals.contains { $0.key == "respRate" }) + XCTAssertEqual(r.signals.first { $0.key == "respRate" }?.evidence, "18.0 vs 14.0 rpm") + } + + func testExplicitTodayWithoutMatchingRowIsInsufficient() { + // Stale historical import: newest row is 2024-03-29, but the device's real calendar day is later. + // An explicit `today` with no matching row must read INSUFFICIENT — NOT synthesize off the newest + // stored (stale) row (issue #23/#24). + let days = baseline(todayHrv: 72, todayRhr: 46, todayStrain: 10) + XCTAssertEqual(ReadinessEngine.evaluate(days: days, today: "2026-06-08").level, .insufficient) + // The day that IS present still computes (no regression for current data). + XCTAssertNotEqual(ReadinessEngine.evaluate(days: days, today: "2024-03-29").level, .insufficient) + // The legacy no-`today` path is unchanged — still falls back to the most recent row. + XCTAssertNotEqual(ReadinessEngine.evaluate(days: days).level, .insufficient) + } + + func testStatsHelpers() { + XCTAssertEqual(ReadinessEngine.mean([2, 4, 6]), 4) + XCTAssertEqual(ReadinessEngine.sampleSD([2, 4, 6])!, 2.0, accuracy: 0.0001) + XCTAssertNil(ReadinessEngine.sampleSD([5])) + XCTAssertNil(ReadinessEngine.mean([])) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryCalibrationTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryCalibrationTests.swift new file mode 100644 index 0000000000..1858ed8f3c --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryCalibrationTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import StrandAnalytics + +/// Unit tests for `RecoveryScorer.calibrationNights`, the pure helper behind the Today recovery +/// cold-start "Calibrating — N of 4 nights" affordance. Recovery is nil until the HRV baseline +/// crosses the seed gate (Baselines.minNightsSeed valid nights); this surfaces honest progress +/// instead of a bare empty state. Mirrors the Android RecoveryCalibrationTest case-for-case. +final class RecoveryCalibrationTests: XCTestCase { + + private let seed = Baselines.minNightsSeed // 4 + + func testNilWhenRecoveryAlreadyExists() { + XCTAssertNil(RecoveryScorer.calibrationNights(nightlyHrv: [55.0, 60.0], hasRecovery: true)) + } + + func testZeroWhenNoNightHasHrvYet() { + // Brand-new user (no valid HRV nights yet) → 0, so Charge reads "Calibrating — 0 of N" + // rather than a bare "No data" (#335). + XCTAssertEqual(RecoveryScorer.calibrationNights(nightlyHrv: [nil, nil], hasRecovery: false), 0) + } + + func testCountsNightsCarryingHrvBelowSeed() { + XCTAssertEqual(RecoveryScorer.calibrationNights(nightlyHrv: [55.0, nil, 61.0], hasRecovery: false), 2) + } + + func testOneNightReportsOne() { + XCTAssertEqual(RecoveryScorer.calibrationNights(nightlyHrv: [58.0], hasRecovery: false), 1) + } + + func testNilAtOrAboveSeedDoesNotClaimCalibrating() { + // At/above the seed gate the baseline should be usable; if recovery is still nil it's + // some other gap, so we must NOT show a misleading "calibrating 4 of 4". + let nights: [Double?] = (1...seed).map { 55.0 + Double($0) } + XCTAssertNil(RecoveryScorer.calibrationNights(nightlyHrv: nights, hasRecovery: false)) + } + + func testIgnoresNilHrvNights() { + XCTAssertEqual(RecoveryScorer.calibrationNights(nightlyHrv: [55.0, nil, nil, 60.0], hasRecovery: false), 2) + } + + func testIgnoresOutOfRangeHrvNights() { + // A physiologically implausible avgHrv (outside the HRV config bounds 5...250) does not + // advance the recovery seed in Baselines.update, so it must not be counted here either — + // only the in-range night does. Keeps the displayed N in step with the real nValid. + XCTAssertEqual(RecoveryScorer.calibrationNights(nightlyHrv: [55.0, 4.0, 999.0], hasRecovery: false), 1) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryForecastTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryForecastTests.swift new file mode 100644 index 0000000000..3d9f3297da --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryForecastTests.swift @@ -0,0 +1,172 @@ +import XCTest +@testable import StrandAnalytics + +/// RecoveryForecaster — evening estimate of tomorrow-morning Charge. The oracle for the +/// Android RecoveryForecastTest; keep the two in lockstep. +final class RecoveryForecastTests: XCTestCase { + + // A steady baseline: 14 nights all at Charge 60, Effort 50. + private let steadyCharge = Array(repeating: 60.0, count: 14) + private let steadyEffort = Array(repeating: 50.0, count: 14) + + // MARK: - Gating + + func testNilUntilEnoughBaseline() { + // Below minBaselineNights → no forecast (honest cold-start). + let few = Array(repeating: 60.0, count: RecoveryForecaster.minBaselineNights - 1) + XCTAssertNil(RecoveryForecaster.forecast(recentCharge: few, todayEffort: 50, + plannedSleepHours: 8)) + // Exactly at the gate → a forecast appears. + let enough = Array(repeating: 60.0, count: RecoveryForecaster.minBaselineNights) + XCTAssertNotNil(RecoveryForecaster.forecast(recentCharge: enough, todayEffort: nil, + plannedSleepHours: 8)) + } + + func testEmptyChargeIsNil() { + XCTAssertNil(RecoveryForecaster.forecast(recentCharge: [], todayEffort: 50, + plannedSleepHours: 8)) + } + + // MARK: - Neutral case anchors to the baseline + + func testNeutralDayLandsNearBaseline() { + // Today's Effort == recent average, sleep == need → only tiny reversion (slope 0 + // on a flat series), so the forecast sits on the baseline mean (60). + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, + recentEffort: steadyEffort, + todayEffort: 50, + plannedSleepHours: RecoveryForecaster.defaultNeedHours) + XCTAssertNotNil(f) + XCTAssertEqual(f!.baseline, 60, accuracy: 1e-9) + XCTAssertEqual(f!.charge, 60, accuracy: 1e-9) // flat slope → no reversion nudge + XCTAssertEqual(f!.nights, 14) + } + + // MARK: - Strain debt + + func testHarderDayLowersForecast() { + // A much harder-than-average day suppresses tomorrow's Charge. + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, + recentEffort: steadyEffort, + todayEffort: 80, // +30 over the avg of 50 + plannedSleepHours: RecoveryForecaster.defaultNeedHours)! + XCTAssertLessThan(f.charge, 60) + } + + func testEasierDayRaisesForecast() { + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, + recentEffort: steadyEffort, + todayEffort: 20, // −30 under the avg + plannedSleepHours: RecoveryForecaster.defaultNeedHours)! + XCTAssertGreaterThan(f.charge, 60) + } + + func testStrainAdjIsCapped() { + // A freak max-Effort day cannot remove more than strainAdjCap points. + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, + recentEffort: steadyEffort, + todayEffort: 100, + plannedSleepHours: RecoveryForecaster.defaultNeedHours)! + // Only the strain term moves here (sleep == need, slope 0). + XCTAssertGreaterThanOrEqual(f.charge, 60 - RecoveryForecaster.strainAdjCap) + } + + func testStrainTermDropsWithoutEffortHistory() { + // No recent Effort → strain term is silent; neutral sleep keeps us on baseline. + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, + recentEffort: [], + todayEffort: 100, + plannedSleepHours: RecoveryForecaster.defaultNeedHours)! + XCTAssertEqual(f.charge, 60, accuracy: 1e-9) + } + + // MARK: - Sleep adequacy + + func testShortSleepLowersForecast() { + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, + recentEffort: steadyEffort, + todayEffort: 50, + plannedSleepHours: 4)! // half the 8 h need + XCTAssertLessThan(f.charge, 60) + } + + func testOversleepHelpIsCapped() { + // Sleeping far beyond need does not keep adding Charge (diminishing returns). + let plenty = RecoveryForecaster.forecast(recentCharge: steadyCharge, recentEffort: steadyEffort, + todayEffort: 50, plannedSleepHours: 12)! + let justOver = RecoveryForecaster.forecast(recentCharge: steadyCharge, recentEffort: steadyEffort, + todayEffort: 50, plannedSleepHours: 10)! + XCTAssertEqual(plenty.charge, justOver.charge, accuracy: 1e-9) + } + + func testNegativeSleepTreatedAsZero() { + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, recentEffort: steadyEffort, + todayEffort: 50, plannedSleepHours: -3)! + XCTAssertEqual(f.plannedSleepHours, 0, accuracy: 1e-9) + } + + // MARK: - Output bounds + + func testChargeAndBandStayInRange() { + // Drive everything negative: low baseline, brutal Effort, no sleep. + let low = Array(repeating: 8.0, count: 14) + let f = RecoveryForecaster.forecast(recentCharge: low, recentEffort: steadyEffort, + todayEffort: 100, plannedSleepHours: 0)! + XCTAssertGreaterThanOrEqual(f.charge, 0) + XCTAssertLessThanOrEqual(f.charge, 100) + XCTAssertGreaterThanOrEqual(f.low, 0) + XCTAssertLessThanOrEqual(f.high, 100) + } + + // MARK: - Band + confidence + + func testThinBaselineWidensBandAndIsBuilding() { + let thin = Array(repeating: 60.0, count: 6) // < trustedNights (10) + let f = RecoveryForecaster.forecast(recentCharge: thin, recentEffort: steadyEffort, + todayEffort: 50, plannedSleepHours: 8)! + // Flat series SD == 0, so band == floor + thin inflation. + XCTAssertEqual(f.band, RecoveryForecaster.minBandPoints + RecoveryForecaster.thinBandPoints, + accuracy: 1e-9) + XCTAssertEqual(f.confidence, .building) + } + + func testFullBaselineWithInformedNeedIsSolid() { + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, recentEffort: steadyEffort, + todayEffort: 50, plannedSleepHours: 8, + needNights: RecoveryForecaster.solidNeedNights)! + XCTAssertEqual(f.confidence, .solid) + // 14 nights ≥ trustedNights → no thin inflation; flat SD → just the floor. + XCTAssertEqual(f.band, RecoveryForecaster.minBandPoints, accuracy: 1e-9) + } + + func testFullBaselineButDefaultNeedIsBuilding() { + // Enough Charge nights but the sleep need is still the unrefined default. + let f = RecoveryForecaster.forecast(recentCharge: steadyCharge, recentEffort: steadyEffort, + todayEffort: 50, plannedSleepHours: 8, needNights: 0)! + XCTAssertEqual(f.confidence, .building) + } + + // MARK: - Mean reversion + + func testDownswingIsDamped() { + // A steady downward streak: the forecast should sit ABOVE a naive last-value read, + // pulled back toward the baseline by the reversion term. + let falling = stride(from: 80.0, through: 54.0, by: -2.0).map { $0 } // 14 pts, mean 67 + let f = RecoveryForecaster.forecast(recentCharge: falling, recentEffort: steadyEffort, + todayEffort: 50, plannedSleepHours: 8)! + XCTAssertGreaterThan(f.charge, falling.last!) // not just extrapolating the slump + } + + // MARK: - Stat helpers + + func testStatHelpers() { + XCTAssertEqual(RecoveryForecaster.mean([2, 4, 6]), 4, accuracy: 1e-9) + XCTAssertEqual(RecoveryForecaster.mean([]), 0, accuracy: 1e-9) + XCTAssertEqual(RecoveryForecaster.sampleSD([10]), 0, accuracy: 1e-9) + // SD of [2,4,6] with ddof=1 is 2. + XCTAssertEqual(RecoveryForecaster.sampleSD([2, 4, 6]), 2, accuracy: 1e-9) + // Perfect +1/day ramp → slope 1. + XCTAssertEqual(RecoveryForecaster.leastSquaresSlope([1, 2, 3, 4]), 1, accuracy: 1e-9) + XCTAssertEqual(RecoveryForecaster.leastSquaresSlope([5]), 0, accuracy: 1e-9) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTests.swift index dfb04b4018..a67efedc88 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTests.swift @@ -79,6 +79,79 @@ final class RecoveryScorerTests: XCTestCase { XCTAssertEqual(withResp, withoutResp, accuracy: 1e-6) } + func testRespAboveBaselineLowersAndBelowRaisesRecovery() { + // Pins the resp-into-recovery wiring direction (mirrors the Android BaselineSeedingTest + // addition): with HRV/RHR pinned at baseline, a nightly respiratory rate above the resp + // baseline must LOWER recovery and one below it must RAISE it. A nil resp renormalizes + // to the no-resp score (testRespTermDropAndRenormalize already pins that). + func score(_ resp: Double?) -> Double { + RecoveryScorer.recovery( + hrv: 50, rhr: 55, resp: resp, + hrvBaseline: baseline(mean: 50, sigma: 6), + rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: baseline(mean: 14.5, sigma: 1), + sleepPerf: 0.9)! + } + let neutral = score(nil) + let elevated = score(17.5) + let lowered = score(12.0) + XCTAssertLessThan(elevated, neutral, "resp above baseline must lower recovery") + XCTAssertGreaterThan(lowered, neutral, "resp below baseline must raise recovery") + } + + func testSkinTempNilLeavesScoreIdenticalToBefore() { + // The no-skin-temp path must be byte-identical to the pre-redesign score: + // when skinTempDev is nil the term drops and the weights renormalize. + func score(_ dev: Double?) -> Double { + RecoveryScorer.recovery( + hrv: 55, rhr: 52, resp: nil, + hrvBaseline: baseline(mean: 50, sigma: 6), + rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: nil, + sleepPerf: 0.9, + skinTempDev: dev)! + } + // Default argument (nil) and explicit nil agree, and both equal the no-skin-temp score. + let implicitNil = RecoveryScorer.recovery( + hrv: 55, rhr: 52, resp: nil, + hrvBaseline: baseline(mean: 50, sigma: 6), + rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: nil, + sleepPerf: 0.9)! + XCTAssertEqual(score(nil), implicitNil, accuracy: 1e-9) + } + + func testSkinTempDeviationLowersChargeSymmetrically() { + // A symmetric penalty: ANY drift from baseline (hot OR cold) lowers Charge, and a + // larger |deviation| lowers it more. Baseline drivers are pinned ABOVE-center + // (positive composite z) so the penalty has a visible direction to push against. + func score(_ dev: Double?) -> Double { + RecoveryScorer.recovery( + hrv: 55, rhr: 52, resp: nil, + hrvBaseline: baseline(mean: 50, sigma: 6), + rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: nil, + sleepPerf: 0.9, + skinTempDev: dev)! + } + let neutral = score(nil) + let zeroDev = score(0.0) + let warm = score(1.0) + let cold = score(-1.0) + let bigWarm = score(2.0) + // A present zero-deviation term adds no penalty itself, but participates in the + // renormalization (extra weight at z=0), so an above-center composite is pulled + // slightly toward the logistic center — strictly below the no-term score. + XCTAssertLessThan(zeroDev, neutral) + // A real deviation penalizes further, below the zero-deviation case… + XCTAssertLessThan(warm, zeroDev, "warm deviation must lower Charge") + XCTAssertLessThan(cold, zeroDev, "cold deviation must lower Charge") + // …symmetrically (±1 °C cost the same)… + XCTAssertEqual(warm, cold, accuracy: 1e-9) + // …and a larger deviation lowers it more. + XCTAssertLessThan(bigWarm, warm) + } + func testBandThresholds() { XCTAssertEqual(RecoveryScorer.band(20), "red") XCTAssertEqual(RecoveryScorer.band(33.9), "red") @@ -102,4 +175,51 @@ final class RecoveryScorerTests: XCTestCase { func testRestingHRNilWhenNoSamples() { XCTAssertNil(RecoveryScorer.restingHR([], start: 0, end: 1000)) } + + // MARK: - #686: artifact hardening of the resting-HR floor + + func testRestingHRRejectsSingleSampleArtifactBin() { + // A dense, well-populated bin at 55 bpm, then a SECOND 5-min bin holding exactly ONE + // artifact beat at 30 bpm. The old min-of-bin-means took the lone-sample bin (30) as the + // floor; with #686 a single-sample bin can't WIN, so the floor is the real 55. + var hr: [HRSample] = [] + let start = 1000 + for i in 0..<300 { hr.append(HRSample(ts: start + i, bpm: 55)) } // bin 0: 300 samples @55 + hr.append(HRSample(ts: start + 300, bpm: 30)) // bin 1: ONE sample @30 + let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) + XCTAssertEqual(r, 55, "a single-sample artifact bin must not win the resting floor") + } + + func testRestingHRRejectsSubPhysiologicalDropoutBin() { + // Two FULLY-populated bins: a real 52 bpm bin and a dropout bin whose 300 samples all read + // an implausible 10 bpm (decode-zero / dropout run). It clears the sample-count bar but is + // sub-physiological, so #686 bars it from the floor → resting reads the real 52. + var hr: [HRSample] = [] + let start = 2000 + for i in 0..<300 { hr.append(HRSample(ts: start + i, bpm: 52)) } // real bin + for i in 0..<300 { hr.append(HRSample(ts: start + 300 + i, bpm: 10)) } // dropout bin + let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) + XCTAssertEqual(r, 52, "a sub-physiological dropout bin must not win the resting floor") + } + + func testRestingHRKeepsGenuineLowFloor() { + // A REAL sustained dip (a full 5-min bin at 45 bpm) is plausible AND well-populated, so it + // still wins — the hardening must not flatten genuine athletic resting HRs. + var hr: [HRSample] = [] + let start = 3000 + for i in 0..<300 { hr.append(HRSample(ts: start + i, bpm: 60)) } + for i in 0..<300 { hr.append(HRSample(ts: start + 300 + i, bpm: 45)) } + let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) + XCTAssertEqual(r, 45, "a genuine sustained low bin must still win the floor") + } + + func testRestingHRFallsBackWhenNoBinQualifies() { + // A wholly sparse window: every bin holds a single sample (none clears the count bar). + // Rather than return nil on data present, fall back to the legacy lowest-bin-mean (here 48). + let start = 4000 + let hr = [HRSample(ts: start + 10, bpm: 58), + HRSample(ts: start + 320, bpm: 48)] // two bins, one sample each + let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) + XCTAssertEqual(r, 48, "with no qualifying bin, fall back to the lowest bin mean (never nil on data)") + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTraceTests.swift new file mode 100644 index 0000000000..144ad1df62 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTraceTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import StrandAnalytics + +/// The Recovery (Charge) test mode's pure term-breakdown trace. Pins the lines a fixture night produces +/// AND proves the emitter never changes the score `recovery(...)` returns (Test Centre Group G). Twin of +/// the Android RecoveryScorerTraceTest. No em-dashes. +final class RecoveryScorerTraceTests: XCTestCase { + + /// A usable (trusted) baseline with a given mean and Gaussian sigma. + private func baseline(mean: Double, sigma: Double, nValid: Int = 14) -> BaselineState { + BaselineState(baseline: mean, spread: sigma / 1.253, nValid: nValid, + nightsSinceUpdate: 0, status: nValid >= 14 ? .trusted : .provisional) + } + + func testTraceScoreIsByteIdenticalToRecovery() { + // Full set of terms present: the trace's returned score must equal recovery(...) exactly. + let hrvB = baseline(mean: 50, sigma: 6) + let rhrB = baseline(mean: 55, sigma: 3) + let respB = baseline(mean: 16, sigma: 2) + let plain = RecoveryScorer.recovery( + hrv: 62, rhr: 51, resp: 15, + hrvBaseline: hrvB, rhrBaseline: rhrB, respBaseline: respB, + sleepPerf: 0.9, skinTempDev: 0.3) + let (traced, lines) = RecoveryScorer.recoveryTrace( + hrv: 62, rhr: 51, resp: 15, + hrvBaseline: hrvB, rhrBaseline: rhrB, respBaseline: respB, + sleepPerf: 0.9, skinTempDev: 0.3) + XCTAssertEqual(traced, plain) + // All five terms present, none nil. + XCTAssertTrue(lines.contains { $0.contains("charge term hrv ") }) + XCTAssertTrue(lines.contains { $0.contains("charge term rhr ") }) + XCTAssertTrue(lines.contains { $0.contains("charge term resp ") }) + XCTAssertTrue(lines.contains { $0.contains("charge term sleepPerf ") }) + XCTAssertTrue(lines.contains { $0.contains("charge term skinTempDev ") }) + XCTAssertTrue(lines.contains { $0.contains("nilTerm dropped=[]") }) + XCTAssertTrue(lines.contains { $0.hasPrefix("charge score=") && $0.contains("band=") }) + XCTAssertFalse(lines.contains { $0.contains("\u{2014}") }) + } + + func testTraceNamesTheNilTermThatForcedRenorm() { + // No RHR baseline, no resp, no skin temp → those three terms drop and the trace must name them. + let hrvB = baseline(mean: 50, sigma: 6) + let plain = RecoveryScorer.recovery( + hrv: 55, rhr: 55, resp: nil, + hrvBaseline: hrvB, rhrBaseline: nil, respBaseline: nil, + sleepPerf: 0.85, skinTempDev: nil) + let (traced, lines) = RecoveryScorer.recoveryTrace( + hrv: 55, rhr: 55, resp: nil, + hrvBaseline: hrvB, rhrBaseline: nil, respBaseline: nil, + sleepPerf: 0.85, skinTempDev: nil) + XCTAssertEqual(traced, plain) + let nilLine = lines.first { $0.contains("nilTerm dropped=") } + XCTAssertNotNil(nilLine) + XCTAssertTrue(nilLine!.contains("rhr")) + XCTAssertTrue(nilLine!.contains("resp")) + XCTAssertTrue(nilLine!.contains("skinTempDev")) + XCTAssertFalse(nilLine!.contains("hrv,")) // hrv + sleepPerf survived + } + + func testColdStartTraceReportsTheGateAndNilScore() { + let coldHRV = BaselineState(baseline: 50, spread: 5, nValid: 2, + nightsSinceUpdate: 0, status: .calibrating) + let (traced, lines) = RecoveryScorer.recoveryTrace( + hrv: 60, rhr: 50, resp: nil, + hrvBaseline: coldHRV, rhrBaseline: nil, respBaseline: nil, + sleepPerf: 0.9, skinTempDev: nil) + XCTAssertNil(traced) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines[0].contains("nilScore reason=hrvBaselineNotUsable")) + XCTAssertTrue(lines[0].contains("hrvStatus=calibrating")) + XCTAssertTrue(lines[0].contains("hrvNValid=2")) + } + + func testBaselineLinesCarryStatusAndNValid() { + let hrvB = baseline(mean: 50, sigma: 6, nValid: 9) + let (_, lines) = RecoveryScorer.recoveryTrace( + hrv: 50, rhr: 55, resp: nil, + hrvBaseline: hrvB, rhrBaseline: nil, respBaseline: nil, + sleepPerf: RecoveryScorer.sleepPerfCenter, skinTempDev: nil) + let base = lines.first { $0.hasPrefix("charge baseline hrv ") } + XCTAssertNotNil(base) + XCTAssertTrue(base!.contains("nValid=9")) + XCTAssertTrue(base!.contains("status=provisional")) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ResonanceEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ResonanceEngineTests.swift new file mode 100644 index 0000000000..4366c8fb4f --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ResonanceEngineTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the L1 `ResonanceEngine`: synthetic R-R with a known RSA peak injected at one pace → the engine +/// selects that pace; a too-few-beats pace → unscored; fewer than N scored paces → the honest "no lock" +/// fallback to 5.5. These are the GOLDEN VECTORS the Kotlin `ResonanceEngineTest` mirrors. +/// See docs/superpowers/specs/2026-06-19-v5-haptic-biofeedback-design.md (L1). +final class ResonanceEngineTests: XCTestCase { + + /// Generate a paced candidate's R-R: a steady baseline R-R with a once-per-breath-cycle sinusoid-like + /// swing of `swingMs` peak-to-trough, sampled at ~1 beat/sec over `durationSec`. A larger `swingMs` + /// means a larger RSA amplitude. Beats start at `startTs`; the scorer drops the first 30 s transient. + /// Deterministic + integer so Swift and Kotlin generate the IDENTICAL series. + private func pacedBeats(bpm: Double, baselineMs: Int, swingMs: Int, + startTs: Int, durationSec: Int) -> [ResonanceEngine.RrBeat] { + let cycleSec = 60.0 / bpm + var out: [ResonanceEngine.RrBeat] = [] + var t = startTs + let end = startTs + durationSec + while t <= end { + // Triangle wave over the breath cycle: phase 0→0.5 rises +half, 0.5→1 falls −half. + let phase = (Double(t - startTs).truncatingRemainder(dividingBy: cycleSec)) / cycleSec + let tri = phase < 0.5 ? (phase * 2.0) : (2.0 - phase * 2.0) // 0→1→0 + // Map tri 0..1 onto −half..+half of the swing, integer. + let delta = Int((tri - 0.5) * Double(swingMs)) + out.append(ResonanceEngine.RrBeat(ts: t, rrMs: baselineMs + delta)) + t += 1 + } + return out + } + + // GOLDEN: three paces, the MIDDLE (5.5) carries the biggest swing → it must be locked. + func test_golden_selects_max_rsa_pace() { + let samples = [ + ResonanceEngine.PaceSample(bpm: 4.5, + rr: pacedBeats(bpm: 4.5, baselineMs: 900, swingMs: 40, startTs: 0, durationSec: 150), + startTs: 0, endTs: 150), + ResonanceEngine.PaceSample(bpm: 5.5, + rr: pacedBeats(bpm: 5.5, baselineMs: 900, swingMs: 120, startTs: 1000, durationSec: 150), + startTs: 1000, endTs: 1150), + ResonanceEngine.PaceSample(bpm: 6.5, + rr: pacedBeats(bpm: 6.5, baselineMs: 900, swingMs: 40, startTs: 2000, durationSec: 150), + startTs: 2000, endTs: 2150), + ] + let result = ResonanceEngine.sweep(samples) + XCTAssertTrue(result.didLock) + XCTAssertEqual(result.lockedBpm, 5.5) + // The 5.5 pace has the largest RSA amplitude of the three scored paces. + let rsa55 = result.scores.first { $0.bpm == 5.5 }?.rsaAmplitude + let rsa45 = result.scores.first { $0.bpm == 4.5 }?.rsaAmplitude + XCTAssertNotNil(rsa55) + XCTAssertNotNil(rsa45) + XCTAssertGreaterThan(rsa55!, rsa45!) + } + + // A pace with too few clean beats is UNSCORED (rsaAmplitude nil). + func test_too_few_beats_pace_is_unscored() { + // Only ~10 beats total in the window, well under minBeats (20) — even before transient drop. + let sparse = (0..<10).map { ResonanceEngine.RrBeat(ts: 40 + $0, rrMs: 900) } + let score = ResonanceEngine.scorePace( + ResonanceEngine.PaceSample(bpm: 5.5, rr: sparse, startTs: 0, endTs: 200)) + XCTAssertNil(score.rsaAmplitude) + XCTAssertFalse(score.scored) + } + + // Fewer than minScoredPaces (3) scored → honest "no lock", fall back to 5.5. + func test_no_lock_fallback_to_5p5() { + let good = ResonanceEngine.PaceSample(bpm: 6.0, + rr: pacedBeats(bpm: 6.0, baselineMs: 900, swingMs: 60, startTs: 0, durationSec: 150), + startTs: 0, endTs: 150) + // Two sparse (unscorable) paces. + let sparseA = ResonanceEngine.PaceSample(bpm: 4.5, + rr: (0..<5).map { ResonanceEngine.RrBeat(ts: 1040 + $0, rrMs: 900) }, + startTs: 1000, endTs: 1200) + let sparseB = ResonanceEngine.PaceSample(bpm: 7.0, + rr: (0..<5).map { ResonanceEngine.RrBeat(ts: 2040 + $0, rrMs: 900) }, + startTs: 2000, endTs: 2200) + let result = ResonanceEngine.sweep([good, sparseA, sparseB]) + XCTAssertFalse(result.didLock) + XCTAssertEqual(result.lockedBpm, ResonanceEngine.fallbackBpm) + XCTAssertEqual(result.lockedBpm, 5.5) + } + + // The transient drop excludes the first 30 s: beats before startTs+30 don't enter the steady window. + func test_transient_drop_excludes_early_beats() { + // A flat (no-swing) pace → RSA ~0 but still scorable if enough beats survive the transient. + let flat = pacedBeats(bpm: 5.5, baselineMs: 900, swingMs: 0, startTs: 0, durationSec: 150) + let score = ResonanceEngine.scorePace( + ResonanceEngine.PaceSample(bpm: 5.5, rr: flat, startTs: 0, endTs: 150)) + // Flat series → zero swing per cycle → RSA amplitude 0 (scored, just no RSA). + if let rsa = score.rsaAmplitude { + XCTAssertEqual(rsa, 0, accuracy: 1e-9) + } + // Clean-beat count reflects only the post-transient window (≤ 121 beats: ts 30..150). + XCTAssertLessThanOrEqual(score.cleanBeats, 121) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RespRateRsaTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RespRateRsaTests.swift new file mode 100644 index 0000000000..66ea519e29 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RespRateRsaTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Tests SleepStager.respRateFromRR (RSA) on a synthetic R-R series with a KNOWN breathing +/// frequency. WHOOP5 v18 carries no raw resp ADC, so respiratory rate is derived on-device +/// from the R-R stream via respiratory sinus arrhythmia; this pins that the estimator recovers +/// a planted breathing rate and returns NaN on too-little data (honest no-data). The value is +/// an APPROXIMATE on-device estimate, not cloud/clinical respiration. Mirrors the Android +/// RespRateRsaTest vectors value-for-value. +final class RespRateRsaTests: XCTestCase { + + func testRespRateFromRRRecoversKnownBreathingFrequency() { + // Synthetic RR: mean HR 60 bpm (RR ~1000 ms) with a 0.25 Hz (15 breaths/min) + // RSA modulation of +/-40 ms. ~7 minutes of beats so multiple 5-min windows. + let breathHz = 0.25 // 15 breaths/min + let baseRrMs = 1000.0 + let ampMs = 40.0 + let start = 1_700_000_000 + var rows: [RRInterval] = [] + var tSec = 0.0 + // generate ~420 s of beats + while tSec < 420.0 { + let rrMs = baseRrMs + ampMs * sin(2.0 * Double.pi * breathHz * tSec) + tSec += rrMs / 1000.0 + rows.append(RRInterval(ts: start + Int(tSec), rrMs: Int(rrMs))) + } + let end = start + Int(tSec) + let est = SleepStager.respRateFromRR(rows, start: start, end: end) + XCTAssertTrue(est.isFinite, "expected finite resp estimate, got \(est)") + // RSA peak-pick should land within ~3 bpm of the true 15 breaths/min. + XCTAssertEqual(est, 15.0, accuracy: 3.0) + } + + /// #958 regression: a slow breather (11 breaths/min, the value in the report) must read back + /// ~11, NOT the doubled ~20-21 the reporter saw. RSA peak-picking has a known failure mode where + /// a split / harmonic peak per breath can inflate the rate toward 2x; this pins that the median + /// across windows stays on the fundamental. Guards the exact factor rather than blindly halving. + func testRespRateFromRRSlowBreatherIsNotDoubled() { + // Mean HR 55 bpm (RR ~1091 ms), 11 breaths/min (0.1833 Hz), +/-45 ms RSA, ~8 min of beats. + let breathHz = 11.0 / 60.0 + let baseRrMs = 60000.0 / 55.0 + let ampMs = 45.0 + let start = 1_700_000_000 + var rows: [RRInterval] = [] + var tSec = 0.0 + while tSec < 480.0 { + let rrMs = baseRrMs + ampMs * sin(2.0 * Double.pi * breathHz * tSec) + tSec += rrMs / 1000.0 + rows.append(RRInterval(ts: start + Int(tSec), rrMs: Int(rrMs))) + } + let end = start + Int(tSec) + let est = SleepStager.respRateFromRR(rows, start: start, end: end) + XCTAssertTrue(est.isFinite, "expected finite resp estimate, got \(est)") + // Must land on the true 11 breaths/min, well below the ~20-21 doubling in #958. + XCTAssertEqual(est, 11.0, accuracy: 2.0) + XCTAssertLessThan(est, 16.0, "resp estimate must not be doubled toward ~22 (#958)") + } + + func testRespRateFromRRTooFewBeatsIsNaN() { + let start = 1_700_000_000 + let rows = [ + RRInterval(ts: start + 1, rrMs: 1000), + RRInterval(ts: start + 2, rrMs: 1000), + RRInterval(ts: start + 3, rrMs: 1000), + ] + XCTAssertTrue(SleepStager.respRateFromRR(rows, start: start, end: start + 10).isNaN) + XCTAssertTrue(SleepStager.respRateFromRR([], start: start, end: start + 10).isNaN) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RestSubScoreTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RestSubScoreTraceTests.swift new file mode 100644 index 0000000000..b0ae0a26d6 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RestSubScoreTraceTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import StrandAnalytics + +final class RestSubScoreTraceTests: XCTestCase { + func testRestSubScoreLine() { + // 8 h TST, 0.92 efficiency, 50% restorative, neutral consistency, 1 night-group fragment. + let line = AnalyticsEngine.Rest.subScoreLine( + tstSeconds: 8 * 3600, inBedSeconds: 8 * 3600 / 0.92, efficiency: 0.92, + restorativeSeconds: 4 * 3600, needHours: 8.0, consistency: nil, + deepSeconds: 1 * 3600, groupFragments: 1, groupInBedSeconds: 8 * 3600 / 0.92) + XCTAssertTrue(line.hasPrefix("rest "), line) + XCTAssertTrue(line.contains("wDur=0.5")) + XCTAssertTrue(line.contains("wEff=0.2")) + XCTAssertTrue(line.contains("wRestor=0.2")) + XCTAssertTrue(line.contains("wConsist=0.1")) + XCTAssertTrue(line.contains("group=1")) + XCTAssertFalse(line.contains("\u{2014}")) + } + + // MARK: - CAPTURE-C (#799): sleep provenance line + + func testSleepProvenanceLineMeasured() { + let line = AnalyticsEngine.sleepProvenanceLine( + provenance: .measured, hoursAsleepMin: 442.4, sourceRowId: "1700000000") + XCTAssertEqual(line, "sleepProvenance provenance=measured hoursAsleep=442 sourceRowId=1700000000") + XCTAssertFalse(line.contains("\u{2014}")) + } + + func testSleepProvenanceLineImportedShowsSource() { + XCTAssertEqual(SleepProvenance.imported("whoop").wire, "imported:whoop") + XCTAssertEqual(SleepProvenance.imported("apple").wire, "imported:apple") + let line = AnalyticsEngine.sleepProvenanceLine( + provenance: .imported("whoop"), hoursAsleepMin: 410, sourceRowId: "imp-42") + XCTAssertTrue(line.contains("provenance=imported:whoop"), line) + XCTAssertTrue(line.contains("hoursAsleep=410"), line) + XCTAssertTrue(line.contains("sourceRowId=imp-42"), line) + } + + func testCompositeMatchesRestComposite() { + // The line's composite= value must equal Rest.composite from the same inputs (cannot diverge). + let tst = 7.5 * 3600.0, inBed = 8.0 * 3600.0, eff = 0.9 + let restorative = 3.0 * 3600.0, need = 8.0, deep = 1.2 * 3600.0 + let line = AnalyticsEngine.Rest.subScoreLine( + tstSeconds: tst, inBedSeconds: inBed, efficiency: eff, + restorativeSeconds: restorative, needHours: need, consistency: 0.7, + deepSeconds: deep, groupFragments: 2, groupInBedSeconds: inBed) + let composite = AnalyticsEngine.Rest.composite( + tstSeconds: tst, inBedSeconds: inBed, efficiency: eff, + restorativeSeconds: restorative, needHours: need, consistency: 0.7, deepSeconds: deep) + let r2 = (composite * 100.0).rounded() / 100.0 + XCTAssertTrue(line.contains("composite=\(r2)"), line) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhythmScreenerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhythmScreenerTests.swift new file mode 100644 index 0000000000..3c1580b195 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhythmScreenerTests.swift @@ -0,0 +1,266 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Tests for the experimental, non-clinical RhythmScreener regularity engine. +/// +/// All fixtures are SYNTHETIC and built from a deterministic integer LCG + integer-ms +/// R-R series (no trig, no platform-dependent floats), so the Kotlin twin +/// (`RhythmScreenerTest.kt`) reproduces byte-identical inputs and therefore identical +/// `RhythmRegularity` labels and rounded stats. This is the cross-platform parity gate. +/// +/// No real patient data is ever used — synthetic only. +final class RhythmScreenerTests: XCTestCase { + + // MARK: - Deterministic synthetic fixtures (mirrored exactly in Kotlin) + + /// A tiny deterministic LCG (Numerical Recipes constants) over UInt32, so Swift and + /// Kotlin produce the identical sequence. `next(mod:)` returns an Int in [0, mod). + struct LCG { + var state: UInt32 + init(_ seed: UInt32) { state = seed } + mutating func nextU32() -> UInt32 { + state = state &* 1664525 &+ 1013904223 + return state + } + /// Symmetric integer jitter in [-amp, +amp]. + mutating func jitter(_ amp: Int) -> Int { + let span = 2 * amp + 1 + return Int(nextU32() % UInt32(span)) - amp + } + } + + /// Regular sinus: mean ~1000 ms (60 bpm) with smooth triangle-wave respiratory + /// modulation (±30 ms) and tiny ±2 ms jitter. Tight, elongated comet → `.steady`. + static func regularSinus(count: Int = 240) -> [Double] { + var rng = LCG(1) + var out: [Double] = [] + out.reserveCapacity(count) + let period = 8 + for i in 0.. [Double] { + var rng = LCG(7) + var out: [Double] = [] + out.reserveCapacity(count) + for _ in 0.. [Double] { + var base = regularSinus(count: count) + // Insert a couplet every ~40 beats (a handful across the window). + var i = 20 + while i + 1 < count { + base[i] = 650 + base[i + 1] = 1350 + i += 40 + } + return base + } + + // MARK: - Window-level classification + + func testRegularSinusReadsSteady() { + let rr = Self.regularSinus() + let input = RhythmScreener.WindowInput(rrMs: rr, motionStill: true, meanHR: 60) + let r = RhythmScreener.screenWindow(input) + XCTAssertEqual(r.label, .steady) + XCTAssertEqual(r.nBeats, rr.count) + XCTAssertNotNil(r.sd1) + XCTAssertNotNil(r.sd2) + // A steady comet has SD1 well below SD2 (ratio below the round-out threshold). + XCTAssertLessThan(r.sd1sd2!, RhythmScreener.tauRatio) + XCTAssertEqual(r.poincare.count, rr.count - 1) + XCTAssertEqual(r.confidence, .solid) // 240 ≥ solidBeats(200) + } + + func testAfibLikeReadsVaried() { + let rr = Self.afibLike() + let input = RhythmScreener.WindowInput(rrMs: rr, motionStill: true, meanHR: 60) + let r = RhythmScreener.screenWindow(input) + XCTAssertEqual(r.label, .varied) + // Diffuse cloud: ratio at/above the round-out threshold. + XCTAssertGreaterThanOrEqual(r.sd1sd2!, RhythmScreener.tauRatio) + XCTAssertGreaterThanOrEqual(r.normRmssd!, RhythmScreener.tauNRmssd) + } + + func testIsolatedEctopyReadsOccasional() { + let rr = Self.isolatedEctopy() + let input = RhythmScreener.WindowInput(rrMs: rr, motionStill: true, meanHR: 60) + let r = RhythmScreener.screenWindow(input) + XCTAssertEqual(r.label, .occasionalEctopy) + XCTAssertNotEqual(r.label, .varied, "isolated ectopy must NOT read as varied") + XCTAssertGreaterThan(r.ectopicFraction!, 0) + } + + // MARK: - Gates + + func testMotionContaminatedIsUnreadable() { + // Even a varied-looking series is discarded when motion isn't still. + let rr = Self.afibLike() + let input = RhythmScreener.WindowInput(rrMs: rr, motionStill: false, meanHR: 60) + let r = RhythmScreener.screenWindow(input) + XCTAssertEqual(r.label, .unreadable, "motion gate must win") + XCTAssertNil(r.sd1) + XCTAssertTrue(r.poincare.isEmpty) + } + + func testSparseWindowIsUnreadableCalibrating() { + // Below windowMinBeats(60) → unreadable, calibrating confidence. + let rr = Array(repeating: 1000.0, count: 40) + let input = RhythmScreener.WindowInput(rrMs: rr, motionStill: true, meanHR: 60) + let r = RhythmScreener.screenWindow(input) + XCTAssertEqual(r.label, .unreadable) + XCTAssertEqual(r.confidence, .calibrating) + XCTAssertEqual(r.nBeats, 40) + } + + func testOutOfRestingBandIsUnreadable() { + // Dense, clean, still — but a 150 bpm mean HR is outside the resting band. + let rr = Self.regularSinus() + let input = RhythmScreener.WindowInput(rrMs: rr, motionStill: true, meanHR: 150) + let r = RhythmScreener.screenWindow(input) + XCTAssertEqual(r.label, .unreadable) + } + + // MARK: - Cross-source agreement (optional PPG IBI channel) + + func testPpgDisagreementSuppressesAgreement() { + // R-R path varied, PPG IBI path steady → no agreement. + let rrVaried = Self.afibLike() + let ppgSteady = Self.regularSinus() + let input = RhythmScreener.WindowInput(rrMs: rrVaried, ppgIBIms: ppgSteady, + motionStill: true, meanHR: 60) + let r = RhythmScreener.screenWindow(input) + XCTAssertEqual(r.label, .varied) // R-R path still labels the window + XCTAssertFalse(r.agreedAcrossSources) // but the channels disagree + } + + func testPpgAgreementWhenBothSteady() { + let rr = Self.regularSinus() + let ppg = Self.regularSinus() + let input = RhythmScreener.WindowInput(rrMs: rr, ppgIBIms: ppg, + motionStill: true, meanHR: 60) + let r = RhythmScreener.screenWindow(input) + XCTAssertEqual(r.label, .steady) + XCTAssertTrue(r.agreedAcrossSources) + } + + func testNoPpgChannelMeansNoAgreement() { + let rr = Self.regularSinus() + let input = RhythmScreener.WindowInput(rrMs: rr, motionStill: true, meanHR: 60) + let r = RhythmScreener.screenWindow(input) + XCTAssertFalse(r.agreedAcrossSources, "no PPG channel → agreement is false") + } + + // MARK: - Property / identity tests + + func testSD1IsRmssdOverRootTwo() { + let nn = Self.regularSinus() + let clean = HRVAnalyzer.rangeFilter(nn) + let rmssd = HRVAnalyzer.rmssdRaw(clean)! + let stats = RhythmScreener.computeStats(clean) + XCTAssertEqual(stats.sd1!, rmssd / 2.0.squareRoot(), accuracy: 1e-9) + } + + func testEctopicFractionReusesRejectEctopic() { + let nn = Self.isolatedEctopy() + let clean = HRVAnalyzer.rangeFilter(nn) + let kept = HRVAnalyzer.rejectEctopic(clean) + let expected = Double(clean.count - kept.count) / Double(clean.count) + XCTAssertEqual(RhythmScreener.ectopicFraction(clean), expected, accuracy: 1e-12) + } + + func testTurningPointRateOfMonotonicIsZero() { + // A strictly increasing series has no turning points. + let mono = (0..<10).map { 800.0 + Double($0) } + XCTAssertEqual(RhythmScreener.turningPointRate(mono)!, 0.0, accuracy: 1e-12) + } + + func testTurningPointRateOfZigzagIsMax() { + // A perfect zigzag turns at every interior point → rate 1.0, normalised to 1.5. + let zig = (0..<11).map { $0 % 2 == 0 ? 800.0 : 900.0 } + XCTAssertEqual(RhythmScreener.turningPointRate(zig)!, 1.0 / (2.0 / 3.0), accuracy: 1e-12) + } + + func testRRIntervalConvenienceInitComputesMeanHR() { + // 1000 ms intervals → 60 bpm computed from the cleaned series. + let rows = (0..<120).map { RRInterval(ts: $0, rrMs: 1000) } + let input = RhythmScreener.WindowInput(rr: rows, motionStill: true) + XCTAssertEqual(input.meanHR, 60.0, accuracy: 1e-9) + XCTAssertEqual(input.ts.count, 120) + } + + // MARK: - Night aggregation (descriptive only — no verdict) + + func testNightSummaryCountsAndRecurrence() { + let steady = RhythmScreener.screenWindow( + .init(rrMs: Self.regularSinus(), motionStill: true, meanHR: 60)) + let varied = RhythmScreener.screenWindow( + .init(rrMs: Self.afibLike(), motionStill: true, meanHR: 60)) + let unreadable = RhythmScreener.screenWindow( + .init(rrMs: Array(repeating: 1000.0, count: 10), motionStill: true, meanHR: 60)) + + // 3 varied windows → meets nightMinVariedWindows → recurred, overall varied. + let many = [varied, varied, varied, steady, unreadable] + let s = RhythmScreener.summarizeNight(many) + XCTAssertEqual(s.readableWindows, 4) // unreadable excluded + XCTAssertEqual(s.variedWindows, 3) + XCTAssertEqual(s.steadyWindows, 1) + XCTAssertTrue(s.variationRecurred) + XCTAssertEqual(s.overall, .varied) + } + + func testSingleVariedBlipDoesNotRecur() { + let steady = RhythmScreener.screenWindow( + .init(rrMs: Self.regularSinus(), motionStill: true, meanHR: 60)) + let varied = RhythmScreener.screenWindow( + .init(rrMs: Self.afibLike(), motionStill: true, meanHR: 60)) + // One varied blip among steady windows → NOT recurring (the false-positive guard). + let s = RhythmScreener.summarizeNight([steady, steady, varied, steady]) + XCTAssertFalse(s.variationRecurred) + XCTAssertNotEqual(s.overall, .varied) + } + + func testEmptyNightIsUnreadable() { + let s = RhythmScreener.summarizeNight([]) + XCTAssertEqual(s.overall, .unreadable) + XCTAssertEqual(s.readableWindows, 0) + } + + // MARK: - Non-clinical copy guard + + func testNoLabelRawStringNamesACondition() { + // The enum raw strings must never name a condition or imply diagnosis. + let banned = ["afib", "fibrillation", "arrhythmia", "diagnos", "ecg", "ekg", + "clinician", "disease", "cardiac", "alert"] + for label in [RhythmRegularity.steady, .occasionalEctopy, .varied, .unreadable] { + let raw = label.rawValue.lowercased() + for term in banned { + XCTAssertFalse(raw.contains(term), + "label raw '\(raw)' must not contain banned term '\(term)'") + } + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SedentaryDetectorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SedentaryDetectorTests.swift new file mode 100644 index 0000000000..8ed8a5af49 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SedentaryDetectorTests.swift @@ -0,0 +1,274 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Tests for SedentaryDetector — the pure core of the inactivity reminder. The detection tests mirror +/// the Android ActivityDetectorTest; the decision tests cover the live-path guard (fires after the +/// threshold; not inside cooldown; not outside active hours; resets on movement; respects the toggle). +/// Fixtures are IDENTICAL to SedentaryDetectorTest.kt so the two engines prove byte-identical output. +final class SedentaryDetectorTests: XCTestCase { + + // Cadence ~3 s (close to real offload data) so the 240 s smoothing window behaves realistically. + private let cad = 3 + + /// A sample at second `sec` with gravity (x, 0, 1). + private func gravS(_ sec: Int, _ x: Double) -> GravitySample { + GravitySample(ts: sec, x: x, y: 0, z: 1) + } + + // ── Detection (ActivityDetector parity) ─────────────────────────────────── + + func testEmptyOrSingle_yieldsNothing() { + XCTAssertTrue(SedentaryDetector.detectSedentaryBouts([]).isEmpty) + XCTAssertTrue(SedentaryDetector.detectSedentaryBouts([gravS(0, 0)]).isEmpty) + } + + func testSittingThenWalking_yieldsOneBoutEndingAtTheWalk() { + var g: [GravitySample] = [] + var t = 0 + // 30 min "sitting": tiny wrist motion (~0.02 g deltas) — below the move threshold. + while t <= 30 * 60 { g.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + // then 8 min "walking": large sustained deltas (~0.5 g) — above the threshold. + while t <= 38 * 60 { g.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.5)); t += cad } + + let bouts = SedentaryDetector.detectSedentaryBouts(g) + XCTAssertEqual(bouts.count, 1) + XCTAssertEqual(bouts[0].start, 0) + // Bout ends shortly after the sit→walk boundary (the smoothed signal takes ~1–2 min to cross). + XCTAssertTrue(bouts[0].end >= 27 * 60 && bouts[0].end <= 34 * 60, + "bout should end ~30min, got \(bouts[0].end / 60)") + } + + func testIsolatedReachesDoNotFragmentIt() { + // Mostly tiny motion with two isolated big "reaches" — the smoothed signal averages them down, + // so the sedentary bout stays whole (reaching for coffee shouldn't reset the timer). + var g: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { + let reach = (t == 10 * 60 || t == 20 * 60) + g.append(gravS(t, reach ? 1.0 : ((t / cad) % 2 == 0 ? 0.0 : 0.02))) + t += cad + } + XCTAssertEqual(SedentaryDetector.detectSedentaryBouts(g).count, 1, + "isolated reaches shouldn't fragment the sedentary bout") + } + + func testContinuousWalking_yieldsNothing() { + var g: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { g.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.5)); t += cad } + XCTAssertTrue(SedentaryDetector.detectSedentaryBouts(g).isEmpty, + "continuous walking is never sedentary") + } + + func testShortStretchUnderMinMinutes_dropped() { + // ~10 min sitting then walking → under the 15-min detector default → no bout. + var g: [GravitySample] = [] + var t = 0 + while t <= 10 * 60 { g.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + while t <= 20 * 60 { g.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.5)); t += cad } + XCTAssertTrue(SedentaryDetector.detectSedentaryBouts(g).isEmpty, + "a <15min stretch shouldn't count") + } + + // ── Pure time helpers (InactivityPrefs parity) ──────────────────────────── + + // epochSec for `hour:min` local when tz offset is 0. + private func atLocal(_ hour: Int, _ min: Int = 0) -> Int { hour * 3600 + min * 60 } + + func testLocalMinuteOfDay_mapsInstantToLocalMinute() { + XCTAssertEqual(SedentaryDetector.localMinuteOfDay(atLocal(8), tzOffsetSec: 0), 8 * 60) + XCTAssertEqual(SedentaryDetector.localMinuteOfDay(atLocal(14), tzOffsetSec: 0), 14 * 60) + // A UTC 08:00 instant in UTC+1 reads as 09:00 local. + XCTAssertEqual(SedentaryDetector.localMinuteOfDay(atLocal(8), tzOffsetSec: 3600), 9 * 60) + // Negative offset wraps correctly (UTC 00:30 in UTC-1 → 23:30 the previous local day). + XCTAssertEqual(SedentaryDetector.localMinuteOfDay(atLocal(0, 30), tzOffsetSec: -3600), 23 * 60 + 30) + } + + func testWindowContains_handlesWrapAround() { + // 9–17 straight window. + XCTAssertTrue(SedentaryDetector.windowContains(14 * 60, startMin: 9 * 60, endMin: 17 * 60)) + XCTAssertFalse(SedentaryDetector.windowContains(8 * 60, startMin: 9 * 60, endMin: 17 * 60)) + // 22:00–07:00 window (crosses midnight): 23:00 inside, 10:00 outside. + XCTAssertTrue(SedentaryDetector.windowContains(23 * 60, startMin: 22 * 60, endMin: 7 * 60)) + XCTAssertFalse(SedentaryDetector.windowContains(10 * 60, startMin: 22 * 60, endMin: 7 * 60)) + } + + // ── Decision / live-path guard ───────────────────────────────────────────── + + // The current sitting bout is "current": its end equals the newest sample, so newest-end == 0 ≤ maxGapS. + func testFiresAfterIdleThreshold() { + // 30 min of pure sitting → a single ≥15-min bout ending at the newest sample (still seated). + var sit: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { sit.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: true, + thresholdMinutes: 15, reNudgeMinutes: 30, buzzLoops: 3, + activeHoursEnabled: false, quietHoursEnabled: false, onlyWhenWorn: false) + let d = SedentaryDetector.evaluate(sit, state: .initial, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertTrue(d.shouldBuzz, "a 30-min current sedentary bout past the 15-min threshold should buzz") + XCTAssertEqual(d.buzzLoops, 3) + XCTAssertEqual(d.nextState.lastBuzzAt, newest) + XCTAssertEqual(d.nextState.lastBuzzedBoutStart, 0) + XCTAssertEqual(d.nextState.lastProcessedGravityTs, newest) + } + + func testDoesNotFireUnderThreshold() { + // 10 min sitting < 15-min threshold → no bout → no buzz. + var sit: [GravitySample] = [] + var t = 0 + while t <= 10 * 60 { sit.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: true, + thresholdMinutes: 15, activeHoursEnabled: false, + quietHoursEnabled: false, onlyWhenWorn: false) + let d = SedentaryDetector.evaluate(sit, state: .initial, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertFalse(d.shouldBuzz) + } + + func testDoesNotFireInsideCooldown() { + // Same continuing bout buzzed 10 min ago; re-nudge is 30 min → still in cooldown → no buzz. + var sit: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { sit.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: true, + thresholdMinutes: 15, reNudgeMinutes: 30, + activeHoursEnabled: false, quietHoursEnabled: false, onlyWhenWorn: false) + // Last buzz 10 min before now, for THIS bout (start 0 ≤ lastBuzzedBoutEnd, so it "continues"). + let prior = SedentaryState(lastProcessedGravityTs: 0, lastBuzzAt: newest - 10 * 60, + lastBuzzedBoutStart: 0, lastBuzzedBoutEnd: newest) + let d = SedentaryDetector.evaluate(sit, state: prior, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertFalse(d.shouldBuzz, "still inside the 30-min re-nudge cooldown") + + // ...but 31 min later the same bout re-nudges. + let later = SedentaryState(lastProcessedGravityTs: 0, lastBuzzAt: newest - 31 * 60, + lastBuzzedBoutStart: 0, lastBuzzedBoutEnd: newest) + let d2 = SedentaryDetector.evaluate(sit, state: later, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertTrue(d2.shouldBuzz, "past the re-nudge cadence the continuing bout buzzes again") + } + + func testDoesNotFireOutsideActiveHours() { + // A 30-min bout whose end maps to 08:00 local; active window is 09:00–17:00 → excluded. + // Anchor the window so the bout end == 08:00. Sitting starts at 07:30, ends 08:00. + let base = atLocal(7, 30) + var sit: [GravitySample] = [] + var t = base + while t <= base + 30 * 60 { sit.append(gravS(t, ((t - base) / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! // == 08:00 local + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: true, + thresholdMinutes: 15, activeHoursEnabled: true, + activeStartMinutes: 9 * 60, activeEndMinutes: 17 * 60, + quietHoursEnabled: false, onlyWhenWorn: false) + let d = SedentaryDetector.evaluate(sit, state: .initial, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertFalse(d.shouldBuzz, "a bout ending 08:00 is outside the 09:00–17:00 active window") + + // Same shape anchored to 14:00 IS inside the window → buzzes. + let base2 = atLocal(13, 30) + var sit2: [GravitySample] = [] + var t2 = base2 + while t2 <= base2 + 30 * 60 { sit2.append(gravS(t2, ((t2 - base2) / cad) % 2 == 0 ? 0.0 : 0.02)); t2 += cad } + let newest2 = sit2.map { $0.ts }.max()! // == 14:00 local + let d2 = SedentaryDetector.evaluate(sit2, state: .initial, config: cfg, + worn: true, nowSec: newest2, tzOffsetSec: 0) + XCTAssertTrue(d2.shouldBuzz, "a bout ending 14:00 is inside the active window") + } + + func testResetsOnDetectedMovement() { + // The bout ended (the user walked), so its end is far behind the newest sample → not current. + // 30 min sitting then 10 min walking; newest is at the end of the walk, bout end ~30 min, gap + // (newest - boutEnd) ~10 min > maxGapS? maxGapS is 20 min, so we use a longer walk to exceed it. + var g: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { g.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + while t <= 55 * 60 { g.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.5)); t += cad } // 25-min walk > 20-min maxGapS + let newest = g.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: true, + thresholdMinutes: 15, activeHoursEnabled: false, + quietHoursEnabled: false, onlyWhenWorn: false) + let d = SedentaryDetector.evaluate(g, state: .initial, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertFalse(d.shouldBuzz, "the user got up and walked — the stale bout must not re-buzz") + } + + func testRespectsDisabledFlag() { + var sit: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { sit.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: false, notificationsMasterOn: true, + thresholdMinutes: 15, activeHoursEnabled: false, + quietHoursEnabled: false, onlyWhenWorn: false) + let d = SedentaryDetector.evaluate(sit, state: .initial, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertFalse(d.shouldBuzz, "disabled → never buzz") + XCTAssertEqual(d.nextState, .initial, "disabled leaves state untouched") + } + + func testRespectsNotificationMasterOff() { + var sit: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { sit.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: false, + thresholdMinutes: 15, activeHoursEnabled: false, + quietHoursEnabled: false, onlyWhenWorn: false) + let d = SedentaryDetector.evaluate(sit, state: .initial, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertFalse(d.shouldBuzz, "master notification switch off → inert") + } + + func testRespectsOnlyWhenWorn() { + var sit: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { sit.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: true, + thresholdMinutes: 15, activeHoursEnabled: false, + quietHoursEnabled: false, onlyWhenWorn: true) + let d = SedentaryDetector.evaluate(sit, state: .initial, config: cfg, + worn: false, nowSec: newest, tzOffsetSec: 0) + XCTAssertFalse(d.shouldBuzz, "only-when-worn on + strap off → no buzz") + } + + func testReplayedOffloadDoesNotReBuzz() { + // The newest gravity ts hasn't advanced past lastProcessedGravityTs → a no-op (idempotent sync). + var sit: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { sit.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: true, + thresholdMinutes: 15, activeHoursEnabled: false, + quietHoursEnabled: false, onlyWhenWorn: false) + let prior = SedentaryState(lastProcessedGravityTs: newest) + let d = SedentaryDetector.evaluate(sit, state: prior, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertFalse(d.shouldBuzz, "a replayed / no-new-rows offload can't re-buzz") + XCTAssertEqual(d.nextState, prior, "no advance → state unchanged") + } + + func testNewBoutAfterMovementAlertsImmediately() { + // A fresh, distinct bout (starts after the last buzzed bout's end) alerts even within the + // re-nudge window, because it is NOT a continuation. + var sit: [GravitySample] = [] + var t = 0 + while t <= 30 * 60 { sit.append(gravS(t, (t / cad) % 2 == 0 ? 0.0 : 0.02)); t += cad } + let newest = sit.map { $0.ts }.max()! + let cfg = SedentaryConfig(enabled: true, notificationsMasterOn: true, + thresholdMinutes: 15, reNudgeMinutes: 30, + activeHoursEnabled: false, quietHoursEnabled: false, onlyWhenWorn: false) + // Last buzz was 5 min ago but for a PRIOR bout that ended well before this one started (start 0 + // > lastBuzzedBoutEnd would mean new; here we make the prior bout end negative-relative). + let prior = SedentaryState(lastProcessedGravityTs: 0, lastBuzzAt: newest - 5 * 60, + lastBuzzedBoutStart: -1000, lastBuzzedBoutEnd: -1) // ended before ts 0 + let d = SedentaryDetector.evaluate(sit, state: prior, config: cfg, + worn: true, nowSec: newest, tzOffsetSec: 0) + XCTAssertTrue(d.shouldBuzz, "a distinct new bout alerts on its own crossing, ignoring cooldown") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SkinTempAnalyticsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SkinTempAnalyticsTests.swift new file mode 100644 index 0000000000..a1316eb730 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SkinTempAnalyticsTests.swift @@ -0,0 +1,233 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Unit tests for the WHOOP 5.0/MG skin-temperature pipeline in AnalyticsEngine +/// (macOS parity with the Android SkinTempAnalyticsTest). +/// +/// Two parts: +/// 1. `AnalyticsEngine.wornNightlySkinTempC` — the wear-gated nightly-mean logic (the part +/// that turns raw skin_temp_raw@73 samples into a trustworthy per-night value). +/// 2. The seed→deviation flow over `Baselines.foldHistory`/`Baselines.deviation` with the +/// standard `skin_temp` config — pinning the honest cold-start gate (<4 nights ⇒ no +/// skinTempDevC) and that a real elevation surfaces as a positive deviation once seeded. +/// +/// SCALE NOTE: the firmware stores CENTIDEGREES in skin_temp_raw@73 — °C = raw/100, matching +/// the Android decoder/tests. (The earlier /128 "AS6221-native" assumption was disproven by the +/// real captures in Whoop5HistoricalTests: worn raw 3057 / off-wrist 2247 are 30.6 °C skin and +/// 22.5 °C room ambient under /100, but an impossible 23.9 °C "skin" under /128 — below the worn +/// gate, silently dropping every real night. PR #97 review / #166.) Worn nightly values on real +/// hardware are ~30–35 °C, off-wrist/charging ~22–27 °C — exactly the contamination the +/// wear-gate excludes. All values APPROXIMATE. +final class SkinTempAnalyticsTests: XCTestCase { + + private func session(start: Int, durSec: Int) -> SleepSession { + SleepSession(start: start, end: start + durSec, efficiency: 0.9, + stages: [], restingHR: 50, avgHRV: 60.0) + } + + private func hr(_ ts: Int, bpm: Int = 55) -> HRSample { HRSample(ts: ts, bpm: bpm) } + /// raw = °C × 100 (centidegrees, firmware scale): 34 °C → 3400, 36 °C → 3600, 22 °C → 2200. + private func skin(_ ts: Int, rawX100: Int) -> SkinTempSample { SkinTempSample(ts: ts, raw: rawX100) } + + // MARK: - wornNightlySkinTempC + + func testMeanOverWornInBedSamples() throws { + let start = 1_000_000 + let sess = [session(start: start, durSec: 600)] + let hrs = (0..<600).map { hr(start + $0) } + let temps = (0..<600).map { skin(start + $0, rawX100: 3400) } // 34.00 °C + let mean = try XCTUnwrap(AnalyticsEngine.wornNightlySkinTempC(sess, hr: hrs, skinTemp: temps)) + XCTAssertEqual(mean, 34.0, accuracy: 1e-9) + } + + func testExcludesSamplesWithoutConcurrentWornHr() { + // The strap streams HR only on-wrist; skin-temp samples with no concurrent worn BPM drop. + let start = 2_000_000 + let sess = [session(start: start, durSec: 600)] + let temps = (0..<600).map { skin(start + $0, rawX100: 3400) } + XCTAssertNil(AnalyticsEngine.wornNightlySkinTempC(sess, hr: [], skinTemp: temps)) + } + + func testExcludesDaytimeSamplesOutsideTheSleepSession() throws { + // Daytime samples are in worn range (36 °C) AND have worn HR, but fall OUTSIDE the in-bed + // session window, so only the in-bed 34 °C samples count. Isolates the session-window gate. + let night = 3_000_000 + let sess = [session(start: night, durSec: 600)] + let inBedHr = (0..<600).map { hr(night + $0) } + let inBedTemp = (0..<600).map { skin(night + $0, rawX100: 3400) } + let day = night + 10_000 + let dayHr = (0..<600).map { hr(day + $0) } + let dayTemp = (0..<600).map { skin(day + $0, rawX100: 3600) } // 36 °C, worn-range, daytime + let mean = try XCTUnwrap(AnalyticsEngine.wornNightlySkinTempC( + sess, hr: inBedHr + dayHr, skinTemp: inBedTemp + dayTemp)) + XCTAssertEqual(mean, 34.0, accuracy: 1e-9) + } + + func testExcludesOnChargerAmbientEvenInBed() { + // Mid-night on charger: HR still has stray worn-range values but skin temp drifts to + // ambient (~22 °C) — which passes the strap's looser decode gate but is below the worn + // floor of 28 °C. + let start = 4_000_000 + let sess = [session(start: start, durSec: 600)] + let hrs = (0..<600).map { hr(start + $0) } + let temps = (0..<600).map { skin(start + $0, rawX100: 2200) } // 22 °C ambient + XCTAssertNil(AnalyticsEngine.wornNightlySkinTempC(sess, hr: hrs, skinTemp: temps)) + } + + func testBelowMinSamplesIsNil() { + let start = 5_000_000 + let sess = [session(start: start, durSec: 100)] + let hrs = (0..<100).map { hr(start + $0) } + let temps = (0..<100).map { skin(start + $0, rawX100: 3400) } // 100 < minSkinTempSamples + XCTAssertNil(AnalyticsEngine.wornNightlySkinTempC(sess, hr: hrs, skinTemp: temps)) + } + + func testEmptyInputsAreNil() { + XCTAssertNil(AnalyticsEngine.wornNightlySkinTempC([], hr: [], skinTemp: [])) + } + + // MARK: - skin-temp funnel diagnostic (#752) + + /// The kept-path: the funnel's mean is byte-identical to `wornNightlySkinTempC`, and the drop buckets + + /// kept sum to the total (every sample is accounted for exactly once). + func testFunnelKeptPathMatchesMeanAndAccountsForEverySample() throws { + let start = 6_000_000 + let sess = [session(start: start, durSec: 600)] + let hrs = (0..<600).map { hr(start + $0) } + let temps = (0..<600).map { skin(start + $0, rawX100: 3400) } // 34 °C, all worn + in-window + let f = AnalyticsEngine.skinTempFunnel(sess, hr: hrs, skinTemp: temps) + XCTAssertEqual(f.totalSamples, 600) + XCTAssertEqual(f.kept, 600) + XCTAssertEqual(f.droppedNotWorn + f.droppedOutOfWindow + f.droppedOutOfRange + f.kept, f.totalSamples) + XCTAssertEqual(try XCTUnwrap(f.mean), 34.0, accuracy: 1e-9) + XCTAssertFalse(f.isAbsent) + // The mean exactly matches the public wrapper (they share gate logic, so can't diverge). + XCTAssertEqual(f.mean, AnalyticsEngine.wornNightlySkinTempC(sess, hr: hrs, skinTemp: temps)) + } + + /// 4.0-style "skin temp absent" triage: samples exist but NONE are worn (no concurrent live HR), so the + /// funnel attributes the whole loss to `droppedNotWorn` and the mean is absent. + func testFunnelAllNotWornExplainsAbsence() { + let start = 7_000_000 + let sess = [session(start: start, durSec: 600)] + let temps = (0..<600).map { skin(start + $0, rawX100: 3400) } + let f = AnalyticsEngine.skinTempFunnel(sess, hr: [], skinTemp: temps) + XCTAssertEqual(f.totalSamples, 600) + XCTAssertEqual(f.droppedNotWorn, 600) + XCTAssertEqual(f.kept, 0) + XCTAssertTrue(f.isAbsent) + XCTAssertTrue(f.summary.contains("notWorn=600"), "the summary names the dominant gate: \(f.summary)") + } + + /// Worn + in-window samples that drift to ambient (~22 °C, on-charger) all fail the worn-range gate, so + /// the loss is attributed to `droppedOutOfRange` - the user can see it was off-wrist drift, not a bug. + func testFunnelOutOfRangeIsAttributedToRangeGate() { + let start = 8_000_000 + let sess = [session(start: start, durSec: 600)] + let hrs = (0..<600).map { hr(start + $0) } + let temps = (0..<600).map { skin(start + $0, rawX100: 2200) } // 22 °C ambient + let f = AnalyticsEngine.skinTempFunnel(sess, hr: hrs, skinTemp: temps) + XCTAssertEqual(f.droppedOutOfRange, 600) + XCTAssertEqual(f.droppedNotWorn, 0) + XCTAssertEqual(f.kept, 0) + XCTAssertTrue(f.isAbsent) + } + + /// Worn samples outside every detected in-bed span are attributed to `droppedOutOfWindow`. With NO + /// session at all, every sample is out of window (matching the old early-return-nil behaviour). + func testFunnelOutOfWindowAndNoSession() { + let start = 9_000_000 + let sess = [session(start: start, durSec: 600)] + let hrs = (0..<600).map { hr(start + 100_000 + $0) } // worn, but far from the session + let temps = (0..<600).map { skin(start + 100_000 + $0, rawX100: 3400) } + let f = AnalyticsEngine.skinTempFunnel(sess, hr: hrs, skinTemp: temps) + XCTAssertEqual(f.droppedOutOfWindow, 600) + XCTAssertEqual(f.kept, 0) + XCTAssertTrue(f.isAbsent) + // No session → every sample is out of window, and the mean is absent (legacy early-return parity). + let none = AnalyticsEngine.skinTempFunnel([], hr: hrs, skinTemp: temps) + XCTAssertEqual(none.droppedOutOfWindow, 600) + XCTAssertTrue(none.isAbsent) + } + + /// Below the min-samples floor: every sample is kept but the mean is still absent (the last gate), and + /// `kept` reports the survivor count so the user sees "only N < min" rather than a silent nil. + func testFunnelBelowMinSamplesKeepsButMeanAbsent() { + let start = 10_000_000 + let sess = [session(start: start, durSec: 100)] + let hrs = (0..<100).map { hr(start + $0) } + let temps = (0..<100).map { skin(start + $0, rawX100: 3400) } // 100 < minSkinTempSamples + let f = AnalyticsEngine.skinTempFunnel(sess, hr: hrs, skinTemp: temps) + XCTAssertEqual(f.kept, 100) + XCTAssertGreaterThan(f.minSamples, 100) + XCTAssertTrue(f.isAbsent, "kept < minSamples → no trusted mean") + } + + // MARK: - device-family-aware conversion (#938) + + /// A WHOOP 4.0 v24 worn night (raw ~826–860, the reporter's steady worn baseline) produced NO nightly + /// mean under the old family-blind /100 (raw 826 → 8.3 °C, below the 28 °C worn gate, kept=0). With the + /// `.whoop4` scale those same raw values land ~33 °C and the night is kept — the fix. + func testWhoop4WornNightProducesMeanUnderFamilyAwareScale() throws { + let start = 11_000_000 + let sess = [session(start: start, durSec: 600)] + let hrs = (0..<600).map { hr(start + $0) } + // Steady worn 4.0 raw ~840 — impossible 8.4 °C under /100, plausible ~33.7 °C under the 4.0 map. + let temps = (0..<600).map { SkinTempSample(ts: start + $0, raw: 840) } + // Old behaviour (family-blind /100 == `.whoop5`): dropped, no mean. + XCTAssertNil(AnalyticsEngine.wornNightlySkinTempC(sess, hr: hrs, skinTemp: temps, family: .whoop5)) + // Fixed behaviour (`.whoop4`): a trusted nightly mean in the plausible worn band. + let mean = try XCTUnwrap(AnalyticsEngine.wornNightlySkinTempC(sess, hr: hrs, skinTemp: temps, family: .whoop4)) + XCTAssertGreaterThan(mean, 28.0) + XCTAssertLessThan(mean, 42.0) + } + + /// A 5/MG worn night is byte-identical whether `family` is defaulted or passed explicitly — the fix + /// changes nothing for the proven centidegree path. + func testWhoop5NightUnchangedByFamilyParameter() throws { + let start = 12_000_000 + let sess = [session(start: start, durSec: 600)] + let hrs = (0..<600).map { hr(start + $0) } + let temps = (0..<600).map { skin(start + $0, rawX100: 3400) } // 34 °C centidegrees + let defaulted = try XCTUnwrap(AnalyticsEngine.wornNightlySkinTempC(sess, hr: hrs, skinTemp: temps)) + let explicit = try XCTUnwrap(AnalyticsEngine.wornNightlySkinTempC(sess, hr: hrs, skinTemp: temps, family: .whoop5)) + XCTAssertEqual(defaulted, 34.0, accuracy: 1e-9) + XCTAssertEqual(defaulted, explicit) + } + + /// The funnel diagnostic reports the SAME family-aware outcome: a worn 4.0 night is kept under `.whoop4` + /// but all-out-of-range (dropped) under the family-blind `.whoop5` scale. + func testFunnelFamilyAwareAttribution() { + let start = 13_000_000 + let sess = [session(start: start, durSec: 600)] + let hrs = (0..<600).map { hr(start + $0) } + let temps = (0..<600).map { SkinTempSample(ts: start + $0, raw: 840) } + let w5 = AnalyticsEngine.skinTempFunnel(sess, hr: hrs, skinTemp: temps, family: .whoop5) + XCTAssertEqual(w5.droppedOutOfRange, 600, "under /100 the 4.0 worn raw reads ~8 °C, all out of range") + XCTAssertTrue(w5.isAbsent) + let w4 = AnalyticsEngine.skinTempFunnel(sess, hr: hrs, skinTemp: temps, family: .whoop4) + XCTAssertEqual(w4.kept, 600) + XCTAssertFalse(w4.isAbsent) + } + + // MARK: - seed → deviation (skin_temp baseline) + + private let skinCfg = Baselines.metricCfg["skin_temp"]! + + func testColdStartBelowSeedBaselineNotUsable() { + // 3 nightly means (< minNightsSeed = 4): still CALIBRATING → skinTempDevC stays nil. + let nights: [Double?] = [33.5, 33.6, 33.4] + XCTAssertFalse(Baselines.foldHistory(nights, cfg: skinCfg).usable) + } + + func testAtSeedUsableElevationShowsPositiveDeviation() { + // 4 baseline nights ~33.5 °C; a +0.8 °C night surfaces as a clearly positive deviation — + // the signal the illness watch reads as its skin-temp flag (fires at ≥ +0.6 °C). + let nights: [Double?] = [33.5, 33.4, 33.6, 33.5] + let base = Baselines.foldHistory(nights, cfg: skinCfg) + XCTAssertTrue(base.usable, "4 valid nights must seed a usable skin-temp baseline") + let dev = Baselines.deviation(34.3, state: base).delta + XCTAssertGreaterThan(dev, 0.5, "a +0.8 °C night must read as a clear positive deviation") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift new file mode 100644 index 0000000000..960357c1ad --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import StrandAnalytics + +final class SleepDebtTests: XCTestCase { + + /// Three nights at need (8 h = 480 min) → zero balance, three counted nights. + func testOnTargetNetsToZero() { + let series: [(day: String, totalSleepMin: Double?)] = [ + ("2026-06-01", 480), ("2026-06-02", 480), ("2026-06-03", 480), + ] + let l = SleepDebt.ledger(series: series, needHours: 8.0) + XCTAssertEqual(l.balanceMin, 0.0, accuracy: 1e-9) + XCTAssertEqual(l.nightCount, 3) + XCTAssertFalse(l.isDebt) + XCTAssertEqual(l.needMin, 480.0, accuracy: 1e-9) + } + + /// A surplus night offsets a deficit one (ledger nets credits and debits). + func testSurplusOffsetsDeficit() { + let series: [(day: String, totalSleepMin: Double?)] = [ + ("2026-06-01", 360), // −120 + ("2026-06-02", 540), // +60 + ("2026-06-03", 420), // −60 + ] + // need 8 h = 480. Σ = −120 + 60 − 60 = −120. + let l = SleepDebt.ledger(series: series, needHours: 8.0) + XCTAssertEqual(l.balanceMin, -120.0, accuracy: 1e-9) + XCTAssertTrue(l.isDebt) + XCTAssertEqual(l.magnitudeMin, 120.0, accuracy: 1e-9) + XCTAssertEqual(l.nights.map { $0.deltaMin }, [-120, 60, -60]) + } + + /// Nights with no usable sleep are skipped entirely (never zero-filled as debt). + func testSkipsNoDataNights() { + let series: [(day: String, totalSleepMin: Double?)] = [ + ("2026-06-01", 480), + ("2026-06-02", nil), // skipped + ("2026-06-03", 0), // skipped (non-positive) + ("2026-06-04", 420), // −60 + ] + let l = SleepDebt.ledger(series: series, needHours: 8.0) + XCTAssertEqual(l.nightCount, 2) + XCTAssertEqual(l.balanceMin, -60.0, accuracy: 1e-9) + XCTAssertEqual(l.nights.map { $0.day }, ["2026-06-01", "2026-06-04"]) + } + + /// Only the most-recent `window` COUNTED nights are in scope. + func testWindowCapKeepsMostRecent() { + // 16 nights, each 60 min UNDER need → each delta −60. + let series: [(day: String, totalSleepMin: Double?)] = (1...16).map { + (String(format: "2026-06-%02d", $0), Double(420)) + } + let l = SleepDebt.ledger(series: series, needHours: 8.0, window: 14) + XCTAssertEqual(l.nightCount, 14) // capped + XCTAssertEqual(l.balanceMin, -840.0, accuracy: 1e-9) // 14 × −60 + XCTAssertEqual(l.nights.first?.day, "2026-06-03") // oldest kept + XCTAssertEqual(l.nights.last?.day, "2026-06-16") // newest kept + } + + /// Empty / all-skipped input → empty ledger, zero balance. + func testEmptyLedger() { + let l = SleepDebt.ledger(series: [], needHours: 8.0) + XCTAssertEqual(l.balanceMin, 0.0, accuracy: 1e-9) + XCTAssertEqual(l.nightCount, 0) + XCTAssertTrue(l.nights.isEmpty) + + let allNil: [(day: String, totalSleepMin: Double?)] = [("2026-06-01", nil)] + XCTAssertEqual(SleepDebt.ledger(series: allNil).nightCount, 0) + } + + /// The default need is AnalyticsEngine.Rest.defaultNeedHours (8 h). + func testDefaultNeedIsEightHours() { + let l = SleepDebt.ledger(series: [("2026-06-01", 420)]) + XCTAssertEqual(l.needMin, AnalyticsEngine.Rest.defaultNeedHours * 60.0, accuracy: 1e-9) + XCTAssertEqual(l.balanceMin, -60.0, accuracy: 1e-9) + } + + /// A NEGATIVE EXACT half-tie balance rounds AWAY from zero (−0.05 → −0.1), the documented + /// `round1` contract the Kotlin mirror must match (audit #6). needMin = 0.1, slept 0.05 → + /// delta −0.05 exactly → balance −0.1. Kotlin's old `roundToInt()` (half toward +∞) gave + /// 0.0 on this exact tie — the real divergence this pins shut. + func testNegativeHalfTieRoundsAwayFromZero() { + let l = SleepDebt.ledger(series: [("2026-06-01", 0.05)], needHours: 0.1 / 60.0) + XCTAssertEqual(l.balanceMin, -0.1, accuracy: 1e-9) // away-from-zero, not 0.0 + } + + /// The symmetric POSITIVE exact half-tie (+0.05 → +0.1), pinned so the sign-aware Kotlin + /// rounding can't silently regress one direction. needMin = 0, slept 0.05 → delta +0.05. + func testPositiveHalfTieRoundsAwayFromZero() { + let l = SleepDebt.ledger(series: [("2026-06-01", 0.05)], needHours: 0.0) + XCTAssertEqual(l.balanceMin, 0.1, accuracy: 1e-9) + } + + /// round1 is exercised on the directly-constructed value too, so the rounding mode is + /// pinned independent of the (slept − need) arithmetic path, both signs + a non-tie. + func testRound1HalfTiesAwayFromZero() { + XCTAssertEqual(SleepDebt.round1(-0.05), -0.1, accuracy: 1e-9) + XCTAssertEqual(SleepDebt.round1(0.05), 0.1, accuracy: 1e-9) + XCTAssertEqual(SleepDebt.round1(-0.04), 0.0, accuracy: 1e-9) + XCTAssertEqual(SleepDebt.round1(-0.25), -0.3, accuracy: 1e-9) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepEditGuardTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepEditGuardTests.swift new file mode 100644 index 0000000000..6839b8880e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepEditGuardTests.swift @@ -0,0 +1,168 @@ +import XCTest +@testable import StrandAnalytics + +/// #940: the sleep-time editor accepted an impossible bed time. Rolling the bed TIME back across +/// midnight (01:06 -> 23:00) kept the calendar date, so the "corrected" bed landed on the coming +/// evening: a future-dated night the Sleep tab could not render. These pin the three pure guard +/// rules (Android twin: SleepEditGuardTest.kt). +final class SleepEditGuardTests: XCTestCase { + + /// Fixed UTC calendar so day math is deterministic regardless of the runner's zone. + private var cal: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + + private func date(_ y: Int, _ mo: Int, _ d: Int, _ h: Int, _ mi: Int) -> Date { + cal.date(from: DateComponents(year: y, month: mo, day: d, hour: h, minute: mi))! + } + + // MARK: - Rule 1: cross-midnight bed auto-correct + + /// THE #940 SHAPE: night tracked late (bed 01:06, wake 05:00 on 2 Jul), user rolls the bed TIME + /// back to 23:00 at 05:03 the same morning. The picker kept the date on 2 Jul, so the candidate + /// is tonight (future, and past the wake). The guard snaps it to 1 Jul 23:00: the evening the + /// user meant. + func testCrossMidnightRollBackDecrementsDate() { + let previous = date(2026, 7, 2, 1, 6) // seeded effective onset + let candidate = date(2026, 7, 2, 23, 0) // rolled back to 23:00, date unchanged + let wake = date(2026, 7, 2, 5, 0) + let now = date(2026, 7, 2, 5, 3) + let corrected = SleepEditGuard.autoCorrectedBed( + previousBed: previous, candidateBed: candidate, originalWake: wake, now: now, calendar: cal) + XCTAssertEqual(corrected, date(2026, 7, 1, 23, 0)) + } + + /// Same roll made in the EVENING (now 23:30, so 23:00 today is not future) still decrements: + /// the candidate sits at/after the night's wake, which is impossible for that night's bed. + func testPastWakeButNotFutureStillDecrements() { + let previous = date(2026, 7, 2, 1, 6) + let candidate = date(2026, 7, 2, 23, 0) + let wake = date(2026, 7, 2, 5, 0) + let now = date(2026, 7, 2, 23, 30) + let corrected = SleepEditGuard.autoCorrectedBed( + previousBed: previous, candidateBed: candidate, originalWake: wake, now: now, calendar: cal) + XCTAssertEqual(corrected, date(2026, 7, 1, 23, 0)) + } + + /// MOVE-LATER (the finding's missing case): a user drags a session's bed LATER, past its own wake, + /// on the SAME day (nap 14:00-15:00 -> bed 16:00 today, wake 15:00 today). The candidate is at/after + /// the wake but in the PAST, so the old rule shoved it back a full day into a ~23h wrong-day window. + /// Decrementing here would form an implausible 23h night, so the candidate must be left VERBATIM. + func testMoveLaterPastWakeIsNotDecremented() { + let previous = date(2026, 7, 2, 14, 0) // nap start being edited + let candidate = date(2026, 7, 2, 16, 0) // rolled LATER, still same day, after the 15:00 wake + let wake = date(2026, 7, 2, 15, 0) + let now = date(2026, 7, 2, 20, 0) // evening: 16:00 today is in the past, not future + let corrected = SleepEditGuard.autoCorrectedBed( + previousBed: previous, candidateBed: candidate, originalWake: wake, now: now, calendar: cal) + XCTAssertEqual(corrected, candidate, "a plausible move-later must not be shoved back a day") + } + + /// A normal correction (01:06 -> 00:30, still before the wake, in the past) is untouched. + func testSaneEditIsUntouched() { + let previous = date(2026, 7, 2, 1, 6) + let candidate = date(2026, 7, 2, 0, 30) + let wake = date(2026, 7, 2, 5, 0) + let now = date(2026, 7, 2, 6, 45) + let corrected = SleepEditGuard.autoCorrectedBed( + previousBed: previous, candidateBed: candidate, originalWake: wake, now: now, calendar: cal) + XCTAssertEqual(corrected, candidate) + } + + /// A DELIBERATE date change (candidate on a different calendar day from the previous value) is + /// always respected verbatim: the rule only rescues time-only rolls. + func testDeliberateDateChangeIsRespected() { + let previous = date(2026, 7, 2, 1, 6) + let candidate = date(2026, 6, 28, 6, 0) // user moved the date wheel back four days + let wake = date(2026, 7, 2, 5, 0) + let now = date(2026, 7, 2, 6, 45) + let corrected = SleepEditGuard.autoCorrectedBed( + previousBed: previous, candidateBed: candidate, originalWake: wake, now: now, calendar: cal) + XCTAssertEqual(corrected, candidate) + } + + /// Add-a-nap (no originalWake): only the FUTURE test applies. A nap start after the night's + /// wake is normal and stays; a future nap start snaps back a day. + func testNapStartOnlyFutureRuleApplies() { + let previous = date(2026, 7, 2, 6, 0) // seed anchor: an hour after wake + let now = date(2026, 7, 2, 18, 0) + // 14:00 today: after the wake but in the past -> untouched. + let pastNap = SleepEditGuard.autoCorrectedBed( + previousBed: previous, candidateBed: date(2026, 7, 2, 14, 0), + originalWake: nil, now: now, calendar: cal) + XCTAssertEqual(pastNap, date(2026, 7, 2, 14, 0)) + // 22:00 today: in the future -> the user means a nap that already happened; snap back a day. + let futureNap = SleepEditGuard.autoCorrectedBed( + previousBed: previous, candidateBed: date(2026, 7, 2, 22, 0), + originalWake: nil, now: now, calendar: cal) + XCTAssertEqual(futureNap, date(2026, 7, 1, 22, 0)) + } + + /// If decrementing a day would STILL be in the future (unreachable from a real time-only roll, + /// but the rule must not loop or overshoot) the candidate is returned unchanged; the disjoint + /// confirm and the persistence clamp are the layers behind it. + func testDecrementThatStaysFutureIsNotApplied() { + let previous = date(2026, 7, 5, 1, 0) + let candidate = date(2026, 7, 5, 23, 0) + let now = date(2026, 7, 2, 6, 45) + let corrected = SleepEditGuard.autoCorrectedBed( + previousBed: previous, candidateBed: candidate, originalWake: nil, now: now, calendar: cal) + XCTAssertEqual(corrected, candidate) + } + + // MARK: - Rule 2: disjoint-from-coverage detection + + func testOverlappingWindowIsNotDisjoint() { + // Coverage 01:06-05:00; corrected 23:00 (prev day) - 05:00 overlaps it. + XCTAssertFalse(SleepEditGuard.isDisjoint(newStart: 1000, newEnd: 5000, + coverageStart: 2000, coverageEnd: 5000)) + // Window fully inside coverage. + XCTAssertFalse(SleepEditGuard.isDisjoint(newStart: 2500, newEnd: 3000, + coverageStart: 2000, coverageEnd: 5000)) + } + + func testFullyFutureWindowIsDisjoint() { + // THE #940 SHAPE: coverage 01:06-05:00 today; corrected window tonight 23:00 -> 05:00 tomorrow. + XCTAssertTrue(SleepEditGuard.isDisjoint(newStart: 80_000, newEnd: 100_000, + coverageStart: 2_000, coverageEnd: 18_000)) + } + + func testFullyPastWindowIsDisjoint() { + XCTAssertTrue(SleepEditGuard.isDisjoint(newStart: 0, newEnd: 1_000, + coverageStart: 2_000, coverageEnd: 18_000)) + } + + /// Touching endpoints share no samples: still disjoint (half-open window semantics). + func testTouchingWindowIsDisjoint() { + XCTAssertTrue(SleepEditGuard.isDisjoint(newStart: 18_000, newEnd: 20_000, + coverageStart: 2_000, coverageEnd: 18_000)) + XCTAssertTrue(SleepEditGuard.isDisjoint(newStart: 0, newEnd: 2_000, + coverageStart: 2_000, coverageEnd: 18_000)) + } + + // MARK: - Rule 3: persistence clamp + + func testPastWindowPersistsUnchanged() { + let w = SleepEditGuard.clampedEditWindow(start: 1_000, end: 5_000, now: 10_000) + XCTAssertEqual(w?.start, 1_000) + XCTAssertEqual(w?.end, 5_000) + } + + func testFutureEndIsCappedAtNowPlusSlack() { + let w = SleepEditGuard.clampedEditWindow(start: 1_000, end: 50_000, now: 10_000, slackSec: 300) + XCTAssertEqual(w?.start, 1_000) + XCTAssertEqual(w?.end, 10_300) + } + + func testFullyFutureWindowIsRefused() { + // THE #940 phantom: both ends after now. Capping the end lands at/below the start -> nil. + XCTAssertNil(SleepEditGuard.clampedEditWindow(start: 80_000, end: 100_000, now: 10_000)) + } + + func testInvertedWindowIsRefused() { + XCTAssertNil(SleepEditGuard.clampedEditWindow(start: 5_000, end: 4_000, now: 10_000)) + XCTAssertNil(SleepEditGuard.clampedEditWindow(start: 5_000, end: 5_000, now: 10_000)) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepReadoutTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepReadoutTests.swift new file mode 100644 index 0000000000..296f05ad84 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepReadoutTests.swift @@ -0,0 +1,83 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +final class SleepReadoutTests: XCTestCase { + func testHrDensityPerMinute() { + // 600 HR samples over 599 s span -> ~60 samples/min. + let start = 1_749_513_600 + let hr = (0..<600).map { HRSample(ts: start + $0, bpm: 50) } + let d = SleepReadout.hrDensityPerMinute(hr: hr) + XCTAssertEqual(d, 60.1, accuracy: 0.2) + } + + func testHrDensityFewerThanTwoSamplesIsZero() { + XCTAssertEqual(SleepReadout.hrDensityPerMinute(hr: []), 0) + XCTAssertEqual(SleepReadout.hrDensityPerMinute(hr: [HRSample(ts: 0, bpm: 50)]), 0) + } + + func testGravityCoverageFraction() { + // Gravity spanning the whole HR window -> coverage ~1.0 (dense, not sparse). + let start = 1_749_513_600 + let hr = (0..<600).map { HRSample(ts: start + $0, bpm: 50) } + let grav = (0..<600).map { GravitySample(ts: start + $0, x: 0, y: 0, z: 1.0) } + let c = SleepReadout.gravityCoverageFraction(gravity: grav, hr: hr) + XCTAssertGreaterThan(c, 0.9) + } + + func testGravityCoverageSparseIsBelowGate() { + // Gravity clumped into the first quarter of the HR window -> sparse (< sparseGravitySpanFrac). + let start = 1_749_513_600 + let hr = (0..<600).map { HRSample(ts: start + $0, bpm: 50) } + let grav = (0..<150).map { GravitySample(ts: start + $0, x: 0, y: 0, z: 1.0) } + let c = SleepReadout.gravityCoverageFraction(gravity: grav, hr: hr) + XCTAssertLessThan(c, SleepStager.sparseGravitySpanFrac) + } + + func testLastGateFiredParsesTaggedTail() { + let tail = [ + "[sleep] gate run=0 spanS=1800 DROPPED gate=minSleepMin spanMin=30 minSleepMin=60", + "[sleep] gate run=1 spanS=5400 KEPT gate=accepted spanMin=90 eff=0.9 restingHR=50 daytime=false", + ] + XCTAssertEqual(SleepReadout.lastGateFired(taggedTail: tail), "accepted") + } + + func testLastGateFiredNilWhenNoGateLine() { + XCTAssertNil(SleepReadout.lastGateFired(taggedTail: ["[sleep] sleep day=2021-06-17 totalSleepMin=420"])) + XCTAssertNil(SleepReadout.lastGateFired(taggedTail: [])) + } +} + +/// The Recovery / HRV live-readout parsers (Test Centre Group G). Twin of the Android TestReadout tests. +final class TestReadoutTests: XCTestCase { + func testLastChargeBreakdownParsesScoreAndBand() { + let tail = [ + "[recovery] charge day=2021-06-17 baseline hrv mean=50.0 spread=4.79 nValid=14 status=trusted", + "[recovery] charge day=2021-06-17 score=62.5 band=yellow (logistic k=1.6 z0=-0.2)", + ] + XCTAssertEqual(TestReadout.lastChargeBreakdown(taggedTail: tail), "score=62.5 band=yellow") + } + + func testLastChargeBreakdownFallsBackToNilReason() { + let tail = ["[recovery] charge day=2021-06-17 nilScore reason=hrvBaselineNotUsable hrvStatus=calibrating hrvNValid=2 (need nValid>=4)"] + XCTAssertEqual(TestReadout.lastChargeBreakdown(taggedTail: tail), "no score (hrvBaselineNotUsable)") + } + + func testLastChargeBreakdownNilWhenNoTrace() { + XCTAssertNil(TestReadout.lastChargeBreakdown(taggedTail: [])) + XCTAssertNil(TestReadout.lastChargeBreakdown(taggedTail: ["[sleep] gate run=0 ... gate=accepted"])) + } + + func testLastHrvComputationParsesRmssdFragment() { + let tail = [ + "[hrv] hrv path=spot nInput=60 nClean=58 rejectedFraction=0.03", + "[hrv] hrv rmssd=42.1ms sdnn=55.3ms meanNN=812.0ms", + ] + XCTAssertEqual(TestReadout.lastHrvComputation(taggedTail: tail), "rmssd=42.1ms sdnn=55.3ms meanNN=812.0ms") + } + + func testLastHrvComputationReportsFilteredOut() { + let tail = ["[hrv] hrv result=nil (a gate above refused the reading)"] + XCTAssertEqual(TestReadout.lastHrvComputation(taggedTail: tail), "no reading (filtered out)") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStageTotalsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStageTotalsTests.swift new file mode 100644 index 0000000000..837f20489a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStageTotalsTests.swift @@ -0,0 +1,1191 @@ +import XCTest +import Foundation +import WhoopStore +import WhoopProtocol +@testable import StrandAnalytics + +final class SleepStageTotalsTests: XCTestCase { + + func testMinutesFromSegmentArray() throws { + let json = """ + [{"start":0,"end":600,"stage":"light"}, + {"start":600,"end":1200,"stage":"deep"}, + {"start":1200,"end":1500,"stage":"wake"}] + """ + let m = try XCTUnwrap(SleepStageTotals.minutes(fromStagesJSON: json)) + XCTAssertEqual(m.light, 10, accuracy: 0.001) + XCTAssertEqual(m.deep, 10, accuracy: 0.001) + XCTAssertEqual(m.awake, 5, accuracy: 0.001) // "wake" → awake + XCTAssertEqual(m.asleep, 20, accuracy: 0.001) + XCTAssertEqual(m.inBed, 25, accuracy: 0.001) + } + + func testMinutesFromMinuteDict() throws { + let m = try XCTUnwrap(SleepStageTotals.minutes(fromStagesJSON: + #"{"awake":20,"light":200,"deep":80,"rem":90}"#)) + XCTAssertEqual(m.asleep, 370, accuracy: 0.001) + XCTAssertEqual(m.inBed, 390, accuracy: 0.001) + } + + func testDailyAggregateSumsBlocksAndComputesEfficiency() throws { + let agg = try XCTUnwrap(SleepStageTotals.dailyAggregate([ + #"{"awake":10,"light":100,"deep":40,"rem":50}"#, // a nap-ish block + #"{"awake":10,"light":100,"deep":40,"rem":40}"#, + ])) + XCTAssertEqual(agg.totalSleepMin, 370, accuracy: 0.001) // (190 + 180) + XCTAssertEqual(agg.deepMin, 80, accuracy: 0.001) + XCTAssertEqual(agg.efficiency, 370.0 / 390.0, accuracy: 0.0001) + } + + func testNilAndGarbage() { + XCTAssertNil(SleepStageTotals.minutes(fromStagesJSON: nil)) + XCTAssertNil(SleepStageTotals.minutes(fromStagesJSON: "nope")) + XCTAssertNil(SleepStageTotals.dailyAggregate([nil, "garbage"])) + } + + // MARK: - the integration seam: detected blocks + edits → corrected daily + + private let detectedNight = "2026-06-14T23:24" // doc only + private func detected(_ startTs: Int, _ stages: String) -> (startTs: Int, stagesJSON: String?) { + (startTs: startTs, stagesJSON: stages) + } + + func testHonoringEditsNoEditsLeavesDetectedSumAndFlagsFalse() throws { + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [detected(1000, #"{"awake":24,"light":214,"deep":82,"rem":96}"#)], + edited: [:])) + XCTAssertFalse(r.editApplied) + XCTAssertEqual(r.sleep.totalSleepMin, 392, accuracy: 0.001) // 214+82+96 + } + + func testHonoringEditsSubstitutesEditedBlockByStartTs() throws { + // Detected says 6h32m; the user's edit (same startTs 1000) trimmed it to ~4h56m. + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [detected(1000, #"{"awake":24,"light":214,"deep":82,"rem":96}"#)], + edited: [1000: #"{"awake":0,"light":118,"deep":82,"rem":96}"#])) + XCTAssertTrue(r.editApplied, "a startTs match must apply the edit") + XCTAssertEqual(r.sleep.totalSleepMin, 296, accuracy: 0.001, "totals come from the EDITED stages") + XCTAssertEqual(r.sleep.lightMin, 118, accuracy: 0.001) + XCTAssertEqual(r.sleep.efficiency, 296.0 / 296.0, accuracy: 0.001) // awake 0 → 100% efficient + } + + func testHonoringEditsKeepsDetectedWhenEditMapsToNil() throws { + // An edit whose reshaped stages came out nil must FALL BACK to the detected block, never drop it + // (which would collapse the night's sleep total). (#318 review #4) + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [(startTs: 1000, stagesJSON: #"{"awake":24,"light":214,"deep":82,"rem":96}"#)], + edited: [1000: nil])) + XCTAssertFalse(r.editApplied, "a nil edit is not a usable substitution") + XCTAssertEqual(r.sleep.totalSleepMin, 392, accuracy: 0.001, "detected stages kept, not dropped") + } + + func testHonoringEditsIgnoresEditWithNonMatchingStartTs() throws { + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [detected(1000, #"{"awake":24,"light":214,"deep":82,"rem":96}"#)], + edited: [9999: #"{"awake":0,"light":10,"deep":10,"rem":10}"#])) // wrong key + XCTAssertFalse(r.editApplied, "an edit that matches no detected block must not apply") + XCTAssertEqual(r.sleep.totalSleepMin, 392, accuracy: 0.001) + } + + func testHonoringEditsMultiBlockSubstitutesOnlyTheEditedBlock() throws { + // A nap (startTs 100, untouched) + a main sleep (startTs 1000, edited shorter). + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [detected(100, #"{"awake":2,"light":30,"deep":10,"rem":8}"#), + detected(1000, #"{"awake":24,"light":214,"deep":82,"rem":96}"#)], + edited: [1000: #"{"awake":0,"light":118,"deep":82,"rem":96}"#])) + XCTAssertTrue(r.editApplied) + // nap asleep 48 + edited main asleep 296 = 344 + XCTAssertEqual(r.sleep.totalSleepMin, 344, accuracy: 0.001) + } + + /// The point of the whole exercise: a shorter (hand-corrected) window yields a LOWER Rest composite, + /// so the daily aggregate genuinely moves when sleep is trimmed — not just the Sleep tab's label. + func testRestCompositeDropsWhenEditedWindowIsShorter() throws { + func daily(_ s: SleepStageTotals.DailySleep) -> DailyMetric { + DailyMetric(day: "2026-06-15", totalSleepMin: s.totalSleepMin, efficiency: s.efficiency, + deepMin: s.deepMin, remMin: s.remMin, lightMin: s.lightMin, disturbances: nil, + restingHr: nil, avgHrv: nil, recovery: nil, strain: nil, exerciseCount: nil) + } + let detected = try XCTUnwrap(SleepStageTotals.dailyAggregate( + [#"{"awake":24,"light":214,"deep":82,"rem":96}"#])) // ~6h32m asleep + let edited = try XCTUnwrap(SleepStageTotals.dailyAggregate( + [#"{"awake":0,"light":118,"deep":82,"rem":96}"#])) // woke ~2h earlier + + let before = try XCTUnwrap(AnalyticsEngine.Rest.composite(daily: daily(detected))) + let after = try XCTUnwrap(AnalyticsEngine.Rest.composite(daily: daily(edited))) + XCTAssertLessThan(after, before, "trimming sleep must lower the Rest composite") + } + + // MARK: - #525 canonical main-night selection (numbers reconcile across screens) + + /// A "yyyy-MM-dd'T'HH:mm" UTC wall-clock as unix seconds. UTC offset 0 in these tests, so local == UTC. + private func ts525(_ iso: String) -> Int { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = "yyyy-MM-dd'T'HH:mm" + return Int(f.date(from: iso)!.timeIntervalSince1970) + } + + func testMainNightPrefersOvernightOverLongerDaytimeNap() { + let nightStart = ts525("2026-06-14T23:00") // overnight onset + let napStart = ts525("2026-06-15T13:00") // daytime onset + // The nap is LONGER in clock span, but the overnight block must still win. + let blocks = [ + SleepStageTotals.NightBlock(start: napStart, end: napStart + 5 * 3600), // 5h daytime + SleepStageTotals.NightBlock(start: nightStart, end: nightStart + 4 * 3600), // 4h overnight + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 1, + "the overnight block is the main night even when a nap is longer") + } + + func testMainNightLongestAmongOvernightBlocks() { + let a = ts525("2026-06-14T22:00") + let b = ts525("2026-06-14T23:30") + let blocks = [ + SleepStageTotals.NightBlock(start: a, end: a + 3 * 3600), // 3h + SleepStageTotals.NightBlock(start: b, end: b + 6 * 3600), // 6h — longer overnight wins + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 1) + } + + func testMainNightEmptyAndTieAreDeterministic() { + XCTAssertNil(SleepStageTotals.mainNightIndex([], offsetSec: 0)) + // Two SCORE-TIED blocks (equal duration AND equal circular distance to the cold-start anchor, + // mirrored either side of 03:30) → the EARLIER onset breaks the tie (stable across platforms). + let early = ts525("2026-06-15T00:30") // 4h → mid 02:30, 1h before the 03:30 anchor + let late = ts525("2026-06-15T02:30") // 4h → mid 04:30, 1h after → SAME bonus, SAME duration + let blocks = [ + SleepStageTotals.NightBlock(start: late, end: late + 4 * 3600), + SleepStageTotals.NightBlock(start: early, end: early + 4 * 3600), + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 1, + "score tie (equal duration + equal anchor distance) → earlier onset breaks it") + } + + /// #555 regression: a biphasic / briefly-interrupted main night (fragments split by short wakes) must + /// resolve to ONE bridged GROUP containing ALL its fragments, while a distant afternoon nap stays + /// OUTSIDE the group. The Sleep tab classifies naps as "not in this group" and aggregates the group for + /// the hero, so the bridged siblings are no longer rendered as phantom naps the way the bare + /// single-block selector left them (the #555 report: "three naps instead of a continuous sleep"). + func testBiphasicNightGroupsAllFragmentsAndExcludesNap() { + // Three fragments of ONE night, each separated by a < 60 min wake gap (so they bridge), plus an + // afternoon nap > 60 min away (so it does NOT bridge). + let f1 = ts525("2026-06-14T23:00") // 23:00–01:00 + let f2 = ts525("2026-06-15T01:40") // 01:40–04:00 (40 min gap → bridges) + let f3 = ts525("2026-06-15T04:30") // 04:30–07:00 (30 min gap → bridges) + let nap = ts525("2026-06-15T14:00") // 14:00–15:00 (7 h gap → does NOT bridge) + let blocks = [ + SleepStageTotals.NightBlock(start: f1, end: f1 + 2 * 3600), + SleepStageTotals.NightBlock(start: f2, end: f2 + 140 * 60), + SleepStageTotals.NightBlock(start: f3, end: f3 + 150 * 60), + SleepStageTotals.NightBlock(start: nap, end: nap + 3600), + ] + let group = SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0) + XCTAssertEqual(group, [0, 1, 2], + "all three bridged night fragments are the main group; the afternoon nap is excluded") + // The BARE single-block selector picks only ONE fragment — exactly why the un-bridged tab labelled + // the other two as naps. The GROUP is what the tab and engine must share. (#555) + let single = SleepStageTotals.mainNightIndex(blocks, offsetSec: 0) + XCTAssertNotNil(single) + XCTAssertTrue([0, 1, 2].contains(single ?? -1), + "the bare winner is one of the night fragments, never the afternoon nap") + XCTAssertFalse(group?.contains(3) ?? true, "the afternoon nap is never in the main-night group") + } + + /// IRON-RULE REGRESSION GUARD (#547 / #407 lanes): the 6.1.1 bridged main-night SELECTION must NOT move + /// when the upstream ingest gate (#547) or the downstream motion trace (#407) change. This pins + /// `mainNightGroupIndices` for a biphasic main night to a BYTE-IDENTICAL expected output so any future + /// edit that perturbs `mainNightGroupIndices` / `mainNightIndex` / the bridge is caught immediately. + /// The values are hard-coded (not re-derived) so the test is a frozen golden, exactly the "before/after" + /// the lane brief requires. + func testMainNightGroupIndicesByteIdenticalForBiphasicNight() { + // A biphasic main night: two fragments split by a 35-min wake gap (< gapBridgeMaxMin → they bridge), + // plus a far-away afternoon nap that must stay OUT of the group. Same shape as the bridge fixture. + let a = ts525("2026-06-14T23:10") // 23:10–01:30 + let b = ts525("2026-06-15T02:05") // 02:05–06:40 (35 min gap → bridges) + let nap = ts525("2026-06-15T15:00") // 15:00–16:20 (far → does NOT bridge) + let blocks = [ + SleepStageTotals.NightBlock(start: a, end: a + 140 * 60), // idx 0 + SleepStageTotals.NightBlock(start: b, end: b + 275 * 60), // idx 1 + SleepStageTotals.NightBlock(start: nap, end: nap + 80 * 60), // idx 2 + ] + // FROZEN GOLDEN: the two bridged night fragments are the group; the nap is excluded. If this value + // changes, the 6.1.1 main-night selection moved — STOP and investigate (the iron rule). + XCTAssertEqual(SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0), [0, 1]) + // Cold-start AND learned-habitual must both land identically (the selection is timing-independent + // here because the night dominates by duration), proving neither path perturbs the bridge. + let habitualMid = SleepStageTotals.localSecOfDay(a + 140 * 60 / 2, offsetSec: 0) + XCTAssertEqual( + SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0, habitualMidsleepSec: habitualMid), + [0, 1]) + // And the bare single-block winner stays inside the group (never the nap). + XCTAssertTrue([0, 1].contains(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0) ?? -1)) + } + + /// THE #525 invariant: a day with an overnight + a nap reports CONSISTENT totals — the day's + /// canonical figure equals the MAIN NIGHT's sleep, NOT the night+nap sum. The honoring-edits seam + /// (with onsets supplied) and the standalone main-night aggregate agree to the minute. + func testOvernightPlusNapReportsConsistentTotalsNotTheSum() throws { + let nightStart = ts525("2026-06-14T23:00") + let napStart = ts525("2026-06-15T14:00") + let nightStages = #"{"awake":24,"light":214,"deep":82,"rem":96}"# // 392 min asleep + let napStages = #"{"awake":2,"light":30,"deep":10,"rem":8}"# // 48 min asleep + + // What the Sleep tab's hero shows for this day = the main night's own aggregate. + let mainOnly = try XCTUnwrap(SleepStageTotals.dailyAggregate([nightStages])) + XCTAssertEqual(mainOnly.totalSleepMin, 392, accuracy: 0.001) + + // The honoring-edits seam (no edits, but onsets supplied) must report the SAME main-night total, + // never the 392 + 48 = 440 sum the old code produced. + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [(startTs: nightStart, stagesJSON: nightStages), + (startTs: napStart, stagesJSON: napStages)], + edited: [:], + onsetByStart: [nightStart: nightStart, napStart: napStart], + offsetSec: 0)) + XCTAssertFalse(r.editApplied) + XCTAssertEqual(r.sleep.totalSleepMin, mainOnly.totalSleepMin, accuracy: 0.001, + "day total must equal the MAIN night, not the night+nap sum") + XCTAssertEqual(r.sleep.deepMin, mainOnly.deepMin, accuracy: 0.001) + XCTAssertEqual(r.sleep.remMin, mainOnly.remMin, accuracy: 0.001) + XCTAssertNotEqual(r.sleep.totalSleepMin, 440, accuracy: 0.001, "must NOT sum the nap in") + } + + /// A hand-corrected (trimmed) main night still wins the pick, and the day total tracks the EDITED + /// main night — the nap is never folded into the headline figure. + func testHonoringEditsMainNightModeTracksEditedNightNotNapSum() throws { + let nightStart = ts525("2026-06-14T23:00") + let napStart = ts525("2026-06-15T14:00") + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [(startTs: nightStart, stagesJSON: #"{"awake":24,"light":214,"deep":82,"rem":96}"#), + (startTs: napStart, stagesJSON: #"{"awake":2,"light":30,"deep":10,"rem":8}"#)], + edited: [nightStart: #"{"awake":0,"light":118,"deep":82,"rem":96}"#], // trimmed to 296 + onsetByStart: [nightStart: nightStart, napStart: napStart], + offsetSec: 0)) + XCTAssertTrue(r.editApplied) + XCTAssertEqual(r.sleep.totalSleepMin, 296, accuracy: 0.001, + "day total tracks the EDITED main night, nap excluded from the headline figure") + } + + /// Backward-compat: with NO onsets supplied the seam keeps the legacy sum-of-all-blocks total, so + /// any caller still on the old signature is unchanged. + func testHonoringEditsLegacySumWhenNoOnsets() throws { + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [(startTs: 100, stagesJSON: #"{"awake":2,"light":30,"deep":10,"rem":8}"#), + (startTs: 1000, stagesJSON: #"{"awake":24,"light":214,"deep":82,"rem":96}"#)], + edited: [:])) + XCTAssertEqual(r.sleep.totalSleepMin, 48 + 392, accuracy: 0.001, "no onsets → legacy sum") + } + + // MARK: - #777/#705 inter-fragment awake (out-of-bed gap between bridged fragments) + + /// The shared definition: sum only the POSITIVE gaps between consecutive (start,end) spans, sorted by + /// start. Abutting / overlapping fragments contribute 0; an unsorted input is sorted first. + func testInterFragmentAwakeSecondsSumsPositiveGapsOnly() { + // One 20-min gap between two fragments (06:00 → 06:20). + let f1 = (start: 0, end: 6 * 3600) + let f2 = (start: 6 * 3600 + 20 * 60, end: 9 * 3600) + XCTAssertEqual(SleepStageTotals.interFragmentAwakeSeconds([f1, f2]), Double(20 * 60), accuracy: 0.001) + // Order-independent: passing them reversed yields the SAME gap. + XCTAssertEqual(SleepStageTotals.interFragmentAwakeSeconds([f2, f1]), Double(20 * 60), accuracy: 0.001) + // Abutting fragments (no gap) → 0; a single fragment → 0. + XCTAssertEqual(SleepStageTotals.interFragmentAwakeSeconds([(0, 100), (100, 200)]), 0, accuracy: 0.001) + XCTAssertEqual(SleepStageTotals.interFragmentAwakeSeconds([(0, 100)]), 0, accuracy: 0.001) + // Two gaps across three fragments sum. + let three = [(0, 100), (160, 300), (340, 500)] // 60s + 40s = 100s + XCTAssertEqual(SleepStageTotals.interFragmentAwakeSeconds(three), 100, accuracy: 0.001) + } + + /// #777/#705 regression fixture: a main night bridged from two fragments split by a 20-min OUT-OF-BED + /// gap must report ~20 min AWAKE on the day's rollup (it read as ~0 before). The seam folds the gap into + /// AWAKE via the in-bed denominator - in-bed = asleep + (fragment awake + gap) - with NO double-count. + func testHonoringEditsFragmentedNightCountsGapAsAwake() throws { + // Two fragments of ONE night, each 0 staged-awake, split by a 20-min gap (< gapBridgeMaxMin → they + // bridge into the main-night group). Fragment 1: 23:00–02:00 (180 min asleep). 20-min gap. Fragment + // 2: 02:20–06:00 (220 min asleep). Per-fragment stages carry 0 awake, so without the fix the gap + // would vanish. + let f1Start = ts525("2026-06-14T23:00") + let f2Start = ts525("2026-06-15T02:20") // 20-min gap after f1's 02:00 end + let f1Stages = #"{"awake":0,"light":120,"deep":30,"rem":30}"# // 180 min asleep, 180 in-bed + let f2Stages = #"{"awake":0,"light":140,"deep":40,"rem":40}"# // 220 min asleep, 220 in-bed + + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [(startTs: f1Start, stagesJSON: f1Stages), + (startTs: f2Start, stagesJSON: f2Stages)], + edited: [:], + onsetByStart: [f1Start: f1Start, f2Start: f2Start], + offsetSec: 0)) + XCTAssertFalse(r.editApplied) + // Asleep is the SUM of the two fragments (180 + 220 = 400 min); the gap is awake, not sleep. + XCTAssertEqual(r.sleep.totalSleepMin, 400, accuracy: 0.001, "asleep is the sum of both fragments") + // In-bed = 180 + 220 + 20 (gap) = 420 min. Awake = in-bed − asleep = 20 min (the gap), NOT ~0. + let awakeMin = r.sleep.totalSleepMin / r.sleep.efficiency - r.sleep.totalSleepMin + XCTAssertEqual(awakeMin, 20, accuracy: 0.01, "the 20-min out-of-bed gap reads as ~20 min awake (#777)") + XCTAssertEqual(r.sleep.efficiency, 400.0 / 420.0, accuracy: 0.0001, "efficiency reflects the gap") + } + + /// The seam definition and the standalone aggregate agree to the minute (no double-count): feeding the + /// SAME gap to `dailyAggregate(_:interFragmentAwakeSeconds:)` yields the identical in-bed/awake the seam + /// reports, proving the two paths share one definition (the PR #787 seam bug this fix avoids). + func testInterFragmentAwakeFoldsConsistentlyNoDoubleCount() throws { + let f1Stages = #"{"awake":0,"light":120,"deep":30,"rem":30}"# // 180 asleep + let f2Stages = #"{"awake":0,"light":140,"deep":40,"rem":40}"# // 220 asleep + let agg = try XCTUnwrap(SleepStageTotals.dailyAggregate([f1Stages, f2Stages], + interFragmentAwakeSeconds: Double(20 * 60))) + XCTAssertEqual(agg.totalSleepMin, 400, accuracy: 0.001) + XCTAssertEqual(agg.efficiency, 400.0 / 420.0, accuracy: 0.0001) + // A zero gap reproduces the legacy behaviour exactly (backward-compat). + let legacy = try XCTUnwrap(SleepStageTotals.dailyAggregate([f1Stages, f2Stages])) + let folded0 = try XCTUnwrap(SleepStageTotals.dailyAggregate([f1Stages, f2Stages], + interFragmentAwakeSeconds: 0)) + XCTAssertEqual(legacy.efficiency, folded0.efficiency, accuracy: 1e-12) + } + + // MARK: - #547 learned-timing scored selector (the gate is gone) + + /// Local time-of-day "HH:mm" → seconds, for habitual-midsleep expectations. + private func sod(_ hhmm: String) -> Int { + let p = hhmm.split(separator: ":"); return Int(p[0])! * 3600 + Int(p[1])! * 60 + } + + /// THE pikapik case: a genuinely LONG sleep whose detected onset falls in the daytime gap + /// [10:00, 20:00) must beat a SHORT overnight fragment. Under the old hard gate the overnight + /// fragment always won and the real sleep got tagged a nap; the score now lets duration prevail. + func testLongDaytimeOnsetBeatsShortOvernightFragment() { + let dayLong = ts525("2026-06-15T11:00") // onset in the daytime gap, 7h long + let nightFrag = ts525("2026-06-14T23:00") // overnight onset, only 1.5h + let blocks = [ + SleepStageTotals.NightBlock(start: dayLong, end: dayLong + 7 * 3600), // 420 + ~0 bonus + SleepStageTotals.NightBlock(start: nightFrag, end: nightFrag + 90 * 60), // 90 + up-to-90 bonus + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 0, + "a real 7h daytime-onset sleep outscores a 1.5h overnight fragment (#547 pikapik)") + } + + /// The reconciled window: a [10:00, 11:00) onset (kept as "night" by the detector but demoted to a + /// "nap" by the OLD selector window [20:00, 10:00)) must now resolve the SAME way on both sides. With + /// the band closed at 11:00, a 10:30-onset block earns the overnight bonus like the detector expects. + func testTenThirtyOnsetIsTreatedAsOvernightNotDaytime() { + // Bonus parity check: a 10:30 onset is inside the reconciled [20:00,11:00) band. + XCTAssertTrue(SleepStageTotals.isOvernightOnset(ts525("2026-06-15T10:30"), offsetSec: 0), + "10:30 is overnight under the reconciled [20:00,11:00) band (off-by-one fixed)") + // Selection consistency: with the band closed at 11:00, a 10:30-onset block is treated like any + // other onset by the SCORE (no special gate). Equal-duration blocks both past the bonus zero + // distance tie on score, so the earlier onset wins deterministically — the same result the + // detector's [20:00,11:00) classification implies (no off-by-one disagreement). + let early = ts525("2026-06-15T10:30") // 3h, mid 12:00 + let nap = ts525("2026-06-15T15:00") // 3h, mid 16:30 (both beyond the 5h bonus zero → bonus 0) + let blocks = [ + SleepStageTotals.NightBlock(start: nap, end: nap + 3 * 3600), + SleepStageTotals.NightBlock(start: early, end: early + 3 * 3600), + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 1, + "score tie → earlier onset wins; the [10,11) boundary no longer disagrees") + } + + /// A late/shift sleeper: when the habitual midsleep is the AFTERNOON, a daytime sleep is the MAIN + /// block, even though it would fail any fixed overnight gate. Timing, learned, drives the pick. + func testHabitualMidsleepShiftsThePickForADaytimeSleeper() { + let habitual = sod("14:00") // this user sleeps midday→afternoon + let dayBlock = ts525("2026-06-15T11:00") // 6h daytime sleep, mid 14:00 (on the habitual) + let nightBlock = ts525("2026-06-14T23:00") // 6h overnight, mid 02:00 (far from 14:00) + let blocks = [ + SleepStageTotals.NightBlock(start: nightBlock, end: nightBlock + 6 * 3600), + SleepStageTotals.NightBlock(start: dayBlock, end: dayBlock + 6 * 3600), + ] + // Equal duration → the habitual-aligned daytime block wins on the bonus. + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0, habitualMidsleepSec: habitual), 1, + "with a 14:00 habitual midsleep the daytime sleep is the main block") + // Sanity: with NO habitual (cold-start band, anchored ~03:30) the overnight block wins instead. + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 0, + "cold-start band still favors the overnight block") + } + + /// #518 intent preserved via TIMING, not a gate: a 5h AFTERNOON block vs a 4h block at the habitual + /// night → the habitual-aligned NIGHT block wins despite being shorter, because its alignment bonus + /// (full 90) outweighs the afternoon block's extra 60 min with no bonus. + func testHabitualAlignedShorterNightBeatsLongerAfternoon() { + let habitual = sod("03:00") // a normal sleeper + let afternoon = ts525("2026-06-15T13:00") // 5h afternoon = 300, mid 15:30, bonus 0 + let night = ts525("2026-06-15T01:00") // 4h at the habitual = 240, mid 03:00, bonus 90 → 330 + let blocks = [ + SleepStageTotals.NightBlock(start: afternoon, end: afternoon + 5 * 3600), + SleepStageTotals.NightBlock(start: night, end: night + 4 * 3600), + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0, habitualMidsleepSec: habitual), 1, + "the habitual-aligned 4h night beats a 5h afternoon (timing, not a hard floor)") + } + + // MARK: - #518 invariant: a realistic daytime nap can NEVER out-rank the real night (R1) + + // After the #547 gate removal the invariant "a nap can't out-rank the real night" is protected ONLY + // by the +90 min alignment margin (score = asleepMinutes + bonus, bonus ∈ [0, 90]). The exact rule: + // a non-main block out-scores the night iff its asleep duration exceeds the night's by MORE than + // (night_bonus − nap_bonus) ≤ 90. So a daytime block must be > the night + 90 min to win. + // + // Cold-start anchor is 03:30. A real ≥4h night scores ≥240. A TRUE daytime doze (onset ≥06:00, + // ≤180 min) tops out at 210 (onset 06:00, 180 min, mid 07:30 → bonus 30), so 210 < 240 — the night + // ALWAYS wins. (The only path to a 240 *tie* is a 180-min sleep onset 05:00 — a dawn main-sleep, not + // a nap — and a tie breaks to the EARLIER onset, which an evening-onset night satisfies.) Under a + // learned night-time habitual the night gets +90 and a far-off nap +0, so the margin is even larger. + // These tests PIN that across the realistic 20–180 min nap range vs a real 4h+ night, both timings. + + /// A realistic daytime nap (20–180 min, onset across the whole day) can NEVER beat a real 4h+ night, + /// COLD-START. Exhaustive sweep over the realistic ranges — every case the night must win or tie-win. + func testRealisticNapNeverBeatsRealNightColdStart() { + // A real night: 4h..9h, onset 20:00..01:00 (overnight). Daytime naps: 20..180 min, onset 06:00..21:00. + let nightOnsets = ["2026-06-14T20:00", "2026-06-14T22:00", "2026-06-14T23:00", "2026-06-15T00:00", + "2026-06-15T01:00"] + let nightHours = [4, 5, 6, 7, 8, 9] + let napOnsets = ["2026-06-15T06:00", "2026-06-15T08:00", "2026-06-15T10:00", "2026-06-15T12:00", + "2026-06-15T13:00", "2026-06-15T15:00", "2026-06-15T17:00", "2026-06-15T19:00", + "2026-06-15T21:00"] + let napMins = [20, 30, 45, 60, 90, 120, 150, 180] + for no in nightOnsets { + for nh in nightHours { + let nStart = ts525(no) + for po in napOnsets { + for pm in napMins { + let pStart = ts525(po) + // Index 1 = the night → the night must always be picked (never the nap at index 0). + let blocks = [ + SleepStageTotals.NightBlock(start: pStart, end: pStart + pm * 60), + SleepStageTotals.NightBlock(start: nStart, end: nStart + nh * 3600), + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 1, + "cold-start: a \(pm)min nap@\(po) must NOT out-rank a \(nh)h night@\(no)") + } + } + } + } + } + + /// Same pin, LEARNED-TIMING: with a normal night habitual (03:00) the real night earns the full +90 + /// and a daytime nap earns 0 (>5h circular away), so the night wins by an even larger margin. + func testRealisticNapNeverBeatsRealNightLearnedTiming() { + let habitual = sod("03:00") + let nightOnsets = ["2026-06-14T22:00", "2026-06-14T23:00", "2026-06-15T00:00", "2026-06-15T01:00"] + let nightHours = [4, 5, 6, 7, 8] + let napOnsets = ["2026-06-15T10:00", "2026-06-15T12:00", "2026-06-15T13:00", "2026-06-15T15:00", + "2026-06-15T17:00", "2026-06-15T19:00"] + let napMins = [20, 45, 60, 90, 120, 150, 180] + for no in nightOnsets { + for nh in nightHours { + let nStart = ts525(no) + for po in napOnsets { + for pm in napMins { + let pStart = ts525(po) + let blocks = [ + SleepStageTotals.NightBlock(start: pStart, end: pStart + pm * 60), + SleepStageTotals.NightBlock(start: nStart, end: nStart + nh * 3600), + ] + XCTAssertEqual( + SleepStageTotals.mainNightIndex(blocks, offsetSec: 0, habitualMidsleepSec: habitual), 1, + "learned: a \(pm)min nap@\(po) must NOT out-rank a \(nh)h night@\(no)") + } + } + } + } + } + + /// The tightest cold-start margin: a 4h night onset 20:00 (mid 22:00, bonus 0 → score 240) vs the + /// single most-favourable TRUE-daytime doze (onset 06:00, 180 min, mid 07:30, bonus 30 → score 210). + /// The night wins by exactly 30. This is the worst case in the realistic range; pin it explicitly so + /// any future change to the bonus shape/size that would erode this margin trips the test. + func testTightestColdStartMarginNightStillWins() { + let night = ts525("2026-06-14T20:00") // 4h, mid 22:00 → bonus 0 → 240 + let bestNap = ts525("2026-06-15T06:00") // 180min, mid 07:30 → bonus 30 → 210 + let blocks = [ + SleepStageTotals.NightBlock(start: bestNap, end: bestNap + 180 * 60), + SleepStageTotals.NightBlock(start: night, end: night + 4 * 3600), + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 1, + "worst-case realistic doze (210) still loses to a barely-timed 4h night (240)") + } + + /// The genuinely-ambiguous case is NOT a regression and is DEFENSIBLE: a short 4h night + a LONG 6h + /// daytime sleep → the 6h block is the main (longest qualifying block wins, per the sleep-timing + /// research). The user can edit, and the guidance layer explains it. This pins the INTENTIONAL + /// behaviour so a future "harden the night" change can't silently flip it back to the short night. + func testAmbiguousLongDaytimeSleepBeatsShortNightByDesign() { + let night = ts525("2026-06-14T23:00") // 4h overnight = 240, mid 01:00, cold-start bonus 75 → 315 + let dayLong = ts525("2026-06-15T12:00") // 6h daytime = 360, mid 15:00, bonus 0 → 360 + let blocks = [ + SleepStageTotals.NightBlock(start: night, end: night + 4 * 3600), + SleepStageTotals.NightBlock(start: dayLong, end: dayLong + 6 * 3600), + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 1, + "a 6h daytime sleep (360) beats a 4h night even WITH its bonus (315) — longest wins, by design") + } + + /// Nap-only day (NO hard duration floor): a single short daytime nap still resolves to a main block, + /// so the day has a sleep figure rather than nil. + func testNapOnlyDayResolvesToTheNapAsMain() throws { + let nap = ts525("2026-06-15T13:00") // 40 min daytime nap, the only block + XCTAssertEqual(SleepStageTotals.mainNightIndex( + [SleepStageTotals.NightBlock(start: nap, end: nap + 40 * 60)], offsetSec: 0), 0, + "a lone nap is the main block (no hard nap floor)") + // And via the stage seam: the nap's own minutes become the day's figure. + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [(startTs: nap, stagesJSON: #"{"awake":2,"light":24,"deep":8,"rem":6}"#)], + edited: [:], + onsetByStart: [nap: nap], offsetSec: 0)) + XCTAssertEqual(r.sleep.totalSleepMin, 38, accuracy: 0.001, "nap-only day reports the nap's sleep") + } + + /// Biphasic / bridged night: two sleep runs separated by a < 60 min wake are one block for selection. + func testGapBridgeMergesShortWakeSplitNight() { + let a = ts525("2026-06-14T23:00") // 3h + let bStart = a + 3 * 3600 + 30 * 60 // 30 min wake gap (< 60) then resume + let blocks = [ + SleepStageTotals.NightBlock(start: a, end: a + 3 * 3600), + SleepStageTotals.NightBlock(start: bStart, end: bStart + 3 * 3600), + ] + let bridged = SleepStageTotals.bridgeAdjacent(blocks) + XCTAssertEqual(bridged.count, 1, "a < 60 min wake gap bridges the two runs into one block") + XCTAssertEqual(bridged[0].start, a) + XCTAssertEqual(bridged[0].end, bStart + 3 * 3600) + // A >= 60 min gap must NOT bridge. + let cStart = a + 3 * 3600 + 75 * 60 + let unbridged = SleepStageTotals.bridgeAdjacent([ + SleepStageTotals.NightBlock(start: a, end: a + 3 * 3600), + SleepStageTotals.NightBlock(start: cStart, end: cStart + 3 * 3600), + ]) + XCTAssertEqual(unbridged.count, 2, "a >= 60 min wake gap stays two blocks") + } + + /// Cross-midnight onset: a 23:30 onset (just before midnight) is overnight and its midpoint math wraps + /// correctly, so it out-scores a midday nap. + func testCrossMidnightOnsetScoresAsNight() { + let night = ts525("2026-06-14T23:30") // 6h crossing midnight, mid 02:30 + let nap = ts525("2026-06-15T13:00") // 1h + let blocks = [ + SleepStageTotals.NightBlock(start: nap, end: nap + 1 * 3600), + SleepStageTotals.NightBlock(start: night, end: night + 6 * 3600), + ] + XCTAssertEqual(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0), 1) + } + + /// Circular-time correctness: 23:30 and 00:30 are an HOUR apart, not 23h. + func testCircularDistanceWrapsMidnight() { + XCTAssertEqual(SleepStageTotals.circularDistanceSec(sod("23:30"), sod("00:30")), 3600) + XCTAssertEqual(SleepStageTotals.circularDistanceSec(sod("00:30"), sod("23:30")), 3600) + XCTAssertEqual(SleepStageTotals.circularDistanceSec(sod("12:00"), sod("00:00")), 43200, "antipodal = 12h") + XCTAssertEqual(SleepStageTotals.circularDistanceSec(sod("03:30"), sod("03:30")), 0) + } + + // MARK: - #547 habitual midsleep (learned timing) + + /// A day key from a midpoint, for synthesizing per-day history. + private func dayKey(_ ts: Int) -> String { + let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC"); f.dateFormat = "yyyy-MM-dd" + return f.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) + } + + /// Cold-start: fewer than minDays of history → nil (the scorer then uses the overnight band). + func testHabitualMidsleepNilOnColdStart() { + var hist: [SleepStageTotals.HistoryBlock] = [] + for d in 0..<5 { // only 5 days, < 14 + let onset = ts525("2026-06-01T23:00") + d * 86_400 + hist.append(.init(start: onset, end: onset + 7 * 3600, dayKey: dayKey(onset))) + } + XCTAssertNil(SleepStageTotals.habitualMidsleepSec(hist, offsetSec: 0), + "too little history → nil (cold-start)") + } + + /// A regular sleeper: 20 nights at 23:00→06:00 (mid 02:30) → habitual midsleep ≈ 02:30. Each night + /// shares its day key with a short same-day nap; longest-per-day must pick the night, so naps never + /// pull the learned midpoint. + func testHabitualMidsleepLearnsRegularTiming() throws { + var hist: [SleepStageTotals.HistoryBlock] = [] + for d in 0..<20 { + let onset = ts525("2026-06-01T23:00") + d * 86_400 + let key = "night-\(d)" // explicit key so the night + its nap share a day, deterministically + hist.append(.init(start: onset, end: onset + 7 * 3600, dayKey: key)) // 7h night + let napOnset = onset - 8 * 3600 // a 15:00 nap + hist.append(.init(start: napOnset, end: napOnset + 1 * 3600, dayKey: key)) // 1h nap, same key + } + let mid = try XCTUnwrap(SleepStageTotals.habitualMidsleepSec(hist, offsetSec: 0)) + XCTAssertEqual(mid, sod("02:30"), "midsleep is the night's midpoint, naps excluded by longest-per-day") + } + + /// Circular learning across midnight: nights straddling 00:00 (e.g. mids at 23:30 and 00:30) average + /// to ~midnight, NOT to noon (which a naive arithmetic mean would give). + func testHabitualMidsleepCircularAcrossMidnight() throws { + var hist: [SleepStageTotals.HistoryBlock] = [] + for d in 0..<16 { + // Alternate the midpoint either side of midnight: half at 23:30, half at 00:30. + let onset = (d % 2 == 0) ? ts525("2026-06-01T20:00") : ts525("2026-06-01T21:00") + let shifted = onset + d * 86_400 // 7h block → mid 23:30 or 00:30 + hist.append(.init(start: shifted, end: shifted + 7 * 3600, dayKey: dayKey(shifted))) + } + let mid = try XCTUnwrap(SleepStageTotals.habitualMidsleepSec(hist, offsetSec: 0)) + // Circular mean of 23:30 and 00:30 is 00:00 (±a few seconds of rounding). + let dist = SleepStageTotals.circularDistanceSec(mid, sod("00:00")) + XCTAssertLessThan(dist, 120, "circular mean of 23:30/00:30 ≈ midnight, not noon") + } + + // MARK: - #547 Caveat A: the UI selector and the engine selector agree for a SHIFT sleeper + + /// THE bug this fix closes: a shift/late sleeper whose LEARNED habitual midsleep is ~14:00. On a day + /// with BOTH an afternoon main sleep AND a shorter overnight block, the engine (which threads the + /// learned habitual into `analyzeDay`) tracked the AFTERNOON block, but the Sleep tab hero — which used + /// to call the selector with NO habitual (cold-start band only) — picked the OVERNIGHT block, breaking + /// the #525/#547 "hero == analytics total" invariant for that user. + /// + /// This replays both seams over the EXACT same blocks: (1) the LEARNED habitual is computed from this + /// shift-sleeper's history via the same `habitualMidsleepSec` pure function the engine and the new + /// `Repository.habitualMidsleepSec` both use; (2) `mainNightIndex(..., habitualMidsleepSec:)` — the + /// single shared selector both `mainNightSession` (UI, now fed the learned habitual) and `analyzeDay` + /// (engine) call — resolves to the SAME index. With the fix the UI passes the learned habitual, so it + /// picks the AFTERNOON block, matching the engine; the asserted contrast is the OLD cold-start UI call + /// (nil habitual) picking the overnight block — the divergence the fix removes. + func testShiftSleeperUIAndEngineSelectorPickSameAfternoonBlock() throws { + // 1) Learn the habitual from ~20 afternoon nights (onset 12:00, 6h → mid 15:00). + var hist: [SleepStageTotals.HistoryBlock] = [] + for d in 0..<20 { + let onset = ts525("2026-06-01T12:00") + d * 86_400 + hist.append(.init(start: onset, end: onset + 6 * 3600, dayKey: dayKey(onset))) + } + let habitual = try XCTUnwrap(SleepStageTotals.habitualMidsleepSec(hist, offsetSec: 0), + "20 afternoon nights clear the cold-start threshold") + XCTAssertEqual(SleepStageTotals.circularDistanceSec(habitual, sod("15:00")) < 120, true, + "learned midsleep is ~15:00 for this shift sleeper") + + // 2) A target day with BOTH an afternoon main sleep (mid ~15:00, on the habitual) AND a shorter + // overnight block. Index 0 = overnight, index 1 = afternoon (input order is irrelevant to the pick). + let overnight = ts525("2026-06-21T23:00") // 5h overnight, mid ~01:30 (far from 15:00) + let afternoon = ts525("2026-06-21T12:00") // 6h afternoon, mid 15:00 (on the habitual) + let blocks = [ + SleepStageTotals.NightBlock(start: overnight, end: overnight + 5 * 3600), + SleepStageTotals.NightBlock(start: afternoon, end: afternoon + 6 * 3600), + ] + + // The shared selector WITH the learned habitual (what BOTH the UI hero AND the engine now use) → + // the afternoon block. This is the byte-identical call both seams make. + let withHabitual = try XCTUnwrap( + SleepStageTotals.mainNightIndex(blocks, offsetSec: 0, habitualMidsleepSec: habitual)) + XCTAssertEqual(withHabitual, 1, + "with the learned ~15:00 habitual, the afternoon block is the main night (engine + UI agree)") + + // The OLD cold-start UI call (nil habitual) diverged — it picked the overnight block. This is the + // exact bug Caveat A removes by feeding the same learned habitual to the UI selector. + let coldStart = try XCTUnwrap(SleepStageTotals.mainNightIndex(blocks, offsetSec: 0)) + XCTAssertEqual(coldStart, 0, + "cold-start band picks the overnight block — the pre-fix UI/engine divergence") + XCTAssertNotEqual(withHabitual, coldStart, + "the learned habitual is exactly what makes the UI agree with the engine") + } + + // MARK: - #547 Caveat B: circularMeanSec degenerate-vector guard + + /// Antipodal midpoints (12h apart) have a near-zero resultant vector, so `atan2` returns a meaningless + /// (and potentially cross-platform-divergent) direction. The guard returns nil so `habitualMidsleepSec` + /// falls back to cold-start rather than emit a bogus anchor. Here: 8 midpoints at 00:00 + 8 at 12:00. + func testCircularMeanReturnsNilForAntipodalMidpoints() { + let secs = (0..<8).flatMap { _ in [sod("00:00"), sod("12:00")] } // 16 values, perfectly antipodal + XCTAssertNil(SleepStageTotals.circularMeanSec(secs), + "antipodal midpoints → degenerate resultant → nil (no meaningless angle)") + } + + /// The guard also fires end-to-end: a 16-day history split evenly between two antipodal sleep times + /// (so the per-day midpoints are 12h apart) clears the day-count threshold but yields nil, NOT a bogus + /// midnight/noon anchor — so the scorer falls back to the cold-start band identically on both platforms. + func testHabitualMidsleepNilWhenLearnedTimingIsAntipodal() { + var hist: [SleepStageTotals.HistoryBlock] = [] + for d in 0..<16 { + // Even days: a night centered 00:00. Odd days: a sleep centered 12:00. Distinct day keys. + let onset = (d % 2 == 0) ? ts525("2026-06-01T20:30") : ts525("2026-06-01T08:30") + let shifted = onset + d * 86_400 // 7h block → mid 00:00 or 12:00 + hist.append(.init(start: shifted, end: shifted + 7 * 3600, dayKey: dayKey(shifted))) + } + XCTAssertNil(SleepStageTotals.habitualMidsleepSec(hist, offsetSec: 0), + "antipodal learned timing → nil (cold-start fallback), not a meaningless anchor") + } + + // MARK: - #547 wire-through: effective (edited) onset crosses the overnight boundary (audit finding C / #8) + + /// The finding-C case: a block's DETECTED onset and its user-CORRECTED (effective) onset fall on + /// opposite sides of the overnight boundary. The seam must score on the EFFECTIVE onset (what the + /// Sleep tab shows), not the immutable detected key, so the seam and the UI pick the same block. The + /// fixture is built so the two onset maps DISAGREE: the main block (300 asleep) is detected at 09:30 + /// (daytime → 0 bonus) but EDITED to start 22:30 (overnight → ~75 min bonus, taking it to ~375). A + /// longer 340-asleep nap with no bonus then loses to the effective-onset main (375>340) but BEATS the + /// detected-onset main (340>300). So the chosen block flips with the onset used — proving the fix. + func testEditedOnsetCrossingBoundaryIsScoredOnTheEffectiveOnset() throws { + let detectedStart = ts525("2026-06-15T09:30") // detected as a daytime onset (bonus 0) + let effectiveStart = ts525("2026-06-14T22:30") // user moved bedtime back → overnight (bonus ~75) + let napStart = ts525("2026-06-15T15:00") // far from the band center (bonus 0) + let mainStages = #"{"awake":0,"light":150,"deep":80,"rem":70}"# // 300 asleep + let napStages = #"{"awake":0,"light":170,"deep":90,"rem":80}"# // 340 asleep (longer) + let blocksByStages = [(startTs: detectedStart, stagesJSON: mainStages), + (startTs: napStart, stagesJSON: napStages)] + // Effective-onset map (correct, finding-C fix): main scored at 22:30 → bonus lifts it over the nap. + let onEffective: [Int: Int] = [detectedStart: effectiveStart, napStart: napStart] + let idxEff = SleepStageTotals.mainNightIndexByStages(blocksByStages, onsetByStart: onEffective, offsetSec: 0) + XCTAssertEqual(idxEff, 0, "effective (edited) overnight onset earns the bonus, so the main block wins") + // Wrong (detected-onset) map: main is daytime → 0 bonus → the longer nap (340) wins instead. + let onDetected: [Int: Int] = [detectedStart: detectedStart, napStart: napStart] + let idxDet = SleepStageTotals.mainNightIndexByStages(blocksByStages, onsetByStart: onDetected, offsetSec: 0) + XCTAssertEqual(idxDet, 1, "detected onset misses the bonus → the longer nap mis-wins (the finding-C bug)") + // End-to-end through the seam: with the effective onset the day total is the MAIN block's 300. + let rEff = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [(startTs: detectedStart, stagesJSON: mainStages), + (startTs: napStart, stagesJSON: napStages)], + edited: [detectedStart: mainStages], + onsetByStart: onEffective, offsetSec: 0)) + XCTAssertTrue(rEff.editApplied) + XCTAssertEqual(rEff.sleep.totalSleepMin, 300, accuracy: 0.001, + "seam scores on the EFFECTIVE onset, so the corrected overnight block is the day total") + } + + /// The habitual midsleep threads through the seam: with a learned AFTERNOON habitual, an afternoon + /// sleep becomes the day's headline total over a shorter overnight block, exactly as the bare selector + /// does. Proves `dailyAggregateHonoringEdits` honors `habitualMidsleepSec`. + func testHonoringEditsHonorsHabitualMidsleep() throws { + let habitual = sod("14:00") + let nightStart = ts525("2026-06-14T23:00") // 4h overnight, mid 01:00 + let dayStart = ts525("2026-06-15T11:00") // 6h afternoon, mid 14:00 (on the habitual) + let nightStages = #"{"awake":0,"light":120,"deep":60,"rem":60}"# // 240 asleep + let dayStages = #"{"awake":0,"light":200,"deep":80,"rem":80}"# // 360 asleep + let detected = [(startTs: nightStart, stagesJSON: nightStages), + (startTs: dayStart, stagesJSON: dayStages)] + let onset = [nightStart: nightStart, dayStart: dayStart] + // With the afternoon habitual the longer, on-timing afternoon block is the day total. + let rHab = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: detected, edited: [dayStart: dayStages], + onsetByStart: onset, offsetSec: 0, habitualMidsleepSec: habitual)) + XCTAssertEqual(rHab.sleep.totalSleepMin, 360, accuracy: 0.001, + "afternoon habitual → the on-timing afternoon block is the headline total") + } + + /// `AnalyticsEngine.analyzeDay` threads `habitualMidsleepSec` into the selector. A real overnight + /// detected night, scored with an afternoon habitual, still produces a sleep metric (the single + /// detected block is always the main night); the point is the arg compiles + flows without breaking + /// the cold-start contract. Cold-start (nil) and an aligned habitual must both resolve the night. + func testAnalyzeDayAcceptsHabitualMidsleepWithoutBreakingColdStart() { + let day = "2021-06-15" + let n = night(endDay: day, hours: 7) + let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male") + let cold = AnalyticsEngine.analyzeDay(day: day, hr: n.hr, rr: n.rr, gravity: n.gravity, + profile: profile) + let withHabitual = AnalyticsEngine.analyzeDay(day: day, hr: n.hr, rr: n.rr, gravity: n.gravity, + profile: profile, habitualMidsleepSec: 3 * 3600 + 1800) + XCTAssertNotNil(cold.daily.totalSleepMin) + XCTAssertNotNil(withHabitual.daily.totalSleepMin) + // One detected night → the same main block either way; the habitual arg must not change a + // single-night day's total. + XCTAssertEqual(cold.daily.totalSleepMin!, withHabitual.daily.totalSleepMin!, accuracy: 0.001) + } + + // MARK: - REAL fixture replay (evidence on recorded data, not synthetic) (#547) + + /// Parse a "UTC±HH:MM" Whoop `Cycle timezone` to seconds east of UTC — the same convention + /// `StrandImport.WhoopTime.tzOffsetMinutes` uses (StrandAnalytics can't depend on StrandImport, so + /// the two columns this replay needs are parsed here with the identical rule). + private func whoopTzOffsetSec(_ raw: String) -> Int { + var s = raw.trimmingCharacters(in: .whitespaces) + if s.uppercased().hasPrefix("UTC") { s = String(s.dropFirst(3)) } + var sign = 1 + if s.hasPrefix("+") { s.removeFirst() } else if s.hasPrefix("-") { sign = -1; s.removeFirst() } + let p = s.split(separator: ":") + let h = Int(p.first ?? "0") ?? 0, m = p.count > 1 ? (Int(p[1]) ?? 0) : 0 + return sign * (h * 60 + m) * 60 + } + + /// Parse a Whoop CSV "YYYY-MM-DD HH:MM:SS" local wall-clock into a UTC unix timestamp, interpreting + /// the string in the given offset — the same instant `WhoopTime.parse` would return. + private func whoopTs(_ wall: String, offsetSec: Int) -> Int { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: offsetSec)! + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return Int(f.date(from: wall)!.timeIntervalSince1970) + } + + /// EVIDENCE on REAL data: the recorded WHOOP `sleeps.csv` fixture + /// (Packages/StrandImport/Tests/StrandImportTests/Resources/sleeps.csv) holds a genuine multi-session + /// day — 2024-01-02, tz UTC+01:00 — with a real overnight sleep (Sleep onset 2024-01-01 23:15 → Wake + /// 06:30, Nap=false, 420 asleep / 455 in-bed) AND a real daytime nap (14:00 → 14:25, Nap=true, 25 min). + /// The obviously-correct human answer is that the OVERNIGHT block is the main night and the 25-min + /// afternoon block is the nap. This replays the production `mainNightIndex` selector over those exact + /// recorded sessions (rows copied verbatim from the fixture) and asserts the overnight wins, so we have + /// evidence the new learned-timing scorer behaves on real export data, not just synthetic blocks. + /// (Cold-start path: no learned habitual yet, so the overnight-band bonus applies.) + func testRealWhoopSleepsCsvFixturePicksOvernightAsMainNight() throws { + // Verbatim rows from the fixture (cycle timezone, sleep onset, wake onset, isNap). + let tz = whoopTzOffsetSec("UTC+01:00") + XCTAssertEqual(tz, 3600, "UTC+01:00 → +3600s east") + + let nightOnset = whoopTs("2024-01-01 23:15:00", offsetSec: tz) // Nap=false + let nightWake = whoopTs("2024-01-02 06:30:00", offsetSec: tz) + let napOnset = whoopTs("2024-01-02 14:00:00", offsetSec: tz) // Nap=true + let napWake = whoopTs("2024-01-02 14:25:00", offsetSec: tz) + + // Sanity on the recorded spans: a ~7h15m overnight and a 25-min nap. + XCTAssertEqual((nightWake - nightOnset) / 60, 435, "recorded overnight clock span ≈ 7h15m") + XCTAssertEqual((napWake - napOnset) / 60, 25, "recorded nap clock span = 25 min") + + // Build the candidate blocks IN FILE ORDER (overnight row first, nap row second — as the CSV lists + // them) and run the real selector with the fixture's true tz offset, cold-start (nil habitual). + let blocks = [ + SleepStageTotals.NightBlock(start: nightOnset, end: nightWake), + SleepStageTotals.NightBlock(start: napOnset, end: napWake), + ] + let idx = try XCTUnwrap(SleepStageTotals.mainNightIndex(blocks, offsetSec: tz), + "selector must resolve a main night on the real fixture day") + XCTAssertEqual(idx, 0, "the recorded overnight sleep is the main night; the 25-min afternoon block is the nap") + + // Order-independence: reverse the candidates and the SAME physical block must still win. + let reversed = [blocks[1], blocks[0]] + XCTAssertEqual(SleepStageTotals.mainNightIndex(reversed, offsetSec: tz), 1, + "the pick is the overnight block regardless of input order") + + // isNap = \"not the chosen main block\": exactly one block is the main, the other is a nap. + XCTAssertEqual(blocks.indices.filter { $0 != idx }, [1], + "the afternoon 25-min block is classified as the nap") + } + + // MARK: - Selection reason (explainability — WHY this block is the main night) (spec 2026-06-20) + + /// `mainNightSelection.index` must always equal `mainNightIndex` (same score, same tie-break) — the + /// enriched call is the SAME pick, just annotated. Replays it over the realistic cold-start sweep. + func testSelectionIndexAlwaysMatchesMainNightIndex() { + let nightOnsets = ["2026-06-14T22:00", "2026-06-14T23:00", "2026-06-15T00:00"] + let nightHours = [4, 6, 8] + let napOnsets = ["2026-06-15T08:00", "2026-06-15T13:00", "2026-06-15T19:00"] + let napMins = [30, 90, 180] + for no in nightOnsets { + for nh in nightHours { + let nStart = ts525(no) + for po in napOnsets { + for pm in napMins { + let pStart = ts525(po) + let blocks = [ + SleepStageTotals.NightBlock(start: pStart, end: pStart + pm * 60), + SleepStageTotals.NightBlock(start: nStart, end: nStart + nh * 3600), + ] + let idx = SleepStageTotals.mainNightIndex(blocks, offsetSec: 0) + let sel = SleepStageTotals.mainNightSelection(blocks, offsetSec: 0) + XCTAssertEqual(sel?.index, idx, "enriched selection must pick the same block as mainNightIndex") + } + } + } + } + } + + /// Reason branch ONLY-BLOCK: a single block carries the `onlyBlock` reason and its own asleep span. + func testSelectionReasonOnlyBlock() throws { + let nap = ts525("2026-06-15T13:00") + let sel = try XCTUnwrap(SleepStageTotals.mainNightSelection( + [SleepStageTotals.NightBlock(start: nap, end: nap + 40 * 60)], offsetSec: 0)) + XCTAssertEqual(sel.index, 0) + XCTAssertEqual(sel.reason, .onlyBlock) + XCTAssertEqual(sel.asleepSeconds, 40 * 60) + XCTAssertEqual(sel.asleepMinutes, 40, accuracy: 0.001, "{DUR} fills from the chosen block's asleep") + } + + /// Reason branch LONGEST (cold-start): no learned habitual, the longest block wins on duration alone, + /// so the reason is plain `longest` even though it sits in the overnight band (cold-start = no habitual). + func testSelectionReasonLongestColdStart() throws { + let night = ts525("2026-06-14T23:00") // 7h overnight, the longest + let nap = ts525("2026-06-15T13:00") // 1h daytime nap + let blocks = [ + SleepStageTotals.NightBlock(start: nap, end: nap + 1 * 3600), + SleepStageTotals.NightBlock(start: night, end: night + 7 * 3600), + ] + let sel = try XCTUnwrap(SleepStageTotals.mainNightSelection(blocks, offsetSec: 0)) // nil habitual + XCTAssertEqual(sel.index, 1, "the 7h overnight block is the main night") + XCTAssertEqual(sel.reason, .longest, "cold-start (no learned habitual) → plain longest, never near-usual") + XCTAssertEqual(sel.asleepSeconds, 7 * 3600) + } + + /// Reason branch LONGEST (learned habitual present but chosen block OUTSIDE the bonus window): the + /// longest block wins on duration and earns NO meaningful bonus (>5h circular from the habitual), so + /// it is plain `longest`, not `longestNearUsual`. + func testSelectionReasonLongestWhenLongestIsOutsideBonusWindow() throws { + let habitual = sod("03:00") // a normal night sleeper + // The longest block is a 7h AFTERNOON sleep (mid 15:30) — >5h circular from 03:00 → bonus 0. + let afternoon = ts525("2026-06-15T12:00") // 7h afternoon, mid 15:30, bonus 0, the longest + let night = ts525("2026-06-14T23:00") // 4h overnight, mid 01:00, full bonus 90 → 330 + let blocks = [ + SleepStageTotals.NightBlock(start: night, end: night + 4 * 3600), // 240 + 90 = 330 + SleepStageTotals.NightBlock(start: afternoon, end: afternoon + 7 * 3600), // 420 + 0 = 420 wins + ] + let sel = try XCTUnwrap(SleepStageTotals.mainNightSelection(blocks, offsetSec: 0, + habitualMidsleepSec: habitual)) + XCTAssertEqual(sel.index, 1, "the 7h afternoon block wins on raw duration (420 > 330)") + XCTAssertEqual(sel.reason, .longest, + "chosen IS the longest but earns no bonus (outside the window) → plain longest") + XCTAssertEqual(sel.asleepSeconds, 7 * 3600) + } + + /// Reason branch LONGEST-NEAR-USUAL: the chosen block is the longest by duration AND a learned habitual + /// exists AND the block earns a meaningful alignment bonus. Duration would have picked it; timing agrees. + func testSelectionReasonLongestNearUsual() throws { + let habitual = sod("03:00") // normal night sleeper, habitual midsleep 03:00 + let night = ts525("2026-06-14T23:00") // 7h overnight, mid ~02:30 (inside the bonus window), longest + let nap = ts525("2026-06-15T13:00") // 1h daytime nap, far from 03:00 (bonus 0) + let blocks = [ + SleepStageTotals.NightBlock(start: nap, end: nap + 1 * 3600), + SleepStageTotals.NightBlock(start: night, end: night + 7 * 3600), + ] + let sel = try XCTUnwrap(SleepStageTotals.mainNightSelection(blocks, offsetSec: 0, + habitualMidsleepSec: habitual)) + XCTAssertEqual(sel.index, 1, "the 7h overnight block is the longest and on the habitual") + XCTAssertEqual(sel.reason, .longestNearUsual, + "longest by duration AND a learned habitual with a meaningful bonus → near-usual") + XCTAssertEqual(sel.asleepSeconds, 7 * 3600) + } + + /// Reason branch ALIGNED-TO-USUAL: the chosen block is NOT the longest; the alignment bonus flipped the + /// pick away from the longer block toward this shorter, well-timed one. (= testHabitualAlignedShorter…) + func testSelectionReasonAlignedToUsual() throws { + let habitual = sod("03:00") // normal sleeper + let afternoon = ts525("2026-06-15T13:00") // 5h afternoon = 300, mid 15:30, bonus 0, the LONGEST + let night = ts525("2026-06-15T01:00") // 4h at habitual = 240, mid 03:00, bonus 90 → 330 wins + let blocks = [ + SleepStageTotals.NightBlock(start: afternoon, end: afternoon + 5 * 3600), + SleepStageTotals.NightBlock(start: night, end: night + 4 * 3600), + ] + let sel = try XCTUnwrap(SleepStageTotals.mainNightSelection(blocks, offsetSec: 0, + habitualMidsleepSec: habitual)) + XCTAssertEqual(sel.index, 1, "the habitual-aligned 4h night wins over the longer 5h afternoon") + XCTAssertEqual(sel.reason, .alignedToUsual, + "the chosen block is NOT the longest; the alignment bonus flipped the pick") + XCTAssertEqual(sel.asleepSeconds, 4 * 3600, "{DUR} is the CHOSEN (shorter, aligned) block's span") + } + + /// Aligned-to-usual also covers the equal-duration case: two equal-length blocks, the habitual-aligned + /// one wins on bonus even though duration-only (earlier-onset tie-break) would have picked the other. + func testSelectionReasonAlignedToUsualOnEqualDurations() throws { + let habitual = sod("14:00") // a daytime/shift sleeper + let night = ts525("2026-06-14T23:00") // 6h overnight, mid 02:00 (far from 14:00) — earlier onset + let afternoon = ts525("2026-06-15T11:00") // 6h afternoon, mid 14:00 (on the habitual) — later onset + let blocks = [ + SleepStageTotals.NightBlock(start: night, end: night + 6 * 3600), + SleepStageTotals.NightBlock(start: afternoon, end: afternoon + 6 * 3600), + ] + let sel = try XCTUnwrap(SleepStageTotals.mainNightSelection(blocks, offsetSec: 0, + habitualMidsleepSec: habitual)) + XCTAssertEqual(sel.index, 1, "equal duration → the habitual-aligned afternoon block wins on bonus") + XCTAssertEqual(sel.reason, .alignedToUsual, + "duration-only (earlier onset) would have picked the night; alignment flipped it") + } + + // MARK: - Selection reason via the STAGES seam (decoded asleep minutes drive {DUR}) (spec 2026-06-20) + + /// The stages-path selection: index matches `mainNightIndexByStages`, the reason is decided on DECODED + /// asleep minutes, and `asleepSeconds` is the chosen block's decoded asleep span (not clock span). + func testSelectionByStagesReasonAndDecodedDuration() throws { + let nightStart = ts525("2026-06-14T23:00") + let napStart = ts525("2026-06-15T14:00") + let nightStages = #"{"awake":24,"light":214,"deep":82,"rem":96}"# // 392 min asleep (longest) + let napStages = #"{"awake":2,"light":30,"deep":10,"rem":8}"# // 48 min asleep + let blocks = [(startTs: napStart, stagesJSON: napStages), + (startTs: nightStart, stagesJSON: nightStages)] + let onset = [napStart: napStart, nightStart: nightStart] + let idx = SleepStageTotals.mainNightIndexByStages(blocks, onsetByStart: onset, offsetSec: 0) + let sel = try XCTUnwrap(SleepStageTotals.mainNightSelectionByStages( + blocks, onsetByStart: onset, offsetSec: 0)) // nil habitual → cold-start + XCTAssertEqual(sel.index, idx, "stages selection index matches mainNightIndexByStages") + XCTAssertEqual(sel.index, 1, "the 392-min overnight block is the main night") + XCTAssertEqual(sel.reason, .longest, "cold-start → plain longest") + XCTAssertEqual(sel.asleepSeconds, 392 * 60, "{DUR} is the DECODED asleep span, not clock span") + XCTAssertEqual(sel.asleepMinutes, 392, accuracy: 0.001) + } + + /// Stages seam, ALIGNED-TO-USUAL: a learned afternoon habitual flips the pick to the shorter, on-timing + /// afternoon block, and the reason reflects that the bonus (not duration) decided it. + func testSelectionByStagesReasonAlignedToUsual() throws { + let habitual = sod("14:00") + let nightStart = ts525("2026-06-14T23:00") // 4h overnight, mid 01:00 → 240 asleep + let dayStart = ts525("2026-06-15T13:00") // afternoon, mid 15:00 (1h from habitual) → 240 asleep, +bonus + let nightStages = #"{"awake":0,"light":300,"deep":80,"rem":40}"# // 420 asleep (LONGEST) + let dayStages = #"{"awake":0,"light":120,"deep":60,"rem":60}"# // 240 asleep, but aligned → +90 + let blocks = [(startTs: nightStart, stagesJSON: nightStages), + (startTs: dayStart, stagesJSON: dayStages)] + let onset = [nightStart: nightStart, dayStart: dayStart] + // night: 420 + bonus(mid 01:00 vs 14:00 → 0) = 420; day: 240 + bonus(~14:30 vs 14:00 → 90) = 330. + // Here the LONGER night still wins (420 > 330) → reason longest. Verify, then shorten the night so + // alignment flips it. + let selA = try XCTUnwrap(SleepStageTotals.mainNightSelectionByStages( + blocks, onsetByStart: onset, offsetSec: 0, habitualMidsleepSec: habitual)) + XCTAssertEqual(selA.index, 0) + XCTAssertEqual(selA.reason, .longest, "longest night wins on duration; habitual far from it → no bonus") + + // Now make the night SHORTER than the aligned afternoon's score so alignment flips the pick. + let shortNight = #"{"awake":0,"light":120,"deep":60,"rem":60}"# // 240 asleep, mid 01:00, bonus 0 + let blocks2 = [(startTs: nightStart, stagesJSON: shortNight), // 240 + (startTs: dayStart, stagesJSON: dayStages)] // 240 + 90 = 330 wins + let sel2 = try XCTUnwrap(SleepStageTotals.mainNightSelectionByStages( + blocks2, onsetByStart: onset, offsetSec: 0, habitualMidsleepSec: habitual)) + XCTAssertEqual(sel2.index, 1, "equal asleep → the aligned afternoon block wins on bonus") + XCTAssertEqual(sel2.reason, .alignedToUsual, "alignment, not duration, decided the flipped pick") + XCTAssertEqual(sel2.asleepSeconds, 240 * 60, "decoded asleep of the chosen afternoon block") + } + + /// Stages seam, ONLY-BLOCK: a single decoded block → onlyBlock with its decoded asleep span. + func testSelectionByStagesReasonOnlyBlock() throws { + let nap = ts525("2026-06-15T13:00") + let sel = try XCTUnwrap(SleepStageTotals.mainNightSelectionByStages( + [(startTs: nap, stagesJSON: #"{"awake":2,"light":24,"deep":8,"rem":6}"#)], + onsetByStart: [nap: nap], offsetSec: 0)) + XCTAssertEqual(sel.reason, .onlyBlock) + XCTAssertEqual(sel.asleepSeconds, 38 * 60, "decoded asleep = 24+8+6 = 38 min") + } + + // MARK: - #561 biphasic gap-bridge (mainNightGroupIndices) + + func testGroupIndicesBridgesTwoAdjacentFragments() throws { + // Two overnight fragments split by a 30-min wake gap (< 60-min bridge) → one group of BOTH. + let a = ts525("2026-06-14T23:00") + let aEnd = a + 3 * 3600 // 23:00 → 02:00 + let b = aEnd + 30 * 60 // 02:30 (30-min gap < gapBridgeMaxMin) + let blocks = [ + SleepStageTotals.NightBlock(start: a, end: aEnd), + SleepStageTotals.NightBlock(start: b, end: b + 3 * 3600), // 02:30 → 05:30 + ] + let group = try XCTUnwrap(SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0)) + XCTAssertEqual(group, [0, 1], "a <60-min wake gap bridges both fragments into the main-night group") + } + + func testGroupIndicesDoesNotBridgeLongGap() throws { + // A 5 h wake gap is NOT a biphasic interruption — the second block is a separate (daytime) sleep, + // so the group is just the single winning block (the longer overnight one). + let a = ts525("2026-06-14T23:00") + let aEnd = a + 5 * 3600 // 23:00 → 04:00 (5h overnight, the main night) + let b = aEnd + 5 * 3600 // 09:00 (5h gap >> 60-min bridge) + let blocks = [ + SleepStageTotals.NightBlock(start: a, end: aEnd), + SleepStageTotals.NightBlock(start: b, end: b + 2 * 3600), // 2h daytime nap + ] + let group = try XCTUnwrap(SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0)) + XCTAssertEqual(group, [0], "a long wake gap is not bridged — only the main block is the group") + } + + func testGroupIndicesSingleBlockMatchesBareSelector() throws { + // No gap to bridge → the group is exactly the single block mainNightIndex would pick (no regression). + let s = ts525("2026-06-15T00:00") + let blocks = [SleepStageTotals.NightBlock(start: s, end: s + 7 * 3600)] + XCTAssertEqual(try XCTUnwrap(SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0)), [0]) + XCTAssertNil(SleepStageTotals.mainNightGroupIndices([], offsetSec: 0)) + } + + func testGroupIndicesBridgedNightOutscoresLoneNap() throws { + // A biphasic main night (2h + gap + 2h = 4h bridged) must out-score a lone 3h daytime nap that, + // un-bridged, would beat either 2h fragment alone — proving the bridge is what wins. + let f1 = ts525("2026-06-14T23:00") + let f1End = f1 + 2 * 3600 // 23:00 → 01:00 + let f2 = f1End + 20 * 60 // 01:20 (20-min gap) + let f2End = f2 + 2 * 3600 // → 03:20 + let nap = ts525("2026-06-15T13:00") // daytime + let blocks = [ + SleepStageTotals.NightBlock(start: f1, end: f1End), + SleepStageTotals.NightBlock(start: nap, end: nap + 3 * 3600), // 3h lone nap + SleepStageTotals.NightBlock(start: f2, end: f2End), + ] + let group = try XCTUnwrap(SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0)) + XCTAssertEqual(group.sorted(), [0, 2], "the bridged biphasic night wins, returning BOTH its fragments") + } + + // MARK: - #861 a real overnight night split by a 60–90 min wake is ONE sleep, not nap + sleep + + /// The reported pattern (#861): one overnight sleep the detector left split into two fragments by a real + /// mid-night wake of ~70 min, longer than the old 60-min `gapBridgeMaxMin`, so the later fragment lost the + /// main-night pick and was LABELLED A NAP. The wider overnight night-tail bridge (≤ `nightTailBridgeMaxMin`, + /// onset still in the overnight band) now folds both fragments into ONE main-night group, so neither part is + /// a nap. Honest-data invariant: no stage is invented; the 70-min gap is later folded into AWAKE by the + /// aggregate, not relabelled sleep. + func testOvernightNightSplitBySeventyMinuteWakeMergesIntoOneSleepNotNap() throws { + let a = ts525("2026-06-14T23:30") // overnight onset + let aEnd = a + 3 * 3600 // 23:30 → 02:30 + let b = aEnd + 70 * 60 // 03:40 onset (70-min wake gap; 60 ≤ gap < 90) + let bEnd = b + 4 * 3600 // 03:40 → 07:40 (the longer tail) + let blocks = [ + SleepStageTotals.NightBlock(start: a, end: aEnd), + SleepStageTotals.NightBlock(start: b, end: bEnd), + ] + // Before the fix a 70-min gap was NOT bridged, the 4h tail won, and the 3h head became a "nap". + let group = try XCTUnwrap(SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0)) + XCTAssertEqual(group.sorted(), [0, 1], + "a 60–90 min mid-night wake bridges both overnight fragments into one sleep (no nap)") + // The wider bridge must NOT touch the bare `bridgeAdjacent` (its <60-min contract is unchanged): a + // 70-min gap still leaves it two blocks, so the golden detector-side bridge tests stay byte-identical. + XCTAssertEqual(SleepStageTotals.bridgeAdjacent(blocks).count, 2, + "the band-aware widening lives only in mainNightGroupIndices, not bridgeAdjacent") + } + + /// The daytime guard the widening must NOT breach: a genuine afternoon nap with the SAME 70-min gap from the + /// night's end stays its OWN block, because its onset is in the daytime band (not the overnight band the + /// wider bridge requires). So a real nap is never folded into the night by the #861 fix. + func testDaytimeNapWithSeventyMinuteGapStillStaysItsOwnBlock() throws { + let night = ts525("2026-06-15T00:00") // overnight + let nightEnd = night + 6 * 3600 // 00:00 → 06:00 (the main night) + // A 70-min gap from the night's end lands the nap onset at 07:10, still inside the broad overnight + // band [20:00, 11:00). To prove the DAYTIME guard, place the nap at a true daytime onset (13:00) and + // confirm it is not bridged regardless of being the same day. + let nap = ts525("2026-06-15T13:00") // daytime onset → never a night-tail + let blocks = [ + SleepStageTotals.NightBlock(start: night, end: nightEnd), + SleepStageTotals.NightBlock(start: nap, end: nap + 90 * 60), // 1.5h afternoon nap + ] + let group = try XCTUnwrap(SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0)) + XCTAssertEqual(group, [0], "a daytime-onset nap is never folded into the night by the wider bridge") + } + + /// The upper guard: a wake gap at/over `nightTailBridgeMaxMin` (90 min) is NOT a mid-night wake, so it stays + /// two blocks even for an overnight-band onset, so a genuinely separate early-morning sleep is not swallowed. + func testOvernightGapAtOrAboveNinetyMinutesDoesNotBridge() throws { + let a = ts525("2026-06-14T23:00") + let aEnd = a + 3 * 3600 // 23:00 → 02:00 + let b = aEnd + 95 * 60 // 03:35 onset (95-min gap ≥ nightTailBridgeMaxMin) + let blocks = [ + SleepStageTotals.NightBlock(start: a, end: aEnd), + SleepStageTotals.NightBlock(start: b, end: b + 4 * 3600), + ] + let group = try XCTUnwrap(SleepStageTotals.mainNightGroupIndices(blocks, offsetSec: 0)) + XCTAssertEqual(group, [1], "a ≥90-min wake is not a night-tail; the blocks stay separate") + } + + // MARK: - #561 stages-path seam sums the bridged group (analyzeDay parity) + + func testHonoringEditsSumsBiphasicGroup() throws { + // Two overnight fragments (each ~3h25m of sleep) split by a short wake gap, fed through the + // edit/recompute seam with onsets supplied → the daily total is the SUM of BOTH, not the longer one. + let a = ts525("2026-06-14T23:00") + // fragment A: 24+82+96 = 202 min sleep + 8 wake = 210 min in-bed → ends 02:30 + let aStages = #"{"awake":8,"light":24,"deep":82,"rem":96}"# + let aInBedSec = 210 * 60 + let b = a + aInBedSec + 20 * 60 // 20-min wake gap < 60-min bridge + // fragment B: 20+90+70 = 180 min sleep + 10 wake = 190 min in-bed + let bStages = #"{"awake":10,"light":20,"deep":90,"rem":70}"# + let r = try XCTUnwrap(SleepStageTotals.dailyAggregateHonoringEdits( + detected: [(startTs: a, stagesJSON: aStages), (startTs: b, stagesJSON: bStages)], + edited: [:], + onsetByStart: [a: a, b: b], offsetSec: 0)) + // Summed sleep = 202 + 180 = 382 min; the longer fragment alone would be only 202. + XCTAssertEqual(r.sleep.totalSleepMin, 382, accuracy: 0.001, + "the seam SUMS the bridged biphasic group, not just the longest fragment") + XCTAssertEqual(r.sleep.deepMin, 172, accuracy: 0.001) // 82 + 90 + } + + private func night(endDay: String, hours: Int) -> (start: Int, end: Int, hr: [HRSample], + rr: [RRInterval], gravity: [GravitySample]) { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "UTC") + fmt.dateFormat = "yyyy-MM-dd" + let dayMidnight = Int(fmt.date(from: endDay)!.timeIntervalSince1970) + let end = dayMidnight + 6 * 3600 + let start = end - hours * 3600 + var hr: [HRSample] = []; var rr: [RRInterval] = []; var grav: [GravitySample] = [] + for t in start.. Int { + // 2026-06-10 00:00:00 UTC (an arbitrary fixed midnight) + hourUTC hours. + let refMidnight = 1_749_513_600 + return refMidnight + hourUTC * 3_600 + } + /// Window anchored at a clear NIGHT hour (center stays out of [11,20) for short windows). + private func nightStart(_ hourUTC: Int) -> Int { startAtHour(hourUTC) } + /// Window anchored at a DAYTIME hour (center lands in [11,20) for the durations tested). + private func daytimeStart(_ hourUTC: Int) -> Int { startAtHour(hourUTC) } + func testDetectSleepFindsStillNight() { // 90 min still + low HR (50 bpm) → one sleep session. - let start = 1_000_000 + // Anchored at 02:00 UTC (center 02:45) so the window is OVERNIGHT at the default + // tzOffset=0 and never trips the daytime false-sleep guard (#90) — a plain still + // night must always register regardless of the guard. + let start = nightStart(02) let dur = 90 * 60 let grav = stillGravity(start: start, durationS: dur) let hr = hrStream(start: start, durationS: dur, bpm: 50) @@ -87,6 +103,285 @@ final class SleepStagerTests: XCTestCase { XCTAssertTrue(sessions.isEmpty) } + // MARK: - Daytime false-sleep guard (#90) + + /// A 70-min still, LOW-HR daytime window is rejected: even though its HR dips, it is + /// shorter than the daytime minimum (90 min), so it's the dominant false-positive a + /// sedentary daytime stretch produces. The preceding active block lifts the day HR + /// baseline so the HR test would otherwise PASS — proving the rejection is the duration + /// gate, not the HR gate. + func testDaytimeShortLowHRWindowRejected() { + let dayStart = daytimeStart(10) // 10:00 active context + let dayDur = 3 * 60 * 60 // 3 h awake, moving, HR 72 + let dayGrav = activeGravity(start: dayStart, durationS: dayDur) + let dayHR = hrStream(start: dayStart, durationS: dayDur, bpm: 72) + + let napStart = dayStart + dayDur // 13:00, center 13:35 → daytime band + let napDur = 70 * 60 // 70 min < 90 min daytime minimum + let napGrav = stillGravity(start: napStart, durationS: napDur) + let napHR = hrStream(start: napStart, durationS: napDur, bpm: 50) + + let sessions = SleepStager.detectSleep(hr: dayHR + napHR, gravity: dayGrav + napGrav) + XCTAssertTrue(sessions.isEmpty, "a 70-min daytime still window must be rejected by the guard") + } + + /// A 120-min still, genuine-dip daytime nap STILL registers: ≥ 90 min AND its resting HR + /// (50) sits clearly below the day HR baseline (~72), the cardiac signature of a real nap. + /// The guard must not suppress legitimate daytime sleep. + func testDaytimeQualityNapRegisters() { + let dayStart = daytimeStart(10) // 10:00 active context, HR 72 + let dayDur = 3 * 60 * 60 + let dayGrav = activeGravity(start: dayStart, durationS: dayDur) + let dayHR = hrStream(start: dayStart, durationS: dayDur, bpm: 72) + + let napStart = dayStart + dayDur // 13:00, center 14:00 → daytime band + let napDur = 120 * 60 // 120 min ≥ 90 min daytime minimum + let napGrav = stillGravity(start: napStart, durationS: napDur) + let napHR = hrStream(start: napStart, durationS: napDur, bpm: 50) + + let sessions = SleepStager.detectSleep(hr: dayHR + napHR, gravity: dayGrav + napGrav) + XCTAssertEqual(sessions.count, 1, "a 120-min daytime nap with a real HR dip must register") + // The run begins at/just after the active→still transition (the rolling stillness window + // shifts the boundary by a few minutes), and its center is firmly in the daytime band. + XCTAssertGreaterThanOrEqual(sessions[0].start, napStart) + XCTAssertLessThan(sessions[0].start, napStart + 10 * 60) + XCTAssertEqual(sessions[0].restingHR, 50) + } + + /// REGRESSION (late wake): a real overnight sleep whose TAIL runs past the daytime-band + /// start — here a brief 40-min morning stir then back to sleep until ~12:40 — must keep the + /// LATE wake time. The tail is daytime-centered and, on its own, fails the daytime guard's + /// resting-HR bar (its HR sits at baseline, not below it), so before the continuation + /// exemption it was rejected and the wake was truncated to ~10:00 ("woke at noon" bug). + /// Because the tail directly continues a chain that began overnight (gap ≤ 90 min), it is + /// kept — the night's wake reaches ~12:40, not late morning. + /// Reimplemented from @vulnix0x4's PR #353. + func testOvernightSleepTailPastNoonKeepsLateWake() { + let nStart = nightStart(02) // 02:00 overnight onset + let nDur = 8 * 60 * 60 // → 10:00 + let wStart = nStart + nDur // 10:00 brief morning wake + let wDur = 40 * 60 // 40 min: > mergeMin (15), ≤ continuation (90) + let tStart = wStart + wDur // 10:40 back to sleep + let tDur = 2 * 60 * 60 // → 12:40; center ~11:40 in the daytime band + + // Tail HR == night HR == baseline (50): passes the basic HR confirmation (≤ baseline×1.05) + // but FAILS the stricter daytime resting bar (> baseline×0.95), so only the overnight + // continuation exemption can keep it. + let grav = stillGravity(start: nStart, durationS: nDur) + + activeGravity(start: wStart, durationS: wDur) + + stillGravity(start: tStart, durationS: tDur) + let hr = hrStream(start: nStart, durationS: nDur, bpm: 50) + + hrStream(start: wStart, durationS: wDur, bpm: 70) + + hrStream(start: tStart, durationS: tDur, bpm: 50) + + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav) + let latestWake = sessions.map(\.end).max() ?? 0 + XCTAssertGreaterThanOrEqual( + latestWake, tStart + tDur - 10 * 60, + "overnight sleep's post-11:00 tail must be kept — wake not truncated to late morning") + } + + /// A 70-min still, low-HR OVERNIGHT window registers unchanged: its center (≈03:35) is + /// outside the daytime band, so the guard never applies and only the base 60-min minimum + /// gates it. This pins that the guard leaves overnight detection exactly as it was. + func testOvernightShortWindowUnchanged() { + let dayStart = nightStart(00) // 00:00 active context so a baseline exists + let dayDur = 3 * 60 * 60 // moving, HR 72 + let dayGrav = activeGravity(start: dayStart, durationS: dayDur) + let dayHR = hrStream(start: dayStart, durationS: dayDur, bpm: 72) + + let sleepStartTs = dayStart + dayDur // 03:00, center 03:35 → overnight + let sleepDur = 70 * 60 // 70 min > 60 min base minimum + let sleepGrav = stillGravity(start: sleepStartTs, durationS: sleepDur) + let sleepHR = hrStream(start: sleepStartTs, durationS: sleepDur, bpm: 50) + + let sessions = SleepStager.detectSleep(hr: dayHR + sleepHR, gravity: dayGrav + sleepGrav) + XCTAssertEqual(sessions.count, 1, "a 70-min overnight still window must register unchanged") + // Begins at/just after the active→still transition; center stays out of the daytime band. + XCTAssertGreaterThanOrEqual(sessions[0].start, sleepStartTs) + XCTAssertLessThan(sessions[0].start, sleepStartTs + 10 * 60) + } + + /// The guard is offset-aware: the SAME absolute window that is overnight at tzOffset=0 + /// becomes daytime under a +10 h offset and is then held to the stricter bar. With no + /// preceding awake block there is no HR baseline, so the daytime path rejects it (it can't + /// confirm a real dip) — while at offset 0 the identical 70-min still window registers. + func testTzOffsetShiftsWindowIntoDaytimeBand() { + let start = nightStart(02) // 02:00 UTC, center 02:35 + let dur = 70 * 60 + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + + // offset 0: overnight → registers. + XCTAssertEqual(SleepStager.detectSleep(hr: hr, gravity: grav).count, 1) + // +10 h: local center ≈ 12:35 → daytime band → stricter bar; no awake baseline → rejected. + let shifted = SleepStager.detectSleep(hr: hr, gravity: grav, tzOffsetSeconds: 10 * 3_600) + XCTAssertTrue(shifted.isEmpty, "a +10h offset pushes the window into the daytime band → rejected") + } + + /// Guards against the index-out-of-range crash class from the prior attempt: no candidate + /// at all (single still day, no HR) must return [] cleanly, not trap on empty median / + /// first/last accesses inside the daytime path. + func testDaytimeGuardEmptyInputsNoCrash() { + // A still daytime stretch with NO HR at all → baseline nil → daytime path returns false + // without touching any HR array; must not crash and must yield no sessions. + let start = daytimeStart(13) + let grav = stillGravity(start: start, durationS: 120 * 60) + XCTAssertTrue(SleepStager.detectSleep(gravity: grav).isEmpty) + // And the pure band/guard helpers tolerate a degenerate zero-length period. + let p = SleepStager.Period(stage: "sleep", start: start, end: start) + _ = SleepStager.isDaytimeCenter(p, tzOffsetSeconds: 0) + XCTAssertFalse(SleepStager.passesDaytimeGuard(p, restingHR: nil, baseline: nil)) + } + + // MARK: - Off-wrist backstop (#500) + + /// A long, still DAYTIME stretch where the HR stream has a >20-min contiguous gap (the strap was + /// off the wrist, so it banked no HR there) must NOT be classified as sleep. Before the off-wrist + /// backstop the gravity spine read the stillness as sleep and the daytime guard let it through as + /// "missing data" (nil restingHR) → a phantom daytime sleep. Here the dip-confirming HR before the + /// gap would even satisfy the daytime guard's resting-HR bar, so ONLY the HR-gap backstop rejects it. + func testOffWristDaytimeGapNotSleep() { + let dayStart = daytimeStart(10) // 10:00 active context, HR 72 (lifts the baseline) + let dayDur = 2 * 60 * 60 + let dayGrav = activeGravity(start: dayStart, durationS: dayDur) + let dayHR = hrStream(start: dayStart, durationS: dayDur, bpm: 72) + + // 12:00 the strap goes still on a desk for 2 h (≥90-min daytime minimum, center in [11,20)). + let offStart = dayStart + dayDur + let offDur = 2 * 60 * 60 + let offGrav = stillGravity(start: offStart, durationS: offDur) + // HR covers only the FIRST 20 min at a low 50 bpm (a real dip that would pass the daytime + // guard), then NOTHING for the rest — a >20-min contiguous off-wrist gap. + let offHR = hrStream(start: offStart, durationS: 20 * 60, bpm: 50) + + let sessions = SleepStager.detectSleep(hr: dayHR + offHR, gravity: dayGrav + offGrav) + XCTAssertTrue(sessions.isEmpty, + "a still daytime stretch with a >20-min HR-coverage gap is off-wrist, not sleep") + } + + /// The off-wrist backstop must NOT suppress a genuine worn night: dense 1 Hz HR has no gap, so the + /// same 90-min still overnight window still registers as exactly one session. + func testWornNightWithDenseHRStillRegisters() { + let start = nightStart(02) + let dur = 90 * 60 + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav) + XCTAssertEqual(sessions.count, 1, "a worn night with dense, gap-free HR must still register") + } + + /// THE critical case j0b-dev's #504 designed (HR-gap path): a real overnight night whose detected + /// still period over-extends into a SHORT off-wrist morning tail — the user takes the strap off + /// shortly after waking, so the tail flatlines to no HR — is KEPT. The old binary guard dropped the + /// WHOLE night on that one trailing gap; the fractional rule keeps it because the tail is < 50% of + /// the period. Here: ~3.5 h worn (dense HR) + 30 min off-wrist tail (no HR) ⇒ ~12.5% off-wrist. + func testRealNightWithShortOffWristTailIsKept_HRGapPath() { + let start = nightStart(01) + let wornDur = 210 * 60 // 3.5 h worn, dense 1 Hz HR + let tailDur = 30 * 60 // 30 min off-wrist tail: still gravity, NO HR + let grav = stillGravity(start: start, durationS: wornDur + tailDur) // one continuous still run + let hr = hrStream(start: start, durationS: wornDur, bpm: 50) // HR stops at the wake + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav) + XCTAssertEqual(sessions.count, 1, + "a real night with a short (<50%) off-wrist morning tail must be KEPT, not dropped") + } + + /// FRACTIONAL rule (#504), explicit-interval variant: a real night whose detected period over-extends + /// into a short off-wrist tail covered by an explicit WRIST_OFF→WRIST_ON interval (HR is dense the + /// whole window, e.g. a 5/MG still streaming PPG-HR) is KEPT — the interval covers < 50% of the run. + func testRealNightWithShortOffWristTailIsKept_IntervalPath() { + let start = nightStart(01) + let dur = 240 * 60 // 4 h, dense HR throughout + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + // Strap removed for the last 30 min (12.5% of the run) → tiny overlap, keep the night. + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav, + wristOff: [(start: start + dur - 30 * 60, end: start + dur)]) + XCTAssertEqual(sessions.count, 1, + "a real night with a short (<50%) explicit off-wrist tail must be KEPT") + } + + /// The explicit-interval path (#500), FRACTIONAL rule (#504): a WRIST_OFF→WRIST_ON interval that + /// covers most of an otherwise-valid overnight window drops it, even though the HR here is dense + /// and gap-free — its off-wrist coverage is ≥ maxOffWristSleepFraction. + func testWristOffIntervalCoveringMostOfRunDropsIt() { + let start = nightStart(02) + let dur = 90 * 60 + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + // No interval → registers (control); a near-full off-wrist interval (≥50%) → dropped. + XCTAssertEqual(SleepStager.detectSleep(hr: hr, gravity: grav).count, 1) + let dropped = SleepStager.detectSleep(hr: hr, gravity: grav, + wristOff: [(start: start + 5 * 60, end: start + dur)]) + XCTAssertTrue(dropped.isEmpty, "a WRIST_OFF interval covering ≥50% of the run must drop it") + } + + /// FRACTIONAL rule (#504): a single BRIEF WRIST_OFF blip (well under 50% of the run) must NOT drop a + /// real, dense, worn night — the flaw the binary "any WRIST_OFF drops it" guard had. Here a 5-min + /// off-wrist interval over a 90-min night is ~5.5% coverage, so the night is kept. + func testBriefWristOffBlipKeepsWornNight() { + let start = nightStart(02) + let dur = 90 * 60 + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + let kept = SleepStager.detectSleep(hr: hr, gravity: grav, + wristOff: [(start: start + 30 * 60, end: start + 35 * 60)]) + XCTAssertEqual(kept.count, 1, "a brief (<50%) WRIST_OFF blip must NOT drop a real worn night") + } + + /// #507 — the off-wrist HR-gap proxy must NOT drop a real night that simply has SPARSE heart rate. + /// A WHOOP 4.0's synced night is motion-reconstructed with thin, derived HR, so it's naturally full + /// of >20-min HR gaps; the proxy would otherwise read it as ~100% off-wrist and drop a real night + /// (the regression a 4.0 owner hit after upgrading). The density gate disables the proxy when the + /// stream averages fewer than one sample per `hrDenseSpacingS`, so the fraction is 0 and it's kept — + /// while explicit WRIST_OFF events remain authoritative regardless of HR density. + func testSparseHRNightDisablesOffWristProxy_507() { + let p = SleepStager.Period(stage: "sleep", start: 0, end: 5_400) // 90-min night + // HR every 25 min → 4 samples, gaps of 1500 s (≥ 20 min): under the OLD logic almost entirely + // "off-wrist". Density = 4 samples over a 4500 s span < 4500/600 = 7 ⇒ proxy disabled. + let sparse = [0, 1_500, 3_000, 4_500].map { HRSample(ts: $0, bpm: 52) } + XCTAssertTrue(SleepStager.offWristHRGapSpans(p, hr: sparse).isEmpty, + "sparse HR (motion-reconstructed 4.0 night) must NOT register off-wrist gap spans") + XCTAssertEqual(SleepStager.offWristFraction(p, hr: sparse, wristOff: []), 0.0, accuracy: 1e-9, + "a sparse-HR real night must read 0% off-wrist, so it is never dropped (#507)") + // An explicit WRIST_OFF interval still drops a genuinely off-wrist sparse night (events are + // independent of the density gate): [0, 3000) over 5400 s = ~55% ≥ maxOffWristSleepFraction. + XCTAssertGreaterThanOrEqual( + SleepStager.offWristFraction(p, hr: sparse, wristOff: [(start: 0, end: 3_000)]), 0.5, + "WRIST_OFF events remain authoritative regardless of HR density") + } + + /// The fractional helpers are precise about the threshold, edges, and the union. `offWristHRGapSpans` + /// returns the ≥20-min gaps as concrete spans; `offWristFraction` divides their union (with the + /// wrist-off intervals) by duration; a run with NO HR at all leaves the gravity-only path alone. + func testOffWristFractionAndGapSpans() { + let p = SleepStager.Period(stage: "sleep", start: 0, end: 3_600) + // Dense coverage → no gap span, zero fraction. + let dense = (0...3_600).map { HRSample(ts: $0, bpm: 50) } + XCTAssertTrue(SleepStager.offWristHRGapSpans(p, hr: dense).isEmpty) + XCTAssertEqual(SleepStager.offWristFraction(p, hr: dense, wristOff: []), 0.0, accuracy: 1e-9) + // A single 21-min interior gap (≥ 20 min) → one span, fraction = 1260/3600. + let gappy = (0...600).map { HRSample(ts: $0, bpm: 50) } + + (1_860...3_600).map { HRSample(ts: $0, bpm: 50) } // gap 600→1860 = 1260 s ≥ 1200 + let spans = SleepStager.offWristHRGapSpans(p, hr: gappy) + XCTAssertEqual(spans.count, 1) + XCTAssertEqual(spans[0].start, 600); XCTAssertEqual(spans[0].end, 1_860) + XCTAssertEqual(SleepStager.offWristFraction(p, hr: gappy, wristOff: []), + 1_260.0 / 3_600.0, accuracy: 1e-9) + // Union must not double-count: a wrist-off interval overlapping the gap doesn't inflate coverage. + XCTAssertEqual(SleepStager.offWristFraction(p, hr: gappy, + wristOff: [(start: 800, end: 1_500)]), + 1_260.0 / 3_600.0, accuracy: 1e-9) + // A disjoint wrist-off interval adds to coverage (union of 1260 s gap + 600 s event = 1860 s). + XCTAssertEqual(SleepStager.offWristFraction(p, hr: gappy, + wristOff: [(start: 2_400, end: 3_000)]), + 1_860.0 / 3_600.0, accuracy: 1e-9) + // No HR stream at all → no gap spans, zero fraction (can't assert off-wrist without HR). + XCTAssertTrue(SleepStager.offWristHRGapSpans(p, hr: []).isEmpty) + XCTAssertEqual(SleepStager.offWristFraction(p, hr: [], wristOff: []), 0.0, accuracy: 1e-9) + } + // MARK: - Staging output integrity func testStagesTileSessionExactly() { @@ -172,4 +467,657 @@ final class SleepStagerTests: XCTestCase { XCTAssertTrue(rate.isNaN) XCTAssertTrue(rrv.isNaN) } + + // #127 / #129: a depth-signature epoch (still, low HR, regular breathing) must be classed DEEP + // even when per-epoch RMSSD is missing — sparse R-R (common on BLE-offloaded nights, esp. 5/MG) + // used to hard-block deep, so those nights decoded 0 m of deep sleep. A MEASURABLE-but-low RMSSD + // must still keep the epoch out of deep (the high-tone bar applies when we can measure it). + private func depthEpoch(rmssd: Double) -> SleepStager.EpochFeatures { + SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0, // still + ckSleep: true, hr: 50, hrVar: 0, rmssd: rmssd, sdnn: 0, + respRate: 14, rrv: .nan, // missing resp → regular (pro-deep) + clock: 0.5) + } + + func testMissingRmssdNoLongerBlocksDeep() { + // hrLo=55 (so hr=50 is "low"), rmssdHi=50, no cardiac activation. + let withMissingRmssd = SleepStager.classifyOne(depthEpoch(rmssd: .nan), + hrLo: 55, hrHi: 90, rmssdHi: 50, hrvarHi: 100, rrvHi: 1, rrvLo: 0.5) + XCTAssertEqual(withMissingRmssd, "deep", "a missing per-epoch RMSSD must not block deep") + + let withLowRmssd = SleepStager.classifyOne(depthEpoch(rmssd: 10), + hrLo: 55, hrHi: 90, rmssdHi: 50, hrvarHi: 100, rrvHi: 1, rrvLo: 0.5) + XCTAssertNotEqual(withLowRmssd, "deep", "a measurable-but-low RMSSD epoch must still clear the high-tone bar") + } + + // #705: a still, low-HR sleep epoch with INFLATED HR-variance (hrVar ≥ the high bar) but a normal HR + // (below hrHigh) and a touch of movement used to be flipped to WAKE on a WHOOP 5/MG night, because the + // PPG-derived HR makes per-epoch hrVar noisy and the WAKE rule trusted hrvarHigh as cardiac activation. + // On a sparse/PPG night the WAKE rule must vet the cardiac half by HR only (down-weight hrVar), so the + // epoch stays sleep. A dense 4.0 night (cardiacSparse:false) keeps the original hrHigh||hrvarHigh signal. + private func ppgWakeEpoch() -> SleepStager.EpochFeatures { + // moveFrac just over the wake bar (0.15), HR normal (60, below hrHi=90, above hrLo=55 so not deep), + // hrVar inflated above the high bar, missing R-R (sparse → resp also NaN → regular). + SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0.16, + ckSleep: true, hr: 60, hrVar: 200, rmssd: .nan, sdnn: 0, + respRate: 14, rrv: .nan, clock: 0.5) + } + + func testSparseCardiacDoesNotPromoteStillSleepToWakeOnHrVarAlone_705() { + // Dense path: hrvarHigh alone clears the WAKE cardiac bar → this epoch reads wake (old behaviour). + let dense = SleepStager.classifyOne(ppgWakeEpoch(), + hrLo: 55, hrHi: 90, rmssdHi: 50, hrvarHi: 100, rrvHi: 1, rrvLo: 0.5, + cardiacSparse: false) + XCTAssertEqual(dense, "wake", "dense 4.0 night keeps the full hrHigh||hrvarHigh wake signal") + + // Sparse/PPG path: the noisy hrVar is down-weighted for the wake promotion → no longer wake. + let sparse = SleepStager.classifyOne(ppgWakeEpoch(), + hrLo: 55, hrHi: 90, rmssdHi: 50, hrvarHi: 100, rrvHi: 1, rrvLo: 0.5, + cardiacSparse: true) + XCTAssertNotEqual(sparse, "wake", "a sparse/PPG night must not flip still low-HR sleep to wake on hrVar alone") + } + + func testCardiacSparseFlagFiresOnMostlyMissingRmssd_705() { + // A night where >= half the sleep epochs carry no finite RMSSD is PPG-derived / sparse-cardiac. + let withRR = SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0, ckSleep: true, + hr: 55, hrVar: 0, rmssd: 40, sdnn: 0, respRate: 14, rrv: .nan, clock: 0.5) + let noRR = SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0, ckSleep: true, + hr: 55, hrVar: 0, rmssd: .nan, sdnn: 0, respRate: 14, rrv: .nan, clock: 0.5) + XCTAssertTrue(SleepStager.isCardiacSparse([noRR, noRR, noRR, withRR]), + "3/4 epochs missing R-R is sparse-cardiac") + XCTAssertFalse(SleepStager.isCardiacSparse([withRR, withRR, withRR, noRR]), + "1/4 epochs missing R-R is a dense (4.0-style) night") + XCTAssertFalse(SleepStager.isCardiacSparse([]), "empty session is not sparse") + } + + // #705 (golden): a still PPG night used to score mostly WAKE because the noisy PPG-derived hrVar + // tripped the high hrVar bar on still, low-HR sleep epochs and the WAKE rule treated that as cardiac + // activation. We classify a batch of such epochs with FIXED session bars (deterministic — same shape + // as the #127 tests) under both rules. On the dense rule the over-wake reproduces; with the + // sparse-cardiac gate the WAKE share collapses while the elevated-HR awakenings still read wake. + func testStillPpgNightNoLongerScoresMostlyWake_705() { + // Fixed bars: hrLo=48, hrHi=70, hrvarHi=120. A still, low-HR (52) epoch with a touch of motion + // (0.16) and inflated hrVar (200) — no finite R-R/resp. 9/10 such, 1/10 a real HR-elevated wake. + func epoch(hr: Double, hrVar: Double) -> SleepStager.EpochFeatures { + SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0.16, + ckSleep: true, hr: hr, hrVar: hrVar, rmssd: .nan, sdnn: 0, + respRate: 14, rrv: .nan, clock: 0.5) + } + let night: [SleepStager.EpochFeatures] = (0..<40).map { i in + (i % 10 == 9) ? epoch(hr: 80, hrVar: 200) // genuine elevated-HR awakening + : epoch(hr: 52, hrVar: 200) // still, low-HR sleep with noisy PPG hrVar + } + let bars = (hrLo: 48.0, hrHi: 70.0, rmssdHi: 50.0, hrvarHi: 120.0, rrvHi: 1.0, rrvLo: 0.5) + + func wakeShare(cardiacSparse: Bool) -> Double { + let labels = night.map { + SleepStager.classifyOne($0, hrLo: bars.hrLo, hrHi: bars.hrHi, rmssdHi: bars.rmssdHi, + hrvarHi: bars.hrvarHi, rrvHi: bars.rrvHi, rrvLo: bars.rrvLo, + cardiacSparse: cardiacSparse) + } + return Double(labels.filter { $0 == "wake" }.count) / Double(labels.count) + } + + // Dense rule reproduces the bug: almost the whole night reads wake (hrvarHigh alone promotes). + XCTAssertGreaterThan(wakeShare(cardiacSparse: false), 0.80, + "dense rule still over-reports wake on a noisy-hrVar night (reproduces #705)") + // Sparse-cardiac gate: only the real HR-elevated awakenings stay wake (~10%). + XCTAssertLessThan(wakeShare(cardiacSparse: true), 0.40, + "a still PPG night must not be classified as mostly wake (was 40%+ before #705)") + } + + // #127 (follow-up): the "deep is front-loaded" re-imposition zeroed deep entirely on nights whose + // whole deep block lands after the first third (clock > 1/3). It must only re-impose late "deep" to + // light when there's deep in the first third to anchor it; otherwise keep the best estimate. + private func clockEpoch(_ clock: Double) -> SleepStager.EpochFeatures { + SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0, ckSleep: true, + hr: 50, hrVar: 0, rmssd: 60, sdnn: 0, respRate: 14, rrv: .nan, clock: clock) + } + + func testDeepReimpositionKeepsLateDeepWhenNoEarlyDeep() { + let labels = ["deep", "deep", "deep", "deep"] + // Early deep present (clock 0.2): the later deep (> 1/3) is re-imposed to light. + let withEarly = SleepStager.reimposePhysiology(labels, + features: [clockEpoch(0.2), clockEpoch(0.5), clockEpoch(0.7), clockEpoch(0.9)], + onsetIdx: 0, finalWakeIdx: 3) + XCTAssertEqual(withEarly, ["deep", "light", "light", "light"]) + // No early deep (all clocks > 1/3): the late deep is KEPT rather than zeroed to 0 m. (#127) + let allLate = SleepStager.reimposePhysiology(labels, + features: [clockEpoch(0.5), clockEpoch(0.6), clockEpoch(0.7), clockEpoch(0.9)], + onsetIdx: 0, finalWakeIdx: 3) + XCTAssertEqual(allLate, ["deep", "deep", "deep", "deep"]) + } + + // MARK: - Fragment merge / hypnogram smoothing (#274) + + /// Expand a [(stage, epochs)] run-list into a flat per-epoch label array. + private func expand(_ runs: [(String, Int)]) -> [String] { + var out: [String] = [] + for (s, n) in runs { out.append(contentsOf: repeatElement(s, count: n)) } + return out + } + /// Collapse a flat label array back into [(stage, epochs)] runs for terse assertions. + private func runs(_ labels: [String]) -> [(String, Int)] { + var out: [(String, Int)] = [] + for s in labels { + if let last = out.last, last.0 == s { out[out.count - 1].1 += 1 } + else { out.append((s, 1)) } + } + return out + } + private func assertRuns(_ labels: [String], _ expected: [(String, Int)], + _ msg: String = "", file: StaticString = #filePath, line: UInt = #line) { + let got = runs(labels) + XCTAssertEqual(got.count, expected.count, "\(msg) run count — got \(got)", file: file, line: line) + for i in 0..maxGapMin gaps across the night. + private func sparseStillGravity(start: Int, durationS: Int, everyS: Int) -> [GravitySample] { + stride(from: 0, to: durationS, by: everyS).map { GravitySample(ts: start + $0, x: 0, y: 0, z: 1.0) } + } + + func testSparseGravityNightNotShredded() { + // A ~6 h overnight window: DENSE 1 Hz sleep-band HR (50 bpm) but SPARSE gravity — one still + // sample every 25 min, so every inter-sample gap (1500 s) exceeds maxGapMin (1200 s). Before + // #308 buildRuns broke the run at every gap and detectSleep dropped every <60-min fragment, + // collapsing the night to ~0. Now the sparse path keeps it as ONE continuous ~6 h session. + let start = nightStart(01) // 01:00, center stays overnight + let dur = 6 * 60 * 60 // 6 h + let grav = sparseStillGravity(start: start, durationS: dur, everyS: 25 * 60) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + + // The gate must classify this gravity as sparse (median gap 1500 s > 1200 s). + XCTAssertTrue(SleepStager.isGravitySparse(grav, hr: hr), "clumped gravity must read as sparse") + + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav) + XCTAssertEqual(sessions.count, 1, "a sparse-gravity night must be ONE session, not shredded") + let s = sessions[0] + // One ~6 h span (bounded by first/last gravity sample), not a sub-60-min fragment. + XCTAssertGreaterThan(Double(s.end - s.start), 5.0 * 60 * 60, + "the bridged session must be ~6 h, not a sub-hour fragment") + XCTAssertEqual(s.restingHR, 50) + } + + func testDenseGravityNightUnchangedBySparsePath() { + // Snapshot/regression guard for the 4.0 path: a DENSE 1 Hz still gravity night must NOT be + // classified sparse, and must produce the SAME single stable session it did before #308 — + // identical start, end and resting HR. Proves the sparse branches never touch the dense path. + let start = nightStart(02) + let dur = 6 * 60 * 60 + let grav = stillGravity(start: start, durationS: dur) // dense 1 Hz + let hr = hrStream(start: start, durationS: dur, bpm: 50) + + XCTAssertFalse(SleepStager.isGravitySparse(grav, hr: hr), "dense 1 Hz gravity must NOT read as sparse") + + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav) + XCTAssertEqual(sessions.count, 1) + let s = sessions[0] + // Stable bounds: dense gravity tiles the whole window, so the session is [start, last sample]. + XCTAssertEqual(s.start, start) + XCTAssertEqual(s.end, start + dur - 1) // last 1 Hz sample is at start+dur-1 + XCTAssertEqual(s.restingHR, 50) + } + + func testBuildRunsDenseGravityByteIdenticalToLegacy() { + // Direct byte-identity proof: buildRuns with the sparse override OFF (the default) returns + // exactly the same runs as passing sparse:false, on a gravity stream with a real >maxGapMin + // gap. The legacy two-arg call and the sparse=false call must be indistinguishable. + let start = 5_000_000 + // Two still blocks separated by a 30-min (>20 min) gap → legacy buildRuns splits them. + let blockA = stillGravity(start: start, durationS: 40 * 60) + let gapStart = start + 40 * 60 + 30 * 60 + let blockB = stillGravity(start: gapStart, durationS: 40 * 60) + let grav = blockA + blockB + let deltas = SleepStager.gravityDeltas(grav) + let flags = SleepStager.classifyStill(grav, deltas) + + let legacy = SleepStager.buildRuns(grav, flags) // default sparse:false + let explicit = SleepStager.buildRuns(grav, flags, sparse: false) + XCTAssertEqual(legacy.count, explicit.count) + for (a, b) in zip(legacy, explicit) { + XCTAssertEqual(a.stage, b.stage); XCTAssertEqual(a.start, b.start); XCTAssertEqual(a.end, b.end) + } + // The dense >20-min gap still splits the night (a real wake), so there are ≥2 runs. + XCTAssertGreaterThanOrEqual(legacy.count, 2, "a real >20-min gap must still split the dense path") + } + + func testGravitySparseGateConditions() { + // The gate trips on EITHER a short gravity span vs HR span OR any inter-sample gravity gap > maxGapMin. + let start = 6_000_000 + let hr = hrStream(start: start, durationS: 6 * 60 * 60, bpm: 50) + + // (a) Span test: gravity confined to the first 30 min of a 6 h HR window (< 0.5 frac). + let clumped = stillGravity(start: start, durationS: 30 * 60) + XCTAssertTrue(SleepStager.isGravitySparse(clumped, hr: hr), "short gravity span → sparse") + + // (b) Large-gap test: gravity spans the night but every gap is 25 min (> maxGapMin). + let bigGaps = sparseStillGravity(start: start, durationS: 6 * 60 * 60, everyS: 25 * 60) + XCTAssertTrue(SleepStager.isGravitySparse(bigGaps, hr: hr), "a large inter-sample gap → sparse") + + // (c) Dense gravity over the same span is NOT sparse. + let dense = stillGravity(start: start, durationS: 6 * 60 * 60) + XCTAssertFalse(SleepStager.isGravitySparse(dense, hr: hr), "dense gravity → not sparse") + + // (d) Degenerate HR (<2 samples) keeps the dense path regardless of gravity. + XCTAssertFalse(SleepStager.isGravitySparse(bigGaps, hr: []), "no HR span → keep dense path") + + // (e) #28: gravity SPANS the night (span gate stays dense) with a ~1 s MEDIAN gap (dense + // bursts) but a single >maxGapMin dropout — the median test misses it, the max-gap test + // catches it. Two 160-min blocks split by a 40-min dropout cover the whole 6 h HR window. + let clumpedBigGap = stillGravity(start: start, durationS: 160 * 60) + + stillGravity(start: start + (160 + 40) * 60, durationS: 160 * 60) + XCTAssertTrue(SleepStager.isGravitySparse(clumpedBigGap, hr: hr), + "clumped gravity + one long dropout (small median, large max) → sparse") + } + + func testClumpedGravityWithLongDropoutBridged_28() { + // #28: WHOOP 4.0 motion arrives CLUMPED — two dense 40-min still blocks split by a 30-min + // dropout, the gravity spanning the whole HR window. The block-internal gaps are ~1 s so the + // MEDIAN gate stays dense and the span gate doesn't fire; only the new max-gap arm catches the + // dropout. With sleep-band HR across the gap the night is bridged into ONE session instead of + // two dropped sub-minSleepMin fragments (~0 sleep) under the old median-only gate. + let start = nightStart(02) + let block = 40 * 60 + let gap = 30 * 60 + let grav = stillGravity(start: start, durationS: block) + + stillGravity(start: start + block + gap, durationS: block) + let dur = 2 * block + gap // HR spans the whole window + let hr = hrStream(start: start, durationS: dur, bpm: 50) + + XCTAssertTrue(SleepStager.isGravitySparse(grav, hr: hr), + "clumped motion with a long dropout (small median, large max gap) must read as sparse") + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav) + XCTAssertEqual(sessions.count, 1, + "the dropout must be bridged into ONE session — not dropped sub-60-min fragments") + XCTAssertGreaterThan(Double(sessions[0].end - sessions[0].start), Double(2 * block), + "the bridged session must span both blocks across the dropout") + } + + func testSessionAvgHRVRejectsEctopicSpikes() { + // A 5-min window of steady ~900 ms beats (≈67 bpm) with a +600 ms ectopic + // spike every 15th beat — the shape of PPG-derived 0x2A37 RR on a WHOOP 5/MG. + // rMSSD is built from SUCCESSIVE differences, so the spikes would inflate the + // session HRV if left in. cleanRR's Malik ectopic rejection drops them, so the + // cleaned series is steady → HRV ≈ 0. Pre-fix (rangeFilter only) this path + // returned ~200 ms; this guards the #262/#235 fix against regression. + var rr: [RRInterval] = [] + let start = 1000, end = start + 300 + for i in 0..<300 { + rr.append(RRInterval(ts: start + i, rrMs: (i % 15 == 0) ? 1500 : 900)) + } + let hrv = SleepStager.sessionAvgHRV(start: start, end: end, rr: rr) + XCTAssertNotNil(hrv) + XCTAssertLessThan(hrv!, 50, "ectopic spikes must be rejected before rMSSD") + } + + // MARK: - Helper robustness + + func testConvolveReflectShortInputDoesNotCrash() { + // A signal far shorter than the kernel radius must not index out of bounds. The DoG sigma2 + // kernel has radius 60; a 3-sample signal would read x[60] without the length guard. (The + // production caller is gated by the 60-min session floor, so this is defensive hardening.) + let kernel = SleepStager.gaussianKernel(sigmaS: 600) // radius 60 + let short = [1.0, 2.0, 3.0] + XCTAssertEqual(SleepStager.convolveReflect(short, kernel), short, + "a signal shorter than the kernel radius returns unchanged instead of trapping") + } + + func testFindPeaksTieBreakKeepsLowestIndex() { + // Two equal-height peaks within `distance` of each other: the greedy min-distance + // suppression must keep the LOWER index deterministically (matching the Android stable + // sort), rather than relying on the stdlib sort's incidental tie order. + let x = [0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0] // equal peaks at indices 2 and 4 + XCTAssertEqual(SleepStager.findPeaks(x, distance: 5, height: 0.5), [2], + "equal-height peaks within distance keep the lowest index") + } + + // MARK: - H4 physiological in-bed span cap (#547/#531/#509 tail) + + func testDetectSleepClampsOverlongBadClockBlock() { + // A frozen-still 18 h "night" (a bad-clock artefact) exceeds the 16 h physiological cap → DROPPED, + // so it can never report a 12 h+ sleep. Anchored at a night hour with low HR so ONLY the span cap + // can reject it (the duration floor + HR confirmation both pass). + let start = nightStart(22) + let dur = 18 * 60 * 60 // 18 h > maxMainSleepSpanS (16 h) + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + XCTAssertTrue(SleepStager.detectSleep(hr: hr, gravity: grav).isEmpty, + "an 18 h still block is a bad-clock artefact and is dropped by the span cap") + } + + func testDetectSleepKeepsLongButPlausibleNight() { + // A genuinely long but plausible night (just under the 16 h cap) is KEPT — the cap only drops the + // clock-artefact range, never a real recovery/lie-in night. + let start = nightStart(21) + let dur = 15 * 60 * 60 // 15 h ≤ cap + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + XCTAssertEqual(SleepStager.detectSleep(hr: hr, gravity: grav).count, 1, + "a 15 h night is below the cap and survives") + } + + // MARK: - H7 morning-stillness nap suppression (#531) — pure guard + + /// A daytime Period helper (center lands in the [11,20) band at tzOffset 0). + private func daytimePeriod(_ startHour: Int, durMin: Int) -> SleepStager.Period { + let s = startAtHour(startHour) + return SleepStager.Period(stage: "sleep", start: s, end: s + durMin * 60) + } + + func testMorningStillnessRejectedNearOvernightWake() { + // A 120-min daytime block at 09:00 that clears the ORDINARY daytime guard (resting 74 ≤ 0.95×80=76) + // but NOT the stronger re-onset bar (74 > 0.90×80=72), beginning right after a 08:00 overnight wake, + // is REJECTED as morning residual stillness. + let p = daytimePeriod(9, durMin: 120) + let wakeEnd = startAtHour(8) // overnight chain woke at 08:00, ~1 h before p + XCTAssertFalse( + SleepStager.passesMorningStillnessGuard(p, restingHR: 74, baseline: 80, morningWakeEnd: wakeEnd), + "a still block right after the overnight wake with no clear re-onset dip is rejected") + } + + func testMorningStillnessKeptOnStrongReonsetDip() { + // Same morning window, but a clear cardiac dip (resting 70 ≤ 0.90×80=72) → a genuine second sleep + // is KEPT. + let p = daytimePeriod(9, durMin: 120) + let wakeEnd = startAtHour(8) + XCTAssertTrue( + SleepStager.passesMorningStillnessGuard(p, restingHR: 70, baseline: 78, morningWakeEnd: wakeEnd), + "a clear re-onset HR dip keeps a genuine morning second sleep") + } + + func testMorningStillnessGuardNoOpOutsideWindow() { + // A nap hours later (no overnight wake nearby → morningWakeEnd nil) faces only the ordinary daytime + // guard, unchanged. + let p = daytimePeriod(14, durMin: 120) // 14:00 afternoon nap + XCTAssertTrue( + SleepStager.passesMorningStillnessGuard(p, restingHR: 70, baseline: 80, morningWakeEnd: nil), + "outside the morning window the guard is the ordinary daytime bar") + } + + func testMorningStillnessRescuedByBandSleepState() { + // The strap's OWN banked band sleep_state reads predominantly "asleep" (2) over the block → the H7 + // guard KEEPS it even though the HR dip is borderline (74 > 0.90×80=72, would otherwise be rejected). + // CONSUME path. + let p = daytimePeriod(9, durMin: 120) + let wakeEnd = startAtHour(8) + // 80% of in-block samples are state 2 (asleep) ≥ the 0.6 fraction. + var band: [(ts: Int, state: Int)] = [] + let n = 100 + for i in 0.. 0.90×80=72). + XCTAssertFalse( + SleepStager.passesMorningStillnessGuard(p, restingHR: 74, baseline: 80, morningWakeEnd: wakeEnd)) + } + + func testBandStateConfirmsAsleepFractionGate() { + let p = daytimePeriod(9, durMin: 60) + // 50% asleep < 0.6 → NOT confirmed. + var half: [(ts: Int, state: Int)] = [] + for i in 0..<100 { half.append((ts: p.start + i * 30, state: i < 50 ? 2 : 0)) } + XCTAssertFalse(SleepStager.bandStateConfirmsAsleep(p, bandSleepState: half)) + // Empty band → never confirmed (no fabricated reading). + XCTAssertFalse(SleepStager.bandStateConfirmsAsleep(p, bandSleepState: [])) + } + + // MARK: - H8 per-epoch motion (persisted beside stagesJSON) + + func testSessionEpochMotionGridsToStageEpochs() { + // 90-min still night → ~180 thirty-second epochs of near-zero motion, on the same grid as staging. + let start = nightStart(02) + let dur = 90 * 60 + let grav = stillGravity(start: start, durationS: dur) + let motion = SleepStager.sessionEpochMotion(start: start, end: start + dur, grav: grav) + // 90 min / 30 s = 180 epochs. + XCTAssertEqual(motion.count, 180, "one motion value per 30 s epoch") + XCTAssertTrue(motion.allSatisfy { $0 >= 0 }, "motion magnitudes are non-negative |Δgravity| sums") + // A perfectly still stream has ~zero motion. + XCTAssertEqual(motion.reduce(0, +), 0, accuracy: 1e-6) + } + + func testSessionEpochMotionEmptyWhenNoGravity() { + // Too little gravity to grid → [] so the caller persists NULL, never a fabricated zero series. + XCTAssertTrue(SleepStager.sessionEpochMotion(start: 0, end: 1800, grav: []).isEmpty) + } + + // MARK: - #175 per-session band sleep_state gridding (persisted beside stagesJSON) + + func testSessionEpochSleepStateGridsOnePerEpoch() { + // 90-min session at 1 sample/30 s all "asleep" (2) → 180 epochs, all 2, on the same grid as staging. + let start = 1_000_000 + let dur = 90 * 60 + var band: [(ts: Int, state: Int)] = [] + for i in 0..<(dur / 30) { band.append((ts: start + i * 30, state: 2)) } + let states = SleepStager.sessionEpochSleepState(start: start, end: start + dur, sleepState: band) + XCTAssertEqual(states.count, 180, "one band-state value per 30 s epoch (matches sessionEpochMotion)") + XCTAssertTrue(states.allSatisfy { $0 == 2 }, "an all-asleep band grids to all-asleep epochs") + } + + func testSessionEpochSleepStateCarriesForwardAndVerbatim() { + // A sparse band that flips wake(0)→asleep(2)→up(3): each epoch takes the last in-window state and + // carries it forward across empty epochs. state 0 is a REAL wake reading, carried verbatim. + let start = 0 + let dur = 6 * 30 // 6 epochs + let band: [(ts: Int, state: Int)] = [ + (ts: 0, state: 0), // epoch 0 → 0 (wake) + (ts: 75, state: 2), // epoch 2 → 2 (asleep); epoch 1 carries 0 forward + (ts: 160, state: 3), // epoch 5 → 3 (up); epochs 3,4 carry 2 forward + ] + let states = SleepStager.sessionEpochSleepState(start: start, end: start + dur, sleepState: band) + XCTAssertEqual(states, [0, 0, 2, 2, 2, 3], "last-in-epoch wins; empty epochs carry forward; 0 kept") + } + + func testSessionEpochSleepStateEmptyWhenNoBandSamples() { + // No band samples in the window → [] so the caller persists NULL (a WHOOP 4.0 / unbanded window), + // never a fabricated array. This is what keeps the derived-only path intact for straps without it. + XCTAssertTrue(SleepStager.sessionEpochSleepState(start: 0, end: 1800, sleepState: []).isEmpty) + // Samples entirely outside the window are also ignored. + let far: [(ts: Int, state: Int)] = [(ts: 9_000, state: 2)] + XCTAssertTrue(SleepStager.sessionEpochSleepState(start: 0, end: 1800, sleepState: far).isEmpty) + } + + func testSessionGridFeedsTheReonsetGuardEndToEnd() { + // The FULL #175 consume chain, as a unit: an "asleep"-banded morning block grids to a per-session + // state array (what IntelligenceEngine persists via persistSessionSleepState), which — expanded back + // to (startTs + i·30, state) samples exactly as IntelligenceEngine.bandSleepStateSamples does — makes + // the H7 re-onset CONFIRM guard KEEP a borderline-HR block it would otherwise reject. This is the + // dormant guard the missing stream starved; it never overrides the derived stage, only confirms. + let p = daytimePeriod(9, durMin: 120) // 120-min morning block → 240 epochs (clears the 90-min bar) + let wakeEnd = startAtHour(8) + // The strap banked this block predominantly "asleep" (2). + var band: [(ts: Int, state: Int)] = [] + for i in 0..<240 { band.append((ts: p.start + i * 30, state: 2)) } + + // 1) Grid it per session (the persist-ready array). + let states = SleepStager.sessionEpochSleepState(start: p.start, end: p.end, sleepState: band) + XCTAssertEqual(states.count, 240) + XCTAssertTrue(states.allSatisfy { $0 == 2 }) + + // 2) Expand back to timestamped samples the way the H7 read path does (startTs + i·epochS). + let epochS = 30 + let reconstructed = states.enumerated().map { (ts: p.start + $0.offset * epochS, state: $0.element) } + + // 3) The guard now CONFIRMS the borderline-HR re-onset (74 > 0.90×80=72 would otherwise reject). + XCTAssertTrue( + SleepStager.passesMorningStillnessGuard(p, restingHR: 74, baseline: 80, + morningWakeEnd: wakeEnd, bandSleepState: reconstructed), + "the persisted+re-expanded band grid drives the H7 confirm end to end") + } + + // MARK: - REM-funnel diagnostic (#688) + + /// A still, REM-eligible epoch (still + cardiac-activated + irregular resp). The percentile + /// arguments below are chosen so this epoch clears every REM gate. + private func remEpoch() -> SleepStager.EpochFeatures { + SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0, // still + ckSleep: true, hr: 80, hrVar: 5, rmssd: 20, sdnn: 0, + respRate: 14, rrv: 2.0, clock: 0.5) // irregular resp + } + + func testRemRejectReasonAttributesEachGate() { + // Percentiles: hrLo=55, hrHi=70 (hr=80 is high), rmssdHi=50, hrvarHi=1 (hrVar=5 is high), + // rrvHi=1 (rrv=2 is irregular), rrvLo=0.5. The base remEpoch clears all REM gates. + let (hrLo, hrHi, rmssdHi, hrvarHi, rrvHi, rrvLo) = + (55.0, 70.0, 50.0, 1.0, 1.0, 0.5) + func reason(_ f: SleepStager.EpochFeatures) -> SleepStager.REMRejectReason { + SleepStager.remRejectReason(f, hrLo: hrLo, hrHi: hrHi, rmssdHi: rmssdHi, + hrvarHi: hrvarHi, rrvHi: rrvHi, rrvLo: rrvLo) + } + XCTAssertEqual(reason(remEpoch()), .remEligible, "still + cardiac + irregular resp → REM") + + // notStill: raise moveFrac above the wake bar — but keep cardiac LOW so it doesn't win wake. + // hr=60 (< hrHi 70, > hrLo 55) and hrVar=0 → not cardiac-activated, so NOT wake; rrv high but + // not still → the REM rule fails first on stillness. + let notStill = SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0.5, + ckSleep: true, hr: 60, hrVar: 0, rmssd: 60, sdnn: 0, + respRate: 14, rrv: 2.0, clock: 0.5) + XCTAssertEqual(reason(notStill), .notStill, "moving body (no cardiac) → blocked notStill") + + // noCardiacActivation: still, resp irregular, but HR mid + flat HR-variability. + let noCardiac = SleepStager.EpochFeatures( + index: 0, midTs: 0, count: 0, moveFrac: 0, ckSleep: true, hr: 60, hrVar: 0, + rmssd: 20, sdnn: 0, respRate: 14, rrv: 2.0, clock: 0.5) + XCTAssertEqual(reason(noCardiac), .noCardiacActivation, "still + irregular resp but no cardiac → blocked") + + // respRegular: still + cardiac-activated but resp present and REGULAR (rrv below rrvLo). + // Keep RMSSD high so it doesn't win deep (deep needs hrLow too — hr=80 isn't low — so it's safe). + let respReg = SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0, + ckSleep: true, hr: 80, hrVar: 5, rmssd: 20, sdnn: 0, + respRate: 14, rrv: 0.1, clock: 0.5) // rrv ≤ rrvLo → regular + XCTAssertEqual(reason(respReg), .respRegular, "still + cardiac but regular resp → blocked respRegular") + + // noRespFallbackBar: resp ABSENT (rrv NaN) and the stricter no-resp REM bar unmet + // (needs BOTH hrHigh AND hrvarHigh). Here hr high but hrVar flat → fallback bar fails. + let noRespBar = SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0, + ckSleep: true, hr: 80, hrVar: 0, rmssd: 20, sdnn: 0, + respRate: .nan, rrv: .nan, clock: 0.5) + XCTAssertEqual(reason(noRespBar), .noRespFallbackBar, "resp absent + no-resp bar unmet → blocked") + } + + func testRemRejectReasonNoRespFallbackIsRemEligible() { + // The no-resp REM fallback: still + HR-high + HR-variability-high + resp absent → REM eligible. + let f = SleepStager.EpochFeatures(index: 0, midTs: 0, count: 0, moveFrac: 0, + ckSleep: true, hr: 80, hrVar: 5, rmssd: 20, sdnn: 0, + respRate: .nan, rrv: .nan, clock: 0.5) + let r = SleepStager.remRejectReason(f, hrLo: 55, hrHi: 70, rmssdHi: 50, + hrvarHi: 1, rrvHi: 1, rrvLo: 0.5) + XCTAssertEqual(r, .remEligible, "no-resp fallback (high HR + high HR-var) is REM-eligible") + } + + func testRemFunnelDiagnosticNilWhenNoGravity() { + XCTAssertNil(SleepStager.remFunnelDiagnostic(start: 0, end: 1800, grav: [], + hr: [], rr: [], resp: [])) + } + + func testRemFunnelDiagnosticZeroREMNightSurfacesRespAbsent() { + // A WHOOP-4.0-style night: still body, low HR, NO respiration and NO R-R → the classifier + // can never reach REM (the no-resp fallback needs cardiac activation, which a flat low-HR + // still night lacks). The hypnogram is 0% REM; the diagnostic must say WHY: resp ABSENT, and + // every sleep epoch attributed to a concrete non-REM reason. This is a triage surface only — + // it asserts the diagnostic, NOT that the stager should have found REM. + let start = nightStart(02) + let dur = 90 * 60 + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) // flat, low → no cardiac activation + let diag = SleepStager.remFunnelDiagnostic(start: start, end: start + dur, + grav: grav, hr: hr, rr: [], resp: []) + XCTAssertNotNil(diag) + let d = diag! + XCTAssertTrue(d.isZeroREM, "a flat still low-HR no-resp night has 0% REM") + XCTAssertEqual(d.remAfterReimpose, 0) + XCTAssertFalse(d.respChannelPresent, "no resp and no R-R → respChannelPresent false") + XCTAssertGreaterThan(d.sleepEpochs, 0, "the sleep period must contain epochs to explain") + // Conservation: every sleep epoch is attributed to exactly one bucket at the classifier mouth. + let attributed = d.remAtClassify + d.wonOtherStage + d.blockedNotStill + + d.blockedNoCardiacActivation + d.blockedRespRegular + d.blockedNoRespFallbackBar + XCTAssertEqual(attributed, d.sleepEpochs, "per-epoch reasons must partition the sleep epochs") + // The summary line a caller would log mentions the absent resp channel. + XCTAssertTrue(d.summary.contains("resp=ABSENT"), "summary surfaces the absent resp channel") + } + + func testRemFunnelDiagnosticIsReadOnly() { + // The diagnostic must not perturb the hypnogram stageSession produces for the same window. + let start = nightStart(02) + let dur = 90 * 60 + let grav = stillGravity(start: start, durationS: dur) + let hr = hrStream(start: start, durationS: dur, bpm: 50) + let before = SleepStager.stageSession(start: start, end: start + dur, + grav: grav, hr: hr, rr: [], resp: []) + _ = SleepStager.remFunnelDiagnostic(start: start, end: start + dur, + grav: grav, hr: hr, rr: [], resp: []) + let after = SleepStager.stageSession(start: start, end: start + dur, + grav: grav, hr: hr, rr: [], resp: []) + XCTAssertEqual(before, after, "remFunnelDiagnostic must not change the staged hypnogram") + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTraceTests.swift new file mode 100644 index 0000000000..98898c2566 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTraceTests.swift @@ -0,0 +1,96 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +final class SleepStagerTraceTests: XCTestCase { + + // MARK: - E1: pure formatter + + func testRunLineKept() { + let line = SleepStager.GateTrace.runLine( + index: 0, startTs: 1_749_513_600, endTs: 1_749_513_600 + 5400, + verdict: .kept, gate: "minSleepMin", detail: "spanMin=90 minSleepMin=60") + XCTAssertEqual(line, "gate run=0 spanS=5400 KEPT gate=minSleepMin spanMin=90 minSleepMin=60") + } + + func testRunLineDropped() { + let line = SleepStager.GateTrace.runLine( + index: 2, startTs: 0, endTs: 1800, + verdict: .dropped, gate: "minSleepMin", detail: "spanMin=30 minSleepMin=60") + XCTAssertEqual(line, "gate run=2 spanS=1800 DROPPED gate=minSleepMin spanMin=30 minSleepMin=60") + } + + func testFlipLine() { + let line = SleepStager.GateTrace.flipLine( + epoch: 14, from: "wake", to: "sleep", threshold: "hrMult=1.05 bpm=49 baseline=52") + XCTAssertEqual(line, "epoch=14 flip wake->sleep threshold=hrMult=1.05 bpm=49 baseline=52") + } + + func testNoEmDash() { + let line = SleepStager.GateTrace.runLine( + index: 0, startTs: 0, endTs: 60, verdict: .kept, gate: "x", detail: "y") + XCTAssertFalse(line.contains("\u{2014}")) + } + + // MARK: - fixtures for the live-ladder tests (E2/E3) + + /// Build a still gravity stream at 1 Hz. + fileprivate func still(_ start: Int, _ durS: Int) -> [GravitySample] { + (0.. [HRSample] { + (0.. one KEPT line. + let start = 1_749_513_600 + 2 * 3600 + let dur = 90 * 60 + var lines: [String] = [] + let sessions = SleepStager.detectSleep( + hr: hr(start, dur, 50), gravity: still(start, dur), + traceSink: { lines.append($0) }) + XCTAssertEqual(sessions.count, 1) + XCTAssertTrue(lines.contains { $0.contains("KEPT gate=accepted") }) + } + + func testTracedAndUntracedReturnIdenticalSessions() { + // The trace is side-effect-only: a traced call and an untraced call must return the + // identical [SleepSession]. This is the byte-identical-output guard for the mode. + let start = 1_749_513_600 + 2 * 3600 + let dur = 90 * 60 + let untraced = SleepStager.detectSleep(hr: hr(start, dur, 50), gravity: still(start, dur)) + let traced = SleepStager.detectSleep(hr: hr(start, dur, 50), gravity: still(start, dur), + traceSink: { _ in }) + XCTAssertEqual(untraced, traced) + } + + // MARK: - E3: sparse-gravity bridge trace + + func testSparseBridgeTraceEmittedOnlyWhenSparse() { + // A dense overnight night is NOT sparse, so no sparse-bridge line appears. + let start = 1_749_513_600 + 2 * 3600 + let dur = 90 * 60 + var lines: [String] = [] + _ = SleepStager.detectSleep(hr: hr(start, dur, 50), gravity: still(start, dur), + traceSink: { lines.append($0) }) + XCTAssertFalse(lines.contains { $0.contains("gate=sparseBridge") }) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2Tests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2Tests.swift new file mode 100644 index 0000000000..630d94ac6b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2Tests.swift @@ -0,0 +1,163 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Basic coverage for the OPT-IN experimental stager `SleepStagerV2` (V7 Pillar 3b, reimplemented from +/// contributor PR #600). These assert the drop-in CONTRACT — same `stageSession` signature + return shape as +/// V1, segments that tile `[start, end]` with canonical stage labels — and a couple of recipe invariants. +/// They are NOT a fidelity claim against any reference (the recipe's own validation is n=1). +final class SleepStagerV2Tests: XCTestCase { + + // MARK: - fixtures + + /// A still gravity stream (constant orientation) at 1 Hz — the quiescent sleep floor. + private func stillGravity(start: Int, durationS: Int) -> [GravitySample] { + (0.. [HRSample] { + (0.. [RRInterval] { + (0.. RRInterval in + let rsa = Int(40.0 * sin(2.0 * Double.pi * Double(i) / 4.0)) // ~0.25 Hz breathing + return RRInterval(ts: start + i, rrMs: 1000 + rsa) + } + } + + // MARK: - drop-in contract + + func testStagesTileTheWholeSpanContiguously() { + let start = 1_700_000_000 + let dur = 90 * 60 // 90 min + let segs = SleepStagerV2.stageSession( + start: start, end: start + dur, + grav: stillGravity(start: start, durationS: dur), + hr: sleepHR(start: start, durationS: dur), + rr: regularRR(start: start, durationS: dur), + resp: []) + + XCTAssertFalse(segs.isEmpty, "a covered window must produce at least one segment") + // First segment starts exactly at `start`, last ends exactly at `end`, and segments are contiguous. + XCTAssertEqual(segs.first?.start, start) + XCTAssertEqual(segs.last?.end, start + dur) + for i in 1.. = ["wake", "light", "deep", "rem"] + for s in segs { + XCTAssertTrue(allowed.contains(s.stage), "unexpected stage label \(s.stage)") + } + } + + /// Degenerate input (too little gravity to grid) must fall back to a single "light" block spanning the + /// window — exactly the shape V1's `stageSession` returns in the same case, so callers/encoders are safe. + func testDegenerateInputFallsBackToSingleLightBlock() { + let start = 1_700_000_000 + let end = start + 3_600 + let segs = SleepStagerV2.stageSession( + start: start, end: end, + grav: [GravitySample(ts: start, x: 0, y: 0, z: 1.0)], // one sample → no epochs + hr: [], rr: [], resp: []) + XCTAssertEqual(segs.count, 1) + XCTAssertEqual(segs.first?.stage, "light") + XCTAssertEqual(segs.first?.start, start) + XCTAssertEqual(segs.first?.end, end) + } + + func testEmptyWindowReturnsLightFallback() { + // end <= start → features() is empty → the single-segment fallback. + let segs = SleepStagerV2.stageSession(start: 100, end: 100, grav: [], hr: [], rr: [], resp: []) + XCTAssertEqual(segs.count, 1) + XCTAssertEqual(segs.first?.stage, "light") + } + + // MARK: - recipe invariants + + /// The cycle prior concentrates deep early in the night and suppresses REM in the first ~12 %. + func testCyclePriorShapesDeepAndRem() { + let early = SleepStagerV2.cyclePrior(0.05) + let late = SleepStagerV2.cyclePrior(0.90) + XCTAssertGreaterThan(early["deep"]!, late["deep"]!, "deep prior is higher early in the night") + XCTAssertLessThan(early["rem"]!, late["rem"]!, "REM prior is suppressed early, rising toward morning") + XCTAssertEqual(early["light"]!, 0.0) + XCTAssertEqual(early["awake"]!, 0.0) + } + + /// Viterbi over a single epoch returns the highest-emission stage (uniform start, no transitions). + func testViterbiSingleEpochPicksMaxEmission() { + let path = SleepStagerV2.viterbi([["deep": 0.1, "rem": 0.1, "light": 5.0, "awake": 0.1]]) + XCTAssertEqual(path, ["light"]) + } + + // MARK: - #690: the V2 flag drives the NORMAL detected-night staging path + + /// A regular R-R stream at ~1 Hz (steady ~1000 ms beats with a small respiratory sinus oscillation), + /// long enough for the V2 recipe to express both early deep and later REM across the night. + private func regularRRLong(start: Int, durationS: Int) -> [RRInterval] { + (0.. RRInterval in + let rsa = Int(40.0 * sin(2.0 * Double.pi * Double(i) / 4.0)) // ~0.25 Hz breathing + return RRInterval(ts: start + i, rrMs: 1000 + rsa) + } + } + + /// #690 (v7 regression): the "Experimental sleep staging (V2)" toggle must affect a NORMAL detected + /// night — not only the userEdited self-heal restage. With the flag ON, `detectSleep` stages the + /// accepted window with V2 (deep + REM present); with the flag OFF it returns the EXACT V1 result, so + /// the byte-identical default (and the frozen-golden tests) is preserved. + func testDetectSleepThreadsV2FlagIntoNormalNight() { + // A 3 h still overnight window (anchored at 01:00 UTC → center ~02:30, clear of the daytime + // guard band at the default tzOffset=0) with sleep-band HR + a regular R-R stream. + let start = 1_749_517_200 // 2026-06-10 01:00:00 UTC + let dur = 3 * 60 * 60 + let grav = stillGravity(start: start, durationS: dur) + let hr = sleepHR(start: start, durationS: dur) + let rr = regularRRLong(start: start, durationS: dur) + + // Flag OFF (the default) — V1 path. + let v1Sessions = SleepStager.detectSleep(hr: hr, rr: rr, gravity: grav) + XCTAssertEqual(v1Sessions.count, 1, "the still night must be detected") + let v1 = v1Sessions[0] + // The detected window's stages MUST equal a direct V1 stageSession over the same span (proof the + // default path is byte-identical and untouched by the new parameter). + let v1Direct = SleepStager.stageSession(start: v1.start, end: v1.end, + grav: grav, hr: hr, rr: rr, resp: []) + XCTAssertEqual(v1.stages.map { [$0.start, $0.end] }, v1Direct.map { [$0.start, $0.end] }, + "flag OFF must reproduce the exact V1 hypnogram boundaries") + XCTAssertEqual(v1.stages.map { $0.stage }, v1Direct.map { $0.stage }, + "flag OFF must reproduce the exact V1 hypnogram labels") + + // Flag ON — the SAME detected window must now be staged by V2. + let v2Sessions = SleepStager.detectSleep(hr: hr, rr: rr, gravity: grav, useSleepStagerV2: true) + XCTAssertEqual(v2Sessions.count, 1, "detection is unchanged by the staging flag") + let v2 = v2Sessions[0] + // Same accepted window (detection is identical — only staging differs). + XCTAssertEqual(v2.start, v1.start) + XCTAssertEqual(v2.end, v1.end) + // The hypnogram is V2's: it matches a direct V2 stageSession over the accepted span, and (proof + // the flag actually flipped the engine) it expresses both deep and REM. + let v2Direct = SleepStagerV2.stageSession(start: v2.start, end: v2.end, + grav: grav, hr: hr, rr: rr, resp: []) + XCTAssertEqual(v2.stages.map { $0.stage }, v2Direct.map { $0.stage }, + "flag ON must produce the V2 hypnogram") + let v2Stages = Set(v2.stages.map { $0.stage }) + XCTAssertTrue(v2Stages.contains("deep"), "V2 night should express deep") + XCTAssertTrue(v2Stages.contains("rem"), "V2 night should express REM") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepWindowReclipTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepWindowReclipTests.swift new file mode 100644 index 0000000000..30826aa460 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepWindowReclipTests.swift @@ -0,0 +1,140 @@ +import XCTest +import Foundation +@testable import StrandAnalytics + +final class SleepWindowReclipTests: XCTestCase { + + private func segments(_ json: String) -> [(start: Int, end: Int, stage: String)] { + let arr = (try? JSONSerialization.jsonObject(with: Data(json.utf8))) as? [[String: Any]] ?? [] + return arr.compactMap { + guard let s = ($0["start"] as? NSNumber)?.intValue, + let e = ($0["end"] as? NSNumber)?.intValue, + let st = $0["stage"] as? String else { return nil } + return (s, e, st) + } + } + + private func minutes(_ json: String) -> [String: Double] { + let dict = (try? JSONSerialization.jsonObject(with: Data(json.utf8))) as? [String: Any] ?? [:] + return dict.compactMapValues { ($0 as? NSNumber)?.doubleValue } + } + + // MARK: - segment array (computed nights) + + func testSegmentTrimDropsAndClips() throws { + let json = """ + [{"start":1000,"end":2000,"stage":"light"}, + {"start":2000,"end":3000,"stage":"deep"}, + {"start":3000,"end":4000,"stage":"wake"}] + """ + let out = try XCTUnwrap(SleepWindowReclip.reclip( + stagesJSON: json, sessionStart: 1000, oldEnd: 4000, newStart: 1000, newEnd: 2500)) + let segs = segments(out) + XCTAssertEqual(segs.count, 2, "the wholly-after segment is dropped") + XCTAssertEqual(segs[0].stage, "light") + XCTAssertEqual(segs[1].stage, "deep") + XCTAssertEqual(segs[1].end, 2500, "the segment spanning the new wake is clipped to it") + } + + func testSegmentExtendAppendsTrailingWake() throws { + let json = """ + [{"start":1000,"end":2000,"stage":"light"}, + {"start":2000,"end":3000,"stage":"deep"}] + """ + let out = try XCTUnwrap(SleepWindowReclip.reclip( + stagesJSON: json, sessionStart: 1000, oldEnd: 3000, newStart: 1000, newEnd: 3600)) + let segs = segments(out) + XCTAssertEqual(segs.count, 3) + XCTAssertEqual(segs.last?.stage, "wake") + XCTAssertEqual(segs.last?.start, 3000) + XCTAssertEqual(segs.last?.end, 3600) + } + + // MARK: - minute dict (imported nights) + + func testMinutesTrimCascadesFromAwakeThenLight() throws { + // Shorten by 40 min: awake (30) → 0 and the remaining 10 comes off light. + let json = #"{"awake":30,"light":200,"deep":80,"rem":90}"# + let out = try XCTUnwrap(SleepWindowReclip.reclip( + stagesJSON: json, sessionStart: 0, oldEnd: 8 * 3600, newStart: 0, newEnd: 8 * 3600 - 40 * 60)) + let m = minutes(out) + XCTAssertEqual(try XCTUnwrap(m["awake"]), 0, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(m["light"]), 190, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(m["deep"]), 80, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(m["rem"]), 90, accuracy: 0.001) + } + + func testMinutesExtendAddsToAwake() throws { + let json = #"{"awake":30,"light":200,"deep":80,"rem":90}"# + let out = try XCTUnwrap(SleepWindowReclip.reclip( + stagesJSON: json, sessionStart: 0, oldEnd: 8 * 3600, newStart: 0, newEnd: 8 * 3600 + 20 * 60)) + let m = minutes(out) + XCTAssertEqual(try XCTUnwrap(m["awake"]), 50, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(m["light"]), 200, accuracy: 0.001) + } + + func testSegmentTrimBeforeAllSegmentsReturnsWakeFillNotNil() throws { + // Corrected wake lands before every stage → instead of returning nil (which would let the store's + // COALESCE keep the OLD stages extending PAST the new wake), emit a single wake segment that + // covers exactly the corrected window. (#318 review #8) + let json = """ + [{"start":2000,"end":3000,"stage":"light"},{"start":3000,"end":4000,"stage":"deep"}] + """ + let out = try XCTUnwrap(SleepWindowReclip.reclip( + stagesJSON: json, sessionStart: 1000, oldEnd: 4000, newStart: 1000, newEnd: 1500)) + let segs = segments(out) + XCTAssertEqual(segs.count, 1) + XCTAssertEqual(segs[0].stage, "wake") + XCTAssertEqual(segs.map { $0.end }.max(), 1500, "no stage extends past the corrected wake") + } + + // MARK: - degenerate input + + func testNilAndGarbageReturnNil() { + XCTAssertNil(SleepWindowReclip.reclip(stagesJSON: nil, sessionStart: 0, oldEnd: 1, newStart: 0, newEnd: 1)) + XCTAssertNil(SleepWindowReclip.reclip(stagesJSON: "not json", sessionStart: 0, oldEnd: 1, newStart: 0, newEnd: 1)) + } + + // MARK: - bed (onset) edits: START-AWARE reclip (#0) + + func testBedOnlyEditSegmentsDropsStagesBeforeNewBed() throws { + // A pure onset edit: the user moves bed time FORWARD from 1000 to 2000, wake unchanged at 4000. + // The "light" segment wholly before the new bed (1000..2000) must drop; the straddling "deep" + // (1800..3000) clips its start UP to 2000; no segment starts before the corrected bed time, and + // total stage seconds == the corrected window (4000-2000). + let json = """ + [{"start":1000,"end":2000,"stage":"light"}, + {"start":1800,"end":3000,"stage":"deep"}, + {"start":3000,"end":4000,"stage":"rem"}] + """ + let out = try XCTUnwrap(SleepWindowReclip.reclip( + stagesJSON: json, sessionStart: 1000, oldEnd: 4000, newStart: 2000, newEnd: 4000)) + let segs = segments(out) + XCTAssertEqual(segs.count, 2, "the segment wholly before the new bed time is dropped") + XCTAssertEqual(segs.map { $0.start }.min(), 2000, "no segment starts before the new bed time") + XCTAssertEqual(segs[0].stage, "deep") + XCTAssertEqual(segs[0].start, 2000, "the straddling segment's start clips up to the new bed time") + let total = segs.reduce(0) { $0 + ($1.end - $1.start) } + XCTAssertEqual(total, 4000 - 2000, "stage total equals the corrected [newStart, newEnd] window") + } + + func testBedOnlyEditMinutesImportedNightShrinksByOnsetDelta() throws { + // An imported (minute-dict) night, pure onset edit: session 0..8h, bed moved forward 40 min so the + // window shrinks 40 min even though newEnd == oldEnd. The duration delta drives the trim + // (awake 30 to 0, then 10 off light); the total trims by the 40 min onset delta to 360, not the + // window (an imported minute-dict need not fill its whole window, so total != window here). + let json = #"{"awake":30,"light":200,"deep":80,"rem":90}"# + let oldEnd = 8 * 3600 + let newStart = 40 * 60 + let out = try XCTUnwrap(SleepWindowReclip.reclip( + stagesJSON: json, sessionStart: 0, oldEnd: oldEnd, newStart: newStart, newEnd: oldEnd)) + let m = minutes(out) + XCTAssertEqual(try XCTUnwrap(m["awake"]), 0, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(m["light"]), 190, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(m["deep"]), 80, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(m["rem"]), 90, accuracy: 0.001) + let total = m.values.reduce(0, +) + XCTAssertEqual(total, 360, accuracy: 0.001, + "imported-night total trims by the onset delta (400 to 360), not the window") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2ReTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2ReTraceTests.swift new file mode 100644 index 0000000000..9606c0cefd --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2ReTraceTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the Connection-mode SpO2 reverse-engineering dump line (PR #945, reimplemented). The output must +/// be byte-identical to the Kotlin Spo2ReTraceTest vectors so a shared log correlates identically from +/// either platform. Log-only diagnostics: nothing here ever becomes a user-facing SpO2 number. +final class Spo2ReTraceTests: XCTestCase { + + func testRecordLinePinnedExactly() { + let line = Spo2ReTrace.recordLine(frame: [0x00, 0x0f, 0xff, 0x10], + version: 24, unix: 1_700_000_000, + red: 512, ir: 480, skinRaw: 330) + XCTAssertEqual(line, + "spo2re v=24 unix=1700000000 red=512 ir=480 skinRaw=330 len=4 raw=000fff10") + } + + func testAbsentChannelsRenderNull() { + // A record with no SpO2 channels mapped (e.g. a v25 motion record) must still dump in full - + // proving "nothing banked" needs the negative case on the record itself. + let line = Spo2ReTrace.recordLine(frame: [1, 2, 3], version: 25, unix: 42, + red: nil, ir: nil, skinRaw: nil) + XCTAssertEqual(line, "spo2re v=25 unix=42 red=null ir=null skinRaw=null len=3 raw=010203") + } + + func testHexRendersUnsignedFullFrame() { + // 0xFF must render "ff" (unsigned, two lowercase hex digits), and the FULL frame ships - the + // unmapped tail bytes are exactly where a banked SpO2 would sit. + let line = Spo2ReTrace.recordLine(frame: [0xff, 0x00, 0xab], version: nil, unix: nil, + red: nil, ir: nil, skinRaw: nil) + XCTAssertTrue(line.hasSuffix("raw=ff00ab"), line) + XCTAssertTrue(line.contains("v=null"), line) + } + + func testSampleCapBoundedAtEight() { + XCTAssertEqual(Spo2ReTrace.maxSamples, 8) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SpotHrvReadingTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SpotHrvReadingTests.swift new file mode 100644 index 0000000000..004edf389a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SpotHrvReadingTests.swift @@ -0,0 +1,155 @@ +import XCTest +@testable import StrandAnalytics + +/// `SpotHrvReading` — the on-demand "take an HRV reading now" spot RMSSD path (#537). +/// +/// Swift parity twin of `android/.../analytics/SpotHrvReadingTest.kt`. The headline guarantee these +/// tests pin is CONSISTENCY: the spot value uses the SAME RMSSD math as NOOP's nightly HRV +/// (`HRVAnalyzer.rmssdRaw`, Task Force 1996, sample (n-1) denominator), so a spot reading is comparable +/// to the overnight number, not a few percent off it. We assert the value against a hand-computed (n-1) +/// RMSSD on a known RR series, and against `HRVAnalyzer` directly. +final class SpotHrvReadingTests: XCTestCase { + + /// Textbook RMSSD with the Task Force (1996) SAMPLE denominator (n-1) — the reference NOOP uses. + private func rmssdSampleDenom(_ rr: [Double]) -> Double { + var sumSq = 0.0 + for i in 1.. [Int] { + // 24 intervals around 850 ms (~70 bpm), alternating +/-20 ms so successive diffs are well-defined + // and every value sits inside [300, 2000] ms so the range filter keeps them all. + var out: [Int] = [] + let base = 850 + for i in 0..<24 { + out.append(base + (i % 2 == 0 ? 20 : -20)) + } + return out + } + + func testSpotRmssdMatchesHandComputedSampleDenominator() { + let rr = knownCleanSeries() + let outcome = SpotHrvReading.compute(rr) + guard case let .reading(rmssdMs, _, beats, _) = outcome else { + return XCTFail("a clean 24-beat series must produce a reading, got \(outcome)") + } + + // The series has no ectopics (alternating +/-20 around the median is within the 20% Malik gate), + // so all 24 survive cleaning and the (n-1) RMSSD over them is the reference. + let expected = rmssdSampleDenom(rr.map(Double.init)) + XCTAssertEqual(rmssdMs, expected, accuracy: 1e-9, "spot RMSSD must equal the (n-1) reference") + XCTAssertEqual(beats, 24, "all 24 clean beats used") + } + + func testSpotRmssdEqualsHrvAnalyzerNightlyMath() { + // The spot path MUST agree with the canonical analyzer the nightly avgHrv is built on, beat for + // beat — that is the whole consistency requirement of this lane. + let rr = knownCleanSeries() + let viaAnalyzer = HRVAnalyzer.analyze(rawRR: rr.map(Double.init)).rmssd + XCTAssertNotNil(viaAnalyzer) + guard case let .reading(viaSpot, _, _, _) = SpotHrvReading.compute(rr) else { + return XCTFail("expected a reading") + } + XCTAssertEqual(viaAnalyzer!, viaSpot, accuracy: 1e-12) + } + + func testUsesTaskForceSampleDenominator() { + // RMSSD divides the summed squared successive diffs by the SAMPLE NN count minus one (n-1), + // which for a contiguous clean series equals the number of diffs. The guard here is that the + // spot path reproduces the Task Force form exactly (a from-scratch port that divided by a + // different count would fail this), and that the same number flows through to the analyzer. + let rr = knownCleanSeries().map(Double.init) + var sumSq = 0.0 + for i in 1.. .insufficient (no crash, no fabrication). + let junk = [0, -5, 50_000, 999_999, 12] + guard case .insufficient = SpotHrvReading.compute(junk) else { + return XCTFail("expected .insufficient for junk input") + } + } + + func testEmptyInputIsInsufficient() { + guard case let .insufficient(clean, _, _) = SpotHrvReading.compute([]) else { + return XCTFail("expected .insufficient for empty input") + } + XCTAssertEqual(clean, 0) + } + + func testSpotGateRefusesNoisyCaptureByDefault() { + // #585: a capture where too many beats were noise must be refused even though >= minBeats clean + // beats survive. 24 valid 850 ms + 16 out-of-range (10 ms) → 16/40 = 0.40 rejected > the default + // 0.35 ceiling → .insufficient (an honest "sit still and try again"), never a fabricated number. + var rr = knownCleanSeries() // 24 clean + rr.append(contentsOf: Array(repeating: 10, count: 16)) // 16 out-of-range → range-dropped + guard case let .insufficient(clean, needed, input) = SpotHrvReading.compute(rr) else { + return XCTFail("0.40 rejected must be refused by the default spot gate") + } + XCTAssertEqual(needed, HRVAnalyzer.minBeats) + XCTAssertEqual(input, 40) + XCTAssertEqual(clean, 0) // refusal reports the empty result's nClean + } + + func testSpotGateRelaxedAllowsTheSameNoisyCapture() { + // Passing a permissive ceiling (> 0.40) lets the same 24-clean capture through — proving the gate, + // not the clean-beat count, is what refused it above. + var rr = knownCleanSeries() + rr.append(contentsOf: Array(repeating: 10, count: 16)) + guard case let .reading(_, _, beats, _) = SpotHrvReading.compute(rr, maxRejectedFraction: 0.5) else { + return XCTFail("a 0.5 ceiling must allow the 0.40-rejected capture") + } + XCTAssertEqual(beats, 24) + } + + func testMeanHrFromNnMatchesDefinition() { + XCTAssertEqual(SpotHrvReading.meanHrFromNN(1000.0)!, 60.0, accuracy: 1e-9) + XCTAssertEqual(SpotHrvReading.meanHrFromNN(800.0)!, 75.0, accuracy: 1e-9) + XCTAssertNil(SpotHrvReading.meanHrFromNN(nil)) + XCTAssertNil(SpotHrvReading.meanHrFromNN(0.0)) + XCTAssertNil(SpotHrvReading.meanHrFromNN(-1.0)) + } + + func testCaveatIsSourceAwareAndClean() { + let ppg = SpotHrvReading.caveatFor(.opticalPPG) + let strap = SpotHrvReading.caveatFor(.chestStrap) + let unknown = SpotHrvReading.caveatFor(.unknown) + + // PPG caveat must call out the noisier optical source; chest strap must not. + XCTAssertTrue(ppg.contains("optical pulse signal"), "PPG caveat mentions the optical pulse signal") + XCTAssertFalse(strap.contains("optical pulse signal"), "chest-strap caveat omits the PPG note") + XCTAssertEqual(strap, unknown, "unknown source uses the base caveat") + + // Every caveat states the universal "spot, not overnight baseline" limit. + for c in [ppg, strap, unknown] { + XCTAssertTrue(c.contains("spot reading"), "states it is a spot reading") + XCTAssertTrue(c.contains("overnight HRV baseline"), "states it is not the overnight baseline") + // House rule: no em-dashes anywhere in user-facing copy. + XCTAssertFalse(c.contains("—"), "no em-dash in caveat") + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift new file mode 100644 index 0000000000..ff331aceda --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift @@ -0,0 +1,144 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Unit tests for the daily-steps derivation in AnalyticsEngine.analyzeDay: cumulative-counter +/// delta summation, u16 wraparound, sub-2-sample and cross-day filtering, and nil-when-no-movement. +/// No DB; pure-function test. step_motion_counter@57 is a CUMULATIVE u16 counter, so the daily total +/// is the sum of positive consecutive deltas (APPROXIMATE — @57 semantics unverified vs the app). +/// Mirrors the Android StepsAnalyticsTest vectors value-for-value. +final class StepsDailyTests: XCTestCase { + + private let profile = UserProfile() + + // A timestamp safely inside UTC day 2026-01-02 (2026-01-02T12:00:00Z = 1767355200). + private let dayUtc = "2026-01-02" + private let noonUtc = 1_767_355_200 + + private func step(_ tsOffsetSec: Int, _ counter: Int) -> StepSample { + StepSample(ts: noonUtc + tsOffsetSec, counter: counter) + } + + private func stepsFor(_ samples: [StepSample]) -> Int? { + AnalyticsEngine.analyzeDay(day: dayUtc, steps: samples, profile: profile).daily.steps + } + + func testSumsPositiveConsecutiveDeltas() { + // counters 100 -> 150 -> 220 => deltas 50 + 70 = 120 + let s = [step(0, 100), step(60, 150), step(120, 220)] + XCTAssertEqual(stepsFor(s), 120) + } + + func testHandlesU16Wraparound() { + // 65500 -> 30 wraps: (30 - 65500) & 0xFFFF => 66 real steps (a small in-range increment, NOT a + // huge negative); then 30 -> 90 => 60. Both deltas are < the 512 guard so both count. + let s = [step(0, 65_500), step(60, 30), step(120, 90)] + XCTAssertEqual(stepsFor(s), 66 + 60) + } + + func testFewerThanTwoSamplesIsNil() { + XCTAssertNil(stepsFor([])) + XCTAssertNil(stepsFor([step(0, 500)])) + } + + func testNoForwardMovementIsNil() { + // Flat counter across the day => no positive delta => nil (not 0). + let s = [step(0, 1_000), step(60, 1_000), step(120, 1_000)] + XCTAssertNil(stepsFor(s)) + } + + func testDropsBigGapDeltaAsBoundary() { + // 100 -> 1000 is a 900-tick jump (a sync-gap/disconnect boundary, not real 1 Hz steps), and + // 1000 -> 50 wrap-corrects to 64586. Both are >= the 512 guard, so both are dropped — the day + // has no in-range increment left, so the total is nil (not an inflated number). + let s = [step(0, 100), step(60, 1_000), step(120, 50)] + XCTAssertNil(stepsFor(s)) + } + + func testJumpGuardDropsGapButKeepsRealSteps() { + // 100 -> 300 (=200 real) ; 300 -> 1200 is a 900-tick GAP (>= 512) and is dropped ; 1200 -> 1500 + // (=300 real). Only the two in-range increments count => 200 + 300 = 500, the gap doesn't inflate. + let s = [step(0, 100), step(60, 300), step(3_600, 1_200), step(3_660, 1_500)] + XCTAssertEqual(stepsFor(s), 500) + } + + func testOldSummingOfRawByteOvercountsVsWrapAwareDiff() { + // THE BUG (#132/#276/#316). A realistic ascending cumulative counter sampled at 1 Hz. The OLD + // code summed the raw running total (here, byte @57 alone summed) — exploding the count; the NEW + // wrap-aware diff sums only the per-record increments and yields a sane number. + let counters = [100, 127, 127, 130, 131, 131, 140, 152, 160, 175] + let samples = counters.enumerated().map { step($0.offset, $0.element) } + // NEW behaviour: sum of wrap-aware deltas == last - first (all small increments, none >= 512). + let sane = counters.last! - counters.first! // 75 + XCTAssertEqual(stepsFor(samples), sane) + // OLD behaviour (summing the cumulative counter itself) would be vastly larger — prove the gap. + let oldOvercount = counters.reduce(0, +) // 1373 + XCTAssertGreaterThan(oldOvercount, sane * 10) + } + + func testIgnoresSamplesOutsideTheTargetDay() { + // One sample 36h before the day (in the analytics window but a different UTC day) + // must be excluded. + let s = [step(-36 * 3_600, 5_000), step(0, 100), step(60, 300)] + XCTAssertEqual(stepsFor(s), 200) // only the in-day 100 -> 300 delta counts + } + + func testDayStepsOverrideCountsFullCalendarDay() { + // The night-window `steps` only sees the early part of the day; the full calendar-day + // stream `daySteps` also carries the late-evening samples. When daySteps is supplied + // the daily total must come from it, so late-day movement is NOT dropped (the past-day + // undercount fix). + let nightWindow = [step(0, 100), step(60, 300)] // early only + let fullDay = [ + step(0, 100), step(60, 300), // morning: 200 + step(10 * 3_600, 700), // evening samples only in the full-day stream + step(11 * 3_600, 1_100), + ] + let total = AnalyticsEngine.analyzeDay( + day: dayUtc, steps: nightWindow, daySteps: fullDay, profile: profile).daily.steps + // deltas over the full day: 100->300=200, 300->700=400, 700->1100=400 => 1000 (all < 512 guard). + XCTAssertEqual(total, 1_000) + } + + func testDayStepsNilFallsBackToWindowSteps() { + // No calendar-day stream supplied (pure-function callers / old tests) -> total falls + // back to the night-window `steps` exactly as before. + let s = [step(0, 100), step(60, 150), step(120, 220)] // 50 + 70 = 120 + XCTAssertEqual(AnalyticsEngine.analyzeDay(day: dayUtc, steps: s, profile: profile).daily.steps, + 120) + } + + // MARK: - Step-scale calibration (#139) + + private func stepsFor(_ samples: [StepSample], ticksPerStep: Double) -> Int? { + AnalyticsEngine.analyzeDay(day: dayUtc, steps: samples, + profile: UserProfile(stepTicksPerStep: ticksPerStep)).daily.steps + } + + func testTicksPerStepTwoHalvesTheTotal() { + // 120 raw ticks at 2.0 ticks/step => 60 steps. + let s = [step(0, 100), step(60, 150), step(120, 220)] + XCTAssertEqual(stepsFor(s, ticksPerStep: 2.0), 60) + } + + func testTicksPerStepHalvingRoundsToNearest() { + // 121 raw ticks at 2.0 => 60.5, rounded to nearest => 61. + let s = [step(0, 100), step(60, 150), step(120, 221)] + XCTAssertEqual(stepsFor(s, ticksPerStep: 2.0), 61) + } + + func testTicksPerStepDefaultIsRawPassThrough() { + // Default 1.0 (and an explicit 1.0) must leave the total untouched — no behavior + // change until the user calibrates. + let s = [step(0, 100), step(60, 150), step(120, 220)] + XCTAssertEqual(stepsFor(s), 120) + XCTAssertEqual(stepsFor(s, ticksPerStep: 1.0), 120) + } + + func testTicksPerStepClampsAtFloor() { + // A divisor below the 0.5 floor clamps: it can at most double the total, never + // explode it. 120 / 0.5 = 240 even when the profile says 0.1. + let s = [step(0, 100), step(60, 150), step(120, 220)] + XCTAssertEqual(stepsFor(s, ticksPerStep: 0.1), 240) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsEstimateEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsEstimateEngineTests.swift new file mode 100644 index 0000000000..884f6c872e --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsEstimateEngineTests.swift @@ -0,0 +1,276 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +final class StepsEstimateEngineTests: XCTestCase { + + // MARK: motion intensity + + func testMotionIntensitySumsDeltas() { + // Three samples: deltas of magnitude 0.3 then 0.4 → total 0.7. + let grav = [ + GravitySample(ts: 0, x: 0, y: 0, z: 1), + GravitySample(ts: 1, x: 0.3, y: 0, z: 1), // Δ = 0.3 + GravitySample(ts: 2, x: 0.3, y: 0.4, z: 1), // Δ = 0.4 + ] + XCTAssertEqual(StepsEstimateEngine.dayMotionIntensity(grav), 0.7, accuracy: 1e-9) + } + + func testMotionIntensityEmptyAndSingle() { + XCTAssertEqual(StepsEstimateEngine.dayMotionIntensity([]), 0) + XCTAssertEqual(StepsEstimateEngine.dayMotionIntensity([GravitySample(ts: 0, x: 0, y: 0, z: 1)]), 0) + } + + // MARK: calibration + + func testCalibrateFitsMedianRatio() { + // steps/motion ratios: 100, 100, 110, 90, 100 → median 100. + let pts = [(10.0, 1000.0), (20.0, 2000.0), (10.0, 1100.0), (10.0, 900.0), (10.0, 1000.0)] + .map { StepsEstimateEngine.CalibrationPoint(motion: $0.0, steps: $0.1) } + let cal = StepsEstimateEngine.calibrate(pts) + XCTAssertNotNil(cal) + XCTAssertEqual(cal!.coefficient, 100, accuracy: 1e-9) + XCTAssertFalse(cal!.manual) + XCTAssertEqual(cal!.sampleDays, 5) + XCTAssertGreaterThan(cal!.confidence, 0) + } + + func testCalibrateNilBelowMinDays() { + let pts = [(10.0, 1000.0), (10.0, 1000.0)] // only 2 < minCalibrationDays(3) + .map { StepsEstimateEngine.CalibrationPoint(motion: $0.0, steps: $0.1) } + XCTAssertNil(StepsEstimateEngine.calibrate(pts)) + } + + func testCalibrateSkipsNearStillAndZeroStepDays() { + // Two near-still days (motion < minMotionForFit) + two zero-step days should NOT count toward the fit; + // only the 3 real days remain, all ratio 100. + let pts = [ + (0.2, 5000.0), // below minMotionForFit → skipped + (10.0, 0.0), // zero steps → skipped + (10.0, 1000.0), (20.0, 2000.0), (15.0, 1500.0), + ].map { StepsEstimateEngine.CalibrationPoint(motion: $0.0, steps: $0.1) } + let cal = StepsEstimateEngine.calibrate(pts) + XCTAssertNotNil(cal) + XCTAssertEqual(cal!.coefficient, 100, accuracy: 1e-9) + XCTAssertEqual(cal!.sampleDays, 3) + } + + func testCalibrateMotionWeightedHighActivityDayDrivesFit() { + // #682: three near-still low-activity days all read ratio 50 (motion 1, steps 50); one busy day reads + // ratio 100 (motion 100, steps 10000). The PLAIN median of [50,50,50,100] would be 50 — the low days + // win by COUNT. The motion-weighted median lets the busy day's 100 units of motion outvote the 3 units + // from the still days (half-mass 51.5 lands inside the busy day), so k = 100. + let pts = [ + (1.0, 50.0), (1.0, 50.0), (1.0, 50.0), // ratio 50, weight 1 each + (100.0, 10000.0), // ratio 100, weight 100 + ].map { StepsEstimateEngine.CalibrationPoint(motion: $0.0, steps: $0.1) } + let cal = StepsEstimateEngine.calibrate(pts) + XCTAssertNotNil(cal) + XCTAssertEqual(cal!.coefficient, 100, accuracy: 1e-9) // weighted → busy day wins (plain median = 50) + XCTAssertEqual(cal!.sampleDays, 4) + } + + func testWeightedMedianReducesToPlainMedianAtEqualWeights() { + // Equal weights must reproduce the old even-count midpoint average exactly (byte-identical fits). + XCTAssertEqual(StepsEstimateEngine.weightedMedian([90, 100, 110, 130], weights: [5, 5, 5, 5]), + 105, accuracy: 1e-9) // plain median = (100+110)/2 + XCTAssertEqual(StepsEstimateEngine.weightedMedian([3, 1, 2], weights: [7, 7, 7]), + 2, accuracy: 1e-9) // odd count, order-independent + } + + func testWeightedMedianFallsBackOnDegenerateWeights() { + XCTAssertEqual(StepsEstimateEngine.weightedMedian([1, 2, 3], weights: []), 2, accuracy: 1e-9) + XCTAssertEqual(StepsEstimateEngine.weightedMedian([1, 2, 3], weights: [0, 0, 0]), 2, accuracy: 1e-9) + XCTAssertEqual(StepsEstimateEngine.weightedMedian([], weights: []), 0, accuracy: 1e-9) + } + + func testManualOverrideWinsWithFullConfidence() { + let cal = StepsEstimateEngine.calibrate([], manualOverride: 123) + XCTAssertNotNil(cal) + XCTAssertEqual(cal!.coefficient, 123) + XCTAssertTrue(cal!.manual) + XCTAssertEqual(cal!.confidence, 1.0) + } + + func testTightFitMoreConfidentThanScattered() { + let tight = (0..<14).map { _ in StepsEstimateEngine.CalibrationPoint(motion: 10, steps: 1000) } + let scattered = (0..<14).map { i in + StepsEstimateEngine.CalibrationPoint(motion: 10, steps: Double(500 + (i % 2) * 1500)) + } + let ct = StepsEstimateEngine.calibrate(tight)! + let cs = StepsEstimateEngine.calibrate(scattered)! + XCTAssertGreaterThan(ct.confidence, cs.confidence) + XCTAssertEqual(ct.confidence, 1.0, accuracy: 1e-9) // 14 days, zero spread + } + + // MARK: estimate + + func testEstimateAppliesCoefficient() { + let cal = StepsEstimateEngine.Calibration(coefficient: 100, sampleDays: 5, confidence: 0.8, manual: false) + XCTAssertEqual(StepsEstimateEngine.estimate(motion: 87, calibration: cal), 8700) + } + + func testEstimateNilBelowMinMotion() { + let cal = StepsEstimateEngine.Calibration(coefficient: 100, sampleDays: 5, confidence: 0.8, manual: false) + XCTAssertNil(StepsEstimateEngine.estimate(motion: 0.5, calibration: cal)) + } + + func testEstimateClampsAbsurd() { + let cal = StepsEstimateEngine.Calibration(coefficient: 1_000_000, sampleDays: 5, confidence: 0.1, manual: false) + XCTAssertEqual(StepsEstimateEngine.estimate(motion: 100, calibration: cal), StepsEstimateEngine.maxDailySteps) + } + + // MARK: calibration status (#589 — explain a blank tile instead of going silent) + + func testStatusNeedsMoreDaysCountsUsableDays() { + // Two usable overlapping days (< minCalibrationDays 3) → needsMoreDays with have=2, message says + // "Need 1 more day". A near-still day and a zero-step day don't count toward `have`. + let pts = [ + (0.2, 5000.0), // below minMotionForFit → not usable + (10.0, 0.0), // zero steps → not usable + (10.0, 1000.0), (20.0, 2000.0), + ].map { StepsEstimateEngine.CalibrationPoint(motion: $0.0, steps: $0.1) } + let status = StepsEstimateEngine.status(pts) + XCTAssertEqual(status, .needsMoreDays(have: 2, need: 3)) + XCTAssertFalse(status.canEstimate) + XCTAssertEqual(status.headline, "Need 1 more day where your phone also counted steps") + } + + func testStatusCalibratedOnceEnoughDays() { + let pts = (0..<3).map { _ in StepsEstimateEngine.CalibrationPoint(motion: 10, steps: 1000) } + let status = StepsEstimateEngine.status(pts) + guard case let .calibrated(coefficient, sampleDays, confidence) = status else { + return XCTFail("3 usable days must report .calibrated, got \(status)") + } + XCTAssertEqual(coefficient, 100, accuracy: 1e-9) + XCTAssertEqual(sampleDays, 3) + XCTAssertGreaterThan(confidence, 0) + XCTAssertTrue(status.canEstimate) + XCTAssertEqual(status.headline, "Estimated from 3 days your phone also counted") + } + + func testStatusManualOverrideWinsEvenWithNoDays() { + // A hand-set coefficient reports .manual regardless of how few overlapping days exist (the whole + // point of the manual path — a user with no phone history can still get an estimate). + let status = StepsEstimateEngine.status([], manualOverride: 42) + XCTAssertEqual(status, .manual(coefficient: 42, sampleDays: 0)) + XCTAssertTrue(status.canEstimate) + XCTAssertEqual(status.headline, "Calibrated by hand") + } + + // MARK: calibration STATUS surfaced on the tile (#760/#792 - k / days / confidence self-explain) + + func testConfidenceTierThresholds() { + // < 0.34 low, < 0.67 medium, else high. The boundaries are inclusive at the lower tier's top. + XCTAssertEqual(StepsEstimateEngine.ConfidenceTier.from(0.0), .low) + XCTAssertEqual(StepsEstimateEngine.ConfidenceTier.from(0.33), .low) + XCTAssertEqual(StepsEstimateEngine.ConfidenceTier.from(0.34), .medium) + XCTAssertEqual(StepsEstimateEngine.ConfidenceTier.from(0.66), .medium) + XCTAssertEqual(StepsEstimateEngine.ConfidenceTier.from(0.67), .high) + XCTAssertEqual(StepsEstimateEngine.ConfidenceTier.from(1.0), .high) + } + + func testStatusDetailCalibratedSurfacesKDaysAndConfidence() { + // A low-confidence calibrated fit must SAY so (the frozen-estimate complaint #760/#792): the detail + // names k, the day count, and the confidence tier. + let status = StepsEstimateEngine.CalibrationStatus.calibrated( + coefficient: 12.34, sampleDays: 6, confidence: 0.2) + XCTAssertEqual(status.confidenceTier, .low) + XCTAssertEqual(status.coefficient, 12.34) + XCTAssertEqual(status.detail, "k=12.3 from 6 days, low confidence") + // Singular day grammar. + let one = StepsEstimateEngine.CalibrationStatus.calibrated( + coefficient: 5.0, sampleDays: 1, confidence: 0.8) + XCTAssertEqual(one.confidenceTier, .high) + XCTAssertEqual(one.detail, "k=5.0 from 1 day, high confidence") + } + + func testStatusDetailManualAndNeedsMoreDays() { + let manual = StepsEstimateEngine.CalibrationStatus.manual(coefficient: 9.5, sampleDays: 0) + XCTAssertEqual(manual.confidenceTier, .high) + XCTAssertEqual(manual.coefficient, 9.5) + XCTAssertEqual(manual.detail, "manual k=9.5") + let needs = StepsEstimateEngine.CalibrationStatus.needsMoreDays(have: 1, need: 3) + XCTAssertEqual(needs.confidenceTier, .low) + XCTAssertNil(needs.coefficient) + XCTAssertEqual(needs.detail, "calibrating: 1/3 days") + // `have` is clamped to `need` in the fraction so it never reads more than the requirement. + let over = StepsEstimateEngine.CalibrationStatus.needsMoreDays(have: 9, need: 3) + XCTAssertEqual(over.detail, "calibrating: 3/3 days") + } + + // MARK: #693 — apple-health steps + strap motion over the calibration window + + /// A day's still-with-walking-bursts gravity at 1 Hz: `bursts` short active windows separated by stillness, + /// so `dayMotionIntensity` returns a positive, day-distinct motion volume (the strap-side input the engine + /// pairs with the phone step count). + private func walkingGravity(start: Int, bursts: Int) -> [GravitySample] { + var out: [GravitySample] = [] + var t = start + for b in 0..= minCalibrationDays days that ALSO have strap motion. The live bug was a wrong DATA SOURCE in + /// IntelligenceEngine — the phone reference was read from `dailyMetrics` (always empty for steps; + /// Apple-Health writes the count into `appleDaily.steps`, an `Int?`), so `refStepsByDay` stayed empty + /// and the fit never had any points → "Need 3 more days" forever. This pins the calibration-point + /// ASSEMBLY the fixed read feeds: building `CalibrationPoint`s from an apple-steps source (`Int?`) keyed + /// by day + per-day `dayMotionIntensity` over >= 3 overlapping days yields a non-nil Calibration and a + /// status that is NOT `needsMoreDays`. + func testCalibrationFromAppleStepsAndStrapMotionAdvances() { + // Five days, each with a real phone step count (the `appleDaily.steps` Int? source) AND strap motion. + // (Day 4 carries a nil step count — the gap the engine's `if let s = r.steps` filter must skip; it + // contributes motion but no calibration point, exactly like a day the phone didn't count.) + let daySecs = 86_400 + let appleSteps: [(day: String, steps: Int?)] = [ + ("2026-06-15", 8000), + ("2026-06-16", 11000), + ("2026-06-17", 6000), + ("2026-06-18", nil), // phone didn't count this day → no reference, must be skipped + ("2026-06-19", 9000), + ] + // Per-day strap motion, distinct volumes (more bursts on busier days), keyed by the same day string. + var motionByDay: [String: Double] = [:] + for (i, e) in appleSteps.enumerated() { + let grav = walkingGravity(start: 1_750_000_000 + i * daySecs, bursts: 6 + i) + let m = StepsEstimateEngine.dayMotionIntensity(grav) + XCTAssertGreaterThan(m, StepsEstimateEngine.minMotionForFit, "each active day must clear the fit floor") + motionByDay[e.day] = m + } + + // Build reference steps from the apple-steps source the SAME way the fixed engine does: + // `for r in appleRows { if let s = r.steps, s > 0 { refStepsByDay[r.day] = Double(s) } }`. + var refStepsByDay: [String: Double] = [:] + for e in appleSteps { if let s = e.steps, s > 0 { refStepsByDay[e.day] = Double(s) } } + XCTAssertEqual(refStepsByDay.count, 4, "the nil-step day must not enter the reference set") + + // Pair into calibration points exactly as the engine's `calPoints` does (motion + reference step). + let calPoints = motionByDay.compactMap { (day, motion) -> StepsEstimateEngine.CalibrationPoint? in + guard let s = refStepsByDay[day] else { return nil } + return StepsEstimateEngine.CalibrationPoint(motion: motion, steps: s) + } + XCTAssertEqual(calPoints.count, 4, "4 overlapping (motion + phone-step) days, the nil day dropped") + + // The fix's payoff: a real fit now exists, and the status is NOT stuck on needsMoreDays. + let cal = StepsEstimateEngine.calibrate(calPoints) + XCTAssertNotNil(cal, "4 usable overlapping days must fit a coefficient (the #693 regression)") + XCTAssertGreaterThan(cal!.coefficient, 0) + XCTAssertGreaterThanOrEqual(cal!.sampleDays, StepsEstimateEngine.minCalibrationDays) + + let status = StepsEstimateEngine.status(calPoints) + if case .needsMoreDays = status { + XCTFail("calibration must have advanced past needsMoreDays, got \(status)") + } + XCTAssertTrue(status.canEstimate) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsEstimateEngineTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsEstimateEngineTraceTests.swift new file mode 100644 index 0000000000..701269db9b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsEstimateEngineTraceTests.swift @@ -0,0 +1,187 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// The Steps test mode's two pure traces. Pins the lines a fixture produces AND proves the trace can never +/// diverge from the production numbers: the 5/MG raw-counter trace's scaledSteps equals +/// AnalyticsEngine.analyzeDay(...).daily.steps EXACTLY (same wrap-aware sum, same maxStepDelta gate, same +/// ticks-per-step scaling), and the WHOOP-4 calibration trace reuses StepsEstimateEngine.calibrate verbatim. +/// Twin of the Android StepsEstimateEngineTraceTest. No em-dashes. +final class StepsEstimateEngineTraceTests: XCTestCase { + + private let profile = UserProfile() + + // A timestamp safely inside UTC day 2026-01-02 (2026-01-02T12:00:00Z = 1767355200). + private let dayUtc = "2026-01-02" + private let noonUtc = 1_767_355_200 + + private func step(_ tsOffsetSec: Int, _ counter: Int) -> StepSample { + StepSample(ts: noonUtc + tsOffsetSec, counter: counter) + } + + // MARK: - 5/MG raw-counter trace + + func testRawTotalEqualsAnalyzeDaySteps() { + // counters 100 -> 150 -> 220 => deltas 50 + 70 = 120; analyzeDay returns the same. + let samples = [step(0, 100), step(60, 150), step(120, 220)] + let production = AnalyticsEngine.analyzeDay(day: dayUtc, steps: samples, profile: profile).daily.steps + XCTAssertEqual(production, 120) + let lines = StepsEstimateEngine.rawCounterTrace( + daySteps: samples, dayKey: dayUtc, tzOffsetSeconds: 0, ticksPerStep: profile.stepTicksPerStep) + let totalLine = lines.first { $0.hasPrefix("stepsRaw total ") } + XCTAssertNotNil(totalLine) + // The trace's scaledSteps must equal the day's production steps total EXACTLY. + XCTAssertTrue(totalLine!.contains("scaledSteps=\(production!)"), + "trace scaledSteps must equal analyzeDay steps, got \(totalLine!)") + XCTAssertTrue(totalLine!.contains("rawTicks=120")) + } + + func testWrapAwareDeltaIsReportedAndCounted() { + // 65500 -> 30 wraps: (30 - 65500) & 0xFFFF = 66; 30 -> 90 = 60. Both kept (< 512). + let samples = [step(0, 65_500), step(60, 30), step(120, 90)] + let production = AnalyticsEngine.analyzeDay(day: dayUtc, steps: samples, profile: profile).daily.steps + XCTAssertEqual(production, 66 + 60) + let lines = StepsEstimateEngine.rawCounterTrace( + daySteps: samples, dayKey: dayUtc, tzOffsetSeconds: 0, ticksPerStep: profile.stepTicksPerStep) + XCTAssertTrue(lines.contains { $0.contains("stepsRaw deltas kept=2 dropped=0") }) + XCTAssertTrue(lines.first { $0.hasPrefix("stepsRaw total ") }!.contains("scaledSteps=\(production!)")) + XCTAssertFalse(lines.contains { $0.contains("\u{2014}") }) + } + + func testDroppedDeltaIsCountedAndExcluded() { + // 100 -> 150 (kept, 50) -> 1000 (delta 850 >= 512, DROPPED as a sync-gap) -> 1050 (kept, 50). + let samples = [step(0, 100), step(60, 150), step(120, 1_000), step(180, 1_050)] + let production = AnalyticsEngine.analyzeDay(day: dayUtc, steps: samples, profile: profile).daily.steps + XCTAssertEqual(production, 100) // 50 + 50, the 850 jump excluded + let lines = StepsEstimateEngine.rawCounterTrace( + daySteps: samples, dayKey: dayUtc, tzOffsetSeconds: 0, ticksPerStep: profile.stepTicksPerStep) + XCTAssertTrue(lines.contains { $0.contains("stepsRaw deltas kept=2 dropped=1") }) + XCTAssertTrue(lines.first { $0.hasPrefix("stepsRaw total ") }!.contains("scaledSteps=\(production!)")) + } + + func testTicksPerStepScalingMatchesAnalyzeDay() { + // A ticks-per-step of 2.0 halves the raw ticks; the trace must match analyzeDay's scaled value. + let scaledProfile = UserProfile(stepTicksPerStep: 2.0) + let samples = [step(0, 0), step(60, 100), step(120, 200)] // raw ticks = 200 + let production = AnalyticsEngine.analyzeDay(day: dayUtc, steps: samples, profile: scaledProfile).daily.steps + let lines = StepsEstimateEngine.rawCounterTrace( + daySteps: samples, dayKey: dayUtc, tzOffsetSeconds: 0, ticksPerStep: scaledProfile.stepTicksPerStep) + XCTAssertTrue(lines.first { $0.hasPrefix("stepsRaw total ") }!.contains("scaledSteps=\(production!)")) + } + + func testTinyTotalRoundingToZeroRendersNoneNotZero() { + // L7: a rawTotal that scales below 0.5 (here 1 tick / ticksPerStep 3.0 = 0.33 -> rounds to 0) makes + // production analyzeDay return NIL (scaled>0 ? scaled : nil). The trace must read "scaledSteps=none", + // not "scaledSteps=0", so it matches the missing headline instead of implying a real zero measurement. + let tinyProfile = UserProfile(stepTicksPerStep: 3.0) + let samples = [step(0, 100), step(60, 101)] // one kept delta of 1 tick + let production = AnalyticsEngine.analyzeDay(day: dayUtc, steps: samples, profile: tinyProfile).daily.steps + XCTAssertNil(production, "a sub-0.5 scaled total is nil in production") + let lines = StepsEstimateEngine.rawCounterTrace( + daySteps: samples, dayKey: dayUtc, tzOffsetSeconds: 0, ticksPerStep: tinyProfile.stepTicksPerStep) + let totalLine = lines.first { $0.hasPrefix("stepsRaw total ") }! + XCTAssertTrue(totalLine.contains("rawTicks=1")) + XCTAssertTrue(totalLine.contains("scaledSteps=none"), "got \(totalLine)") + XCTAssertFalse(totalLine.contains("scaledSteps=0")) + } + + func testFewerThanTwoSamplesReportsNoDelta() { + let lines = StepsEstimateEngine.rawCounterTrace( + daySteps: [step(0, 100)], dayKey: dayUtc, tzOffsetSeconds: 0, ticksPerStep: 1.0) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines[0].contains("counterSamples=1")) + XCTAssertTrue(lines[0].contains("need >=2")) + } + + func testEmptyCounterReportsNoRawCounterNotBroken() { + // #810: a WHOOP 4.0 sends NO raw step counter, so daySteps is empty for it. The trace must say so + // honestly (the device is motion-estimated), NOT emit the "counterSamples=0 ... need >=2" line that + // read as broken. A 5/MG never hits this branch (it always banks counter rows). Twin of the Android + // emptyCounterReportsNoRawCounterNotBroken. + let lines = StepsEstimateEngine.rawCounterTrace( + daySteps: [], dayKey: dayUtc, tzOffsetSeconds: 0, ticksPerStep: 1.0) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines[0].contains("counterSamples=0")) + XCTAssertTrue(lines[0].contains("noRawCounter")) + XCTAssertTrue(lines[0].contains("motion-estimated")) + XCTAssertFalse(lines[0].contains("need >=2")) // not the misleading "broken" line + XCTAssertFalse(lines[0].contains("\u{2014}")) // no em-dash + } + + func testEmptyAfterDayFilterAlsoReportsNoRawCounter() { + // daySteps has rows, but none fall on the requested day (e.g. all on a neighbouring day). After the + // local-day filter the sorted list is empty, so the same honest noRawCounter line is emitted rather + // than a broken-looking counterSamples=0 ... need >=2. Twin of the Android + // emptyAfterDayFilterAlsoReportsNoRawCounter. + let otherDay = [step(2 * 86_400, 100), step(2 * 86_400 + 60, 150)] + let lines = StepsEstimateEngine.rawCounterTrace( + daySteps: otherDay, dayKey: dayUtc, tzOffsetSeconds: 0, ticksPerStep: 1.0) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines[0].contains("noRawCounter")) + } + + // MARK: - WHOOP-4 calibration trace + + func testCalibrationTraceReusesCalibrateVerbatim() { + let points = [ + StepsEstimateEngine.CalibrationPoint(motion: 100, steps: 1_000), + StepsEstimateEngine.CalibrationPoint(motion: 200, steps: 2_000), + StepsEstimateEngine.CalibrationPoint(motion: 300, steps: 3_000), + ] + let cal = StepsEstimateEngine.calibrate(points)! + let lines = StepsEstimateEngine.calibrationTrace(points: points) + let fitLine = lines.first { $0.hasPrefix("stepsCal fit ") } + XCTAssertNotNil(fitLine) + // The reported coefficient must equal calibrate(...)'s, rounded to 2dp. + let k2 = (cal.coefficient * 100).rounded() / 100 + XCTAssertTrue(fitLine!.contains("k=\(k2)"), fitLine!) + XCTAssertTrue(fitLine!.contains("sampleDays=\(cal.sampleDays)")) + XCTAssertTrue(fitLine!.contains("manual=false")) + // One point line per usable day. + XCTAssertEqual(lines.filter { $0.hasPrefix("stepsCal point ") }.count, 3) + XCTAssertFalse(lines.contains { $0.contains("\u{2014}") }) + } + + func testCalibrationTraceNamesWithheldReason() { + // Two usable days < minCalibrationDays (3), no manual override: withheld with needsMoreDays. + let points = [ + StepsEstimateEngine.CalibrationPoint(motion: 100, steps: 1_000), + StepsEstimateEngine.CalibrationPoint(motion: 200, steps: 2_000), + ] + XCTAssertNil(StepsEstimateEngine.calibrate(points)) + let lines = StepsEstimateEngine.calibrationTrace(points: points) + let withheld = lines.first { $0.contains("stepsCal withheld ") } + XCTAssertNotNil(withheld) + XCTAssertTrue(withheld!.contains("reason=needsMoreDays")) + XCTAssertTrue(withheld!.contains("have=2")) + XCTAssertTrue(withheld!.contains("need=3")) + } + + func testManualOverrideTraceReportsManual() { + let points = [StepsEstimateEngine.CalibrationPoint(motion: 100, steps: 1_000)] + let lines = StepsEstimateEngine.calibrationTrace(points: points, manualOverride: 9.5) + let fitLine = lines.first { $0.hasPrefix("stepsCal fit ") } + XCTAssertNotNil(fitLine) + XCTAssertTrue(fitLine!.contains("manual=true")) + XCTAssertTrue(fitLine!.contains("k=9.5")) + } + + // MARK: - Readout parsers + + func testStepsReadoutParsesScaledSteps() { + let tail = ["[steps] stepsRaw total rawTicks=120 ticksPerStep=1.0 scaledSteps=120 (steps_est for the day)"] + XCTAssertEqual(StepsReadout.stepsToday(taggedTail: tail), 120) + } + + func testStepsReadoutParsesEstimateLine() { + let tail = ["[steps] stepsEst day=2026-01-02 steps=8421 motion=4123.5 (motion-volume estimate)"] + XCTAssertEqual(StepsReadout.stepsToday(taggedTail: tail), 8421) + } + + func testCalibrationStateReadoutParsesFitAndWithheld() { + let fit = ["[steps] stepsCal fit k=10.0 sampleDays=5 confidence=0.8 manual=false (k = motion-weighted median of steps/motion)"] + XCTAssertEqual(StepsReadout.calibrationState(taggedTail: fit), "k=10.0 sampleDays=5 confidence=0.8 manual=false") + let withheld = ["[steps] stepsCal withheld reason=needsMoreDays have=2 need=3 (no usable auto-fit and no manual k)"] + XCTAssertEqual(StepsReadout.calibrationState(taggedTail: withheld), "not calibrated (needsMoreDays have=2 need=3)") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainScorerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainScorerTests.swift index ba06d75c13..93c40aeb53 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainScorerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainScorerTests.swift @@ -14,22 +14,24 @@ final class StrainScorerTests: XCTestCase { XCTAssertEqual(StrainScorer.defaultMaxHR(age: 30), 190) } - func testTrimpToStrainCeilingMapsTo21() { - // Edwards 24 h ceiling TRIMP = 7200 → strain exactly 21.0 with D = 7201. - XCTAssertEqual(StrainScorer.trimpToStrain(7200), 21.0, accuracy: 1e-9) + func testTrimpToStrainCeilingMapsTo100() { + // Edwards 24 h ceiling TRIMP = 7200 → Effort exactly 100.0 with D = 7201 + // (rescaled from the old 21.0; the curve/saturation point is unchanged). + XCTAssertEqual(StrainScorer.trimpToStrain(7200), 100.0, accuracy: 1e-9) } func testTrimpToStrainKnownValues() { XCTAssertEqual(StrainScorer.trimpToStrain(0), 0.0, accuracy: 1e-9) XCTAssertEqual(StrainScorer.trimpToStrain(-5), 0.0, accuracy: 1e-9) - XCTAssertEqual(StrainScorer.trimpToStrain(100), 10.91, accuracy: 1e-9) + // 10.91 × 100/21 on the rescaled axis. + XCTAssertEqual(StrainScorer.trimpToStrain(100), 51.96, accuracy: 1e-2) } func testStrainGoldenEdwardsZone5() { // 600 z5 samples at 1 Hz, resting 60, max 190. TRIMP = 600*5*(1/60)=50. - // strain = 21*ln(51)/ln(7201) = 9.3. + // Effort = 100*ln(51)/ln(7201) = 44.27 (was 9.3 on the 0–21 axis). let s = StrainScorer.strain(hr(185, 600), maxHR: 190, restingHR: 60) - XCTAssertEqual(s!, 9.3, accuracy: 1e-2) + XCTAssertEqual(s!, 44.27, accuracy: 1e-2) } func testStrainReturnsNilTooFewReadings() { @@ -58,7 +60,48 @@ final class StrainScorerTests: XCTestCase { func testStrainBanisterAlsoBounded() { let s = StrainScorer.strain(hr(185, 600), maxHR: 190, restingHR: 60, method: .banister)! XCTAssertGreaterThan(s, 0) - XCTAssertLessThanOrEqual(s, 21.0) + XCTAssertLessThanOrEqual(s, 100.0) + } + + // MARK: - #482/#480 sparse-strap acceptance + honest-zero (regression guards) + + /// Build n samples at a fixed cadence (default 30 s — the WHOOP 5/MG live-HR rate). + private func hrEvery(_ bpm: Int, _ n: Int, stepS: Int = 30, start: Int = 0) -> [HRSample] { + (0.. 0, proving the + // zero above is about intensity, not about the sparse path swallowing real load. + let sparseHard = hrEvery(175, 40) // 175 bpm ≈ 93% HRR → z5 + let s = StrainScorer.strain(sparseHard, maxHR: 184, restingHR: 60) + XCTAssertNotNil(s) + XCTAssertGreaterThan(s!, 0) } func testEstimateHRmaxObservedVsTanaka() { @@ -89,9 +132,10 @@ final class StrainScorerTests: XCTestCase { } func testFitStrainDenominator() throws { - // Pairs generated from a known D should recover that D. + // Pairs generated from a known D should recover that D. Pairs use the rescaled + // 0–100 axis (maxStrain = 100), matching fitStrainDenominator's maxStrain term. let knownD = 5000.0 - func strainFor(_ t: Double) -> Double { 21 * log(t + 1) / log(knownD) } + func strainFor(_ t: Double) -> Double { 100 * log(t + 1) / log(knownD) } let pairs = [(100.0, strainFor(100)), (1000.0, strainFor(1000)), (50.0, strainFor(50))] let fitted = try StrainScorer.fitStrainDenominator(pairs) XCTAssertEqual(fitted, knownD, accuracy: 1.0) diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StressIndexTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StressIndexTests.swift new file mode 100644 index 0000000000..2fba15586f --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StressIndexTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +final class StressIndexTests: XCTestCase { + + // MARK: - Golden value (hand-computed histogram) + + func testGoldenStressIndexHandComputed() { + // 22 beats (ms) that all survive range + Malik ectopic cleaning. In seconds the cleaned series spans + // [0.70, 0.86] (MxDMn = 0.16), bins at 0.05 s into 4 bins with counts [3, 5, 13, 1]: the modal bin is + // index 2 (count 13), centre Mo = 0.70 + 2.5*0.05 = 0.825 s, AMo = 13/22 = 59.0909...%, so + // SI = AMo / (2*Mo*MxDMn) = 59.0909.. / (2*0.825*0.16) = 223.829201101928... + let rr: [Double] = [700, 720, 740, 760, 780, 800, 820, 840, 860, 800, 800, + 800, 800, 820, 780, 800, 810, 790, 800, 800, 805, 795] + let comp = StressIndex.components(rawRR: rr) + XCTAssertNotNil(comp) + XCTAssertEqual(comp!.mxDMnSec, 0.16, accuracy: 1e-9) + XCTAssertEqual(comp!.moSec, 0.825, accuracy: 1e-9) + XCTAssertEqual(comp!.aMoPercent, 59.09090909090909, accuracy: 1e-9) + XCTAssertEqual(comp!.si, 223.82920110192836, accuracy: 1e-9) + XCTAssertEqual(StressIndex.stressIndex(rawRR: rr)!, 223.82920110192836, accuracy: 1e-9) + } + + // MARK: - Monotonicity: a tighter histogram (more rigid rhythm) raises SI + + func testTighterHistogramRaisesSI() { + // A broad, flexible rhythm (wide spread) vs a rigid one (tightly clustered). SI must be higher for + // the rigid series: tall narrow peak + small range both push SI up. + let broad: [Double] = (0..<30).map { 700.0 + Double($0 % 11) * 18.0 } // spread ~700..880 + let rigid: [Double] = (0..<30).map { i in i % 6 == 0 ? 810.0 : 800.0 } // nearly all 800 + let siBroad = StressIndex.stressIndex(rawRR: broad) + let siRigid = StressIndex.stressIndex(rawRR: rigid) + XCTAssertNotNil(siBroad) + XCTAssertNotNil(siRigid) + XCTAssertGreaterThan(siRigid!, siBroad!, "a rigid, tightly-clustered rhythm has a higher Stress Index") + } + + // MARK: - Honest gates + + func testTooFewBeatsReturnsNil() { + let rr = Array(repeating: 800.0, count: StressIndex.minBeats - 1) + XCTAssertNil(StressIndex.stressIndex(rawRR: rr)) + } + + func testDegenerateRangeReturnsNil() { + // All-equal beats: MxDMn == 0 → SI undefined → nil (never Infinity). + let rr = Array(repeating: 800.0, count: 30) + XCTAssertNil(StressIndex.stressIndex(rawRR: rr)) + } + + func testRRIntervalOverloadMatchesRaw() { + let raw: [Double] = [700, 720, 740, 760, 780, 800, 820, 840, 860, 800, 800, + 800, 800, 820, 780, 800, 810, 790, 800, 800, 805, 795] + let rr = raw.enumerated().map { RRInterval(ts: 1000 + $0.offset, rrMs: Int($0.element)) } + XCTAssertEqual(StressIndex.stressIndex(rr: rr)!, StressIndex.stressIndex(rawRR: raw)!, accuracy: 1e-9) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StressOnsetDetectorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StressOnsetDetectorTests.swift new file mode 100644 index 0000000000..d1adbb0c05 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StressOnsetDetectorTests.swift @@ -0,0 +1,136 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the L3 `StressOnsetDetector` — the highest-value test, guarding the credibility line: it fires +/// ONCE on a fresh non-metabolic HRV dip, is suppressed by the exercise gate (HR-out-of-band and/or +/// motion), honours the rate limit + quiet hours, and replays safely (a re-fed window can't re-fire). +/// GOLDEN/behaviour vectors the Kotlin `StressOnsetDetectorTest` mirrors. +/// See docs/superpowers/specs/2026-06-19-v5-haptic-biofeedback-design.md (L3). +final class StressOnsetDetectorTests: XCTestCase { + + private let on = StressOnsetDetector.Config(enabled: true, autoNudge: true) + + /// A clean R-R buffer of `n` beats all equal to `rrMs` (RMSSD 0 if constant) — for the seed step. + private func flat(_ rrMs: Int, _ n: Int) -> [Int] { Array(repeating: rrMs, count: n) } + + /// A clean R-R buffer of `n` beats alternating ±`jitterMs` around `rrMs`, giving a controllable RMSSD + /// (≈ 2*jitter). Larger jitter → higher RMSSD → higher HRV. + private func jittered(_ rrMs: Int, jitter: Int, _ n: Int) -> [Int] { + (0..". + func testGithubLabels() { + XCTAssertEqual(TestDomain.master.githubLabel, "test:all") + XCTAssertEqual(TestDomain.sleep.githubLabel, "test:sleep") + XCTAssertEqual(TestDomain.battery.githubLabel, "test:battery") + XCTAssertEqual(TestDomain.dataImport.githubLabel, "test:import") + } + + // The full id set is declared now so later phases just flip emitters on. Pin it. + func testFullIdSet() { + XCTAssertEqual(TestDomain.allCases.map(\.id), [ + "universal", "sleep", "connection", "workouts", "display", "import", + "steps", "notifications", "battery", "recovery", "hrv", "sources", + "stress", "longevity", "master", + ]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/TestModeRegistryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/TestModeRegistryTests.swift new file mode 100644 index 0000000000..98391beb7d --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/TestModeRegistryTests.swift @@ -0,0 +1,176 @@ +import XCTest +@testable import StrandAnalytics + +final class TestModeRegistryTests: XCTestCase { + + func testRegistryOrderAndIds() { + // Phase 1 shipped sleep + battery; Phase 2 appended the 5 high-pain domains plus recovery + hrv. + // Screen priority order: sleep, connection, workouts, display, import, steps, battery, recovery, hrv. + XCTAssertEqual(TestModeRegistry.all.map(\.domain), + [.sleep, .connection, .workouts, .display, .dataImport, .steps, .battery, .recovery, .hrv]) + XCTAssertEqual(TestModeRegistry.all.map(\.id), + ["sleep", "connection", "workouts", "display", "import", "steps", "battery", "recovery", "hrv"]) + XCTAssertEqual(TestModeRegistry.all.count, 9) + } + + func testLookupByDomain() { + XCTAssertEqual(TestModeRegistry.mode(.sleep)?.title, "Sleep & Rest") + XCTAssertEqual(TestModeRegistry.mode(.connection)?.title, "Connection & Sync") + XCTAssertEqual(TestModeRegistry.mode(.workouts)?.title, "Workouts & GPS") + XCTAssertEqual(TestModeRegistry.mode(.display)?.title, "Display & Performance") + XCTAssertEqual(TestModeRegistry.mode(.dataImport)?.title, "Import & Data Ingest") + XCTAssertEqual(TestModeRegistry.mode(.steps)?.title, "Steps") + XCTAssertEqual(TestModeRegistry.mode(.battery)?.title, "Battery & Charging") + XCTAssertEqual(TestModeRegistry.mode(.recovery)?.title, "Recovery (Charge)") + XCTAssertEqual(TestModeRegistry.mode(.hrv)?.title, "HRV & Autonomic") + XCTAssertNil(TestModeRegistry.mode(.notifications)) + } + + func testSleepCaptureSet() { + XCTAssertEqual(TestModeRegistry.mode(.sleep)?.captures, [ + "gateTrace", "gravityCoverage", "hrDensity", "wristOff", "perEpochFeatures", + "hypnogramV1V2", "ppgOnlyNight", "skinTempDsp", "restSubScores", + ]) + } + + func testSleepIsGuidedThreeNights() { + guard case .guided(let unit, let count)? = TestModeRegistry.mode(.sleep)?.capture else { + return XCTFail("sleep should be guided") + } + XCTAssertEqual(unit, .nights) + XCTAssertEqual(count, 3) + } + + func testBatteryIsGuidedThreeDays() { + guard case .guided(let unit, let count)? = TestModeRegistry.mode(.battery)?.capture else { + return XCTFail("battery should be guided") + } + XCTAssertEqual(unit, .days) + XCTAssertEqual(count, 3) + } + + func testBatteryCaptureSetAndReadout() { + XCTAssertEqual(TestModeRegistry.mode(.battery)?.captures, [ + "socSeries", "chargeSteps", "offWristGaps", "dischargeRun", "fittedSlope", + "sourceMeasuredVsRated", "batteryGates", + ]) + XCTAssertEqual(TestModeRegistry.mode(.battery)?.liveReadout, ["currentSoc", "estimateDaysLeft", "slopeSource"]) + } + + func testSleepQuestionnaireKeys() { + XCTAssertEqual(TestModeRegistry.mode(.sleep)?.questionnaire.map(\.id), [ + "sleepTimes", "awakeStill", "naps", "shiftWork", "chargeTiming", "healthSleep", + ]) + } + + func testScreenshotAndRequires5MGFlags() { + // Only Display & Performance carries a screenshot; nothing registered yet requires 5/MG. + for m in TestModeRegistry.all { + XCTAssertFalse(m.requires5MG, "\(m.id) should not require 5/MG") + XCTAssertEqual(m.includesScreenshot, m.domain == .display, "\(m.id) screenshot flag") + } + } + + func testPhase2HighPainAreToggles() { + for d in [TestDomain.connection, .workouts, .display, .dataImport, .steps, .recovery, .hrv] { + XCTAssertEqual(TestModeRegistry.mode(d)?.capture, .toggle, "\(d.id) should be a plain toggle") + } + } + + func testPhase2Priorities() { + XCTAssertEqual(TestModeRegistry.mode(.connection)?.priority, .high) + XCTAssertEqual(TestModeRegistry.mode(.workouts)?.priority, .high) + XCTAssertEqual(TestModeRegistry.mode(.display)?.priority, .high) + XCTAssertEqual(TestModeRegistry.mode(.dataImport)?.priority, .high) + XCTAssertEqual(TestModeRegistry.mode(.steps)?.priority, .high) + XCTAssertEqual(TestModeRegistry.mode(.recovery)?.priority, .med) + XCTAssertEqual(TestModeRegistry.mode(.hrv)?.priority, .med) + } + + func testPhase2CaptureSets() { + XCTAssertEqual(TestModeRegistry.mode(.connection)?.captures, [ + "connectTiming", "bondState", "frameTiming", "reconnectChurn", "offloadProgress", + "offloadStalls", "firmwareDecode", "clockDrift", "otherCentral", + ]) + XCTAssertEqual(TestModeRegistry.mode(.workouts)?.captures, [ + "sessionLifecycle", "hrSamples", "gpsFixes", "autoDetectThresholds", + "autoDetectWhy", "crossSourceDedup", + ]) + XCTAssertEqual(TestModeRegistry.mode(.display)?.captures, [ + "screenshot", "deviceMetrics", "frameTimeTrace", "memoryHighWater", + ]) + XCTAssertEqual(TestModeRegistry.mode(.dataImport)?.captures, [ + "parserVersion", "fileMeta", "perStageRows", "rejectCounts", "dayDeltas", + ]) + XCTAssertEqual(TestModeRegistry.mode(.steps)?.captures, [ + "motionVolume", "stepCalibration", "phoneReferenceCount", "rawStepCounter", + "wrapAwareDeltas", "droppedDeltas", + ]) + XCTAssertEqual(TestModeRegistry.mode(.recovery)?.captures, [ + "chargeTermBreakdown", "baselinesPerNight", "termZScores", "nilTerm", "forecastInputs", + ]) + XCTAssertEqual(TestModeRegistry.mode(.hrv)?.captures, [ + "rawRR", "nInputCleanRejected", "rmssdSdnn", "minBeatsCleared", "spotVsContinuous", "respRsa", + ]) + } + + func testPhase2QuestionnaireIdsAndKinds() { + XCTAssertEqual(TestModeRegistry.mode(.connection)?.questionnaire.map(\.id), ["otherDevicePaired"]) + XCTAssertEqual(TestModeRegistry.mode(.connection)?.questionnaire.first?.kind, .yesNo) + XCTAssertEqual(TestModeRegistry.mode(.workouts)?.questionnaire.map(\.id), ["startMethod"]) + XCTAssertEqual(TestModeRegistry.mode(.workouts)?.questionnaire.first?.kind, .text) + XCTAssertEqual(TestModeRegistry.mode(.display)?.questionnaire.map(\.id), ["screenAndIssue"]) + XCTAssertEqual(TestModeRegistry.mode(.display)?.questionnaire.first?.kind, .text) + XCTAssertEqual(TestModeRegistry.mode(.dataImport)?.questionnaire.map(\.id), ["appFormatExpected"]) + XCTAssertEqual(TestModeRegistry.mode(.dataImport)?.questionnaire.first?.kind, .text) + XCTAssertEqual(TestModeRegistry.mode(.steps)?.questionnaire.map(\.id), ["otherTrackerSteps"]) + XCTAssertEqual(TestModeRegistry.mode(.steps)?.questionnaire.first?.kind, .text) + XCTAssertEqual(TestModeRegistry.mode(.recovery)?.questionnaire.map(\.id), ["recalHealthHrv"]) + XCTAssertEqual(TestModeRegistry.mode(.recovery)?.questionnaire.first?.kind, .text) + XCTAssertEqual(TestModeRegistry.mode(.hrv)?.questionnaire.map(\.id), ["otherAppHrv"]) + XCTAssertEqual(TestModeRegistry.mode(.hrv)?.questionnaire.first?.kind, .yesNo) + } + + func testPhase2LiveReadoutIds() { + XCTAssertEqual(TestModeRegistry.mode(.connection)?.liveReadout, + ["connectionUptime", "reconnectCount", "lastOffloadResult"]) + XCTAssertEqual(TestModeRegistry.mode(.workouts)?.liveReadout, ["lastSessionSummary"]) + XCTAssertEqual(TestModeRegistry.mode(.display)?.liveReadout, ["deviceMetricsNow"]) + XCTAssertEqual(TestModeRegistry.mode(.dataImport)?.liveReadout, ["lastImportSummary"]) + XCTAssertEqual(TestModeRegistry.mode(.steps)?.liveReadout, ["stepsToday", "calibrationState"]) + XCTAssertEqual(TestModeRegistry.mode(.recovery)?.liveReadout, ["lastChargeBreakdown"]) + XCTAssertEqual(TestModeRegistry.mode(.hrv)?.liveReadout, ["lastHrvComputation"]) + } + + func testNoQuestionnairePromptHasEmDash() { + for m in TestModeRegistry.all { + for q in m.questionnaire { + XCTAssertFalse(q.prompt.contains("\u{2014}"), "\(m.id)/\(q.id) prompt has an em-dash") + } + XCTAssertFalse(m.blurb.contains("\u{2014}"), "\(m.id) blurb has an em-dash") + } + } +} + +// MARK: - Group E (Sleep & Rest): pin the questionnaire kinds + live-readout ids against drift. +// The ids are meta.json keys and the readout ids the panel binds; a later edit that renames or drops +// one must fail here. The id ORDER is already covered by TestModeRegistryTests.testSleepQuestionnaireKeys. + +final class TestModeRegistrySleepTests: XCTestCase { + func testSleepQuestionnaireIdsAndKinds() { + let sleep = TestModeRegistry.mode(.sleep)! + XCTAssertEqual(sleep.questionnaire.map(\.id), + ["sleepTimes", "awakeStill", "naps", "shiftWork", "chargeTiming", "healthSleep"]) + XCTAssertEqual(sleep.questionnaire.first { $0.id == "shiftWork" }?.kind, .yesNo) + XCTAssertEqual(sleep.questionnaire.first { $0.id == "healthSleep" }?.kind, .yesNo) + XCTAssertEqual(sleep.questionnaire.first { $0.id == "sleepTimes" }?.kind, .text) + XCTAssertEqual(sleep.questionnaire.first { $0.id == "naps" }?.kind, .text) + // No em-dash in any prompt (the writing-voice rule applies to user-facing strings too). + XCTAssertFalse(sleep.questionnaire.contains { $0.prompt.contains("\u{2014}") }) + } + + func testSleepLiveReadoutIds() { + let sleep = TestModeRegistry.mode(.sleep)! + XCTAssertEqual(sleep.liveReadout, ["hrDensityNow", "gravityCoverageNow", "lastNightGateFired"]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UniversalTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UniversalTraceTests.swift new file mode 100644 index 0000000000..35b0472283 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UniversalTraceTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import StrandAnalytics + +/// The universal clock-drift line that rides EVERY export (RTC cluster #531/#767/#804/#812). Pins the +/// format and the FUTURE-DATE flag so the export parser and the Kotlin twin can never silently drift. +final class UniversalTraceTests: XCTestCase { + + // 2026-06-28 00:00:00 UTC + private let wall = 1782604800 + + func testClockOkLineFormat() { + let line = UniversalTrace.clockDriftLine(newestUnix: wall - 60, wallNowUnix: wall, + oldestUnix: wall - 86_400 * 3, firmwareLayout: 25) + XCTAssertTrue(line.hasPrefix("strapClock newest=")) + XCTAssertTrue(line.contains("wall=")) + XCTAssertTrue(line.contains("newestVsWall=-60s")) + XCTAssertTrue(line.contains("spanDays=3")) + XCTAssertTrue(line.contains("firmware=v25")) + XCTAssertTrue(line.hasSuffix("clockOk")) + XCTAssertFalse(line.contains("\u{2014}"), "no em-dashes") + } + + func testFutureDatedStrapIsFlagged() { + // Newest record is an hour ahead of wall: a wandering / un-clocked RTC, the #767 tell. + let line = UniversalTrace.clockDriftLine(newestUnix: wall + 3_600, wallNowUnix: wall) + XCTAssertTrue(line.contains("newestVsWall=+3600s")) + XCTAssertTrue(line.contains("FUTURE-DATED")) + XCTAssertFalse(line.contains("clockOk")) + } + + func testWithinToleranceIsNotFlagged() { + // A minute ahead is normal RTC skew, under the default 120s tolerance. + let line = UniversalTrace.clockDriftLine(newestUnix: wall + 60, wallNowUnix: wall) + XCTAssertTrue(line.contains("clockOk")) + XCTAssertFalse(line.contains("FUTURE-DATED")) + } + + func testUnknownFirmwareWhenNotObserved() { + let line = UniversalTrace.clockDriftLine(newestUnix: wall, wallNowUnix: wall) + XCTAssertTrue(line.contains("firmware=unknown")) + } + + func testOldestOmittedWhenNotBelowNewest() { + // A half/short range reply (oldest >= newest, or nil) omits the span entirely. + let line = UniversalTrace.clockDriftLine(newestUnix: wall, wallNowUnix: wall, oldestUnix: wall + 10) + XCTAssertFalse(line.contains("oldest=")) + XCTAssertFalse(line.contains("spanDays=")) + } + + // #990: a newest banked record 363 DAYS behind wall used to read "clockOk" - the false all-clear + // that hid the reporter's real clock fault. Beyond +-48h it must read as a clock warning. + func testFarBehindDriftIsAClockWarningNotOk() { + let line = UniversalTrace.clockDriftLine(newestUnix: wall - 363 * 86_400, wallNowUnix: wall) + XCTAssertTrue(line.contains("CLOCK-WARNING"), line) + XCTAssertTrue(line.contains("363d behind wall"), line) + XCTAssertFalse(line.contains("clockOk"), line) + } + + func testBehindWithinTwoDaysStaysOk() { + // 47h behind = an unworn strap, not a clock fault: still clockOk. + let line = UniversalTrace.clockDriftLine(newestUnix: wall - 47 * 3_600, wallNowUnix: wall) + XCTAssertTrue(line.hasSuffix("clockOk"), line) + } + + // #987: an epoch-era newest (strap RTC never set, reads ~1970/71) is its own named fault, more + // specific than the generic behind warning, with the fix (charge + reconnect) in the line. + func testEpochEraNewestReadsRtcEpoch() { + let line = UniversalTrace.clockDriftLine(newestUnix: 40_000_000, wallNowUnix: wall) // 1971-04 + XCTAssertTrue(line.contains("RTC-EPOCH"), line) + XCTAssertTrue(line.contains("1970/71"), line) + XCTAssertFalse(line.contains("clockOk"), line) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/VitalBandsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/VitalBandsTests.swift new file mode 100644 index 0000000000..fd82eca6a3 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/VitalBandsTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins `VitalBands` — the Health Monitor's personal-baseline banding. Mirrors the Android +/// `VitalBandsTest` case-for-case with identical numbers, so the two platforms can never +/// band the same vital differently. +final class VitalBandsTests: XCTestCase { + + private let hrvCfg = Baselines.hrvCfg + private let hrvPop: ClosedRange = 40...120 + + func testNullValueIsNoData() { + let r = VitalBands.band(value: nil, history: [50.0], populationRange: hrvPop, cfg: hrvCfg) + XCTAssertEqual(r.band, .noData) + } + + // THE MOTIVATING CASE: a personal-normal HRV of 35 ms with the population band at 40-120. + // Below the trust gate it is still judged against the population, hence out-of-range. + func testLowHrvBelow14NightsPopulationOutOfRange() { + let r = VitalBands.band(value: 35, history: Array(repeating: 35.0, count: 10), + populationRange: hrvPop, cfg: hrvCfg) + XCTAssertEqual(r.band, .outOfRange) + XCTAssertEqual(r.basis, .population) + XCTAssertEqual(r.nights, 10) + } + + // The fix: at 14 trusted nights the same 35 ms is in-range against the user's OWN baseline. + func testLowHrvAt14NightsPersonalInRange() { + let r = VitalBands.band(value: 35, history: Array(repeating: 35.0, count: 14), + populationRange: hrvPop, cfg: hrvCfg) + XCTAssertEqual(r.band, .inRange) + XCTAssertEqual(r.basis, .personal) + XCTAssertEqual(r.nights, 14) + } + + func testPersonalBigDeviationOutOfRange() { + // Constant 35 ms history → spread floors out; 70 ms is far beyond 2σ of that baseline. + let r = VitalBands.band(value: 70, history: Array(repeating: 35.0, count: 30), + populationRange: hrvPop, cfg: hrvCfg) + XCTAssertEqual(r.band, .outOfRange) + XCTAssertEqual(r.basis, .personal) + } + + func testPersonalJustInside2SigmaInRange() { + let hist: [Double?] = Array(repeating: 35.0, count: 30) + let state = Baselines.foldHistory(hist, cfg: hrvCfg) + // 1.99σ in σ-space (spread is abs-dev, 1.253×spread ≈ σ): strictly inside the 2σ gate. + let edge = state.baseline + 1.99 * 1.253 * state.spread + XCTAssertEqual(VitalBands.band(value: edge, history: hist, + populationRange: hrvPop, cfg: hrvCfg).band, .inRange) + } + + func testImplausibleValueAlwaysOutOfRangeEvenWithTrustedBaseline() { + // hrv cfg bounds are 5-250: 300 ms is implausible regardless of personal spread, + // so the absolute outer guard fires and basis stays population. + let r = VitalBands.band(value: 300, history: Array(repeating: 35.0, count: 30), + populationRange: hrvPop, cfg: hrvCfg) + XCTAssertEqual(r.band, .outOfRange) + XCTAssertEqual(r.basis, .population) + } + + func testNilCfgSpo2StaysPopulationOnly() { + // A nil cfg (SpO₂) disables the personal path: an absolute <95% floor always applies. + let r = VitalBands.band(value: 93, history: [], populationRange: 95...100, cfg: nil) + XCTAssertEqual(r.band, .outOfRange) + XCTAssertEqual(r.basis, .population) + } + + func testNilNightsDoNotCountTowardTrust() { + // 13 valid nights then 10 trailing skips: only 13 valid → provisional, still population. + let hist: [Double?] = Array(repeating: 35.0, count: 13) + Array(repeating: nil, count: 10) + let r = VitalBands.band(value: 35, history: hist, populationRange: hrvPop, cfg: hrvCfg) + XCTAssertEqual(r.basis, .population) + } + + func testStaleBaselineFallsBackToPopulation() { + // 20 valid nights then 20 missing (> staleDays = 14): status stale → population fallback. + let hist: [Double?] = Array(repeating: 35.0, count: 20) + Array(repeating: nil, count: 20) + let r = VitalBands.band(value: 35, history: hist, populationRange: hrvPop, cfg: hrvCfg) + XCTAssertEqual(r.basis, .population) + } + + func testSkinTempHistoryPartitionsMixedSemantics() { + // 34.1/33.8 are absolute °C; 0.2/-0.1 are deviations. Each displayed kind keeps only its own. + let mixed: [Double?] = [34.1, 0.2, nil, 33.8, -0.1] + XCTAssertEqual(VitalBands.skinTempHistory(matching: 0.3, in: mixed), [nil, 0.2, nil, nil, -0.1]) + XCTAssertEqual(VitalBands.skinTempHistory(matching: 34.0, in: mixed), [34.1, nil, nil, 33.8, nil]) + } + + func testCalendarSeriesPadsMissingDays() { + let rows: [(day: String, value: Double?)] = [("2026-06-01", 50.0), ("2026-06-04", 52.0)] + XCTAssertEqual(VitalBands.calendarSeries(rows), [50.0, nil, nil, 52.0]) + } + + func testCalendarSeriesDropsMalformedKeysEmptyIsEmpty() { + XCTAssertEqual(VitalBands.calendarSeries([]), []) + let rows: [(day: String, value: Double?)] = [("not-a-date", 1.0), ("2026-06-01", 50.0)] + XCTAssertEqual(VitalBands.calendarSeries(rows), [50.0]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/VitalityEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/VitalityEngineTests.swift new file mode 100644 index 0000000000..2553876dce --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/VitalityEngineTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import StrandAnalytics + +final class VitalityEngineTests: XCTestCase { + + /// An average-for-their-age person nets ~0 hazard → Body Age == chronological age, Vitality 50. + func testAveragePersonReadsAtTheirAge() { + let r = VitalityEngine.compute(.init( + chronoAge: 40, restingHR: 65, vo2max: 45, expectedVO2max: 45, + sleepHours: 7.5, sleepConsistency: 0.75, rmssd: 45, rmssdNorm: 45, steps: 7000))! + XCTAssertEqual(r.bodyAge, 40, accuracy: 0.01) + XCTAssertEqual(r.vitality, 50, accuracy: 0.01) + XCTAssertEqual(r.deltaYears, 0, accuracy: 0.01) + XCTAssertEqual(r.factorsUsed, 6) + } + + /// A clearly healthy person reads younger + higher vitality (hand-computed Δage ≈ −7.58). + func testHealthyPersonIsYounger() { + let r = VitalityEngine.compute(.init( + chronoAge: 40, restingHR: 52, vo2max: 55.5, expectedVO2max: 45, + sleepHours: 7.5, sleepConsistency: 0.9, rmssd: 54, rmssdNorm: 45, steps: 11000))! + XCTAssertEqual(r.bodyAge, 32.42, accuracy: 0.1) + XCTAssertEqual(r.vitality, 68.95, accuracy: 0.2) + XCTAssertGreaterThan(r.deltaYears, 0) // younger than chrono age + } + + /// A clearly unhealthy person reads older + lower vitality (hand-computed Δage ≈ +9.71). + func testUnhealthyPersonIsOlder() { + let r = VitalityEngine.compute(.init( + chronoAge: 40, restingHR: 80, vo2max: 34.5, expectedVO2max: 45, + sleepHours: 5.5, sleepConsistency: 0.5, rmssd: 31.5, rmssdNorm: 45, steps: 3000))! + XCTAssertEqual(r.bodyAge, 49.71, accuracy: 0.1) + XCTAssertEqual(r.vitality, 25.73, accuracy: 0.2) + XCTAssertLessThan(r.deltaYears, 0) // older than chrono age + } + + /// Below the minimum-factor honesty gate → nil (don't show a number on too little data). + func testNilBelowMinFactors() { + XCTAssertNil(VitalityEngine.compute(.init(chronoAge: 40, restingHR: 65, sleepHours: 7.5))) // 2 factors + XCTAssertNotNil(VitalityEngine.compute(.init(chronoAge: 40, restingHR: 65, sleepHours: 7.5, + sleepConsistency: 0.75))) // 3 factors + } + + /// Body Age + Vitality stay within their clamped ranges at the extremes. + func testClamps() { + let young = VitalityEngine.compute(.init( + chronoAge: 22, restingHR: 40, vo2max: 70, expectedVO2max: 40, + sleepHours: 7.5, sleepConsistency: 1.0, rmssd: 90, rmssdNorm: 45, steps: 11000))! + XCTAssertGreaterThanOrEqual(young.bodyAge, VitalityEngine.minBodyAge) + XCTAssertLessThanOrEqual(young.vitality, 100) + XCTAssertGreaterThanOrEqual(young.vitality, 0) + + let old = VitalityEngine.compute(.init( + chronoAge: 85, restingHR: 110, vo2max: 12, expectedVO2max: 35, + sleepHours: 3, sleepConsistency: 0.1, rmssd: 8, rmssdNorm: 30, steps: 200))! + XCTAssertLessThanOrEqual(old.bodyAge, VitalityEngine.maxBodyAge) + XCTAssertGreaterThanOrEqual(old.vitality, 0) + } + + func testRmssdNormByAge() { + XCTAssertEqual(VitalityEngine.rmssdNorm(forAge: 20), 47, accuracy: 0.01) + XCTAssertEqual(VitalityEngine.rmssdNorm(forAge: 40), 33, accuracy: 0.01) + XCTAssertEqual(VitalityEngine.rmssdNorm(forAge: 45), 31, accuracy: 0.01) // halfway 33→29 + XCTAssertEqual(VitalityEngine.rmssdNorm(forAge: 90), 20, accuracy: 0.01) // clamps to last anchor + } + + func testSleepConsistency() { + XCTAssertEqual(VitalityEngine.sleepConsistency(nightlyHours: [7, 7, 7, 7])!, 1.0, accuracy: 1e-9) + XCTAssertEqual(VitalityEngine.sleepConsistency(nightlyHours: [6, 8, 6, 8])!, 0.857, accuracy: 0.005) + XCTAssertNil(VitalityEngine.sleepConsistency(nightlyHours: [7, 7])) // < 3 nights + } + + /// Contributions carry the right sign: a low resting HR is protective (negative), a high one ages you. + func testContributionSigns() { + let lowRHR = VitalityEngine.contributions(.init(chronoAge: 40, restingHR: 50)) + .first { $0.key == "rhr" }! + XCTAssertLessThan(lowRHR.lnHazard, 0) + let highRHR = VitalityEngine.contributions(.init(chronoAge: 40, restingHR: 85)) + .first { $0.key == "rhr" }! + XCTAssertGreaterThan(highRHR.lnHazard, 0) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WatchRecoveryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WatchRecoveryTests.swift new file mode 100644 index 0000000000..c7f1f221f6 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WatchRecoveryTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import StrandAnalytics + +/// Tests for `WatchRecovery`, the honesty-critical recovery-from-daily-aggregate engine behind +/// "Apple Watch as a device". The watch gives sparse daily SDNN + resting HR rather than the +/// strap's dense RR stream, so these fixtures pin the BEHAVIOUR (at-baseline ≈ mid, high-HRV / +/// low-RHR → high, thin history / missing today → nil + calibrating) regardless of the exact +/// logistic constants, which are inherited unchanged from `RecoveryScorer` (the strap Charge +/// engine) so watch recovery and strap recovery sit on the same scale. +final class WatchRecoveryTests: XCTestCase { + + // A person whose HRV today equals their baseline and RHR equals baseline → mid recovery, + // solid confidence (14 nights of history clears the trusted gate). + func testAtBaselineGivesMidRecoverySolid() { + let hist = Array(repeating: 45.0, count: 14) // 14 nights of SDNN + let rhrHist = Array(repeating: 52.0, count: 14) + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52, + sdnnHistory: hist, rhrHistory: rhrHist) + XCTAssertNotNil(out.recovery) + XCTAssertGreaterThanOrEqual(out.recovery!, 40) + XCTAssertLessThanOrEqual(out.recovery!, 60) + XCTAssertEqual(out.confidence, .solid) + } + + // HRV well above baseline + RHR below baseline → high recovery. + func testHighHRVLowRHRGivesHighRecovery() { + let hist = Array(repeating: 45.0, count: 14) + let rhrHist = Array(repeating: 52.0, count: 14) + let out = WatchRecovery.compute(todaySDNN: 70.0, todayRHR: 46, + sdnnHistory: hist, rhrHistory: rhrHist) + XCTAssertNotNil(out.recovery) + XCTAssertGreaterThan(out.recovery!, 65) + } + + // HRV well below baseline + RHR above baseline → low recovery (the symmetric case; + // a bad night must read low, not get floored at mid). + func testLowHRVHighRHRGivesLowRecovery() { + let hist = Array(repeating: 45.0, count: 14) + let rhrHist = Array(repeating: 52.0, count: 14) + let out = WatchRecovery.compute(todaySDNN: 22.0, todayRHR: 62, + sdnnHistory: hist, rhrHistory: rhrHist) + XCTAssertNotNil(out.recovery) + XCTAssertLessThan(out.recovery!, 40) + } + + // Too little history → calibrating, nil recovery (never a fabricated number). + func testInsufficientHistoryCalibrates() { + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52, + sdnnHistory: [45, 46], rhrHistory: [52, 51]) + XCTAssertNil(out.recovery) + XCTAssertEqual(out.confidence, .calibrating) + } + + // History just under the week gate → still calibrating (the gate is exactly minBaselineNights). + func testHistoryJustBelowGateCalibrates() { + let n = WatchRecovery.minBaselineNights - 1 + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52, + sdnnHistory: Array(repeating: 45.0, count: n), + rhrHistory: Array(repeating: 52.0, count: n)) + XCTAssertNil(out.recovery) + XCTAssertEqual(out.confidence, .calibrating) + } + + // History at the week gate (and usable baseline) → scores, no longer calibrating. + func testHistoryAtGateScores() { + let n = WatchRecovery.minBaselineNights + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52, + sdnnHistory: Array(repeating: 45.0, count: n), + rhrHistory: Array(repeating: 52.0, count: n)) + XCTAssertNotNil(out.recovery) + XCTAssertNotEqual(out.confidence, .calibrating) + } + + // Missing today's HRV → calibrating, nil (we never score off RHR alone). + func testMissingTodayCalibrates() { + let out = WatchRecovery.compute(todaySDNN: nil, todayRHR: 52, + sdnnHistory: Array(repeating: 45.0, count: 14), + rhrHistory: Array(repeating: 52.0, count: 14)) + XCTAssertNil(out.recovery) + XCTAssertEqual(out.confidence, .calibrating) + } + + // Missing today's RHR (but HRV present + baseline usable) → still scores off HRV alone, + // honestly, rather than nil-ing out. RHR is an optional term. + func testMissingTodayRHRStillScoresFromHRV() { + let hist = Array(repeating: 45.0, count: 14) + let rhrHist = Array(repeating: 52.0, count: 14) + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil, + sdnnHistory: hist, rhrHistory: rhrHist) + XCTAssertNotNil(out.recovery) + // At-baseline HRV with the RHR term dropped should still land near the mid band. + XCTAssertGreaterThanOrEqual(out.recovery!, 40) + XCTAssertLessThanOrEqual(out.recovery!, 70) + } + + // Watch recovery is on the SAME scale as strap recovery: feeding identical at-baseline inputs + // to RecoveryScorer directly (HRV + RHR terms only) reproduces WatchRecovery's number. + func testSameScaleAsStrapRecovery() { + let hist = Array(repeating: 45.0, count: 14) + let rhrHist = Array(repeating: 52.0, count: 14) + let out = WatchRecovery.compute(todaySDNN: 58.0, todayRHR: 50, + sdnnHistory: hist, rhrHistory: rhrHist) + let hrvBase = Baselines.foldHistory(hist.map { Optional($0) }, cfg: Baselines.hrvCfg) + let rhrBase = Baselines.foldHistory(rhrHist.map { Optional($0) }, cfg: Baselines.restingHRCfg) + let strap = RecoveryScorer.recovery(hrv: 58.0, rhr: 50.0, resp: nil, + hrvBaseline: hrvBase, rhrBaseline: rhrBase, + respBaseline: nil, sleepPerf: nil) + XCTAssertNotNil(out.recovery) + XCTAssertNotNil(strap) + XCTAssertEqual(out.recovery!, strap!, accuracy: 0.0001) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WeeklyDigestTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WeeklyDigestTests.swift new file mode 100644 index 0000000000..d825cda58b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WeeklyDigestTests.swift @@ -0,0 +1,359 @@ +import XCTest +@testable import StrandAnalytics + +final class WeeklyDigestTests: XCTestCase { + + // MARK: - Pure week math + + func testMondayOfWeek() { + // 2026-06-13 is a Saturday → its Monday is 2026-06-08. + XCTAssertEqual(WeeklyDigestEngine.mondayOfWeek(containing: "2026-06-13"), "2026-06-08") + // 2026-06-08 is itself a Monday → unchanged. + XCTAssertEqual(WeeklyDigestEngine.mondayOfWeek(containing: "2026-06-08"), "2026-06-08") + // 2026-06-14 is a Sunday → still the same Monday. + XCTAssertEqual(WeeklyDigestEngine.mondayOfWeek(containing: "2026-06-14"), "2026-06-08") + } + + func testWeekdaySakamoto() { + // 0=Sun … 6=Sat. 2026-06-08 = Monday(1), 2026-06-13 = Saturday(6), 2026-06-14 = Sunday(0). + XCTAssertEqual(WeeklyDigestEngine.weekday(2026, 6, 8), 1) + XCTAssertEqual(WeeklyDigestEngine.weekday(2026, 6, 13), 6) + XCTAssertEqual(WeeklyDigestEngine.weekday(2026, 6, 14), 0) + // A classic anchor: 2000-01-01 was a Saturday. + XCTAssertEqual(WeeklyDigestEngine.weekday(2000, 1, 1), 6) + } + + func testAddDaysCrossesMonthAndYear() { + XCTAssertEqual(WeeklyDigestEngine.addDays("2026-06-08", -1), "2026-06-07") + XCTAssertEqual(WeeklyDigestEngine.addDays("2026-06-08", -7), "2026-06-01") + XCTAssertEqual(WeeklyDigestEngine.addDays("2026-06-08", -8), "2026-05-31") // month rollback + XCTAssertEqual(WeeklyDigestEngine.addDays("2026-01-01", -1), "2025-12-31") // year rollback + XCTAssertEqual(WeeklyDigestEngine.addDays("2026-06-08", 6), "2026-06-14") + } + + func testAddDaysLeapYear() { + // 2024 is a leap year → Feb 29 exists. + XCTAssertEqual(WeeklyDigestEngine.addDays("2024-02-28", 1), "2024-02-29") + XCTAssertEqual(WeeklyDigestEngine.addDays("2024-02-29", 1), "2024-03-01") + // 2026 is not → Feb has 28 days. + XCTAssertEqual(WeeklyDigestEngine.addDays("2026-02-28", 1), "2026-03-01") + XCTAssertNil(WeeklyDigestEngine.parseYMD("2026-02-29")) // not a real date + } + + func testBadAnchorGivesEmptyDigest() { + let d = WeeklyDigestEngine.build(byMetric: [:], anchorDay: "not-a-date") + XCTAssertTrue(d.isEmpty) + XCTAssertEqual(d.daysWithData, 0) + XCTAssertEqual(d.metrics.count, WeeklyMetric.allCases.count) + XCTAssertEqual(d.balance, .insufficient) + XCTAssertTrue(d.focalPoints.isEmpty) + } + + // MARK: - Window split (golden fixture) + + /// Build a fixture where Charge is exactly 70 every day THIS week (Mon 2026-06-08 → + /// Sun 2026-06-14) and exactly 60 every day LAST week. Anchor = Saturday of this week. + func testWeekSplitAndWoW() { + var charge: [String: Double] = [:] + // This week: Mon..Sun = 70. + for d in 8...14 { charge[String(format: "2026-06-%02d", d)] = 70 } + // Last week: Mon 2026-06-01 .. Sun 2026-06-07 = 60. + for d in 1...7 { charge[String(format: "2026-06-%02d", d)] = 60 } + + let digest = WeeklyDigestEngine.build(byMetric: [.charge: charge], anchorDay: "2026-06-13") + XCTAssertEqual(digest.weekStart, "2026-06-08") + XCTAssertEqual(digest.weekEnd, "2026-06-14") + XCTAssertEqual(digest.daysWithData, 7) + + let c = digest.summary(.charge)! + XCTAssertEqual(c.thisWeek.n, 7) + XCTAssertEqual(c.thisWeek.mean, 70.0, accuracy: 1e-9) + XCTAssertEqual(c.weekOverWeek.previous.n, 7) + XCTAssertEqual(c.weekOverWeek.previous.mean, 60.0, accuracy: 1e-9) + XCTAssertEqual(c.wowDelta, 10.0, accuracy: 1e-9) // 70 − 60 + XCTAssertEqual(c.weekOverWeek.pctChange!, 100.0 / 6.0, accuracy: 1e-6) // 10/60 + XCTAssertEqual(c.wowGoodness, 1) // Charge up → good + } + + func testDaysOutsideTheWeekAreIgnored() { + var charge: [String: Double] = [:] + for d in 8...14 { charge[String(format: "2026-06-%02d", d)] = 70 } + charge["2026-06-15"] = 999 // next Monday — must NOT be in this week + charge["2026-06-07"] = 999 // last Sunday — must NOT be in this week + let c = WeeklyDigestEngine.build(byMetric: [.charge: charge], anchorDay: "2026-06-10").summary(.charge)! + XCTAssertEqual(c.thisWeek.n, 7) + XCTAssertEqual(c.thisWeek.max, 70.0, accuracy: 1e-9) // the 999s were excluded + } + + // MARK: - vs-baseline + + func testVsBaselineUsesFourPriorWeeks() { + var hrv: [String: Double] = [:] + // This week (Mon 06-08 .. Sun 06-14): mean 60. + for d in 8...14 { hrv[String(format: "2026-06-%02d", d)] = 60 } + // Last week (06-01 .. 06-07): mean 55 (not part of baseline). + for d in 1...7 { hrv[String(format: "2026-06-%02d", d)] = 55 } + // Baseline = the 4 complete weeks BEFORE last week: 2026-05-04 .. 2026-05-31, all 50. + for day in WeeklyDigestTests.daysBetween("2026-05-04", "2026-05-31") { hrv[day] = 50 } + + let h = WeeklyDigestEngine.build(byMetric: [.hrv: hrv], anchorDay: "2026-06-13").summary(.hrv)! + XCTAssertEqual(h.baselineMean!, 50.0, accuracy: 1e-9) + XCTAssertEqual(h.vsBaseline!, 10.0, accuracy: 1e-9) // 60 − 50 + } + + // MARK: - Sleep consistency + + func testSleepConsistencyIsSDOfRest() { + // Rest values 80,82,84,86,88,90,92 → sample SD ≈ 4.3205. + var rest: [String: Double] = [:] + let vals = [80.0, 82, 84, 86, 88, 90, 92] + for (i, v) in vals.enumerated() { rest[String(format: "2026-06-%02d", 8 + i)] = v } + let d = WeeklyDigestEngine.build(byMetric: [.rest: rest], anchorDay: "2026-06-10") + XCTAssertNotNil(d.sleepConsistencySD) + XCTAssertEqual(d.sleepConsistencySD!, 4.320493798938574, accuracy: 1e-9) + } + + func testSleepConsistencyNilWithOneNight() { + let rest: [String: Double] = ["2026-06-08": 85] + let d = WeeklyDigestEngine.build(byMetric: [.rest: rest], anchorDay: "2026-06-10") + XCTAssertNil(d.sleepConsistencySD) // < 2 nights → no consistency read + } + + // MARK: - Balance read + + func testBalanceOverreaching() { + // Effort mean 80, Charge mean 50 → gap +30 > band → overreaching. + var effort: [String: Double] = [:], charge: [String: Double] = [:] + for d in 8...14 { effort[String(format: "2026-06-%02d", d)] = 80; charge[String(format: "2026-06-%02d", d)] = 50 } + let d = WeeklyDigestEngine.build(byMetric: [.effort: effort, .charge: charge], anchorDay: "2026-06-10") + XCTAssertEqual(d.balance, .overreaching) + } + + func testBalanceUnderloaded() { + var effort: [String: Double] = [:], charge: [String: Double] = [:] + for d in 8...14 { effort[String(format: "2026-06-%02d", d)] = 40; charge[String(format: "2026-06-%02d", d)] = 75 } + let d = WeeklyDigestEngine.build(byMetric: [.effort: effort, .charge: charge], anchorDay: "2026-06-10") + XCTAssertEqual(d.balance, .underloaded) + } + + func testBalanceBalanced() { + var effort: [String: Double] = [:], charge: [String: Double] = [:] + for d in 8...14 { effort[String(format: "2026-06-%02d", d)] = 55; charge[String(format: "2026-06-%02d", d)] = 60 } + let d = WeeklyDigestEngine.build(byMetric: [.effort: effort, .charge: charge], anchorDay: "2026-06-10") + XCTAssertEqual(d.balance, .balanced) + } + + func testBalanceInsufficientWithTooFewDays() { + // Only 2 days each side → below minDaysForFocus (3). + var effort: [String: Double] = [:], charge: [String: Double] = [:] + for d in 8...9 { effort[String(format: "2026-06-%02d", d)] = 80; charge[String(format: "2026-06-%02d", d)] = 50 } + let d = WeeklyDigestEngine.build(byMetric: [.effort: effort, .charge: charge], anchorDay: "2026-06-10") + XCTAssertEqual(d.balance, .insufficient) + } + + // MARK: - Focal points + + func testFocalPointSurfacesBiggestMover() { + // Charge up big this week, last week flat-low; both weeks fully populated. + var charge: [String: Double] = [:] + for d in 8...14 { charge[String(format: "2026-06-%02d", d)] = 80 } // this week + for d in 1...7 { charge[String(format: "2026-06-%02d", d)] = 55 } // last week + let d = WeeklyDigestEngine.build(byMetric: [.charge: charge], anchorDay: "2026-06-13") + XCTAssertFalse(d.focalPoints.isEmpty) + let top = d.focalPoints[0] + XCTAssertTrue(top.contains("Charge"), "Expected Charge in: \(top)") + XCTAssertTrue(top.contains("up"), "Expected an upward move in: \(top)") + XCTAssertTrue(top.contains("good sign"), "Charge rising should read positively: \(top)") + } + + func testRestingHRRiseReadsAsWorthALook() { + // RHR up week over week → higherIsBetter == false → "worth a look". + var rhr: [String: Double] = [:] + for d in 8...14 { rhr[String(format: "2026-06-%02d", d)] = 60 } // this week + for d in 1...7 { rhr[String(format: "2026-06-%02d", d)] = 52 } // last week + let d = WeeklyDigestEngine.build(byMetric: [.rhr: rhr], anchorDay: "2026-06-13") + let line = d.focalPoints.first ?? "" + XCTAssertTrue(line.contains("Resting HR"), "Expected RHR mover: \(line)") + XCTAssertTrue(line.contains("worth a look"), "RHR rising should read as a caution: \(line)") + } + + func testSteadyWeekGivesCalmLine() { + // Identical values both weeks → no mover, balanced → a single steady line. + var charge: [String: Double] = [:], effort: [String: Double] = [:], rest: [String: Double] = [:] + for d in 1...14 { + let key = String(format: "2026-06-%02d", d) + charge[key] = 65; effort[key] = 63; rest[key] = 84 + } + let d = WeeklyDigestEngine.build(byMetric: [.charge: charge, .effort: effort, .rest: rest], + anchorDay: "2026-06-13") + XCTAssertEqual(d.focalPoints.count, 1) + XCTAssertTrue(d.focalPoints[0].lowercased().contains("steady"), + "Expected a steady-week line: \(d.focalPoints[0])") + } + + func testSparseWeekSaysTooEarlyNotSteady() { + // Current week has only 2 days, with a big raw drop vs a full previous week — the + // per-metric chips would show a large %, but 2 days can't anchor a week-over-week + // trend. The summary must defer ("too early") rather than claim a steady week with + // nothing moved, which would contradict the chips (#463). + var charge: [String: Double] = [:] + for d in 1...7 { charge[String(format: "2026-06-%02d", d)] = 70 } // last week, full + charge["2026-06-08"] = 40 // this week, day 1 + charge["2026-06-09"] = 40 // this week, day 2 + let d = WeeklyDigestEngine.build(byMetric: [.charge: charge], anchorDay: "2026-06-09") + XCTAssertEqual(d.summary(.charge)!.thisWeek.n, 2) // sparse current week + XCTAssertEqual(d.focalPoints.count, 1) + let line = d.focalPoints[0] + XCTAssertTrue(line.contains("too early"), "Sparse week should defer the call: \(line)") + XCTAssertTrue(line.contains("2 days"), "Should name the day count: \(line)") + XCTAssertFalse(line.lowercased().contains("steady"), + "Must NOT claim a steady week on 2 days: \(line)") + } + + func testSparsePreviousWeekSaysRoughNotSteady() { + // The mirror image of the sparse-current case (typical new user in week 2): the + // CURRENT week has plenty of days, but LAST week only had 2 — movers are gated on + // previous.n ≥ minDaysForFocus, so nothing surfaces, while the chips show a big raw % + // off those 2 days. The summary must call the comparison rough rather than claim a + // steady week with nothing moved (#463, the residual half). + var charge: [String: Double] = [:] + charge["2026-06-06"] = 70 // last week, day 1 + charge["2026-06-07"] = 74 // last week, day 2 + for d in 8...12 { charge[String(format: "2026-06-%02d", d)] = 41 } // this week, 5 days + let d = WeeklyDigestEngine.build(byMetric: [.charge: charge], anchorDay: "2026-06-12") + XCTAssertEqual(d.summary(.charge)!.thisWeek.n, 5) // full-enough current week + XCTAssertEqual(d.summary(.charge)!.weekOverWeek.previous.n, 2) // sparse previous week + XCTAssertEqual(d.focalPoints.count, 1) + let line = d.focalPoints[0] + XCTAssertTrue(line.contains("Last week"), "Should point at last week: \(line)") + XCTAssertTrue(line.contains("rough"), "Should call the comparison rough: \(line)") + XCTAssertTrue(line.contains("2 days"), "Should name the day count: \(line)") + XCTAssertFalse(line.lowercased().contains("steady"), + "Must NOT claim a steady week against 2 days: \(line)") + XCTAssertFalse(line.contains("too early"), + "The current week is fine; the honesty aims at last week: \(line)") + } + + // MARK: - Rough-comparison flag (chips must not imply confidence on a sparse side) + + func testIsRoughComparisonFlagsSparseSides() { + // Sparse CURRENT week (2 days) vs full previous → rough. + var charge: [String: Double] = [:] + for d in 1...7 { charge[String(format: "2026-06-%02d", d)] = 70 } + charge["2026-06-08"] = 40; charge["2026-06-09"] = 40 + let sparseCurrent = WeeklyDigestEngine.build(byMetric: [.charge: charge], anchorDay: "2026-06-09") + XCTAssertTrue(sparseCurrent.summary(.charge)!.isRoughComparison) + + // Sparse PREVIOUS week (2 days) vs full-enough current → rough. + var rest: [String: Double] = [:] + rest["2026-06-06"] = 80; rest["2026-06-07"] = 82 + for d in 8...12 { rest[String(format: "2026-06-%02d", d)] = 85 } + let sparsePrevious = WeeklyDigestEngine.build(byMetric: [.rest: rest], anchorDay: "2026-06-12") + XCTAssertTrue(sparsePrevious.summary(.rest)!.isRoughComparison) + + // Both weeks fully populated → a real comparison, not rough. + var hrv: [String: Double] = [:] + for d in 1...14 { hrv[String(format: "2026-06-%02d", d)] = 60 } + let full = WeeklyDigestEngine.build(byMetric: [.hrv: hrv], anchorDay: "2026-06-12") + XCTAssertFalse(full.summary(.hrv)!.isRoughComparison) + + // No previous week at all → there is no comparison to call rough. + var rhr: [String: Double] = [:] + rhr["2026-06-08"] = 55; rhr["2026-06-09"] = 56 + let noPrevious = WeeklyDigestEngine.build(byMetric: [.rhr: rhr], anchorDay: "2026-06-09") + XCTAssertFalse(noPrevious.summary(.rhr)!.isRoughComparison) + } + + // MARK: - Effort display factor (the #268 scale toggle reaches the prose) + + func testEffortDisplayFactorDefaultIsByteIdentical() { + // factor 1.0 (and the omitted default) must not change a single character of + // any focal point — this pins every existing sentence for existing callers. + var effort: [String: Double] = [:], charge: [String: Double] = [:] + for d in 8...14 { let k = String(format: "2026-06-%02d", d); effort[k] = 25; charge[k] = 60 } + for d in 1...7 { let k = String(format: "2026-06-%02d", d); effort[k] = 35; charge[k] = 60 } + let input: [WeeklyMetric: [String: Double]] = [.effort: effort, .charge: charge] + let implicitDefault = WeeklyDigestEngine.build(byMetric: input, anchorDay: "2026-06-13") + let explicitOne = WeeklyDigestEngine.build(byMetric: input, anchorDay: "2026-06-13", + effortDisplayFactor: 1.0) + XCTAssertEqual(implicitDefault, explicitOne) + XCTAssertTrue(implicitDefault.focalPoints[0].contains("(avg 25 vs 35)"), + "Stored-scale averages at factor 1.0: \(implicitDefault.focalPoints[0])") + } + + func testEffortDisplayFactorRescalesEffortAveragesOnly() { + // Effort mover 25 vs 35 (stored 0–100). On the 0–21 scale (factor 0.21) the prose + // averages must read 5 vs 7 while the % (scale-invariant) stays 29%. + var effort: [String: Double] = [:] + for d in 8...14 { effort[String(format: "2026-06-%02d", d)] = 25 } + for d in 1...7 { effort[String(format: "2026-06-%02d", d)] = 35 } + let scaled = WeeklyDigestEngine.build(byMetric: [.effort: effort], anchorDay: "2026-06-13", + effortDisplayFactor: 0.21) + let line = scaled.focalPoints[0] + XCTAssertTrue(line.contains("(avg 5 vs 7)"), "0–21 averages expected: \(line)") + XCTAssertTrue(line.contains("29%"), "Percent change is scale-invariant: \(line)") + + // A non-Effort mover is untouched by the factor. + var chargeOnly: [String: Double] = [:] + for d in 8...14 { chargeOnly[String(format: "2026-06-%02d", d)] = 80 } + for d in 1...7 { chargeOnly[String(format: "2026-06-%02d", d)] = 55 } + let input: [WeeklyMetric: [String: Double]] = [.charge: chargeOnly] + let a = WeeklyDigestEngine.build(byMetric: input, anchorDay: "2026-06-13") + let b = WeeklyDigestEngine.build(byMetric: input, anchorDay: "2026-06-13", + effortDisplayFactor: 0.21) + XCTAssertEqual(a.focalPoints, b.focalPoints) + XCTAssertTrue(b.focalPoints[0].contains("(avg 80 vs 55)"), "Charge stays stored-scale: \(b.focalPoints[0])") + } + + func testEffortDisplayFactorRescalesPtsFallback() { + // Previous Effort mean is 0 → pctChange is nil → the "pts" fallback renders, and it + // must be in display units too (10 stored pts × 0.21 = 2.1 pts on the 0–21 scale). + var effort: [String: Double] = [:] + for d in 8...14 { effort[String(format: "2026-06-%02d", d)] = 10 } + for d in 1...7 { effort[String(format: "2026-06-%02d", d)] = 0 } + let d = WeeklyDigestEngine.build(byMetric: [.effort: effort], anchorDay: "2026-06-13", + effortDisplayFactor: 0.21) + let line = d.focalPoints[0] + XCTAssertTrue(line.contains("2.1 pts"), "pts fallback should be display-scaled: \(line)") + XCTAssertTrue(line.contains("(avg 2 vs 0)"), "Averages display-scaled: \(line)") + } + + func testFocalPointsCappedAtTwo() { + // Several big movers + a non-trivial balance → still ≤ 2 lines. + var charge: [String: Double] = [:], effort: [String: Double] = [:], hrv: [String: Double] = [:] + for d in 8...14 { let k = String(format: "2026-06-%02d", d); charge[k] = 85; effort[k] = 30; hrv[k] = 75 } + for d in 1...7 { let k = String(format: "2026-06-%02d", d); charge[k] = 50; effort[k] = 70; hrv[k] = 45 } + let d = WeeklyDigestEngine.build(byMetric: [.charge: charge, .effort: effort, .hrv: hrv], + anchorDay: "2026-06-13") + XCTAssertLessThanOrEqual(d.focalPoints.count, 2) + XCTAssertGreaterThanOrEqual(d.focalPoints.count, 1) + } + + // MARK: - Determinism + + func testDeterministicAcrossRuns() { + var charge: [String: Double] = [:], hrv: [String: Double] = [:] + for d in 1...14 { + let k = String(format: "2026-06-%02d", d) + charge[k] = Double((d * 7) % 40 + 50) + hrv[k] = Double((d * 13) % 30 + 45) + } + let a = WeeklyDigestEngine.build(byMetric: [.charge: charge, .hrv: hrv], anchorDay: "2026-06-13") + let b = WeeklyDigestEngine.build(byMetric: [.charge: charge, .hrv: hrv], anchorDay: "2026-06-13") + XCTAssertEqual(a, b) // same input → byte-identical digest + } + + // MARK: - Helpers + + /// Inclusive list of "yyyy-MM-dd" days from `start` to `end`, using the engine's own + /// pure date math (so the fixture and the code agree on the calendar). + private static func daysBetween(_ start: String, _ end: String) -> [String] { + var out: [String] = [] + var cur = start + while cur <= end { + out.append(cur) + cur = WeeklyDigestEngine.addDays(cur, 1) + } + return out + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WorkoutDetectorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WorkoutDetectorTests.swift index c9048037c9..dd50e1e827 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WorkoutDetectorTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WorkoutDetectorTests.swift @@ -138,4 +138,83 @@ final class WorkoutDetectorTests: XCTestCase { // age 30 → hrmax 187, zone math available → z2+ fraction ≈ 0 < 0.50 → rejected. XCTAssertTrue(WorkoutDetector.detect(hr: hr, gravity: grav, age: 30).isEmpty) } + + // MARK: - Sustained-effort fragmentation (#303) + + /// A long endurance bout (e.g. a road bike ride) that dips momentarily every few + /// minutes — coasting downhill, a junction, a brief sensor gap — so that motion + /// falls below threshold for a `dipS`-long stretch on a `cadenceS` cadence. HR + /// stays elevated throughout (you don't actually rest). Helper returns a full day + /// with the ride embedded in rest, plus the true ride span for assertions. + private func longRideWithDips( + rideStart: Int, rideDur: Int, cadenceS: Int, dipS: Int + ) -> (hr: [HRSample], grav: [GravitySample]) { + var hr: [HRSample] = [] + var grav: [GravitySample] = [] + let dayStart = rideStart - 30 * 60 + let dayEnd = rideStart + rideDur + 30 * 60 + for t in dayStart..= rideStart && t < rideStart + rideDur + // Coasting dip: the last `dipS` seconds of every `cadenceS`-second cycle. + let phaseInCycle = (t - rideStart) % cadenceS + let coasting = inRide && phaseInCycle >= cadenceS - dipS + // HR stays high the whole ride (a real dip in cadence ≠ a dip in HR). + hr.append(HRSample(ts: t, bpm: inRide ? 150 : 52)) + if inRide && !coasting { + let osc = Double((t - rideStart) % 2) * 0.5 // pedalling → moving + grav.append(GravitySample(ts: t, x: osc, y: 0, z: 1)) + } else { + grav.append(GravitySample(ts: t, x: 0, y: 0, z: 1)) // still / coasting + } + } + return (hr, grav) + } + + func testLongRideWithBriefDipsIsOneWorkout() { + // ~4 h ride (matches the issue: 13:00–16:52) with a ~2-min coasting dip every + // ~8 min. Each dip exceeds the OLD 150 s merge gap, so it used to shatter the + // ride into ~30 sub-5-min slivers, most of which were then dropped by the + // minimum-duration filter — surfacing as a handful of tiny "workouts". + let start = 9_000_000 + let rideDur = 232 * 60 // 3 h 52 m + let (hr, grav) = longRideWithDips( + rideStart: start, rideDur: rideDur, cadenceS: 8 * 60, dipS: 180) + let sessions = WorkoutDetector.detect(hr: hr, gravity: grav, age: 30) + + // One ride → one workout, spanning ~the whole ride (not a pile of fragments). + XCTAssertEqual(sessions.count, 1, "sustained ride fragmented into \(sessions.count) workouts") + let w = sessions[0] + XCTAssertGreaterThan(w.durationS, Double(rideDur) * 0.9, + "merged ride too short: \(Int(w.durationS))s of \(rideDur)s") + XCTAssertEqual(w.avgHR, 150, accuracy: 2.0) + } + + func testGenuinelySeparateWorkoutsStaySeparate() { + // Two real workouts separated by a long genuine rest (HR drops to resting and + // motion stops for ~25 min) must NOT be merged by the bridge. Guards against + // the fix over-merging unrelated sessions. + let startA = 10_000_000 + let durA = 20 * 60 + let restGap = 25 * 60 // 25 min true rest, well beyond the bridge + let startB = startA + durA + restGap + let durB = 20 * 60 + var hr: [HRSample] = [] + var grav: [GravitySample] = [] + let dayStart = startA - 30 * 60 + let dayEnd = startB + durB + 30 * 60 + for t in dayStart..= startA && t < startA + durA + let inB = t >= startB && t < startB + durB + let active = inA || inB + hr.append(HRSample(ts: t, bpm: active ? 160 : 52)) + if active { + let osc = Double(t % 2) * 0.5 + grav.append(GravitySample(ts: t, x: osc, y: 0, z: 1)) + } else { + grav.append(GravitySample(ts: t, x: 0, y: 0, z: 1)) + } + } + let sessions = WorkoutDetector.detect(hr: hr, gravity: grav, age: 30) + XCTAssertEqual(sessions.count, 2, "separate workouts were over-merged") + } } diff --git a/Packages/StrandDesign/Package.swift b/Packages/StrandDesign/Package.swift index 2502944ac6..1278aa42ec 100644 --- a/Packages/StrandDesign/Package.swift +++ b/Packages/StrandDesign/Package.swift @@ -3,11 +3,16 @@ import PackageDescription let package = Package( name: "StrandDesign", - platforms: [.macOS(.v13), .iOS(.v16)], + // The package carries its OWN string catalog (Sources/StrandDesign/Resources/Localizable.xcstrings). + // defaultLocalization is what makes SPM build the localized resource bundle at all; every + // String(localized:) in the package passes `bundle: .module` so lookups hit that catalog instead of + // silently falling back to the host app's main bundle (where package-only keys do not exist). + defaultLocalization: "en", + platforms: [.macOS(.v13), .iOS(.v16), .watchOS(.v10)], products: [.library(name: "StrandDesign", targets: ["StrandDesign"])], dependencies: [], targets: [ - .target(name: "StrandDesign"), + .target(name: "StrandDesign", resources: [.process("Resources")]), .testTarget(name: "StrandDesignTests", dependencies: ["StrandDesign"]), ] ) diff --git a/Packages/StrandDesign/Sources/StrandDesign/AppBackground.swift b/Packages/StrandDesign/Sources/StrandDesign/AppBackground.swift new file mode 100644 index 0000000000..954cd62018 --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/AppBackground.swift @@ -0,0 +1,86 @@ +import SwiftUI + +// MARK: - App Background +// +// The single full-screen backdrop behind every screen. Replaces the old day-cycle background +// systems (LiquidSky's twinkling sky, SceneScreenBackground's hour-picked illustrations, +// TimeOfDayBackground's night stars/moon) — all of which read the system clock and repainted +// themselves as the day went on. This is deliberately the opposite: no clock, no TimelineView, +// no per-frame cost, and the same look every time, morning or night. +// +// Light: a soft pink / purple / baby-blue gradient mesh, full-bleed. Dark: unchanged flat +// `surfaceBase`, so dark mode's look is untouched. + +public struct AppBackground: View { + @Environment(\.colorScheme) private var scheme + + public init() {} + + public var body: some View { + Group { + if scheme == .light { + MeshBackgroundLight() + } else { + StrandPalette.surfaceBase + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +/// A static pink/purple/baby-blue gradient mesh: several large, soft-edged colour blobs layered over +/// a pale canvas so they blend into one continuous field rather than a single vignette. Pure geometry — +/// no animation, no time-of-day input, so it never changes between renders. +private struct MeshBackgroundLight: View { + var body: some View { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let span = max(w, h) + ZStack { + Color(hex: "#FBFAFC") // pale neutral canvas so the blob seams never show raw white + + blob(hex: "#F7D9EC", at: UnitPoint(x: 0.06, y: 0.04), radius: span * 0.78) // pink — top-left + blob(hex: "#DCD1F5", at: UnitPoint(x: 0.94, y: 0.08), radius: span * 0.72) // purple — top-right + blob(hex: "#CDE7F6", at: UnitPoint(x: 0.08, y: 0.98), radius: span * 0.80) // baby blue — bottom-left + blob(hex: "#E7D6F2", at: UnitPoint(x: 0.92, y: 0.94), radius: span * 0.74) // lilac — bottom-right + blob(hex: "#F0E3F4", at: UnitPoint(x: 0.50, y: 0.46), radius: span * 0.55) // soft centre wash + } + .frame(width: w, height: h) + } + } + + @ViewBuilder + private func blob(hex: String, at point: UnitPoint, radius: CGFloat) -> some View { + RadialGradient( + colors: [Color(hex: hex).opacity(0.85), Color(hex: hex).opacity(0)], + center: point, startRadius: 0, endRadius: radius + ) + } +} + +#if DEBUG +#Preview("App Background — light") { + ZStack { + AppBackground() + Text("88") + .font(.system(size: 60, weight: .bold, design: .rounded)) + .foregroundStyle(StrandPalette.textPrimary) + } + .frame(width: 380, height: 760) + .preferredColorScheme(.light) +} + +#Preview("App Background — dark") { + ZStack { + AppBackground() + Text("88") + .font(.system(size: 60, weight: .bold, design: .rounded)) + .foregroundStyle(StrandPalette.textPrimary) + } + .frame(width: 380, height: 760) + .preferredColorScheme(.dark) +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/Appearance.swift b/Packages/StrandDesign/Sources/StrandDesign/Appearance.swift new file mode 100644 index 0000000000..2956ea979f --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/Appearance.swift @@ -0,0 +1,127 @@ +import SwiftUI + +/// The data-visualisation colour style: the brand "Titanium & Gold" data ramps, or a "Classic" +/// throwback — the recognizable red → amber → green readiness scale (cool→hot zones, green→red stress, +/// purple REM) that health apps have always used. Works in BOTH light and dark. It only re-colours the +/// DATA encodings (gauge rings, charts, sparklines, scales, stage bands) — never the chrome/surfaces. +/// +/// Read globally via `StrandPalette.chartStyle` (set from `@AppStorage(ChartStyle.storageKey)` at the +/// app root); the data-ramp accessors in `StrandPalette` branch on it. The app root keys its content on +/// the raw value so a flip re-renders the visible charts live. +public enum ChartStyle: String, CaseIterable, Identifiable, Sendable { + case titanium // brand: gold recovery, amber strain, blue rest + case classic // throwback: red→green recovery, cool→hot zones, green→red stress + + public var id: String { rawValue } + public static let storageKey = "chart.style" + + public var label: String { + switch self { + case .titanium: return String(localized: "Default", bundle: .module) + case .classic: return String(localized: "Classic", bundle: .module) + } + } + + public static func resolve(_ raw: String) -> ChartStyle { ChartStyle(rawValue: raw) ?? .titanium } +} + +/// Applies the chart style: sets the global `StrandPalette.chartStyle` (read by the data-ramp +/// accessors) AND keys the content on the raw value so a flip re-renders the visible charts. The +/// global is set during body evaluation, before the keyed content renders, so the new ramps are live +/// on the rebuild. Apply at each app root: `.chartStyle(chartStyleRaw)`. +public extension View { + func chartStyle(_ raw: String) -> some View { + StrandPalette.chartStyle = ChartStyle.resolve(raw) + return self.id("noop.chartStyle.\(raw)") + } +} + +/// The user's appearance preference for the whole app. Persisted via +/// `@AppStorage(AppearanceMode.storageKey)`. `.system` follows the OS (the default); +/// `.light` / `.dark` force a scheme regardless of the system setting. +/// +/// Applied once at each app root via `.preferredColorScheme(mode.colorScheme)`. Because every +/// `StrandPalette` token is a dynamic `Color(light:dark:)`, flipping this re-resolves the entire +/// UI automatically — no per-view plumbing. +public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { + case system + case light + case dark + + public var id: String { rawValue } + + /// The @AppStorage key shared by the app roots and the Settings picker. + public static let storageKey = "theme.appearance" + + /// Human label for the Settings control. + public var label: String { + switch self { + case .system: return String(localized: "System", bundle: .module) + case .light: return String(localized: "Light", bundle: .module) + case .dark: return String(localized: "Dark", bundle: .module) + } + } + + /// SF Symbol for the Settings control. + public var symbol: String { + switch self { + case .system: return "circle.lefthalf.filled" + case .light: return "sun.max" + case .dark: return "moon.stars" + } + } + + /// The `ColorScheme` to force, or `nil` to follow the system (the `.system` case). + public var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + + /// Resolve a stored raw value (tolerant of an unknown/missing value → `.system`). + public static func resolve(_ raw: String) -> AppearanceMode { + AppearanceMode(rawValue: raw) ?? .system + } +} + +// MARK: - Light-idiom helpers + +/// An additive glow (ring blooms, sparkline heads, hero halos) only reads on a DARK canvas — +/// `.plusLighter` blending on white produces no visible glow and just muddies edges. On dark this +/// applies the additive blend; on light it hides the layer. Self-contained (reads the scheme itself) +/// so every glow becomes a one-token swap from `.blendMode(.plusLighter)` → `.additiveBloom()`. +private struct AdditiveBloom: ViewModifier { + @Environment(\.colorScheme) private var scheme + func body(content: Content) -> some View { + // Dialed back (0.55) — the full-strength additive bloom read as too much glow against the + // crisper design language. Still present on dark for depth, just restrained. + if scheme == .dark { content.blendMode(.plusLighter).opacity(0.55) } + else { content.opacity(0) } + } +} + +/// Card / floating-surface elevation. Dark separates surfaces by a lighter FILL (no resting shadow); +/// light separates white-on-paper by a soft DROP SHADOW. Reads the scheme itself and deepens on hover. +private struct NoopElevation: ViewModifier { + @Environment(\.colorScheme) private var scheme + var hovering: Bool + func body(content: Content) -> some View { + let lightShadow = Color(hex: "#1A2230") + return content.shadow( + color: scheme == .light ? lightShadow.opacity(hovering ? 0.16 : 0.09) + : Color.black.opacity(hovering ? 0.45 : 0.0), + radius: scheme == .light ? (hovering ? 14 : 10) : (hovering ? 18 : 0), + x: 0, y: scheme == .light ? (hovering ? 5 : 3) : (hovering ? 8 : 0) + ) + } +} + +public extension View { + /// Apply the additive glow only on dark; hide it on light. See `AdditiveBloom`. + func additiveBloom() -> some View { modifier(AdditiveBloom()) } + + /// Apply the per-scheme card/surface elevation (shadow on light, lighter-fill idiom on dark). + func noopElevation(hovering: Bool = false) -> some View { modifier(NoopElevation(hovering: hovering)) } +} diff --git a/Packages/StrandDesign/Sources/StrandDesign/BevelGauge.swift b/Packages/StrandDesign/Sources/StrandDesign/BevelGauge.swift new file mode 100644 index 0000000000..8dca61c91a --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/BevelGauge.swift @@ -0,0 +1,217 @@ +import SwiftUI + +// MARK: - BevelGauge (NEW) — the layered ring gauge primitive +// +// The shared instrument behind RecoveryRing and StrainGauge: a 240° open gauge with +// • a soft frosted inner disc (subtle radial fill, hairline rim) +// • a faint full-span track ring carved from `surfaceInset` (the Titanium "well") +// • a gradient-stroked progress arc (AngularGradient over the domain ramp: +// Charge=green, Effort=blue, Rest=slate-blue — caller-supplied score tokens; WHOOP, no gold) +// • a clean end-cap dot at the arc tip (small white core, very faint shadow) — NO outer bloom +// • a centred SF Pro **Rounded** bold number with an "of N" caption + state word +// +// It owns no domain logic — callers pass the fraction, the stroke gradient, the tip +// colour, and the centre read-out strings. RecoveryRing / StrainGauge keep their own +// public init signatures and delegate their visuals here, so every screen re-skins +// without any call-site change. + +public struct BevelGauge: View { + + /// Fill fraction 0...1 of the 240° span. + public var fraction: Double + /// Angular gradient stops for the progress arc (the domain ramp). + public var stops: [Gradient.Stop] + /// Colour of the glowing end-cap + state word (usually the ramp sampled at `fraction`). + public var tipColor: Color + /// Big centred number, already formatted (e.g. "87" or "12.4"). + public var numberText: String + /// Small caption under the number (e.g. "of 100" / "of 21"). nil hides it. + public var captionText: String? + /// State word above/below the number (e.g. "PRIMED"). nil hides it. + public var stateText: String? + /// Optional supporting line under the read-out. + public var supporting: String? + public var diameter: CGFloat + public var lineWidth: CGFloat + public var showsLabel: Bool + /// Animated draw-in fraction supplied by the caller (so it owns the @State + animation). + public var animatedFraction: Double + /// Whether the bloom is at full (vs resting) intensity — caller drives the breathe pulse. + public var bloomActive: Bool + + public init( + fraction: Double, + stops: [Gradient.Stop], + tipColor: Color, + numberText: String, + captionText: String? = nil, + stateText: String? = nil, + supporting: String? = nil, + diameter: CGFloat = 200, + lineWidth: CGFloat = 16, + showsLabel: Bool = true, + animatedFraction: Double, + bloomActive: Bool = true + ) { + self.fraction = fraction + self.stops = stops + self.tipColor = tipColor + self.numberText = numberText + self.captionText = captionText + self.stateText = stateText + self.supporting = supporting + self.diameter = diameter + self.lineWidth = lineWidth + self.showsLabel = showsLabel + self.animatedFraction = animatedFraction + self.bloomActive = bloomActive + } + + private let arcSpanDegrees: Double = 240 + private var startAngle: Angle { .degrees(150) } + private var endAngle: Angle { .degrees(150 + arcSpanDegrees) } + + private var gradient: Gradient { Gradient(stops: stops) } + + public var body: some View { + ZStack { + // STATIC BACKDROP: the frosted inner disc + the faint full-span track. Neither depends on + // `animatedFraction`, so SwiftUI/CoreAnimation already caches it as an unchanged layer and + // does NOT re-render it when only the arc animates. No .drawingGroup() — a per-instance + // offscreen flatten cost more than it saved (it was part of the v7.0.2 lag regression). + staticBackdrop + .frame(width: diameter, height: diameter) + + // LIVE LAYER: the gradient progress arc + end-cap, kept OUTSIDE the drawingGroup so the + // shape's `animatableData` still animates smoothly (a drawingGroup would freeze it). + animatedArc + + if showsLabel { centerLabel } + } + .frame(width: diameter, height: diameter) + } + + /// The non-animating backdrop: frosted disc behind the arc + the faint full-span track "well". + private var staticBackdrop: some View { + ZStack { + innerDisc + // Faint full-span track — the inset "well" the score arc sits in. + arcShape(to: 1.0) + .stroke(StrandPalette.surfaceInset, + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + } + } + + /// The live layer: the filled gradient arc + its clean end-cap dot (both driven by animatedFraction). + private var animatedArc: some View { + ZStack { + // Filled gradient arc. + arcShape(to: animatedFraction) + .stroke( + AngularGradient(gradient: gradient, center: .center, + startAngle: startAngle, endAngle: endAngle), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + + // Clean end-cap dot at the arc tip. + if animatedFraction > 0.001 { endCap } + } + } + + // Frosted inner disc behind the arc — gives the gauge a glassy "well". + private var innerDisc: some View { + Circle() + .fill( + RadialGradient( + colors: [StrandPalette.surfaceInset.opacity(0.0), StrandPalette.surfaceInset.opacity(0.55)], + center: .center, startRadius: diameter * 0.10, endRadius: diameter * 0.5 + ) + ) + .overlay(Circle().strokeBorder(StrandPalette.hairline.opacity(0.5), lineWidth: 1)) + .padding(lineWidth * 1.4) + } + + // Design Reset (WHOOP): NO outer bloom. Fill-contrast carries the arc edge, so the ring reads as a + // clean, crisp Material instrument rather than a skeuomorphic glow. `bloomActive` stays in the + // signature (callers still pass it) but no longer renders. The track + disc now live in + // `staticBackdrop` and the filled arc + tip in `animatedArc` (see `body`). + + private var endCap: some View { + GeometryReader { geo in + let radius = (min(geo.size.width, geo.size.height) - lineWidth) / 2 + let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) + let tipAngle = startAngle.radians + (arcSpanDegrees * .pi / 180) * animatedFraction + let pt = CGPoint(x: center.x + radius * cos(tipAngle), + y: center.y + radius * sin(tipAngle)) + // Clean Material tip: a single small solid dot at the arc end. The large + // blurred halo is gone; only a very faint shadow keeps it from looking pasted on. + Circle().fill(StrandPalette.tipCore) + .frame(width: lineWidth * 0.7, height: lineWidth * 0.7) + .overlay(Circle().fill(tipColor).opacity(0.35)) + .shadow(color: tipColor.opacity(0.35), radius: lineWidth * 0.18) + .position(pt) + } + } + + private var centerLabel: some View { + VStack(spacing: 2) { + Text(numberText) + .font(StrandFont.rounded(diameter * 0.30, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .contentTransition(.numericText()) + if let captionText { + Text(captionText) + .font(StrandFont.rounded(diameter * 0.085, weight: .medium)) + .foregroundStyle(StrandPalette.textTertiary) + } + if let stateText { + // Scale the state word WITH the gauge, like the number (0.30·d) and caption (0.085·d). + // `min(11, …)` pins it to the original 11pt overline on the large solo-hero rings (≥130pt) + // — byte-identical there — and shrinks it on the small three-up rings, where a fixed 11pt + // word overflowed the arc and collided with the number/caption. Uses a *scaled overline* + // (not rounded()) so Dynamic-Type text-scaling is preserved. Thanks @claypilat (#403). + let stateSize = min(11, diameter * 0.085) + Text(stateText) + .font(StrandFont.overlineScaled(stateSize)) + .tracking(StrandFont.overlineTracking * stateSize / 11) + .foregroundStyle(tipColor) + .padding(.top, 2) + } + if let supporting { + Text(supporting) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: diameter * 0.78) + .padding(.top, 2) + } + } + } + + private func arcShape(to fraction: Double) -> RecoveryArc { + RecoveryArc(startAngle: startAngle, spanDegrees: arcSpanDegrees, + fraction: fraction, lineWidth: lineWidth) + } +} + +#if DEBUG +#Preview("BevelGauge") { + HStack(spacing: 24) { + BevelGauge( + fraction: 0.78, stops: StrandPalette.recoveryStops, + tipColor: StrandPalette.recoveryColor(78), numberText: "78", + captionText: "of 100", stateText: "PRIMED", + diameter: 200, animatedFraction: 0.78 + ) + BevelGauge( + fraction: 0.55, stops: StrandPalette.strainStops, + tipColor: StrandPalette.strainColor(55), numberText: "11.6", + captionText: "of 21", stateText: "MODERATE", + diameter: 200, animatedFraction: 0.55 + ) + } + .padding(40) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/BrandMark.swift b/Packages/StrandDesign/Sources/StrandDesign/BrandMark.swift new file mode 100644 index 0000000000..b93b60f8f2 --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/BrandMark.swift @@ -0,0 +1,135 @@ +import SwiftUI + +// MARK: - BrandMark — the NOOP logo mark (Titanium & Gold) +// +// The app's identity glyph, rendered natively for use as a hero on onboarding, +// "about", and empty states. Per the design handoff ("Engraved" app-icon +// direction + the brand glyph spec): +// +// • a circular DEEP-NAVY tile (Circle filled with the navy ramp, a faint top +// sheen, and a 1px hairline rim), over which sits +// • an OPEN GOLD recovery ring — an ~80% arc starting at 12 o'clock (-90°) and +// sweeping clockwise, stroked with the gold ramp and round-capped (a THICK +// stroke to match the app icon), and +// • a solid GOLD CORE DOT centred ("on-device core"). +// +// Gold-on-navy, matching the app icon (the maintainer's brand direction, 2026-06-15). +// +// It reads as the "O" in NOOP and as a small echo of the hero recovery ring. +// CLEAN and flat by design: no bloom, no shadow, no glow — the titanium does the +// depth via its gradient + sheen, the gold ring does the accent. Everything is +// driven off a single `size`, so the mark stays crisp from a 28pt list avatar up +// to a 120pt onboarding hero. + +public struct BrandMark: View { + + /// Edge length of the square mark; everything scales from this. + public var size: CGFloat + + public init(size: CGFloat = 120) { + self.size = size + } + + // The open ring sweeps ~80% of a full turn (≈291° of 364, per the logo spec), + // starting at 12 o'clock and going clockwise — the same orientation as the + // hero recovery ring, so the two read as one family. + private let openFraction: Double = 0.80 + private var startAngle: Angle { .degrees(-90) } + + // Proportions derived from `size` so the mark is resolution-independent. + private var ringInset: CGFloat { size * 0.20 } // tile edge → ring band + private var ringWidth: CGFloat { size * 0.13 } // THICK gold stroke (matches the icon) + private var ringDiameter: CGFloat { size - ringInset * 2 } + private var coreDiameter: CGFloat { size * 0.18 } // centre core dot + private var rimWidth: CGFloat { max(1, size * 0.008) } // ~1px hairline rim + + public var body: some View { + ZStack { + navyTile + goldRing + coreDot + } + .frame(width: size, height: size) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("NOOP")) + .accessibilityAddTraits(.isImage) + } + + // MARK: Deep-navy tile + + /// The navy disc the gold mark sits on — a deep-navy vertical ramp (lifted at + /// the top, deeper at the bottom) with a faint cool top sheen and a soft + /// hairline rim, matching the app icon. No shadow — flat and clean. + private var navyTile: some View { + Circle() + .fill( + LinearGradient( + colors: [Color(hex: "#1A1E24"), Color(hex: "#0E1116")], + startPoint: .top, + endPoint: .bottom + ) + ) + // Faint cool top sheen — a soft light catch across the upper third (flat, no bloom). + .overlay( + Circle() + .fill( + LinearGradient( + colors: [Color(hex: "#2A2F37").opacity(0.5), .clear], + startPoint: .top, + endPoint: .center + ) + ) + .opacity(0.6) + ) + // 1px hairline rim so the disc reads cleanly on the navy canvas. + .overlay( + Circle().strokeBorder(StrandPalette.hairline, lineWidth: rimWidth) + ) + } + + // MARK: Open gold recovery ring + + /// The open ~80% gold arc — round-capped, stroked with the gold ramp via an + /// AngularGradient so the metal shifts along the sweep (light → gold → deep), + /// matching how the hero recovery ring fills. + private var goldRing: some View { + RecoveryArc( + startAngle: startAngle, + spanDegrees: 360 * openFraction, + fraction: 1, + lineWidth: ringWidth + ) + .stroke( + StrandPalette.chargeColor, + style: StrokeStyle(lineWidth: ringWidth, lineCap: .round) + ) + .frame(width: ringDiameter, height: ringDiameter) + } + + // MARK: Solid gold core + + /// The "on-device core" — a solid gold dot at the exact centre, completing the + /// open-ring + core-dot lock-up. + private var coreDot: some View { + Circle() + .fill(Color.white) + .frame(width: coreDiameter, height: coreDiameter) + } +} + +#if DEBUG +#Preview("BrandMark — sizes") { + VStack(spacing: 40) { + BrandMark(size: 120) + HStack(spacing: 28) { + BrandMark(size: 72) + BrandMark(size: 44) + BrandMark(size: 28) + } + } + .padding(48) + .frame(width: 420, height: 460) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift b/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift index 3dee28b143..1f9e5ee4cd 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift @@ -1,3 +1,6 @@ +#if !os(watchOS) +// The chart-hover toolkit (tooltips, crosshair, nearest-point) is for pointer/cursor charts the +// watch never shows; excluded on watchOS, iOS/macOS unchanged. import SwiftUI // MARK: - Chart Hover Toolkit (reusable across every visualization) @@ -24,6 +27,8 @@ public struct ChartTooltip: View { /// gradient colour for that datum) so the tooltip explains the colour. public var accent: Color? + @Environment(\.colorScheme) private var scheme + public init(value: String, label: String? = nil, accent: Color? = nil) { self.value = value self.label = label @@ -60,7 +65,8 @@ public struct ChartTooltip: View { RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(StrandPalette.hairlineStrong, lineWidth: 1) ) - .shadow(color: Color.black.opacity(0.45), radius: 10, x: 0, y: 6) + .shadow(color: scheme == .light ? Color(hex: "#1A2230").opacity(0.18) : Color.black.opacity(0.45), + radius: scheme == .light ? 8 : 10, x: 0, y: scheme == .light ? 4 : 6) .fixedSize() .accessibilityElement(children: .ignore) .accessibilityLabel(label != nil ? "\(value), \(label!)" : value) @@ -159,19 +165,32 @@ struct HighlightDot: View { var diameter: CGFloat = 9 var body: some View { + // Design Reset (WHOOP): a crisp solid dot with a clean surface ring, no blurred bloom halo. ZStack { - Circle() - .fill(color) - .frame(width: diameter * 1.8, height: diameter * 1.8) - .blur(radius: diameter * 0.6) - .opacity(0.7) - .blendMode(.plusLighter) Circle() .fill(StrandPalette.surfaceBase) - .frame(width: diameter, height: diameter) + .frame(width: diameter + 3, height: diameter + 3) Circle() .fill(color) - .frame(width: diameter - 3, height: diameter - 3) + .frame(width: diameter, height: diameter) + } + .allowsHitTesting(false) + } +} + +// MARK: - "Now" end-cap + +/// The crisp "now" marker pinned to a trend line's latest point: a soft tinted outer ring, a brighter +/// mid-ring, and a white core — flat, no bloom (WHOOP). Positioned by `TrendChart` inside its own plot +/// coordinate space so it sits exactly on the curve (#458). +struct NowCapDot: View { + var color: Color + + var body: some View { + ZStack { + Circle().fill(color.opacity(0.30)).frame(width: 18, height: 18) + Circle().fill(color.opacity(0.65)).frame(width: 11, height: 11) + Circle().fill(StrandPalette.tipCore).frame(width: 5, height: 5) } .allowsHitTesting(false) } @@ -194,7 +213,7 @@ struct PositionedTooltip: View { GeometryReader { g in Color.clear .onAppear { measured = g.size } - .onChange(of: g.size) { measured = $0 } + .onChangeCompat(of: g.size) { measured = $0 } } ) .position( @@ -222,3 +241,4 @@ struct PositionedTooltip: View { .preferredColorScheme(.dark) } #endif +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/Components.swift b/Packages/StrandDesign/Sources/StrandDesign/Components.swift index cfea1019b9..12b1f7af47 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Components.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Components.swift @@ -6,49 +6,137 @@ import SwiftUI // the uniform, instrument-grade look from the reference. Do not invent ad-hoc cards. public enum NoopMetrics { - public static let cardRadius: CGFloat = 16 - public static let cardPadding: CGFloat = 16 + public static let cardRadius: CGFloat = 22 // Apple x WHOOP rounded cards — matches the liquid home card (LiquidTodayView.card) // Apple x WHOOP: rounded cards + public static let cardPadding: CGFloat = 16 // Apple x WHOOP: roomier card interior public static let gap: CGFloat = 12 // gap between cards - public static let sectionGap: CGFloat = 28 // gap between sections - public static let screenPadding: CGFloat = 24 - public static let tileHeight: CGFloat = 104 // every metric tile is this tall + public static let sectionGap: CGFloat = 22 // Apple x WHOOP: breathing room (not cramped) + public static let screenPadding: CGFloat = 18 + public static let tileHeight: CGFloat = 96 // Design Reset: tighter metric tile + // Key Metrics grid: one fixed height every tile snaps to, so a sparkline-and-caption tile and a + // plain value tile read the same. maxHeight: .infinity can't equalise them inside a LazyVGrid (the + // grid only offers a cell its content height, so there's nothing for the shorter tile to grow into), + // so we pin a single height that clears the tallest layout (value + inline sparkline + caption). + public static let keyMetricTileHeight: CGFloat = 122 public static let chartHeight: CGFloat = 220 + public static let hypnogramBandMinThickness: CGFloat = 14 // floor so short stages read as bars, not ticks + public static let tabBarClearance: CGFloat = 76 // iOS: extra bottom scroll room so the last card clears the floating tab bar + + // MARK: Standardised spacing scale (the ONE source of truth for margins) + // + // A 4pt-based ramp. Reach for these instead of literal numbers so every gap, + // inset and margin lines up to the same grid. Note `cardPadding` (16) above is + // the same value as `space4` — kept as a named alias for the existing call sites. + public static let space1: CGFloat = 4 + public static let space2: CGFloat = 8 + public static let space3: CGFloat = 12 + public static let space4: CGFloat = 16 + public static let space5: CGFloat = 20 + public static let space6: CGFloat = 24 + public static let space8: CGFloat = 32 + public static let space10: CGFloat = 40 + + // MARK: Named layout constants — the canonical margins/heights screens compose with. + /// Horizontal page margin (the gutter on the left/right edge of a screen). Use via `.screenPadding()`. + public static let screenHPadding: CGFloat = 20 + /// Vertical gap between top-level page sections. + public static let sectionSpacing: CGFloat = 24 + /// Interior padding inside a card's content (matches `cardPadding`). + public static let cardInnerPadding: CGFloat = 16 + /// Vertical gap between stacked elements INSIDE a card. + public static let cardInnerSpacing: CGFloat = 12 + /// Vertical gap between rows in a list-style card. + public static let rowSpacing: CGFloat = 10 + /// Standard interactive-control height (buttons, fields, segmented controls). + public static let controlHeight: CGFloat = 48 + /// Fully-rounded corner radius — pills, chips, capsule buttons. + public static let pillRadius: CGFloat = 999 +} + +// MARK: - Screen padding + +public extension View { + /// Apply the canonical horizontal page gutter (`NoopMetrics.screenHPadding`). The single + /// source of truth for left/right screen margins — use this instead of a literal padding so + /// every screen lines up to the same edge. + func screenPadding() -> some View { + self.padding(.horizontal, NoopMetrics.screenHPadding) + } +} + +// MARK: - iOS sheet presentation idiom + +#if os(iOS) +public extension View { + /// The house iOS sheet idiom: the drag indicator (the touch affordance that says + /// "swipe to dismiss") plus detents. macOS sheets are free-floating windows and must + /// NOT receive this, so the helper is iOS-only and call sites stay shared via #if. + /// `largeFirst == false` opens at .medium with .large reachable by dragging up (short + /// forms); `true` opens full-height (long scrolls). + func noopSheetPresentation(largeFirst: Bool) -> some View { + self + .presentationDragIndicator(.visible) + .presentationDetents(largeFirst ? [.large] : [.medium, .large]) + } } +#endif // MARK: - Surface -/// The one card surface. All cards use this — same radius, border, fill. +/// The one card surface — now the Bevel frosted card. PUBLIC API is unchanged +/// (padding + content); an optional `tint` was ADDED (defaulted) so callers can opt +/// into a per-domain accent wash without breaking existing call sites. public struct NoopCard: View { private let padding: CGFloat + private let tint: Color? @ViewBuilder private let content: () -> Content + #if os(macOS) @State private var hover = false - public init(padding: CGFloat = NoopMetrics.cardPadding, @ViewBuilder content: @escaping () -> Content) { - self.padding = padding; self.content = content + #endif + public init(padding: CGFloat = NoopMetrics.cardPadding, tint: Color? = nil, @ViewBuilder content: @escaping () -> Content) { + self.padding = padding; self.tint = tint; self.content = content } public var body: some View { content() .padding(padding) .frame(maxWidth: .infinity, alignment: .leading) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) - .strokeBorder(hover ? StrandPalette.hairlineStrong : StrandPalette.hairline, lineWidth: 1)) - .shadow(color: .black.opacity(hover ? 0.25 : 0), radius: 10, y: 4) + // Hover chrome (fill + border + shadow) lives in the background so its animation is + // scoped to the card surface ONLY. It must never animate the content() subtree, or a + // chart inside re-animates its line every time the cursor crosses the card. (#104) + .background { cardSurface } + #if os(macOS) .onHover { hover = $0 } + #endif + } + + // Touch can't hover, so iOS renders only the static resting frosted surface — no + // hover @State, no .onHover tracking, no .animation node. That trims the modifier + // count on every card, which multiplies across long scrolling lists. macOS adds the + // hover emphasis border on top (with the #104 animation scoping) unchanged. + @ViewBuilder private var cardSurface: some View { + let shape = RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) + #if os(macOS) + FrostedCardSurface(tint: tint, cornerRadius: NoopMetrics.cardRadius) + .overlay( + shape.strokeBorder(StrandPalette.hairlineStrong, lineWidth: 1).opacity(hover ? 1 : 0) + ) .animation(.easeOut(duration: 0.16), value: hover) + #else + FrostedCardSurface(tint: tint, cornerRadius: NoopMetrics.cardRadius) + #endif } } // MARK: - Section header public struct SectionHeader: View { - let overline: String?; let title: String; let trailing: String? - public init(_ title: String, overline: String? = nil, trailing: String? = nil) { + let overline: LocalizedStringKey?; let title: LocalizedStringKey; let trailing: String? + public init(_ title: LocalizedStringKey, overline: LocalizedStringKey? = nil, trailing: String? = nil) { self.title = title; self.overline = overline; self.trailing = trailing } public var body: some View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { - if let overline { Text(overline.uppercased()).strandOverline() } + if let overline { Text(overline).strandOverline() } Text(title).font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary) } Spacer() @@ -61,68 +149,147 @@ public struct SectionHeader: View { // MARK: - Metric tile (UNIFORM fixed height) -public struct StatTile: View { - let label: String, value: String +public struct StatTile: View { + let label: LocalizedStringKey, value: String var caption: String? = nil var accent: Color = StrandPalette.textPrimary var delta: String? = nil var deltaColor: Color = StrandPalette.textTertiary var sparkline: [Double]? = nil var sparkColor: Color = StrandPalette.accent + /// An optional trailing accessory laid out INLINE in the header row beside the label (e.g. a small + /// ⓘ that opens a scoring guide). Inline placement — not a corner overlay — so it can never sit on + /// top of the value, sparkline or trend chip on a narrow tile (#495). Defaults to nothing. + @ViewBuilder var accessory: () -> Accessory - public init(label: String, value: String, caption: String? = nil, + public init(label: LocalizedStringKey, value: String, caption: String? = nil, accent: Color = StrandPalette.textPrimary, delta: String? = nil, deltaColor: Color = StrandPalette.textTertiary, - sparkline: [Double]? = nil, sparkColor: Color = StrandPalette.accent) { + sparkline: [Double]? = nil, sparkColor: Color = StrandPalette.accent, + @ViewBuilder accessory: @escaping () -> Accessory) { self.label = label; self.value = value; self.caption = caption; self.accent = accent self.delta = delta; self.deltaColor = deltaColor; self.sparkline = sparkline; self.sparkColor = sparkColor + self.accessory = accessory } public var body: some View { - NoopCard(padding: 14) { + // The tile borrows its accent as a faint card wash, so each metric tile reads as + // part of its colour world while staying legible on the deep blue-black. + NoopCard(padding: 14, tint: accent) { VStack(alignment: .leading, spacing: 0) { - Text(label.uppercased()).strandOverline() + // Header row: the metric label, and (right-aligned) the optional accessory laid out in + // flow so it reserves its own space rather than floating over the value below (#495). + HStack(alignment: .top, spacing: 4) { + Text(label).strandOverline() + Spacer(minLength: 0) + accessory() + } Spacer(minLength: 4) - Text(value).font(StrandFont.number(26)).foregroundStyle(accent).lineLimit(1).minimumScaleFactor(0.6) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(value).font(StrandFont.number(26)).foregroundStyle(accent).lineLimit(1).minimumScaleFactor(0.6) + Spacer(minLength: 0) + // Trend chip — the delta as a tinted pill with a direction arrow. + if let delta { TrendChip(text: delta, color: deltaColor) } + } + // Sparkline isn't available on watchOS (it relies on chart-hover helpers); the watch + // doesn't use StatTile, but guard the reference so the file still compiles there. + #if !os(watchOS) if let sparkline, sparkline.count > 1 { - Sparkline(values: sparkline).frame(height: 22).padding(.top, 4) + Sparkline(values: sparkline, gradient: Gradient(colors: [sparkColor.opacity(0.5), sparkColor])) + .frame(height: 22).padding(.top, 4) + .accessibilityHidden(true) } - HStack(spacing: 6) { - if let caption { Text(caption).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary).lineLimit(1) } - Spacer(minLength: 0) - if let delta { Text(delta).font(StrandFont.captionNumber).foregroundStyle(deltaColor) } + #endif + if let caption { + Text(caption).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary).lineLimit(1) + .padding(.top, 2) } - .padding(.top, 2) } } - .frame(height: NoopMetrics.tileHeight) + // A FLOOR, not a fixed height: a sparkline tile's content exceeds the 96pt base and must be + // allowed to grow rather than clip. maxHeight: .infinity lets a caller that DOES hand this tile a + // bounded height (e.g. the Key Metrics grid pins every cell to NoopMetrics.keyMetricTileHeight) + // stretch it to fill; in an unbounded parent it resolves to the content's own height, unchanged. + // Note: inside a LazyVGrid the cell only offers content height, so equal heights come from the + // caller pinning a fixed height, not from maxHeight: .infinity alone. + .frame(minHeight: NoopMetrics.tileHeight, maxHeight: .infinity) + // One VoiceOver stop per tile (label, value, caption, delta) instead of up + // to four fragmented stops; the decorative sparkline is hidden above. + .accessibilityElement(children: .combine) + } +} + +// Backward-compatible convenience: a StatTile with NO accessory (the common case) — every existing +// call site keeps working unchanged, and the type defaults `Accessory` to `EmptyView`. +public extension StatTile where Accessory == EmptyView { + init(label: LocalizedStringKey, value: String, caption: String? = nil, + accent: Color = StrandPalette.textPrimary, delta: String? = nil, + deltaColor: Color = StrandPalette.textTertiary, + sparkline: [Double]? = nil, sparkColor: Color = StrandPalette.accent) { + self.init(label: label, value: value, caption: caption, accent: accent, delta: delta, + deltaColor: deltaColor, sparkline: sparkline, sparkColor: sparkColor, + accessory: { EmptyView() }) + } +} + +// MARK: - Trend chip — a small tinted delta pill with a direction arrow. + +/// A compact trend pill: an up/down/flat arrow + the delta text, tinted to `color`. +/// Inferred direction comes from a leading +/− in the text (else flat). Sits in the +/// corner of a StatTile or beside a metric value. +public struct TrendChip: View { + let text: String + var color: Color = StrandPalette.textTertiary + public init(text: String, color: Color = StrandPalette.textTertiary) { + self.text = text; self.color = color + } + private var symbol: String? { + let t = text.trimmingCharacters(in: .whitespaces) + if t.hasPrefix("+") || t.hasPrefix("▲") || t.lowercased().hasPrefix("up") { return "arrow.up.right" } + if t.hasPrefix("-") || t.hasPrefix("−") || t.hasPrefix("▼") || t.lowercased().hasPrefix("down") { return "arrow.down.right" } + // No sign → a plain magnitude (e.g. a workout's "874 kcal"), not a trend: show NO direction + // glyph. Previously this fell to "minus", whose leading dash read as a negative ("-874 kcal" — #41). + return nil + } + public var body: some View { + HStack(spacing: 3) { + if let symbol { Image(systemName: symbol).font(.system(size: 8, weight: .bold)) } + // One line, always: a long chip (e.g. a workout's kcal) truncates rather than wraps, so + // the pill never grows a tile past its floor. Matches Android's unconditional ellipsize (#934). + Text(text).font(StrandFont.captionNumber).lineLimit(1) + } + .foregroundStyle(color) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(color.opacity(0.14), in: Capsule(style: .continuous)) + .accessibilityHidden(true) } } // MARK: - Chart card (UNIFORM: header + fixed chart body + footer) public struct ChartCard: View { - let title: String + let title: LocalizedStringKey var subtitle: String? = nil var trailing: String? = nil var height: CGFloat = NoopMetrics.chartHeight + var tint: Color? = nil @ViewBuilder let chart: () -> ChartBody @ViewBuilder let footer: () -> Footer - public init(title: String, subtitle: String? = nil, trailing: String? = nil, - height: CGFloat = NoopMetrics.chartHeight, + public init(title: LocalizedStringKey, subtitle: String? = nil, trailing: String? = nil, + height: CGFloat = NoopMetrics.chartHeight, tint: Color? = nil, @ViewBuilder chart: @escaping () -> ChartBody, @ViewBuilder footer: @escaping () -> Footer = { EmptyView() }) { self.title = title; self.subtitle = subtitle; self.trailing = trailing - self.height = height; self.chart = chart; self.footer = footer + self.height = height; self.tint = tint; self.chart = chart; self.footer = footer } public var body: some View { - NoopCard { + NoopCard(tint: tint) { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { - Text(title.uppercased()).strandOverline() + Text(title).strandOverline() if let subtitle { Text(subtitle).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) } } Spacer() @@ -141,13 +308,13 @@ public struct ChartCard: View { /// A footer row of small "label / value" stats for ChartCard. public struct ChartFooter: View { - let items: [(String, String)] - public init(_ items: [(String, String)]) { self.items = items } + let items: [(LocalizedStringKey, String)] + public init(_ items: [(LocalizedStringKey, String)]) { self.items = items } public var body: some View { HStack(spacing: 0) { ForEach(Array(items.enumerated()), id: \.offset) { _, it in VStack(alignment: .leading, spacing: 2) { - Text(it.0.uppercased()).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + Text(it.0).textCase(.uppercase).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) Text(it.1).font(StrandFont.captionNumber).foregroundStyle(StrandPalette.textSecondary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -159,16 +326,31 @@ public struct ChartFooter: View { // MARK: - Insight card public struct InsightCard: View { - let category: String, status: String, detail: String + let category: LocalizedStringKey, status: LocalizedStringKey, detail: LocalizedStringKey var statusColor: Color = StrandPalette.accent - public init(category: String, status: String, detail: String, statusColor: Color = StrandPalette.accent) { - self.category = category; self.status = status; self.detail = detail; self.statusColor = statusColor + var tint: Color? = nil + /// Extra trailing inset reserved on the overline + status rows so a caller's + /// `.overlay(alignment: .topTrailing)` (greeting + state pill) doesn't run over the + /// card's own title text on a narrow screen (#69). Defaults to 0 — no effect unless set. + var titleTrailingInset: CGFloat = 0 + public init(category: LocalizedStringKey, status: LocalizedStringKey, detail: LocalizedStringKey, statusColor: Color = StrandPalette.accent, tint: Color? = nil, titleTrailingInset: CGFloat = 0) { + self.category = category; self.status = status; self.detail = detail; self.statusColor = statusColor; self.tint = tint; self.titleTrailingInset = titleTrailingInset } public var body: some View { - NoopCard(padding: 18) { + // Defaults the card wash to the status colour so the coaching card sits in the + // same colour world as the score it summarises (e.g. gold for Charge). The + // insight card reads a touch stronger than a tile: an explicit hue wash + // (.14 → .04) + a matching .22 hue border on top of the frosted surface. + let hue = tint ?? statusColor + // Apple-flat: a plain flat card. Identity comes from the COLOURED status headline alone — no extra + // hue-gradient wash, no border (so it reads identical to every other card on the page). + return NoopCard(padding: 18, tint: hue) { VStack(alignment: .leading, spacing: 8) { - Text(category.uppercased()).strandOverline() - Text(status).font(StrandFont.title1).foregroundStyle(statusColor) + Text(category).strandOverline() + .padding(.trailing, titleTrailingInset) + Text(status).font(StrandFont.rounded(28, weight: .bold)).foregroundStyle(statusColor) + .fixedSize(horizontal: false, vertical: true) + .padding(.trailing, titleTrailingInset) Text(detail).font(StrandFont.subhead).foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) } @@ -181,23 +363,58 @@ public struct InsightCard: View { public struct SegmentedPillControl: View { let items: [T] let label: (T) -> String + /// Per-segment availability (#943): a disabled segment stays visible (so users learn the + /// option exists) but renders extra-dim and ignores taps; VoiceOver announces it dimmed. + /// Defaults to everything enabled; ADDED additively, no existing call site touched. + let isEnabled: (T) -> Bool @Binding var selection: T + @Environment(\.colorScheme) private var scheme public init(_ items: [T], selection: Binding, label: @escaping (T) -> String) { - self.items = items; self._selection = selection; self.label = label + self.init(items, selection: selection, isEnabled: { _ in true }, label: label) + } + public init(_ items: [T], selection: Binding, isEnabled: @escaping (T) -> Bool, + label: @escaping (T) -> String) { + self.items = items; self._selection = selection; self.isEnabled = isEnabled; self.label = label } public var body: some View { HStack(spacing: 4) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in let sel = item == selection - Button { withAnimation(StrandMotion.interactive) { selection = item } } label: { + let enabled = isEnabled(item) + Button { + guard selection != item else { return } // re-tapping the active segment stays silent + StrandHaptic.selection.play() + withAnimation(StrandMotion.interactive) { selection = item } + } label: { Text(label(item)) .font(StrandFont.captionNumber) - .foregroundStyle(sel ? StrandPalette.surfaceBase : StrandPalette.textSecondary) - .frame(minWidth: 32) - .padding(.vertical, 6).padding(.horizontal, 11) - .background(Capsule(style: .continuous).fill(sel ? StrandPalette.accent : Color.clear)) + // Active segment is SELECTION CHROME, so it follows the accent: on dark a + // gold-gradient pill with gold-deep ink; on light a flat blue accent pill with + // white ink (so the light theme's selection matches its blue chrome, not gold). + // Disabled segments drop to a fainter tertiary so the lock reads at a glance. + .foregroundStyle(sel ? (scheme == .light ? Color.white : StrandPalette.textPrimary) + : StrandPalette.textTertiary.opacity(enabled ? 1 : 0.35)) + // Fill the segment height so the selected pill has EQUAL margins to the track + // on every side. (The old compact pill inside a taller 44pt touch frame left + // more vertical margin than horizontal — it read as off-centre.) + .frame(minWidth: 26, maxHeight: .infinity) + .padding(.horizontal, 9) + .background( + // WHOOP selection chrome: a flat LIGHTER-grey pill on dark (white ink), a flat + // blue accent pill on light — no gold, no gradient. + Capsule(style: .continuous) + .fill(sel ? (scheme == .light + ? AnyShapeStyle(StrandPalette.accent) + : AnyShapeStyle(Color(hex: "#363B41"))) + : AnyShapeStyle(Color.clear)) + ) + .contentShape(Capsule(style: .continuous)) } .buttonStyle(.plain) + .frame(height: 32) // segment height; the pill fills it for an even inset + .disabled(!enabled) + // Announce the active range to VoiceOver and give a non-colour cue. + .accessibilityAddTraits(sel ? .isSelected : []) } } .padding(3) @@ -209,13 +426,222 @@ public struct SegmentedPillControl: View { // MARK: - Badges public struct SourceBadge: View { - let text: String; var tint: Color = StrandPalette.accent - public init(_ text: String, tint: Color = StrandPalette.accent) { self.text = text; self.tint = tint } + let text: LocalizedStringKey; var tint: Color = StrandPalette.accent + public init(_ text: LocalizedStringKey, tint: Color = StrandPalette.accent) { self.text = text; self.tint = tint } public var body: some View { - Text(text.uppercased()).font(.system(size: 10, weight: .semibold)).tracking(0.5) - .padding(.horizontal, 8).padding(.vertical, 3) - .background(tint.opacity(0.14), in: Capsule()) + Text(text).textCase(.uppercase).font(.system(size: 10, weight: .semibold, design: .rounded)).tracking(0.5) + .padding(.horizontal, 9).padding(.vertical, 3) + .background(tint.opacity(0.16), in: Capsule(style: .continuous)) .foregroundStyle(tint) - .overlay(Capsule().strokeBorder(tint.opacity(0.30), lineWidth: 1)) + .overlay(Capsule(style: .continuous).strokeBorder(tint.opacity(0.34), lineWidth: 1)) + } +} + +// MARK: - Numeric field helpers (iOS soft-keyboard) + +public extension View { + /// Configures a TextField for whole-number-or-decimal entry on iOS: the decimal-pad + /// keyboard (handles both integer Avg-HR and decimal calories). No-op on macOS + /// (hardware keyboard), so the SAME shared view compiles on both. Pair with + /// `.keyboardDoneToolbar(...)` on the enclosing view to add a Done button (the decimal + /// pad has no return key). + func numericKeyboard() -> some View { + #if os(iOS) + self.keyboardType(.decimalPad).textContentType(nil) + #else + self + #endif + } + + /// Adds a single trailing "Done" button to the software-keyboard accessory bar that + /// resigns the given focus binding. iOS-only; the keyboard toolbar is hosted by the + /// keyboard itself, so it works inside a sheet with no NavigationStack. No-op on macOS. + func keyboardDoneToolbar(_ focus: FocusState.Binding) -> some View { + #if os(iOS) + self.toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { focus.wrappedValue = nil } + .font(StrandFont.body) + .foregroundStyle(StrandPalette.accent) + } + } + #else + self + #endif + } +} + +// MARK: - Buttons (Titanium & Gold) — ADDED additively, no existing API touched. +// +// Three house button styles for primary actions, secondary chrome and ghost/gold +// CTAs. Drop in via `.buttonStyle(.noopPrimary)` etc. on any `Button`. All read off +// the new gold tokens so they match Apple ⇄ Android. Pressed = subtle dim + scale. + +/// Primary call-to-action: gold-gradient fill, dark gold-deep ink (700), rounded 13. +public struct NoopPrimaryButtonStyle: ButtonStyle { + public init() {} + public func makeBody(configuration: Configuration) -> some View { + let pressed = configuration.isPressed + return configuration.label + .font(StrandFont.body.weight(.bold)) + .foregroundStyle(StrandPalette.goldDeepText) + .padding(.vertical, 11).padding(.horizontal, 18) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill(LinearGradient(gradient: StrandPalette.goldGradient, startPoint: .topLeading, endPoint: .bottomTrailing)) + ) + // A crisp, subtle NEUTRAL elevation — the gold cast-glow read as too much against the + // clean design, so it's a soft dark lift now, no bloom. + .shadow(color: .black.opacity(pressed ? 0.08 : 0.16), radius: 6, x: 0, y: 3) + .opacity(pressed ? 0.9 : 1) + .scaleEffect(pressed ? 0.98 : 1) + .animation(StrandMotion.interactive, value: pressed) + .contentShape(Rectangle()) + } +} + +/// Secondary: inset well + 1px white-12 border + primary text. Quieter than gold. +public struct NoopSecondaryButtonStyle: ButtonStyle { + public init() {} + public func makeBody(configuration: Configuration) -> some View { + let pressed = configuration.isPressed + let shape = RoundedRectangle(cornerRadius: 13, style: .continuous) + return configuration.label + .font(StrandFont.body.weight(.semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .padding(.vertical, 11).padding(.horizontal, 18) + .frame(maxWidth: .infinity) + .background(shape.fill(StrandPalette.surfaceInset)) + .overlay(shape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) + .opacity(pressed ? 0.82 : 1) + .scaleEffect(pressed ? 0.98 : 1) + .animation(StrandMotion.interactive, value: pressed) + .contentShape(Rectangle()) + } +} + +/// Ghost / gold: transparent + 1px gold@.3 hairline + gold text. Tertiary CTA. +public struct NoopGhostButtonStyle: ButtonStyle { + public init() {} + public func makeBody(configuration: Configuration) -> some View { + let pressed = configuration.isPressed + let shape = RoundedRectangle(cornerRadius: 13, style: .continuous) + return configuration.label + .font(StrandFont.body.weight(.semibold)) + .foregroundStyle(StrandPalette.gold) + .padding(.vertical, 11).padding(.horizontal, 18) + .frame(maxWidth: .infinity) + .background(shape.fill(StrandPalette.gold.opacity(pressed ? 0.10 : 0))) + .overlay(shape.strokeBorder(StrandPalette.gold.opacity(0.3), lineWidth: 1)) + .scaleEffect(pressed ? 0.98 : 1) + .animation(StrandMotion.interactive, value: pressed) + .contentShape(Rectangle()) + } +} + +public extension ButtonStyle where Self == NoopPrimaryButtonStyle { + /// Gold-gradient primary CTA. + static var noopPrimary: NoopPrimaryButtonStyle { .init() } +} +public extension ButtonStyle where Self == NoopSecondaryButtonStyle { + /// Inset secondary button. + static var noopSecondary: NoopSecondaryButtonStyle { .init() } +} +public extension ButtonStyle where Self == NoopGhostButtonStyle { + /// Transparent gold-outline ghost button. + static var noopGhost: NoopGhostButtonStyle { .init() } +} + +// MARK: - Score state pill (SOLID / BUILDING / CALIBRATING / LIVE) +// +// ADDED additively — the existing `StatePill` (tone-based, in StatePill.swift) is +// untouched. This is the score-lifecycle chip the new design calls for: SOLID = gold +// fill, BUILDING = blue, CALIBRATING = slate, LIVE = gold dot with a pulsing halo. + +public enum ScoreState: Sendable, Equatable { + case solid // a settled, trustworthy score + case building // accruing nights, not yet settled + case calibrating // baseline still forming + case live // streaming right now + + /// The chip's hue, drawn from the re-pointed palette (gold / blue / slate). + public var color: Color { + switch self { + case .solid: return StrandPalette.statusPositive // settled / trustworthy — WHOOP green + case .live: return StrandPalette.accent // streaming now — WHOOP blue + case .building: return StrandPalette.sleepLight // #4A90E2 blue + case .calibrating: return StrandPalette.textTertiary // #8A94A4 slate + } + } + public var label: LocalizedStringKey { + switch self { + case .solid: return "Solid" + case .building: return "Building" + case .calibrating: return "Calibrating" + case .live: return "Live" + } + } + var pulsing: Bool { self == .live } +} + +/// The score-lifecycle chip: dot + hue@.12 fill + hue@.32 border + hue text. LIVE +/// pulses its dot. `text` overrides the default state label (e.g. "Building — 2 of 4"). +public struct ScoreStatePill: View { + public var state: ScoreState + public var text: LocalizedStringKey? + public init(_ state: ScoreState, text: LocalizedStringKey? = nil) { + self.state = state; self.text = text + } + public var body: some View { + let hue = state.color + return HStack(spacing: 6) { + PulseDot(color: hue, pulsing: state.pulsing, size: 7) + Text(text ?? state.label) + .font(StrandFont.overline) + .tracking(0.4) + .foregroundStyle(hue) + } + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Capsule(style: .continuous).fill(hue.opacity(0.12))) + .overlay(Capsule(style: .continuous).stroke(hue.opacity(0.32), lineWidth: 1)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(text ?? state.label) + } +} + +/// A small dot with an optional breathing pulse halo (LIVE). Honours Reduce Motion. +/// Local to the score pill so it doesn't disturb StatePill.swift's ConnectionDot. +private struct PulseDot: View { + var color: Color + var pulsing: Bool + var size: CGFloat + @State private var animate = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorScheme) private var scheme + var body: some View { + ZStack { + // Dark-mode only (#review): AdditiveBloom used to hide this expanding ring on light + // (content.opacity(0)); now that we drop the offscreen bloom, gate it explicitly so light + // mode stays ring-free (the resting dot + its shadow carry the live state there). + if pulsing && scheme == .dark { + Circle().fill(color) + .frame(width: size, height: size) + .scaleEffect(animate ? 2.4 : 1.0) + .opacity(animate ? 0.0 : 0.5) + // No .additiveBloom(): the .plusLighter blend forced an offscreen pass every + // frame of the repeatForever pulse, a continuous cost while a strap is backfilling + // (exactly when this live dot is on screen). The expanding/fading ring reads the + // same without it; the resting dot's shadow still carries the "live" glow. + } + Circle().fill(color) + .frame(width: size, height: size) + .shadow(color: color.opacity(0.8), radius: pulsing ? 4 : 2) + } + .frame(width: size, height: size) + .onAppear { if pulsing && !reduceMotion { animate = true } } + .animation(pulsing && !reduceMotion ? StrandMotion.breathe : nil, value: animate) + .accessibilityHidden(true) } } diff --git a/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift b/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift new file mode 100644 index 0000000000..71c5009dfd --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift @@ -0,0 +1,153 @@ +#if !os(watchOS) +// The watch app never shows the day navigator (no DatePicker(.graphical) / .popover on watchOS), +// so this whole control is excluded there; iOS/macOS are unchanged. +import SwiftUI + +// MARK: - DayNavBar — chevron + date-jump day selector +// +// The Today screen's day navigator: ◀/▶ chevrons step one day at a time (◀ older, ▶ newer, +// disabled at today so a future day can't be selected), and the centre accent block shows the +// selected day's label + date and opens a graphical DatePicker capped at today for a direct jump. +// Replaces the fixed three-day strip so navigation reaches arbitrarily far back. The same control +// renders on macOS and iOS — the DatePicker is shown in a popover on both. Mirrors the Android +// DayNavBar (StrandComponents.kt). Offset is days-back-from-today (0 = today). + +public struct DayNavBar: View { + private let selectedOffset: Int + private let today: Date + private let onSelect: (Int) -> Void + + @State private var showingPicker = false + + /// `today` is the caller's LOGICAL day (the same anchor the rest of Today uses, rolling at 04:00), + /// so every label here counts back from it. Passing it in instead of reading `Date()` keeps the + /// macOS full-date label in step with the data shown in the 00:00-04:00 window, where a raw + /// `Date()` already reads the next calendar day while the screen still shows the logical day (#14). + public init(selectedOffset: Int, today: Date, onSelect: @escaping (Int) -> Void) { + self.selectedOffset = selectedOffset + self.today = today + self.onSelect = onSelect + } + + /// The calendar day the current offset resolves to, counting back from the caller's logical day. + private var selectedDay: Date { + Calendar.current.date(byAdding: .day, value: -selectedOffset, to: today) ?? today + } + + private var canGoNewer: Bool { selectedOffset > 0 } + + private var label: LocalizedStringKey { + switch selectedOffset { + case 0: return "Today" + case 1: return "Yesterday" + default: return "\(Self.dayFmt.string(from: selectedDay))" + } + } + + public var body: some View { + HStack(spacing: 12) { + Spacer(minLength: 0) + Button { onSelect(selectedOffset + 1) } label: { + Image(systemName: "chevron.left") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.accent) + .frame(width: 44, height: 44) // ≥44pt hit target (HIG); glyph stays 17pt + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Previous day") + + // Centre accent block — the selected day's label + full date, tappable to jump. + Button { showingPicker = true } label: { + VStack(spacing: 2) { + Text(label) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + // On today the label already reads "Today"; the full date would just duplicate the + // header, so it's shown only once you've navigated to another day (for orientation). + if selectedOffset > 0 { + Text(Self.fullDateFmt.string(from: selectedDay)) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.accent) + .lineLimit(1) + } + } + .padding(.vertical, 9) + .padding(.horizontal, 20) + // Reads as one of the flat WHOOP-grey cards, not a black bar. On macOS the full-width + // pill sits over the bright Today day-scene, where the darker inset well read as black; + // surfaceRaised (the card fill) lifts it to card level so it matches the dashboard + // cards. No gold wash behind the date — the gold pop lives only on the date text. + .background(blockFill, in: blockShape) + .overlay(blockShape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) + } + .buttonStyle(.plain) + .accessibilityLabel("Pick a date") + .popover(isPresented: $showingPicker) { + datePickerPopover + } + + Button { if canGoNewer { onSelect(selectedOffset - 1) } } label: { + Image(systemName: "chevron.right") + .font(StrandFont.headline) + .foregroundStyle(canGoNewer ? StrandPalette.accent : StrandPalette.textTertiary) + .frame(width: 44, height: 44) // ≥44pt hit target (HIG); glyph stays 17pt + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canGoNewer) + .accessibilityLabel("Next day") + Spacer(minLength: 0) + } + } + + /// Graphical date jump, capped at today so a future day can't be picked. Converting the chosen + /// date back to a whole-day offset keeps the rest of the screen driven by the single offset value. + private var datePickerPopover: some View { + // A local binding so the picker writes straight through to an offset via onSelect. + let pickedBinding = Binding( + get: { selectedDay }, + set: { newValue in + let cal = Calendar.current + let start = cal.startOfDay(for: newValue) + // Offset is measured from the caller's LOGICAL day (not raw Date()), so a date picked in + // the 00:00-04:00 window maps to the same offset the labels count back from (#14). + let anchor = cal.startOfDay(for: today) + let days = cal.dateComponents([.day], from: start, to: anchor).day ?? 0 + onSelect(max(0, days)) + showingPicker = false + } + ) + return DatePicker("", selection: pickedBinding, in: ...today, displayedComponents: [.date]) + .datePickerStyle(.graphical) + .labelsHidden() + .padding(12) + // #840 — iPad popover needs an explicit size or the graphical picker clips. Safe on macOS 13 + // and iPhone (the popover/sheet sizes to this); avoids the macOS 13.3-only compact-adaptation API. + .frame(minWidth: 320, minHeight: 360) + } + + private var blockShape: RoundedRectangle { RoundedRectangle(cornerRadius: 14, style: .continuous) } + + /// Fill for the centre day block. On macOS the bar spans the bright Today day-scene, so it uses the + /// raised WHOOP-grey card fill to read as a card rather than a black bar; iOS (which uses the compact + /// top-bar day-nav, not this control) keeps the inset well fill unchanged. + private var blockFill: Color { + #if os(macOS) + // Compact translucent pill (not a full-width bar) — the scene shows through so it reads as a + // floating control over the day-scene, never a solid black bar; white label stays legible at 0.72. + StrandPalette.surfaceBase.opacity(0.72) + #else + StrandPalette.surfaceInset + #endif + } + + private static let dayFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "EEE d MMM"; f.locale = Locale(identifier: "en_US_POSIX"); return f + }() + private static let fullDateFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "d MMM yyyy"; f.locale = Locale(identifier: "en_US_POSIX"); return f + }() +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/DomainTheme.swift b/Packages/StrandDesign/Sources/StrandDesign/DomainTheme.swift new file mode 100644 index 0000000000..a38dc26582 --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/DomainTheme.swift @@ -0,0 +1,145 @@ +import SwiftUI + +// MARK: - Domain Theme (NEW — Bevel per-domain colour worlds) +// +// Maps a daily-score domain (Charge / Effort / Rest / Stress) to its accent +// "colour world": a primary colour, a deep→bright gradient for gauge strokes and +// card washes, and a glow colour for blooms / end-cap halos. Every Bevel surface +// (layered gauge, frosted card tint, scenic hero) reads its colours from here so a +// screen only has to name its domain. + +public enum DomainTheme: String, CaseIterable, Sendable { + case charge + case effort + case rest + case stress + + /// The dominant accent colour for the world. + public var color: Color { + switch self { + case .charge: return StrandPalette.chargeColor + case .effort: return StrandPalette.effortColor + case .rest: return StrandPalette.restColor + case .stress: return StrandPalette.stressColor + } + } + + /// The deep (low) end of the world's accent ramp. + public var deep: Color { + switch self { + case .charge: return StrandPalette.chargeDeep + case .effort: return StrandPalette.effortDeep + case .rest: return StrandPalette.restDeep + case .stress: return StrandPalette.stressDeep + } + } + + /// The bright (high) end of the world's accent ramp. + public var bright: Color { + switch self { + case .charge: return StrandPalette.chargeBright + case .effort: return StrandPalette.effortBright + case .rest: return StrandPalette.restBright + case .stress: return StrandPalette.stressBright + } + } + + /// The world's glow colour for blooms and gauge end-caps. + public var glow: Color { + switch self { + case .charge: return StrandPalette.chargeGlow + case .effort: return StrandPalette.effortGlow + case .rest: return StrandPalette.restGlow + case .stress: return StrandPalette.stressGlow + } + } + + /// Deep → bright gradient for gauge strokes and the diagonal card wash. + public var gradient: Gradient { + switch self { + case .charge: return StrandPalette.chargeGradient + case .effort: return StrandPalette.effortGradient + case .rest: return StrandPalette.restGradient + case .stress: return StrandPalette.stressGradient + } + } + + /// The data gradient the world samples values along (Charge/Rest = recovery + /// scale, Effort = strain ramp), used by sparklines and value-tinted strokes. + public var dataGradient: Gradient { + switch self { + case .charge, .rest, .stress: return StrandPalette.recoveryGradient + case .effort: return StrandPalette.strainGradient + } + } +} + +// MARK: - Scenic Hero Background (NEW) +// +// A Canvas-drawn premium backdrop for detail-screen heroes: a radial deep blue-black +// gradient (warm-lit center → near-black edge) sprinkled with a faint deterministic +// starfield, optionally tinted toward a domain's glow. Sits behind a ScoreGauge / +// hero number. Deterministic (no per-frame randomness) so it never flickers, and the +// starfield is purely decorative (hidden from VoiceOver via the caller's container). + +public struct ScenicHeroBackground: View { + + /// Optional domain whose glow tints the upper bloom. nil = neutral blue-black. + public var domain: DomainTheme? + /// Star count — kept modest so the field reads as texture, not noise. + public var starCount: Int + /// Whether to draw the bottom fade that lets content sit cleanly over the field. + public var fadesToBase: Bool + + @Environment(\.colorScheme) private var scheme + + public init(domain: DomainTheme? = nil, starCount: Int = 40, fadesToBase: Bool = true) { + self.domain = domain + self.starCount = starCount + self.fadesToBase = fadesToBase + } + + public var body: some View { + ZStack { + // Radial deep blue-black: lit center → near-black edge. + RadialGradient( + gradient: Gradient(colors: [StrandPalette.scenicCenter, StrandPalette.scenicEdge]), + center: .init(x: 0.5, y: 0.36), + startRadius: 0, + endRadius: 520 + ) + + // Design Reset (2026-06-22): the domain bloom + starfield are removed for the flat + // WHOOP look. The hero is now a clean blue-grey radial — no glow, no stars. `domain` and + // `starCount` stay on the type for API stability but no longer paint. + + // Bottom fade so a hero number / card reads cleanly over the field. + if fadesToBase { + LinearGradient( + colors: [.clear, StrandPalette.scenicEdge.opacity(0.72), StrandPalette.scenicEdge], + startPoint: .center, + endPoint: .bottom + ) + } + } + .accessibilityHidden(true) + } +} + +#if DEBUG +#Preview("ScenicHeroBackground") { + VStack(spacing: 0) { + ScenicHeroBackground(domain: .charge) + .frame(height: 220) + .overlay( + Text("87").font(.system(size: 60, weight: .bold, design: .rounded)) + .foregroundStyle(StrandPalette.textPrimary) + ) + ScenicHeroBackground(domain: .rest) + .frame(height: 220) + } + .frame(width: 420, height: 440) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/GlowRing.swift b/Packages/StrandDesign/Sources/StrandDesign/GlowRing.swift new file mode 100644 index 0000000000..bba34e843e --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/GlowRing.swift @@ -0,0 +1,80 @@ +import SwiftUI + +// MARK: - GlowRing — crisp WHOOP-style score ring +// +// Quality here is CRISPNESS, not blur. A clean solid arc with rounded caps over a clearly-visible +// full-circle track (so the ring reads as "X% of a circle"), a bold centred number that counts up, and +// only a TIGHT, low-opacity glow hugging the arc (additive on dark, hidden on light) — never a wide +// fuzzy bloom. The arc springs in from 12 o'clock and re-animates when the value changes (day nav). +// Theme-aware (number + track follow light/dark). Motion gated on Reduce Motion; macOS-13 / iOS-17 safe. + +public struct GlowRing: View { + + /// Target fill, 0...1. + public var fraction: Double + /// The number shown in the centre — rolls up to this. + public var value: Double + /// Formats the (animated) value into the centre string. + public var format: (Double) -> String + /// The arc colour (solid, saturated — the domain accent). + public var color: Color + public var diameter: CGFloat + public var lineWidth: CGFloat + + public init(fraction: Double, value: Double, format: @escaping (Double) -> String, + color: Color, diameter: CGFloat, lineWidth: CGFloat) { + self.fraction = fraction + self.value = value + self.format = format + self.color = color + self.diameter = diameter + self.lineWidth = lineWidth + } + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var appeared = false + + /// The centre-number font for a ring of the given diameter — the house numeral at `diameter * 0.36`, + /// bold. Exposed so an EMPTY / carried / "No data" ring (which doesn't draw a `GlowRing`) can render + /// its centre text in the EXACT same size + weight as a filled ring, keeping the hero trio's three + /// centre read-outs visually consistent regardless of state. + public static func centerFont(diameter: CGFloat) -> Font { + StrandFont.rounded(diameter * 0.36, weight: .bold) + } + + private var clamped: CGFloat { CGFloat(min(max(fraction, 0), 1)) } + private var filled: CGFloat { appeared ? clamped : 0 } + private var shown: Double { appeared ? value : 0 } + private var drawSpring: Animation { .spring(response: 0.9, dampingFraction: 0.86) } + + public var body: some View { + ZStack { + // Clearly-visible full-circle track, so the arc reads as a fraction of a circle (like WHOOP). + Circle() + .stroke(StrandPalette.textPrimary.opacity(0.10), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + + // Design Reset: NO glow. A flat, crisp solid arc only — the clean Material-style look. + arc.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + + // Centred rolling number. + Text(format(shown)) + .font(Self.centerFont(diameter: diameter)) + .foregroundStyle(StrandPalette.textPrimary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.5) + .contentTransition(.numericText()) + .padding(.horizontal, lineWidth + 4) + .animation(reduceMotion ? nil : .easeOut(duration: 0.85), value: shown) + } + .frame(width: diameter, height: diameter) + .animation(reduceMotion ? nil : drawSpring, value: filled) + .onAppear { appeared = true } + } + + /// The trimmed arc, drawn from 12 o'clock clockwise. + private var arc: some Shape { + Circle().trim(from: 0, to: max(0.0001, filled)).rotation(.degrees(-90)) + } +} diff --git a/Packages/StrandDesign/Sources/StrandDesign/Haptics.swift b/Packages/StrandDesign/Sources/StrandDesign/Haptics.swift new file mode 100644 index 0000000000..d849f1329d --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/Haptics.swift @@ -0,0 +1,89 @@ +import SwiftUI +#if os(watchOS) +import WatchKit +#elseif canImport(UIKit) +import UIKit +#endif + +// MARK: - Strand Haptics (the tactile sibling of the Motion tokens) +// +// One tasteful tactile vocabulary shared across the app, mirroring StrandMotion. iOS fires +// the Taptic engine; macOS is a no-op. Keep it SPARSE — confirmations and state landings +// only, never per-keystroke or per-frame. The system already honours the user's +// Settings ▸ Sounds & Haptics master switch. + +public enum StrandHaptic { + case selection // segmented-pill / tab switch / toggle — light, frequent-safe + case light // a soft tap (alias kept for press-feedback call sites) + case commit // a primary action succeeded (save workout, finish interval) + case success // a meaningful milestone (bond success, breathe session done) + case warning // a soft "not allowed / invalid" + + #if os(watchOS) + // watchOS has no UIKit feedback generators; the Taptic engine is driven through WatchKit's + // `WKHapticType`. Map our vocabulary onto the closest watch haptics so the breathing / interval + // features (which depend on a real tactile cue) actually buzz on the wrist. + private func fire() { + let device = WKInterfaceDevice.current() + switch self { + case .selection: device.play(.click) + case .light: device.play(.click) + case .commit: device.play(.success) + case .success: device.play(.success) + case .warning: device.play(.failure) + } + } + #elseif canImport(UIKit) + // Generators are cheap to make; UIKit pools the engine. We don't retain selection + // generators (tab/pill taps are bursty). + private func fire() { + switch self { + case .selection: UISelectionFeedbackGenerator().selectionChanged() + case .light: UIImpactFeedbackGenerator(style: .light).impactOccurred() + case .commit: UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + case .success: UINotificationFeedbackGenerator().notificationOccurred(.success) + case .warning: UINotificationFeedbackGenerator().notificationOccurred(.warning) + } + } + #endif + + /// Fire this haptic now. No-op on macOS. + public func play() { + #if os(watchOS) + fire() + #elseif canImport(UIKit) + fire() + #endif + } +} + +public extension View { + /// Declarative haptic fired when `trigger` changes (iOS 17+ `.sensoryFeedback`; no-op + /// below / on macOS). Use for value-driven landings: score reveal, bond success, + /// refresh-done — anything where a state change, not a tap, is the cue. + @ViewBuilder + func strandHaptic(_ haptic: StrandHaptic, trigger: V) -> some View { + #if os(iOS) + if #available(iOS 17.0, *) { + self.sensoryFeedback(trigger: trigger) { _, _ in haptic.sensory } + } else { self } + #else + self + #endif + } +} + +#if os(iOS) +@available(iOS 17.0, *) +private extension StrandHaptic { + var sensory: SensoryFeedback { + switch self { + case .selection: return .selection + case .light: return .impact(weight: .light) + case .commit: return .impact(weight: .heavy) + case .success: return .success + case .warning: return .warning + } + } +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/Hypnogram.swift b/Packages/StrandDesign/Sources/StrandDesign/Hypnogram.swift index 734f6bb07c..8e6b054615 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Hypnogram.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Hypnogram.swift @@ -1,19 +1,29 @@ +#if !os(watchOS) +// The watch never draws the hypnogram (uses .onContinuousHover + ChartHover helpers, unavailable +// on watchOS); excluded there, iOS/macOS unchanged. import SwiftUI // MARK: - Hypnogram (§9.4 Sleep) // // A sleep-stage horizontal banded timeline. Each interval is drawn as a band at -// the height of its stage (awake top → deep bottom), colored per §9.1 (awake -// rose, light periwinkle, deep indigo, REM glowing mint). Adjacent intervals are -// connected by vertical risers so the trace reads as one continuous "staircase". +// the height of its stage (awake top → deep bottom), colored per §9.1 with the +// Titanium & Gold sleep tokens — awake pale slate, light blue (#4A90E2), deep +// blue (#2F6FCB), REM bright blue (#6FA8E8) — so the four stages stay clearly +// distinguishable (fixes #345). Adjacent intervals are connected by vertical +// risers so the trace reads as one continuous "staircase". /// A single stage interval. `start`/`end` are seconds from the start of the night. public struct SleepInterval: Identifiable, Sendable { - public let id = UUID() public var stage: SleepStage public var start: TimeInterval public var end: TimeInterval + /// Stable, CONTENT-derived identity (stage + start + end) rather than a random `UUID()`. + /// A fresh UUID per value defeated SwiftUI's `ForEach` diffing — every body eval re-identified + /// all bands as brand-new, so the whole hypnogram rebuilt on each hover/diff. Intervals are + /// non-overlapping with distinct starts within a night, so this composite is unique and stable. + public var id: String { "\(stage.rawValue)|\(start)|\(end)" } + public init(stage: SleepStage, start: TimeInterval, end: TimeInterval) { self.stage = stage self.start = start @@ -37,26 +47,33 @@ public struct Hypnogram: View { /// real clock times (e.g. "23:42–00:04"); otherwise it shows elapsed time /// from the start of the night (e.g. "0:06–0:28"). public var nightStart: Date? + /// Whether to anchor the timeline with an x time axis (onset · midpoint · wake + /// hairlines + clock labels). Needs `nightStart`. Defaults off so existing + /// callers are unchanged. + public var showsTimeAxis: Bool public init( intervals: [SleepInterval], height: CGFloat = 180, showsStageAxis: Bool = true, showsHover: Bool = true, - nightStart: Date? = nil + nightStart: Date? = nil, + showsTimeAxis: Bool = false ) { self.intervals = intervals.sorted { $0.start < $1.start } self.height = height self.showsStageAxis = showsStageAxis self.showsHover = showsHover self.nightStart = nightStart + self.showsTimeAxis = showsTimeAxis } /// Index of the hovered interval, or nil. @State private var hoverIndex: Int? = nil private static let clockFormatter: DateFormatter = { - let f = DateFormatter(); f.dateFormat = "HH:mm"; return f + // "jmm" respects the device's 12-/24-hour setting (#337) rather than forcing 24-hour. + let f = DateFormatter(); f.locale = Locale.current; f.setLocalizedDateFormatFromTemplate("jmm"); return f }() /// Format a seconds-from-origin offset either as wall-clock (if nightStart @@ -78,85 +95,154 @@ public struct Hypnogram: View { } private var origin: TimeInterval { intervals.first?.start ?? 0 } + /// ONE spoken summary of the whole night for VoiceOver: total time in each stage. Replaces the old + /// per-band accessibility layer (which emitted one element PER interval — O(intervals), a heavy + /// semantics subtree the Compose/AppKit accessibility walk re-copied on every scroll, a contributor + /// to the #707 OOM). Collapsing to one node keeps a clear screen-reader read-out at O(1) node cost. + /// e.g. "Sleep stages, 2 hours deep, 1 hour 30 minutes REM, 3 hours light, 20 minutes awake". + private var axSummary: String { + guard !intervals.isEmpty else { return String(localized: "Sleep stages, no data", bundle: .module) } + // Sum duration per stage in the natural read order (deep · REM · light · awake), naming only the + // stages that actually occur so a night with no awake time doesn't read "0 minutes awake". + var parts: [String] = [] + for stage in [SleepStage.deep, .rem, .light, .awake] { + let total = intervals.filter { $0.stage == stage }.reduce(0.0) { $0 + $1.duration } + if total > 0 { parts.append("\(Hypnogram.durationPhrase(total)) \(stage.label.lowercased())") } + } + return parts.isEmpty ? String(localized: "Sleep stages, no data", bundle: .module) + : String(localized: "Sleep stages, \(parts.joined(separator: ", "))", bundle: .module) + } + + /// A spoken duration phrase ("2 hours 5 minutes", "45 minutes", "1 hour") for a seconds interval. + private static func durationPhrase(_ seconds: TimeInterval) -> String { + let total = Int((seconds / 60).rounded()) // whole minutes + let h = total / 60 + let m = total % 60 + // Whole-phrase per unit (no "s"-suffix stitching) so each key can carry its own plural rule. + func hours(_ n: Int) -> String { n == 1 ? String(localized: "1 hour", bundle: .module) : String(localized: "\(n) hours", bundle: .module) } + func minutes(_ n: Int) -> String { n == 1 ? String(localized: "1 minute", bundle: .module) : String(localized: "\(n) minutes", bundle: .module) } + if h > 0 && m > 0 { return "\(hours(h)) \(minutes(m))" } + if h > 0 { return hours(h) } + return minutes(max(m, 1)) + } + // 4 stage rows; awake = rank 0 (top), deep = rank 3 (bottom). private let rowCount = 4 public var body: some View { - HStack(spacing: 12) { + HStack(alignment: .top, spacing: 12) { if showsStageAxis { axis } - GeometryReader { geo in - ZStack { - // faint baselines per stage row - ForEach(0.. CGRect { let x0 = CGFloat((interval.start - origin) / span) * size.width let x1 = CGFloat((interval.end - origin) / span) * size.width - let thickness: CGFloat = 10 + // Row-proportional thickness (floored) so bands fill the tall row gaps. + let rowStep = size.height / CGFloat(rowCount) + let thickness = max(NoopMetrics.hypnogramBandMinThickness, rowStep * 0.40) + // Floor the WIDTH at the thickness and centre the band on its interval, so a brief stage — + // especially a short Awake blip — reads as a rounded pill/dot rather than a thin glitch tick. + let mid = (x0 + x1) / 2 + let width = max(thickness, x1 - x0) let y = rowY(interval.stage.bandRank, in: size.height) - return CGRect(x: x0, y: y - thickness / 2, width: max(2, x1 - x0), height: thickness) + return CGRect(x: mid - width / 2, y: y - thickness / 2, width: width, height: thickness) } private func risers(in size: CGSize) -> some View { @@ -266,3 +358,4 @@ private func sampleNight() -> [SleepInterval] { .preferredColorScheme(.dark) } #endif +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift index cac6fe3c82..b56a263120 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift @@ -38,11 +38,26 @@ public enum StrandMotion { /// Ease for the ring/gauge draw-in when a value changes. public static let drawIn = Animation.easeOut(duration: durationSlow) + /// The ring/gauge draw-in, suppressed when Reduce Motion is on. Returns `nil` + /// (no animation) when reduced so `withAnimation` sets the fraction instantly and + /// the arc/bead snaps to its final frame instead of sweeping. Mirrors + /// `breathe(reduced:)` and honours Apple's Reduce Motion HIG. + public static func drawIn(reduced: Bool) -> Animation? { + reduced ? nil : drawIn + } + /// Looping breathe animation for ambient glow/pulse. public static var breathe: Animation { .easeInOut(duration: breathPeriod).repeatForever(autoreverses: true) } + /// Looping breathe animation, suppressed when Reduce Motion is on. Returns + /// `nil` (no animation) when reduced so call sites collapse to the resting + /// frame instead of an indefinite loop. Honours Apple's Reduce Motion HIG. + public static func breathe(reduced: Bool) -> Animation? { + reduced ? nil : breathe + } + /// A single heartbeat ripple pulse. public static let pulse = Animation.easeOut(duration: 0.6) diff --git a/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift b/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift new file mode 100644 index 0000000000..0cd8839c1f --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift @@ -0,0 +1,127 @@ +import SwiftUI + +// MARK: - MotionTrace (§9.4 Sleep — restlessness trace, #407) +// +// A subordinate per-epoch MOVEMENT / restlessness trace drawn UNDERNEATH the Hypnogram, on the SAME +// horizontal timeline. Each value is one epoch's motion magnitude (the SleepStager's per-epoch movement +// on the 30 s grid persisted as `motionJSON`); higher = more movement = more restless. It reads as a +// short filled "seismograph" strip so the eye can line a restless burst up against the stage band above +// it, without competing with the hypnogram for vertical space. +// +// HONESTY (#407): when there is no persisted motion (older rows whose `motionJSON` is NULL) the caller +// shows an honest empty state instead of this view — a flat fabricated zero line would be a lie. This +// view itself only renders when given a non-empty series. + +public struct MotionTrace: View { + + /// Per-epoch motion magnitudes, oldest→newest, laid left→right across the SAME span as the hypnogram + /// above. Values are arbitrary-unit magnitudes (≥ 0); the trace self-normalises to its own peak so a + /// quiet night and a restless night both fill the strip — it shows the SHAPE of movement, not an + /// absolute scale (which the strap doesn't calibrate). + public var epochs: [Double] + /// Strip height. Kept short so it stays clearly subordinate to the hypnogram. + public var height: CGFloat + /// The trace tint — defaults to the sleep accent so it belongs to the same visual family as the + /// hypnogram bands without mimicking a stage colour. + public var tint: Color + + public init(epochs: [Double], height: CGFloat = 44, tint: Color = StrandPalette.textTertiary) { + self.epochs = epochs + self.height = height + self.tint = tint + } + + /// The peak magnitude used to normalise the fill height. A non-positive peak (all-zero / empty) maps + /// everything to the baseline so the strip is flat rather than dividing by zero. + private var peak: Double { max(epochs.max() ?? 0, 0) } + + public var body: some View { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + ZStack { + // Faint baseline so the strip reads as a grounded trace even on a calm night. + Path { p in + p.move(to: CGPoint(x: 0, y: h - 1)) + p.addLine(to: CGPoint(x: w, y: h - 1)) + } + .stroke(StrandPalette.hairline.opacity(0.4), lineWidth: 1) + + // Filled area under the per-epoch magnitude, normalised to the night's own peak. + if epochs.count >= 2, peak > 0 { + let pts = points(in: geo.size) + Path { p in + p.move(to: CGPoint(x: 0, y: h)) + for pt in pts { p.addLine(to: pt) } + p.addLine(to: CGPoint(x: w, y: h)) + p.closeSubpath() + } + .fill(tint.opacity(0.22)) + + // The crest line on top of the fill for definition. + Path { p in + guard let first = pts.first else { return } + p.move(to: first) + for pt in pts.dropFirst() { p.addLine(to: pt) } + } + .stroke(tint.opacity(0.8), style: StrokeStyle(lineWidth: 1.5, lineJoin: .round)) + } else if epochs.count == 1, peak > 0 { + // A single epoch can't form a line — draw it as one centered tick so it isn't invisible. + let y = h - CGFloat(epochs[0] / peak) * (h - 2) + Path { p in + p.move(to: CGPoint(x: w / 2, y: h)) + p.addLine(to: CGPoint(x: w / 2, y: y)) + } + .stroke(tint.opacity(0.8), lineWidth: 1.5) + } + } + .accessibilityElement() + .accessibilityLabel(Text("Movement during sleep")) + .accessibilityValue(Text(accessibilitySummary)) + } + .frame(height: height) + } + + /// One screen point per epoch: x spread evenly across the width (matching the hypnogram's left→right + /// time mapping), y the magnitude normalised to the night's peak (0 at the baseline, full at the top). + private func points(in size: CGSize) -> [CGPoint] { + let n = epochs.count + guard n >= 2, peak > 0 else { return [] } + let h = size.height + let usable = h - 2 // leave 1px top/bottom padding so the crest isn't clipped + return epochs.enumerated().map { i, v in + let x = CGFloat(i) / CGFloat(n - 1) * size.width + let frac = CGFloat(max(0, min(v / peak, 1))) + return CGPoint(x: x, y: h - frac * usable) + } + } + + /// A coarse VoiceOver summary — the share of epochs with above-half-peak movement — since a per-epoch + /// trace can't be voiced point by point. "Calm" when nothing crosses the threshold. + private var accessibilitySummary: String { + guard peak > 0, !epochs.isEmpty else { return "no movement data" } + let restless = epochs.filter { $0 >= peak * 0.5 }.count + if restless == 0 { return "calm throughout" } + let pct = Int((Double(restless) / Double(epochs.count) * 100).rounded()) + return "\(pct)% of the night had elevated movement" + } +} + +#if DEBUG +#Preview("MotionTrace") { + let calm = (0..<60).map { _ in Double.random(in: 0...0.1) } + let restless = (0..<60).map { i -> Double in + i % 11 == 0 ? Double.random(in: 0.7...1.0) : Double.random(in: 0...0.2) + } + return VStack(alignment: .leading, spacing: 16) { + Text("Calm night").strandOverline() + MotionTrace(epochs: calm) + Text("Restless night").strandOverline() + MotionTrace(epochs: restless) + } + .padding(28) + .frame(width: 720, height: 280) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift new file mode 100644 index 0000000000..0662b11a78 --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift @@ -0,0 +1,224 @@ +import SwiftUI + +// MARK: - NoopButton — the unified button system (Design Reset, 2026-06-22) +// +// One button, four kinds, no glow. Beauty comes from a crisp filled accent, honest +// surface fills, restrained spacing and a subtle press — never neon, bloom or a halo. +// Every colour is a token from `StrandPalette`; every dimension reads off `NoopMetrics`. +// +// Two front doors: +// • `NoopButton("Save", kind: .primary) { … }` — the convenience view. +// • `Button("Save") { … }.buttonStyle(NoopButtonStyle(.primary))` — adopt on an +// existing Button (e.g. a Menu/role button) without rewriting it. +// +// Labels are sentence-case (never ALL CAPS), single line, optical-centred with the +// optional leading icon as one unit, and degrade gracefully under Reduce Motion (the +// press scale drops; only the dim remains). + +/// The four button roles. Colour + emphasis differ; geometry is identical across all four. +public enum NoopButtonKind: Sendable { + /// Filled accent (blue), white label — the one primary action on a screen. + case primary + /// Raised-surface fill, primary-text label, hairline edge — secondary actions. + case secondary + /// No fill, accent label — low-emphasis / inline actions. + case tertiary + /// Filled critical (red), white label — destructive / irreversible actions. + case destructive +} + +// MARK: - Shared geometry / resolved styling + +/// Fixed geometry shared by the convenience view and the ButtonStyle so the two paths +/// are pixel-identical. The single source of truth for button shape. +public enum NoopButtonMetrics { + /// Standard control height (48) — also the source for the min hit target floor. + public static let height: CGFloat = NoopMetrics.controlHeight + /// Corner radius (14) — softer than a card, not a pill. + public static let cornerRadius: CGFloat = 14 + /// Horizontal label inset. + public static let hPadding: CGFloat = 18 + /// Spacing between a leading icon and the label. + public static let iconSpacing: CGFloat = 8 + /// Label tracking — a hair of openness on the semibold face. + public static let tracking: CGFloat = 0.2 + /// Apple's minimum touch target. The button never reports a hit area below this. + public static let minHitTarget: CGFloat = 44 + /// Pressed scale (spec: subtle 0.97). Reduce-Motion collapses this to 1 (dim only). + public static let pressedScale: CGFloat = 0.97 + /// Pressed dim — a slight opacity drop, applied in BOTH motion modes. + public static let pressedOpacity: Double = 0.82 + /// Disabled dim, shared so call sites don't invent their own. + public static let disabledOpacity: Double = 0.4 +} + +/// Resolves a `NoopButtonKind` to its concrete fill / label / border tokens. Internal +/// so the fill model stays in one place; both the style and the view read from here. +struct NoopButtonAppearance { + let fill: Color? // nil = no fill (tertiary) + let label: Color + let border: Color? // nil = no hairline edge + + init(_ kind: NoopButtonKind) { + switch kind { + case .primary: + fill = StrandPalette.accent + label = StrandPalette.goldDeepText // designated crisp white for text on accent fills + border = nil + case .secondary: + fill = StrandPalette.surfaceRaised + label = StrandPalette.textPrimary + border = StrandPalette.hairline + case .tertiary: + fill = nil + label = StrandPalette.accent + border = nil + case .destructive: + fill = StrandPalette.statusCritical + label = StrandPalette.goldDeepText // crisp white on the critical fill + border = nil + } + } +} + +// MARK: - The crisp background (no glow, ever) + +/// The flat, glow-free button background: a filled (or unfilled) rounded rect with an +/// optional hairline edge. No shadow, no blur halo, no additive bloom — restraint only. +private struct NoopButtonBackground: View { + let appearance: NoopButtonAppearance + + var body: some View { + let shape = RoundedRectangle(cornerRadius: NoopButtonMetrics.cornerRadius, style: .continuous) + ZStack { + if let fill = appearance.fill { + shape.fill(fill) + } + if let border = appearance.border { + shape.strokeBorder(border, lineWidth: 1) + } + } + } +} + +// MARK: - ButtonStyle (adopt on any existing Button) + +/// Apply the NOOP button look to ANY `Button` — e.g. a role/`Menu` button you can't +/// replace with `NoopButton`. Honours Reduce Motion: the press scale drops to a dim-only +/// state. Pixel-identical to `NoopButton` since both share `NoopButtonMetrics`/appearance. +public struct NoopButtonStyle: ButtonStyle { + private let kind: NoopButtonKind + private let fullWidth: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.isEnabled) private var isEnabled + + public init(_ kind: NoopButtonKind = .primary, fullWidth: Bool = false) { + self.kind = kind + self.fullWidth = fullWidth + } + + public func makeBody(configuration: Configuration) -> some View { + let appearance = NoopButtonAppearance(kind) + let pressed = configuration.isPressed + // Reduce Motion: no scale, dim only. Otherwise subtle scale + dim. + let scale: CGFloat = (pressed && !reduceMotion) ? NoopButtonMetrics.pressedScale : 1 + let opacity: Double = pressed ? NoopButtonMetrics.pressedOpacity : 1 + + configuration.label + .labelStyle(.titleAndIcon) + .font(StrandFont.headline.weight(.semibold)) + .tracking(NoopButtonMetrics.tracking) + .lineLimit(1) + .minimumScaleFactor(0.9) + .foregroundStyle(appearance.label) + .frame(maxWidth: fullWidth ? .infinity : nil) + .padding(.horizontal, NoopButtonMetrics.hPadding) + .frame(height: NoopButtonMetrics.height) + .frame(minHeight: NoopButtonMetrics.minHitTarget) + .contentShape(Rectangle()) + .background(NoopButtonBackground(appearance: appearance)) + .clipShape(RoundedRectangle(cornerRadius: NoopButtonMetrics.cornerRadius, style: .continuous)) + .opacity(isEnabled ? opacity : NoopButtonMetrics.disabledOpacity) + .scaleEffect(scale) + .animation(reduceMotion ? nil : StrandMotion.interactive, value: pressed) + } +} + +// MARK: - NoopButton (the convenience view) + +/// The unified button. A title (sentence-case `LocalizedStringKey`), an optional leading +/// SF Symbol, a `NoopButtonKind`, an optional `fullWidth`, and an action. Crisp, flat, +/// glow-free; subtle press; 44pt hit floor; Reduce-Motion aware. +/// +/// ```swift +/// NoopButton("Save changes", systemImage: "checkmark", kind: .primary, fullWidth: true) { +/// save() +/// } +/// ``` +public struct NoopButton: View { + private let title: LocalizedStringKey + private let systemImage: String? + private let kind: NoopButtonKind + private let fullWidth: Bool + private let action: () -> Void + + public init( + _ title: LocalizedStringKey, + systemImage: String? = nil, + kind: NoopButtonKind = .primary, + fullWidth: Bool = false, + action: @escaping () -> Void + ) { + self.title = title + self.systemImage = systemImage + self.kind = kind + self.fullWidth = fullWidth + self.action = action + } + + public var body: some View { + Button(action: action) { + // The ButtonStyle owns the chrome (fill / colour / press / padding). The label here is + // just the icon + word as one centred unit at the exact 8pt token spacing. When there's + // no icon the HStack holds a single Text, so the word sits dead-centre with no phantom gap. + HStack(spacing: NoopButtonMetrics.iconSpacing) { + if let systemImage { + Image(systemName: systemImage) + .imageScale(.medium) // optically centres to the cap height of the label + } + Text(title) + } + } + .buttonStyle(NoopButtonStyle(kind, fullWidth: fullWidth)) + } +} + +#if DEBUG +#Preview("NoopButton") { + ScrollView { + VStack(spacing: NoopMetrics.rowSpacing) { + NoopButton("Primary action", systemImage: "checkmark", kind: .primary) {} + NoopButton("Secondary action", systemImage: "square.and.arrow.up", kind: .secondary) {} + NoopButton("Tertiary action", kind: .tertiary) {} + NoopButton("Delete recording", systemImage: "trash", kind: .destructive) {} + + Divider().overlay(StrandPalette.hairline) + + NoopButton("Full-width primary", systemImage: "bolt.fill", kind: .primary, fullWidth: true) {} + NoopButton("Full-width secondary", kind: .secondary, fullWidth: true) {} + + // Adopting the style on a vanilla Button. + Button("Adopted via NoopButtonStyle") {} + .buttonStyle(NoopButtonStyle(.secondary, fullWidth: true)) + + NoopButton("Disabled", kind: .primary) {} + .disabled(true) + } + .screenPadding() + .padding(.vertical, NoopMetrics.space6) + } + .frame(width: 380, height: 560) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift new file mode 100644 index 0000000000..5bd46ab2ac --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift @@ -0,0 +1,336 @@ +import SwiftUI + +// MARK: - NoopMotion — the "Design Reset" motion set (WHOOP design language, 2026-06-22) +// +// The house motion language for the WHOOP-flavoured redesign: smooth, snappy, almost no +// bounce. Beauty is in the restraint — type, spacing and a single confident settle, NOT +// effects. There is NO glow here and nothing that pulses or loops; that lives elsewhere +// and is being retired. This file adds three things screens reach for constantly: +// +// • a refined spring/transition set (screen / card / value) +// • `CountUpText` — big scores/metrics tick up to their new value +// • `.staggeredAppear(index:)` — list/grid items fade + rise in, once, in sequence +// • `.softCardTransition()` — card insert/remove (opacity + a hair of scale) +// +// Every helper is PUBLIC, GPU-cheap (opacity / offset / scale only), and honours +// `@Environment(\.accessibilityReduceMotion)` — under Reduce Motion animations collapse +// to their final frame instantly, with no offset, scale or counting. +// +// This complements `StrandMotion` (the physiological breathe/pulse set) rather than +// replacing it: where StrandMotion leans organic, NoopMotion leans crisp and mechanical, +// matching the white-on-near-black WHOOP target. + +public enum NoopMotion { + + // MARK: Springs — smooth, snappy, minimal bounce + + /// Screen-level spring — page pushes, sheet/tab swaps, large layout moves. A touch + /// slower so big surfaces feel weighted, still effectively bounce-free. + public static let screen = Animation.spring(response: 0.46, dampingFraction: 0.88) + + /// Card-level spring — the default for card insert/remove, row reflow, expand/collapse. + /// The house tempo: `spring(response: 0.4, dampingFraction: 0.85)`. + public static let card = Animation.spring(response: 0.40, dampingFraction: 0.85) + + /// Value-level spring — number ticks, gauge fraction, small chip/state changes. Snappy + /// and tightly damped so a changing read-out settles cleanly without overshoot. + public static let value = Animation.spring(response: 0.34, dampingFraction: 0.90) + + // MARK: Stagger + + /// Per-item delay for a staggered list/grid reveal. Index 0 fires immediately; each + /// subsequent item waits `index * stagger` so a column ripples in top-to-bottom. + public static let stagger: Double = 0.04 + + /// The pre-reveal vertical offset for a staggered/appear item (rises UP into place). + public static let riseOffset: CGFloat = 8 + + // MARK: Reduce-Motion gating + + /// Returns `animation` normally, or `nil` (instant, no animation) when Reduce Motion is on, + /// so a `withAnimation` / `.animation(_:value:)` call site snaps straight to the final frame. + /// Mirrors `StrandMotion.drawIn(reduced:)`. + @inline(__always) + public static func gated(_ animation: Animation, reduced: Bool) -> Animation? { + reduced ? nil : animation + } +} + +// MARK: - CountUpText +// +// Animates a numeric value counting up (or down) to its latest value whenever `value` +// changes, and on first appear (from 0 → value). Driven by a custom `Animatable` modifier +// so it works on the iOS 16 / macOS 13 floor (no TimelineView spring / PhaseAnimator needed) +// and rides whatever animation the environment supplies — by default `NoopMotion.value`. +// +// Reduce Motion → the final value is shown instantly, with no tick. + +/// A text view whose number animates from its previous value to the new one. +/// Use for the big scores / hero metric read-outs. +/// +/// ```swift +/// CountUpText(value: score, +/// format: { "\(Int($0.rounded()))" }, +/// font: StrandFont.display(72), +/// color: StrandPalette.textPrimary) +/// .tracking(StrandFont.displayTracking(72)) +/// ``` +public struct CountUpText: View { + private let value: Double + private let format: (Double) -> String + private let font: Font + private let color: Color + private let animation: Animation + + /// The value currently being animated TO. `_AnimatableNumber` interpolates from the + /// last committed `target` to this one; on appear it starts the run from 0. + @State private var target: Double = 0 + @State private var hasAppeared = false + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// - Parameters: + /// - value: the number to display / animate to. + /// - format: maps the (interpolated) number to its display string — round, clamp, add units here. + /// - font: the text font (e.g. `StrandFont.display(72)`). + /// - color: the text colour (e.g. `StrandPalette.textPrimary`). + /// - animation: the count-up curve. Defaults to `NoopMotion.value`. + public init(value: Double, + format: @escaping (Double) -> String, + font: Font, + color: Color, + animation: Animation = NoopMotion.value) { + self.value = value + self.format = format + self.font = font + self.color = color + self.animation = animation + } + + public var body: some View { + // `_AnimatableNumber` conforms to `Animatable`, so SwiftUI interpolates `number` + // frame-by-frame under whatever animation wraps the `target` change. + _AnimatableNumber(number: target, format: format, font: font, color: color) + .onAppear { + guard !hasAppeared else { return } + hasAppeared = true + if reduceMotion { + target = value // snap, no tick + } else { + target = 0 + withAnimation(animation) { target = value } + } + } + .onChangeCompat(of: value) { newValue in + if reduceMotion { + var tx = Transaction(); tx.disablesAnimations = true + withTransaction(tx) { target = newValue } + } else { + withAnimation(animation) { target = newValue } + } + } + // Expose the formatted value to assistive tech as a single, stable label + // (the visual ticking is decorative; VoiceOver reads the final number). + .accessibilityElement() + .accessibilityLabel(Text(format(value))) + } +} + +/// A `View` whose `number` is the animatable channel: SwiftUI interpolates it frame-by-frame +/// under whatever animation wraps the value change, and `body` re-renders `format(number)` +/// each frame. Conforming the VIEW to `Animatable` (rather than using the deprecated +/// `AnimatableModifier`) keeps this warning-clean on the iOS-17 / macOS-14 build while still +/// compiling on the iOS-16 / macOS-13 floor. +private struct _AnimatableNumber: View, Animatable { + var number: Double + let format: (Double) -> String + let font: Font + let color: Color + + var animatableData: Double { + get { number } + set { number = newValue } + } + + var body: some View { + Text(format(number)) + .font(font) + .foregroundStyle(color) + .fixedSize() // never truncate the number + .accessibilityHidden(true) // CountUpText supplies the a11y label + } +} + +// MARK: - Staggered appear +// +// Fade-in + 8pt rise, sequenced by `index`. Runs ONCE per element (guarded by `hasAppeared`), +// so re-renders / scroll recycling don't re-trigger it. Reduce Motion → visible instantly, +// no offset. + +private struct StaggeredAppear: ViewModifier { + let index: Int + let isVisible: Bool + + @State private var hasAppeared = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + // `shown` is true once we've appeared (or immediately under Reduce Motion / when the + // element is asked to appear without animation). + let shown = hasAppeared || reduceMotion + content + .opacity(isVisible ? (shown ? 1 : 0) : 1) + .offset(y: (isVisible && !shown) ? NoopMotion.riseOffset : 0) + .onAppear { + guard isVisible, !hasAppeared else { return } + if reduceMotion { + hasAppeared = true // no animation, no delay + } else { + let delay = Double(max(0, index)) * NoopMotion.stagger + withAnimation(NoopMotion.card.delay(delay)) { + hasAppeared = true + } + } + } + } +} + +public extension View { + /// Fade-in + 8pt rise on first appearance, delayed by `index * 0.04s` for a sequenced + /// list/grid reveal. Runs ONCE per element. Honours Reduce Motion (appears instantly, + /// no offset). + /// + /// - Parameters: + /// - index: position in the sequence (0 = first / no delay). + /// - isVisible: set `false` to opt an element out of the animation (it stays fully shown). + func staggeredAppear(index: Int, isVisible: Bool = true) -> some View { + modifier(StaggeredAppear(index: index, isVisible: isVisible)) + } +} + +// MARK: - Soft card transition +// +// For card insertion/removal inside an animated container (`if`/`ForEach`). Opacity + a +// tiny scale (0.98), asymmetric so an inserted card grows in and a removed card fades out +// without a jarring collapse. Reduce Motion → a plain opacity fade (no scale). + +public extension AnyTransition { + /// The house card insert/remove transition: opacity + a hair of scale. Pass + /// `reduced:` from `@Environment(\.accessibilityReduceMotion)` so it degrades to a + /// plain fade when Reduce Motion is on. + static func softCard(reduced: Bool) -> AnyTransition { + if reduced { + return .opacity + } + let insertion = AnyTransition.opacity.combined(with: .scale(scale: 0.98, anchor: .center)) + let removal = AnyTransition.opacity.combined(with: .scale(scale: 0.98, anchor: .center)) + return .asymmetric(insertion: insertion, removal: removal) + } +} + +private struct SoftCardTransition: ViewModifier { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + func body(content: Content) -> some View { + content.transition(.softCard(reduced: reduceMotion)) + } +} + +public extension View { + /// Applies the house card insert/remove transition (`opacity` + tiny `scale`), wired to + /// Reduce Motion automatically. Drive the change with `NoopMotion.card`, e.g. + /// `withAnimation(NoopMotion.card) { cards.append(...) }`. + func softCardTransition() -> some View { + modifier(SoftCardTransition()) + } +} + +// MARK: - Preview + +#if DEBUG +private struct NoopMotionDemo: View { + @State private var score: Double = 72 + @State private var revealKey = 0 + @State private var cards: [Int] = [0, 1, 2] + private let labels = ["SLEEP", "RECOVERY", "STRAIN", "HRV", "RHR", "CALORIES"] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + + // CountUpText — the big score ticks to a new value. + VStack(alignment: .leading, spacing: 8) { + Text("COUNT-UP SCORE").strandOverline() + CountUpText(value: score, + format: { "\(Int($0.rounded()))" }, + font: StrandFont.display(72), + color: StrandPalette.textPrimary) + .tracking(StrandFont.displayTracking(72)) + Button("Roll the number") { + withAnimation(NoopMotion.value) { + score = Double(Int.random(in: 12...99)) + } + } + .foregroundStyle(StrandPalette.accent) + } + + Divider().overlay(StrandPalette.hairline) + + // staggeredAppear — a list ripples in. `id` reset replays it. + VStack(alignment: .leading, spacing: 8) { + Text("STAGGERED APPEAR").strandOverline() + VStack(spacing: 10) { + ForEach(Array(labels.enumerated()), id: \.offset) { i, label in + HStack { + Text(label).font(StrandFont.headline) + Spacer() + Text("\(42 + i * 7)").font(StrandFont.number(20)) + } + .foregroundStyle(StrandPalette.textPrimary) + .padding(.horizontal, 16).padding(.vertical, 12) + .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 14)) + .staggeredAppear(index: i) + } + } + .id(revealKey) + Button("Replay reveal") { revealKey += 1 } + .foregroundStyle(StrandPalette.accent) + } + + Divider().overlay(StrandPalette.hairline) + + // softCardTransition — insert/remove. + VStack(alignment: .leading, spacing: 8) { + Text("SOFT CARD TRANSITION").strandOverline() + VStack(spacing: 10) { + ForEach(cards, id: \.self) { c in + Text("Card \(c)") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 14)) + .softCardTransition() + } + } + HStack { + Button("Add") { + withAnimation(NoopMotion.card) { cards.append((cards.max() ?? -1) + 1) } + } + Button("Remove") { + withAnimation(NoopMotion.card) { if !cards.isEmpty { cards.removeLast() } } + } + } + .foregroundStyle(StrandPalette.accent) + } + } + .padding(28) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(width: 420, height: 720) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) + } +} + +#Preview("NoopMotion") { NoopMotionDemo() } +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift new file mode 100644 index 0000000000..2fb157b2eb --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift @@ -0,0 +1,703 @@ +#if !os(watchOS) +// OverviewHRChart is a Swift Charts view with .onContinuousHover / MagnificationGesture pan-zoom +// (none available on watchOS); the watch never shows it, so the whole file is excluded there. +import SwiftUI +import Charts + +// MARK: - Overview HR Chart (§ Today — day-in-review) +// +// The Today screen's 24h heart-rate line, annotated like WHOOP's "Overview HR": +// the warm HR curve overlaid with a sleep band, a recovery marker at wake, a +// strain marker at "now", and a sport glyph at each workout's HR peak. The line + +// hover affordance mirror `TrendChart`; this view adds the marker layers and pins +// the x-axis to the HR window so markers never stretch the timeline. +// +// Colours stay in NOOP's Titanium & Gold language (burnt-orange HR line, gold +// recovery, amber strain, blue sleep) rather than copying WHOOP's blue. Tokens +// only — never hardcode hex. + +public struct OverviewHRChart: View { + + /// The sleep period to shade as a band, with an optional corner label (e.g. duration "6:06"). + public struct SleepSpan: Sendable { + public var start: Date + public var end: Date + public var label: String? + public init(start: Date, end: Date, label: String? = nil) { + self.start = start; self.end = end; self.label = label + } + } + + /// A workout window; the sport glyph is placed at the HR peak inside [start, end]. + public struct WorkoutSpan: Identifiable, Sendable { + public let id = UUID() + public var start: Date + public var end: Date + public var symbol: String // SF Symbol (see `sportSymbol`) + public init(start: Date, end: Date, symbol: String) { + self.start = start; self.end = end; self.symbol = symbol + } + } + + /// A labelled vertical marker pinned to a moment in the day (recovery at wake, strain at now). + public struct EdgeMarker: Sendable { + public var date: Date + public var label: String + public var color: Color + public var alignment: HorizontalAlignment + public init(date: Date, label: String, color: Color, alignment: HorizontalAlignment = .leading) { + self.date = date; self.label = label; self.color = color; self.alignment = alignment + } + } + + public var points: [TrendPoint] + public var sleep: SleepSpan? + public var workouts: [WorkoutSpan] + public var recovery: EdgeMarker? + public var effort: EdgeMarker? + public var gradient: Gradient + public var valueRange: ClosedRange + /// Explicit x-axis window. When set, the axis spans exactly this range (so missing data shows as + /// empty space); when nil, the axis is derived from the data extent. + public var xRange: ClosedRange? + public var height: CGFloat + public var showsHover: Bool + /// iPhone touch scrub (#979 spin-off): when true, touch-and-hold pins the hover crosshair under the + /// finger and dragging scrubs it — driving the SAME readout layer the Mac pointer hover uses. Off by + /// default so every existing call site keeps its exact touch behaviour; the Deep Timeline opts in. + public var touchScrub: Bool + public var valueFormat: (Double) -> String + public var dateFormat: (Date) -> String + + /// Deep-Timeline zoom/pan window. When bound (Deep Timeline only), it OVERRIDES `xRange`/data extent + /// as the visible x-domain, and pinch-magnify (iOS) / scroll-to-zoom (macOS) + drag-pan mutate it. + /// `bounds` is the full clamp the window can never escape (the day's full extent). nil on every other + /// call site, so the existing static chart is byte-for-byte unchanged. + @Binding public var zoomDomain: ClosedRange? + public var zoomBounds: ClosedRange? + + /// Tint for the workout glyph badges (NOOP's warm strain accent by default). + public var workoutTint: Color + + private let averageValue: Double + + public init( + points: [TrendPoint], + sleep: SleepSpan? = nil, + workouts: [WorkoutSpan] = [], + recovery: EdgeMarker? = nil, + effort: EdgeMarker? = nil, + gradient: Gradient = Gradient(colors: [StrandPalette.metricRose.opacity(0.55), StrandPalette.metricRose]), + valueRange: ClosedRange = 40...120, + xRange: ClosedRange? = nil, + height: CGFloat = 220, + showsHover: Bool = true, + touchScrub: Bool = false, + workoutTint: Color = StrandPalette.strain033, + zoomDomain: Binding?> = .constant(nil), + zoomBounds: ClosedRange? = nil, + valueFormat: @escaping (Double) -> String = { String(Int($0.rounded())) }, + dateFormat: @escaping (Date) -> String = { TrendChart.defaultDateString($0) } + ) { + let sorted = points.sorted { $0.date < $1.date } + self.points = sorted + self.sleep = sleep + self.workouts = workouts + self.recovery = recovery + self.effort = effort + self.gradient = gradient + self.valueRange = valueRange + self.xRange = xRange + self.height = height + self.showsHover = showsHover + self.touchScrub = touchScrub + self.workoutTint = workoutTint + self._zoomDomain = zoomDomain + self.zoomBounds = zoomBounds + self.valueFormat = valueFormat + self.dateFormat = dateFormat + self.averageValue = sorted.isEmpty + ? valueRange.lowerBound + : sorted.map(\.value).reduce(0, +) / Double(sorted.count) + // Deep Timeline (the only caller that supplies `zoomBounds`) keeps full resolution so pinch-zoom + // into sub-windows stays crisp. The default-binding case here is `zoomBounds == nil`, so the + // static Today chart downsamples. `zoomBounds` is the stable discriminator (set once, never + // toggles) — unlike the live `zoomDomain`, which is nil until the user first zooms. + self.displayPoints = (zoomBounds != nil) + ? sorted + : ChartDownsample.minMaxBucketed(sorted, threshold: ChartDownsample.markThreshold, + targetCount: ChartDownsample.targetVertices) + } + + @State private var hoverX: CGFloat? = nil + /// The zoom window captured at the start of a magnify/drag gesture, so the gesture is applied + /// against a stable anchor instead of compounding each frame. + @State private var gestureAnchorDomain: ClosedRange? = nil + #if os(iOS) + /// True while a touch scrub is engaged (the hold completed). Only used to fire the engage haptic + /// exactly once per scrub — the sequenced gesture can report `.second(true, nil)` more than once. + @State private var scrubEngaged = false + #endif + + /// PERF: the 24h HR line can carry hundreds of samples — more than the ~360pt plot has pixels, so most + /// are sub-pixel pure draw cost. This is the point set actually handed to the line/area marks: + /// full resolution in the Deep Timeline (where the user pinches into sub-windows), else + /// min/max-per-bucket down to ~the plot pixel width. Min/max bucketing keeps every visible spike, so + /// the static Today chart is pixel-identical. Computed ONCE in `init` (not per body/hover eval) so + /// it's memoized on `points`; hover / markers / accessibility stay on the full `points`. + private let displayPoints: [TrendPoint] + + /// Smallest zoom window we allow (1 minute) — past this the line is just two points and pinch jitters. + public static let minZoomSpan: TimeInterval = 60 + + // MARK: Zoom / pan math (pure, testable in isolation) + + /// The visible domain after scaling `base` about `anchorFraction` (0…1 across the window) by + /// `scale` (>1 zooms in), clamped into `bounds` and floored at `minZoomSpan`. Pure. + public static func zoomed(_ base: ClosedRange, scale: Double, anchorFraction: Double, + bounds: ClosedRange, minSpan: TimeInterval = minZoomSpan) -> ClosedRange { + let lo = base.lowerBound.timeIntervalSince1970 + let hi = base.upperBound.timeIntervalSince1970 + let span = hi - lo + guard span > 0, scale > 0 else { return base } + let pivot = lo + span * min(max(anchorFraction, 0), 1) + let boundsSpan = bounds.upperBound.timeIntervalSince1970 - bounds.lowerBound.timeIntervalSince1970 + let newSpan = min(max(span / scale, minSpan), max(boundsSpan, minSpan)) + var newLo = pivot - (pivot - lo) * (newSpan / span) + var newHi = newLo + newSpan + // Clamp inside bounds, preserving span. + if newLo < bounds.lowerBound.timeIntervalSince1970 { + newLo = bounds.lowerBound.timeIntervalSince1970; newHi = newLo + newSpan + } + if newHi > bounds.upperBound.timeIntervalSince1970 { + newHi = bounds.upperBound.timeIntervalSince1970; newLo = newHi - newSpan + } + newLo = max(newLo, bounds.lowerBound.timeIntervalSince1970) + return Date(timeIntervalSince1970: newLo)...Date(timeIntervalSince1970: max(newLo + 1, newHi)) + } + + // MARK: Annotation scoping (pure, testable in isolation) + + /// The single sleep to band for a visible window: the LONGEST candidate overlapping it — the main + /// night, never an afternoon nap — the same pick the classic Today makes for its whole-day HR chart. + /// Extracted pure so the Deep Timeline's annotation parity (#979 spin-off) is headless-testable. + /// Overlap is half-open-ish like Today's filter: a sleep ending exactly at the window start (or + /// starting exactly at its end) does NOT count — it contributes zero visible band. + public static func mainSleep(_ candidates: [SleepSpan], overlapping window: ClosedRange) -> SleepSpan? { + candidates + .filter { $0.end > window.lowerBound && $0.start < window.upperBound } + .max(by: { $0.end.timeIntervalSince($0.start) < $1.end.timeIntervalSince($1.start) }) + } + + /// The workout spans overlapping a visible window (a glyph for an out-of-window workout would be + /// clamp-dragged to the plot edge and lie about its time). Edge-touching spans are kept — mirrors + /// the classic Today's inclusive workout filter. Order preserved. Pure. + public static func workouts(_ candidates: [WorkoutSpan], overlapping window: ClosedRange) -> [WorkoutSpan] { + candidates.filter { $0.end >= window.lowerBound && $0.start <= window.upperBound } + } + + /// The visible domain after panning `base` by `deltaSeconds`, clamped into `bounds` (span preserved). Pure. + public static func panned(_ base: ClosedRange, deltaSeconds: Double, + bounds: ClosedRange) -> ClosedRange { + let lo = base.lowerBound.timeIntervalSince1970 + let hi = base.upperBound.timeIntervalSince1970 + let span = hi - lo + var newLo = lo + deltaSeconds + newLo = min(max(newLo, bounds.lowerBound.timeIntervalSince1970), + bounds.upperBound.timeIntervalSince1970 - span) + newLo = max(newLo, bounds.lowerBound.timeIntervalSince1970) + return Date(timeIntervalSince1970: newLo)...Date(timeIntervalSince1970: newLo + span) + } + + // MARK: Geometry helpers + + /// The x-axis window. When the caller supplies `xRange` (e.g. a full calendar day for a past + /// date), that wins — so a stretch with no samples reads as visible empty space rather than the + /// axis silently collapsing to the data extent. Otherwise it's pinned to the HR data so markers + /// (sleep onset the night before, etc.) can't stretch the timeline. Safe even for sparse input. + private var xDomain: ClosedRange { + // Deep Timeline: the bound zoom window wins over everything (it's what gestures drive). + if let zoomDomain, zoomDomain.upperBound > zoomDomain.lowerBound { return zoomDomain } + if let xRange, xRange.upperBound > xRange.lowerBound { return xRange } + let lo = points.first?.date ?? Date(timeIntervalSince1970: 0) + let hi = points.last?.date ?? lo.addingTimeInterval(3600) + return hi > lo ? lo...hi : lo...lo.addingTimeInterval(3600) + } + + /// The hard clamp a zoom/pan window may never escape: the caller-supplied bounds, else the data extent. + private var zoomClampBounds: ClosedRange { + if let zoomBounds, zoomBounds.upperBound > zoomBounds.lowerBound { return zoomBounds } + if let xRange, xRange.upperBound > xRange.lowerBound { return xRange } + let lo = points.first?.date ?? Date(timeIntervalSince1970: 0) + let hi = points.last?.date ?? lo.addingTimeInterval(3600) + return hi > lo ? lo...hi : lo...lo.addingTimeInterval(3600) + } + + private func clampX(_ d: Date) -> Date { + min(max(d, xDomain.lowerBound), xDomain.upperBound) + } + + /// Map a value onto 0...1 over the value range, for the gradient stops. + private func unit(_ value: Double) -> Double { + let lo = valueRange.lowerBound, hi = valueRange.upperBound + guard hi > lo else { return 0 } + return min(max((value - lo) / (hi - lo), 0), 1) + } + + private var valueGradient: LinearGradient { + LinearGradient(gradient: gradient, startPoint: .bottom, endPoint: .top) + } + + /// The peak HR sample inside a workout window, where its glyph is anchored. + private func peak(in w: WorkoutSpan) -> TrendPoint? { + points + .filter { $0.date >= w.start && $0.date <= w.end } + .max(by: { $0.value < $1.value }) + } + + private func nearestPoint(toX x: CGFloat, proxy: ChartProxy, plot: CGRect) -> TrendPoint? { + guard !points.isEmpty else { return nil } + let relX = x - plot.minX + guard let date: Date = proxy.value(atX: relX) else { return nil } + return points.min(by: { + abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) + }) + } + + // MARK: Mark layers + // + // Only lines/bands live as Chart marks. All text labels + workout glyphs are drawn in the + // overlay (below) via the proxy — Swift Charts' `.annotation` overflow-clamping needs macOS 14 + // and gets clipped by the card's fixed height on 13, so we position labels ourselves. + + @ChartContentBuilder private var marks: some ChartContent { + // Sleep band — shaded region behind the curve (drawn first so the HR line/area sit on top). + if let sleep, sleep.end > xDomain.lowerBound { + RectangleMark( + xStart: .value("Sleep start", clampX(sleep.start)), + xEnd: .value("Sleep end", clampX(sleep.end)) + ) + .foregroundStyle(StrandPalette.sleepDeep.opacity(0.32)) + } + + ForEach(displayPoints) { p in + AreaMark(x: .value("Time", p.date), y: .value("BPM", p.value)) + .interpolationMethod(.catmullRom) + .foregroundStyle( + LinearGradient( + colors: [ + StrandPalette.sample(stops: gradient.toStops(), at: unit(averageValue)).opacity(0.28), + Color.clear + ], + startPoint: .top, endPoint: .bottom + ) + ) + } + ForEach(displayPoints) { p in + LineMark(x: .value("Time", p.date), y: .value("BPM", p.value)) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(valueGradient) + } + + // Wake divider — the sleep→day boundary. Always shown with a sleep band so the band reads + // even before recovery calibrates (when the gold recovery rule is absent). + if let sleep, sleep.end > xDomain.lowerBound, sleep.end < xDomain.upperBound { + RuleMark(x: .value("Wake", clampX(sleep.end))) + .foregroundStyle(StrandPalette.sleepLight.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + } + if let recovery { + RuleMark(x: .value("Recovery", clampX(recovery.date))) + .foregroundStyle(recovery.color.opacity(0.85)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + } + if let effort { + RuleMark(x: .value("Effort", clampX(effort.date))) + .foregroundStyle(effort.color.opacity(0.85)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + } + } + + // MARK: Overlay helpers + + /// Screen x for a date (plot-relative position offset into container coords). nil if off-scale. + private func xPos(_ date: Date, _ proxy: ChartProxy, _ plot: CGRect) -> CGFloat? { + proxy.position(forX: date).map { $0 + plot.minX } + } + + /// Rough label width for edge-clamping (footnote ≈ 6.5pt/char + padding/glyph). + private func estWidth(_ s: String, extra: CGFloat = 18) -> CGFloat { CGFloat(s.count) * 6.5 + extra } + + /// Place a label centred on `x`, clamped so it never spills past the plot edges. + @ViewBuilder + private func placed(_ view: V, atX x: CGFloat, topY: CGFloat, width: CGFloat, plot: CGRect) -> some View { + let half = width / 2 + let cx = min(max(x, plot.minX + half + 4), plot.maxX - half - 4) + view.position(x: cx, y: topY) + } + + @ViewBuilder + private func markerLabels(proxy: ChartProxy, plot: CGRect) -> some View { + let topY = plot.minY + 12 + if let sleep, let label = sleep.label, sleep.end > xDomain.lowerBound { + placed(SleepBandLabel(text: label), + atX: xPos(clampX(sleep.start), proxy, plot) ?? plot.minX, + topY: topY, width: estWidth(label, extra: 34), plot: plot) + } + if let recovery, let rx = xPos(clampX(recovery.date), proxy, plot) { + placed(MarkerLabel(text: recovery.label, color: recovery.color), + atX: rx, topY: topY, width: estWidth(recovery.label), plot: plot) + } + if let effort, let sx = xPos(clampX(effort.date), proxy, plot) { + placed(MarkerLabel(text: effort.label, color: effort.color), + atX: sx, topY: topY, width: estWidth(effort.label), plot: plot) + } + ForEach(workouts) { w in + if let pk = peak(in: w), + let px = xPos(pk.date, proxy, plot), + let pyRel = proxy.position(forY: pk.value) { + let cx = min(max(px, plot.minX + 14), plot.maxX - 14) + WorkoutBadge(symbol: w.symbol, tint: workoutTint) + .position(x: cx, y: max(topY + 26, pyRel + plot.minY - 20)) + } + } + } + + @ViewBuilder + private func hoverLayer(proxy: ChartProxy, plot: CGRect, container: CGSize) -> some View { + if showsHover, let hx = hoverX, + let p = nearestPoint(toX: hx, proxy: proxy, plot: plot), + let pxRel = proxy.position(forX: p.date), + let pyRel = proxy.position(forY: p.value) { + let cx = pxRel + plot.minX + let cy = pyRel + plot.minY + let color = StrandPalette.sample(stops: gradient.toStops(), at: unit(p.value)) + CrosshairRule(x: cx, height: container.height) + HighlightDot(color: color).position(x: cx, y: cy) + PositionedTooltip( + anchor: CGPoint(x: cx, y: cy), + container: container, + tooltip: ChartTooltip(value: valueFormat(p.value), label: dateFormat(p.date), accent: color) + ) + } + } + + #if os(iOS) + /// Touch-and-hold-then-drag scrub (#979 spin-off). The stationary hold (0.25 s within 8 pt) is the + /// gate that separates scrubbing from the pan drag (min 6 pt) and pinch that own immediate movement. + /// LongPressGesture reports no location, so the crosshair appears from the drag phase's coordinates — + /// in practice the first micro-movement of a held finger, which is immediate; the engage haptic marks + /// the mode switch the instant the hold lands. Drives the SAME `hoverX` the Mac pointer hover drives, + /// so the readout (crosshair + dot + tooltip) is byte-identical across input methods. + private var touchScrubGesture: some Gesture { + LongPressGesture(minimumDuration: 0.25, maximumDistance: 8) + .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local)) + .onChanged { value in + guard case .second(true, let drag) = value else { return } + if !scrubEngaged { + scrubEngaged = true + StrandHaptic.selection.play() + } + if let drag { + // Non-animating transaction, same reason as hover (TrendChart #104 flicker). + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { hoverX = drag.location.x } + } + } + .onEnded { _ in + scrubEngaged = false + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { hoverX = nil } + } + } + #endif + + // MARK: Body + + public var body: some View { + Chart { marks } + .chartXScale(domain: xDomain) + .chartYScale(domain: valueRange) + // catmullRom overshoots past the data on sharp turns and the area gradient draws + // unclipped — clip the plot so a spiky HR curve doesn't bleed past the chart (see TrendChart). + .chartPlotStyle { plotArea in plotArea.clipped() } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 5)) { _ in + AxisGridLine().foregroundStyle(StrandPalette.hairline.opacity(0.4)) + AxisValueLabel().foregroundStyle(StrandPalette.textTertiary) + .font(StrandFont.footnote) + } + } + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { _ in + AxisGridLine().foregroundStyle(StrandPalette.hairline.opacity(0.4)) + AxisValueLabel().foregroundStyle(StrandPalette.textTertiary) + .font(StrandFont.footnote) + } + } + .chartOverlay { proxy in + GeometryReader { geo in + let plot = proxy.plotRectCompat(in: geo) + ZStack(alignment: .topLeading) { + markerLabels(proxy: proxy, plot: plot) + hoverLayer(proxy: proxy, plot: plot, container: geo.size) + } + .animation(StrandMotion.fade, value: hoverX) + .contentShape(Rectangle()) + .onContinuousHover(coordinateSpace: .local) { phase in + guard showsHover else { return } + // Non-animating transaction: otherwise crossing the plot edge re-runs the + // line's draw-on animation and flickers the curve (mirrors TrendChart #104). + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { + switch phase { + case .active(let location): hoverX = location.x + case .ended: hoverX = nil + } + } + } + #if os(iOS) + // #979 spin-off — touch scrub. onContinuousHover above is pointer-only, so on iPhone the + // crosshair readout was unreachable (the collapsed a11y summary was the only datum + // affordance; cf. CompareView's touch-scrub note). Touch-and-hold claims the touch for + // scrubbing, then dragging moves the crosshair; lift clears it. Gating behind the hold is + // what keeps zoom/pan untouched: an immediate drag exceeds the hold's max distance and + // still pans (ZoomPanModifier), pinch still zooms, double-tap still resets. `.subviews` + // masks the gesture entirely on the call sites that don't opt in. + .gesture(touchScrubGesture, including: (touchScrub && showsHover) ? .all : .subviews) + #endif + } + } + .frame(height: height) + // Zoom/pan: active whenever a zoom binding is supplied (the Deep Timeline and the Today HR chart). + // Every other call site passes the default `.constant(nil)`, so those static charts keep their exact + // gestures. The modifier itself reads Reduce Motion, so the double-tap reset snaps when it's on. + // #829 follow-up: keyed on `zoomBounds` (the stable "a zoom binding was supplied" discriminator the + // init comment names, set once by the two zooming call sites, nil everywhere else), NOT on the live + // `zoomDomain`, which is nil until the user first zooms, so keying on it left the gestures unmounted + // in exactly the state where the first pinch has to land. `apply` below normalises a window that + // covers the full clamp bounds back to nil, so an un-zoomed drag (a pan of the whole day into + // itself) never flips the hint/Reset row into its "Zoomed in" state. + .modifier(ZoomPanModifier( + isActive: zoomBounds != nil, + isZoomed: { zoomDomain != nil }, + current: { xDomain }, + bounds: zoomClampBounds, + anchor: $gestureAnchorDomain, + apply: { newDomain in + zoomDomain = (newDomain.lowerBound <= zoomClampBounds.lowerBound + && newDomain.upperBound >= zoomClampBounds.upperBound) ? nil : newDomain + }, + reset: { zoomDomain = nil }, + zoom: { base, scale, frac, bounds in Self.zoomed(base, scale: scale, anchorFraction: frac, bounds: bounds) }, + pan: { base, dx, plotWidth, bounds in + // Map a horizontal drag (points) to seconds across the current visible span. + let span = base.upperBound.timeIntervalSince1970 - base.lowerBound.timeIntervalSince1970 + let secPerPoint = plotWidth > 0 ? span / Double(plotWidth) : 0 + return Self.panned(base, deltaSeconds: -dx * secPerPoint, bounds: bounds) + } + )) + // Collapse the Charts marks into ONE meaningful VoiceOver element with a summary, instead of + // letting VoiceOver walk every line/area/rule/rect mark as a separate, contextless axis value + // (matches the sibling TrendChart). The only datum affordance otherwise is hover, which is + // dead on touch — so on iPhone this chart spoke no heart rate at all. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Heart rate, 24 hours")) + .accessibilityValue(Text(accessibilitySummary)) + } + + /// One-line VoiceOver summary: the day's HR (count, mean, range) plus the band/marker context the + /// chart shows visually, so the collapsed element still conveys the whole picture (cf. the Android + /// OverviewHRChart semantics). + private var accessibilitySummary: String { + guard !points.isEmpty else { return String(localized: "No heart-rate data", bundle: .module) } + let values = points.map(\.value) + let lo = values.min() ?? valueRange.lowerBound + let hi = values.max() ?? valueRange.upperBound + var parts = [String(localized: "\(points.count) readings", bundle: .module), + String(localized: "average \(valueFormat(averageValue)) bpm", bundle: .module), + String(localized: "range \(valueFormat(lo)) to \(valueFormat(hi))", bundle: .module)] + if let sleep { + parts.append(String(localized: "asleep \(Self.hoursMinutes(sleep.end.timeIntervalSince(sleep.start)))", bundle: .module)) + } + if let recovery { parts.append(recovery.label) } + if let effort { parts.append(effort.label) } + if !workouts.isEmpty { + parts.append(workouts.count == 1 ? String(localized: "1 workout", bundle: .module) : String(localized: "\(workouts.count) workouts", bundle: .module)) + } + return parts.joined(separator: ", ") + } + + private static func hoursMinutes(_ interval: TimeInterval) -> String { + let total = max(0, Int(interval.rounded())) + let h = total / 3_600, m = (total % 3_600) / 60 + if h > 0 && m > 0 { return "\(h)h \(m)m" } + return h > 0 ? "\(h)h" : "\(m)m" + } +} + +// MARK: - Marker chrome + +/// Sport glyph in a tinted badge, anchored above a workout's HR peak. +private struct WorkoutBadge: View { + let symbol: String + let tint: Color + var body: some View { + Image(systemName: symbol) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .frame(width: 22, height: 22) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint) + ) + .shadow(color: tint.opacity(0.5), radius: 4, y: 1) + .allowsHitTesting(false) + } +} + +/// Small caps read-out for the recovery / strain edge markers (e.g. "67% Recovery"). +private struct MarkerLabel: View { + let text: String + let color: Color + var body: some View { + Text(text) + .font(StrandFont.footnote) + .fontWeight(.semibold) + .foregroundStyle(color) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(StrandPalette.surfaceOverlay.opacity(0.92)) + ) + .fixedSize() + .allowsHitTesting(false) + } +} + +/// Moon glyph + sleep duration, shown at the leading corner of the sleep band. +private struct SleepBandLabel: View { + let text: String + var body: some View { + HStack(spacing: 4) { + Image(systemName: "moon.fill").font(.system(size: 9)) + Text(text).font(StrandFont.footnote).fontWeight(.semibold) + } + .foregroundStyle(StrandPalette.sleepLight) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(StrandPalette.surfaceOverlay.opacity(0.92)) + ) + .fixedSize() + .allowsHitTesting(false) + } +} + +// MARK: - Zoom / pan gesture host +// +// Kept as a standalone modifier so the chart body stays readable and so the platform split (pinch on +// iOS, scroll-to-zoom on macOS; drag-pan on both) lives in one place. No-op when `isActive` is false. + +private struct ZoomPanModifier: ViewModifier { + let isActive: Bool + /// True while the window is zoomed in (a reset is meaningful). Read at double-tap time so a tap on the + /// already-full-day chart does nothing rather than re-triggering a no-op animation. + let isZoomed: () -> Bool + let current: () -> ClosedRange + let bounds: ClosedRange + @Binding var anchor: ClosedRange? + let apply: (ClosedRange) -> Void + /// Drop the zoom window back to the full extent (sets the bound `zoomDomain` to nil). + let reset: () -> Void + let zoom: (ClosedRange, Double, Double, ClosedRange) -> ClosedRange + let pan: (ClosedRange, CGFloat, CGFloat, ClosedRange) -> ClosedRange + + @State private var plotWidth: CGFloat = 1 + // Reduce Motion: the reset snaps instantly when on, otherwise it eases out (the gesture frames + // themselves are never animated, per the per-frame apply below, so only the reset honours this). + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Reset the window, snapping when Reduce Motion is on and easing otherwise. + private func resetZoom() { + guard isZoomed() else { return } + withAnimation(NoopMotion.gated(StrandMotion.interactive, reduced: reduceMotion)) { reset() } + } + + func body(content: Content) -> some View { + guard isActive else { return AnyView(content) } + let drag = DragGesture(minimumDistance: 6) + .onChanged { value in + let base = anchor ?? current() + if anchor == nil { anchor = base } + apply(pan(base, value.translation.width, plotWidth, bounds)) + } + .onEnded { _ in anchor = nil } + + let magnify = MagnificationGesture() + .onChanged { scale in + let base = anchor ?? current() + if anchor == nil { anchor = base } + // Pinch zooms about the window centre (we don't get a focal point from MagnificationGesture). + apply(zoom(base, Double(scale), 0.5, bounds)) + } + .onEnded { _ in anchor = nil } + + // Double-tap anywhere on the plot resets to the full window: the discoverable "back out" affordance + // both platforms share (alongside the explicit Reset button the host shows). It does NOT add an + // accessibility action: the chart collapses to one labelled VoiceOver element with a static value, so + // a hidden tap gesture here is invisible to VoiceOver and never steals its double-tap-to-activate. + let doubleTapReset = TapGesture(count: 2).onEnded { resetZoom() } + + let measured = content.background( + GeometryReader { geo in + Color.clear.onAppear { plotWidth = geo.size.width } + .onChangeCompat(of: geo.size.width) { plotWidth = $0 } + } + ) + + #if os(macOS) + // macOS has no pinch in this context; drag pans. Scroll-to-zoom is handled by the Deep Timeline + // host's scroll modifier (it owns the NSEvent monitor); here we wire pan + the double-tap reset. + return AnyView(measured.gesture(drag).simultaneousGesture(doubleTapReset)) + #else + return AnyView(measured.gesture(magnify) + .simultaneousGesture(drag) + .simultaneousGesture(doubleTapReset)) + #endif + } +} + +#if DEBUG +#Preview("OverviewHRChart") { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let pts: [TrendPoint] = (0..<288).map { i in + let t = now.addingTimeInterval(Double(i) * 300 - 288 * 300) + let base = 58.0 + 18 * abs(sin(Double(i) / 30.0)) + let spike = (i > 200 && i < 215) ? 75.0 : 0 + return TrendPoint(date: t, value: base + spike) + } + return VStack(alignment: .leading, spacing: 12) { + Text("Overview HR").strandOverline() + OverviewHRChart( + points: pts, + sleep: .init(start: pts.first!.date, end: pts.first!.date.addingTimeInterval(6 * 3600 + 6 * 60), label: "6:06"), + workouts: [.init(start: pts[200].date, end: pts[215].date, symbol: "figure.run")], + recovery: .init(date: pts.first!.date.addingTimeInterval(6 * 3600), label: "67% Recovery", color: StrandPalette.recoveryColor(67)), + effort: .init(date: pts.last!.date, label: "12.5 Effort", color: StrandPalette.strainColor(12.5), alignment: .trailing), + valueRange: 45...140 + ) + } + .padding(28) + .frame(width: 760, height: 360) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) +} +#endif +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift index ffb805b2e4..96d726ef4e 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift @@ -3,146 +3,333 @@ import SwiftUI // MARK: - Hex Color Helper public extension Color { - /// Create a Color from a hex string like "#0B0D12" or "0B0D12" (RGB) or "#AARRGGBB" / "RRGGBBAA". - /// Supported lengths: 6 (RGB), 8 (RGBA). - init(hex: String) { + /// Parse a hex string ("#0B0D12" / "0B0D12" RGB, or "#AARRGGBB"/"RRGGBBAA" RGBA) to sRGB + /// components in 0...1. Shared by `Color(hex:)` and the dynamic `Color(light:dark:)` provider. + static func sRGBComponents(hex: String) -> (r: Double, g: Double, b: Double, a: Double) { let raw = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 Scanner(string: raw).scanHexInt64(&int) - let r, g, b, a: Double switch raw.count { case 8: // RRGGBBAA - r = Double((int >> 24) & 0xFF) / 255.0 - g = Double((int >> 16) & 0xFF) / 255.0 - b = Double((int >> 8) & 0xFF) / 255.0 - a = Double(int & 0xFF) / 255.0 + return (Double((int >> 24) & 0xFF) / 255.0, Double((int >> 16) & 0xFF) / 255.0, + Double((int >> 8) & 0xFF) / 255.0, Double(int & 0xFF) / 255.0) default: // RRGGBB (6) and any fallback - r = Double((int >> 16) & 0xFF) / 255.0 - g = Double((int >> 8) & 0xFF) / 255.0 - b = Double(int & 0xFF) / 255.0 - a = 1.0 + return (Double((int >> 16) & 0xFF) / 255.0, Double((int >> 8) & 0xFF) / 255.0, + Double(int & 0xFF) / 255.0, 1.0) } - self.init(.sRGB, red: r, green: g, blue: b, opacity: a) + } + + /// Create a Color from a hex string like "#0B0D12" or "0B0D12" (RGB) or "#AARRGGBB" / "RRGGBBAA". + /// Supported lengths: 6 (RGB), 8 (RGBA). + init(hex: String) { + let c = Color.sRGBComponents(hex: hex) + self.init(.sRGB, red: c.r, green: c.g, blue: c.b, opacity: c.a) + } + + /// A colour that resolves to `light` or `dark` (both hex strings) per the active appearance. + /// Backed by a `UIColor`/`NSColor` dynamic provider, so a single token automatically re-resolves + /// at every one of its call sites when the colour scheme flips — no per-view environment plumbing. + /// This is the whole light-theme strategy: only the token definitions change, never the call sites. + init(light: String, dark: String) { + #if os(watchOS) + // watchOS has no UITraitCollection / dynamic-provider UIColor, and our watch app is effectively + // always dark, so a token resolves straight to its dark hex. No per-scheme plumbing on the wrist. + self.init(hex: dark) + #elseif canImport(UIKit) + self.init(UIColor { trait in + let c = Color.sRGBComponents(hex: trait.userInterfaceStyle == .dark ? dark : light) + return UIColor(red: CGFloat(c.r), green: CGFloat(c.g), blue: CGFloat(c.b), alpha: CGFloat(c.a)) + }) + #elseif canImport(AppKit) + self.init(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let c = Color.sRGBComponents(hex: isDark ? dark : light) + return NSColor(srgbRed: CGFloat(c.r), green: CGFloat(c.g), blue: CGFloat(c.b), alpha: CGFloat(c.a)) + }) + #else + self.init(hex: dark) + #endif } } // MARK: - Strand Palette // -// Every semantic token from design spec §9.1. Dark-only, instrument-grade. -// Hex values are exact per the spec — do not substitute. +// The "Ink & Bloom" re-skin: a strictly black/white/gray structural base (surfaces, text, the +// titanium ramp) carrying a single violet → purple → fuchsia → pink accent system for every +// domain (Charge, Effort, Rest, Stress, HR zones, status). Hue walks one continuous path — +// cooler/violet reads calm or "good", warmer/pink reads hot or urgent — and every ramp shifts +// lightness in step with hue, since pink/purple/violet sit close together for red-green +// colorblind viewers and hue alone isn't a reliable signal at this end of the wheel. +// +// PUBLIC API IS FROZEN: every property name below is depended on by screens across +// macOS / iOS, so the names never change — only the VALUES were re-themed (still called "gold" +// and "titanium" in code for that reason). New Titanium & Gold tokens (gold ramp, titanium ramp, +// gradients) are ADDED at the end of the type; nothing existing was removed or renamed. public enum StrandPalette { - // MARK: Surfaces (§9.1) - public static let surfaceBase = Color(hex: "#060A08") // near-black, faint green (brief) - public static let surfaceRaised = Color(hex: "#0D1512") // dark green-black cards - public static let surfaceOverlay = Color(hex: "#121D18") // raised / popovers / sheets - public static let surfaceInset = Color(hex: "#0A100D") // wells / chart insets - public static let hairline = Color(hex: "#1B2620") // soft green-grey 1px border - public static let hairlineStrong = Color(hex: "#27362E") // hover / emphasis border - - // MARK: Text (§9.1) - public static let textPrimary = Color(hex: "#F4F7F5") - public static let textSecondary = Color(hex: "#8B9690") - public static let textTertiary = Color(hex: "#6F7A74") - - // MARK: Glow (§9.1) - public static let glowAmbient = Color(hex: "#1B2A3A") - - // MARK: Accent — chrome, not data (§9.1) - public static let accent = Color(hex: "#18C98B") // health green (brief) - public static let accentHover = Color(hex: "#2FE0A0") - public static let accentMuted = Color(hex: "#10271F") // dark-green tint (selected rows) - /// Focus ring color (same as accent). - public static let focusRing = Color(hex: "#18C98B") + // MARK: Surfaces — black canvas, tinted frosted cards + // Background is a near-black (NOT pure black); cards float just above it. A whisper of cool + // violet in the undertone keeps the neutral feeling chosen rather than default. + public static let surfaceBase = Color(light: "#FAF9FB", dark: "#0E0C11") + public static let surfaceRaised = Color(light: "#FFFFFF", dark: "#1B171F") // card & list-row fill + public static let surfaceOverlay = Color(light: "#FFFFFF", dark: "#221D27") // popovers / sheets / tooltips + public static let surfaceInset = Color(light: "#F0EEF3", dark: "#191520") // wells / chart insets / segmented track + public static let hairline = Color(light: "#E4E0EA", dark: "#2C2733") // soft 1px border (stronger on light for card edges) + public static let hairlineStrong = Color(light: "#D3CCDC", dark: "#3B3444") // hover / emphasis border + + // MARK: Text — near-black ink on paper / near-white on black + public static let textPrimary = Color(light: "#14111A", dark: "#F7F5F9") + public static let textSecondary = Color(light: "#4A4553", dark: "#C9C3D1") + public static let textTertiary = Color(light: "#85808D", dark: "#8B8594") + + // MARK: Glow — ambient bloom behind heroes / charts (additive on dark; faint wash on light) + public static let glowAmbient = Color(light: "#F3E4F0", dark: "#2C1230") + + // MARK: Accent — chrome anchor (links, selection, focus, generic accent). A vivid fuchsia on + // both schemes, the boldest statement in the system — deeper on light for contrast against + // white, brighter on dark for contrast against near-black. + public static let accent = Color(light: "#A21CAF", dark: "#E879F9") + public static let accentHover = Color(light: "#86198F", dark: "#F0ABFC") + public static let accentMuted = Color(light: "#FAE8FF", dark: "#3B0764") // selected-row tint + /// Focus ring color. + public static let focusRing = Color(light: "#C026D3", dark: "#E879F9") /// Opacity for dimmed/disabled sections (shared so screens don't invent their own value). public static let disabledOpacity: Double = 0.45 - // MARK: Recovery gradient — vitaltrends-style traffic light (low red → high green). - // 0.00 red → 0.30 amber → 0.55 gold → 0.78 green → 1.00 emerald-mint. - public static let recovery000 = Color(hex: "#FF4F73") // depleted — pink-red (brief) - public static let recovery030 = Color(hex: "#F5A623") // low — amber - public static let recovery055 = Color(hex: "#E8C24B") // moderate — gold - public static let recovery078 = Color(hex: "#18C98B") // primed — health green - public static let recovery100 = Color(hex: "#2FE6A8") // peak — bright green - - /// Ordered gradient stops for the recovery scale (location + color). - public static let recoveryStops: [Gradient.Stop] = [ - .init(color: recovery000, location: 0.00), - .init(color: recovery030, location: 0.30), - .init(color: recovery055, location: 0.55), - .init(color: recovery078, location: 0.78), - .init(color: recovery100, location: 1.00), + // MARK: - Chart style (data-viz colour mode) — Titanium or Classic + // + // Set from `@AppStorage(ChartStyle.storageKey)` at the app root. The DATA-RAMP accessors below + // (recoveryStops, strainStops, hrZones, sleepStageColor, stress gradient, status, metric, and the + // DomainTheme worlds) branch on this. Classic now mirrors Titanium's pink/purple values 1:1 — the + // old red→green throwback scale can't exist inside a pink/purple-only system — but the switch and + // its plumbing are kept as-is since ChartStyle is still a public, persisted setting call sites read. + // Chrome (surfaces, text, accent) is never touched. + public static var chartStyle: ChartStyle = .titanium + @inline(__always) static var isClassic: Bool { chartStyle == .classic } + + // MARK: Classic ramps — mirrors the Titanium values below 1:1 (see chartStyle note above). + static let cRecovery000 = Color(light: "#BE185D", dark: "#EC4899") + static let cRecovery030 = Color(light: "#DB2777", dark: "#F472B6") + static let cRecovery055 = Color(light: "#C026D3", dark: "#E879F9") + static let cRecovery078 = Color(light: "#9333EA", dark: "#C084FC") + static let cRecovery100 = Color(light: "#7C3AED", dark: "#A78BFA") + static let cRecoveryStops: [Gradient.Stop] = [ + .init(color: cRecovery000, location: 0.00), .init(color: cRecovery030, location: 0.30), + .init(color: cRecovery055, location: 0.55), .init(color: cRecovery078, location: 0.78), + .init(color: cRecovery100, location: 1.00), ] - - /// The signature recovery gradient (indigo → mint). - public static let recoveryGradient = Gradient(stops: recoveryStops) - - // MARK: Strain ramp — ember → magenta (§9.1) - public static let strain000 = Color(hex: "#E8B04B") // ember / warm gold - public static let strain033 = Color(hex: "#E8743B") // orange - public static let strain066 = Color(hex: "#E0476B") // rose-red - public static let strain100 = Color(hex: "#C13AC1") // magenta - - public static let strainStops: [Gradient.Stop] = [ - .init(color: strain000, location: 0.00), - .init(color: strain033, location: 0.33), - .init(color: strain066, location: 0.66), - .init(color: strain100, location: 1.00), + static let cStrain000 = Color(light: "#4C1D95", dark: "#6D28D9") + static let cStrain033 = Color(light: "#6D28D9", dark: "#8B5CF6") + static let cStrain066 = Color(light: "#9333EA", dark: "#C084FC") + static let cStrain100 = Color(light: "#C026D3", dark: "#F0ABFC") + static let cStrainStops: [Gradient.Stop] = [ + .init(color: cStrain000, location: 0.00), .init(color: cStrain033, location: 0.33), + .init(color: cStrain066, location: 0.66), .init(color: cStrain100, location: 1.00), + ] + static let cSleepAwake = Color(light: "#B4A8C4", dark: "#D6CBE3") + static let cSleepLight = Color(light: "#A855F7", dark: "#D8B4FE") + static let cSleepDeep = Color(light: "#5B21B6", dark: "#7C3AED") + static let cSleepREM = Color(light: "#C026D3", dark: "#E879F9") + static let cZone1 = Color(light: "#A78BFA", dark: "#C4B5FD") + static let cZone2 = Color(light: "#A855F7", dark: "#C084FC") + static let cZone3 = Color(light: "#C026D3", dark: "#E879F9") + static let cZone4 = Color(light: "#DB2777", dark: "#F472B6") + static let cZone5 = Color(light: "#9D174D", dark: "#EC4899") + static let cStressStops: [Gradient.Stop] = [ + .init(color: Color(light: "#8B5CF6", dark: "#C4B5FD"), location: 0.0), + .init(color: Color(light: "#C026D3", dark: "#E879F9"), location: 0.5), + .init(color: Color(light: "#BE185D", dark: "#EC4899"), location: 1.0), ] - /// The strain gradient (output / heat). - public static let strainGradient = Gradient(stops: strainStops) + // MARK: Recovery / Charge gradient — the fuchsia "Charge" colour world. + // Pink at depleted sweeping through fuchsia and purple to a calm violet peak — hue AND + // lightness both shift stop to stop, so the ramp stays legible even where hue alone is hard + // to tell apart. + // 0.00 pink → 0.30 rose → 0.55 fuchsia → 0.78 purple → 1.00 violet. + public static let recovery000 = Color(light: "#BE185D", dark: "#EC4899") // depleted + public static let recovery030 = Color(light: "#DB2777", dark: "#F472B6") // low + public static let recovery055 = Color(light: "#C026D3", dark: "#E879F9") // moderate + public static let recovery078 = Color(light: "#9333EA", dark: "#C084FC") // primed + public static let recovery100 = Color(light: "#7C3AED", dark: "#A78BFA") // peak + + /// Ordered gradient stops for the recovery scale (Titanium pink→violet ramp, or Classic, which mirrors it). + public static var recoveryStops: [Gradient.Stop] { + isClassic ? cRecoveryStops : [ + .init(color: recovery000, location: 0.00), + .init(color: recovery030, location: 0.30), + .init(color: recovery055, location: 0.55), + .init(color: recovery078, location: 0.78), + .init(color: recovery100, location: 1.00), + ] + } - // MARK: Sleep stages (§9.1) - public static let sleepAwake = Color(hex: "#E0476B") // rose - public static let sleepLight = Color(hex: "#5C6FB1") // periwinkle - public static let sleepDeep = Color(hex: "#2C3A7A") // deep indigo - public static let sleepREM = Color(hex: "#5BE0C7") // mint (glows) + /// The signature recovery gradient (pink → violet, or Classic, which mirrors it). + public static var recoveryGradient: Gradient { Gradient(stops: recoveryStops) } + + // MARK: Strain / Effort ramp — the violet "Effort" colour world. + // Deep violet → mid violet/purple → bright purple → fuchsia peak: kept off pink entirely so + // Effort never reads as the same domain as Charge at a glance. + public static let strain000 = Color(light: "#4C1D95", dark: "#6D28D9") // deep + public static let strain033 = Color(light: "#6D28D9", dark: "#8B5CF6") // low-mid + public static let strain066 = Color(light: "#9333EA", dark: "#C084FC") // high-mid + public static let strain100 = Color(light: "#C026D3", dark: "#F0ABFC") // peak + + public static var strainStops: [Gradient.Stop] { + isClassic ? cStrainStops : [ + .init(color: strain000, location: 0.00), + .init(color: strain033, location: 0.33), + .init(color: strain066, location: 0.66), + .init(color: strain100, location: 1.00), + ] + } - // MARK: HR zones (§9.1) - public static let zone1 = Color(hex: "#4FA9C9") - public static let zone2 = Color(hex: "#5BD3A0") - public static let zone3 = Color(hex: "#E8C24B") - public static let zone4 = Color(hex: "#E8743B") - public static let zone5 = Color(hex: "#E0476B") + /// The strain gradient (violet → fuchsia, or Classic, which mirrors it). + public static var strainGradient: Gradient { Gradient(stops: strainStops) } - /// HR zones indexed 1...5; index 0 mirrors zone1 for convenience. - public static let hrZones: [Color] = [zone1, zone1, zone2, zone3, zone4, zone5] + // MARK: Sleep stages — muted lavender awake, deepening through violet; REM the vivid outlier. + public static var sleepAwake: Color { isClassic ? cSleepAwake : Color(light: "#B4A8C4", dark: "#D6CBE3") } + public static var sleepLight: Color { isClassic ? cSleepLight : Color(light: "#A855F7", dark: "#D8B4FE") } + public static var sleepDeep: Color { isClassic ? cSleepDeep : Color(light: "#5B21B6", dark: "#7C3AED") } + public static var sleepREM: Color { isClassic ? cSleepREM : Color(light: "#C026D3", dark: "#E879F9") } - // MARK: Status (§9.1) — never reused as recovery colors. - public static let statusPositive = Color(hex: "#18C98B") - public static let statusWarning = Color(hex: "#F5A623") - public static let statusCritical = Color(hex: "#FF4F73") + // MARK: HR zones — cool violet (resting) climbing to warm pink (max), or the Classic mirror. + public static var zone1: Color { isClassic ? cZone1 : Color(light: "#A78BFA", dark: "#C4B5FD") } + public static var zone2: Color { isClassic ? cZone2 : Color(light: "#A855F7", dark: "#C084FC") } + public static var zone3: Color { isClassic ? cZone3 : Color(light: "#C026D3", dark: "#E879F9") } + public static var zone4: Color { isClassic ? cZone4 : Color(light: "#DB2777", dark: "#F472B6") } + public static var zone5: Color { isClassic ? cZone5 : Color(light: "#9D174D", dark: "#EC4899") } - // MARK: Per-metric accents (brief) — Apple-Health bars / HRV / energy / risk. - public static let metricCyan = Color(hex: "#2FC7FF") // Apple Health bars - public static let metricPurple = Color(hex: "#A879FF") // HRV / strain-style data - public static let metricAmber = Color(hex: "#F5A623") // calories / moderate - public static let metricRose = Color(hex: "#FF4F73") // risk / high strain / low recovery + /// HR zones indexed 1...5; index 0 mirrors zone1 for convenience. + public static var hrZones: [Color] { [zone1, zone1, zone2, zone3, zone4, zone5] } + + // MARK: Status — violet = good, fuchsia = caution, hot pink = critical. No red/green shorthand + // survives inside a pink/purple-only system; this hierarchy is real but learned, not instant. + public static var statusPositive: Color { isClassic ? Color(light: "#6D28D9", dark: "#A78BFA") : Color(light: "#6D28D9", dark: "#A78BFA") } + public static var statusWarning: Color { isClassic ? Color(light: "#A21CAF", dark: "#E879F9") : Color(light: "#A21CAF", dark: "#E879F9") } + public static var statusCritical: Color { isClassic ? Color(light: "#BE185D", dark: "#EC4899") : Color(light: "#BE185D", dark: "#EC4899") } + + // MARK: Per-metric accents — HRV / SpO₂ / energy / risk, each a distinct pink/purple hue so + // they stay individually identifiable. Classic mirrors Titanium (see chartStyle note above). + public static var metricCyan: Color { isClassic ? Color(light: "#8B5CF6", dark: "#C4B5FD") : Color(light: "#8B5CF6", dark: "#C4B5FD") } + public static var metricPurple: Color { isClassic ? Color(light: "#9333EA", dark: "#D8B4FE") : Color(light: "#9333EA", dark: "#D8B4FE") } + public static var metricAmber: Color { isClassic ? Color(light: "#DB2777", dark: "#F9A8D4") : Color(light: "#DB2777", dark: "#F9A8D4") } + public static var metricRose: Color { isClassic ? Color(light: "#BE185D", dark: "#EC4899") : Color(light: "#BE185D", dark: "#EC4899") } + + // MARK: - Titanium & Gold domain "colour worlds" (NEW) + // + // Each daily score owns a two-stop accent gradient (deep → bright) plus a glow. These drive + // the layered gauges, frosted-card tints and scenic heroes. Charge owns fuchsia; Effort stays + // violet-only; Rest is deliberately the most desaturated, grayed-lavender domain; Stress arcs + // calm-violet → fuchsia → alarmed-pink. + + // Classic mirrors Titanium 1:1 for every domain below (see chartStyle note above), so the + // ternaries stay purely structural now. The gauge ARC itself samples the recovery/strain/stress + // STOPS above, unaffected by these. + + /// Charge (recovery) — fuchsia world. + public static var chargeColor: Color { isClassic ? Color(light: "#C026D3", dark: "#E879F9") : Color(light: "#C026D3", dark: "#E879F9") } + public static var chargeDeep: Color { isClassic ? Color(light: "#86198F", dark: "#C026D3") : Color(light: "#86198F", dark: "#C026D3") } + public static var chargeBright: Color { isClassic ? Color(light: "#F472B6", dark: "#F9A8D4") : Color(light: "#F472B6", dark: "#F9A8D4") } + public static var chargeGlow: Color { isClassic ? Color(light: "#C026D3", dark: "#E879F9") : Color(light: "#C026D3", dark: "#E879F9") } + /// Diagonal accent pair for the Charge card wash + gauge stroke (deep → bright). + public static var chargeGradient: Gradient { Gradient(colors: [chargeDeep, chargeBright]) } + + /// Effort (strain) — violet world. + public static var effortColor: Color { isClassic ? Color(light: "#7C3AED", dark: "#A78BFA") : Color(light: "#7C3AED", dark: "#A78BFA") } + public static var effortDeep: Color { isClassic ? Color(light: "#5B21B6", dark: "#7C3AED") : Color(light: "#5B21B6", dark: "#7C3AED") } + public static var effortBright: Color { isClassic ? Color(light: "#C084FC", dark: "#D8B4FE") : Color(light: "#C084FC", dark: "#D8B4FE") } + public static var effortGlow: Color { isClassic ? Color(light: "#7C3AED", dark: "#A78BFA") : Color(light: "#7C3AED", dark: "#A78BFA") } + public static var effortGradient: Gradient { Gradient(colors: [effortDeep, effortBright]) } + + /// Rest (sleep) — the quietest domain: muted, grayed lavender. + public static var restColor: Color { isClassic ? Color(light: "#7C6E94", dark: "#A79BC0") : Color(light: "#7C6E94", dark: "#A79BC0") } + public static var restDeep: Color { isClassic ? Color(light: "#5B21B6", dark: "#7C3AED") : Color(light: "#5B21B6", dark: "#7C3AED") } + public static var restBright: Color { isClassic ? Color(light: "#C084FC", dark: "#D8B4FE") : Color(light: "#C084FC", dark: "#D8B4FE") } + public static var restGlow: Color { isClassic ? Color(light: "#A855F7", dark: "#C084FC") : Color(light: "#A855F7", dark: "#C084FC") } + public static var restGradient: Gradient { Gradient(colors: [restDeep, restBright]) } + + /// Stress — calm violet → fuchsia → alarmed pink. + public static var stressColor: Color { isClassic ? Color(light: "#C026D3", dark: "#E879F9") : Color(light: "#C026D3", dark: "#E879F9") } + public static var stressDeep: Color { isClassic ? Color(light: "#8B5CF6", dark: "#C4B5FD") : Color(light: "#8B5CF6", dark: "#C4B5FD") } + public static var stressBright: Color { isClassic ? Color(light: "#BE185D", dark: "#EC4899") : Color(light: "#BE185D", dark: "#EC4899") } + public static var stressGlow: Color { isClassic ? Color(light: "#C026D3", dark: "#E879F9") : Color(light: "#C026D3", dark: "#E879F9") } + /// 3-stop gauge ramp: calm → balanced → high. + public static var stressGradient: Gradient { Gradient(colors: [stressDeep, stressColor, stressBright]) } + + // MARK: Scenic background (NEW) — detail-screen hero gradient + starfield. Soft plum atmosphere. + /// Radial canvas: lit center → deep edge. Used by `ScenicHeroBackground`. + public static let scenicCenter = Color(light: "#FBF0FA", dark: "#1D1626") + public static let scenicEdge = Color(light: "#F0DFF0", dark: "#100C16") + /// Star tint for the scenic starfield (very faint on light; the hero suppresses stars there). + public static let scenicStar = Color(light: "#DCC6E0", dark: "#C9C3D1") + + /// Frosted-card tint endpoints (white→blush on light; the accent wash sits over them). + public static let cardFillTop = Color(light: "#FFFFFF", dark: "#241A30") + public static let cardFillBottom = Color(light: "#FBF3FA", dark: "#140D1C") + + // MARK: - Titanium & Gold core tokens (NEW) + // + // The signature fuchsia ramp (buttons, ring fills, FAB, active chrome) and the neutral + // titanium ramp (tiles, avatars, icon plates). Same names + hexes on Android so + // Apple and Android match byte-for-byte. + + /// Brand accent — primary. FILLS stay bright (dark text on them is legible in both schemes); + /// only a hair deeper on light so the fill doesn't wash out against white. + public static let gold = Color(light: "#A21CAF", dark: "#E879F9") + /// Accent highlight / hover. + public static let goldLight = Color(light: "#E879F9", dark: "#F0ABFC") + /// Accent low stop. + public static let goldDeep = Color(light: "#86198F", dark: "#C026D3") + /// White — text / icons placed ON accent surfaces (scheme-invariant; accent fills stay accent). + public static let goldDeepText = Color(hex: "#FFFFFF") + /// The bright core dot at a gauge arc tip / sparkline head. White reads as a highlight on the dark + /// canvas; on light it would vanish into the white card, so it flips to a deep ink that reads as a + /// crisp centre on the (deepened) coloured tip bead. + public static let tipCore = Color(light: "#1A1420", dark: "#FFFFFF") + /// High-vis signal — sparing emphasis (badges / alerts); the most saturated pink in the system. + public static let signalYellow = Color(light: "#DB2777", dark: "#F9A8D4") + /// 135–155° accent ramp for buttons, ring fills, FAB (light → mid → deep). + public static let goldGradient = Gradient(colors: [goldLight, gold, goldDeep]) + + /// Brushed-titanium ramp (top highlight → mid body → low → deep) for tiles, avatars and icon plates. + /// Shifted to a MID-grey ramp on light so brushed-metal tiles stay visible against white cards. + public static let titaniumTop = Color(light: "#EDEAF0", dark: "#F1EEF5") + public static let titaniumMid = Color(light: "#C9C2D1", dark: "#D2CBDA") + public static let titaniumLow = Color(light: "#A79FB0", dark: "#A39AAD") + public static let titaniumDeep = Color(hex: "#5C5566") + /// 150° titanium ramp for tiles / avatars / icon plates. + public static let titaniumGradient = Gradient(colors: [titaniumTop, titaniumMid, titaniumLow, titaniumDeep]) // MARK: - Sampling helpers - /// Sample the recovery gradient (indigo → mint) at a recovery score 0...100. + /// Sample the recovery gradient (pink → violet) at a recovery score 0...100. /// Returns the exact interpolated color used everywhere recovery is tinted. public static func recoveryColor(_ score: Double) -> Color { sample(stops: recoveryStops, at: score / 100.0) } - /// Sample the strain gradient at a strain value on the 0...21 Whoop scale. + /// Sample the strain ("Effort") gradient at a value on NOOP's 0...100 Effort scale. public static func strainColor(_ strain: Double) -> Color { - sample(stops: strainStops, at: strain / 21.0) + sample(stops: strainStops, at: strain / 100.0) + } + + /// Effort tint sampled by a 0...1 fraction (e.g. value/scaleMax), spreading the full violet→fuchsia + /// ramp. Prefer this for gauge tips / value-tinted accents so a high Effort reads as bright fuchsia + /// rather than deep violet. `strainColor(_:)` stays for callers holding a 0...100 value. + public static func effortTint(fraction: Double) -> Color { + sample(stops: strainStops, at: min(max(fraction, 0), 1)) } /// The state word for a recovery score, per spec §9.3. /// DEPLETED · LOW · MODERATE · PRIMED · PEAK public static func recoveryState(_ score: Double) -> String { switch score { - case ..<25: return "DEPLETED" - case ..<50: return "LOW" - case ..<70: return "MODERATE" - case ..<88: return "PRIMED" - default: return "PEAK" + case ..<25: return String(localized: "DEPLETED", bundle: .module) + case ..<50: return String(localized: "LOW", bundle: .module) + case ..<70: return String(localized: "MODERATE", bundle: .module) + case ..<88: return String(localized: "PRIMED", bundle: .module) + default: return String(localized: "PEAK", bundle: .module) } } @@ -190,8 +377,8 @@ public enum StrandPalette { /// Linear-interpolate two colors in sRGB space. static func interpolate(_ a: Color, _ b: Color, _ t: Double) -> Color { - let ca = a.rgbaComponents - let cb = b.rgbaComponents + let ca = ColorComponentCache.components(of: a) + let cb = ColorComponentCache.components(of: b) let tt = min(max(t, 0.0), 1.0) return Color( .sRGB, @@ -203,6 +390,63 @@ public enum StrandPalette { } } +// MARK: - Resolved-component memo cache +// +// PERF: `interpolate(_:_:_:)` is the leaf of ALL gradient sampling — every sparkline point, every pip +// segment, every gauge tip, every heat-strip cell calls `sample(stops:at:)` → `interpolate`, which used +// to build a fresh UIColor/NSColor and run `getRed()` on BOTH endpoints on every single call. The stop +// colours are a tiny fixed set of static `let`s, so resolving them over and over dominated the draw. +// +// This memoizes the resolved sRGB components per Color. Crucially the cache is keyed on the CURRENT +// resolved appearance as well as the Color, because the palette tokens are dynamic `Color(light:dark:)` +// providers that resolve to DIFFERENT components per light/dark — so a bare Color key would return a +// stale, wrong-scheme value after an appearance flip. Including the appearance token in the key makes +// the cache miss (and re-resolve) exactly when the scheme changes, so the output stays byte-identical to +// calling `rgbaComponents` directly. Bounded so a pathological caller can't grow it without limit. +enum ColorComponentCache { + private static var store: [Key: (r: Double, g: Double, b: Double, a: Double)] = [:] + private static let lock = NSLock() + + private struct Key: Hashable { + let color: Color + let appearance: Int + } + + /// A small integer identifying the current resolved appearance (light vs dark), matching the trait + /// that `UIColor(color)` / `NSColor(color)` resolves against at this call site. + private static var appearanceToken: Int { + #if os(watchOS) + // No UITraitCollection on watchOS; the watch app is always dark, so the cache key is constant. + return 1 + #elseif canImport(UIKit) + return UITraitCollection.current.userInterfaceStyle == .dark ? 1 : 0 + #elseif canImport(AppKit) + let match = NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua]) + return match == .darkAqua ? 1 : 0 + #else + return 0 + #endif + } + + static func components(of color: Color) -> (r: Double, g: Double, b: Double, a: Double) { + let key = Key(color: color, appearance: appearanceToken) + lock.lock() + if let hit = store[key] { + lock.unlock() + return hit + } + lock.unlock() + let resolved = color.rgbaComponents + lock.lock() + // Cap the cache so an adversarial stream of unique colours can't grow it unboundedly; the real + // working set is the handful of static palette stops, so this ceiling is never hit in practice. + if store.count > 512 { store.removeAll(keepingCapacity: true) } + store[key] = resolved + lock.unlock() + return resolved + } +} + // MARK: - Sleep stage enum (shared with Hypnogram) public enum SleepStage: String, CaseIterable, Sendable { @@ -214,9 +458,9 @@ public enum SleepStage: String, CaseIterable, Sendable { /// Display label. public var label: String { switch self { - case .awake: return "Awake" - case .light: return "Light" - case .deep: return "Deep" + case .awake: return String(localized: "Awake", bundle: .module) + case .light: return String(localized: "Light", bundle: .module) + case .deep: return String(localized: "Deep", bundle: .module) case .rem: return "REM" } } @@ -275,6 +519,19 @@ extension Color { ("hover", StrandPalette.accentHover), ("muted", StrandPalette.accentMuted), ]) + swatchRow("Gold", [ + ("gold", StrandPalette.gold), + ("light", StrandPalette.goldLight), + ("deep", StrandPalette.goldDeep), + ("deepText", StrandPalette.goldDeepText), + ("signal", StrandPalette.signalYellow), + ]) + swatchRow("Titanium", [ + ("top", StrandPalette.titaniumTop), + ("mid", StrandPalette.titaniumMid), + ("low", StrandPalette.titaniumLow), + ("deep", StrandPalette.titaniumDeep), + ]) VStack(alignment: .leading, spacing: 8) { Text("RECOVERY GRADIENT").font(.caption).foregroundStyle(StrandPalette.textTertiary) LinearGradient(gradient: StrandPalette.recoveryGradient, startPoint: .leading, endPoint: .trailing) diff --git a/Packages/StrandDesign/Sources/StrandDesign/PipBar.swift b/Packages/StrandDesign/Sources/StrandDesign/PipBar.swift new file mode 100644 index 0000000000..2232d92972 --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/PipBar.swift @@ -0,0 +1,286 @@ +import SwiftUI + +// MARK: - PipBar (the NOOP segmented count-up bar) +// +// A horizontal row of N equal rounded segments ("pips") separated by small uniform gaps. Segments +// from the left up to the value's fraction are filled with the tint; the rest stay the track colour. +// The last filled segment is a touch brighter (the lead edge). This is the NOOP signature for showing +// a 0…max value — a flat, crisp, WHOOP-grade alternative to a smooth progress bar. +// +// COUNT-UP: on first appear AND on every value change, the fill cascades segment-by-segment from 0 up +// to the value in a quick eased sweep (~0.5–0.7s). The whole effect is driven by a SINGLE animated +// fraction on a spring — each segment derives its own fill from that one value over a short per-segment +// ramp, so the pips appear to light up in sequence with NO per-segment timers (cheap to animate). +// Reduce Motion → the fraction is set instantly and the bar renders static at its final frame. +// +// HARD constraints honoured: NO GLOW (flat fills only), TOKENS only (surfaceInset track, tint fill), +// crisp high-contrast, PUBLIC stable API, self-contained in this file. +// +// Two surfaces: +// • `PipBar` — just the segmented bar, for inline use under a value. +// • `PipBarRow` — the card-ready WHOOP metric row: UPPERCASE label + big white value/unit on top, +// the PipBar beneath. + +// MARK: - PipBar + +public struct PipBar: View { + + /// The value to display, in `range`. + public var value: Double + /// The value's domain (mapped to 0…1 across the bar). Defaults to a percentage scale. + public var range: ClosedRange + /// Number of segments ("pips"). Higher = finer resolution. Default 24. + public var segments: Int + /// The fill colour for lit segments (a domain / status / score token). + public var tint: Color + /// Bar height in points. + public var height: CGFloat + + public init( + value: Double, + range: ClosedRange = 0...100, + segments: Int = 24, + tint: Color, + height: CGFloat = 10 + ) { + self.value = value + self.range = range + self.segments = segments + self.tint = tint + self.height = height + } + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The single animated driver: a 0…1 fraction that the whole bar derives from. One eased sweep moves + /// it 0 → target so segments light in sequence; Reduce Motion snaps it to the target with no animation. + @State private var animatedFraction: Double = 0 + + /// The count-up curve: a quick eased cascade (~0.6s total) so the pips light left→right. Suppressed to + /// `nil` under Reduce Motion so `withAnimation` sets the fraction instantly and the bar renders static. + /// Self-contained here (no edit to StrandMotion); mirrors `StrandMotion.drawIn(reduced:)`'s pattern. + private var countUp: Animation? { + reduceMotion ? nil : .easeOut(duration: 0.6) + } + + /// The target fill fraction (value mapped into 0…1, clamped). + private var targetFraction: Double { + let lo = range.lowerBound + let hi = range.upperBound + guard hi > lo else { return 0 } + return min(max((value - lo) / (hi - lo), 0), 1) + } + + /// Effective segment count (always ≥ 1 so we never divide by zero or draw nothing). + private var pipCount: Int { max(1, segments) } + + public var body: some View { + // Gap scales with height so the bar reads consistently at any size; pips stay rounded (rx ~2.5). + let gap: CGFloat = max(2, height * 0.28) + let corner: CGFloat = 2.5 + + // Precompute the three constant colours ONCE per body eval rather than re-deriving them inside + // every one of the (default 24) segment closures: the inset track, the plain tint, and the + // brightened lead-edge tint (which itself runs an interpolate → Color(hex:) allocation). Combined + // with the Palette memoization this removes the per-segment colour allocations across the bar. + let track = StrandPalette.surfaceInset + let leadBase = brighten(tint) + + HStack(spacing: gap) { + ForEach(0.. Color { + let n = Double(pipCount) + let segStart = Double(index) / n + let segEnd = Double(index + 1) / n + let f = animatedFraction + + // How much of THIS segment is covered by the current fraction (0…1 across the segment span). + let local: Double + if f >= segEnd { + local = 1 + } else if f <= segStart { + local = 0 + } else { + local = (f - segStart) / (segEnd - segStart) // span is 1/n, always > 0 + } + + // Track colour for unlit pips: surfaceInset is the canonical well; fall back to a faint hairline + // feel by mixing toward the track for partially-lit pips so the cascade edge reads cleanly. + if local <= 0 { return track } + + // Lit pips use the tint; the segment holding the live lead edge (target fraction sits inside it) + // is nudged a touch brighter for a crisp leading highlight. Flat — no glow. (`leadBase` is the + // brightened tint, precomputed once in `body`.) + let isLeadEdge = targetFraction > segStart && targetFraction <= segEnd + let base = isLeadEdge ? leadBase : tint + + // Partially-covered pip (the moving front of the cascade): blend track → fill by coverage so the + // sweep edge is smooth, not stepped. Fully covered pips are the solid fill. + return local >= 1 ? base : StrandPalette.interpolate(track, base, local) + } + + /// Pure white, built once (not a fresh `Color(hex:)` per `brighten` call). + private static let white = Color(hex: "#FFFFFF") + + /// A small, glow-free brightness lift for the lead-edge segment — blend the tint toward white. + private func brighten(_ color: Color) -> Color { + StrandPalette.interpolate(color, Self.white, 0.22) + } + + private var axValue: String { + // Report the raw value in its range, rounded for speech. + let v = (value * 10).rounded() / 10 + let shown = v.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(v)) : String(v) + return shown + } +} + +// MARK: - PipBarRow (card-ready WHOOP metric row) + +/// A card-ready row: UPPERCASE label + big white value/unit on top, the `PipBar` beneath. Matches the +/// WHOOP metric-row type — bold white number with a smaller-weight unit suffix over a tracked overline +/// label. Drop into a `StrandCard` for an instant metric tile. +public struct PipBarRow: View { + + /// UPPERCASE-style label (rendered with overline tracking + textCase upper). + public var label: LocalizedStringKey + /// The value for the bar, in `range`. + public var value: Double + /// The value's domain. + public var range: ClosedRange + /// Lit-segment fill colour. + public var tint: Color + /// The big value string shown on top (already formatted, e.g. "87" or "9.0"). + public var valueText: String + /// Optional smaller-weight unit suffix (e.g. "%", "bpm"). nil hides it. + public var unit: String? + /// Segment count, forwarded to the bar. + public var segments: Int + + public init( + label: LocalizedStringKey, + value: Double, + range: ClosedRange = 0...100, + tint: Color, + valueText: String, + unit: String? = nil, + segments: Int = 24 + ) { + self.label = label + self.value = value + self.range = range + self.tint = tint + self.valueText = valueText + self.unit = unit + self.segments = segments + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + // UPPERCASE label. + Text(label) + .font(StrandFont.overline) + .tracking(StrandFont.overlineTracking) + .textCase(.uppercase) + .foregroundStyle(StrandPalette.textSecondary) + + // Big white value + smaller-weight unit suffix. + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(valueText) + .font(StrandFont.number(30, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + if let unit { + Text(unit) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textTertiary) + } + } + + // The segmented count-up bar. + PipBar(value: value, range: range, segments: segments, tint: tint) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(label)) + .accessibilityValue(Text(unit.map { "\(valueText) \($0)" } ?? valueText)) + } +} + +#if DEBUG +#Preview("PipBar") { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Group { + Text("Values").strandOverline() + labelled("0%", PipBar(value: 0, tint: StrandPalette.chargeColor)) + labelled("25%", PipBar(value: 25, tint: StrandPalette.chargeColor)) + labelled("62%", PipBar(value: 62, tint: StrandPalette.effortColor)) + labelled("88%", PipBar(value: 88, tint: StrandPalette.restColor)) + labelled("100%", PipBar(value: 100, tint: StrandPalette.statusPositive)) + } + + Group { + Text("Segment counts & heights").strandOverline().padding(.top, 8) + labelled("12 seg", PipBar(value: 70, segments: 12, tint: StrandPalette.effortColor)) + labelled("36 seg", PipBar(value: 70, segments: 36, tint: StrandPalette.effortColor)) + labelled("tall", PipBar(value: 45, tint: StrandPalette.statusWarning, height: 14)) + } + + // Card-ready rows. + Text("In a card").strandOverline().padding(.top, 8) + StrandCard(tint: StrandPalette.chargeColor) { + VStack(alignment: .leading, spacing: 18) { + PipBarRow(label: "Charge", value: 74, tint: StrandPalette.chargeColor, + valueText: "74", unit: "%") + PipBarRow(label: "Effort", value: 9.0, range: 0...21, tint: StrandPalette.effortColor, + valueText: "9.0") + PipBarRow(label: "Rest", value: 87, tint: StrandPalette.restColor, + valueText: "87", unit: "%") + } + } + + Text("Reduce Motion renders static at the final frame.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + } + .padding(28) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(width: 460, height: 720) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) +} + +@ViewBuilder +private func labelled(_ name: String, _ bar: PipBar) -> some View { + HStack(spacing: 14) { + Text(name) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textTertiary) + .frame(width: 52, alignment: .leading) + bar + } +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/RecoveryRing.swift b/Packages/StrandDesign/Sources/StrandDesign/RecoveryRing.swift index 3a32ad71c7..af33b3b8cf 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/RecoveryRing.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/RecoveryRing.swift @@ -2,13 +2,26 @@ import SwiftUI // MARK: - Recovery Ring (§9.3) — THE signature component // +// watchOS NOTE: the `RecoveryRing` view (below) uses .onContinuousHover + BevelGauge + ChartHover +// tooltips, none of which exist on watchOS, so the VIEW is excluded there (the watch uses the +// lightweight GlowRing instead). The pure `RecoveryArc` Shape at the bottom of this file stays +// available on ALL platforms because the watch-safe BevelGauge / BrandMark depend on it. +// // A 240° open gauge arc (gap at the bottom), thick rounded-cap stroke filled -// with an AngularGradient sampling the recovery gradient (indigo → mint), filled -// to score/100 of the 240° span over a faint track. A soft outer BLOOM whose -// intensity scales with score; a luminous leading bead at the fill tip; a draw-in -// animation when the value changes. Center shows the big monospaced number (no %), -// a state word tinted to the sampled color, and an optional supporting line. +// with an AngularGradient sampling the recovery gradient (WHOOP: value-based +// green→yellow→red via `recoveryStops`), filled to score/100 of the 240° span +// over a faint `surfaceInset` track. NO outer bloom (WHOOP-flat); a crisp leading +// bead at the fill tip; a draw-in animation when the value changes. Center shows the +// big rounded-700 number (no %), a state word tinted to the sampled color, and an +// optional supporting line. +// +// This is also the app's BRAND GLYPH: an open ~80% ring + a SOLID ACCENT CORE DOT +// ("on-device core"). The recovery ring uniquely carries a micro "NOOP" wordmark +// above the number (letter-spacing ≈ .34em, tertiary) so the lock-up reads as the +// "O" in NOOP. The arc geometry, gradient stroke, track and centre number live in +// the shared `BevelGauge`; this view layers the wordmark + core dot on top. +#if !os(watchOS) public struct RecoveryRing: View { /// Recovery score 0...100. @@ -17,10 +30,14 @@ public struct RecoveryRing: View { public var supporting: String? /// Diameter of the ring. public var diameter: CGFloat - /// Stroke thickness (14–18pt per spec). + /// Stroke thickness — hero 13–14pt per the Titanium & Gold spec (§4). public var lineWidth: CGFloat /// Whether to show the center read-out (number + state + supporting). public var showsLabel: Bool + /// Whether to draw the micro "NOOP" wordmark above the number. Turn it OFF for compact rings + /// (e.g. a three-up hero row) where the number is large relative to the ring and the wordmark + /// would crowd it. + public var showsWordmark: Bool /// Whether hovering the ring shows a subtle tooltip (score + state word). public var showsHover: Bool /// Formats the score for the hover tooltip's bold line. @@ -30,8 +47,9 @@ public struct RecoveryRing: View { score: Double, supporting: String? = nil, diameter: CGFloat = 240, - lineWidth: CGFloat = 16, + lineWidth: CGFloat = 14, showsLabel: Bool = true, + showsWordmark: Bool = true, showsHover: Bool = true, valueFormat: @escaping (Double) -> String = { "Recovery \(Int($0.rounded()))" } ) { @@ -40,34 +58,45 @@ public struct RecoveryRing: View { self.diameter = diameter self.lineWidth = lineWidth self.showsLabel = showsLabel + self.showsWordmark = showsWordmark self.showsHover = showsHover self.valueFormat = valueFormat } /// Cursor location while hovering, in ring-local coordinates. @State private var hoverPoint: CGPoint? = nil + @Environment(\.accessibilityReduceMotion) private var reduceMotion - // 240° open gauge: gap centered at the bottom. - // Sweep from 150° to 390° (== 30°), i.e. start lower-left, end lower-right. - private let arcSpanDegrees: Double = 240 - private var startAngle: Angle { .degrees(150) } // lower-left - private var endAngle: Angle { .degrees(150 + arcSpanDegrees) } // 390° == 30° - - // Animated fill fraction so changing `score` draws the arc in. + // Animated fill fraction so changing `score` draws the arc in. The 240° open-gauge + // geometry + bloom now live in the shared `BevelGauge` this delegates to. @State private var animatedFraction: Double = 0 @State private var bloomPulse: Bool = false private var fraction: Double { min(max(score / 100.0, 0), 1) } private var tipColor: Color { StrandPalette.recoveryColor(score) } private var stateWord: String { StrandPalette.recoveryState(score) } - /// Bloom intensity 0.18...0.55 scaled by score. - private var bloomOpacity: Double { 0.18 + 0.37 * fraction } - private var bloomRadius: CGFloat { lineWidth * (0.9 + 1.4 * fraction) } public var body: some View { ZStack { - ring - if showsLabel { centerLabel } + BevelGauge( + fraction: fraction, + stops: StrandPalette.recoveryStops, + tipColor: tipColor, + numberText: numberString, + captionText: showsLabel ? "of 100" : nil, + stateText: showsLabel ? stateWord : nil, + supporting: supporting, + diameter: diameter, + lineWidth: lineWidth, + showsLabel: showsLabel, + animatedFraction: animatedFraction, + bloomActive: bloomPulse + ) + // Brand layers over the shared gauge: the solid gold CORE DOT (so the + // open-ring + core-dot lock-up reads), then the micro "NOOP" wordmark + // sitting just ABOVE the centre number. + coreDot + if showsLabel && showsWordmark { wordmark } if showsHover, let pt = hoverPoint { PositionedTooltip( anchor: pt, @@ -82,6 +111,11 @@ public struct RecoveryRing: View { } } .frame(width: diameter, height: diameter) + // Collapse the loose center Text fragments (and the otherwise-unlabeled + // standalone ring) into one coherent VoiceOver element. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(valueFormat(score))) + .accessibilityValue(Text(stateWord)) .contentShape(Rectangle()) .onContinuousHover(coordinateSpace: .local) { phase in guard showsHover else { return } @@ -91,110 +125,12 @@ public struct RecoveryRing: View { } } .onAppear { - withAnimation(StrandMotion.drawIn) { animatedFraction = fraction } - bloomPulse = true + withAnimation(StrandMotion.drawIn(reduced: reduceMotion)) { animatedFraction = fraction } + // Reduce Motion: leave the bloom at its resting opacity instead of breathing. + if !reduceMotion { bloomPulse = true } } - .onChange(of: score) { _ in - withAnimation(StrandMotion.drawIn) { animatedFraction = fraction } - } - } - - // MARK: Ring assembly - - private var ring: some View { - ZStack { - // Outer bloom: a blurred copy of the filled arc, opacity scaled by score, - // gently breathing for life. - arcShape(to: animatedFraction) - .stroke( - AngularGradient( - gradient: StrandPalette.recoveryGradient, - center: .center, - startAngle: startAngle, - endAngle: endAngle - ), - style: StrokeStyle(lineWidth: lineWidth * 1.05, lineCap: .round) - ) - .blur(radius: bloomRadius) - .opacity(bloomOpacity * (bloomPulse ? 1.0 : 0.78)) - .animation(StrandMotion.breathe, value: bloomPulse) - .blendMode(.plusLighter) - - // Faint full-span track (remainder). - arcShape(to: 1.0) - .stroke( - StrandPalette.hairline.opacity(0.55), - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) - ) - - // The filled gradient arc. - arcShape(to: animatedFraction) - .stroke( - AngularGradient( - gradient: StrandPalette.recoveryGradient, - center: .center, - startAngle: startAngle, - endAngle: endAngle - ), - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) - ) - - // Luminous leading bead at the fill tip. - if animatedFraction > 0.001 { - bead - } - } - } - - // MARK: Leading bead - - private var bead: some View { - GeometryReader { geo in - let radius = (min(geo.size.width, geo.size.height) - lineWidth) / 2 - let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) - let tipAngle = startAngle.radians + (arcSpanDegrees * .pi / 180) * animatedFraction - let pt = CGPoint( - x: center.x + radius * cos(tipAngle), - y: center.y + radius * sin(tipAngle) - ) - ZStack { - // soft halo - Circle() - .fill(tipColor) - .frame(width: lineWidth * 2.4, height: lineWidth * 2.4) - .blur(radius: lineWidth * 0.9) - .opacity(0.7) - .blendMode(.plusLighter) - // bright core - Circle() - .fill(Color.white) - .frame(width: lineWidth * 0.62, height: lineWidth * 0.62) - .overlay(Circle().fill(tipColor).opacity(0.35)) - } - .position(pt) - } - } - - // MARK: Center read-out - - private var centerLabel: some View { - VStack(spacing: 2) { - Text(numberString) - .font(StrandFont.display(diameter * 0.30)) - .foregroundStyle(StrandPalette.textPrimary) - .contentTransition(.numericText()) - Text(stateWord) - .font(StrandFont.overline) - .tracking(StrandFont.overlineTracking) - .foregroundStyle(tipColor) - if let supporting { - Text(supporting) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: diameter * 0.78) - .padding(.top, 4) - } + .onChangeCompat(of: score) { _ in + withAnimation(StrandMotion.drawIn(reduced: reduceMotion)) { animatedFraction = fraction } } } @@ -202,17 +138,38 @@ public struct RecoveryRing: View { String(Int(score.rounded())) } - // MARK: Arc shape + // MARK: Brand layers + + /// Micro "NOOP" wordmark above the number — the recovery ring carries the + /// lock-up so its centre reads as the "O" in NOOP. ALL-CAPS, tertiary, + /// letter-spacing ≈ .34em (× the cap height per the spec). Nudged up so it + /// sits clear above BevelGauge's centred number. + private var wordmark: some View { + let size = diameter * 0.052 + return Text("NOOP") + .font(StrandFont.rounded(size, weight: .bold)) + .tracking(size * 0.34) // ≈ .34em + .foregroundStyle(StrandPalette.textTertiary) + .offset(y: -diameter * 0.205) + .allowsHitTesting(false) + .accessibilityHidden(true) + } - private func arcShape(to fraction: Double) -> RecoveryArc { - RecoveryArc( - startAngle: startAngle, - spanDegrees: arcSpanDegrees, - fraction: fraction, - lineWidth: lineWidth - ) + /// The brand "on-device core" — a small solid ACCENT dot at the exact centre (WHOOP: blue, no + /// gold). It belongs to the glyph-only brand lock-up (logo / nav / onboarding), where it reads as + /// the core of the open ring. On a METRIC gauge the centre is occupied by the read-out number, and + /// a dot sitting behind the digits just muddies them (community feedback at the v3 launch), so it + /// is hidden whenever a number is shown — leaving a clean ring + number + micro-NOOP wordmark. + private var coreDot: some View { + Circle() + .fill(StrandPalette.accent) + .frame(width: diameter * 0.026, height: diameter * 0.026) + .opacity(showsLabel ? 0.0 : 1.0) // hidden under the number; full when glyph-only + .allowsHitTesting(false) + .accessibilityHidden(true) } } +#endif // MARK: - Arc Shape @@ -244,7 +201,7 @@ public struct RecoveryArc: Shape { } } -#if DEBUG +#if DEBUG && !os(watchOS) #Preview("RecoveryRing — scores") { VStack(spacing: 16) { HStack(spacing: 28) { diff --git a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings new file mode 100644 index 0000000000..9e3047ffef --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings @@ -0,0 +1,918 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "%@" : { + + }, + "%@ · recovery %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · recovery %2$lld" + } + } + } + }, + "%lld days ago" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days ago" + } + } + } + }, + "%lld hours" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld hours" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld horas" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ore" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小時" + } + } + } + }, + "%lld minutes" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld minutes" + } + } + } + }, + "%lld points, mean %@, range %@ to %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld points, mean %@, range %@ to %@" + } + } + } + }, + "%lld readings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld readings" + } + } + } + }, + "%lld workouts" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld workouts" + } + } + } + }, + "%lldd ago" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd ago" + } + } + } + }, + "%lldh ago" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldh ago" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hace %lldh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldh fa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小时前" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小時前" + } + } + } + }, + "%lldm ago" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldm ago" + } + } + } + }, + "1 hour" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 hour" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 hora" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 ora" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 小時" + } + } + } + }, + "1 minute" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 minute" + } + } + } + }, + "1 workout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 workout" + } + } + } + }, + "ALL-OUT" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALL-OUT" + } + } + } + }, + "asleep %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "asleep %@" + } + } + } + }, + "average %@ bpm" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "average %@ bpm" + } + } + } + }, + "Awake" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wach" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Awake" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Despierto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Éveillé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sveglio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Бодрствование" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清醒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清醒" + } + } + } + }, + "BPM" : { + + }, + "Building" : { + + }, + "Calibrating" : { + + }, + "Classic" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Classic" + } + } + } + }, + "Dark" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark" + } + } + } + }, + "Date" : { + + }, + "Deep" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tief" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deep" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profundo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profond" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profondo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Глубокий" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "深睡" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "深睡" + } + } + } + }, + "Default" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predeterminado" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predefinito" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "預設" + } + } + } + }, + "DEPLETED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DEPLETED" + } + } + } + }, + "Done" : { + + }, + "Effort" : { + + }, + "Heart rate, 24 hours" : { + + }, + "HIGH" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HIGH" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALTO" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALTO" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "高" + } + } + } + }, + "just now" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "just now" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ahora mismo" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "proprio ora" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刚刚" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "剛剛" + } + } + } + }, + "Light" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leicht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ligero" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Léger" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leggero" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лёгкий" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "浅睡" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "淺睡" + } + } + } + }, + "LIGHT" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIGHT" + } + } + } + }, + "Live" : { + + }, + "LOW" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LOW" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "BAJO" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "BASSO" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "低" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "低" + } + } + } + }, + "MODERATE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "MODERATE" + } + } + } + }, + "Movement during sleep" : { + + }, + "Next day" : { + + }, + "No data" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Daten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No data" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas de données" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dato" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无数据" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無資料" + } + } + } + }, + "No heart-rate data" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No heart-rate data" + } + } + } + }, + "NOOP" : { + + }, + "PEAK" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PEAK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PICO" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "PICCO" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "峰值" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "巔峰" + } + } + } + }, + "Pick a date" : { + + }, + "Previous day" : { + + }, + "PRIMED" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIMED" + } + } + } + }, + "range %@ to %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "range %@ to %@" + } + } + } + }, + "Recovery" : { + + }, + "Recovery calendar, %lld days, average %lld, low %lld, high %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recovery calendar, %lld days, average %lld, low %lld, high %lld" + } + } + } + }, + "Recovery calendar, no data" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recovery calendar, no data" + } + } + } + }, + "Sleep end" : { + + }, + "Sleep stages, %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sleep stages, %@" + } + } + } + }, + "Sleep stages, no data" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sleep stages, no data" + } + } + } + }, + "Sleep start" : { + + }, + "Solid" : { + + }, + "STRENUOUS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "STRENUOUS" + } + } + } + }, + "System" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + } + } + }, + "Time" : { + + }, + "Today" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoy" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aujourd'hui" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oggi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сегодня" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天" + } + } + } + }, + "Trend, %lld points, latest %@, low %@, high %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trend, %lld points, latest %@, low %@, high %@" + } + } + } + }, + "Value" : { + + }, + "Wake" : { + + }, + "Yesterday" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yesterday" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ieri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "昨天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "昨天" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift b/Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift index c5bf5677e5..8e33ca07e0 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift @@ -1,10 +1,13 @@ +#if !os(watchOS) +// Sparkline uses .onContinuousHover + ChartHover helpers (unavailable on watchOS); the watch +// doesn't draw sparklines, so the whole view is excluded there. iOS/macOS unchanged. import SwiftUI // MARK: - Sparkline (§9.4 Today / Live HR tile) // // A tiny inline line for live HR (or any short numeric series). Gradient-stroked, -// with an optional glowing leading dot at the latest sample and a faint area -// wash. Designed to sit in a card/tile or the menu-bar popover. +// with an optional crisp leading dot at the latest sample and a faint area +// wash (WHOOP-flat: no bloom). Designed to sit in a card/tile or the menu-bar popover. public struct Sparkline: View { @@ -62,36 +65,51 @@ public struct Sparkline: View { return (lo - pad, hi + pad) } + /// The area-wash top colour (gradient sampled at 0.7, dimmed). Computed once per body eval instead of + /// re-sampling the gradient inside the ZStack on every draw. + private var areaWashColor: Color { + StrandPalette.sample(stops: gradient.stops, at: 0.7).opacity(0.22) + } + /// The head-dot ring colour (gradient sampled at its bright end). Computed once per body eval. + private var headColor: Color { + StrandPalette.sample(stops: gradient.stops, at: 1.0) + } + public var body: some View { GeometryReader { geo in let pts = points(in: geo.size) ZStack { - if showsArea, pts.count > 1 { - areaPath(pts, in: geo.size) - .fill( - LinearGradient( - colors: [ - StrandPalette.sample(stops: gradient.stops, at: 0.7).opacity(0.22), - Color.clear - ], - startPoint: .top, endPoint: .bottom + // STATIC LAYER: area wash + gradient line + head dot. Drawn INLINE — NO .drawingGroup(). + // A ~14-point polyline + fill + 2 dots is trivially cheap, and a per-sparkline offscreen + // flatten costs FAR more (a dedicated MTLTexture + an extra composite pass) than it saves. + // Today shows ~10-16 tiles at once, so per-tile .drawingGroup() piled up ~16 offscreen + // passes that re-rasterised on every scroll / body re-eval — the v7.0.2 lag regression. + // CoreAnimation already caches this flat layer natively. + ZStack { + if showsArea, pts.count > 1 { + areaPath(pts, in: geo.size) + .fill( + LinearGradient( + colors: [areaWashColor, Color.clear], + startPoint: .top, endPoint: .bottom + ) ) - ) - } - if pts.count > 1 { - linePath(pts) - .stroke( - LinearGradient(gradient: gradient, startPoint: .leading, endPoint: .trailing), - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round) - ) - } - if showsHead, let head = pts.last { - let c = StrandPalette.sample(stops: gradient.stops, at: 1.0) - Circle().fill(c).frame(width: lineWidth * 3.2, height: lineWidth * 3.2) - .blur(radius: lineWidth * 1.2).opacity(0.8).blendMode(.plusLighter) - .position(head) - Circle().fill(Color.white).frame(width: lineWidth * 1.6, height: lineWidth * 1.6) - .position(head) + } + if pts.count > 1 { + linePath(pts) + .stroke( + LinearGradient(gradient: gradient, startPoint: .leading, endPoint: .trailing), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round) + ) + } + if showsHead, let head = pts.last { + // Design Reset (WHOOP): a crisp solid leading dot, no blurred bloom halo. + // The line colour reads as the head ring; a small core sits inside it. + Circle().fill(headColor).frame(width: lineWidth * 2.2, height: lineWidth * 2.2) + .position(head) + Circle().fill(StrandPalette.tipCore).frame(width: lineWidth * 1.0, height: lineWidth * 1.0) + .position(head) + } } // Hover affordance: crosshair + highlighted sample + tooltip. @@ -123,7 +141,20 @@ public struct Sparkline: View { case .ended: hoverX = nil } } + // The line is pointer-hover only (dead on touch); give VoiceOver a + // spoken summary of the series so the trend isn't silent on iPhone. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(axSummary)) + } + } + + /// A spoken summary of the series for VoiceOver: count + latest/low/high, + /// formatted via the same `valueFormat` closure so units match the call site. + private var axSummary: String { + guard let last = values.last, let lo = values.min(), let hi = values.max() else { + return String(localized: "No data", bundle: .module) } + return String(localized: "Trend, \(values.count) points, latest \(valueFormat(last)), low \(valueFormat(lo)), high \(valueFormat(hi))", bundle: .module) } /// The gradient colour at a sample's normalized position along the line. @@ -197,3 +228,4 @@ private func sampleHR() -> [Double] { .preferredColorScheme(.dark) } #endif +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift b/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift new file mode 100644 index 0000000000..69dec9ef2c --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift @@ -0,0 +1,49 @@ +import Foundation + +// MARK: - Sport → SF Symbol +// +// Maps a free-text sport/activity name to an SF Symbol name. Shared so a sport +// reads identically everywhere it appears — the Workouts list, per-sport +// breakdown cards, and the Today HR overview's workout annotations. + +/// The SF Symbol that best represents a free-text `sport` label (case-insensitive, +/// substring-matched). Falls back to `figure.mixed.cardio` for anything unrecognised. +public func sportSymbol(_ sport: String) -> String { + let s = sport.lowercased() + switch true { + case s.contains("run"): return "figure.run" + case s.contains("walk") || s.contains("hike"): return "figure.walk" + case s.contains("cycl") || s.contains("bike") || s.contains("ride"): + return "figure.outdoor.cycle" + case s.contains("swim"): return "figure.pool.swim" + case s.contains("row"): return "figure.rower" + case s.contains("yoga"): return "figure.yoga" + case s.contains("strength") || s.contains("weight") || s.contains("lift"): + return "dumbbell.fill" + case s.contains("box"): return "figure.boxing" + case s.contains("martial") || s.contains("jiu") || s.contains("judo") || s.contains("karate"): + return "figure.martial.arts" + case s.contains("hiit") || s.contains("functional"): + return "figure.highintensity.intervaltraining" + case s.contains("elliptical"): return "figure.elliptical" + case s.contains("snowboard"): return "figure.snowboarding" + case s.contains("ski"): return "figure.skiing.downhill" + // "padel"/"pickleball" deliberately precede "tennis" so they don't get swallowed by a + // broader racket match; all the racket sports share the tennis glyph (no dedicated SF Symbol). + case s.contains("padel") || s.contains("pickle") || s.contains("tennis") + || s.contains("squash") || s.contains("racquet") || s.contains("badminton"): + return "figure.tennis" + case s.contains("volleyball"): return "figure.volleyball" + case s.contains("stretch"): return "figure.flexibility" + case s.contains("golf"): return "figure.golf" + case s.contains("bowl"): return "figure.bowling" + case s.contains("soccer") || s.contains("football"): + return "figure.soccer" + case s.contains("basketball"): return "figure.basketball" + case s.contains("dance"): return "figure.dance" + case s.contains("climb"): return "figure.climbing" + case s.contains("pilates"): return "figure.pilates" + case s.contains("meditat"): return "figure.mind.and.body" + default: return "figure.mixed.cardio" + } +} diff --git a/Packages/StrandDesign/Sources/StrandDesign/StatePill.swift b/Packages/StrandDesign/Sources/StrandDesign/StatePill.swift index e5cf14c51b..9fd961d735 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/StatePill.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/StatePill.swift @@ -27,13 +27,13 @@ public enum StrandTone: Sendable { public struct StatePill: View { - public var title: String + public var title: LocalizedStringKey public var tone: StrandTone public var showsDot: Bool /// Pulse the leading dot (e.g. "live" / "syncing"). public var pulsing: Bool - public init(_ title: String, tone: StrandTone = .neutral, showsDot: Bool = true, pulsing: Bool = false) { + public init(_ title: LocalizedStringKey, tone: StrandTone = .neutral, showsDot: Bool = true, pulsing: Bool = false) { self.title = title self.tone = tone self.showsDot = showsDot @@ -76,6 +76,8 @@ public struct ConnectionDot: View { public var size: CGFloat @State private var animate = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorScheme) private var scheme public init(tone: StrandTone = .positive, pulsing: Bool = false, size: CGFloat = 9) { self.tone = tone @@ -85,13 +87,19 @@ public struct ConnectionDot: View { public var body: some View { ZStack { - if pulsing { + // Dark-mode only (#review): AdditiveBloom used to hide this expanding ring on light + // (content.opacity(0)); now that we drop the offscreen bloom, gate it explicitly so light + // mode stays ring-free (the resting dot + its shadow carry the live state there). + if pulsing && scheme == .dark { Circle() .fill(tone.color) .frame(width: size, height: size) .scaleEffect(animate ? 2.4 : 1.0) .opacity(animate ? 0.0 : 0.5) - .blendMode(.plusLighter) + // No .additiveBloom(): the .plusLighter blend forced an offscreen pass every + // frame of the repeatForever pulse, a continuous cost while a strap is backfilling + // (exactly when this live dot is on screen). The expanding/fading ring reads the + // same without it; the resting dot's shadow still carries the "live" glow. } Circle() .fill(tone.color) @@ -99,8 +107,10 @@ public struct ConnectionDot: View { .shadow(color: tone.color.opacity(0.8), radius: pulsing ? 4 : 2) } .frame(width: size, height: size) - .onAppear { if pulsing { animate = true } } - .animation(pulsing ? StrandMotion.breathe : nil, value: animate) + // Honour Reduce Motion: don't kick off the looping pulse (settles at the + // resting dot) and never attach the repeatForever breathe animation. + .onAppear { if pulsing && !reduceMotion { animate = true } } + .animation(pulsing && !reduceMotion ? StrandMotion.breathe : nil, value: animate) .accessibilityHidden(true) } } diff --git a/Packages/StrandDesign/Sources/StrandDesign/StrainGauge.swift b/Packages/StrandDesign/Sources/StrandDesign/StrainGauge.swift index b7af744feb..588a16e2f5 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/StrainGauge.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/StrainGauge.swift @@ -1,16 +1,29 @@ +#if !os(watchOS) +// StrainGauge uses .onContinuousHover + ChartHover tooltips (unavailable on watchOS); the watch +// uses GlowRing instead, so the whole view is excluded there. iOS/macOS unchanged. import SwiftUI // MARK: - Strain Gauge (§9.1 strain ramp) // -// Ember → magenta gauge for the 0–21 Whoop strain scale. Same open-gauge -// instrument language as the Recovery Ring, but warm (output / heat) instead of -// the cool recovery scale. Filled to strain/21 of a 240° arc, with a soft bloom -// and a leading bead at the tip. +// Blue Effort gauge for the strain/effort scale (WHOOP: the always-blue effort ramp, +// no gold). Same open-gauge instrument language as the Recovery Ring, but cardiovascular +// output instead of the value-based recovery scale. Filled to strain/outOf of a 240° arc, +// flat and crisp (no bloom) with a clean leading bead at the tip. +// +// `outOf` is the maximum of the scale the passed `strain` is ON (default 21 for the +// WHOOP Day-Strain axis). The Effort hero gauge passes the value already converted to +// the user's selected display scale (#268) plus its matching max (100 or 21), so the +// arc fraction, the centre numeral and the "of N" caption all read on the same scale +// instead of being hardcoded to 0–21. The gauge stays scale-agnostic — the caller owns +// the conversion (EffortScale lives in the app layer, not this design package). public struct StrainGauge: View { - /// Strain value on the 0...21 scale. + /// Strain value on the displayed scale (its maximum is `outOf`). public var strain: Double + /// The maximum of the scale `strain` is on — the arc fills `strain/outOf` and the caption + /// reads "of \(outOf)". Defaults to 21 (WHOOP Day Strain) so existing call sites are unchanged. + public var outOf: Double /// Optional supporting line, e.g. "moderate cardiovascular load". public var supporting: String? public var diameter: CGFloat @@ -23,6 +36,7 @@ public struct StrainGauge: View { public init( strain: Double, + outOf: Double = 21, supporting: String? = nil, diameter: CGFloat = 200, lineWidth: CGFloat = 14, @@ -31,6 +45,7 @@ public struct StrainGauge: View { valueFormat: @escaping (Double) -> String = { String(format: "Strain %.1f", $0) } ) { self.strain = strain + self.outOf = outOf self.supporting = supporting self.diameter = diameter self.lineWidth = lineWidth @@ -41,34 +56,45 @@ public struct StrainGauge: View { /// Cursor location while hovering, in gauge-local coordinates. @State private var hoverPoint: CGPoint? = nil + @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// A short load word for the strain value, mirroring the recovery state idea. + /// A short load word for the strain value, mirroring the recovery state idea. Computed off the + /// fraction (not the raw value) so the bands read the same on the 0–100 and 0–21 display scales. private var strainWord: String { - switch strain { - case ..<6: return "LIGHT" - case ..<10: return "MODERATE" - case ..<14: return "STRENUOUS" - case ..<18: return "HIGH" - default: return "ALL-OUT" + switch fraction { + case ..<(6.0 / 21): return String(localized: "LIGHT", bundle: .module) + case ..<(10.0 / 21): return String(localized: "MODERATE", bundle: .module) + case ..<(14.0 / 21): return String(localized: "STRENUOUS", bundle: .module) + case ..<(18.0 / 21): return String(localized: "HIGH", bundle: .module) + default: return String(localized: "ALL-OUT", bundle: .module) } } - private let arcSpanDegrees: Double = 240 - private var startAngle: Angle { .degrees(150) } - private var endAngle: Angle { .degrees(150 + arcSpanDegrees) } - + // The 240° open-gauge geometry + bloom now live in the shared `BevelGauge`. @State private var animatedFraction: Double = 0 @State private var bloomPulse = false - private var fraction: Double { min(max(strain / 21.0, 0), 1) } - private var tipColor: Color { StrandPalette.strainColor(strain) } - private var bloomOpacity: Double { 0.16 + 0.34 * fraction } - private var bloomRadius: CGFloat { lineWidth * (0.8 + 1.2 * fraction) } + private var fraction: Double { min(max(strain / outOf, 0), 1) } + /// Tip tint sampled by the fill FRACTION so it spans the full ember→amber ramp identically on the + /// 0–100 and 0–21 display scales (a maxed gauge reaches the bright-amber peak, not a stuck ember). + private var tipColor: Color { StrandPalette.effortTint(fraction: fraction) } public var body: some View { ZStack { - ring - if showsLabel { centerLabel } + BevelGauge( + fraction: fraction, + stops: StrandPalette.strainStops, + tipColor: tipColor, + numberText: strainString, + captionText: showsLabel ? "of \(Int(outOf.rounded()))" : nil, + stateText: showsLabel ? strainWord : nil, + supporting: supporting, + diameter: diameter, + lineWidth: lineWidth, + showsLabel: showsLabel, + animatedFraction: animatedFraction, + bloomActive: bloomPulse + ) if showsHover, let pt = hoverPoint { PositionedTooltip( anchor: pt, @@ -83,6 +109,10 @@ public struct StrainGauge: View { } } .frame(width: diameter, height: diameter) + // Collapse the loose center Text fragments into one coherent VoiceOver element. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(valueFormat(strain))) + .accessibilityValue(Text(strainWord)) .contentShape(Rectangle()) .onContinuousHover(coordinateSpace: .local) { phase in guard showsHover else { return } @@ -92,98 +122,18 @@ public struct StrainGauge: View { } } .onAppear { - withAnimation(StrandMotion.drawIn) { animatedFraction = fraction } - bloomPulse = true - } - .onChange(of: strain) { _ in - withAnimation(StrandMotion.drawIn) { animatedFraction = fraction } - } - } - - private var ring: some View { - ZStack { - arc(to: animatedFraction) - .stroke( - AngularGradient( - gradient: StrandPalette.strainGradient, - center: .center, - startAngle: startAngle, - endAngle: endAngle - ), - style: StrokeStyle(lineWidth: lineWidth * 1.05, lineCap: .round) - ) - .blur(radius: bloomRadius) - .opacity(bloomOpacity * (bloomPulse ? 1.0 : 0.8)) - .animation(StrandMotion.breathe, value: bloomPulse) - .blendMode(.plusLighter) - - arc(to: 1.0) - .stroke(StrandPalette.hairline.opacity(0.55), - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - - arc(to: animatedFraction) - .stroke( - AngularGradient( - gradient: StrandPalette.strainGradient, - center: .center, - startAngle: startAngle, - endAngle: endAngle - ), - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) - ) - - if animatedFraction > 0.001 { bead } + withAnimation(StrandMotion.drawIn(reduced: reduceMotion)) { animatedFraction = fraction } + // Reduce Motion: leave the bloom at its resting opacity instead of breathing. + if !reduceMotion { bloomPulse = true } } - } - - private var bead: some View { - GeometryReader { geo in - let radius = (min(geo.size.width, geo.size.height) - lineWidth) / 2 - let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) - let tipAngle = startAngle.radians + (arcSpanDegrees * .pi / 180) * animatedFraction - let pt = CGPoint(x: center.x + radius * cos(tipAngle), - y: center.y + radius * sin(tipAngle)) - ZStack { - Circle().fill(tipColor) - .frame(width: lineWidth * 2.2, height: lineWidth * 2.2) - .blur(radius: lineWidth * 0.85).opacity(0.7).blendMode(.plusLighter) - Circle().fill(Color.white) - .frame(width: lineWidth * 0.58, height: lineWidth * 0.58) - .overlay(Circle().fill(tipColor).opacity(0.35)) - } - .position(pt) - } - } - - private var centerLabel: some View { - VStack(spacing: 2) { - Text(strainString) - .font(StrandFont.display(diameter * 0.26)) - .foregroundStyle(StrandPalette.textPrimary) - .contentTransition(.numericText()) - Text("STRAIN") - .font(StrandFont.overline) - .tracking(StrandFont.overlineTracking) - .foregroundStyle(tipColor) - if let supporting { - Text(supporting) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: diameter * 0.78) - .padding(.top, 4) - } + .onChangeCompat(of: strain) { _ in + withAnimation(StrandMotion.drawIn(reduced: reduceMotion)) { animatedFraction = fraction } } } private var strainString: String { String(format: "%.1f", strain) } - - private func arc(to fraction: Double) -> RecoveryArc { - RecoveryArc(startAngle: startAngle, spanDegrees: arcSpanDegrees, - fraction: fraction, lineWidth: lineWidth) - } } #if DEBUG @@ -202,3 +152,4 @@ public struct StrainGauge: View { .preferredColorScheme(.dark) } #endif +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift b/Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift index 3f22b39993..8019ffe02a 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift @@ -1,24 +1,102 @@ import SwiftUI +// MARK: - Frosted card surface (Titanium & Gold) + StrandCard +// +// The card surface: a flat `surfaceRaised` fill, continuous rounded corners and a +// single 1px `hairline` border — NO shadow (the Titanium look reads off the hairline +// + tint, not a drop shadow). The TINTED variant deepens into a navy bevel +// (150° #15243C → #0B1424) under a faint per-domain hue wash + a hue-biased border. +// `.frostedCardSurface(tint:…)` is the one place the look lives so StrandCard / +// NoopCard / ad-hoc surfaces all share it. Pass a domain tint (or nil for the neutral +// flat raised surface). + +public extension View { + /// Apply the frosted-card surface as a background. `tint` colours the diagonal + /// wash + border bias; nil uses the flat raised surface with no wash. + func frostedCardSurface( + tint: Color? = nil, + cornerRadius: CGFloat = 22, + washStrength: Double = 1.0 + ) -> some View { + background(FrostedCardSurface(tint: tint, cornerRadius: cornerRadius, washStrength: washStrength)) + } +} + +/// The frosted-card background fill and border. Standalone so it can be a +/// `.background { }` (animation never reaches the card's content subtree — #104). +/// No drop shadow — the Titanium surface reads off the hairline + tint alone. +public struct FrostedCardSurface: View { + public var tint: Color? + public var cornerRadius: CGFloat + public var washStrength: Double + @Environment(\.colorScheme) private var scheme + + public init(tint: Color? = nil, cornerRadius: CGFloat = 22, washStrength: Double = 1.0) { + self.tint = tint + self.cornerRadius = cornerRadius + self.washStrength = washStrength + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + // Base fill: tinted cards deepen into the 150° navy bevel (#15243C → #0B1424, + // = surfaceOverlay → cardFillBottom); neutral cards sit on the flat raised + // surface. The 150° axis ≈ top-trailing → bottom-leading. + // Design Reset: a flat raised fill reads cleaner than the navy bevel gradient. Tinted and + // neutral cards now share the same flat surface; tint identity is carried by the softened + // hue wash + the tinted hairline below, not a gradient, so cards stay familiar but flatten. + let baseFill = AnyShapeStyle(StrandPalette.surfaceRaised) + shape + .fill(baseFill) + .overlay( + // A faint per-domain hue wash — only on tinted cards; neutral stays flat. + shape.fill( + LinearGradient( + colors: [ + (tint ?? .clear).opacity(0.05 * washStrength), + (tint ?? .clear).opacity(0.015 * washStrength), + .clear + ], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + ) + ) + // Liquid redesign (2026-07-02): a 1px resting hairline in BOTH themes so every card + // matches the liquid home card's edge (LiquidTodayView.card), not just fill contrast. + .overlay(shape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) + // LIGHT raises white cards off the warm-paper canvas with a soft resting drop shadow; DARK + // stays flat (the hairline + fill carry the edge, matching the home card which has no shadow). + .shadow( + color: scheme == .light ? Color(hex: "#1A2230").opacity(0.11) : .clear, + radius: scheme == .light ? 10 : 0, + x: 0, y: scheme == .light ? 3 : 0 + ) + } +} + // MARK: - StrandCard (§9.4 Cards) // -// The card container: surface.raised, 16pt radius, 1px hairline border, and the -// mandated hover lift (shadow + translateY(-1px)) with a hairline → hairline.strong -// border transition. Use `.strandCardHover()` to add the lift to any view. +// The card container — now the Bevel frosted surface, but the PUBLIC API is +// unchanged (padding, cornerRadius, content). Adds an optional `tint` (defaulted) +// so callers can opt into a domain wash without breaking existing call sites. +// Keeps the mandated hover lift via `.strandCardHover()`. public struct StrandCard: View { public var padding: CGFloat public var cornerRadius: CGFloat + public var tint: Color? @ViewBuilder public var content: () -> Content public init( padding: CGFloat = 16, - cornerRadius: CGFloat = 16, + cornerRadius: CGFloat = 22, + tint: Color? = nil, @ViewBuilder content: @escaping () -> Content ) { self.padding = padding self.cornerRadius = cornerRadius + self.tint = tint self.content = content } @@ -26,7 +104,7 @@ public struct StrandCard: View { content() .padding(padding) .frame(maxWidth: .infinity, alignment: .leading) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .frostedCardSurface(tint: tint, cornerRadius: cornerRadius) .strandCardHover(cornerRadius: cornerRadius) } } @@ -38,37 +116,123 @@ public struct StrandCard: View { public struct StrandCardHover: ViewModifier { public var cornerRadius: CGFloat @State private var hovering = false + @Environment(\.colorScheme) private var scheme - public init(cornerRadius: CGFloat = 16) { + public init(cornerRadius: CGFloat = 22) { self.cornerRadius = cornerRadius } public func body(content: Content) -> some View { content + // Hover emphasis: brighten the hairline edge (the frosted surface owns the + // resting border) and add the mandated lift (shadow + translateY(-1px)). .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(hovering ? StrandPalette.hairlineStrong : StrandPalette.hairline, lineWidth: 1) + .strokeBorder(StrandPalette.hairlineStrong, lineWidth: 1) + .opacity(hovering ? 1 : 0) ) + // Incremental hover lift on top of the surface's resting elevation: a warm soft shadow on + // light (the white card lifts off the paper), the signature black on dark. .shadow( - color: Color.black.opacity(hovering ? 0.45 : 0.0), - radius: hovering ? 14 : 0, + color: hovering ? (scheme == .light ? Color(hex: "#1A2230").opacity(0.16) + : Color.black.opacity(0.45)) : .clear, + radius: hovering ? (scheme == .light ? 14 : 16) : 0, x: 0, - y: hovering ? 8 : 0 + y: hovering ? (scheme == .light ? 6 : 10) : 0 ) .offset(y: hovering ? -1 : 0) .animation(StrandMotion.interactive, value: hovering) + // .onHover is unavailable on watchOS (no pointer); the watch never hovers a card. + #if !os(watchOS) .onHover { hovering = $0 } + #endif } } public extension View { /// Apply the Strand card hover lift (shadow + -1px translate + border emphasis). - func strandCardHover(cornerRadius: CGFloat = 16) -> some View { + func strandCardHover(cornerRadius: CGFloat = 22) -> some View { modifier(StrandCardHover(cornerRadius: cornerRadius)) } } -#if DEBUG +// MARK: - Touch press feedback (iOS) — the hover lift's touch analogue. +// +// `.onHover` never fires on a touchscreen, so tappable cards/rows feel dead on iPhone. +// This gives a subtle press-DOWN state (scale + edge emphasis) for direct manipulation, +// honouring Reduce Motion (which swaps the transform for a gentle dim). It's additive to +// the hover lift: hover (pointer NEAR) and pressed (finger/click DOWN) animate distinct +// properties on the shared StrandMotion.interactive spring, so they compose without a +// double-bounce. Exposed two ways — a ButtonStyle for Button/NavigationLink-as-card (the +// `.plain` replacement), and a `.strandPressable()` modifier for `.onTapGesture`-driven cards. + +/// Drop-in replacement for `.buttonStyle(.plain)` on full-card Buttons / NavigationLinks: +/// a subtle press-down scale + hairline-strong edge. +public struct StrandPressableButtonStyle: ButtonStyle { + public var cornerRadius: CGFloat + public var scale: CGFloat + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + public init(cornerRadius: CGFloat = NoopMetrics.cardRadius, scale: CGFloat = 0.985) { + self.cornerRadius = cornerRadius + self.scale = scale + } + + public func makeBody(configuration: Configuration) -> some View { + let pressed = configuration.isPressed + return configuration.label + .scaleEffect(reduceMotion ? 1 : (pressed ? scale : 1)) + .opacity(reduceMotion && pressed ? 0.82 : 1) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(StrandPalette.hairlineStrong, lineWidth: 1) + .opacity(pressed ? 1 : 0) + ) + .animation(StrandMotion.interactive, value: pressed) + .contentShape(Rectangle()) + } +} + +/// Backs `.strandPressable()` — a press-down state for cards driven by `.onTapGesture` +/// (no Button). A 0-distance drag tracks the finger; @GestureState auto-resets on release +/// or when a parent scroll claims the gesture. +public struct StrandPressableModifier: ViewModifier { + public var cornerRadius: CGFloat + public var scale: CGFloat + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @GestureState private var pressed = false + + public init(cornerRadius: CGFloat = NoopMetrics.cardRadius, scale: CGFloat = 0.985) { + self.cornerRadius = cornerRadius + self.scale = scale + } + + public func body(content: Content) -> some View { + content + .scaleEffect(reduceMotion ? 1 : (pressed ? scale : 1)) + .opacity(reduceMotion && pressed ? 0.82 : 1) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(StrandPalette.hairlineStrong, lineWidth: 1) + .opacity(pressed ? 1 : 0) + ) + .animation(StrandMotion.interactive, value: pressed) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .updating($pressed) { _, state, _ in state = true } + ) + } +} + +public extension View { + /// Subtle touch press-down feedback for a tappable card/row that uses `.onTapGesture` + /// (not a Button). For Buttons/NavigationLinks, use `StrandPressableButtonStyle` instead. + func strandPressable(cornerRadius: CGFloat = NoopMetrics.cardRadius, scale: CGFloat = 0.985) -> some View { + modifier(StrandPressableModifier(cornerRadius: cornerRadius, scale: scale)) + } +} + +#if DEBUG && !os(watchOS) #Preview("StrandCard") { VStack(spacing: 16) { StrandCard { diff --git a/Packages/StrandDesign/Sources/StrandDesign/StrandDesign.swift b/Packages/StrandDesign/Sources/StrandDesign/StrandDesign.swift index 7440021dbe..c0a0329003 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/StrandDesign.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/StrandDesign.swift @@ -1,4 +1,68 @@ import SwiftUI +#if !os(watchOS) +import Charts // Swift Charts isn't used by the watch app; the ChartProxy shim below is watchOS-excluded. +#endif + +// MARK: - iOS-17 / macOS-14 deprecation shims +// +// NOOP ships a split deployment target — the iOS app targets iOS 17 but the +// macOS app targets macOS 13 — and the Strand/ + StrandDesign sources compile +// into BOTH. The two-parameter `onChange(of:initial:_:)` and the optional +// `ChartProxy.plotFrame` arrived in iOS 17 / macOS 14 and deprecated their +// predecessors, so a blind swap silences the iOS warning yet fails to compile on +// macOS 13 (the new overloads don't exist there). These shims call the modern +// form where available and the legacy form (un-deprecated on macOS 13) otherwise, +// so each deprecation is acknowledged exactly once — here — instead of at every +// call site. Behaviour is identical to a direct `.onChange` / `plotAreaFrame`. + +public extension View { + /// macOS-13-safe `onChange` that hands the closure the new value. Every NOOP + /// call site reads only the new value, so a single-parameter shim keeps the + /// existing closures byte-for-byte unchanged (no `_,` rewrite needed). + @ViewBuilder + func onChangeCompat( + of value: V, + perform action: @escaping (V) -> Void + ) -> some View { + if #available(iOS 17.0, macOS 14.0, *) { + self.onChange(of: value) { _, newValue in action(newValue) } + } else { + self.legacyOnChange(of: value, perform: action) + } + } +} + +private extension View { + /// The legacy single-parameter `onChange`, isolated so its deprecation is + /// acknowledged once. The `@available` annotation marks it deprecated exactly + /// where the modern overload takes over (iOS 17 / macOS 14), so no warning + /// fires on the macOS-13 build that genuinely needs this path. + @available(iOS, introduced: 16.0, deprecated: 17.0) + @available(macOS, introduced: 13.0, deprecated: 14.0) + @ViewBuilder + func legacyOnChange( + of value: V, + perform action: @escaping (V) -> Void + ) -> some View { + self.onChange(of: value, perform: action) + } +} + +#if !os(watchOS) +public extension ChartProxy { + /// macOS-13-safe plot rect: the optional `plotFrame` on iOS 17 / macOS 14, the + /// deprecated non-optional `plotAreaFrame` otherwise. `.zero` on a nil anchor + /// matches the old pre-layout behaviour (call sites guard via `position(forX:)`). + func plotRectCompat(in geo: GeometryProxy) -> CGRect { + if #available(iOS 17.0, macOS 14.0, *) { + guard let frame = plotFrame else { return .zero } + return geo[frame] + } else { + return geo[plotAreaFrame] + } + } +} +#endif /// Strand design system: palette, typography, motion, and signature components /// (Recovery Ring, Strain Gauge, Hypnogram, Trend/Sparkline charts, Year heat diff --git a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift index 881fd47d4c..69348f1135 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift @@ -1,3 +1,6 @@ +#if !os(watchOS) +// TrendChart is a Swift Charts view with .onContinuousHover (unavailable on watchOS); the watch +// never shows it, so the whole file is excluded there. iOS/macOS unchanged. import SwiftUI import Charts @@ -5,15 +8,20 @@ import Charts // // A line/area chart whose line is gradient-stroked by value — reusable for // recovery / HRV / RHR / strain trends. The gradient defaults to the recovery -// scale (so a recovery-over-time line travels indigo → mint by daily score), but -// any gradient + value-range can be supplied for HRV/RHR/etc. +// scale (so a recovery-over-time line travels deep-gold → pale-gold by daily +// score), but any gradient + value-range can be supplied — pass the blue sleep +// ramp for sleep, the teal HRV scale for HRV, the amber strain ramp for strain. /// One point on a trend line. public struct TrendPoint: Identifiable, Sendable { - public let id = UUID() public var date: Date public var value: Double + /// Stable, content-derived identity (one point per date in a series). A random + /// `UUID()` defeats Swift Charts' diffing — every render re-identifies all marks + /// and replays the draw animation; keying on the date lets Charts diff by data. + public var id: Date { date } + public init(date: Date, value: Double) { self.date = date self.value = value @@ -36,6 +44,26 @@ public struct TrendChart: View { public var valueFormat: (Double) -> String /// Formats a point's date for the tooltip's secondary line. public var dateFormat: (Date) -> String + /// Optional human-readable series name for VoiceOver (e.g. "HRV trend"). When nil the + /// element falls back to a generic "Trend" label so it's never unlabeled. + public var accessibilityLabel: String? + /// When set, draws a glowing "now" end-cap on the most-recent point — IN the chart's own + /// coordinate space (via the overlay proxy), so it sits exactly on the line. nil = no cap. + /// (#458: an earlier sibling-overlay cap guessed the plot insets and floated off the line.) + public var nowCapColor: Color? + /// Y-axis domain when it should differ from `valueRange` — e.g. an axis fitted to the data + /// window (with a little headroom) while the gradient stays anchored to the metric's full + /// scale. nil = `valueRange`. Widening the TOP of this domain is how a caller keeps a peak + /// curve and the top axis label clear of the plot clip (see #974); done purely in data space + /// so it needs no macOS14/iOS17 plot-dimension padding API — works on our macOS13/iOS16 floor. + public var yDomain: ClosedRange? + + /// Mean of all point values, computed once in `init` so the area fill's gradient + /// stop doesn't run an O(n) reduce for every mark on every render. + private let averageValue: Double + + /// One-line VoiceOver summary (count + mean + range), built once in `init`. + private let a11ySummary: String public init( points: [TrendPoint], @@ -45,9 +73,13 @@ public struct TrendChart: View { height: CGFloat = 220, showsHover: Bool = true, valueFormat: @escaping (Double) -> String = { String(Int($0.rounded())) }, - dateFormat: @escaping (Date) -> String = { TrendChart.defaultDateString($0) } + dateFormat: @escaping (Date) -> String = { TrendChart.defaultDateString($0) }, + accessibilityLabel: String? = nil, + nowCapColor: Color? = nil, + yDomain: ClosedRange? = nil ) { - self.points = points.sorted { $0.date < $1.date } + let sorted = points.sorted { $0.date < $1.date } + self.points = sorted self.gradient = gradient self.valueRange = valueRange self.showsArea = showsArea @@ -55,11 +87,42 @@ public struct TrendChart: View { self.showsHover = showsHover self.valueFormat = valueFormat self.dateFormat = dateFormat + self.accessibilityLabel = accessibilityLabel + self.nowCapColor = nowCapColor + self.yDomain = yDomain + let avg = sorted.isEmpty + ? valueRange.lowerBound + : sorted.map(\.value).reduce(0, +) / Double(sorted.count) + self.averageValue = avg + + // The point set handed to the marks: full resolution up to the threshold, else min/max-bucketed + // to ~the plot pixel width (pixel-identical line, far fewer GPU vertices). Computed once here. + self.displayPoints = ChartDownsample.minMaxBucketed(sorted, threshold: ChartDownsample.markThreshold, + targetCount: ChartDownsample.targetVertices) + + // VoiceOver one-liner: count + mean + range — formatted with the SAME valueFormat the + // tooltip uses, so units match. Computed once here, not per render. + if sorted.isEmpty { + self.a11ySummary = String(localized: "No data", bundle: .module) + } else { + let vals = sorted.map(\.value) + let lo = vals.min()!, hi = vals.max()! + self.a11ySummary = String(localized: "\(sorted.count) points, mean \(valueFormat(avg)), range \(valueFormat(lo)) to \(valueFormat(hi))", bundle: .module) + } } /// The x-position the cursor is hovering, in chart-local coordinates. @State private var hoverX: CGFloat? = nil + /// PERF: a 365-day (or longer) series feeds Swift Charts hundreds of LineMark/AreaMark vertices, each + /// catmullRom-interpolated — far more than the ~360pt plot has pixels, so most are sub-pixel and pure + /// draw cost. `displayPoints` is the point set actually handed to the marks: full resolution up to a + /// threshold, else min/max-per-bucket down to roughly the plot pixel width. Min/max bucketing keeps + /// every visible peak and trough, so the rendered line is pixel-identical on a normal-width chart. + /// Computed ONCE in `init` (not per body/hover eval), so it's memoized on `points`; hover / now-cap / + /// accessibility stay on the full-resolution `points` so those readouts are unchanged. + private let displayPoints: [TrendPoint] + private static let sharedDateFormatter: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE d MMM"; return f }() @@ -94,10 +157,15 @@ public struct TrendChart: View { LinearGradient(gradient: gradient, startPoint: .bottom, endPoint: .top) } + /// The Y domain actually applied to the axis + plot clip: the explicit `yDomain` when a caller + /// supplied one (e.g. a data-fitted axis with top headroom), else the gradient's `valueRange`. + /// Exposed internally so a unit test can pin the resolution without rendering the chart. + var resolvedYDomain: ClosedRange { yDomain ?? valueRange } + public var body: some View { Chart { if showsArea { - ForEach(points) { p in + ForEach(displayPoints) { p in AreaMark( x: .value("Date", p.date), y: .value("Value", p.value) @@ -114,7 +182,7 @@ public struct TrendChart: View { ) } } - ForEach(points) { p in + ForEach(displayPoints) { p in LineMark( x: .value("Date", p.date), y: .value("Value", p.value) @@ -123,16 +191,30 @@ public struct TrendChart: View { .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) .foregroundStyle(valueGradient) } - ForEach(points) { p in - PointMark( - x: .value("Date", p.date), - y: .value("Value", p.value) - ) - .symbolSize(18) - .foregroundStyle(StrandPalette.sample(stops: gradient.toStops(), at: unit(p.value))) + // 18pt dots are invisible on dense series (e.g. a 365-day year) but still cost the + // GPU a mark each — hide them past a threshold; the line carries the data there. The gate + // stays on the full `points.count` (≤60 is never downsampled, so displayPoints == points). + if points.count <= 60 { + ForEach(displayPoints) { p in + PointMark( + x: .value("Date", p.date), + y: .value("Value", p.value) + ) + .symbolSize(18) + .foregroundStyle(StrandPalette.sample(stops: gradient.toStops(), at: unit(p.value))) + } } } - .chartYScale(domain: valueRange) + // Domain drives BOTH the axis extent and the plot clip. A caller that wants a top-of-range + // peak (and the top axis label) to clear the clip passes a `yDomain` whose upper bound sits a + // little above the data — pure data-space headroom, so no macOS14/iOS17 plot-dimension endPadding + // API is needed (#974). The value→color gradient still keys off `valueRange`, unchanged. + .chartYScale(domain: resolvedYDomain) + // Clip the plot to its own bounds. catmullRom interpolation overshoots past the data extremes + // on sharp turns, and the AreaMark gradient is drawn UNCLIPPED — so on a spiky HR curve the + // rose fill bled down the page behind the cards below the chart. Clipping the plot area bounds + // every mark (line, area, points, overshoot) to the chart rectangle. + .chartPlotStyle { plotArea in plotArea.clipped() } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 5)) { _ in AxisGridLine().foregroundStyle(StrandPalette.hairline.opacity(0.4)) @@ -149,7 +231,7 @@ public struct TrendChart: View { } .chartOverlay { proxy in GeometryReader { geo in - let plot = geo[proxy.plotAreaFrame] + let plot = proxy.plotRectCompat(in: geo) ZStack(alignment: .topLeading) { if showsHover, let hx = hoverX, @@ -178,24 +260,118 @@ public struct TrendChart: View { ) ) } + + // "Now" end-cap on the latest point (#458). Positioned with the SAME proxy mapping the + // line uses (position(forX:/forY:) + plot origin), so it lands exactly on the curve — + // not via a sibling overlay guessing the axis insets, which floated it left/below. + if let capColor = nowCapColor, let last = points.last, + let px = proxy.position(forX: last.date), + let py = proxy.position(forY: last.value) { + NowCapDot(color: capColor) + .position(x: px + plot.minX, y: py + plot.minY) + .allowsHitTesting(false) + } } .animation(StrandMotion.fade, value: hoverX) .contentShape(Rectangle()) .onContinuousHover(coordinateSpace: .local) { phase in guard showsHover else { return } - switch phase { - case .active(let location): hoverX = location.x - case .ended: hoverX = nil + // Update the hover position in a NON-animating transaction. Otherwise entering or + // leaving the chart flips hoverX inside an animated context, the body re-evaluates, + // and SwiftUI Charts re-runs the line's draw-on animation — flickering the curve to a + // flat baseline and back as the cursor crosses the plot edge (#104). The crosshair's + // own fade is the overlay's .animation(value: hoverX) above and is unaffected by this. + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { + switch phase { + case .active(let location): hoverX = location.x + case .ended: hoverX = nil + } } } } } .frame(height: height) + // Belt-and-suspenders: also bound the whole chart (axes + overlay) to its frame so nothing + // a Charts internal might draw outside the plot can reach the surrounding layout. + .clipped() + // Collapse the Charts marks (line/area/points) into ONE meaningful VoiceOver element instead + // of letting VoiceOver walk raw per-mark axis values with no series context. The decorative + // stacked under-glow copy (showsHover:false, no label) is hidden so the same series isn't + // double-announced; the crisp interactive copy passes showsHover:true (default) and speaks. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(accessibilityLabel ?? "Trend")) + .accessibilityValue(Text(a11ySummary)) + .accessibilityHidden(!showsHover && accessibilityLabel == nil) } +} + +// MARK: - Chart downsampling (pure) +// +// Reduces a dense point series to roughly the plot's pixel width BEFORE it reaches Swift Charts, so the +// GPU draws ~one vertex per pixel instead of hundreds it can't resolve. Uses MIN/MAX-per-bucket: each +// bucket contributes its lowest and highest sample (in time order), so every visible peak and trough +// survives and the rendered envelope is identical at normal chart widths. First and last points are +// always kept so the line spans the full domain. Pure + deterministic — same input → same output. + +enum ChartDownsample { + /// Above this many points we downsample; at or below it the series is passed through untouched (so + /// the common 7/30/90-day trends and the ≤60-point dotted series are byte-for-byte unchanged). + static let markThreshold = 120 + /// Target drawn-vertex budget — a touch above a typical ~360pt plot so the line stays crisp. + static let targetVertices = 400 + + /// Min/max-bucketed copy of `points` when it exceeds `threshold`, else `points` unchanged. + /// Assumes `points` is already sorted by date (both chart callers sort in their init). + static func minMaxBucketed(_ points: [TrendPoint], threshold: Int, targetCount: Int) -> [TrendPoint] { + let n = points.count + guard n > threshold, n > 2, targetCount >= 4 else { return points } + + // Reserve the first and last; bucket the interior. Each bucket yields up to 2 vertices (min+max), + // so aim for ~targetCount/2 buckets to land near the vertex budget. + let first = points[0] + let last = points[n - 1] + let interior = n - 2 + let bucketCount = max(1, (targetCount - 2) / 2) + guard bucketCount < interior else { return points } + + var out: [TrendPoint] = [] + out.reserveCapacity(targetCount) + out.append(first) - private var averageValue: Double { - guard !points.isEmpty else { return valueRange.lowerBound } - return points.map(\.value).reduce(0, +) / Double(points.count) + var lastEmittedDate = first.date + for b in 0.. points[maxIdx].value { maxIdx = i } + i += 1 + } + + // Emit the two extremes in chronological order, skipping duplicates (monotone bucket → one + // point) and any whose date would not advance (keeps `id: Date` unique for ForEach). + let lowFirst = minIdx <= maxIdx + let aIdx = lowFirst ? minIdx : maxIdx + let bIdx = lowFirst ? maxIdx : minIdx + for idx in [aIdx, bIdx] { + let p = points[idx] + if p.date > lastEmittedDate { + out.append(p) + lastEmittedDate = p.date + } + } + } + + if last.date > lastEmittedDate { out.append(last) } + return out } } @@ -253,3 +429,4 @@ private func sampleTrend(days: Int, base: Double, swing: Double) -> [TrendPoint] .preferredColorScheme(.dark) } #endif +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/TypicalRangeBar.swift b/Packages/StrandDesign/Sources/StrandDesign/TypicalRangeBar.swift new file mode 100644 index 0000000000..b9bf63ac3d --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/TypicalRangeBar.swift @@ -0,0 +1,243 @@ +import SwiftUI + +// MARK: - TypicalRangeBar (WHOOP detail "typical range" bar) +// +// "Solid = you, hatch = the context." A horizontal bar where a DIAGONAL-HATCH track marks the +// typical / reference RANGE (lower…upper, as fractions of the bar) and a SOLID coloured fill marks +// the user's VALUE. Mirrors WHOOP's sleep-stage and metric range rows: the eye instantly sees whether +// you landed inside, below or above the typical band, without a legend. +// +// This is a shared primitive so any screen can adopt the pattern with data it ALREADY has — pass a +// 0…1 value fraction and a 0…1 typical range. It invents no data: when no range is supplied it renders +// the value fill alone over a plain inset track (still flat + crisp, WHOOP-style). +// +// Two surfaces: +// • `TypicalRangeBar` — just the bar (swatch-free), for inline use under a value. +// • `TypicalRangeRow` — the full WHOOP row: [swatch] UPPERCASE LABEL · coloured value · bar · +// right-aligned white trailing (e.g. a duration), matching the sleep-stage list. +// +// Tokens only (no hardcoded hex), light/dark safe (the hatch reads on both), VoiceOver-summarised. + +// MARK: - Diagonal hatch shape + +/// A field of parallel 45° diagonal lines clipped to the shape's rect — the "typical range" texture. +/// Spacing/inset are in points so the hatch density stays constant regardless of bar width. +public struct DiagonalHatch: Shape { + /// Gap between hatch lines, in points. + public var spacing: CGFloat + public init(spacing: CGFloat = 5) { self.spacing = spacing } + + public func path(in rect: CGRect) -> Path { + var path = Path() + guard rect.width > 0, rect.height > 0, spacing > 0 else { return path } + // Draw lines running bottom-left → top-right (45°). Start far enough left that the slanted + // lines still cover the full rect after the diagonal offset, then clip to the rect. + var x = rect.minX - rect.height + while x <= rect.maxX { + path.move(to: CGPoint(x: x, y: rect.maxY)) + path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY)) + x += spacing + } + return path + } +} + +// MARK: - TypicalRangeBar + +public struct TypicalRangeBar: View { + + /// The user's value as a 0…1 fraction of the bar's full width (clamped). + public var value: Double + /// The typical / reference range as 0…1 fractions (lower…upper) of the bar (clamped, ordered). + /// nil = no range → the value fill sits over a plain inset track (no hatch). + public var typical: ClosedRange? + /// The solid fill colour for the user's value (the domain / stage / status token). + public var color: Color + /// Bar height. Kept short so it reads as a row element. + public var height: CGFloat + /// Corner radius of the bar; defaults to fully-rounded for the WHOOP pill look. + public var cornerRadius: CGFloat? + + public init( + value: Double, + typical: ClosedRange? = nil, + color: Color, + height: CGFloat = 8, + cornerRadius: CGFloat? = nil + ) { + self.value = value + self.typical = typical + self.color = color + self.height = height + self.cornerRadius = cornerRadius + } + + private var clampedValue: Double { min(max(value, 0), 1) } + + /// The typical range clamped to 0…1 and ordered low→high, or nil. + private var clampedTypical: ClosedRange? { + guard let t = typical else { return nil } + let lo = min(max(t.lowerBound, 0), 1) + let hi = min(max(t.upperBound, 0), 1) + return lo <= hi ? lo...hi : hi...lo + } + + public var body: some View { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let radius = cornerRadius ?? h / 2 + let shape = RoundedRectangle(cornerRadius: radius, style: .continuous) + + ZStack(alignment: .leading) { + // Base track — the inset "well" the bar sits in. + shape.fill(StrandPalette.surfaceInset) + + // Diagonal-hatch "typical range" segment (only over the range span). + if let t = clampedTypical, t.upperBound > t.lowerBound { + let x = w * CGFloat(t.lowerBound) + let segWidth = w * CGFloat(t.upperBound - t.lowerBound) + DiagonalHatch(spacing: 5) + .stroke(StrandPalette.textTertiary.opacity(0.55), lineWidth: 1) + .frame(width: segWidth, height: h) + .clipShape(RoundedRectangle(cornerRadius: min(radius, segWidth / 2), style: .continuous)) + .offset(x: x) + .accessibilityHidden(true) + } + + // Solid value fill — "you". Flat + crisp, no glow. + shape + .fill(color) + .frame(width: max(h, w * CGFloat(clampedValue))) + } + .clipShape(shape) + } + .frame(height: height) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(axLabel)) + } + + private var axLabel: String { + let pct = Int((clampedValue * 100).rounded()) + if let t = clampedTypical { + let lo = Int((t.lowerBound * 100).rounded()) + let hi = Int((t.upperBound * 100).rounded()) + let placement = clampedValue < t.lowerBound ? "below" : (clampedValue > t.upperBound ? "above" : "within") + return "\(pct) percent, \(placement) the typical range of \(lo) to \(hi) percent" + } + return "\(pct) percent" + } +} + +// MARK: - TypicalRangeRow (full WHOOP detail row) + +/// One WHOOP-style range row: a leading colour swatch, an UPPERCASE label, the value tinted to the +/// stage/domain colour, the hatched range bar, and a right-aligned WHITE trailing string (e.g. a +/// duration like "1:24"). Mirrors WHOOP's sleep-stage breakdown rows. +public struct TypicalRangeRow: View { + + /// UPPERCASE label (e.g. "DEEP", "REM", "HRV"). + public var label: String + /// The coloured value string shown next to the label (e.g. "18%"). nil hides it. + public var valueText: String? + /// Right-aligned white trailing string (e.g. a duration "1:24" or "62 ms"). nil hides it. + public var trailingText: String? + /// The user's value 0…1 fraction for the bar. + public var value: Double + /// The typical range 0…1, or nil for no hatch. + public var typical: ClosedRange? + /// The stage / domain colour (swatch, value tint, value fill). + public var color: Color + + public init( + label: String, + valueText: String? = nil, + trailingText: String? = nil, + value: Double, + typical: ClosedRange? = nil, + color: Color + ) { + self.label = label + self.valueText = valueText + self.trailingText = trailingText + self.value = value + self.typical = typical + self.color = color + } + + public var body: some View { + HStack(spacing: 10) { + // Colour swatch. + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(color) + .frame(width: 12, height: 12) + .accessibilityHidden(true) + + // Label + coloured value. + HStack(spacing: 6) { + Text(label.uppercased()) + .font(StrandFont.overline) + .tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textPrimary) + if let valueText { + Text(valueText) + .font(StrandFont.captionNumber) + .foregroundStyle(color) + } + } + .frame(width: 96, alignment: .leading) + + // The hatched range bar. + TypicalRangeBar(value: value, typical: typical, color: color) + + // Right-aligned WHITE trailing (duration / raw value). + if let trailingText { + Text(trailingText) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .frame(minWidth: 44, alignment: .trailing) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(axLabel)) + } + + private var axLabel: String { + var parts: [String] = [label] + if let valueText { parts.append(valueText) } + if let trailingText { parts.append(trailingText) } + let base = parts.joined(separator: ", ") + guard let t = typical else { return base } + let v = min(max(value, 0), 1) + let placement = v < t.lowerBound ? "below typical" : (v > t.upperBound ? "above typical" : "within typical range") + return "\(base), \(placement)" + } +} + +#if DEBUG +#Preview("TypicalRangeBar / Row") { + VStack(alignment: .leading, spacing: 16) { + Text("Sleep stages").strandOverline() + VStack(spacing: 10) { + TypicalRangeRow(label: "Awake", valueText: "4%", trailingText: "0:18", + value: 0.04, typical: 0.02...0.10, color: StrandPalette.sleepAwake) + TypicalRangeRow(label: "Light", valueText: "52%", trailingText: "4:02", + value: 0.52, typical: 0.45...0.60, color: StrandPalette.sleepLight) + TypicalRangeRow(label: "Deep", valueText: "18%", trailingText: "1:24", + value: 0.18, typical: 0.12...0.23, color: StrandPalette.sleepDeep) + TypicalRangeRow(label: "REM", valueText: "26%", trailingText: "2:01", + value: 0.26, typical: 0.18...0.25, color: StrandPalette.sleepREM) + } + + Text("Bar only").strandOverline().padding(.top, 8) + TypicalRangeBar(value: 0.72, typical: 0.40...0.65, color: StrandPalette.statusPositive) + .frame(width: 240) + TypicalRangeBar(value: 0.30, color: StrandPalette.accent) + .frame(width: 240) + } + .padding(28) + .frame(width: 520, height: 360) + .background(StrandPalette.surfaceBase) + .preferredColorScheme(.dark) +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/Typography.swift b/Packages/StrandDesign/Sources/StrandDesign/Typography.swift index e0659207f4..2e701e3d8a 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Typography.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Typography.swift @@ -2,74 +2,120 @@ import SwiftUI // MARK: - Strand Typography (§9.2) // -// SF Pro (Display ≥20pt, Text <20pt); tabular/monospaced digits everywhere for -// live values. SF Mono for raw/log views. Overline = sparing ALL-CAPS w/ tracking. +// Helvetica Neue everywhere (Titanium & Gold): a precise, mechanical grotesque +// in place of the old rounded face. Tabular/monospaced digits on every numeric +// role so live values don't reflow. SF Mono stays for raw/log views. Overline = +// sparing ALL-CAPS w/ wide tracking. // // All numeric styles use `.monospacedDigit()` so live values don't reflow. public enum StrandFont { + // MARK: Family + + /// The house family — Helvetica Neue, a built-in system face. Weight is applied + /// via `.weight()` since `Font.custom` ignores the design's default weight. + private static let family = "Helvetica Neue" + + /// Helvetica Neue at a FIXED size/weight — used by the big gauge/tile numerals (`display`, + /// `rounded`, `number`) that live in fixed-geometry rings/tiles where unbounded growth would + /// overflow. Prose and inline-number roles use `helveticaScaled` instead. + private static func helvetica(_ size: CGFloat, weight: Font.Weight) -> Font { + .custom(family, size: size).weight(weight) + } + + /// Like `helvetica`, but the size SCALES with the user's Dynamic Type / Larger Text setting, + /// anchored to a matching text style. The plain `.custom(_:size:)` overload produces a FROZEN + /// point size, so every prose/label role used to ignore Dynamic Type entirely — this routes them + /// through `.custom(_:size:relativeTo:)` so they scale (available on the iOS 16 / macOS 13 floor). + private static func helveticaScaled(_ size: CGFloat, weight: Font.Weight, + relativeTo style: Font.TextStyle) -> Font { + .custom(family, size: size, relativeTo: style).weight(weight) + } + // MARK: Scale (§9.2) - /// Display 64–80 / Semibold — the recovery ring number. Tabular digits. + /// Display 64–80 / Bold — the gauge score number. Helvetica Neue 700 with tight + /// tracking (≈ -0.04em), tabular digits so a changing value never reflows. public static func display(_ size: CGFloat = 72) -> Font { - .system(size: size, weight: .semibold, design: .default).monospacedDigit() + helvetica(size, weight: .bold).monospacedDigit() } - /// Title1 28 / Bold. - public static let title1 = Font.system(size: 28, weight: .bold) + /// The tight tracking for big display numbers (≈ -0.04em). Apply alongside + /// `display(_:)` at the use site, e.g. `.tracking(StrandFont.displayTracking(72))`. + public static func displayTracking(_ size: CGFloat = 72) -> CGFloat { + -size * 0.04 + } + + /// A Helvetica-Neue numeric style at an arbitrary size/weight — the house + /// numeral. Tabular so live values align. Use anywhere a score/number is shown. + public static func rounded(_ size: CGFloat, weight: Font.Weight = .bold) -> Font { + helvetica(size, weight: weight).monospacedDigit() + } - /// Title2 22 / Semibold. - public static let title2 = Font.system(size: 22, weight: .semibold) + /// Title1 28 / Bold. Scales with Dynamic Type. + public static let title1 = helveticaScaled(28, weight: .bold, relativeTo: .title) - /// Headline 17 / Semibold. - public static let headline = Font.system(size: 17, weight: .semibold) + /// Title2 22 / Semibold. Scales with Dynamic Type. + public static let title2 = helveticaScaled(22, weight: .semibold, relativeTo: .title2) - /// Body 15 / Regular. - public static let body = Font.system(size: 15, weight: .regular) + /// Headline 17 / Semibold. Scales with Dynamic Type. + public static let headline = helveticaScaled(17, weight: .semibold, relativeTo: .headline) - /// Subhead 13. - public static let subhead = Font.system(size: 13, weight: .regular) + /// Body 15 / Regular. Scales with Dynamic Type. + public static let body = helveticaScaled(15, weight: .regular, relativeTo: .body) - /// Caption 12. - public static let caption = Font.system(size: 12, weight: .regular) + /// Subhead 13. Scales with Dynamic Type. + public static let subhead = helveticaScaled(13, weight: .regular, relativeTo: .subheadline) - /// Footnote 11. - public static let footnote = Font.system(size: 11, weight: .regular) + /// Caption 12. Scales with Dynamic Type. + public static let caption = helveticaScaled(12, weight: .regular, relativeTo: .caption) - /// Overline 11 / Semibold, +0.8 tracking (apply `.tracking(0.8)` at use site; - /// `overlineText(_:)` does it for you). Sparing ALL-CAPS labels. - public static let overline = Font.system(size: 11, weight: .semibold) + /// Footnote 11. Scales with Dynamic Type. + public static let footnote = helveticaScaled(11, weight: .regular, relativeTo: .footnote) + + /// Overline 11 / Bold, +1.4 tracking (apply `.tracking(1.4)` at use site; + /// `overlineText(_:)` does it for you). Sparing ALL-CAPS labels. Scales with Dynamic Type. + public static let overline = helveticaScaled(11, weight: .bold, relativeTo: .caption2) + + /// `overline` at a custom point size — same Helvetica face, weight and Dynamic-Type scaling + /// (relativeTo `.caption2`), just smaller. Passing 11 returns exactly `.overline`. Lets a caller + /// shrink an ALL-CAPS label to fit a small container without losing accessibility text-scaling. + public static func overlineScaled(_ size: CGFloat) -> Font { + helveticaScaled(size, weight: .bold, relativeTo: .caption2) + } /// Mono 13 (SF Mono) — raw / log views. Tabular by nature. public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced) // MARK: Numeric variants (tabular digits) - /// A monospaced-digit numeric style at an arbitrary size/weight, for live values. + /// A numeric style at an arbitrary size/weight, for live values — Helvetica + /// Neue, tabular digits. This is the tile/value numeral. public static func number(_ size: CGFloat, weight: Font.Weight = .semibold) -> Font { - .system(size: size, weight: weight, design: .default).monospacedDigit() + helvetica(size, weight: weight).monospacedDigit() } - /// Monospaced-digit body — for inline live values that should align. - public static let bodyNumber = Font.system(size: 15, weight: .regular).monospacedDigit() + /// Helvetica-Neue body number — for inline live values that should align. Scales with Dynamic + /// Type alongside its sibling `body`/`caption` labels so a value and its label stay matched. + public static let bodyNumber = helveticaScaled(15, weight: .medium, relativeTo: .body).monospacedDigit() - /// Monospaced-digit caption — for small live values (sparklines, chips). - public static let captionNumber = Font.system(size: 12, weight: .medium).monospacedDigit() + /// Helvetica-Neue caption number — for small live values (sparklines, chips). Scales with Dynamic Type. + public static let captionNumber = helveticaScaled(12, weight: .medium, relativeTo: .caption).monospacedDigit() /// Mono at an arbitrary size. public static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { .system(size: size, weight: weight, design: .monospaced) } - /// The recommended tracking for overline text. - public static let overlineTracking: CGFloat = 0.8 + /// The recommended tracking for overline text (wide ALL-CAPS labels, ≈ 0.13em). + public static let overlineTracking: CGFloat = 1.4 } // MARK: - Text helpers public extension Text { - /// Style as an overline label: ALL-CAPS, semibold, +0.8 tracking, tertiary text. + /// Style as an overline label: ALL-CAPS, bold, +1.4 tracking, tertiary text. func strandOverline() -> some View { self.font(StrandFont.overline) .tracking(StrandFont.overlineTracking) @@ -89,7 +135,7 @@ public extension View { #Preview("Typography") { ScrollView { VStack(alignment: .leading, spacing: 18) { - Text("88").font(StrandFont.display(72)).foregroundStyle(StrandPalette.textPrimary) + Text("88").font(StrandFont.display(72)).tracking(StrandFont.displayTracking(72)).foregroundStyle(StrandPalette.textPrimary) Text("Title 1 / Bold 28").font(StrandFont.title1).foregroundStyle(StrandPalette.textPrimary) Text("Title 2 / Semibold 22").font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary) Text("Headline / Semibold 17").font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) diff --git a/Packages/StrandDesign/Sources/StrandDesign/WatchScoreSnapshot.swift b/Packages/StrandDesign/Sources/StrandDesign/WatchScoreSnapshot.swift new file mode 100644 index 0000000000..f321847c90 --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/WatchScoreSnapshot.swift @@ -0,0 +1,207 @@ +import Foundation + +/// The phone to watch payload. The iPhone is the brain (M1 computes Charge / Effort / Rest with +/// confidence + provenance); this is the small, honest snapshot it pushes over WatchConnectivity for +/// the watch app + its complication to DISPLAY. The watch never recomputes a score, it only renders +/// what arrived here. +/// +/// It lives in StrandDesign so BOTH sides import one definition: the iOS `WatchSessionBridge` encodes +/// it, the watchOS app + complication decode it (read from the shared app group's UserDefaults under +/// `latestWatchSnapshot`). One type, one wire shape, so the glance and the complication can never +/// disagree about what the phone said. +/// +/// The honesty rule carries through: a calibrating score is the number being `nil` AND the matching +/// `Calibrating` flag set true. The watch UI must render that as "needs more data" (a dash + a small +/// cal marker), NEVER a fabricated number. A score that simply has not been computed yet is also `nil` +/// but with its flag false (missing data, not mid-calibration) and reads as a plain dash. +public struct WatchScoreSnapshot: Codable, Equatable, Sendable { + /// Charge (recovery), 0 to 100. `nil` when there is no earned number for the day. + public var charge: Double? + /// True when Charge is still calibrating (the baseline is not usable yet). When true, `charge` is + /// `nil` and the watch shows a cal marker rather than a number. + public var chargeCalibrating: Bool + + /// Effort (strain) on NOOP's 0 to 100 axis. `nil` when there is no usable HR window for the day. + public var effort: Double? + /// True when Effort is still calibrating. `effort` is `nil` while this is true. + public var effortCalibrating: Bool + + /// Rest (sleep) composite, 0 to 100. `nil` when there is no matched in-bed session for the day. + public var rest: Double? + /// True when Rest is still calibrating. `rest` is `nil` while this is true. + public var restCalibrating: Bool + + /// Most recent heart rate the phone knows about (bpm). The watch shows its OWN live HR off its + /// sensor; this is just the last value the phone had, used as a fallback / sync indicator. + public var hr: Int? + + /// A one line sleep summary for the glance (e.g. "7h 12m · 81% efficiency"), already formatted by + /// the phone. Empty string when there is nothing to show. + public var sleepSummary: String + + /// When the phone built this snapshot. The watch shows its age ("as of 2h ago") rather than + /// implying the numbers are live. + public var asOf: Date + + /// The anchor day these scores describe, as a "YYYY-MM-DD" local day key (nil when unknown). This is + /// the day the numbers are ABOUT, which is not the same as `asOf` (when the phone built the snapshot): + /// you can build a snapshot at 9am that still describes yesterday's scores until today's are computed. + /// The watch prefers this for its recency label so it reads honestly ("Yesterday") even when the build + /// is recent. Optional + decodes as nil when absent so older payloads on the wire stay compatible. + public var scoreDay: String? + + public init(charge: Double?, chargeCalibrating: Bool, + effort: Double?, effortCalibrating: Bool, + rest: Double?, restCalibrating: Bool, + hr: Int?, sleepSummary: String, asOf: Date, + scoreDay: String? = nil) { + self.charge = charge + self.chargeCalibrating = chargeCalibrating + self.effort = effort + self.effortCalibrating = effortCalibrating + self.rest = rest + self.restCalibrating = restCalibrating + self.hr = hr + self.sleepSummary = sleepSummary + self.asOf = asOf + self.scoreDay = scoreDay + } + + // MARK: - Shared app group transport + // + // The watch app + its complication read the latest snapshot from the shared app group's + // UserDefaults under this key. The phone side writes the same key on its own UserDefaults view of + // the group too (belt and braces alongside updateApplicationContext), so a freshly launched watch + // reads the last known value immediately. + + /// The shared app group both the watch app and its complication read the snapshot from. + public static let appGroupId = "group.bbdw.noop" + /// The UserDefaults key the latest snapshot is stored under in the shared app group. + public static let storageKey = "latestWatchSnapshot" + + /// A neutral placeholder for previews / a not-yet-synced watch. Everything calibrating + empty so + /// nothing fake is ever drawn. + public static var placeholder: WatchScoreSnapshot { + WatchScoreSnapshot(charge: nil, chargeCalibrating: true, + effort: nil, effortCalibrating: true, + rest: nil, restCalibrating: true, + hr: nil, sleepSummary: "", asOf: Date(timeIntervalSince1970: 0)) + } + + // MARK: - Freshness + // + // The phone is the only thing that computes scores; the watch just renders the last snapshot it has. + // If the wrist hasn't seen the phone for a long stretch (off the charger, phone dead, app not opened), + // the numbers it's holding can quietly go stale. These two helpers let the watch UI stay honest about + // that: degrade a too-old snapshot to a dash, and always show a short recency label next to the rings. + + /// How old a snapshot may be before the watch should stop presenting it as the current day's scores. + /// ~36h, not 24h: scores anchor on a logical day and Rest in particular lands the morning after, so a + /// little past a full day is normal. Beyond this it's almost certainly a phone the watch lost touch with. + private static let stalenessThreshold: TimeInterval = 36 * 3600 + + /// True when this snapshot is too old to present as current. Callers degrade the rings + number to a + /// dash (and lean on `freshnessText` to explain why) rather than drawing a confidently wrong figure. + /// The placeholder (asOf at the epoch) always reads stale, which is what we want for a never-synced watch. + public func isStale(now: Date = Date()) -> Bool { + now.timeIntervalSince(asOf) > Self.stalenessThreshold + } + + /// The semantic freshness buckets behind `freshnessText`. Display code that needs to REASON about + /// recency (e.g. "the label adds nothing while the scores are current, hide it") goes through + /// `isFreshToday`, which switches on this classification. It must never compare the localized + /// display string ("Today" / "just now") instead: that reads fine in English and silently breaks + /// in every translated language. + private enum FreshnessKind { + case today + case yesterday + case weekday(Date) + case daysAgo(Int) + case builtJustNow + case builtMinutesAgo(Int) + case builtHoursAgo(Int) + case builtDaysAgo(Int) + } + + /// Classify this snapshot's recency. Prefers `scoreDay` (the day the scores are ABOUT) when the + /// phone supplied it, so the label can read "Today" / "Yesterday" / a weekday rather than implying + /// live numbers. Falls back to the `asOf` build age for older payloads that predate `scoreDay`. + private func freshnessKind(now: Date) -> FreshnessKind { + let cal = Calendar.current + + // Preferred path: we know which day the scores describe. Compare day keys against "now" so the + // label tracks the actual calendar day, not the build clock. + if let scoreDay, let scored = Self.dayKeyFormatter.date(from: scoreDay) { + if cal.isDateInToday(scored) { return .today } + if cal.isDateInYesterday(scored) { return .yesterday } + let days = cal.dateComponents([.day], from: cal.startOfDay(for: scored), + to: cal.startOfDay(for: now)).day ?? 0 + // Within the last week a weekday name ("Mon") is the most readable; past that, a plain count. + if days >= 2 && days <= 6 { return .weekday(scored) } + return .daysAgo(max(days, 0)) + } + + // Fallback: no scoreDay (an older snapshot). Describe the build age of the snapshot itself. + let age = now.timeIntervalSince(asOf) + if age < 60 { return .builtJustNow } + if age < 3600 { return .builtMinutesAgo(Int(age / 60)) } + if age < 86_400 { return .builtHoursAgo(Int(age / 3600)) } + return .builtDaysAgo(Int(age / 86_400)) + } + + /// A short, honest recency label for the glance + complication ("Today" / "Yesterday" / "2h ago"). + /// Rendered off `freshnessKind` so the words and the semantics (`isFreshToday`) can never drift. + public func freshnessText(now: Date = Date()) -> String { + switch freshnessKind(now: now) { + case .today: return String(localized: "Today", bundle: .module) + case .yesterday: return String(localized: "Yesterday", bundle: .module) + case .weekday(let scored): + let f = DateFormatter() + f.dateFormat = "EEE" + return f.string(from: scored) + case .daysAgo(let days): return String(localized: "\(days) days ago", bundle: .module) + case .builtJustNow: return String(localized: "just now", bundle: .module) + case .builtMinutesAgo(let m): return String(localized: "\(m)m ago", bundle: .module) + case .builtHoursAgo(let h): return String(localized: "\(h)h ago", bundle: .module) + case .builtDaysAgo(let d): return String(localized: "\(d)d ago", bundle: .module) + } + } + + /// True when the scores read as CURRENT: they describe today's local day, or (for older payloads + /// without `scoreDay`) the snapshot was built under a minute ago. This is the semantic twin of + /// `freshnessText` returning "Today" / "just now", and it is what display code must key off when it + /// appends the freshness label only where it adds information. Never compare the localized display + /// text for that decision: it breaks the moment a string catalog translates "Today". Derived at + /// read time from fields already on the wire (`scoreDay` + `asOf`), so the encoded payload is + /// unchanged and a snapshot from an older phone build classifies exactly as before. + public func isFreshToday(now: Date = Date()) -> Bool { + switch freshnessKind(now: now) { + case .today, .builtJustNow: return true + default: return false + } + } + + /// Shared "YYYY-MM-DD" parser/formatter for `scoreDay`. Fixed locale + POSIX so it round-trips the + /// phone's `Repository.localDayKey` keys identically regardless of the watch's region settings. + private static let dayKeyFormatter: DateFormatter = { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + + /// Decode the last snapshot the phone wrote into the shared app group, if any. + public static func load(from defaults: UserDefaults? = UserDefaults(suiteName: appGroupId)) -> WatchScoreSnapshot? { + guard let defaults, + let data = defaults.data(forKey: storageKey), + let snap = try? JSONDecoder().decode(WatchScoreSnapshot.self, from: data) else { return nil } + return snap + } + + /// Persist this snapshot into the shared app group so the watch app + complication can read it. + public func save(to defaults: UserDefaults? = UserDefaults(suiteName: WatchScoreSnapshot.appGroupId)) { + guard let defaults, let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: WatchScoreSnapshot.storageKey) + } +} diff --git a/Packages/StrandDesign/Sources/StrandDesign/YearHeatStrip.swift b/Packages/StrandDesign/Sources/StrandDesign/YearHeatStrip.swift index a4510aafb3..b754897b84 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/YearHeatStrip.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/YearHeatStrip.swift @@ -1,3 +1,6 @@ +#if !os(watchOS) +// YearHeatStrip uses .onContinuousHover + .help() tooltips (unavailable on watchOS); the watch +// never shows the year heat strip, so the whole view is excluded there. iOS/macOS unchanged. import SwiftUI // MARK: - Year Heat Strip (§9.4 Trends) @@ -32,6 +35,13 @@ public struct YearHeatStrip: View { /// Formats a day's score for the tooltip's bold line. public var valueFormat: (Double) -> String + /// The week-column layout, built ONCE here in `init` from the sorted days rather than on every + /// `body` eval. `buildWeeks()` reads `.component` for up to 365 days, and `body` re-ran on every + /// hover (which mutates `@State hoverCell`) — so the layout was being recomputed on each pointer + /// move. Since the struct is only re-created when `days` actually changes, computing it here + /// memoizes the layout on `days` identity for free, with no behaviour change. + private let weeks: [Week] + public init( days: [RecoveryDay], cellSize: CGFloat = 12, @@ -40,12 +50,14 @@ public struct YearHeatStrip: View { showsHover: Bool = true, valueFormat: @escaping (Double) -> String = { "Recovery \(Int($0.rounded()))" } ) { - self.days = days.sorted { $0.date < $1.date } + let sorted = days.sorted { $0.date < $1.date } + self.days = sorted self.cellSize = cellSize self.spacing = spacing self.showsMonthLabels = showsMonthLabels self.showsHover = showsHover self.valueFormat = valueFormat + self.weeks = YearHeatStrip.buildWeeks(from: sorted) } // The grid layout constants used both for drawing and hover hit-testing. @@ -55,11 +67,15 @@ public struct YearHeatStrip: View { /// Hovered cell as (weekIndex, row), or nil. @State private var hoverCell: (week: Int, row: Int)? = nil - private var calendar: Calendar { + // A fixed Monday-first Gregorian calendar, stored once as a constant rather than a computed + // property. `buildWeeks()` runs on every render (including each hover, which mutates @State) + // and reads `.component` for up to 365 days, so the old computed form allocated a fresh + // Calendar on every one of those ~730 accesses per render. + private static let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.firstWeekday = 2 // Monday-first columns read nicely return c - } + }() // Group days into week columns. weekday 0 = Monday ... 6 = Sunday. private struct Week: Identifiable { @@ -68,7 +84,9 @@ public struct YearHeatStrip: View { var monthLabel: String? } - private func buildWeeks() -> [Week] { + /// Pure: group the (already-sorted) days into Monday-first week columns. Static so it can run once + /// from `init` (no instance state is read — only the static calendar + formatter cache). + private static func buildWeeks(from days: [RecoveryDay]) -> [Week] { guard let first = days.first?.date else { return [] } var weeks: [Week] = [] var current = Week(cells: Array(repeating: nil, count: 7), monthLabel: nil) @@ -98,13 +116,13 @@ public struct YearHeatStrip: View { return weeks } - private func weekdayRow(_ date: Date) -> Int { + private static func weekdayRow(_ date: Date) -> Int { // Map Calendar weekday (1=Sun...7=Sat) to Monday-first 0...6. let wd = calendar.component(.weekday, from: date) return (wd + 5) % 7 } - private func monthShort(_ date: Date) -> String { + private static func monthShort(_ date: Date) -> String { let f = DateFormatterCache.month return f.string(from: date) } @@ -112,7 +130,7 @@ public struct YearHeatStrip: View { private let rowLabels = ["Mon", "", "Wed", "", "Fri", "", "Sun"] public var body: some View { - let weeks = buildWeeks() + // `weeks` is the layout built ONCE in init (see the stored property), not rebuilt per body eval. // Total drawn size, so the hover overlay can be laid over the grid and // a tooltip can be clamped within bounds. let gridWidth = gridOriginX + CGFloat(weeks.count) * (cellSize + spacing) - spacing @@ -163,6 +181,22 @@ public struct YearHeatStrip: View { hoverCell = nil } } + // ONE collapsed VoiceOver element for the whole calendar. The 365 coloured cells are pure shapes + // (hover is dead on touch), and emitting one a11y node PER scored day (the old `cell` did) built + // an O(days) semantics subtree the accessibility walk re-copied on every scroll — a #707 OOM + // contributor. `children: .ignore` collapses the grid to this single summary at O(1) node cost. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(axSummary)) + } + + /// A spoken one-line summary of the whole strip for VoiceOver. + private var axSummary: String { + let scored = days.compactMap { $0.score } + guard let lo = scored.min(), let hi = scored.max() else { + return String(localized: "Recovery calendar, no data", bundle: .module) + } + let avg = scored.reduce(0, +) / Double(scored.count) + return String(localized: "Recovery calendar, \(scored.count) days, average \(Int(avg.rounded())), low \(Int(lo.rounded())), high \(Int(hi.rounded()))", bundle: .module) } // MARK: Grid geometry @@ -241,6 +275,9 @@ public struct YearHeatStrip: View { .frame(width: cellSize, height: cellSize) .opacity(isHovered ? 1.0 : (hoverCell == nil ? 1.0 : 0.78)) .help("\(DateFormatterCache.day.string(from: day.date)) · recovery \(Int(score.rounded()))") + // No per-cell a11y element: the whole strip is one collapsed VoiceOver element (see the + // `children: .ignore` summary on the body), so per-day detail no longer builds an O(days) + // semantics subtree. The `.help` above stays — it's a macOS pointer tooltip, not an a11y node. } else if day != nil { shape .fill(StrandPalette.surfaceInset) @@ -290,3 +327,4 @@ private func sampleYear() -> [RecoveryDay] { .preferredColorScheme(.dark) } #endif +#endif diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/OverviewHRChartAnnotationTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/OverviewHRChartAnnotationTests.swift new file mode 100644 index 0000000000..1a7ca217c1 --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/OverviewHRChartAnnotationTests.swift @@ -0,0 +1,87 @@ +#if !os(watchOS) +import XCTest +import SwiftUI +@testable import StrandDesign + +/// Deep Timeline annotation parity (#979 spin-off): the pure span-scoping that decides WHICH sleep +/// band and workout glyphs annotate a visible day window. These mirror the classic Today's picks +/// (longest overlapping sleep = the main night; edge-inclusive workout overlap), so the two whole-day +/// charts can never disagree about what a day looked like. +final class OverviewHRChartAnnotationTests: XCTestCase { + + private func date(_ t: TimeInterval) -> Date { Date(timeIntervalSince1970: t) } + private func sleep(_ lo: TimeInterval, _ hi: TimeInterval, label: String? = nil) -> OverviewHRChart.SleepSpan { + .init(start: date(lo), end: date(hi), label: label) + } + private func workout(_ lo: TimeInterval, _ hi: TimeInterval) -> OverviewHRChart.WorkoutSpan { + .init(start: date(lo), end: date(hi), symbol: "figure.run") + } + + /// A day window: 86 400 s starting at t=100 000 (arbitrary epoch, values only matter relatively). + private let day: ClosedRange = Date(timeIntervalSince1970: 100_000)...Date(timeIntervalSince1970: 186_400) + + // MARK: mainSleep — the main night, never a nap + + /// The LONGEST overlapping block wins, exactly like the classic Today: a 7h night beats a 40m nap. + func testMainSleepPicksLongestOverlappingBlock() { + let night = sleep(95_000, 120_200, label: "7:00") // 25 200 s = 7h, straddles the day start + let nap = sleep(150_000, 152_400, label: "0:40") // 2 400 s afternoon nap + let picked = OverviewHRChart.mainSleep([nap, night], overlapping: day) + XCTAssertEqual(picked?.start, night.start) + XCTAssertEqual(picked?.end, night.end) + XCTAssertEqual(picked?.label, "7:00") + } + + /// A night that merely STRADDLES the window edge still counts (the pre-midnight onset case #144 + /// lives on) — overlap, not containment. + func testMainSleepKeepsStraddlingNight() { + let night = sleep(80_000, 110_000) // starts well before the day, ends inside + XCTAssertNotNil(OverviewHRChart.mainSleep([night], overlapping: day)) + } + + /// Blocks entirely OUTSIDE the window never band it — including exact edge-touching ones, which + /// contribute zero visible band (mirrors Today's strict `>` / `<` sleep filter). + func testMainSleepDropsNonOverlappingAndEdgeTouching() { + let before = sleep(10_000, 50_000) + let endsAtStart = sleep(90_000, 100_000) // ends exactly at window start → zero band + let startsAtEnd = sleep(186_400, 190_000) // starts exactly at window end → zero band + let after = sleep(200_000, 220_000) + XCTAssertNil(OverviewHRChart.mainSleep([before, endsAtStart, startsAtEnd, after], overlapping: day)) + } + + /// Empty candidates → nil, never a fabricated band. + func testMainSleepEmptyIsNil() { + XCTAssertNil(OverviewHRChart.mainSleep([], overlapping: day)) + } + + // MARK: workouts — edge-inclusive overlap, order preserved + + /// Overlapping workouts are kept in their supplied order; disjoint ones are dropped. + func testWorkoutsKeepsOverlappingInOrder() { + let morning = workout(110_000, 113_600) + let evening = workout(170_000, 173_600) + let lastWeek = workout(10_000, 13_600) + let kept = OverviewHRChart.workouts([morning, lastWeek, evening], overlapping: day) + XCTAssertEqual(kept.map(\.start), [morning.start, evening.start]) + } + + /// Edge-TOUCHING workouts are kept (inclusive `>=` / `<=`, mirroring Today's workout filter — a + /// session ending exactly at midnight still belongs to the day it filled). + func testWorkoutsKeepsEdgeTouching() { + let endsAtStart = workout(96_400, 100_000) // ends exactly at the window start + let startsAtEnd = workout(186_400, 190_000) // starts exactly at the window end + let kept = OverviewHRChart.workouts([endsAtStart, startsAtEnd], overlapping: day) + XCTAssertEqual(kept.count, 2) + } + + /// A workout spanning the WHOLE window (an ultra, a long hike) is kept. + func testWorkoutsKeepsWindowSpanning() { + let ultra = workout(90_000, 200_000) + XCTAssertEqual(OverviewHRChart.workouts([ultra], overlapping: day).count, 1) + } + + func testWorkoutsEmptyIsEmpty() { + XCTAssertTrue(OverviewHRChart.workouts([], overlapping: day).isEmpty) + } +} +#endif diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/PlaceholderTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/PlaceholderTests.swift index b6c579da33..ebfad13faa 100644 --- a/Packages/StrandDesign/Tests/StrandDesignTests/PlaceholderTests.swift +++ b/Packages/StrandDesign/Tests/StrandDesignTests/PlaceholderTests.swift @@ -56,11 +56,11 @@ final class StrandDesignTests: XCTestCase { } func testStrainColorScaleAndEndpoints() { - // Strain samples the 0...21 ramp; endpoints match ember/magenta. + // Effort samples the 0...100 ramp; endpoints match ember/magenta. let ember = StrandPalette.strainColor(0).rgbaComponents let start = StrandPalette.strain000.rgbaComponents XCTAssertEqual(ember.r, start.r, accuracy: 0.02) - let magenta = StrandPalette.strainColor(21).rgbaComponents + let magenta = StrandPalette.strainColor(100).rgbaComponents let end = StrandPalette.strain100.rgbaComponents XCTAssertEqual(magenta.b, end.b, accuracy: 0.02) } @@ -169,4 +169,47 @@ final class StrandDesignTests: XCTestCase { XCTAssertEqual(Sparkline.defaultValueString(64), "64") XCTAssertEqual(Sparkline.defaultValueString(64.5), "64.5") } + + // MARK: - TrendChart Y domain (#974 top-headroom fix) + + /// With no explicit yDomain the axis falls back to the gradient's valueRange. + func testTrendChartResolvedDomainDefaultsToValueRange() { + let pts = [ + TrendPoint(date: Date(timeIntervalSince1970: 0), value: 10), + TrendPoint(date: Date(timeIntervalSince1970: 86_400), value: 40), + ] + let chart = TrendChart(points: pts, valueRange: 0...100) + XCTAssertEqual(chart.resolvedYDomain.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(chart.resolvedYDomain.upperBound, 100, accuracy: 0.0001) + } + + /// An explicit yDomain (a data-fitted axis with top headroom) overrides valueRange, and its + /// top sits ABOVE the highest reading so a peak curve + the top axis label clear the plot clip. + func testTrendChartExplicitYDomainProvidesTopHeadroom() { + let peak = 2.4 + let pts = [ + TrendPoint(date: Date(timeIntervalSince1970: 0), value: 0.5), + TrendPoint(date: Date(timeIntervalSince1970: 86_400), value: peak), + ] + // Mirrors StressView's fitted axis: round the peak up, add headroom, floor at 1. + let yTop = max(1, peak.rounded(.up) + 0.3) + let chart = TrendChart(points: pts, valueRange: 0...3, yDomain: 0...yTop) + XCTAssertEqual(chart.resolvedYDomain.lowerBound, 0, accuracy: 0.0001) + // 2.4 → ceil 3 → +0.3 = 3.3, comfortably above the peak. + XCTAssertGreaterThan(chart.resolvedYDomain.upperBound, peak) + XCTAssertEqual(chart.resolvedYDomain.upperBound, 3.3, accuracy: 0.0001) + } + + /// A flat, all-calm history (max 0) must not collapse to a zero-height axis: the floor holds it at 1. + func testTrendChartFittedDomainFloorsAtOne() { + let peak = 0.0 + let yTop = max(1, peak.rounded(.up) + 0.3) + let pts = [ + TrendPoint(date: Date(timeIntervalSince1970: 0), value: 0), + TrendPoint(date: Date(timeIntervalSince1970: 86_400), value: 0), + ] + let chart = TrendChart(points: pts, valueRange: 0...3, yDomain: 0...yTop) + XCTAssertEqual(chart.resolvedYDomain.upperBound, 1, accuracy: 0.0001) + XCTAssertGreaterThan(chart.resolvedYDomain.upperBound, chart.resolvedYDomain.lowerBound) + } } diff --git a/Packages/StrandImport/Package.resolved b/Packages/StrandImport/Package.resolved index b2ef5d5aab..c9f3ca88a0 100644 --- a/Packages/StrandImport/Package.resolved +++ b/Packages/StrandImport/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift.git", - "state" : { - "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", - "version" : "6.29.3" - } - }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/Packages/StrandImport/Package.swift b/Packages/StrandImport/Package.swift index ceea8b0802..72b3a8af7b 100644 --- a/Packages/StrandImport/Package.swift +++ b/Packages/StrandImport/Package.swift @@ -8,14 +8,19 @@ let package = Package( dependencies: [ .package(path: "../WhoopProtocol"), .package(path: "../WhoopStore"), - .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0"), + // Supply-chain: pinned EXACT (not `from:`) so a clean resolve can't auto-pull a newer — + // potentially compromised — upstream release. The exact version MUST match the other + // Packages/*/Package.swift and project.yml or SPM resolution fails. Bump deliberately. + .package(url: "https://github.com/weichsel/ZIPFoundation.git", exact: "0.9.20"), ], targets: [ .target(name: "StrandImport", dependencies: [ "WhoopProtocol", "WhoopStore", .product(name: "ZIPFoundation", package: "ZIPFoundation"), ]), - .testTarget(name: "StrandImportTests", dependencies: ["StrandImport"], resources: [ + .testTarget(name: "StrandImportTests", dependencies: [ + "StrandImport", + ], resources: [ .copy("Resources"), ]), ] diff --git a/Packages/StrandImport/Sources/StrandImport/ActivityFileImporter.swift b/Packages/StrandImport/Sources/StrandImport/ActivityFileImporter.swift new file mode 100644 index 0000000000..c887e407ef --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/ActivityFileImporter.swift @@ -0,0 +1,393 @@ +import Foundation + +// MARK: - On-device activity-file import (GPX / TCX / FIT) — source "activity-file" +// +// Lets a user bring in a single exported activity FILE from ANY brand — Garmin, Coros, Suunto, +// Wahoo, Polar, Strava, WHOOP, Apple, etc. — fully offline. The three universal interchange formats +// are covered: +// +// • GPX (XML) — the universal GPS-track format. with optional +// ,