Skip to content

feat(analysis): import-time breath persistence and two-phase import job - #156

Draft
wpfleger96 wants to merge 13 commits into
mainfrom
will/import-time-analysis
Draft

feat(analysis): import-time breath persistence and two-phase import job#156
wpfleger96 wants to merge 13 commits into
mainfrom
will/import-time-analysis

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Adds the substrate PR-B (MCP server) requires: a Breath ORM model atomically persisted at analysis time, versioned algorithm identity, typed service seams with a centralized device resolver and status reducer, and the two-phase import job lifecycle.

This branch grounds on main @ 22a5b13 (Phase 1 multiuser). No Alembic migration per ruling #4 — fresh DBs get the right schema; drop-and-reimport is the upgrade path.

Breath model + analysis persistence

  • database/models.py: Breath model (FK to analysis_results, CASCADE) with BreathMetrics, ShapeFeatures, peak_insp_flow_lpm, peak_exp_flow_lpm, mid_insp_flattening, inferred trigger/cycle (experimental), quality flags (leak_valid, ramp_active, mask_off); all timing/amplitude/shape fields nullable
  • analysis/service.py: store_result() atomically persists AnalysisResult + Breath rows in one transaction; host-TZ-independent timestamps; (created_at DESC, id DESC) selector at all three AnalysisFacade sites

BreathService seams — centralized device resolver and status reducer

  • DeviceAmbiguityError: structured error listing owned device IDs when a date-range query spans multiple profile-owned devices and no device_id was specified
  • _resolve_device(therapy_date, device_id): auto-selects when 1 device, raises DeviceAmbiguityError for ≥2, ValueError for 0
  • _fetch_day_sessions(device_id, therapy_date): all sessions for the resolved device/date
  • _reduce_day_status(coverages, identities): single-sourced pure status reducer — exact precedence: MIXED_VERSION → OK → NOT_RUN → STALE (all-stale only) → PARTIAL
  • Every date/range seam (find_windows, get_nightly_summary, compare_epochs, get_contextual_events, get_ca_analysis, get_waveform_window) goes through _resolve_device and _fetch_day_sessions; point seams enforce single-session on top
  • get_breath_table: verifies explicit session_id matches both profile/date AND resolved/requested device

Service seam correctness

  • get_nightly_summary: reads Day.total_therapy_hours (canonical) instead of summing Session.duration_seconds
  • get_nightly_range_summary: rejects reversed date ranges; divides compliance by n_calendar_nights
  • get_device_capabilities: rx_keys_present uses RX_KEYS from snore.analysis.rx_tracker; supported_vendor_models from new parser_registry.list_supported_models() (implemented in registry)
  • compare_epochs: RX homogeneity checked across EVERY contributing session/night BEFORE breath queries; refused epochs return null distributions; cross-epoch identity guard also runs before distributions
  • get_contextual_events: validates event_types (list of non-empty strings) and min_duration >= 0 at boundary; re-raises ValueError (corrupt blob); swallows only genuine channel-absent conditions
  • get_ca_analysis: uses _resolve_device + _fetch_day_sessions for split-night correctness; re-raises corrupt-blob ValueError; PB% computed from end_time - start_time of persisted episodes; MV variance uses intentional full-session cap; per-CA MV slope, PS (IPAP−EPAP), stability (CV) computed via waveform window seam
  • fetch_waveform_window_raw: profile_id required; joins Device in the first resolution query

primary_mode end-to-end

  • api/schemas.py, api/routers/analysis.py, cli/groups/analysis.py: primary_mode threaded through all layers with proper resolution and API boundary validation

Import pipeline

  • Two-phase import lifecycle; JobPhase enum; non-terminal phase_complete SSE; --no-analyze flag

Tests (1341 passing)

  • TestSameProfileTwoDevice (7 tests): DeviceAmbiguityError for multi-device nightly/windows/events/CA/waveform; explicit-device isolation; session/device mismatch rejection
  • TestSplitNight (2 tests): split-night (2 sessions, 1 device) contextual events and CA aggregation
  • TestStaleCoverageStateMachine (4 tests): all-stale → STALE; stale+not-run → PARTIAL (plan §1 line 864); stale-row exclusion from find_windows; CA events available on stale days
  • TestTwoProfileIsolation (12 tests): full adversarial matrix including ambiguity-payload exclusion (falsifiable against profile predicate), foreign analysis result, contextual events, CA analysis
  • TestContextualEventsInputValidation (3 tests): invalid event_types, empty string, negative min_duration
  • TestNightlyRangeDateValidation (1 test): reversed date range raises ValueError
  • A-suite (8 tests), vendor_applicability, deletion-cascade, missing-table, seam coverage, UTC timestamp tests — all plan-line citations added to status/precedence assertions

Boundary deviation — max_workers=1

cli/groups/analysis.py is outside the named boundary but required at all three analysis-invocation sites to prevent database is locked deadlocks under SQLite.

npub1rw3epj3u6w6mkg5cd70yqcjn4m6kwtdwz5j930uea2eq0drtlmns883sdj and others added 2 commits August 3, 2026 13:25
…e seams, two-phase import job

Implements PR-A of the multiuser MCP plan: the substrate required before
BreathService queries can be answered.

Key changes:

- Breath ORM model and migration: new breaths table with all per-breath
  columns (timing, flow features, trigger/cycle types, flatness_index).
  Written atomically in store_result() within the same analysis transaction.

- AlgorithmIdentity / AlgoVersions / AnalysisRunMetadata in versioning.py
  (all StrEnum; engine_versions_json uses nested {identity, run} shape).

- AnalysisComputation: new intermediate type returned by compute_for_session();
  decouples compute from DB write so analyze_session() handles all three
  phases (read, compute, write) behind one async boundary.

- run_batch_analysis(session_ids=...): when session_ids is provided, filters
  to exactly those rows instead of date range — used by the post-import hook.

- ensure_registered_parsers() in register_all.py: idempotent parser
  registration checked by parser ID; safe to call multiple times.

- --no-analyze CLI flag on snore import: skips analysis phase when set.
  When omitted, runs AnalysisFacade.run_batch_analysis(session_ids=...) on
  the freshly imported session IDs immediately after the import transaction
  commits.

- ImportResult.imported_session_ids (and per-source): session IDs thread
  from _import_single_session() through import_sessions_batch() up through
  ImportService.import_sources() so callers have the exact IDs to analyze.

- Two-phase import job (API path): _run_import() now calls phase_complete()
  after import commits (non-terminal milestone), then runs analysis.
  All terminal payloads produced after import commits carry import_committed
  and import_result — including analysis failure and cancellation.

- BreathService typed seam: all Appendix A types and stub method signatures;
  NotImplementedError bodies (PR-B fills them). Waveform helpers
  fetch_waveform_window_raw() and compute_waveform_window() implemented.

- 7 pinned two-phase job contract tests (test_import_two_phase.py).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
- Add test_import_session_ids.py: 4 unit tests pinning that
  import_sessions_batch returns correct DB Session.id values for new
  sessions, returns empty for skipped sessions, and that ImportResult
  correctly aggregates imported_session_ids from per-source batches.

- Add two e2e tests to test_import_options.py: --no-analyze skips the
  analysis phase (analysis show fails for session 1), and a default
  import (no --no-analyze) stores an analysis result at import time.

- Fix SQLite write concurrency: set max_workers=1 in all three sites
  that call run_batch_analysis (CLI import hook, API import router,
  analysis run CLI). SQLite tolerates only one concurrent writer;
  prior runs with max_workers=4 caused database-is-locked failures
  when store_result began writing Breath rows alongside analysis_results.

- Add --no-analyze to e2e conftest import_fixture helper so base
  fixtures don't pre-populate analysis results, keeping existing
  tests that assert on initial DB state deterministic.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title feat(analysis): import-time analysis substrate — Breath model, service seams, two-phase import feat(analysis): import-time breath persistence and two-phase import job Aug 3, 2026
npub1rw3epj3u6w6mkg5cd70yqcjn4m6kwtdwz5j930uea2eq0drtlmns883sdj and others added 11 commits August 3, 2026 14:02
…outers, and CLI

Add primary_mode field to AnalysisRunRequest and BatchAnalysisRequest
(api/schemas.py), pass it through both API router handlers, and wire it
through the CLI analysis run command with --primary-mode option.

- api/schemas.py: primary_mode: str | None on both request models
- api/routers/analysis.py: pass primary_mode to facade.run_analysis and
  run_batch_analysis
- cli/groups/analysis.py: --primary-mode option, thread through
  _analyze_single_session and _analyze_batch helpers; ValueError →
  ClickException translation in both helpers (max_workers=1 retained:
  SQLite tolerates only one concurrent writer; PostgreSQL callers can
  increase via the session_ids path — boundary deviation justified)
- analysis/service.py: add id DESC tie-breaker to latest-run selector

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…r to 422

Implement five BreathService methods that were stubbed NotImplementedError:

- _resolve_session_for_date: single/multi-session day disambiguation with
  MultiSessionAmbiguityError for ambiguous multi-device days
- _latest_analysis_for_session: (status, algo, ar_id) with tie-breaker
  ORDER BY created_at DESC, id DESC
- get_breath_table: paginated BreathRow or time-binned BreathBin aggregates;
  NOT_RUN / STALE_VERSION early-returns; CROSS_VERSION_REFUSAL_KEYS guard
- find_windows: three criteria (WORST_FLATTENING_LEAK_VALID, CA_CENTERED,
  FL_RUN_ENDING_IN_RECOVERY) with >50% overlap dedup and top-N selection;
  per-criterion options validation; mixed-version / partial-coverage handling
- compare_epochs: RX uniformity check (EpochRxViolation), ALGO_VERSION_MISMATCH
  refusal, PRIMARY_MODE_MISMATCH demotion, DistributionStats for four metrics
  (mid_insp_flattening, flatness_index, tidal_volume_ml, ie_ratio on leak-valid
  breaths), RERA proxy from FL-run-ending-in-recovery pattern

Also add ValueError → HTTPException(422) conversion in both analysis router
handlers (run_analysis and run_batch_analysis) so invalid primary_mode
arguments surface as 422 Unprocessable Entity rather than falling through to
the 500 handler.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… boundaries, and breath-write acceptance

Add two test files covering all pinned obligations from plan v3.8:

tests/unit/test_primary_mode_rejection.py (18 tests):
- _resolve_primary_mode: ValueError on out-of-modes primary_mode; ValueError
  when DEFAULT_MODE absent and primary_mode=None; valid member returned;
  DEFAULT_MODE used when present
- API single-session route: 422 on invalid primary_mode, facade delegated on valid
- API batch route: 422 on invalid primary_mode, non-422 on valid
- _compute_leak_valid boundaries: absent channel, empty array, overlap below/above
  threshold, nearest-neighbour at exactly 5s gap, just above 5s gap, 1Hz vs 25Hz
  sample rates, ramp_active=None compile-time assertion

tests/integration/test_breath_analysis_write.py (8 tests):
- A1 fresh import: Breath rows present after store_result; session_id correct
- A2 two re-analyses: two AnalysisResult rows retained; equal created_at
  tie-breaker selects highest id
- A3 atomic rollback: RuntimeError after AnalysisResult flush rolls back parent
  and all Breath children
- A4 non-UTC determinism: timestamps stored as naive UTC regardless of host tz;
  datetime.utcfromtimestamp epoch round-trip

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Implement five methods that raised NotImplementedError:

- get_nightly_summary: queries Day/Session rows, calls
  _latest_analysis_for_session per session, checks CROSS_VERSION_REFUSAL_KEYS
  across ok sessions (MIXED_VERSION on mismatch), computes FL metrics
  (median/95th/max) and RERA proxy (FL runs of >=2 ending in recovery breath)
  from Breath rows, derives compliance from total therapy hours

- get_nightly_range_summary: iterates date range calling get_nightly_summary,
  aggregates compliance stats into NightlyRangeSummary

- get_device_capabilities: queries session/waveform/event/setting tables for
  actual date coverage and distinct channels/event types/setting keys; uses
  getattr guard for parser_registry.list_supported_models() since that method
  may not be implemented by all registry versions

- get_contextual_events: fetches machine events enriched with session-level
  Statistics (pressure_mean, leak_mean) as per-event context approximation;
  per-moment waveform context deferred (NOT_AVAILABLE) to avoid full waveform
  deserialization

- get_ca_analysis: fetches CA events and computes periodic-breathing proxy as
  fraction of breaths within 60s of any CA event start

Type fixes: DayAnalysisStatus.STALE (not STALE_VERSION), MIXED_VERSION for
cross-version mismatch, NullReason.ALGO_VERSION_MISMATCH (not
CROSS_VERSION_MISMATCH); annotate fl_median/fl_95th/fl_max as float | None
before branch to satisfy mypy; remove unused total_duration variable.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…onable missing-table error, seam tests

- Gate trigger/cycle inference on device manufacturer: ResMed →
  APPLICABILITY_VALIDATED; any other vendor →
  APPLICABILITY_UNVALIDATED_DEVICE.  Adds device_manufacturer field to
  RawSessionBlobs/AnalysisInputs; load_session_inputs_raw fetches the
  Device row via a second query and threads manufacturer through to
  _build_computed_breaths.

- Per-session timing in CLI import analysis phase: monotonic clock with
  closure-based accumulator; progress line now shows
  'session: X.Xs, total: Y.Ys'.

- Actionable missing-breaths-table error in store_result(): catches
  OperationalError('no such table: breaths') and re-raises as
  RuntimeError with drop-and-reimport instructions.

- fetch_waveform_window_raw raises ValueError when an explicit
  session_id is supplied but not found, instead of returning an empty
  window silently.

- test_breath_service_seams.py: all 33 seam tests now pass.  Fixed
  invalid enum strings in ComputedBreath factory
  (inferred_trigger_type='patient'→'normal', inferred_cycle_type=
  'machine'→'normal', ramp_active_reason='ramp_settings_absent'→
  'not_available'); fixed CaDetail assertion (event_type does not exist
  on CaDetail → assert duration_seconds + session_id instead).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… breaths assertion

Three acceptance blockers from Paul's final list:

- Item 3 (A4): datetime.fromtimestamp in store_result was host-TZ-dependent.
  Fix: fromtimestamp(ts, tz=UTC).replace(tzinfo=None) — TZ-independent naive
  UTC regardless of process timezone.  Test drives real store_result() under
  os.environ["TZ"]="America/New_York" + time.tzset(), asserts stored
  timestamp equals datetime.utcfromtimestamp(epoch).  The old two A4 tests
  hand-built rows and could not fail; replaced with one test that can.

- Item 2: try/except wrapped self.db_session.add_all() which is in-memory
  registration and never raises OperationalError.  Fix: move flush() for
  breath rows inside the try block so the real DB-touching call is guarded.
  Test creates full schema, DROP TABLE breaths via sqlite3 (no monkeypatching),
  re-opens engine, calls store_result() — verifies RuntimeError with
  actionable message from the real SQLite error path.

- Item 1: e2e test_import_without_no_analyze_runs_analysis_phase only checked
  analysis show success; never asserted breath rows.  Fix: after import,
  sqlite3.connect(db).execute('SELECT COUNT(*) FROM breaths') must be > 0.
  Segmenter wiring break would now fail here.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…context, compliance denominator

Address all Thufir pass-1 CRITICAL and IMPORTANT findings:

CRITICAL — BreathService cross-profile read isolation
- Add profile_id to BreathService.__init__ (required arg).
- _resolve_session_for_date, get_breath_table, find_windows,
  compare_epochs, get_nightly_summary, get_analysis_status all join
  through Device.profile_id == self._profile_id.
- get_device_capabilities verifies device ownership; returns null/empty
  for foreign devices.
- fetch_waveform_window_raw accepts optional profile_id and enforces
  ownership when supplied.
- get_waveform_window passes self._profile_id through.
- 7 two-profile adversarial tests: foreign session/device IDs, date
  auto-selection, capabilities, find_windows, compare_epochs, waveform.

IMPORTANT — AnalysisFacade latest-run selector inconsistency
- analysis_facade.py: add id DESC tie-breaker to all 3 row_number()
  ORDER BY clauses (list_status, delete_latest, get_analysis_result).

IMPORTANT — compliance denominator
- get_nightly_range_summary: divide by n_calendar not n_nights.

IMPORTANT — waveform corruption swallowed as CHANNEL_ABSENT
- compute_waveform_window: re-raise ValueError (corrupt blob / sample
  mismatch); only unknown exceptions collapse to missing_channels.

IMPORTANT — contextual event values were session means not at-event
- get_contextual_events: use waveform window seam (pressure/leak ±5 s,
  MV over prior 120 s). null + NOT_AVAILABLE when channel absent.

IMPORTANT — CA analysis used proxy instead of persisted data
- get_ca_analysis: periodic_breathing_pct from persisted
  periodic_breathing_episodes (total duration / session duration * 100).
  MV rolling variance from MV waveform binned into 10-min windows.

IMPORTANT — breath-table zero-coercion and missing peak_exp_flow
- Add peak_exp_flow_lpm to models.Breath (nullable, no migration per
  ruling #4), ComputedBreath, and store_result().
- _build_computed_breaths threads peak_expiratory_flow through.
- BreathRow fields (timing, amplitude, shape, class) made nullable;
  get_breath_table passes None instead of or-0.0/or-1/or-False.

IMPORTANT — compare_epochs metrics filter and cross-epoch identity
- metrics filter: only requested DistributionMetric fields are computed;
  unrequested fields receive null DistributionStats.
- Cross-epoch CROSS_VERSION_REFUSAL_KEYS comparison added at return.

MINOR
- Replace datetime.utcfromtimestamp deprecation in TZ test with
  datetime.fromtimestamp(epoch, tz=UTC).replace(tzinfo=None).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…t access

Address remaining items from Thufir pass-1 finding #3 not covered by 153bb31:

get_nightly_summary: `analyzed == 0` inside `if not ok_sessions` was
tautologically true, always returning NOT_RUN for all-stale days.
Fixed: inspect session_coverages for any STALE_VERSION status.

find_windows breath helpers: _find_worst_flattening_windows and
_find_fl_run_windows skipped only `ar_id is None`, allowing stale
sessions' breath rows to contribute to results.
Fixed: also skip when `ar_status != AnalysisStatus.OK`.

get_ca_analysis: early return on non-OK analysis blocked CA events
even though events are stored at import time (event-anchored).
Fixed: always fetch and return CA events; map status to honest
day_status (STALE/NOT_RUN); gate pb_pct on ar_id not None.

Tests (4 new, TestStaleCoverageStateMachine):
- test_all_stale_nightly_summary_is_stale: day_status=STALE not NOT_RUN
- test_stale_session_excluded_from_find_windows_breath_rows: stale breath
  rows absent from WORST_FLATTENING results; day_status=STALE
- test_stale_and_not_run_find_windows_status_is_stale: mixed day is STALE
- test_ca_events_returned_when_analysis_stale: CA events present,
  day_status=STALE

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…e adversarial matrix

Item 1 — fetch_waveform_window_raw resolution query had no profile predicate.

The function accepted profile_id as Optional, checked ownership only after
resolution, and allowed foreign sessions to enter MultiSessionAmbiguityError
payloads.  Concrete leak: profile A (1 session) + profile B (1 session) on
same date → resolution saw 2 rows → ambiguity error exposed B's session_id,
start_time, and duration.

Fix: make profile_id required; join Device and add Device.profile_id == profile_id
in the first (resolution) query so foreign rows never enter resolution or ambiguity
payloads.  Three internal callers that omitted profile_id now pass self._profile_id.

Item 2 — adversarial matrix missing 4 categories from Thufir's corrective action.

Added to TestTwoProfileIsolation:
- test_ambiguity_payload_excludes_foreign_profile_session: A has 2 sessions,
  B has 1 on same date → ambiguity error payload contains only A's 2 sessions;
  B's session_id absent.  Fails without the Item-1 resolution-query fix.
- test_foreign_session_on_same_date_does_not_cause_ambiguity: A (1) + B (1)
  on same date → resolves cleanly to A's session.  Fails without the fix.
- test_foreign_analysis_result_not_returned_via_breath_table_by_date: B has
  10-breath analysis, A has 3-breath analysis on same date → A's get_breath_table
  by date returns exactly 3 rows.
- test_get_contextual_events_foreign_session_not_returned: A has 1 OA event,
  B has 2 CA events on same date → A's get_contextual_events returns only OA.
- test_get_ca_analysis_foreign_session_ca_events_not_returned: B has 2 CA
  events, A has none → A's get_ca_analysis returns empty ca_events.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…metrics, epoch refusal, capabilities, validation

Addresses all Thufir pass-2 CRITICAL and IMPORTANT findings.

CRITICAL:
- Add DeviceAmbiguityError raised when ≥2 devices have sessions on same date
- Add _resolve_device() centralizing profile-scoped device resolution
- Add _fetch_day_sessions() returning all sessions for a resolved device/date
- Add _reduce_day_status() pure reducer implementing 5-case precedence:
  mixed identities → MIXED_VERSION; all-OK → OK; all-NOT_RUN → NOT_RUN;
  all-STALE → STALE; anything else (incl. stale+not-run) → PARTIAL
- Update find_windows(), get_nightly_summary(), get_nightly_range_summary(),
  compare_epochs(), get_contextual_events(), get_ca_analysis() to use helpers

IMPORTANT #4 (status test):
- Rename test_stale_and_not_run_find_windows_status_is_stale to _is_partial
- Flip expectation to PARTIAL (stale+not-run → PARTIAL per plan §1 line 864)
- Add plan-line citation comments to all status/precedence assertions

IMPORTANT #5 (compliance):
- get_nightly_summary() reads Day.total_therapy_hours instead of summing Session.duration_seconds
- get_nightly_range_summary() validates date_end >= date_start

IMPORTANT #6 (capabilities):
- rx_keys_present uses RX_KEYS from rx_tracker (not CROSS_VERSION_REFUSAL_KEYS)
- Add list_supported_models() to ParserRegistry aggregating parser metadata
- get_device_capabilities() uses list_supported_models()

IMPORTANT #7 (epoch refusal):
- Move RX homogeneity check before any breath queries
- Check ALL contributing sessions per night (not just first session)
- Immediately refuse with null distributions when RX violation detected

IMPORTANT #8 (corruption propagation):
- get_contextual_events() and get_ca_analysis() narrow except-Exception catches
  to re-raise ValueError (corrupt blob) while swallowing absent-channel errors

IMPORTANT #9 (input validation):
- get_contextual_events() validates event_types (non-empty strings or None)
  and min_duration (>= 0 or None)

IMPORTANT #2 (CA metrics):
- Implement preceding_mv_slope via linear regression over prior 120s MV
- Implement ps_delivered_cmh2o via mean(THERAPY_PRESSURE - EPAP) over ±5s
- Implement stability_index as CV (std/mean) of MV over prior 120s
- Fix WaveformWindowRequest window_cap_seconds bypass for full-session MV fetch
- get_ca_analysis() and get_contextual_events() now aggregate across ALL sessions

New tests:
- TestSameProfileTwoDevice: DeviceAmbiguityError for methods without device_id
- TestSplitNight: contextual events and CA analysis return from both sessions
- TestContextualEventsInputValidation: invalid event_types and min_duration
- TestNightlyRangeDateValidation: reversed date range raises ValueError

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…am tests

Closes two acceptance gaps from ca05051:

Numeric provenance (TestCaNumericProvenance, 4 tests):
- test_ca_pb_pct_nonzero_from_known_episodes: episode end_time-start_time=360s
  in 3600s session → pb_pct=10.0% (asserts exact value, not just non-null)
- test_ca_mv_slope_nonzero_from_linear_ramp: MV=t (unit ramp) → slope≈1.0
- test_ca_ps_nonzero_from_known_pressures: THERAPY_PRESSURE=20, EPAP=8 → PS≈12.0
- test_ca_mv_variance_nonzero_from_two_distinct_bins: MV bins [5.0, 15.0] →
  variance=50.0 (1200s session, exactly two 600s bins)

Corrupt blob through public seams (TestCorruptBlobThroughPublicSeams, 2 tests):
- test_corrupt_pressure_blob_raises_in_contextual_events: corrupt pressure
  waveform → ValueError propagates, not silently NOT_AVAILABLE
- test_corrupt_mv_blob_raises_in_ca_analysis: corrupt MV waveform → same

Blob builder helper: _make_waveform_blob_from_arrays (caller-supplied arrays);
_make_corrupt_waveform_blob (3-byte string, not parseable as float32 pairs).
Lifted pattern from tests/unit/test_waveform_service.py:_make_waveform_blob.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant