Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ src/snore/
│ ├── backup_service.py # Raw file backup
│ ├── database_service.py # DB stats + device listing (system/metadata)
│ ├── day_service.py # Day aggregation
│ ├── event_service.py # Event queries + matching
│ ├── event_service.py # Event queries
│ ├── export_service.py # Data export (CSV, JSON)
│ ├── lttb.py # LTTB downsampling algorithm
│ ├── session_service.py # Session management
Expand Down Expand Up @@ -372,11 +372,13 @@ running every `DEFAULT_TOOLS` entry with SQL capture.
**REST gating rule:** no default REST operation may return an Experimental-tagged field. Experimental
operations keep their URLs and carry the FastAPI `experimental` tag: at `include_router` for the
analysis and validation routers, per operation elsewhere (`GET /days/{date}/experimental`,
`GET /stats/experimental`, `/sessions/{id}/events/match`, `/sessions/{id}/waveforms/compare`).
`GET /stats/experimental`, `/sessions/{id}/waveforms/compare`).
`GET /days/{date}` does not touch analysis tables. `tests/unit/test_provenance_tags.py` enforces the
rule by walking every untagged operation's response schemas, and `EXPERIMENTAL_OPERATIONS` there pins
the tagged list, so tagging a route to escape the check fails until that list is edited too. HTML
reports have no response schema: `tests/unit/test_report_service.py` checks that the models a report
rule by walking every untagged operation's response schemas, and `EXPERIMENTAL_OPERATIONS` there, read
from `ui/src/tests/experimentalOperations.json`, pins the tagged list, so tagging a route to escape the
check fails until that list is edited too. `ui/src/tests/experimentalBoundary.test.ts` reads the same
file: every listed operation needs a wrapper in `ui/src/api/`, and default pages may not reference those
wrappers, `useValidationRuns` or `components/validation/*`. HTML reports have no response schema: `tests/unit/test_report_service.py` checks that the models a report
fetches carry no Experimental field.

CLI entry points (`cli/display`): `mark_field(label, Model, "field")` returns the label with the
Expand Down Expand Up @@ -590,7 +592,7 @@ Integration test seed helpers (`_make_profile`, `_make_device`, `_make_day_sessi
| Tune detection thresholds | `src/snore/analysis/modes/config.py` (DetectionModeConfig fields), validate with `validate_against_machine_events()` |
| Modify data models | `src/snore/parsers/unified.py` (data), `database/models.py` (ORM), use Pydantic; keep `test_schema_alignment.py` passing |
| Add waveform visualization | `src/snore/waveform/renderer.py` (ASCII/plotext), `inspector.py` (data loading) |
| Add API endpoint | `src/snore/api/routers/`, `api/schemas.py`, `api/deps.py`; `EXPERIMENTAL_OPERATIONS` in `tests/unit/test_provenance_tags.py` if it carries the `experimental` tag; then `just ui-generate-types` |
| Add API endpoint | `src/snore/api/routers/`, `api/schemas.py`, `api/deps.py`; `ui/src/tests/experimentalOperations.json` if it carries the `experimental` tag; then `just ui-generate-types` |
| Add/modify service | `src/snore/services/`, `services/schemas.py` |
| Frontend development | `ui/src/views/`, `ui/src/components/`, `ui/src/api/`, `ui/src/composables/`, `ui/src/utils/` |
| Regenerate UI API types | `just ui-generate-types` (`scripts/export_openapi.py` → `ui/src/types/generated.ts`) |
Expand Down
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ FastAPI application serving the same data as the CLI through HTTP endpoints. Lau
| profiles | `/profiles` | Profile CRUD |
| sessions | `/sessions` | List, detail, enable/disable, delete, bulk delete-preview |
| waveforms | `/sessions` | List types, get data (LTTB downsampling), compare events |
| events | `/sessions` | List, match machine vs programmatic |
| events | `/sessions` | List |
| analysis | (v1 prefix) | List status, get result, run, delete, batch analysis |
| stats | `/stats` | Summary, periods, trends, records, data range; experimental effectiveness band and AHI trend direction (`GET /stats/experimental`) |
| devices | `/devices` | List, detail |
Expand All @@ -342,7 +342,7 @@ FastAPI application serving the same data as the CLI through HTTP endpoints. Lau
| db | `/db` | Stats, vacuum; reset (local mode only) |
| validation | `/validate` | Batch validation report |

**Experimental operations:** operations that return SNORE's own heuristics carry the FastAPI `experimental` tag: everything on the analysis and validation routers (tagged at `include_router`), `GET /sessions/{id}/events/match`, `GET /sessions/{id}/waveforms/compare`, `GET /days/{date}/experimental` and `GET /stats/experimental`. No other operation may return an Experimental-tagged field. `tests/unit/test_provenance_tags.py` enforces this and pins the tagged list in `EXPERIMENTAL_OPERATIONS`.
**Experimental operations:** operations that return SNORE's own heuristics carry the FastAPI `experimental` tag: everything on the analysis and validation routers (tagged at `include_router`), `GET /sessions/{id}/waveforms/compare`, `GET /days/{date}/experimental` and `GET /stats/experimental`. No other operation may return an Experimental-tagged field. `tests/unit/test_provenance_tags.py` enforces this and pins the tagged list against `ui/src/tests/experimentalOperations.json`, which `ui/src/tests/experimentalBoundary.test.ts` uses to keep default pages off those operations.

**Key patterns:**
- Dependency injection: `db: Session = Depends(get_db)` for database sessions
Expand Down Expand Up @@ -382,7 +382,7 @@ Imports and analysis run as serialised background jobs to avoid SQLite write-loc
| DayService | Day aggregation and lookup |
| DemoService | Demo mode bootstrap — provisions the demo user, profile, and fixture session data for local evaluation |
| DeviceService | Device listing and per-device detail (usage summary, current settings, settings history) |
| EventService | Event queries and matching |
| EventService | Event queries |
| ExportService | Data export (CSV, JSON, raw files) |
| ImportService | CPAP data import: source detection, file upload, backup, parse orchestration |
| lttb (module) | Largest-Triangle-Three-Buckets downsampling via `lttb_downsample()` |
Expand Down
14 changes: 6 additions & 8 deletions src/snore/analysis/modes/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
_deduplicate_events,
_merge_adjacent_events,
_validate_event,
split_by_tolerance_match,
match_events_by_start_time,
validate_event_type,
)
from snore.analysis.modes.types import HypopneaMode, ModeResult
Expand Down Expand Up @@ -955,12 +955,10 @@ def validate_against_machine_events(
if has_machine_reras:
all_programmatic.extend(programmatic_reras)
all_machine.extend(machine_reras)
all_programmatic.sort(key=lambda e: e.start_time)
all_machine.sort(key=lambda e: e.start_time)

_, false_negative_events = split_by_tolerance_match(
all_machine, all_programmatic, tolerance_seconds
)
matched_events, false_positive_events = split_by_tolerance_match(
combined = match_events_by_start_time(
all_programmatic, all_machine, tolerance_seconds
)

Expand All @@ -984,9 +982,9 @@ def validate_against_machine_events(
"apnea_matches": apnea_matches,
"hypopnea_matches": hypopnea_matches,
"rera_matches": rera_matches,
"matched_events": matched_events,
"false_negative_events": false_negative_events,
"false_positive_events": false_positive_events,
"matched_events": [programmatic for programmatic, _ in combined.matched],
"false_negative_events": combined.false_negatives,
"false_positive_events": combined.false_positives,
"overall_agreement": {
"total_machine_events": total_machine,
"total_programmatic_events": total_programmatic,
Expand Down
48 changes: 3 additions & 45 deletions src/snore/analysis/modes/postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol
from typing import TYPE_CHECKING

import numpy as np

Expand All @@ -27,13 +27,6 @@
MatchableEvent = ApneaEvent | HypopneaEvent | RERAEvent


class HasStartTime(Protocol):
"""Anything one-to-one matching can pair: only ``start_time`` is read."""

@property
def start_time(self) -> float: ...


def _calculate_event_overlap(event1: ApneaEvent, event2: ApneaEvent) -> float:
"""
Calculate overlap ratio between two events.
Expand Down Expand Up @@ -240,7 +233,7 @@ def _merge_two_events(


@dataclass(frozen=True)
class MatchedEvents[E: HasStartTime]:
class MatchedEvents[E: MatchableEvent]:
"""
Outcome of greedy one-to-one matching of programmatic vs machine events.

Expand All @@ -255,7 +248,7 @@ class MatchedEvents[E: HasStartTime]:
false_negatives: list[E]


def match_events_by_start_time[E: HasStartTime](
def match_events_by_start_time[E: MatchableEvent](
programmatic: Sequence[E],
machine: Sequence[E],
tolerance_seconds: float = EVENT_MATCH_TOLERANCE_SECONDS,
Expand Down Expand Up @@ -317,41 +310,6 @@ def match_events_by_start_time[E: HasStartTime](
)


def split_by_tolerance_match(
events: Sequence[MatchableEvent],
references: Sequence[MatchableEvent],
tolerance_seconds: float = EVENT_MATCH_TOLERANCE_SECONDS,
) -> tuple[list[MatchableEvent], list[MatchableEvent]]:
"""
Split events into (matched, unmatched) by start-time proximity.

An event is matched if ANY reference event starts within tolerance
(references may match multiple events).

Args:
events: Events to classify
references: Reference events to match against
tolerance_seconds: Max start-time difference for a match

Returns:
Tuple of (matched events, unmatched events), preserving input order
"""
matched: list[MatchableEvent] = []
unmatched: list[MatchableEvent] = []

for event in events:
is_matched = any(
abs(event.start_time - ref.start_time) <= tolerance_seconds
for ref in references
)
if is_matched:
matched.append(event)
else:
unmatched.append(event)

return matched, unmatched


def validate_event_type(
programmatic: Sequence[MatchableEvent],
machine: Sequence[MatchableEvent],
Expand Down
34 changes: 1 addition & 33 deletions src/snore/api/routers/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@
from fastapi import APIRouter, Depends, Query

from snore.api.deps import service_dep
from snore.api.errors import NotFoundError
from snore.api.schemas import EventItem
from snore.database import models
from snore.services import AnalysisFacade, EventService
from snore.services.schemas import EventMatchResult
from snore.services import EventService

router = APIRouter()

EventServiceDep = Annotated[EventService, Depends(service_dep(EventService))]
AnalysisFacadeDep = Annotated[AnalysisFacade, Depends(service_dep(AnalysisFacade))]


def _event_to_item(event: models.Event, session_start: datetime) -> EventItem:
Expand All @@ -36,32 +33,3 @@ async def list_events(
) -> list[EventItem]:
events, session_start = await svc.list_session_events(session_id, event_type)
return [_event_to_item(e, session_start) for e in events]


@router.get(
"/{session_id}/events/match",
response_model=EventMatchResult,
tags=["experimental"],
)
async def match_events(
session_id: int,
svc: EventServiceDep,
facade: AnalysisFacadeDep,
mode: str = Query(default="aasm"),
) -> EventMatchResult:
machine_times = await svc.get_machine_apnea_hypopnea_times(session_id)

analysis = await facade.get_analysis_result(session_id)
if not analysis:
raise NotFoundError(f"No analysis results found for session {session_id}")

programmatic_times: list[float] = []
mode_result = analysis.mode_results.get(mode)
if mode_result is not None:
for apnea in mode_result.apneas:
programmatic_times.append(analysis.timestamp_start + apnea.start_time)
for hyp in mode_result.hypopneas:
programmatic_times.append(analysis.timestamp_start + hyp.start_time)
programmatic_times.sort()

return svc.match_events(machine_times, programmatic_times)
7 changes: 3 additions & 4 deletions src/snore/cli/groups/waveform.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,18 +366,17 @@ async def compare_events(

service = WaveformService(ctx.db, ctx.profile_id)
try:
comparison = await service.compare_events(
resolved_id, mode, tolerance_seconds=5.0
)
comparison = await service.compare_events(resolved_id, mode)
except NotFoundError as e:
raise click.ClickException(str(e)) from e

programmatic = mark_field(
"Programmatic", EventComparisonResult, "programmatic_event_count"
)
matched = mark_field("Matched", EventComparisonResult, "matched_events")
console.print(f"Session {resolved_id} - Event Comparison ({mode} mode)")
console.print(
f"Machine: {comparison.machine_event_count} events | {programmatic}: {comparison.programmatic_event_count} events"
f"Machine: {comparison.machine_event_count} events (apneas and hypopneas only) | {programmatic}: {comparison.programmatic_event_count} events | {matched}: {comparison.matched_events}"
)
console.print("")

Expand Down
73 changes: 3 additions & 70 deletions src/snore/services/event_service.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,21 @@
"""Event matching service for comparing machine vs programmatic detections."""
"""Event query service for device-scored session events."""

from __future__ import annotations

from datetime import datetime
from typing import Any

from pydantic import BaseModel
from sqlalchemy import select

from snore.analysis.modes.postprocess import (
EVENT_MATCH_TOLERANCE_SECONDS,
match_events_by_start_time,
)
from snore.constants import APNEA_TYPE_MAP, EVENT_TYPE_HYPOPNEA
from snore.database import models
from snore.exceptions import NotFoundError
from snore.services._base import ProfileScopedService, session_device_join
from snore.services.schemas import EventMatchResult

__all__ = [
"EVENT_MATCH_TOLERANCE_SECONDS",
"MATCHABLE_MACHINE_EVENT_TYPES",
"EventService",
]

# Device event types matched against programmatic apneas + hypopneas: the apnea
# types batch validation converts (``APNEA_TYPE_MAP``, incl. ResMed's CAA) plus H.
MATCHABLE_MACHINE_EVENT_TYPES: frozenset[str] = frozenset(
{*APNEA_TYPE_MAP, EVENT_TYPE_HYPOPNEA}
)


class _TimedEvent(BaseModel, frozen=True):
"""A bare timestamp shaped for ``match_events_by_start_time``."""

start_time: float
__all__ = ["EventService"]


class EventService(ProfileScopedService):
"""Service for event matching and comparison."""
"""Service for querying a session's device-scored events."""

async def _get_session_owned(self, session_id: int) -> Any:
"""Return the session if it belongs to this profile, else raise NotFoundError."""
Expand Down Expand Up @@ -73,47 +50,3 @@ async def list_session_events(
stmt = stmt.order_by(models.Event.start_time)
events = list((await self.db_session.execute(stmt)).scalars().all())
return events, session.start_time

async def get_machine_apnea_hypopnea_times(self, session_id: int) -> list[float]:
"""Return sorted start timestamps of a session's device apneas + hypopneas.

Only the types the programmatic side detects (``MATCHABLE_MACHINE_EVENT_TYPES``,
the apnea/hypopnea set batch validation compares): other device flags (FL,
VS, LL, PB, RE, ...) would otherwise take one-to-one matches.
"""

await self._get_session_owned(session_id)

start_times = (
await self.db_session.execute(
select(models.Event.start_time).where(
models.Event.session_id == session_id,
models.Event.event_type.in_(MATCHABLE_MACHINE_EVENT_TYPES),
)
)
).scalars()
return sorted(t.timestamp() for t in start_times)

@staticmethod
def match_events(
machine_times: list[float],
programmatic_times: list[float],
tolerance: float = EVENT_MATCH_TOLERANCE_SECONDS,
) -> EventMatchResult:
"""Match machine vs programmatic events one-to-one by start time.

Uses the batch-validation matcher, but on one pooled apnea + hypopnea
list rather than batch validation's separate per-type matches.
"""
result = match_events_by_start_time(
[_TimedEvent(start_time=t) for t in sorted(programmatic_times)],
[_TimedEvent(start_time=t) for t in sorted(machine_times)],
tolerance,
)
return EventMatchResult(
machine_count=len(machine_times),
programmatic_count=len(programmatic_times),
matched=len(result.matched),
false_positives=len(result.false_positives),
false_negatives=len(result.false_negatives),
)
Loading