Apnea Analyzer

Status: in-progress  ·  software  ·  View on GitHub →

What It Does

A local Streamlit dashboard that combines CPAP therapy data (ResMed via OSCAR), Garmin Fenix wearable metrics, Withings body composition, and tirzepatide medication tracking into a single SQLite-backed analytics tool. Built to answer whether sleep apnea treatment is working holistically — not just controlled AHI events, but improving HRV, sleep quality, and trending toward weight-driven remission. Pre-CPAP nights (before therapy started) are visible on all charts for before/after baseline comparison. A dedicated Weight & Medication page tracks 14-day rolling weight loss velocity with dose tier context, making the tirzepatide treatment arc visible alongside CPAP data. A fifth data source, an EMAY pulse oximeter, now has its own eleventh dashboard page: high-resolution overnight SpO2/pulse tracking with auto-detected desaturation events matched to the nearest CPAP event, plus a sidebar badge showing at a glance whether recent oximeter coverage has lapsed. All timestamp/date conversions are anchored to a configurable Home Timezone rather than the OS’s ambient clock, and a Settings page section flags EMAY sessions recorded on a broken device clock, letting the user manually assign the correct date so it re-joins the rest of that night’s data.

How It Works

Four data connectors (OSCAR/oscar-etl, Garmin Connect API, Withings API, tirzepatide CSV) write into SQLite via a one-directional sync pipeline. A nightly_summary view uses a 3-source UNION CTE spine so every night with any data — including pre-CPAP Garmin and Withings rows — appears with NULL CPAP columns. Two Streamlit pages — a CPAP Dashboard and a Correlation Dashboard — read exclusively from SQLite and never touch the sync layer. The CPAP page filters to CPAP-only nights (cpap_hours IS NOT NULL) so pre-CPAP rows never distort CPAP metrics. The correlation charts split weight and HRV into pre-CPAP (40% opacity) and post-CPAP traces on dual-axis Plotly charts, with tirzepatide injection markers color-coded by dose tier and a configurable baseline band overlay on all charts.

The Correlation Dashboard includes two clinical scatter charts: SpO2 nadir vs. AHI (inverted y-axis, dashed 90% threshold line) and sleep stages quality vs. AHI (filled target bands for deep and REM). The garmin_sleep schema was extended with a min_spo2 column capturing the overnight SpO2 nadir from Garmin’s lowestSpO2Value API field.

A fourth page — Weight & Medication — shows a 14-day rolling weight loss velocity chart (calendar-day indexed, kg/week units, y-axis inverted so downward = progress) with colored background bands marking each tirzepatide dose tier period. A body composition chart tracks fat mass and lean mass separately. Three KPI tiles summarize current velocity, total weight change with tirzepatide-era delta, and current dose tier. The data layer in queries/weight.py forward-fills dose tier across the full date range before returning — ensuring tier context is never lost due to injection date gaps.

A fifth page — Nightly Score — computes a composite 0–100 sleep health score for each post-CPAP night, weighting AHI (35%), deep sleep % (25%), REM % (15%), SpO2 (15%), and HRV (10%). Missing components reduce the score proportionally (fixed denominator of 1.0 keeps the scale consistent). The Score Trend chart overlays green/yellow/red zone bands on the nightly dot series, adds a white 7-day rolling average line, and marks nights missing 2+ data sources with diamond symbols. The Score Breakdown chart shows the per-component contribution as stacked bars so the source of a good or bad score is immediately visible. Three KPI tiles summarize Last Night (with delta vs. 7-day average), 7-Day Average, and Best Score Ever.

A trend detection engine runs automatically at the end of every sync. Four detectors — AHI drift, Nightly Score decline, HRV decline, and weight stall/reversal — each compare the current 7-day window against the prior 7-day window using date-bounded selection (immune to pre-CPAP CTE spine gaps). Triggered alerts are persisted to an alert_log SQLite table with a UNIQUE index on (metric, sync_date) for deduplication; open alerts are auto-resolved (sets resolved_at) when the triggering condition clears on a subsequent sync. The score-decline detector is suppressed when an AHI or HRV alert fired in the same run, preventing badge inflation from correlated signals. The computation layer (utils/trend_math.py) has no Streamlit imports and is callable from sync.py directly; queries/trends.py exposes the read side to the dashboard with @st.cache_data(ttl=300).

Three alert surfaces expose the trend detection output in the dashboard. A sidebar “Trend Alerts” badge (using st.metric) shows the active alert count on every page — it is fully hidden when count is zero or when fewer than 14 post-CPAP nights exist, preventing false urgency during data-sparse startup. A dedicated sixth page — Alert History (pages/06_Alerts.py) — lists all active and resolved alerts in reverse-chronological order with humanized metric labels (e.g., “AHI Drift” instead of “ahi”), date-only Triggered timestamps, and empty-state info callouts when sections are empty. A Week-over-Week digest section on the Nightly Score page renders four st.metric tiles — AHI (7d), Score (7d), HRV (7d), Weight (7d) — showing the current 7-day mean as the value and the delta vs. the prior 7-day window as the arrow. delta_color polarity is set correctly: inverse for AHI and Weight (lower is better), normal for Score and HRV (higher is better). The Weight tile gracefully shows “–” when the current window has no weigh-ins and omits the delta arrow when either window is data-sparse. The digest section is hidden entirely when fewer than 14 post-CPAP nights exist.

A seventh page — Pressure Analytics — extends the CPAP analysis with six pressure-specific charts: P95 trend with 7-day rolling average, AHI vs. pressure scatter, AHI by pressure quintile, pressure/leak dual-axis, per-night Apnea Events vs. Leak Rate (PRES-05), and a per-event scatter — Apnea Events at Moment of Occurrence (EVT-02). EVT-02 plots one dot per apnea event with leak at event time on the x-axis and pressure at event time on the y-axis, color-coded OA/CA/H. The sub-nightly resolution distinguishes central apneas that co-occurred with high leak (likely machine artifacts) from centrals at normal leak (likely real respiratory events). The underlying cpap_events table is populated by fetch_cpap_events() in sync.py, which joins OSCAR EDF per-event records to concurrent pressure/leak time-series using pd.merge_asof with a 10-second backward-direction tolerance window. Both scatter charts pin the x-axis to the data range so the 24 L/min threshold annotation only appears when data approaches it.

An eighth page — Fatigue Signal — answers whether controlled AHI is translating into real daytime recovery. A BATT-03 fix corrected the Garmin connector to read sleepBodyBattery (the correct API field, returning a list of {value, startGMT} dicts) instead of bodyBatteryChange, enabling backfill of 118 nights of body battery data. The Morning Body Battery Trend chart (BATT-01) plots the Garmin body battery morning reading with four recovery zone bands (depleted 0–25, low 26–50, moderate 51–75, high 76–100), a 7-day rolling average overlay, and a dashed threshold line at 30%. The Score vs. Battery Scatter (BATT-02) correlates the prior night’s composite score with the next morning’s body battery using a date-aware lag merge — avoiding the shift(-1) footgun that silently corrupts results across date gaps — with an OLS regression line and Pearson r annotation. A new queries/garmin.py module provides a Garmin-specific convenience accessor (get_garmin_df) for future Garmin-only phases.

A ninth page — Pressure Analytics — now includes Chart 7: Nightly Event Pattern (SWIM-01). This per-night swimlane renders one row per CPAP night on the y-axis (categorical, ascending) and OA/CA/H events as colored vertical bar marks at their exact time-of-night position on the x-axis (minutes from midnight, range -60 to +480). The chart exposes CA event periodicity — a Cheyne-Stokes/TECSA signature would appear as clustered red marks at 40–90 second intervals — and time-of-night clustering patterns that distinguish positional from REM-pressure effects. The build_nightly_event_swimlane builder was implemented TDD-first: 7 RED test stubs locked the contract before implementation, then 7 GREEN tests confirmed correct behavior including pre/post-midnight minute arithmetic and categorical y-axis ordering. A key implementation detail: sleep_date+1 (not sleep_date) is used as the midnight reference because CPAP sessions start in the evening and events occur in the early morning of the next calendar day. 193 tests pass.

A tenth page — TECSA Pattern Analysis — quantifies central apnea periodicity to detect Treatment-Emergent Central Sleep Apnea (TECSA) and Cheyne-Stokes Respiration (CSR) signatures. It computes a nightly Central Apnea Index (CAI), inter-event intervals (IEI) between CA events, and a 5-tier classification (Tier 0 = normal through Tier 4 = persistent CSR pattern). The CAI Trend chart plots nightly CA events per hour with a dashed ICSD-3 threshold at 5/hr. The IEI Histogram highlights the 45–120s CSR band with a shaded background and shows band fraction and coefficient of variation as in-chart annotations. The Tier Timeline shows the full classification history color-coded by tier (green/blue/amber/red). The Time-of-Night Distribution breaks CA events into early/middle/late thirds of the recording to distinguish sleep-onset effects from REM-phase clustering. Tier 3 and 4 status triggers an amber banner with a hedged clinical note. Built TDD-first across 4 waves: 20 RED stubs → constants + query layer → chart builders → page wiring. 213 tests pass.

Four clinical context annotations are embedded across three pages (Phase 21). TECSA Watch shows a persistent evidence-based header (“67% spontaneous resolution within 4–8 weeks”), the redundant tier-1 banner is removed, and the IEI section is gated behind a 50+ CA events threshold. The CAI threshold trace is relabeled “TECSA concern threshold (ICSD-3)”. Sleep Quality shows a Garmin accuracy disclaimer (~40–50% vs. polysomnography) and a three-state CPAP therapy progress note that turns green during the evidence-based HRV improvement window (weeks 6–12). The Weight vs. AHI chart adds two dashed reference lines on the weight axis at 10% (AASM re-evaluation threshold) and 20% (SURMOUNT-OSA remission candidacy) body weight reduction from CPAP start.

The garmin_sleep schema was extended with an altitude_m column capturing the sleep altitude from Garmin’s get_user_summary() API (day-wide averageMonitoringEnvironmentAltitude scalar confirmed via live probe). A per-night altitude fetch block was added to fetch_garmin_sleep() using a guarded try/except with None fallback — altitude failure never aborts the sleep row. The idempotent ALTER TABLE garmin_sleep ADD COLUMN altitude_m REAL migration guard runs before the VIEW is recreated on every init_db(), following the min_spo2 pattern from Phase 8. Historical altitude data backfills automatically via python sync.py backfill --source garmin with zero changes to sync.py. 225 tests pass.

Altitude overlays are now visible on two clinical charts. The AHI Trend chart (Treatment Overview) and CAI Trend chart (TECSA Watch) each gain an optional secondary y-axis showing raw sleep altitude in meters, rendered as a thin sage-green line (#78C1A3). A dashed “Home baseline” reference line appears when a home elevation is configured. Both charts are backward-compatible — the overlay is absent when altitude data is NULL, and null-altitude nights render as gaps rather than zero dips. A user_settings key-value table (reusable for Phase 25 AI settings) was added to init_db() with upsert_setting() and a cached get_setting() reader. The Settings & Context page gains an “Altitude Baseline” form that persists the home elevation across browser restarts. A Plotly 6.x autorange bug — secondary-axis data bleeding into the primary axis scale — was fixed by pinning the left axis range explicitly from AHI/CAI data. 236 tests pass.

An AI data extraction layer (queries/ai_analysis.py) assembles a structured, anonymized payload from the full post-CPAP window for Phase 25’s Claude API call. The pure _build_payload(nightly_df, weight_df, home_altitude_m) function produces four clinical sections: treatment_efficacy (therapy-week AHI aggregates keyed by week_of_therapy — a 1-indexed integer from CPAP start date — alongside P95 pressure/leak ISO-week aggregates, first-2-vs-last-2-week trend deltas, and HRV window flag), sleep_quality (per-night arrays for HRV, deep%, REM%, SpO2 nadir, body battery), remission_outlook (per-night weight, kg-to-AASM-threshold, kg-to-SURMOUNT-milestone, 14-day velocity, active dose tier), and confounders (bidirectional altitude deviation flags with direction and per-night AHI; dose-period summaries grouped by contiguous tier arc; CPAP interruption flags). The altitude section is omitted entirely when home_altitude_m is None. The @st.cache_data(ttl=300) wrapper get_ai_payload() is the Phase 25 entry point. A 13-test suite in TDD RED/GREEN sequence locks the full payload contract before implementation. 249 tests pass.

A new AI Analysis page (pages/07_AI_Analysis.py) closes the v3.0 milestone with an on-demand Claude clinical synthesis. One button click sends the anonymized get_ai_payload() dict to claude-sonnet-4-6 via a blocking client.messages.create() call. The system prompt (prompts/ai_analysis_system_prompt.txt) embeds all six REQUIREMENTS.md-cited clinical benchmarks — AHI <5/<2, HRV recovery weeks 6–12, SpO2 90% nadir, AASM 10% re-evaluation threshold (96.1 kg), SURMOUNT-OSA remission milestone (85.4 kg), TECSA 67% spontaneous resolution — so Claude reasons against verified benchmarks rather than its training data. The response is validated by a Pydantic AnalysisResponse model requiring all four keys (treatment_efficacy, sleep_quality, confounders, remission_outlook) to be non-empty strings. The result is cached in an ai_analysis_cache SQLite table (single-row upsert at id=1); subsequent page visits load from cache without an API call. A “Regenerate” button overwrites the cache on demand. A static disclaimer (“This analysis is generated by an AI model and is not medical advice”) renders at the top on every visit; missing ANTHROPIC_API_KEY shows st.error and disables the Generate button. The generate_analysis() function in utils/ai_client.py is a pure module with no Streamlit imports, testable in isolation. 268 tests pass.

A new Export & Reports page (pages/09_Export.py) adds one-click CSV downloads for all four data sources. The queries/export.py module exposes four @st.cache_data(ttl=300) functions — get_nightly_summary_csv, get_cpap_sessions_csv, get_garmin_sleep_csv, get_withings_measurements_csv — each returning a UTF-8 CSV string via pd.read_sql("SELECT * FROM <table>", get_connection()).to_csv(index=False). The page renders four st.download_button calls with date-stamped filenames (e.g., nightly_summary_2026-06-13.csv). Empty tables produce headers-only CSVs automatically — no special-casing. The page is registered in st.navigation between AI Analysis and Alert History. 268 tests pass.

A Symptom Log page (pages/10_Symptoms.py) lets the user record five morning symptom severity ratings — Mask issues, Dry mouth / throat, Sleep disruption, Aerophagia, Air starvation — plus an optional freeform note for each sleep night. The date picker defaults to yesterday (enforcing the noon-boundary sleep-date convention). Selecting today shows an amber warning. Selecting a previously logged date auto-populates the form with stored values for editing; saving overwrites the existing row with no duplicate. Severity choices (None / Mild / Moderate / Severe) are stored as integers 0–3 via a pure-Python utils/symptom_form.py helper module (zero Streamlit imports, fully unit-tested) and rendered as text labels in the Recent Entries table. The page follows the write-path-exception pattern from the Settings page: queries/symptoms.get_symptom_log_df() handles cached reads, loaders/db.upsert_symptom_log() handles writes, and get_symptom_log_df.clear() + st.rerun() refreshes the table on save. 317 tests pass.

The Symptoms page now closes the loop with a deterministic Suggestions section. utils/suggestion_engine.py evaluates the last 7 nights of CPAP and symptom data against the user’s prescribed settings (min/max pressure, EPR level — configured in a new Settings & Context section) through 10 named rules, returning specific recommendations (e.g., “consider raising minimum pressure”) or, when no rule fires, an explicit “Stable” state. Two rules can fire in direct conflict; the engine detects these pairs and surfaces a contradiction notice instead of contradictory advice. Every threshold is a named constant with an inline source comment — the AHI threshold cites AASM directly, while four thresholds without a clinical citation are marked PLACEHOLDER and read “discuss with prescriber” rather than presenting as settled fact. The engine has zero Streamlit imports and is independently unit-tested (21 tests); queries/suggestions.py wraps it with @st.cache_data(ttl=300).

A “Get AI Explanation” button at the bottom of the Suggestions section asks Claude to explain, in plain language, why the rules engine’s suggestions fired — using the patient’s own numbers rather than generic clinical advice. This is a second, independent Claude API call (generate_nuance() in utils/ai_client.py, separate from the main AI Analysis page’s generate_analysis()) that returns markdown prose directly with no JSON parsing. The explanation is cached in its own suggestion_nuance_cache table and re-rendered cache-first on every page load; a “Regenerate” button lets the user request a fresh explanation, and both buttons disable automatically when the night is in a stable, no-rules-fired state. If the user logs a newer symptom night after the explanation was generated, a staleness warning appears above the cached text so the explanation is never silently presented as current when it may not be. 346 tests pass.

Two Symptom Correlation Charts now appear at the top of the Symptoms page (above the date picker and log form), implemented TDD-first in a new pages/symptom_charts.py module. “Symptom Category Trends” (CORR-01) renders up to 5 colored severity lines — mask issues, dry mouth, sleep disruption, aerophagia, air starvation — over the last 60 days using a date-window filter (not a .head() count) so non-consecutive logging is handled correctly; a caption replaces the chart when no entries exist. “Symptom Burden vs. Sleep Quality” (CORR-02) is a dual-axis chart showing symptom burden (row-wise sum of all 5 severity scores, 0–15 scale, white line), AHI (red, left axis), and Nightly Score (blue, right axis, 0–100); dates present in symptom data but absent from nightly_summary render as NaN gaps via an outer merge rather than being interpolated. This chart is gated behind a 14-entry minimum using the full symptom history count (not the 60-day window), with a countdown st.info message for users still building their log. 359 tests pass.

The Export & Reports page now includes a “For Your Doctor” section at the top (above the treatment report) with two clinician-ready downloads: a Symptom History CSV containing the full symptom_log with severity integers rendered as human-readable labels (None/Mild/Moderate/Severe), logged_at omitted, and rows newest-first; and a Suggestions Report PDF that lists the current triggered settings suggestions, any contradicting-indicator pairs, a Threshold Sources section citing AASM 2012 for the AHI threshold and marking the four PLACEHOLDER thresholds, and an optional AI-generated explanation. Both buttons disable with a caption when no symptoms have been logged. The PDF builder receives an already-evaluated SuggestionResult from the page layer, keeping it fully Streamlit-free and unit-testable. 372 tests pass.

The Export page also generates a PDF Treatment Report suitable for clinical appointments. A “Generate PDF Report” button triggers utils/pdf_builder.build_treatment_report(), which assembles a multi-page PDF using fpdf2 and kaleido. The report contains: a title block with weeks of therapy; a Summary Statistics table (7-day avg AHI, Nightly Score, SpO2 nadir, weight lost since CPAP start); a clinician-facing Data Sources & Accuracy Limitations section disclosing wrist oximetry accuracy (+/-2-4 pp vs. fingertip) and sleep staging PSG agreement (~40-50%); the cached AI narrative with four labeled sections (the “Sleep Quality” section is explicitly labeled as wearable-derived with a context note for clinical readers); four Plotly chart images rendered to PNG via kaleido with a B&W palette and consistent axes; and an active trend alerts section when alerts exist. Chart date ranges are gated by clinical context — CPAP charts start at CPAP start date, Garmin/Withings charts start at the configured baseline period. PDF bytes are cached in st.session_state for the session to avoid re-rendering on each interaction. 276 tests pass.

A Travel Periods section on the Settings & Context page lets the user tag date ranges as travel with an optional destination label. Travel nights are excluded from alert triggering: _load_travel_dates() and _window_is_travel() in utils/trend_math.py check whether each detector’s lookback window overlaps a recorded travel period, suppressing AHI/HRV/Score alerts before the detector call and filtering weight stall/reversal alerts independently after their own 28-day/14-day windows are computed — preventing false positives from altitude changes or routine disruption while traveling. 290 tests pass.

A Narrative PDF download is now available directly on the AI Analysis page. A “Build Narrative PDF” button (disabled when no cached analysis exists) generates a standalone PDF via utils/pdf_builder.build_narrative_pdf() — a lightweight companion to the full treatment report containing only the clinician disclaimer and the 5 AI sections (Executive Summary, Treatment Efficacy, Sleep Quality, Confounders, Remission Outlook) with no charts, summary statistics, or data-accuracy sections. The PDF title block and download filename both use the analysis creation date (from generated_at) rather than today’s date, so the file timestamp reflects when the AI narrative was written. The _add_ai_narrative() helper gained an include_executive_summary: bool = False flag to support the new entry point while leaving build_treatment_report() output unchanged. 298 tests pass.

The sync pipeline’s upsert_cpap() function uses a column-level INSERT ... ON CONFLICT(sleep_date) DO UPDATE SET strategy for leak_median and leak_95 only. When the same night is ingested from both the EDF cache (which lacks leak rate) and a later OSCAR 2.0 sync (which provides it), the leak columns are backfilled with newest-non-null-wins semantics while AHI, pressure, event count, and session columns are preserved from the original EDF row. A NULL leak value in the incoming row never overwrites an existing non-null value. 373 tests pass.

Two new SQLite tables — cpap_settings_log and mask_log — were added to loaders/db.py as the schema foundation for upcoming Settings History (Phase 41) and Mask Tracking (Phase 42) UI pages. cpap_settings_log is an append-only audit log (id, changed_at, field_name, old_value, new_value) written by insert_settings_change(); changed_at is generated in SQL via datetime('now') so no caller can pass a fabricated timestamp. mask_log is a per-night keyed table (sleep_date PRIMARY KEY, mask_name) written by upsert_mask_log() with ON CONFLICT DO UPDATE SET semantics so mask corrections overwrite silently. The nightly_summary VIEW was extended to expose mask_name via a LEFT JOIN mask_log — critically, mask_log is excluded from the all_dates CTE UNION spine so a mask-only night never injects a phantom row into any consumer query. init_db() remains idempotent via CREATE TABLE IF NOT EXISTS. 8 new TDD tests cover all table/loader/VIEW contracts. 362 tests pass (excluding 6 pre-existing pdf_builder failures unrelated to this phase).

CPAP data can now be ingested two ways. A python sync.py sdcard subcommand (or sync.py run when sd_card_dir is configured) reads a mounted ResMed AirSense 11 SD card directly via cpap-py, bypassing OSCAR entirely as an option. SD card sync runs before OSCAR whenever both are configured, and both sources write through the same upsert_cpap() column-level guards from Phase 38 — so an SD card night’s AHI/pressure data is never overwritten by a later OSCAR sync, while OSCAR can still backfill leak rate for nights the SD card already populated. A missing or unmounted card is always non-fatal; sync.py run skips it silently and the dedicated sdcard command prints a warning rather than failing.

Prescribed CPAP setting changes (min pressure, max pressure, EPR) are now logged and visualized. Saving the CPAP Settings form on the Settings & Context page diffs the new values against the values captured at page-load time — via a new pure utils/settings_diff.py helper — and writes one cpap_settings_log row per genuinely changed field; identical re-saves produce zero new rows. A new queries/cpap_settings.py module exposes get_settings_history_df() (@st.cache_data(ttl=300)) as the sole read path for the log, and the save handler busts that cache alongside the existing settings cache so a change is visible immediately. On the Pressure Analytics page, logged changes render as dashed vertical lines on both the P95 Pressure Trend and Pressure/Leak dual-axis charts: pressure changes appear on both charts, EPR changes on the P95 trend only, in visually distinct colors, with multiple fields changed in the same save combined into one label (e.g. “min 8→9, max 18→20”). The markers use add_vline(..., label=dict(...)) rather than the legacy annotation_text= kwarg, avoiding a documented Plotly 6.7.0 rendering bug on datetime axes. A post-execution code review caught and fixed a pandas NULLnan coercion bug that would have rendered the literal string “nan” for a never-before-tracked field’s prior value, a UTC-vs-local timestamp bug that could misplace a marker by one calendar day, and an unguarded EPR selectbox index that could crash the Settings page. 446 tests pass (excluding 6 pre-existing test_pdf_builder.py failures unrelated to this phase).

Mask usage is now tracked directly on the renamed Daily Log page (formerly Symptoms), using a switch-based forward-fill model instead of per-night entry. Logging a new mask on a given date calls apply_mask_switch(), which fills every CPAP night from that date forward but stops before the next already-recorded switch rather than overwriting through it — so a retroactive or gap-filling entry only claims the span up to the next manually-logged switch. An optional “Correct through” date lets a user shrink an existing switch’s start date (e.g. re-entering the mask that was in use just before it) without erasing the rest of that switch’s run, since a single date+name pair alone can’t distinguish “shrink this switch” from “undo it entirely.” A sync-time forward_fill_mask_log() gap-fills any remaining nights without touching existing rows. The Daily Log page shows the current mask and a collapsed history of past switches; the Treatment Overview page adds an “AHI & Leak by Mask Type” chart (gated at 10+ nights per mask type) so mask changes can be correlated with treatment metrics. 604 tests pass (excluding 6 pre-existing test_pdf_builder.py failures unrelated to this phase).

The dashboard can now trigger its own data sync. A “Sync Now” section on the Settings & Context page launches python sync.py run via subprocess.Popen (non-blocking, sys.executable, CREATE_NO_WINDOW on Windows) and live-tails its output through a thread+queue relay into a st.status() container that shows running/success/failed states with the exit code and any stderr. On success, utils/cache.py‘s clear_all_data_caches() clears every one of the 24 @st.cache_data query functions across the codebase before an immediate rerun, so the dashboard reflects fresh data without a manual page reload — and the Sync Now section is placed above the cold-database empty-state guard, so a brand-new install can be fully populated from the browser with no terminal required. pages/06_Settings.py is the only file in the codebase permitted to call subprocess — an architectural boundary enforced by test.

A full UI overhaul unified visual structure across all ten dashboard pages. Every chart builder now reads height, color, and x-axis date-domain from a single chart_config.py source of truth, eliminating drift between pages. A long-standing chart-alignment discrepancy between the live Streamlit dashboard and the exported PDF report was root-caused — not to the leading hypothesis (an automargin content-growth interaction) but to a genuine gap in Streamlit’s own width='stretch' resolution logic that never fully resolves against the page’s actual content width — and fixed by threading an explicit CHART_WIDTH_MODE = "content" constant through every page’s chart calls. Every chart and data section across the app now has a defined empty-state behavior (an informational caption, an st.info message, or a hidden section), and a consistent section-hierarchy convention (one page title, st.subheader-scoped sections, single dividers between them) was applied dashboard-wide, closing gaps a structural regression guard now enforces going forward.

A milestone-close cleanup phase fixed a chain of four stale-fixture bugs in the test suite: tests/test_pdf_builder.py‘s synthetic test data was missing four columns (avg_spo2, weight_kg, missing_count, rolling_avg) that real chart-building code reads unconditionally — each one had been silently masking the next behind a short-circuiting KeyError, discovered and fixed one at a time. A parallel Nyquist validation pass across all seven v4.1 phases confirmed automated test coverage for every shipped requirement and caught one genuine gap — a missing regression test guarding against the mask-tracking UI accidentally reappearing on the Settings page after its Phase 42 redesign — closed with a new test before the milestone closed.

A Clinical Evidence Audit is underway for v4.2, cross-checking every clinical threshold, chart annotation, and numeric constant rendered by the dashboard against a 31-PDF medical literature library (29 unique documents). A page-indexed text search pipeline with two mechanical anti-fabrication gates — one confirming every citation names a real file, one re-locating each quoted phrase in the cited file at the cited page — verifies every finding before it’s recorded. The first page audited, Treatment Overview, found the AHI Trend chart’s AHI_THRESHOLD = 5.0 line correctly reflects the AASM diagnostic severity boundary but is misapplied as an on-treatment adequacy target the library doesn’t support, and its hardcoded 90-day adaptation-period overlay is incorrectly sourced (the library supports roughly 1 week to 1 month for CPAP adherence establishment). The second page audited, Sleep Quality, found its most consequential finding on the Garmin accuracy disclaimer: the dashboard’s “40-50% accuracy vs. polysomnography” figure traces in the library only to the Vivosmart 4, a different and less capable device, never to the Fenix the dashboard actually claims it for. The “Week 6-12” HRV recovery-window framing is unsourced for both boundary numbers even though the underlying HRV-improves-with-CPAP concept is supported, and the deep-sleep (15-20%) and REM (18-22%) target bands shown on the Sleep Stages Quality chart have no normative source in the library at all. No code has been changed yet — findings are queued for a future remediation phase.

The v4.2 Clinical Evidence Audit is now complete. All ten dashboard pages plus a final Cross-Cutting Synthesis section covering the Nightly Score formula, its five component weights, and every fixed clinical score anchor have each been cross-checked against the same 31-PDF library — 105 audited items in total, verdicted 16 Keep / 89 Modify / 0 Cut, backed by 201 file-and-page citations and 94 explicit flagged gaps where the library is silent. A compiled report at the top of the audit document ranks every item into three priority tiers (90 unsupported-but-presented-as-fact, 9 weak-evidence, 6 well-supported) and reconciles six constants reused across multiple charts — such as two divergent central-apnea-fraction thresholds and the 24 L/min leak figure defined under three different names — to one verdict each rather than one per page. A separate subsection resolves the 90-day CPAP-adaptation-window deferral from Phase 45’s OV-1 finding without altering that row. The milestone closes with zero application code changed anywhere: every finding is queued as a future remediation milestone’s work, reviewed and signed off by the developer before close.

A post-milestone literature search added 11 new candidate sources to .planning/research/medical-sources/ (42 files total, up from the v4.2-closed 31), targeting the highest-priority Tier 1 gaps: the SURMOUNT-OSA primary trial results (correcting REM-8’s weight-milestone framing against the trial’s actual AHI-based remission definition), a systematic review supporting TEC-6’s “roughly two-thirds resolve” figure while showing its “4-8 week” window is narrower than the literature actually supports, a network meta-analysis that overturns OV-5’s “no head-to-head study exists” claim about mask type and AHI, and further evidence reinforcing (not resolving) the existing device-substitution and altitude-threshold gaps. Full write-up with every quote and page reference is in .planning/research/NEW-SOURCES-LOG.md, staged for a future remediation milestone to formally re-verdict against.

The v4.3 Evidence Remediation milestone has begun re-verdicting the audit against those 11 new sources. The first re-verdict phase closes three of nine theme groups. The on-treatment “controlled” AHI threshold finding (five rows: the AHI Trend chart, an alert threshold, a drift-guard, and two suggestion-engine rules) escalates from “unsourced” to “incorrectly sourced as applied” and its lead row’s verdict flips from Keep to Modify — two new sources directly contradict, rather than merely leave uncited, the treatment-adequacy reading of AHI=5. The altitude-confounder finding (four rows, including the ALTITUDE_DEVIATION_THRESHOLD_M = 300 placeholder) is reframed rather than resolved: a systematic review does establish real altitude effects on central apneas, but only at 1,500-2,000+ MAMSL — roughly 5-7x the magnitude the dashboard’s home-baseline overlay flags. The mask-type-vs-AHI chart’s “no head-to-head study exists” claim is overturned by a network meta-analysis showing mask type does affect AHI, though how clinically significant that difference is remains genuinely disputed in the literature. All ten re-verdicted rows carry 22 new verbatim-verified citations and pass both mechanical anti-fabrication gates. No application code has changed — this remains a documentation-only remediation milestone.

The second re-verdict phase closed the device-classification-reliability, TECSA natural-history, and CMS usage-adherence theme groups. TEC-6’s clinical-context header claim (“resolves spontaneously in ~67% of cases within 4-8 weeks”) — previously the audit’s strongest unsourced finding — is now partially sourced against a systematic review: the qualitative and proportion halves are correctly sourced (the review’s own phrasing is “about two-thirds,” not a precise percentage), while the 4-8-week window is reclassified as incorrectly sourced as applied, since the review’s own timescale is materially broader, with a concrete rewording recommendation recorded for a future phase. Four charts built on the same onboard CPAP event classifier (pages/cpap_charts.py, pages/pressure_charts.py, pages/tecsa_charts.py) each gained a distinct citation set from a longitudinal detection-accuracy study showing moderate, stable device-vs-manual-scoring disagreement (r=0.445-0.560) — reinforcing, not closing, the device-vs-PSG validation gap that this reference standard’s own limits still leave open. The most consequential of the four: the pressure scatter’s central-apnea-as-primary-artifact-suspect premise now runs counter to the only on-point evidence in the library, which describes central-event under-detection rather than over-reporting. PRS-14’s USAGE_CRITICAL = 4.0 gained its first primary-source citation — the official Medicare CMS “4/70 rule” — sharpening the existing sourced-4.0-vs-unsourced-6.0 distinction with two new findings: the standard is a proportion-of-nights-over-a-30-day-window rule the app renders as a bare per-night boundary, and its documented consequence for non-compliance is a coverage denial, not a clinical judgment about therapy sufficiency. All six re-verdicted rows carry 19 new verbatim-verified citations (243 total in the document) and pass both mechanical anti-fabrication gates. No verdict flipped in this phase, and no application code has changed.

The third re-verdict phase closed the SURMOUNT-OSA/tirzepatide remission framing, wearable SpO2/sleep-stage accuracy, and symptom-log severity-scale theme groups — the last three of nine. REM-8’s SURMOUNT_OSA_MILESTONE_KG framing is now explicitly corrected: the SURMOUNT-OSA trial’s actual key secondary end point is an AHI/Epworth-Sleepiness-Scale-based definition (AHI <5, or 5-14 with ESS ≤10), not the 20%-body-weight milestone the dashboard cited it for — REM-7 and the AI Analysis page’s remission-outlook section and system-prompt benchmarks (AI-3, AI-5) were resynced to the same corrected framing. The wearable-accuracy group (EXP-1, EXP-2, SQ-3, SQ-6, AI-2 — the SpO2/sleep-stage disclaimers on Export & Reports, Sleep Quality, and the AI payload) gained citations from a second Garmin device study (Budig 2022, Forerunner 945) and an attribution correction for a duplicate source already in the library — strengthening the existing device-substitution finding while confirming no source validates the Fenix specifically, the device this dashboard actually uses. One EXP-1 claim (“no paper reports any Garmin-specific SpO2 figure”) had gone stale between phases and was retired in favor of Budig’s actual measured accuracy (SpO2-minimum r=0.27, MAE 4.58 — above the disclaimer’s own stated ±2-4 point range). SYM-11’s symptom-log severity scale gained a citation sourcing the aerophagia category’s clinical reality (Hillamaa 2025), while the app’s 4-point ordinal scale format itself remains uncited against a literature instrument that uses a continuous 0-100mm visual analogue scale instead. All ten re-verdicted rows carry 26 new verbatim-verified citations (270 total in the document) and pass both mechanical anti-fabrication gates. This closes the per-theme re-verdict work for v4.3; a fourth phase will synthesize all nine theme groups into the audit’s master index and priority tables. No application code has changed anywhere in this milestone.

The fourth and final v4.3 phase compiled all 26 re-verdicted rows (Phases 51-53 touched one row more than the roadmap named — the CAI Trend chart’s altitude overlay, TEC-2 — corrected here after developer sign-off) into the audit’s executive summary, master index, tier assignments, and priority tables. One row, the on-treatment “controlled” AHI=5 finding (OV-6), moved from the weak-evidence tier into the unsupported-but-presented-as-fact tier — the only tier movement this milestone produced; six further rows moved from unsourced to incorrectly-sourced-as-applied. The two roadmap-flagged “explicit flips” (the mask-type-vs-AHI finding and the SURMOUNT-OSA remission framing) changed the basis of their finding, not their underlying Keep/Modify verdict — the compiled report states this distinction plainly rather than implying a bigger change occurred than did. A new Queued for Future Remediation section records 14 code-level fixes these findings imply — the CMS usage-hours threshold, the TECSA “4-8 weeks” wording, the symptom-log’s dry-mouth remedy copy, and eleven others — none implemented in this milestone. All 11 Citation Verification Log tables were mechanically regenerated from the document’s own citation lines rather than hand-maintained, closing the one transcription surface the citation-integrity gate itself cannot see. The milestone closes with 105 verdict rows, 364 citation lines (270 verified, 94 flagged gaps), and zero application code, chart, threshold, or prompt files changed anywhere across all four phases — reviewed and signed off by the developer before close.

The v4.4 O2 Monitor Integration milestone has begun with a schema-and-alignment foundation phase (Phase 55) — no real EMAY file is parsed yet, and no UI exists. Three new SQLite tables were added to loaders/db.py::init_db(): o2_sessions (one row per EMAY session, holding match metadata), o2_samples (per-second SpO2/pulse readings, both columns nullable with no default so a sensor-off gap is stored as NULL rather than a false zero), and o2_desaturation_events (schema only — detection lands in a future phase). All three follow the existing sleep_date TEXT join-key convention and are proven, by a dedicated test reading sqlite_master directly, to have zero references anywhere in the nightly_summary VIEW — matching the mask_log precedent of never entering the CTE join spine. The core correctness problem this phase solves is that an EMAY pulse oximeter’s own clock cannot be trusted (observed phone-clock drift): a new pure-function module, transforms/emay_alignment.py, anchors each EMAY session to the CPAP session it actually co-occurred with by comparing raw first-sample timestamps against cpap_sessions.session_start within an 8-hour window, and computes an explicit, separately-stored clock offset rather than silently correcting the sample timestamps in place. Three outcomes are handled as first-class, distinctly labeled results — matched (exactly one CPAP session in range), multi_candidate (two or more; the nearest wins, but the row is flagged as ambiguous rather than presented as clean), and unmatched (zero in range; the night is still kept, using a noon-boundary fallback date derived from EMAY’s own untrusted clock as the sole sanctioned exception to that rule, with a null offset that can never be mistaken for a perfect-alignment zero). All three outcomes are locked behind 13 dedicated unit tests plus 11 schema tests, including a regression test proving a matched session’s date always comes from the CPAP row verbatim, never recomputed from EMAY’s own clock — the single most important correctness property this phase exists to guarantee. 870 tests pass.

Phase 56 adds the actual EMAY file connector on top of that foundation, with two parsing paths that produce the same output shape. connectors/emay.py::parse_emay_csv() reads EMAY’s canonical CSV export into per-second sample rows, maps blank readings to NULL (never a false zero) so sensor-off gaps are preserved, and drops physiologically implausible SpO2/pulse pairs via an is_plausible() filter, whose range constants are marked PLACEHOLDER pending clinical citation per this project’s Verifiable Sources rule. When no CSV exists for a night, parse_emay_spo2() decodes EMAY’s proprietary .spo2 binary format directly — its byte layout (a 1084-byte header before the per-second sample body, a (0x7F, 0xFF) sentinel pair marking sensor-off gaps) is documented in-repo with every offset marked [VERIFIED: ...] against real recording bytes or [ASSUMED: ...], and is cross-validated against three real overnight recordings before being trusted. Both paths write through a new loaders/db.py::upsert_o2_samples() into the o2_samples table via a new sync.py::_sync_emay() stage — the sole wiring point, keeping connectors/emay.py a pure parse layer with no Streamlit or sqlite3 imports. A malformed or corrupt file is logged and skipped without aborting the rest of the sync run. The four real EMAY recordings used to build and validate this phase are kept local-only (gitignored) rather than committed as test fixtures, since this repo publishes to a public GitHub remote and personal overnight pulse-oximetry data is not something to put in git history irreversibly; the cross-validation test that depends on them skips cleanly on a fresh clone or in CI. 899 tests pass.

Phase 57 wires desaturation detection onto the now-real, aligned EMAY samples from Phase 56. utils/o2_desaturation.py wraps the pobm library’s ODI3 detector (with a numpy 2.x compatibility shim for a deprecated RankWarning) and writes results into o2_desaturation_events via a new _sync_desaturation() stage in sync.py, run automatically at the end of every sync. The dip threshold ships as a named PLACEHOLDER constant, DESAT_THRESHOLD_PCT = 3 — it is pobm‘s own published ODI3 default (Levy et al. 2021), not a value this project derived, and is explicitly distinguished in-repo from the AASM 3%/4% hypopnea-scoring rule, which measures a related but different thing. queries/o2_monitor.py computes ODI (desaturation events per hour) on read from the stored samples and events — never persisted as its own column — with a cached wrapper registered in the Sync Now cache-clear registry. Re-running sync on an already-processed night replaces that night’s events wholesale rather than duplicating them. Pointing python sync.py run at four real EMAY recordings surfaced a real bug — a NULL session_start on some CPAP rows was silently aborting EMAY-to-CPAP matching for every file — fixed before the real-data run produced its first honest numbers: 150 desaturation events across 3 real nights, plausibility-checked by hand against the developer’s own data before being treated as more than a technical proof-of-pipeline. 924 tests pass.

An inserted bug-fix phase, discovered during Phase 57’s own real-data verification, repaired a data-corruption defect in the OSCAR connector that predates this milestone entirely (it traces back to Phase 19, v2.3). _fetch_sessions_sqlite() was joining CPAP session data without filtering by which physical CPAP machine record it belonged to, and grouping sessions by raw calendar date rather than this project’s own noon-boundary sleep-date rule — together, these two defects had silently corrupted 115 of the 139 cpap_sessions rows in the developer’s real database, misattributing session start/end times and, transitively, the sleep dates joined against every other data source. The fix filters the sessions query by the resolved CPAP machine id and re-implements the noon-boundary grouping correctly in Python; a new python sync.py backfill-cpap-sessions command (dry-run by default, requiring an explicit --apply flag) recomputes and repairs the affected rows via a scoped, direct UPDATE rather than the normal upsert path, which would have silently no-opted on a conflict. The 115 corrupted rows were repaired under explicit developer review, with a verified pre-write database backup taken first and the three EMAY oximetry recordings affected by the bad dates re-aligned afterward. A defense-in-depth guard now also rejects any EMAY-to-CPAP session match spanning more than 18 implausible hours, so a similar upstream defect can’t silently mis-date a night of oximetry again. 931 tests pass.

Phase 58 closes the v4.4 O2 Monitor Integration milestone with a new eleventh dashboard page, pages/11_O2_Monitor.py, and its supporting queries/o2_monitor.py data layer. A night picker sourced exclusively from nights that actually have EMAY data drives a dual-axis go.Scattergl SpO2/pulse trace chart with sensor-off gaps rendered as real breaks (never a drop to zero) and CPAP OA/CA/H event markers aligned via pd.merge_asof. Above the chart, three st.metric tiles report min/average/time-below-90% SpO2 for the selected night; below it, an auto-detected desaturation event table shows each event’s nearest CPAP event and signed time offset — a neutral proximity fact, never a causal claim. Selecting a table row zooms into a ±60-second, edge-clipped, gap-preserving mini plot of that specific dip via a new st.selectbox event picker. A sidebar st.metric badge, mirroring the existing Trend Alerts badge’s shape and non-critical failure isolation exactly, reports how many of the last 7 nights have oximeter data — visible on every page, rendering nothing at all when recent coverage is zero, and independent of the Trend Alerts badge’s 14-night CPAP-history gate since EMAY use is intentionally sparse (worn on selected nights, not every night). 1033 tests pass.

The v4.4 O2 Monitor Integration milestone is now complete. At close, a live-database regression test (added by the OSCAR bug-fix phase above to pin the repaired session times) failed by exactly one hour after the developer traveled — tracing to a second, related bug: connectors/oscar.py converts stored session timestamps with Python’s datetime.fromtimestamp(), which silently uses whichever timezone the computer’s operating system happens to be set to rather than a fixed home timezone. This means any sync or dashboard query run while the machine’s OS timezone differs from home can silently miscompute CPAP session times and, in turn, which calendar date a night’s data belongs to. The bug was root-caused and documented but deliberately not fixed in this milestone, since it predates v4.4 and is unrelated to EMAY oximetry — it’s queued as a todo for a future pass. 1032 of 1033 tests pass, with the one failure being that same known, understood, environment-dependent timezone artifact rather than a code defect.

Phase 59 (v4.5 Timezone & Time Correction) closes that queued bug. A new home_timezone Settings & Context field, backed by the same user_settings key-value pattern as the existing Altitude Baseline, now anchors every epoch-to-wall-clock conversion in the ingest pipeline — connectors/oscar.py‘s session and event paths, connectors/withings.py‘s weigh-in noon-boundary decision, and connectors/cpap_sdcard.py‘s SD-card route — to the configured Home Timezone instead of the OS’s ambient timezone, via a shared transforms/sleep_date.py::epoch_to_home_local() helper. sync.py refuses to run at all when Home Timezone is unset rather than silently falling back to the OS clock. connectors/garmin.py was audited and confirmed already correct (explicit UTC, sleep_date taken from Garmin’s own field) with zero functional change. A dry-run of the existing historical CPAP repair tool against the real database found zero rows still corrupted, so no write-mode repair was needed; a matching repair tool for withings_measurements does not yet exist and remains a known, documented gap.

Phase 60 (v4.5 Timezone & Time Correction) adds a standalone broken-clock classifier ahead of any UI. A new is_likely_broken_clock() function in transforms/emay_alignment.py looks at an EMAY session’s raw first-sample timestamp and flags a likely device-clock reset — distinct from a legitimate no-CPAP night — whenever the local time-of-day falls inside a 10:00-18:00 implausible-daytime window. The window bounds are named, PLACEHOLDER-flagged constants documented as a device-behavior heuristic rather than a clinical claim, per this project’s Verifiable Sources rule. The classifier returns a strict bool and, deliberately, is not yet wired into align_emay_session() or any sync path — detection and correction are being built as separate phases so the review/correction UI planned for Phase 61 has a settled, unit-tested signal to build on rather than a moving target.

Phase 61 closes the v4.5 Timezone & Time Correction milestone by wiring Phase 60’s classifier into a real sync-time backfill stage and giving the user a way to fix what it flags. A new _sync_broken_clock() stage in sync.py runs is_likely_broken_clock() against both newly-ingested and already-backfilled unmatched EMAY sessions, storing the result and the reconstructed raw timestamp in three new o2_sessions columns. A new “Broken-Clock Sessions” section on the Settings & Context page lists every flagged, uncorrected session and lets the user assign the correct calendar date; saving it calls a new correct_o2_session_date() in loaders/db.py, which moves the session’s o2_sessions row and its o2_samples rows to the new date inside one atomic transaction, then re-runs CPAP-session matching against the corrected date so the O2 Monitor page’s event overlay works after the fix, not just the raw SpO2 trace. A code-review pass caught a real cross-session data-corruption risk before ship: because o2_samples has no session-scoping column, the date-only re-parent update could have silently dragged an unrelated session’s samples onto the wrong date if two sessions ever shared the same broken fallback date — a plausible failure mode for the exact class of device this feature exists to fix. A collision guard (raising instead of silently corrupting) and a precondition check restricting the function to genuinely flagged, uncorrected sessions were added in response, along with cache-invalidation, error-handling, and null-guard fixes surfaced by the same review. 1083 tests pass, closing the milestone.

Last updated: 2026-09-18


2026-09-21 — Phase 62: O2 Threshold Evidence Review (v4.6 milestone shipped)

Closed the single-phase v4.6 O2 Evidence Review milestone: a documentation-only audit extending the v4.2/v4.3 evidence-review process to the two O2/EMAY clinical constants v4.4 shipped without ever citing. Added a new ## O2 Monitor section to CLINICAL-EVIDENCE-AUDIT.md with two full verdict rows — O2M-1 for DESAT_THRESHOLD_PCT=3 (pobm’s ODI3 desaturation-event threshold) and O2M-2 for SPO2_DESATURATION_THRESHOLD=90‘s new “Time Below 90%” use on the O2 Monitor page — both verdicted Keep, both citing real sources, both honestly flagging a narrower unresolved gap rather than overclaiming full coverage. The automated PDF download of the primary new source (Whenn et al. 2024, PMC11063702) hit NCBI’s bot-protection gate exactly as anticipated during planning; the developer completed the retrieval manually via browser, plus a bonus AASM position-page PDF, both verified by %PDF magic bytes before citation. O2M-2 reused SQ-8’s four existing T90 citations verbatim, cross-referencing it by ID rather than editing its row — closing the exact gap SQ-8’s own Deferred section had flagged for the O2 Monitor’s new use of the same 90% threshold. Both auditcheck.sh and citecheck.sh pass against the final document (277 citations, up from 272), and an independently re-derived scope-diff confirmed this phase’s commits touched only the audit document — zero application code, chart, or threshold changed anywhere in the repo.

2026-09-18 — Phase 61: Broken-Clock Review & Correction (v4.5 milestone shipped)

Closed the v4.5 Timezone & Time Correction milestone across two plans plus a post-review fix pass. Plan 1 wired Phase 60’s standalone is_likely_broken_clock() classifier into a real _sync_broken_clock() sync stage that flags both newly-ingested and already-backfilled unmatched EMAY sessions, added a 3-column schema migration to store the flag and reconstructed raw timestamp, and built the atomic date-correction function (correct_o2_session_date()) the UI would call next — gated behind a one-way-door checkpoint confirming the atomic two-table transaction approach before implementation began, since a bug here on the single-copy SQLite database has no cheap undo. Plan 2 added the Settings & Context “Broken-Clock Sessions” review/correction section and confirmed the O2 Monitor page’s CPAP event overlay survives a correction by wiring the right cache-clear targets. A code review pass then caught a real cross-session data-corruption risk that no test had exercised: because o2_samples has no session-scoping column, the date-only re-parent UPDATE could silently drag an unrelated session’s samples onto the wrong date if two sessions ever shared the same broken fallback sleep_date — precisely the failure mode a device that resets to the same garbage timestamp every time it glitches would produce, which is the exact scenario this feature exists to remediate. A fix pass added a collision guard (raise instead of silently corrupt) and a precondition check restricting the correction function to genuinely flagged, uncorrected sessions, plus four smaller fixes the same review surfaced (a missed cache invalidation leaving a sidebar badge briefly stale, missing exception handling around the correction call, a missing null-guard on a timestamp field, and a stale docstring comment). Full suite: 1083/1083 tests passing, zero regressions. Closes the three-phase v4.5 milestone (Phases 59-61): the Home Timezone setting, the broken-clock classifier, and now its review/correction UI.

2026-09-18 — Phase 60: EMAY Broken-Clock Classifier

Added a pure, standalone is_likely_broken_clock(emay_first_ts) function to transforms/emay_alignment.py: given an EMAY session’s raw first-sample datetime, it flags whether the local time-of-day is implausible for a sleep session (a likely EMAY device-clock reset or drift) versus a legitimate no-CPAP night that simply failed CPAP-session matching. The 10:00-18:00 daytime window is two named constants carrying a PLACEHOLDER comment block — framed explicitly as a device-behavior heuristic, not a clinical claim, matching the existing MAX_PLAUSIBLE_SESSION_HOURS pattern — since no citation exists for “an implausible hour for sleep to start.” The function returns a strict bool (locked by a type(...) is bool identity check, since bool subclasses int) and is deliberately not wired into align_emay_session() or any call site this phase; an AST call-site assertion enforces that isolation so Phase 61 must wire it in consciously rather than by drift. This keeps the classifier and its honest sourcing settled and unit-tested (30 tests, zero regressions across 1061) before Phase 61 builds the review/correction UI that will actually use it — reusing existing EMAY-vs-CPAP historical data without re-deriving the heuristic under UI-development pressure.

2026-09-17 — Phase 59: Home Timezone Setting & Connector Fixes

Closed the travel-induced timestamp corruption bug that v4.4 discovered but deliberately left unfixed. Every epoch-to-wall-clock conversion in the ingest pipeline (connectors/oscar.py‘s session and event paths, connectors/withings.py‘s weigh-in noon-boundary decision, and connectors/cpap_sdcard.py‘s SD-card ingest route) now anchors to a new home_timezone user setting instead of the OS’s ambient timezone, via a shared transforms/sleep_date.py::epoch_to_home_local() helper; the stored string format was deliberately kept naive so the EMAY alignment and TECSA query paths keep working unchanged. sync.py fails loud (aborts with a clear error rather than silently falling back to the OS clock) when home_timezone is unset, and the same guard now covers the SD-card subcommand. connectors/garmin.py was independently audited (TZ-04) and confirmed already correct — explicit UTC conversion, sleep_date taken directly from Garmin’s own calendarDate field, never recomputed locally — recorded here with zero functional code change. With the real home timezone (America/Denver) now configured, a dry-run of the existing sync.py backfill-cpap-sessions repair command against the real database found zero rows differing (139/139 already correct) — Phase 57.1’s earlier repair still holds and no travel-corrupted sync has landed since — so the developer chose to skip a write-mode re-run rather than manufacture one. One known gap remains open for a future phase: no equivalent repair command exists for withings_measurements, so any historically mis-dated weigh-in (if one exists) stays mis-dated until that tooling is built.

2026-09-16 — v4.4 O2 Monitor Integration: Milestone shipped

Closed the v4.4 O2 Monitor Integration milestone (5 phases, 16 plans, 1032/1033 tests passing). At close, an audit of open verification items surfaced two things worth recording honestly rather than quietly closing. First, a live-database regression test (added by Phase 57.1 to pin the repaired session times) failed by exactly one hour after the developer traveled to a different timezone — traced to connectors/oscar.py‘s datetime.fromtimestamp() silently using the OS’s ambient timezone rather than a fixed home timezone, meaning any sync run while the machine’s OS timezone differs from home can miscompute CPAP session times and, transitively, which sleep_date a night’s data belongs to. The bug predates this milestone (Phase 19, v2.3) and was logged as a todo rather than fixed here, since it’s unrelated to EMAY oximetry. Second, Phases 55/56/57’s verification reports came back flagged “stale” by the project’s content-fingerprint check — traced to later phases (57.1, 58) legitimately extending the same shared files (sync.py, loaders/db.py, transforms/emay_alignment.py) after those reports were written, not to any regression; Phase 58’s own fresh verification against the final end state (including full human UAT) already covers what those three reports no longer do. Both findings, plus older carried-forward verification gaps from v2.1/v3.2 requiring a live browser session unavailable in this environment, were explicitly acknowledged and recorded rather than silently dismissed. Archived to .planning/milestones/v4.4-ROADMAP.md/v4.4-REQUIREMENTS.md, tagged v4.4.

2026-09-13 — Phase 58: O2 Monitor Dashboard Page

Closed the v4.4 O2 Monitor Integration milestone with a new eleventh dashboard page across four plans. Plan 1’s tracer slice wired the end-to-end skeleton first — a night picker sourced only from nights with real EMAY data, feeding a dual-axis go.Scattergl SpO2/pulse trace chart with connectgaps=False on both traces so sensor-off gaps render as real breaks rather than false zeros, plus CPAP OA/CA/H event markers aligned via pd.merge_asof with zero manual timestamp arithmetic. Plan 2 added three st.metric stats tiles (min/average/time-below-90% SpO2, reusing the existing SPO2_DESATURATION_THRESHOLD constant rather than a new one) and an auto-detected desaturation event table whose nearest-CPAP-event column is deliberately framed as a neutral proximity fact, never a causal claim. Plan 3 let a user zoom into any specific dip: an st.selectbox event picker drives a ±60-second, edge-clipped (never padded), gap-preserving mini plot, reusing the exact Plotly 6.7.0 datetime-axis-annotation workaround (add_vline needs a Unix-ms epoch, not a raw Timestamp) that pages/pressure_charts.py had already established. Plan 4 closed the phase with an ambient sidebar indicator: count_recent_emay_nights() is a bounded COUNT(DISTINCT sleep_date) over an inclusive 7-night window (matching the Week-over-Week digest’s framing, deliberately independent of the Trend Alerts badge’s 14-night CPAP-history gate), feeding a second sidebar st.metric that mirrors the Trend Alerts badge’s exact shape and non-critical try/except failure isolation — rendering nothing at all, never a “0/7” tile, when recent coverage is zero. This last plan needed zero deviations from its written plan. utils/cache.py‘s cache-invalidation registry grew from 25 functions at the start of the phase to 30 by its close, keeping Sync Now’s invalidation coverage complete as each new query function landed. Full suite: 1033 tests passing, up from 899 at phase start. Closes the four-phase v4.4 O2 Monitor Integration milestone (schema/alignment → connector → detection/sync wiring → this dashboard page).

2026-09-13 — Phase 57.1: Fix OSCAR CPAP session-date corruption (inserted)

Inserted an urgent bug-fix phase after Phase 57’s own real-data verification surfaced a data-corruption defect in the OSCAR connector predating this milestone (traces to Phase 19, v2.3). _fetch_sessions_sqlite() joined CPAP session data without filtering by CPAP machine and grouped sessions by raw calendar date instead of this project’s noon-boundary sleep-date rule — together corrupting 115 of 139 cpap_sessions rows in the developer’s real database. Five plans across four waves: a tracer fixing both defects together with a fabricated OSCAR-schema test fixture and a real-db noon-boundary invariant test; a python sync.py backfill-cpap-sessions command (dry-run by default, --apply-gated) that recomputes and repairs affected rows via a scoped direct UPDATE, bypassing upsert_cpap()‘s conflict clause that would otherwise silently no-op the fix; an 18-hour implausible-session-duration guard in find_matching_cpap_session() as defense-in-depth; the production repair itself — backup taken, dry-run reviewed, --apply run under explicit developer sign-off, then the 3 affected EMAY oximetry recordings re-aligned to their corrected dates; and a gap-closure plan filtering the events-path query (_fetch_events_sqlite()) by the same CPAP-machine fix, plus two review warnings (multi-device resolution, a leaked backfill connection on error). Zero noon-boundary violations remain in the repaired database. 931 tests pass.

2026-09-12 — Phase 57: Desaturation Detection + Sync Wiring

Wired desaturation event detection onto Phase 55/56’s aligned EMAY samples across three plans. utils/o2_desaturation.py wraps the pobm library’s ODI3 detector (numpy 2.x compatibility shim for a deprecated RankWarning) behind a DESAT_THRESHOLD_PCT = 3 constant, marked PLACEHOLDER per this project’s Verifiable Sources rule — it’s pobm‘s own published default (Levy et al. 2021), explicitly distinguished from the different AASM 3%/4% hypopnea-scoring rule it might be mistaken for. A new _sync_desaturation() stage in sync.py runs detection automatically at the end of every sync, writing into o2_desaturation_events; re-running sync on an already-processed night replaces that night’s events wholesale rather than duplicating them. queries/o2_monitor.py computes ODI (events/hour) on read, never persisted, with a cached wrapper registered in the Sync Now cache-clear registry. Pointing python sync.py run at four real EMAY files surfaced and fixed a real bug — a NULL session_start on some CPAP rows was silently aborting EMAY-to-CPAP matching entirely — before the first honest real-data run produced 150 desaturation events across 3 nights, spot-checked by hand (nadir_spo2/depth_pct arithmetic, plausibility of the counts against the developer’s own data) rather than trusted on fixture tests alone. 924 tests pass.

2026-09-11 — Phase 56: EMAY Connector (CSV + Binary Fallback)

Built the real EMAY file connector on top of Phase 55’s schema and alignment foundation, in two waves. Plan 1 wired the canonical CSV export path end-to-end: connectors/emay.py::parse_emay_csv() maps blank readings to NULL rather than a false zero so sensor-off gaps survive, filters physiologically implausible SpO2/pulse pairs behind an explicitly PLACEHOLDER-labeled range constant, and feeds a new loaders/db.py::upsert_o2_samples() through a new sync.py::_sync_emay() stage — kept as the sole wiring point so connectors/emay.py stays a pure parse layer with no Streamlit or sqlite3 imports. Plan 2 added the .spo2 binary fallback for nights with no CSV export. Rather than trust the research doc’s byte-offset prose, the executor independently re-derived every offset against the raw bytes of all three real recordings before writing the parser, correcting the sample-body offset from an initially-assumed 856 to the actual 1084 — a direct instance of this project’s Verifiable Sources rule doing its job. Every offset in parse_emay_spo2() carries an in-repo [VERIFIED: ...] or [ASSUMED: ...] marker, and a dedicated cross-validation test proves the layout against all three fixtures rather than just one. Plan 2 also hit its one designed checkpoint: whether the four real overnight recordings used to build and validate the parser should be committed as test fixtures, given this repo has a public GitHub remote with an auto-publish action. Decision: local-only — O2 Samples/ is gitignored, the cross-validation test runs for real on this machine but skips cleanly on a fresh clone or in CI, and no personal health data enters git history. Full suite: 899 tests passing (2 pre-existing, unrelated kaleido/Chrome subprocess failures on this Windows machine, already tracked in .planning/WINDOWS.md). Sets up Phase 57 to wire desaturation detection onto these now-real, aligned SpO2 samples.

2026-09-11 — Phase 55: Schema + Alignment Foundation

Opened the v4.4 O2 Monitor Integration milestone with a schema-and-pure-logic-only phase — no real EMAY file is parsed and no UI exists yet, by design. Added three new SQLite tables (o2_sessions, o2_samples, o2_desaturation_events) to loaders/db.py::init_db(), following the existing sleep_date TEXT join-key convention rather than introducing a new integer-FK pattern, and proved by a direct sqlite_master read that the nightly_summary VIEW references none of them — the same never-in-the-spine precedent mask_log established. The load-bearing work is transforms/emay_alignment.py: a pure stdlib module (zero DB access, zero Streamlit import) that anchors an EMAY pulse-oximeter session’s untrustworthy phone-clock timestamp to the CPAP session it actually co-occurred with, within an 8-hour window, and reports a matched / multi_candidate / unmatched result rather than ever silently guessing. Plan 1 built the schema and the matched-path tracer; plan 2 wrote 13 unit tests locking the two harder branches — zero in-window candidates (kept, flagged unmatched, given a noon-boundary fallback date, null offset that can never read as a false “perfectly aligned”) and two-or-more in-window candidates (nearest wins, but flagged multi_candidate so an ambiguous match can never be mistaken for a clean one). All 13 tests passed immediately against plan 1’s existing implementation — the tracer task had already built the full three-branch function, ahead of what plan 1’s own summary described — so plan 2’s contribution is proof, not new production code: a regression test pinning that a matched session’s date always comes from the CPAP row verbatim (never recomputed from EMAY’s own clock) is the single most important test in the phase, deliberately built around a fixture where recomputation would silently produce the wrong date if the copy-not-recompute discipline ever regressed. Full suite: 870 tests passing, zero regressions. Sets up Phase 56 to build the real EMAY file connector against this now-stable schema and alignment contract.

2026-08-26 — v4.3 Evidence Remediation: Milestone shipped

Closed the v4.3 Evidence Remediation milestone (Phases 51-54, 20 plans) after Phase 54 compiled all 26 re-verdicted rows from Phases 51-53 into CLINICAL-EVIDENCE-AUDIT.md‘s executive summary, master index, tier assignments, and priority tables across seven strictly sequential plans (same shared file). The synthesis work itself caught a discrepancy the individual re-verdict phases didn’t surface: Phase 51 had actually re-verdicted 26 rows against the literature, not the 25 the roadmap’s success criterion named — a re-verdict of the CAI Trend chart’s altitude overlay (TEC-2) had been done but left off the tracked list. The correction routed through a blocking developer-approval checkpoint rather than a silent roadmap edit. One row, the on-treatment “controlled” AHI=5 finding (OV-6), moved from the weak-evidence tier into the unsupported-but-presented-as-fact tier — the only tier movement the milestone produced; six further rows moved from unsourced to incorrectly-sourced-as-applied. The two roadmap-named “explicit flips” (mask type vs. AHI, and the SURMOUNT-OSA remission framing) changed the basis of their finding, not their underlying Keep/Modify verdict, and the compiled report states that distinction plainly rather than implying a bigger change occurred than did. A new Queued for Future Remediation section records 14 code-level fixes these findings imply — the CMS usage-hours threshold, the TECSA “4-8 weeks” wording, the symptom log’s dry-mouth remedy copy, and eleven others — none implemented. All 11 Citation Verification Log tables were mechanically regenerated from the document’s own citation lines with a scripted zero-mismatch proof, closing the one transcription surface the citation-integrity gate itself cannot see. Both mechanical gates were demonstrated non-vacuous at every plan boundary (deliberately broken, confirmed failing, restored) rather than merely run. The milestone closes at 105 verdict rows, 364 citation lines (270 verified, 94 flagged gaps), and zero application code, chart, threshold, or prompt files changed anywhere across all four phases — independently re-verified by a fresh-context verifier agent before archiving, then signed off by the developer. Archived to .planning/milestones/v4.3-ROADMAP.md / v4.3-REQUIREMENTS.md; the 14-entry remediation queue is the leading candidate for the next milestone’s scope.

2026-08-24 — Phase 53: Remission, Wearable Accuracy & Symptom Instrument Re-verdict

Closed the last three of nine theme groups in the v4.3 Evidence Remediation milestone across five strictly sequential plans (same audit document, each depending on the last). The tracer task corrected REM-8’s SURMOUNT-OSA/tirzepatide remission framing against the trial’s own publication: the actual key secondary end point is AHI/Epworth-Sleepiness-Scale-based (AHI <5, or 5-14 with ESS <=10), not the 20%-body-weight milestone previously cited, with the trial’s separately-reported ~-17.7% body-weight change kept explicit as a distinct, non-equivalent measurement rather than merged into the corrected endpoint. REM-7 and the AI Analysis page’s remission-outlook section and system-prompt benchmarks (AI-3, AI-5) were resynced to the same corrected trial citation, closing REV-02 across all four rows. The wearable-accuracy group (REV-05: EXP-1, EXP-2, SQ-3, SQ-6, AI-2) gained a second Garmin-device validation study (Budig 2022, Forerunner 945) and an attribution-only citation for a newly-discovered duplicate source already in the library (Schyvens 2025 content-identical to the existing zpaf021.pdf) — the plan explicitly disclosed the duplicate rather than double-counting it as independent corroboration. The most consequential single finding: EXP-1 previously claimed no paper reports any Garmin-specific SpO2 accuracy figure, which Budig 2022 now directly contradicts (SpO2-minimum r=0.27, MAE 4.58) — that stale claim was retired, and the correction doesn’t rescue the page’s own disclaimer, since the measured MAE sits above its stated +/-2-4 point range. Every wearable-accuracy row still confirms no source validates the Fenix specifically, the device this dashboard actually uses. The closing plan sourced SYM-11’s aerophagia symptom category against a 324-respondent CPAP-aerophagia questionnaire study (Hillamaa 2025, REV-09) while explicitly flagging that the app’s own 4-point ordinal severity scale remains uncited against a literature instrument built on a continuous 0-100mm visual analogue scale instead. All ten re-verdicted rows across the phase carry 26 new verbatim-verified citations (270 total in the document) and pass both mechanical anti-fabrication gates (citecheck.sh, auditcheck.sh) in an independent phase-closing verification sweep; no application code changed anywhere. This closes per-theme re-verdict work for v4.3 — Phase 54 synthesizes all nine theme groups into the audit’s master index and priority tables next.

2026-08-24 — Phase 52: Device Reliability & Pressure/TECSA Re-verdict

Continued the v4.3 Evidence Remediation milestone by re-verdicting the device-classification-reliability, TECSA natural-history, and CMS usage-adherence theme groups across three strictly sequential plans (same audit document, each depending on the last). The tracer task re-verdicted TEC-6 — the audit’s strongest prior negative finding, “resolves spontaneously in ~67% of cases within 4-8 weeks,” previously unsourced at every layer — against a new systematic review (Nigam 2018): the qualitative and proportion halves are now correctly sourced (the review’s own phrasing is “about two-thirds,” not a precise percentage), but the 4-8-week window is reclassified as incorrectly sourced as applied, since the review’s own resolution timescale (“a period of few weeks to several months”) is materially broader, with a concrete rewording recommendation recorded in the row for a future phase to act on. The device-reliability group (PRS-2, PRS-9, TEC-1, TEC-4 — four charts built on the same onboard CPAP event classifier) each gained a distinct, chart-specific citation set from a longitudinal detection-accuracy study (Ni & Thomas 2022, r=0.445-0.560 device-vs-manual-scoring correlation): the finding is stated carefully as reinforcing, not closing, the device-vs-PSG validation gap, since the study’s reference standard is expert manual scoring of the device’s own flow signal — a best-case comparison, not an independent polysomnogram. PRS-9’s central-apnea-as-primary-artifact-suspect premise came out the most consequential of the four: the only on-point evidence in the library now points the opposite direction, describing central-event under-detection rather than over-reporting, which the row records as running counter to the chart’s premise rather than as disproving it. TEC-1’s inherited zero-hit natural-history claim — stale the moment TEC-6 landed — was resynchronized in the same plan. The closing plan cited CMS LCD L33718’s official “4/70 rule” as PRS-14’s first primary-source citation, upgrading the existing sourced-4.0-vs-unsourced-6.0 distinction with two sharper findings: the standard is a proportion-of-nights-over-a-30-day-window rule that the app renders as a bare per-night boundary, and its documented consequence for non-compliance is a Medicare coverage denial — an eligibility outcome, not a clinical judgment that therapy stopped working. All six re-verdicted rows carry 19 new verbatim-verified citations (243 total) and pass both mechanical anti-fabrication gates together in one phase-closing sweep; no verdict flipped, and no application code changed anywhere. Sets up Phase 53 to continue with remission framing, wearable accuracy, and the symptom-log severity scale.

2026-08-23 — Phase 51: Overview & Confounder Framing Re-verdict

Opened the v4.3 Evidence Remediation milestone by re-verdicting three of nine theme groups against the post-v4.2 literature search, across five plans in a strictly sequential chain (each editing the same audit document). The tracer task resolved the milestone’s one live judgment call: OV-6’s on-treatment “controlled” AHI=5.0 reading flips from Keep to Modify, since two new sources (an ATS adherence-tracking statement, a 2025 RCT on AHI-normalized-but-symptomatic patients) directly contradict rather than merely leave uncited the chart’s treatment-adequacy framing — the same escalation precedent OV-1’s own rubric already established a milestone earlier. That escalation propagated to four more rows sharing the same premise (SET-1, ALT-1, SYM-5, SYM-9), completing REV-01. The altitude-confounder group (OV-1, TEC-2, SET-7, AI-4 — REV-07) took the opposite shape: a systematic review does establish real central-apnea/periodic-breathing effects from altitude, but only at 1,500-2,000+ MAMSL, roughly 5-7x the magnitude the dashboard’s 300m home-baseline overlay flags — reframed, not closed, with each row explicitly splitting its finding by axis where a second, unaddressed axis (sleep architecture, HRV) remained a pure flagged gap. OV-5’s mask-type-vs-AHI chart (REV-08) was the milestone’s other named verdict flip: a network meta-analysis overturns its “no head-to-head study exists” claim outright, though the AHI difference’s clinical significance remains genuinely disputed between two studies within that same meta-analysis. All ten re-verdicted rows carry 22 new verbatim-verified citations (224 total in the document) and pass both mechanical anti-fabrication gates; a phase-closing verification sweep confirmed no edits leaked into the master index, per-page summary tables, or executive summary sections reserved for Phase 54’s synthesis. Sets up Phase 52 to continue with device-reliability and pressure/TECSA re-verdicts.

2026-08-23 — Post-v4.2: Literature search for the Tier 1 remediation backlog

With the v4.2 audit closed and its 31-file library fixed, started sourcing candidate evidence for the 90-item Tier 1 backlog ahead of a future remediation milestone. Grouped the 90 unsourced findings into 17 recurring clinical themes (rather than 90 one-off searches) and worked through the 5 highest-priority plus several secondary themes, adding 11 new peer-reviewed papers (42 files total) to .planning/research/medical-sources/ via Europe PMC, each with a page-indexed text mirror so pdfsearch.sh/citecheck.sh work on them identically to the original library. Two finds change rather than just fill existing gaps: the SURMOUNT-OSA primary trial results (Malhotra et al. 2024, NEJM) show the trial’s actual “disease resolution” endpoint is AHI-based, not the 20%-body-weight milestone REM-8 currently cites it for; and a network meta-analysis on CPAP mask interfaces overturns OV-5’s “no head-to-head study exists” claim outright — head-to-head studies do exist, the real open question is whether the measured AHI difference is clinically significant, which the literature itself disputes. A systematic review on TECSA natural history supports the app’s “roughly two-thirds resolve” figure (TEC-6) while showing its specific “4-8 week” window is narrower than any of the five reviewed studies actually found. Two theme searches (HRV decline magnitude, CPAP leak clinical significance) came up empty — either no open-access primary source exists or the concept appears to be genuinely project-authored — and are logged as such so a future pass doesn’t repeat them. Full source-by-source write-up, including exact quotes, page references, and a read on what each source does and doesn’t establish, is in .planning/research/NEW-SOURCES-LOG.md. CLINICAL-EVIDENCE-AUDIT.md itself is untouched — these are staged inputs for whichever future milestone re-verdicts the Tier 1 backlog.

2026-08-22 — Phase 50: Cross-Cutting Synthesis & Final Report

Closed the v4.2 Clinical Evidence Audit milestone. This last phase verdicted the Nightly Score’s own composite construct, all five component weights, all ten scale-anchor constants (floor/ceiling pairs for AHI, deep sleep, REM, SpO2, and HRV), and the two score zone-band cut points against the same 31-file library (12 new items, 0 Keep, 12 Modify, 0 Cut) — the most consequential of which reopened whether a vendor multi-signal composite score is a defensible clinical construct at all, and found a 2024 umbrella review naming Garmin explicitly stating this class of composite has “not undergone formal validation,” a stronger and more on-point finding than the questionnaire-analogy verdict it supersedes. A prepended executive summary then ranked all 105 items audited across Phases 45-50 into three priority tiers (90 unsupported-but-presented-as-fact, 9 weak-evidence, 6 well-supported), and a cross-page shared-constants section reconciled six thresholds reused across multiple charts — including two divergent central-apnea-fraction definitions and a single 24 L/min leak figure defined under three different names — to one verdict each rather than one per page. A separate subsection resolved the 90-day CPAP-adaptation-window deferral from Phase 45’s OV-1 finding without altering that row, plainly recording the direct guidance the developer received from medical professionals as an out-of-scope input rather than silently dropping it or letting it override the library-only verdict. Before any requirement was marked complete, a fresh-context sweep independently re-walked all four ROADMAP success criteria and all eight of this phase’s own decisions directly against the compiled document (not the prior plans’ own summaries), proved both mechanical anti-fabrication gates non-vacuous by forcing each to fail and confirming a byte-identical restore, and re-derived eleven tier assignments from their own verdict rows with no mismatch found — before the developer reviewed and signed off on the finished report. The milestone closes with 201 file-and-page citations, 94 explicit flagged gaps, and zero application code changed anywhere: every one of the 90 Tier 1 findings is queued as a future remediation milestone’s work.

2026-08-22 — Phase 49: Settings, AI, Alerts, Export & Daily Log Audit

Extended the v4.2 Clinical Evidence Audit across five more pages in one phase — Settings & Context (8 items), AI Analysis (5 items), Alert History (5 items), Daily Log/Symptoms (14 items), and Export & Reports (5 items) — closing AUDIT-SET-01, AUDIT-AI-01, AUDIT-ALT-01, AUDIT-SYM-01, and AUDIT-EXP-01 in a single sweep. The most consequential findings reach a clinician directly: the exported PDF’s SpO2 accuracy disclaimer substitutes a real Apple Watch figure for an unmeasured Garmin one (EXP-1), and its sleep-stage disclaimer claims Deep Sleep is the least reliably detected stage when the library’s own Garmin figures rank it second-most reliable (EXP-2) — the second and third instances of the device-substitution pattern first found in Phase 46. On the AI Analysis page, all three milestone-based prompt constants (AASM_10PCT_THRESHOLD_KG, SURMOUNT_OSA_MILESTONE_KG, and the system prompt’s six-item clinical-benchmarks block) turned out to cite REQUIREMENTS.md itself rather than any library source, directly contradicting this project’s own prior “already sourced” description. Alert History’s most consequential finding is a zero-prior-mean guard that silently withholds any AHI-drift alert from a patient relapsing out of a perfectly-controlled baseline — undisclosed to the reader who sees only the alert’s “prior” framing. A plan-level independent verification sweep across all five pages proved both mechanical anti-fabrication gates non-vacuous and confirmed no item was left unclassified before developer sign-off closed the phase. Sets up Phase 50 to synthesize all five audited pages plus Phases 45-48 into one compiled report.

2026-08-22 — Phase 48: Pressure Analytics & TECSA Watch Audit

Extended the v4.2 Clinical Evidence Audit to Pressure Analytics (14 items) and TECSA Watch (16 items), closing AUDIT-PRS-01, AUDIT-TEC-01, and AUDIT-TEC-02. The load-bearing finding for the entire TECSA half of the dashboard is PRS-2’s flagged gap: zero library evidence at any confidence level for whether the CPAP device’s own onboard OA/Hypopnea/Central-apnea classification agrees with a scored polysomnogram — every downstream TECSA chart inherits this same unresolved premise. TEC-6’s persistent clinical-context header claim (“resolves spontaneously in approximately 67% of cases within 4-8 weeks”) is the single strongest negative finding in the whole document: all three components — the qualitative claim, the 67% figure, and the 4-8 week window — are independently unsourced, with no correctly-sourced layer to fall back on, yet it displays unconditionally regardless of the viewing patient’s own elapsed time. TEC-8’s cardiovascular-association banner is incorrectly sourced in direction — the library’s one on-point sentence describes patients recruited from heart-failure clinics (cardiac-diagnosed first), the reverse of the banner’s detection-first implied reading — stacked atop a six-link detection chain in which every other link is itself Modify or a flagged gap. An independent 19-term altitude search (TEC-2) reproduced OV-1’s exact zero-hit conclusion using central-apnea-specific mechanism terms not tried before. A plan-level independent verification sweep proved both mechanical gates non-vacuous before developer sign-off closed the phase. Sets up Phase 49 to continue the page-by-page sweep with the five remaining pages.

2026-08-21 — Phase 47: Weight & Remission Audit

Extended the v4.2 Clinical Evidence Audit to the Weight & Remission page’s nine charts and reference lines (REM-1 through REM-9), closing AUDIT-REM-01 and AUDIT-REM-02. This phase’s headline finding is REM-2: “remission” as a within-patient, on-treatment concept returns zero hits under roughly 20 search wordings tried anywhere in the library, unsourcing the entire chart’s framing premise. Both named milestone reference lines turned out to be unsourced by attribution rather than by magnitude — REM-7’s “AASM re-evaluation threshold” cites an authority whose every library hit is a sleep-scoring or diagnostic-severity citation, never a weight-management practice parameter, and REM-8’s “SURMOUNT-OSA remission candidacy” trial exists in the library only as a bibliography entry, confirmed by direct inspection to be a reference-list line rather than an in-text finding. REM-3’s inverted “faster weight loss is better” axis framing and REM-4’s dose-tier background bands both assert relationships (rate-to-outcome, dose-to-response) the library’s between-patient, non-rate, non-dose-stratified evidence cannot support. A plan-level independent verification sweep re-checked two named substitution risks and confirmed both absent as written, then re-ran both mechanical anti-fabrication gates from scratch before developer sign-off (“approved”, no disputed verdicts) closed the phase. Sets up Phase 48 to continue the page-by-page sweep with Pressure Analytics and TECSA Watch.

2026-08-21 — Phase 46: Sleep Quality Audit

Extended the v4.2 Clinical Evidence Audit to the Sleep Quality page’s five charts and two clinical framing statements, reusing the Phase 45 evidence pipeline unmodified. Verdicts: 2 Keep, 8 Modify, 0 Cut, across all ten scoped items (SQ-1 through SQ-10). The most consequential finding is a genuine device-substitution error: the page’s Garmin Fenix “40-50% accuracy vs. polysomnography” disclaimer traces in the library only to studies of the Vivosmart 4 — a different, more basic device — with no Fenix-specific sleep-staging accuracy figure found anywhere in the 31-file library. The “Week 6-12” HRV recovery-window message is unsourced for both boundary numbers (the general HRV-improves-with-CPAP concept is supported, but neither week 6 nor week 12 individually is). The deep-sleep (15-20%) and REM (18-22%) target bands on the Sleep Stages Quality chart have no normative source at all, and the Body Battery metric underlying the Morning Body Battery Trend and Prior-Night Score charts is flagged as definitionally unvalidatable — the library states vendor “bespoke biometrics” of this kind have never undergone formal validation. A plan-level independent verification sweep re-ran both mechanical anti-fabrication gates from scratch, proved each one non-vacuous by forcing a deliberate failure, hand-checked load-bearing citations against raw PDF text, and confirmed no chart’s numeric threshold was left unclassified before developer sign-off closed the phase. All findings are queued, not applied. Sets up Phase 47 to continue the page-by-page sweep with Weight & Remission.

2026-08-21 — Phase 45: Treatment Overview Audit

Kicked off the v4.2 Clinical Evidence Audit milestone by auditing the Treatment Overview page’s seven clinical items — the AHI Trend chart, Week-over-Week digest, Score Trend chart, Score Breakdown chart, AHI & Leak by Mask Type breakdown, and the two shared numeric constants AHI_THRESHOLD = 5.0 and MASK_BREAKDOWN_MIN_NIGHTS = 10 — against a 31-PDF medical literature library (29 unique documents after de-duplication). Built the audit tooling first: a page-indexed pdftotext search pipeline plus two mechanical anti-fabrication gates, auditcheck.sh (every citation must name a real file in the library) and citecheck.sh (every quoted phrase must be re-locatable in the cited file at the cited page) — both proven non-vacuous by injecting a deliberately fabricated citation and confirming the gate actually fails on it. Verdicts: 4 Keep, 3 Modify, 0 Cut. The most consequential finding is on AHI_THRESHOLD = 5.0 — the library supports 5 as a diagnostic severity boundary but not as the on-treatment “controlled” adequacy target the chart annotation implies. The hardcoded 90-day adaptation-period overlay and the score-chart’s 75/50 zone-band cut points were both found unsourced or incorrectly sourced; the mask-type-affects-outcomes premise behind the mask breakdown chart has no supporting study in the library. All findings are queued, not applied — this phase is audit-only, and a plan-level independent verification sweep plus developer sign-off confirmed the section’s completeness before closing. Sets up phases 46-50 to audit the remaining dashboard pages and synthesize cross-page findings.

2026-08-20 — Phase 44.1: Fix test_pdf_builder.py Stale Fixture

Closed out the v4.1 milestone by fixing a chain of four stale-fixture bugs in tests/test_pdf_builder.py that had been re-discovered and re-logged as “pre-existing, unrelated” across three separate phases (41, 43, 44) without ever being fixed. The synthetic _make_nightly_df() test fixture was missing avg_spo2, which real chart-building code (build_spo2_ahi_chart‘s fallback trace) reads unconditionally — but fixing that unmasked three more of the same class hidden behind it (weight_kg, missing_count, rolling_avg, each masked by the prior column’s KeyError short-circuiting execution before the next chart builder ran). Fixed one at a time, verifying between each, until all 25 tests in the file passed and the full 846-test suite went green with zero production files touched. A parallel Nyquist validation pass across all seven v4.1 phases confirmed automated test coverage for every shipped requirement and caught one genuine gap — a missing regression test guarding the mask-tracking UI’s removal from the Settings page (Phase 42’s redesign) — closed with a new test before archiving the milestone. v4.1 Polish & Completeness ships as 8 phases, 32 plans, 846 tests passing, tagged v4.1.

2026-08-16 — Phase 44: UI Overhaul

The largest phase of v4.1 (12 plans) unified visual structure across all ten dashboard pages. Every chart builder now reads height, color, and x-axis date-domain from a single chart_config.py source of truth. A long-standing Streamlit-vs-PDF chart alignment discrepancy was root-caused — not to the leading hypothesis (an automargin content-growth interaction) but to a genuine gap in Streamlit’s own width='stretch' resolution logic — and fixed by threading an explicit CHART_WIDTH_MODE = "content" constant through every page’s chart calls. Every chart and data section across the app now has a defined empty-state behavior, and a consistent section-hierarchy convention (one page title, st.subheader-scoped sections, single dividers) was applied dashboard-wide, closing gaps a new structural regression guard enforces going forward. A live-app checkpoint during the final plan moved the Treatment Overview KPI row above the AHI Trend chart at the user’s request and confirmed the whole redesigned dashboard reads well end-to-end; one cosmetic Pressure Analytics dual-axis alignment quirk was diagnosed as likely browser-side (invisible to server-side Kaleido tooling) and deferred pending real-browser access.

2026-08-09 — Phase 43: Sync Now + Cache Layer

The dashboard can now trigger its own data sync without a terminal. A “Sync Now” section on the Settings & Context page launches python sync.py run via a non-blocking subprocess.Popen (sys.executable, CREATE_NO_WINDOW on Windows) and live-tails its stdout through a thread+queue relay into a st.status() container showing running/success/failed states with exit code and stderr. On success, a new utils/cache.py module clears all 24 @st.cache_data query functions across the codebase before an immediate rerun, so fresh data appears without a manual reload. The Sync Now section sits above the cold-database empty-state guard, so a brand-new install can be fully populated from the browser. pages/06_Settings.py is now the only file in the codebase permitted to call subprocess — enforced as an explicit architecture constraint and pinned by test. 623 tests pass (6 pre-existing test_pdf_builder.py failures, unrelated to this phase, re-confirmed and logged for a future cleanup pass).

2026-08-08 — Phase 42: Mask Tracking

Replaced per-night mask entry with a switch-based forward-fill model after a live-app checkpoint rejected the original per-night design mid-flight. apply_mask_switch() fills every CPAP night from a switch’s effective date forward but stops before the next already-recorded switch, so a retroactive entry can no longer erase a later switch — a real data-integrity bug the checkpoint caught before ship. The renamed Daily Log page (was Symptoms — mask issues, dry mouth, etc. aren’t symptoms of the underlying condition) shows the current mask, a collapsed switch history, and the new mask-switch field; Treatment Overview adds an AHI & Leak by Mask Type chart gated at 10+ nights per mask type. A post-ship code review then caught a related edge case (CR-01): re-applying the mask that preceded an existing switch, at that switch’s own date, was still silently erasing the entire run up to the next real switch, because a single (date, name) pair can’t distinguish “shrink this switch’s start” from “undo it entirely.” Fixed with an optional until bound on apply_mask_switch() (exposed in the UI as an optional “Correct through” date) — ambiguity resolved by giving the caller a way to state the boundary explicitly rather than inferring it. 604 tests pass (6 pre-existing test_pdf_builder.py failures unrelated to this phase).

2026-07-31 — Phase 41: Settings Change History

Wired the cpap_settings_log table (built in Phase 39) into the dashboard: the Settings & Context save handler now diffs and logs every genuine CPAP prescription change through a new pure utils/settings_diff.py module, and a new queries/cpap_settings.py accessor feeds two new marker overlays on the Pressure Analytics charts — pressure changes on both the P95 trend and pressure/leak dual-axis charts, EPR changes on the trend chart only, using Plotly’s label=dict(...) API to sidestep a datetime-axis rendering bug in the installed Plotly 6.7.0. The phase had no separate SPEC.md, so planning ran a deterministic edge-probe fallback to derive edge-case must-haves directly from the requirements text instead. Post-execution code review caught a real bug before it shipped: pandas silently coerces a NULL old_value to float('nan') once the settings log has more than one row, which would have rendered the literal string “nan” on the chart for any user’s second-ever settings change — fixed along with a UTC-vs-local timestamp bug and an unguarded EPR index. Sets up Phase 42 (Mask Tracking), which reuses the same append-only-log-plus-chart-marker pattern for a different data source.

2026-07-18 — Phase 40 probe: cpap-py / AirSense 11 SD card field names

Ran scripts/probe_cpap_sdcard.py --path D:\ against the user’s physical ResMed AirSense 11 SD card (mounted at drive D:). Verbatim output below, source of truth for Plan 2’s normalize_cpap_sdcard_row() field-name constants per INGEST-03/D-09/D-10.

cpap-py method calls used: CPAPLoader(str(sd_path)).load_identification_only(), cpap_py.edf_parser.EDFParser (opened directly on STR.edf to enumerate raw signal labels), STRParser (to parse the most recent STRRecord).

Card root structure: STR.edf and DATALOG/ sit at the card root (not under a RESMED/ subdirectory). Root also contains SETTINGS/, Identification.json, Identification.crc, journal.jnl, System Volume Information, LOST.DIR.

DATALOG filename prefix pattern: DATALOG/YYYYMMDD/YYYYMMDD_HHMMSS_<SUFFIX>.edf where <SUFFIX> observed values include CSL, EVE, BRP, PLD, SA2. The per-day folder name (e.g. 20260426) is one day earlier than the session timestamps inside it (e.g. 20260427_...), consistent with overnight sessions crossing midnight.

⚠️ Field-name discrepancy found (Pitfall 2 confirmed): The raw STR.edf signal labels for pressure settings are S.C.StartPress, S.C.Press, S.A.StartPress, S.A.MaxPress, S.A.MinPress, S.EPR.Level, etc. (gain-encoded, cmH2O dimension). However, STRParser‘s parsed STRRecord object exposes different attribute names — min_pressure, max_pressure, set_pressure, epr, epr_level — and on this real card all five returned 0.0 / -1, while ahi, leak_50, leak_95 returned real nonzero values. This matches RESEARCH.md’s documented cpap-py Pitfall 2 (STRParser silently returns 0.0, not None, for signals it can’t map) — cpap-py’s STRParser does not currently map the S.* pressure-setting signals to its STRRecord output. Plan 2 must not read STRRecord.min_pressure/max_pressure/set_pressure/epr/epr_level — pressure-setting values will need to be read directly from the raw EDFParser signal array using the confirmed labels above, not from STRParser‘s convenience object.

Correction (2026-07-30, Phase 40 code review CR-01): the sentence above originally also claimed mp_50/95/max “returned real nonzero values” — that contradicts this same probe run’s raw output pasted below, which shows mp_50/mp_95/mp_max all at 0.0. mp_50/95/max reliability on real hardware is unconfirmed, not verified. transforms/normalize.py was corrected to collapse falsy mp_50/95/max readings to None rather than trust them as real zero pressure values. Re-probe on a future night to confirm whether these fields are ever genuinely populated.

=== SD Card Top-Level Contents ===
[WindowsPath('D:/System Volume Information'), WindowsPath('D:/journal.jnl'), WindowsPath('D:/DATALOG'), WindowsPath('D:/SETTINGS'), WindowsPath('D:/Identification.crc'), WindowsPath('D:/Identification.json'), WindowsPath('D:/STR.edf'), WindowsPath('D:/LOST.DIR')]

=== Device Identification ===
  Model:  'AirSense11AutoSet'
  Serial: '23261577558'
  Series: 'AirSense11'

=== STR.edf Signal Labels (raw — all signals) ===
  label='Date'                          dim=''          phys_min=0.0  phys_max=24836.0  digi_min=0  digi_max=24836  gain=1.00000000  samples_per_rec=1
  label='MaskOn'                        dim='MINUTES'   phys_min=0.0  phys_max=1440.0  digi_min=0  digi_max=1440  gain=1.00000000  samples_per_rec=20
  label='MaskOff'                       dim='MINUTES'   phys_min=0.0  phys_max=1440.0  digi_min=0  digi_max=1440  gain=1.00000000  samples_per_rec=20
  label='MaskEvents'                    dim=''          phys_min=0.0  phys_max=255.0  digi_min=0  digi_max=255  gain=1.00000000  samples_per_rec=1
  label='Duration'                      dim='min.'      phys_min=0.0  phys_max=1440.0  digi_min=0  digi_max=1440  gain=1.00000000  samples_per_rec=1
  label='Mode'                          dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.C.StartPress'                dim='cmH2O'     phys_min=4.0  phys_max=20.0  digi_min=200  digi_max=1000  gain=0.02000000  samples_per_rec=1
  label='S.C.Press'                     dim='cmH2O'     phys_min=4.0  phys_max=20.0  digi_min=200  digi_max=1000  gain=0.02000000  samples_per_rec=1
  label='S.A.StartPress'                dim='cmH2O'     phys_min=4.0  phys_max=20.0  digi_min=200  digi_max=1000  gain=0.02000000  samples_per_rec=1
  label='S.A.MaxPress'                  dim='cmH2O'     phys_min=4.0  phys_max=20.0  digi_min=200  digi_max=1000  gain=0.02000000  samples_per_rec=1
  label='S.A.MinPress'                  dim='cmH2O'     phys_min=4.0  phys_max=20.0  digi_min=200  digi_max=1000  gain=0.02000000  samples_per_rec=1
  label='S.AFH.StartPress'              dim='cmH2O'     phys_min=4.0  phys_max=20.0  digi_min=200  digi_max=1000  gain=0.02000000  samples_per_rec=1
  label='S.AFH.MaxPress'                dim='cmH2O'     phys_min=4.0  phys_max=20.0  digi_min=200  digi_max=1000  gain=0.02000000  samples_per_rec=1
  label='S.AFH.MinPress'                dim='cmH2O'     phys_min=4.0  phys_max=20.0  digi_min=200  digi_max=1000  gain=0.02000000  samples_per_rec=1
  label='S.AS.Comfort'                  dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.RampEnable'                  dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.RampTime'                    dim='min.'      phys_min=5.0  phys_max=45.0  digi_min=5  digi_max=45  gain=1.00000000  samples_per_rec=1
  label='S.EPR.ClinEnable'              dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.EPR.EPREnable'               dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.EPR.Level'                   dim='cmH2O'     phys_min=1.0  phys_max=3.0  digi_min=50  digi_max=150  gain=0.02000000  samples_per_rec=1
  label='S.EPR.EPRType'                 dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.SmartStart'                  dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.PtAccess'                    dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.ABFilter'                    dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.Mask'                        dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.Tube'                        dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.ClimateControl'              dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.HumEnable'                   dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.HumLevel'                    dim=''          phys_min=1.0  phys_max=8.0  digi_min=1  digi_max=8  gain=1.00000000  samples_per_rec=1
  label='S.TempEnable'                  dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='S.Temp'                        dim='Celsius'   phys_min=15.6  phys_max=30.0  digi_min=156  digi_max=300  gain=0.10000000  samples_per_rec=1
  label='HeatedTube'                    dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='Humidifier'                    dim=''          phys_min=0.0  phys_max=16.0  digi_min=0  digi_max=16  gain=1.00000000  samples_per_rec=1
  label='BlowPress.95'                  dim='cmH2O'     phys_min=-10.0  phys_max=45.0  digi_min=-500  digi_max=2250  gain=0.02000000  samples_per_rec=1
  label='BlowPress.5'                   dim='cmH2O'     phys_min=-10.0  phys_max=45.0  digi_min=-500  digi_max=2250  gain=0.02000000  samples_per_rec=1
  label='Flow.95'                       dim='L/s'       phys_min=-2.0  phys_max=3.0  digi_min=-1000  digi_max=1500  gain=0.00200000  samples_per_rec=1
  label='Flow.5'                        dim='L/s'       phys_min=-2.0  phys_max=3.0  digi_min=-1000  digi_max=1500  gain=0.00200000  samples_per_rec=1
  label='BlowFlow.50'                   dim='L/s'       phys_min=-4.0  phys_max=4.0  digi_min=-2000  digi_max=2000  gain=0.00200000  samples_per_rec=1
  label='AmbHumidity.50'                dim='mg/L'      phys_min=0.0  phys_max=100.0  digi_min=0  digi_max=1000  gain=0.10000000  samples_per_rec=1
  label='HumTemp.50'                    dim='Celsius'   phys_min=0.0  phys_max=100.0  digi_min=0  digi_max=1000  gain=0.10000000  samples_per_rec=1
  label='HTubeTemp.50'                  dim='Celsius'   phys_min=0.0  phys_max=40.0  digi_min=0  digi_max=400  gain=0.10000000  samples_per_rec=1
  label='HTubePow.50'                   dim='%'         phys_min=0.0  phys_max=100.0  digi_min=0  digi_max=1000  gain=0.10000000  samples_per_rec=1
  label='HumPow.50'                     dim='%'         phys_min=0.0  phys_max=100.0  digi_min=0  digi_max=1000  gain=0.10000000  samples_per_rec=1
  label='SpO2.50'                       dim='%'         phys_min=0.0  phys_max=100.0  digi_min=0  digi_max=100  gain=1.00000000  samples_per_rec=1
  label='SpO2.95'                       dim='%'         phys_min=0.0  phys_max=100.0  digi_min=0  digi_max=100  gain=1.00000000  samples_per_rec=1
  label='SpO2.Max'                      dim='%'         phys_min=0.0  phys_max=100.0  digi_min=0  digi_max=100  gain=1.00000000  samples_per_rec=1
  label='SpO2Thresh'                    dim='min.'      phys_min=0.0  phys_max=1440.0  digi_min=0  digi_max=1440  gain=1.00000000  samples_per_rec=1
  label='MaskPress.50'                  dim='cmH2O'     phys_min=0.0  phys_max=40.0  digi_min=0  digi_max=2000  gain=0.02000000  samples_per_rec=1
  label='MaskPress.95'                  dim='cmH2O'     phys_min=0.0  phys_max=40.0  digi_min=0  digi_max=2000  gain=0.02000000  samples_per_rec=1
  label='MaskPress.Max'                 dim='cmH2O'     phys_min=0.0  phys_max=40.0  digi_min=0  digi_max=2000  gain=0.02000000  samples_per_rec=1
  label='TgtIPAP.50'                    dim='cmH2O'     phys_min=0.0  phys_max=50.0  digi_min=0  digi_max=2500  gain=0.02000000  samples_per_rec=1
  label='TgtIPAP.95'                    dim='cmH2O'     phys_min=0.0  phys_max=50.0  digi_min=0  digi_max=2500  gain=0.02000000  samples_per_rec=1
  label='TgtIPAP.Max'                   dim='cmH2O'     phys_min=0.0  phys_max=50.0  digi_min=0  digi_max=2500  gain=0.02000000  samples_per_rec=1
  label='TgtEPAP.50'                    dim='cmH2O'     phys_min=0.0  phys_max=30.0  digi_min=0  digi_max=1500  gain=0.02000000  samples_per_rec=1
  label='TgtEPAP.95'                    dim='cmH2O'     phys_min=0.0  phys_max=30.0  digi_min=0  digi_max=1500  gain=0.02000000  samples_per_rec=1
  label='TgtEPAP.Max'                   dim='cmH2O'     phys_min=0.0  phys_max=30.0  digi_min=0  digi_max=1500  gain=0.02000000  samples_per_rec=1
  label='Leak.50'                       dim='L/s'       phys_min=0.0  phys_max=2.0  digi_min=0  digi_max=100  gain=0.02000000  samples_per_rec=1
  label='Leak.95'                       dim='L/s'       phys_min=0.0  phys_max=2.0  digi_min=0  digi_max=100  gain=0.02000000  samples_per_rec=1
  label='Leak.70'                       dim='L/s'       phys_min=0.0  phys_max=2.0  digi_min=0  digi_max=100  gain=0.02000000  samples_per_rec=1
  label='Leak.Max'                      dim='L/s'       phys_min=0.0  phys_max=2.0  digi_min=0  digi_max=100  gain=0.02000000  samples_per_rec=1
  label='MinVent.50'                    dim='L/min'     phys_min=0.0  phys_max=30.0  digi_min=0  digi_max=240  gain=0.12500000  samples_per_rec=1
  label='MinVent.95'                    dim='L/min'     phys_min=0.0  phys_max=30.0  digi_min=0  digi_max=240  gain=0.12500000  samples_per_rec=1
  label='MinVent.Max'                   dim='L/min'     phys_min=0.0  phys_max=30.0  digi_min=0  digi_max=240  gain=0.12500000  samples_per_rec=1
  label='RespRate.50'                   dim='bpm'       phys_min=0.0  phys_max=90.0  digi_min=0  digi_max=450  gain=0.20000000  samples_per_rec=1
  label='RespRate.95'                   dim='bpm'       phys_min=0.0  phys_max=90.0  digi_min=0  digi_max=450  gain=0.20000000  samples_per_rec=1
  label='RespRate.Max'                  dim='bpm'       phys_min=0.0  phys_max=90.0  digi_min=0  digi_max=450  gain=0.20000000  samples_per_rec=1
  label='TidVol.50'                     dim='L'         phys_min=0.0  phys_max=4.0  digi_min=0  digi_max=200  gain=0.02000000  samples_per_rec=1
  label='TidVol.95'                     dim='L'         phys_min=0.0  phys_max=4.0  digi_min=0  digi_max=200  gain=0.02000000  samples_per_rec=1
  label='TidVol.Max'                    dim='L'         phys_min=0.0  phys_max=4.0  digi_min=0  digi_max=200  gain=0.02000000  samples_per_rec=1
  label='AHI'                           dim=''          phys_min=0.0  phys_max=240.0  digi_min=0  digi_max=2400  gain=0.10000000  samples_per_rec=1
  label='HI'                            dim=''          phys_min=0.0  phys_max=240.0  digi_min=0  digi_max=2400  gain=0.10000000  samples_per_rec=1
  label='AI'                            dim=''          phys_min=0.0  phys_max=240.0  digi_min=0  digi_max=2400  gain=0.10000000  samples_per_rec=1
  label='OAI'                           dim=''          phys_min=0.0  phys_max=240.0  digi_min=0  digi_max=2400  gain=0.10000000  samples_per_rec=1
  label='CAI'                           dim=''          phys_min=0.0  phys_max=240.0  digi_min=0  digi_max=2400  gain=0.10000000  samples_per_rec=1
  label='UAI'                           dim=''          phys_min=0.0  phys_max=240.0  digi_min=0  digi_max=2400  gain=0.10000000  samples_per_rec=1
  label='RIN'                           dim=''          phys_min=0.0  phys_max=240.0  digi_min=0  digi_max=2400  gain=0.10000000  samples_per_rec=1
  label='CSR'                           dim=''          phys_min=0.0  phys_max=1440.0  digi_min=0  digi_max=1440  gain=1.00000000  samples_per_rec=1
  label='Crc16'                         dim=''          phys_min=-32768.0  phys_max=32767.0  digi_min=-32768  digi_max=32767  gain=1.00000000  samples_per_rec=1

=== STR.edf Parsed STRRecord (most recent active day) ===
  date:          2026-07-17
  mask_on:       [1784358960, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
  mask_off:      [1784390340, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
  mask_duration: 523.0
  first mask_on decoded: 2026-07-18T01:16:00
  ahi:           0.8
  oai:           0.6000000000000001
  cai:           0.0
  hi:            0.1
  uai:           0.0
  csr:           0.0
  leak_50:       6.0
  leak_95:       18.0
  leak_max:      27.6
  mp_50:         0.0
  mp_95:         0.0
  mp_max:        0.0
  min_pressure:  0.0
  max_pressure:  0.0
  set_pressure:  0.0
  rms9_mode:     1
  epr:           -1
  epr_level:     -1

=== DATALOG Directory Structure ===
  20260426/
    20260427_003454_CSL.edf
    20260427_003454_EVE.edf
    20260427_003509_BRP.edf
    20260427_003509_PLD.edf
    20260427_003509_SA2.edf
    20260427_003537_CSL.edf
  20260427/
    20260428_011320_CSL.edf
    20260428_011320_EVE.edf
    20260428_011333_BRP.edf
    20260428_011333_PLD.edf
    20260428_011333_SA2.edf
  20260428/
    20260428_235019_CSL.edf
    20260428_235019_EVE.edf
    20260428_235025_BRP.edf
    20260428_235025_PLD.edf
    20260428_235025_SA2.edf
  20260429/
    20260430_002030_CSL.edf
    20260430_002030_EVE.edf
    20260430_002037_BRP.edf
    20260430_002037_PLD.edf
    20260430_002037_SA2.edf
  20260430/
    20260501_005504_CSL.edf
    20260501_005504_EVE.edf
    20260501_005509_BRP.edf
    20260501_005509_PLD.edf
    20260501_005509_SA2.edf
    20260501_005528_CSL.edf

=== Sample DATALOG File Signal Labels ===
  File: DATALOG\20260426\20260427_003454_CSL.edf
    'EDF Annotations'  (dim='')
    'Crc16'  (dim='')

2026-07-30 — Phase 40: SD Card CPAP Import

Added direct ResMed AirSense 11 SD card ingest as an alternative/supplement to the OSCAR connector, gated behind a package-legitimacy checkpoint (cpap-py 1.0.0, confirmed correct after a stale doc pointed at the yanked 0.1.0). A python sync.py sdcard subcommand (with --path override) and SD-first ordering in cmd_run() both fetch via connectors/cpap_sdcard.py, normalize via normalize_cpap_sdcard_row(), and persist through the existing upsert_cpap() — no new upsert variant — so SD card and OSCAR data coexist safely under the Phase 38 column-level upsert guards. Code review of the finished phase caught two real bugs before they shipped: the BUILDLOG probe entry documenting field names self-contradicted itself (narrative claimed mp_50/95/max pressure fields were reliable while the pasted raw probe output showed 0.0 for all three), which combined with upsert_cpap()‘s first-insert-wins pressure columns would have permanently locked bad 0.0 pressure data in the database with no way for OSCAR to ever backfill it — fixed by collapsing falsy pressure readings to None, matching the existing uai treatment, with the BUILDLOG entry corrected in place rather than silently rewritten. Separately, cmd_sdcard()‘s pipeline lacked the try/except wrapping used everywhere else in sync.py, so a missing cpap-py install would have crashed the dedicated subcommand instead of degrading non-fatally as designed. Both fixes, plus two lower-severity findings (a sync-log date regression when OSCAR ran after the SD card, and unescaped quote characters in the config-writer), landed with dedicated regression tests. 395 tests pass (6 pre-existing, unrelated PDF-builder failures excluded).

2026-06-29 — Phase 39: Schema Migrations

Added the schema foundation for the v5.0 Polish & Completeness milestone’s upcoming Settings History and Mask Tracking UI pages. Two new SQLite tables — cpap_settings_log (append-only settings audit log with auto-timestamped changed_at) and mask_log (per-night mask name with last-write-wins conflict semantics) — were added to init_db() as CREATE TABLE IF NOT EXISTS for idempotency. The nightly_summary VIEW was extended with m.mask_name via LEFT JOIN mask_log while deliberately keeping mask_log out of the all_dates CTE UNION spine — a phantom-row guard that prevents mask-only nights from corrupting every downstream metric query. Loader functions insert_settings_change() and upsert_mask_log() use ? positional bind parameters throughout (no SQL string interpolation). Built TDD-first: 8 RED test stubs committed before implementation, all 8 GREEN after. Full suite: 362 passing, zero regressions. Phases 41 and 42 can now be coded against a stable schema contract.

2026-06-29 — Phase 38: Bug Fixes

Two data-correctness bugs closed. BUG-01: the AI payload’s weekly_ahi list previously used iso_year/iso_week keys, causing Claude to misread ISO calendar weeks as therapy weeks. A new _therapy_week_aggregate() helper computes week_of_therapy as a 1-indexed integer from the CPAP start date; the existing _weekly_aggregate() (used for pressure/leak aggregates) was left untouched so those entries remain ISO-keyed. BUG-02: upsert_cpap() used INSERT OR IGNORE, silently discarding OSCAR 2.0 rows that conflicted with EDF-sourced nights and making leak rate impossible to backfill. Rewritten as INSERT ... ON CONFLICT(sleep_date) DO UPDATE SET with CASE WHEN guards on leak_median and leak_95 only — AHI, pressure, event, and session columns are protected from overwrite. Together these fixes mean re-running sync.py after upgrading to OSCAR 2.0 will backfill leak data correctly, and Claude will reason about therapy weeks rather than calendar weeks. 373 tests pass.

2026-06-28 — Phase 37: Doctor Export

Added a “For Your Doctor” section at the top of the Export & Reports page, surfacing two clinician-ready downloads built entirely behind testable, Streamlit-free modules. get_symptom_history_csv() (TDD, 5 tests) serializes the full symptom log with severity integers mapped to human-readable labels and logged_at excluded. build_suggestions_report() (TDD, 9 tests) produces a text-only PDF with a dual clinician disclaimer, triggered suggestions or stable notice, a Threshold Sources section transcribed verbatim from utils/suggestion_engine.py constant comments (AASM 2012 for AHI, PLACEHOLDER for the four uncited thresholds), and an optional AI nuance section — keeping the builder fully Streamlit-free so it remains independently testable. An fpdf2 2.8.x cursor-drift bug in _add_threshold_sources (back-to-back multi_cell calls defaulting new_x to END) was caught and fixed during the GREEN phase. The page wiring gates both buttons on symptom_df.empty, passes an empty SuggestionResult when prescribed settings are absent rather than crashing, and was human-verified on the running app. 372 tests pass.

2026-06-25 — Phase 36: Symptom Correlation Charts

Added two symptom-correlation charts to the top of the Symptoms page, completing the v4.0 closed-loop feedback milestone’s visualization layer. pages/symptom_charts.py was built TDD-first: 13 RED tests locked the builder contracts before a single line of implementation was written, then both builders passed on the first implementation attempt. “Symptom Category Trends” renders 5 colored severity lines over a 60-day date-window filter (not a count-based .head()) so non-consecutive logging is handled correctly. “Symptom Burden vs. Sleep Quality” is a dual-axis chart using an outer merge on normalized sleep_date so nights present in symptom data but absent from nightly_summary render as gaps rather than interpolated values. The 14-entry gate for Chart 2 uses the full symptom history count (not the windowed subset) — this ensures users with 14+ total entries see the chart even if most are older than 60 days. Both charts are wired into pages/10_Symptoms.py above the log form with a single module-scope get_nightly_df() read following the CLAUDE.md one-read-per-page rule. 359 tests pass.

2026-06-23 — Phase 35: AI Nuance Layer

Added a “Get AI Explanation” button to the Symptoms page that asks Claude to explain, in plain language, why the Phase 34 rules engine fired the suggestions and contradictions it did. generate_nuance(payload) in utils/ai_client.py is a new, separate Claude API call (zero Streamlit imports, same client/model as generate_analysis()) that returns raw markdown text — no JSON parsing or Pydantic validation, since the response is prose rather than structured data. The result caches into its own single-row suggestion_nuance_cache table via get_cached_nuance() in queries/suggestions.py, deliberately undecorated (no @st.cache_data) since the row changes on every Generate/Regenerate and a TTL cache would mask fresh writes. Staleness is computed inline — MAX(sleep_date) FROM symptom_log compared against the cached sleep_date — and surfaces as a warning above the cached explanation when a newer symptom night has been logged since generation. The system prompt (prompts/ai_nuance_system_prompt.txt) enumerates all 10 suggestion-engine rules with their AASM/PLACEHOLDER-labeled thresholds so Claude reasons from the same numbers the rules engine used, not its own training data. Built TDD-first: 7 RED tests locked the generate_nuance() and get_cached_nuance() contracts before implementation. 346 tests pass.

2026-06-22 — Phase 34: Settings Baseline + Rules Engine

Added a prescribed CPAP Settings section (min/max pressure, EPR level) to the Settings & Context page, persisted to user_settings and pre-populated on reload. The core of this phase is utils/suggestion_engine.py — a 10-rule deterministic evaluator (zero Streamlit imports, fully unit-tested) that takes the last 7 nights of CPAP + symptom data plus the prescribed settings and returns a SuggestionResult with fired suggestions, detected contradictions, and an is_stable flag. Two rules can fire in direct conflict (e.g., recommending both a pressure increase and a pressure decrease); the engine detects these pairs, suppresses them from the suggestion list, and flags the contradiction explicitly rather than showing the user contradictory advice. Threshold constants are named and source-commented per CLAUDE.md’s verifiable-sources rule: the AHI threshold cites AASM directly, while four thresholds (pressure ceiling/floor margins, high leak, CA fraction) are marked PLACEHOLDER with “discuss with prescriber” copy since no clinical citation was available. queries/suggestions.py wraps the engine with @st.cache_data(ttl=300). This phase lays the groundwork Phase 35 builds on: the rules engine’s output becomes the input to the AI nuance explanation.

2026-06-21 — Phase 33: Symptom Log UI

Added the Symptom Log page (pages/10_Symptoms.py) — the morning entry point of the v4.0 closed-loop feedback flow. The page records five severity categories (Mask issues, Dry mouth / throat, Sleep disruption, Aerophagia, Air starvation) plus an optional freeform note, wired to the Phase 32 symptom_log table via upsert_symptom_log(). Decision logic was extracted into a pure-Python utils/symptom_form.py helper module (zero Streamlit imports) so that severity mapping, pre-population index lookup, note truncation, and date-default behavior could be unit-tested directly — 8 TDD tests in RED/GREEN sequence before the page was written. The page follows the write-path-exception pattern from Settings: cached read via queries/symptoms.get_symptom_log_df(), write via loaders/db.upsert_symptom_log(), cache-clear + st.rerun() on success. Date picker defaults to yesterday per the noon-boundary convention; selecting today shows an amber warning; selecting a past date auto-populates the form for editing. Registered in app.py navigation at index 6, between Settings & Context and AI Analysis. 317 tests pass.

2026-06-17 — Phase 31: AI Narrative PDF Export

Added build_narrative_pdf() to utils/pdf_builder.py — a standalone PDF entry point that produces a lightweight document containing only the clinician disclaimer and the 5 AI sections (Executive Summary, Treatment Efficacy, Sleep Quality with wearable caveat, Confounders, Remission Outlook), with no charts, summary statistics, or data-accuracy sections. The _add_ai_narrative() helper gained an include_executive_summary: bool = False flag to support the new path while leaving build_treatment_report() output byte-for-byte unchanged. A “Build Narrative PDF” / “Download Narrative PDF” block was added to the bottom of the AI Analysis page; the button is disabled when no cached analysis exists, and both the filename and in-PDF date reflect the analysis creation date (generated_at) rather than today. Code review surfaced a stale-session-state bug (CR-02) where regenerating the analysis didn’t clear the cached PDF bytes — fixed with a one-line session_state.pop before st.rerun(). Built TDD-first: 7 RED tests committed before implementation, all 7 GREEN after. 298 tests pass.

2026-06-16 — Phase 30: Travel Period Tracking

Added travel period tagging to the Settings & Context page and wired it into alert suppression. A new travel_periods table (with write helpers and a cached queries/travel.py reader) backs a Settings UI section for adding/removing date ranges with an optional destination label. utils/trend_math.py gained _load_travel_dates() and _window_is_travel(), which suppress AHI/HRV/Score alerts before the detector runs and filter weight stall/reversal alerts independently after their own windows are computed, since those two sub-metrics use differently-shaped lookback windows. Code review caught two real bugs before this shipped: a crash in the Settings form when a user selected only one end of the date range, and an off-by-one boundary error that let a travel period landing exactly on the 28-day/14-day weight window edge silently fail to suppress — both fixed with a regression test added for the boundary case. 290 tests pass.

2026-06-14 — Phase 27: PDF Treatment Report

Added a clinician-facing PDF treatment report to the Export & Reports page. The “Generate PDF Report” button (lazy — runs only on click, cached in session_state) calls utils/pdf_builder.build_treatment_report(), which uses fpdf2 + kaleido to assemble a multi-page PDF: title block with therapy duration; summary stats table; a Data Sources & Accuracy Limitations section with specific disclosures on wrist SpO2 accuracy (+/-2-4 pp vs. fingertip, no Garmin clinical validation) and sleep staging PSG agreement (~40-50%); the cached AI narrative with the “Sleep Quality” section explicitly labeled as wearable-derived; four chart images rendered B&W via kaleido with consistent axes and date-gating (CPAP charts start at CPAP start, sensor charts start at the configured baseline); and an alerts section. Chart images were fixed to include all axis labels (dark-theme white-on-black text made invisible on white background), remove duplicate Plotly titles, and tighten margins. 276 tests pass.

2026-06-14 — Phase 26: Export Page & CSV Downloads

Added a new “Export & Reports” page (pages/09_Export.py) as the eighth dashboard entry, registered in st.navigation between AI Analysis and Alert History. Four st.download_button calls let the user download date-stamped CSVs of the nightly_summary view and the three per-source raw tables (cpap_sessions, garmin_sleep, withings_measurements). Data flows through a new queries/export.py module — four @st.cache_data(ttl=300) functions delegating to the existing shared WAL connection singleton, so no second DB connection is opened. df.to_csv(index=False) on an empty DataFrame already yields a header-only CSV, eliminating the need for any empty-table guard. This is the CSV foundation for Phase 27, which will add a PDF treatment report to the same page.

2026-06-13 — Phase 25: AI Analysis Page

Added the AI Analysis page (pages/07_AI_Analysis.py) — the final piece of v3.0 AI Treatment Intelligence. On button click, the anonymized get_ai_payload() dict is sent to claude-sonnet-4-6 via a single blocking API call; the system prompt embeds all six REQUIREMENTS.md-cited clinical benchmarks so the model reasons against verified facts rather than training data. The response is Pydantic-validated (4 required non-empty string keys), cached in a new ai_analysis_cache SQLite table (single-row upsert), and displayed in four ordered clinical sections: Treatment Efficacy, Sleep Quality Trajectory, Confounding Variables, Remission Outlook. Cache hits load without an API call; Regenerate overwrites. A static st.info disclaimer and ANTHROPIC_API_KEY guard (disables the button when unset) round out the safety boundaries. Two bugs surfaced during human verification and fixed inline: st.cache_resource in this Streamlit version does not accept check_same_thread=False, and get_ai_payload() returns datetime.date objects requiring a custom JSON encoder. Code review added pydantic to requirements.txt and upgraded the missing-key error to an explicit RuntimeError handler. 268 tests pass.

2026-06-13 — Phase 24: AI Data Extraction Layer

Built queries/ai_analysis.py — the pure data-extraction module that assembles the anonymized clinical payload Phase 25 will send to the Claude API. _build_payload() produces four sections: treatment_efficacy (ISO-week AHI/HRV/pressure/leak aggregates, first-2-vs-last-2-week trend deltas), sleep_quality (per-night HRV, deep%, REM%, SpO2 nadir, body battery), remission_outlook (weight trajectory, kg-to-AASM/SURMOUNT thresholds, dose tier), and confounders (bidirectional altitude deviation flags with direction; per-arc dose-period summaries; CPAP interruption nights). The altitude section is omitted when home elevation is unknown — a PII-adjacent edge case tested explicitly. A 13-test TDD suite locked the full payload contract in RED before a line of aggregation logic was written, then Wave 2 turned all 13 GREEN. Code review surfaced a crash-risk bug (unguarded float() cast on the home altitude setting) and two NaN-handling issues — targeted for fix before Phase 25 ships. 249 tests pass.

2026-06-12 — Phase 23: Altitude Visualization

Added secondary-axis altitude overlays to the AHI Trend (Treatment Overview) and CAI Trend (TECSA Watch) charts — a thin sage-green altitude line on the right axis makes travel nights visible as elevation deltas next to clinical events. A dashed home-baseline reference line appears when a home elevation is set. Both charts degrade gracefully: null-altitude nights render as gaps, and the single-axis path is byte-for-byte unchanged when no altitude data is present. A new user_settings(key TEXT PRIMARY KEY, value TEXT) table with upsert_setting() / get_setting() handles home elevation persistence and is intentionally reusable for Phase 25 AI settings. Notable bug caught in review: Plotly 6.x bleeds overlaid-axis data into the primary axis autorange; fixed by pinning the left axis range explicitly from AHI/CAI data maxima. Built TDD-first: 11 RED tests committed before any implementation, all 11 GREEN after Wave 1. 236 tests pass.

2026-06-11 — Phase 22: Altitude Data Layer

Added altitude_m REAL to the garmin_sleep table — the storage prerequisite for altitude confounding analysis in the upcoming AI Treatment Intelligence phase. A live Garmin API probe confirmed the altitude source: get_user_summary()averageMonitoringEnvironmentAltitude (day-wide scalar returning ~-14 m at home). The idempotent ALTER TABLE migration guard follows the Phase 8 min_spo2 pattern and runs before the nightly_summary VIEW is recreated. The fetch_garmin_sleep() connector was extended with a per-night Option B fetch wrapped in try/except — altitude failure yields NULL without aborting the sleep row. Historical altitude backfills automatically via sync.py backfill with zero sync.py changes. Built TDD-first: 9 RED tests from a Nyquist scaffold, 8 turned GREEN when the schema landed, all 9 GREEN when the connector was wired. 225 tests pass.

2026-06-10 — Phase 21: Clinical Context Annotations

Added four evidence-based clinical context elements across three pages as additive-only annotations. TECSA Watch gains a persistent header citing 67% spontaneous TECSA resolution within 4–8 weeks, drops the redundant tier-1 banner, and guards the IEI histogram behind a 50+ CA events threshold; the CAI chart legend is relabeled to “TECSA concern threshold (ICSD-3)”. Sleep Quality adds a Garmin accuracy disclaimer (~40–50% vs. PSG) and a three-state CPAP therapy progress note that shows green (st.success) during the evidence-based HRV improvement window (weeks 6–12). The Weight vs. AHI chart gets a _add_weight_milestone_lines helper drawing dashed 10%/20% body weight reduction targets on the secondary axis, labeled with AASM re-evaluation and SURMOUNT-OSA remission candidacy thresholds. All changes are zero-logic-impact on existing features; 216 tests pass unchanged.

2026-06-08 — Phase 19: OSCAR 2.0 SQLite Connector

Rewrote the CPAP data connector to read from OSCAR 2.0’s SQLite database (oscar.db) instead of the stale EDF cache, adding 10 new nights (34 → 44) and enabling rera_count data. Added _fetch_sessions_sqlite and _fetch_events_sqlite to connectors/oscar.py with dispatch on .db suffix; OSCAR 1.x EDF path is preserved with a deprecation warning. Extended normalize_oscar_row to compute ahi_rera = rera_count / duration_hours. Wired _detect_oscar_dir() to prefer the OSCAR 2.0 path and cmd_run() to resolve the oscar_db config key first. Built TDD-first with 5 RED stubs committed before implementation; all 5 turned GREEN. Code review flagged a noon-boundary bug in _fetch_events_sqlite (sleep dates for pre-noon sessions) and an ahi_rera = 0 vs None inconsistency for zero-duration guards — both targeted for fix in Phase 19 gap closure. 218 tests pass.

2026-06-03 — Phase 17: TECSA Pattern Analysis

Added a TECSA Pattern Analysis page (pages/09_TECSA.py) that computes and visualizes central apnea periodicity to detect Treatment-Emergent Central Sleep Apnea and Cheyne-Stokes Respiration signatures. The data layer (queries/tecsa.py) computes nightly CAI, IEI inter-event intervals, and a 5-tier classification; the chart layer (pages/tecsa_charts.py) builds four pure go.Figure functions testable without Streamlit. Built entirely TDD-first across 4 waves: 20 RED stubs locked all contracts before any implementation, then each wave turned them GREEN. Code review caught three bugs fixed before phase completion: a division-by-zero in the IEI CV calculation when all band events share identical timestamps, a stale run-start index in the Tier 2 consecutive-nights promotion loop, and a leak filter source disagreement between the nightly query and the per-date IEI query. 213 tests pass.

2026-06-02 — Phase 16: Per-Night Event Swimlane

Added Chart 7 “Nightly Event Pattern” to the Pressure Analytics page — a per-night swimlane showing OA/CA/H events as colored vertical bar marks at their exact time-of-night position (x-axis: minutes from midnight of sleep night, range -60 to +480; y-axis: one row per CPAP night, categorical ascending). Built TDD-first: 7 RED test stubs locked the behavioral contract before any implementation, then the GREEN implementation satisfied all 7. Two bugs surfaced during human verification: the midnight reference needed to be sleep_date+1 (CPAP sessions span into the next calendar morning), and line-ns Plotly markers required explicit marker.line.width=2 for browser visibility. 193 tests pass.

2026-06-01 — Phase 14: Fatigue Signal

Added a Fatigue Signal page that closes the loop between CPAP event control and real daytime recovery. A root-cause fix to the Garmin connector (BATT-03) corrected the body battery extraction from bodyBatteryChange (wrong field, always null) to sleepBodyBattery with live API shape correction ({value, startGMT} dicts), enabling backfill of 118 nights of body battery data across Feb–May 2026. Two chart builders were implemented TDD-first in pages/fatigue_charts.py: a morning battery trend with four recovery zone bands and 7-day rolling average (BATT-01), and a prior-night score vs. next-morning battery scatter using a date-aware lag merge that correctly handles missing-night gaps (BATT-02). The date-aware merge contract is locked at the test layer with a gap-fixture test that proves shift(-1) cannot be silently reintroduced. A code review catch fixed the OLS regression line from zigzagging (unsorted x) to correctly drawing left-to-right. 186 tests pass.

2026-05-31 — Phase 15: Event-at-Pressure Analysis

Added sub-nightly event resolution to the Pressure Analytics page. A new cpap_events SQLite table stores one row per apnea event (OA/CA/H/UA/Arousal) with concurrent pressure and leak readings attached via pd.merge_asof from the OSCAR EDF time-series cache. The fetch_cpap_events() connector runs non-fatally during sync.py run and uses a 10-second backward-direction tolerance window to match each event timestamp to its nearest preceding pressure/leak sample. A new Chart 6 — “Apnea Events at Moment of Occurrence” — renders as a scatter with leak_at_event on x and pressure_at_event on y, three color-coded series (OA/CA/H), and a dashed 24 L/min threshold line. Key finding from first render: all 580 events sit at 0–0.5 L/min leak, well below the 24 L/min threshold, supporting the real-respiratory-event hypothesis for the central apnea burden rather than the mask-leak artifact hypothesis. Both scatter charts now pin the x-axis to the data range to prevent vline placement from compressing data into a narrow slice.

2026-05-29 — Phase 12: Alert Surfaces

Three user-facing surfaces expose the Phase 11 trend detection engine in the running dashboard. A sidebar “Trend Alerts” badge renders on every page via app.py — hidden entirely when count is zero or fewer than 14 post-CPAP nights exist, so new users never see phantom urgency. A dedicated Alert History page (pages/06_Alerts.py) shows active and resolved alerts with humanized metric labels, date-only timestamps, and independent empty-state callouts per section. A Week-over-Week digest on the Nightly Score page adds four st.metric tiles with correct delta_color polarity (inverse for AHI/Weight, normal for Score/HRV) and sparse-safe weight handling. Key decisions: the digest and badge share the same 14-night guard to ensure both surfaces go live at the same data threshold; get_digest_stats was added to queries/trends.py TDD-first with 6 tests covering date-bounded windowing, per-metric independence, and empty-DataFrame edge cases. 150 tests pass.

2026-05-27 — Phase 11: Trend Detection Engine

Added automated trend detection to the sync pipeline. Four detectors run at the end of every sync.py run: AHI drift (current 7d mean >3.0 AND >50% above prior 7d), Nightly Score decline (≥8 point drop vs. prior 7d), HRV decline (>15% below prior 7d and 30d average, guarded until 30 CPAP nights), and weight stall/reversal (28d velocity plateau or 14d +0.5 kg gain). Results are written to a new alert_log SQLite table with a UNIQUE index on (metric, sync_date) for deduplication; a second pass auto-resolves open alerts when conditions clear. Score-decline alerts are suppressed when AHI or HRV alerts fired in the same run to prevent badge inflation from correlated signals. The compute layer lives in utils/trend_math.py with no Streamlit imports; queries/trends.py exposes the read side for Phase 12’s alert surfaces. Built TDD: 11 RED stubs first, then four implementation waves. 144 tests pass.

2026-05-26 — Phase 10: Nightly Score Page

Added a fifth Streamlit page that computes a composite 0–100 sleep health score for each post-CPAP night. The score weights AHI (35%), deep sleep % (25%), REM % (15%), SpO2 (15%), and HRV (10%) using a fixed denominator — missing components reduce the numerator but not the denominator, so the scale stays anchored at 100. Pre-CPAP nights produce NaN rather than zero, keeping the pre-therapy era absent from the trend. Built with a strict TDD red-green cycle across 4 sequential waves: 12 RED tests first, then constants in chart_config.py, then the pure pages/score_charts.py module (no st.* calls), then the Streamlit page wiring. A notable Plotly 6.x compatibility issue: add_hrect rejects xref="paper" — omitting the parameter lets Plotly default to xref="x domain", which renders correctly. 133 tests pass.

2026-05-26 — Phase 9: Weight & Medication Page

Added a fourth Streamlit dashboard page for weight and tirzepatide medication tracking. The page centers on a 14-day rolling weight loss velocity chart using a calendar-day indexed rolling window (not row count — important for sparse Withings data) that fits a linear slope over each 14-day window and converts to kg/week units. Dose tier background bands (add_vrect) mark each tirzepatide period using the six-tier boundary table in chart_config.py; the y-axis is inverted so downward motion reads as progress. A body composition chart tracks fat mass vs. lean mass separately. Three KPI tiles show current velocity, total change with a tirzepatide-era delta, and current dose tier. A key design decision: dose tier ffill runs on the full date range before the function returns — applying ffill after a date filter would silently drop tier context for periods where injections don’t align with weigh-in dates. 121 tests pass (108 existing + 13 new).

2026-05-25 — Phase 8: SpO2 Schema + Correlation Additions

Extended the garmin_sleep schema with a min_spo2 REAL column via safe ALTER TABLE migration (guard runs before the DROP/CREATE VIEW cycle so the column is available when nightly_summary is rebuilt). The Garmin connector now extracts lowestSpO2Value from the daily sleep DTO and passes it through upsert_garmin(). The Correlation Dashboard gains two new scatter charts: SpO2 nadir vs. AHI with an inverted y-axis and a dashed 90% clinical threshold line, and sleep stages quality vs. AHI with filled target bands for deep sleep (15–20%) and REM (18–22%). A notable Plotly 6.x compatibility fix: add_hrect ignores xref/x0/x1 parameters and appends " domain" to the reference, so those parameters were dropped and Plotly defaults to full chart width correctly. 108 tests pass.

2026-05-24 — v1.1: Treatment Context: Historical Baseline

Two phases shipped: a sync.py backfill command that loads 15 months of pre-CPAP Garmin and Withings data (monthly-chunked Garmin at 1 req/sec to avoid account lockout), and a full rewrite of nightly_summary from a CPAP-spine JOIN to a 3-source UNION CTE so pre-CPAP nights appear on all charts with NULL CPAP columns. The correlation charts now show the full Jan 2025 → present timeline — pre-CPAP data points at 40% opacity, post-CPAP at full opacity — with the baseline band overlay active on both weight vs. AHI and HRV vs. AHI charts when a baseline period is defined. Before/after comparison is now visually complete and anchored to real pre-treatment data.

2026-05-24 — Phase 7: Pre-CPAP Data Layer

Rewrote the nightly_summary VIEW to use a 3-source UNION CTE spine instead of cpap_sessions as the driving table — pre-CPAP nights (Garmin or Withings only, before therapy started 4/24/2026) now appear in every downstream query with NULL CPAP columns. Added pre/post CPAP visual split across both correlation chart builders (weight and HRV traces at 40% opacity for pre-CPAP data points) and wired the _add_baseline_band helper onto all 7 chart builders — the baseline band overlay built in Phase 5 is now active on every chart. Added a cpap_nights filter in 01_CPAP.py so the CPAP page anchors its date picker, KPI metrics, and all five chart builders to actual CPAP nights only, preventing pre-CPAP NULL rows from reaching charts that expect CPAP data. 78 tests pass; 11 code review findings noted for follow-up (3 critical, 5 warning, 3 info — see 07-REVIEW.md).

2026-05-18 — Project complete: all four phases shipped

Started this project because CPAP therapy data in isolation doesn’t tell the whole story. OSCAR shows AHI numbers but can’t correlate them against weight trends or overnight HRV — the two signals that matter most for whether tirzepatide-driven weight loss is actually moving the needle on apnea severity.

Built a four-phase local analytics tool from scratch: SQLite schema with a noon-boundary sleep date convention (sessions before noon belong to the previous night), CPAP ingest via oscar-etl reading OSCAR’s EDF cache directly (the standard CSV export silently drops leak rate), then Garmin Connect and Withings API connectors, and finally a Correlation Dashboard that puts all four data streams on the same timeline.

The correlation charts were the point of the whole thing — dual-axis Plotly charts overlaying AHI against weight and HRV, with tirzepatide injection markers color-coded by dose tier. A few non-obvious discoveries along the way: Plotly 6.7.0 breaks add_vline with string date arguments (requires Unix milliseconds), the Garmin API puts avgOvernightHrv at the top level of the sleep response rather than inside dailySleepDTO where you’d expect it, and garminconnect’s get_garmin_client() needs to create the token directory before calling login() or it silently falls back to credential re-auth — which can lock your account for 48 hours.

All 58 tests pass. The Garmin sync window is currently 3 days (a Phase 3 decision for rate-limit safety during development) — the next task is extending it to backfill all historical nights that have CPAP data but no Garmin row yet.

Scroll to Top