Technical documentation for the SNORE system architecture, components, and design decisions.
┌─────────────────────────────────────────────┐
│ Vue 3 Frontend (ui/) │
│ Dashboard, Session Explorer, Waveforms │
│ PrimeVue components + uPlot charts │
├──────────────┬──────────────┬──────────────┤
│ FastAPI REST │ CLI (cli/) │ MCP Server │
│ API (api/) │ Click cmds │ (mcp/) │
│ 17 routers │ snore cmds │ 10 tools │
├──────────────┴──────────────┴──────────────┤
│ Service Layer (services/) │
│ 13 services: business logic between │
│ CLI/API and database │
├─────────────────────────────────────────────┤
│ Analysis Layer (Parser Agnostic) │
│ Breath segmentation, event detection, │
│ flow limitation, pulse change detection │
├─────────────────────────────────────────────┤
│ SQLite Database ✅ │
│ Universal schema, direct BLOB storage │
│ create_all schema; Alembic kept (pre-1.0) │
├─────────────────────────────────────────────┤
│ CLI Import Tool ✅ │
│ snore import (auto-detection) │
│ Raw backup, parallel parsing │
├─────────────────────────────────────────────┤
│ Unified Data Model │
│ UnifiedSession, WaveformData, etc. │
│ All parsers output to this format │
├─────────────────────────────────────────────┤
│ Parser Registry & Detection │
│ Auto-detects device from file structure │
│ Confidence-based selection │
├─────────────────────────────────────────────┤
│ Device Parser Plugins │
│ ┌──────────┬──────────┬──────────┬────────┐│
│ │ ResMed │ OSCAR │ Philips │ Future ││
│ │ EDF+ │ Binary │ (TODO) │ ││
│ │ ✅ │ ✅ │ │ ││
│ └──────────┴──────────┴──────────┴────────┘│
└─────────────────────────────────────────────┘
- Separation of Concerns: Analysis layer never knows about parser formats
- Extensibility: Add new device support without touching existing code
- Single Source of Truth: SQLite database stores unified format
- Auto-Detection: Users just point to data, it "just works"
- Testability: Each parser tested independently with real data
File: src/snore/parsers/unified.py
All parsers convert to these universal Pydantic structures:
UnifiedSession
- Device-agnostic session container
- Start/end times, duration, mode
- Waveforms dict (by type)
- Events list
- Statistics
- Quality notes
WaveformData
- Time-series data (Flow, Pressure, Leak, SpO2, Pulse)
- Timestamps as numpy array (seconds offset from session start)
- Values as numpy float32 array
- Sample rate, unit, min/max/mean statistics
RespiratoryEvent
- Event types: OA, CA, H, UA, RERA, FL, etc.
- Start time, duration
- Optional annotations (from EVE files)
DeviceInfo
- Manufacturer, model, serial number
- Firmware version
- Metadata
SessionStatistics
- Event counts (OA, CA, H, etc.)
- Indices (AHI, OAI, CAI, HI, REI)
- Pressure stats (min, max, median, p95)
- Leak stats
- Respiratory stats
- SpO2 stats (min, max, mean, time below 90%)
- Pulse stats
File: src/snore/parsers/base.py
DeviceParser (Abstract Base Class)
class DeviceParser(ABC):
@abstractmethod
def detect(self, path: Path) -> ParserDetectionResult:
"""Check if this parser can handle the data"""
@abstractmethod
def get_device_info(self, path: Path) -> DeviceInfo:
"""Extract device metadata"""
@abstractmethod
def parse_sessions(self, path: Path, ...) -> Iterator[UnifiedSession]:
"""Parse all sessions to unified format"""
@abstractmethod
def get_metadata(self) -> ParserMetadata:
"""Parser identification and capabilities"""Adding New Parser:
# File: src/snore/parsers/philips.py
class PhilipsParser(DeviceParser):
def detect(self, path):
return (path / "PXXXXXX").exists()
def get_device_info(self, path):
return DeviceInfo(manufacturer="Philips", ...)
def parse_sessions(self, path):
# Convert Philips format → UnifiedSession
yield unified_session
def get_metadata(self):
return ParserMetadata(parser_id="philips", ...)
# Auto-register
parser_registry.register(PhilipsParser())File: src/snore/parsers/registry.py
- Auto-detects device type from file structure
- Confidence-based parser selection
- Manufacturer hints (optional)
- Global singleton:
parser_registry
# Usage
parser = parser_registry.detect_parser(path)
for session in parser.parse_sessions(path):
# session is UnifiedSession
print(f"{session.start_time}: {session.duration_hours}h")File: src/snore/parsers/formats/edf.py
Generic EDF/EDF+ file reader for medical devices:
- Signal extraction with proper unit conversion
- Annotation parsing (for events)
- Header information extraction
- Reusable by any EDF-based parser
The analysis system performs programmatic respiratory event detection on imported CPAP sessions using configurable detection modes.
Architecture:
analysis/
├── service.py # Orchestration
├── types.py # AnalysisResult, AnalysisEvent
├── calculations.py # Metric calculations
├── rx_tracker.py # Prescription change tracking
├── utils.py # Analysis utilities
├── data/
│ └── waveform_loader.py # Database waveform loading
├── shared/ # Core algorithms
│ ├── breath_segmenter.py
│ ├── feature_extractors.py
│ ├── flow_limitation.py
│ ├── pattern_detector.py
│ ├── pulse_detector.py # Pulse change detection (NEW)
│ └── types.py
└── modes/
├── config.py # AASM_CONFIG, AASM_RELAXED_CONFIG, RESMED_CONFIG
├── detector.py
└── types.py
Config-Based Strategy Pattern:
- Single
EventDetectorclass configurable viaDetectionModeConfig - No class hierarchy - behavior controlled by configuration
- Easily extensible by adding new configs
Available Modes:
-
AASM Mode (default)
- AASM Scoring Manual v2.6 compliant
- Time-based baseline (120 seconds, 2 minutes)
- 90% validation threshold
- Strict apnea detection (≥90% flow reduction)
- Hypopnea detection (30% flow + 3% SpO2 desaturation)
- RERA detection enabled
-
AASM Relaxed Mode
- AASM-based with relaxed thresholds
- Breath-based baseline (30 breaths)
- 85% validation threshold
- Hypopnea detection (30% flow + 3% SpO2 desaturation)
- RERA detection enabled
- Better for matching machine-detected events
-
ResMed Mode
- Approximates ResMed machine detection logic (gap + low-flow approach)
- Time-based baseline (120 seconds, 2 minutes)
- 50% apnea threshold (vs 90% AASM) — detects flow gaps, not near-complete cessation
- Flow-only hypopnea detection (20% reduction, no SpO2 required)
- RERA detection enabled
- Designed to match ResMed AirSense/AirCurve event counts
Configuration Parameters:
DetectionModeConfig(
name="mode_name",
baseline_method=BaselineMethod.TIME | BaselineMethod.BREATH,
baseline_window=120.0, # seconds or breath count
apnea_threshold=0.90, # 90% flow reduction
apnea_validation_threshold=0.90,
hypopnea_min_threshold=0.30,
hypopnea_max_threshold=0.89,
min_event_duration=10.0,
merge_gap=3.0,
metric="amplitude",
hypopnea_mode=HypopneaMode.AASM_3PCT | AASM_4PCT | FLOW_ONLY | DISABLED,
hypopnea_flow_only_fallback=True, # Fallback if no SpO2 data
rera_detection_enabled=True, # Detect RERA-like events
)Hypopnea Detection Modes:
AASM_3PCT- 30% flow + 3% SpO2 drop (AASM recommended)AASM_4PCT- 30% flow + 4% SpO2 drop (CMS/Medicare)FLOW_ONLY- Flow reduction, no SpO2 required (threshold config-controlled; 20% in ResMed mode)DISABLED- Skip hypopnea detection
1. Load waveform data (timestamps, flow values)
↓
2. BreathSegmenter.segment_breaths()
→ Identifies individual breaths from flow signal
↓
3. WaveformFeatureExtractor (per breath)
→ Extracts shape features, spectral features, waveform features
↓
4. FlowLimitationClassifier.analyze_session()
→ Classifies flow limitation severity
↓
5. ComplexPatternDetector
→ Detects CSR (Cheyne-Stokes Respiration)
→ Detects periodic breathing
↓
5.5. PulseChangeDetector
→ Detects pulse rate changes ≥5 BPM within 8-second sliding windows
→ Outputs pulse_change_count and pulse_change_index (per hour)
↓
6. EventDetector.detect_events() (per mode)
→ Detects apneas (obstructive, central, mixed, unspecified) with confidence levels
→ Detects hypopneas (mode-dependent: SpO2-based or flow-only)
→ Detects RERAs (Respiratory Effort-Related Arousals)
→ Calculates AHI, RDI (includes RERAs)
↓
7. AnalysisResult (stored in database)
→ mode_results: {mode_name: ModeResult}
→ flow_analysis, csr_detection, periodic_breathing
All analysis types use Pydantic models:
- Validation at construction time
- Automatic JSON serialization via
model_dump() - No manual
to_dict()/from_dict()methods needed
Key Types:
BreathMetrics- Individual breath measurements (Pydantic)ApneaEvent,HypopneaEvent,RERAEvent- Detected events (Pydantic)ModeResult- Per-mode detection results (Pydantic)AnalysisResult- Complete analysis output (Pydantic)DetectionModeConfig- Mode configuration (frozen Pydantic)
FastAPI application serving the same data as the CLI through HTTP endpoints. Launched via snore serve.
Application: src/snore/api/app.py
Routers:
| Router | Prefix | Endpoints |
|---|---|---|
| auth | /auth |
Login/logout/demo-login/status, active profile, invite lookup/redeem, Google OAuth (login + invite signup, single callback) |
| me | /auth/me |
Self-service account: display name, password, preferences |
| admin | /admin |
User management, invite lifecycle (admin-only) |
| 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 |
| analysis | (v1 prefix) | List status, get result, run, delete, batch analysis |
| stats | /stats |
Summary, periods, trends, records |
| devices | /devices |
List, detail |
| days | /days |
List, detail |
| rx | /rx |
History, current, compare |
| reports | /reports |
Report generation |
| import | /import |
Upload files, SSE progress, cancel, pipeline job list; detect + path-import (local mode only) |
| export | /export |
CSV, JSON, raw file download |
| db | /db |
Stats, vacuum; reset (local mode only) |
| validation | /validate |
Batch validation report |
Key patterns:
- Dependency injection:
db: Session = Depends(get_db)for database sessions - Caller-controlled transactions:
get_db()handles commit/rollback - Domain exceptions:
NotFoundError→ 404, genuineValueError→ 500 - LTTB downsampling: 720k-point waveforms served in <100ms via
max_pointsparam - CORS: Configured for Vue dev server (
localhost:5173) - OpenAPI: Auto-generated docs at
/docs - Auth middleware (
api/middleware.py):AuthMiddlewareresolves the actor from the signed session cookie on every request (auto-provisions the local actor in local mode);AuthPathMiddlewareenforces the CSRF Origin/Referer check on unsafe methods, a 16 KiB body cap on/api/v1/auth/*, and blanketCache-Control: no-storeon auth responses;RateLimitMiddlewareapplies a per-IP 30 req/60 s window to/api/v1/auth/*in multiuser mode
Imports and analysis run as serialised background jobs to avoid SQLite write-lock contention.
Two-phase import flow:
POST /import— accepts multipart upload, reserves an admission slot, writes files to a temp directory, and returns ajob_idimmediately (HTTP 202).- The persistent import worker thread (
api/import_worker.py) picks up the job from the FIFO queue and runsImportService.import_sources(). On success it emits a non-terminalphase_completeSSE event carrying the import result, then enqueues a downstreamAnalysisJob. - The import job terminates with
complete(orerror/cancel). Every terminal payload carriesimport_committed=Trueand the full import result whenever the import phase committed to the database, so late or stalled SSE observers always learn that data landed. GET /import/{id}/progress— SSE stream; connecting after the job is terminal delivers the stored terminal event immediately.
Admission caps — SNORE_MAX_JOBS_PER_USER (default 3) and SNORE_MAX_JOBS_GLOBAL (default 10) gate new reservations before any request body bytes are read. The slot counts until release_capacity() is called after temp-file cleanup.
TTL reaper — api/import_jobs.py runs both a dedicated background reaper thread (every 60 s) and an eager reap-on-read inside get_job / list_jobs / reserve_slot (because HTTP handlers poll the store on every request). api/analysis_jobs.py reaps inline after each job completes inside the worker loop — sufficient there because the analysis store is accessed only by the worker thread and a low-frequency list endpoint.
13 service modules in src/snore/services/ form the business logic layer between CLI/API and database:
| Service | Responsibility |
|---|---|
| AnalysisFacade | Analysis orchestration, result retrieval, batch analysis |
| BackupService | Raw SD card file backup to ~/.snore/raw/ |
| DatabaseService | Database operations (stats, vacuum, init) |
| DayService | Day aggregation and lookup |
| DeviceService | Device listing and per-device detail (usage summary, current settings, settings history) |
| EventService | Event queries and matching |
| 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() |
| SessionService | Session CRUD and filtering |
| StatsService | Statistics calculations and summaries |
| WaveformService | Waveform data access, formatting, event comparison |
| WriterLeaseManager | Shared/exclusive SQLite writer lease acquired at startup; serialises import + analysis writes |
Prescription/therapy settings tracking lives in analysis/rx_tracker.py (RxTracker),
which returns the Pydantic responses from services/schemas.py directly.
Pattern: Constructor injection with SQLAlchemy session, typed Pydantic returns via services/schemas.py.
profiles
id, username (UNIQUE), first_name, last_name, date_of_birth, height_cm,
settings (JSON), created_at, updated_atdevices
id, manufacturer, model, serial_number, firmware_version,
hardware_version, product_code, first_seen, last_import
UNIQUE(serial_number)days
id, device_id (FK devices), date,
session_count, total_therapy_hours,
obstructive_apneas, central_apneas, hypopneas, reras,
ahi, oai, cai, hi,
pressure_min/max/median/mean/95th, epap_min/max/median/mean/95th,
leak_min/max/median/mean/95th, spo2_min/max/mean,
created_at, updated_at
UNIQUE(device_id, date)sessions
id, device_id (FK devices), day_id (FK days),
device_session_id, start_time, end_time, duration_seconds,
therapy_mode, import_date, import_source, parser_version,
data_quality_notes (JSON),
has_waveform_data, has_event_data, has_statistics, enabled
UNIQUE(device_id, device_session_id)waveforms
id, session_id, waveform_type, sample_rate, unit,
min_value, max_value, mean_value, data_blob, sample_count
UNIQUE(session_id, waveform_type)data_blob: Numpy array as bytes (timestamps and values stacked)- No compression - SQLite/filesystem handles that efficiently
- Simplified from original design for performance
events
id, session_id (FK sessions), event_type, start_time, duration_seconds,
spo2_drop, peak_flow_limitationstatistics
session_id (PK, FK sessions), [45+ metric columns]
AHI, OAI, CAI, HI, REI, pressure stats, leak stats, SpO2 stats, etc.settings
id, session_id (FK sessions), key, value
UNIQUE(session_id, key)Key-value pairs for extensibility across device types
analysis_results
id, session_id (FK sessions),
timestamp_start, timestamp_end,
programmatic_result_json (JSON), processing_time_ms,
engine_versions_json (JSON), created_atStores programmatic analysis results (detection mode, events, AHI/RDI, metadata)
detected_patterns
id, analysis_result_id (FK analysis_results),
pattern_id, start_time, duration, confidence,
detected_by, metrics_json (JSON), notesResMed SD Card
↓
DATALOG/YYYY/*.edf files
↓
ResMed EDF+ Parser (src/snore/parsers/resmed_edf.py)
↓
UnifiedSession objects
↓
Session Importer (src/snore/database/importers.py)
↓
SQLite Database (~/.snore/snore.db)
↓
Analysis Tools
↓
CLI/Reports
Any CPAP Device
↓
OSCAR Desktop App
↓
.000/.001 binary files
↓
OSCAR Binary Parser ✅
↓
UnifiedSession objects
↓
[Same pipeline as ResMed]
SQLite Database
↓
Service Layer (services/)
↓
FastAPI Routers (api/routers/)
↓
JSON Response → Vue Frontend (ui/)
src/snore/mcp/ implements the third presentation layer — a peer of the CLI and the FastAPI REST API. It exposes SNORE data to LLM clients via the Model Context Protocol.
Two transports are supported, selected at startup:
- stdio (
snore mcp stdio): local tool use; no authentication. AStaticRuntimeresolves the active profile at startup from the first live profile row and watches for database file replacement (inode change → auto-refresh). - streamable-HTTP (
snore mcp serve): multi-user, OAuth 2.1 via FastMCP'sGoogleProvider. Each request carries a per-userActorRuntimethat readsprofile_idfrom the request context var rather than a startup-time lookup.
Both transports share the same tool and resource registrations; the only difference is which SNORERuntime implementation is yielded by the FastMCP lifespan.
Tools never import a database session factory directly. Instead, server.py yields a SNORERuntime instance from its FastMCP lifespan context, and every tool accesses runtime.scope_provider() and runtime.profile_id through this interface:
StaticRuntime — stdio path; profile_id resolved at startup
ActorRuntime — OAuth HTTP path; profile_id from per-request context var
_scope_and_run(ctx, impl, *, tool_name, **kwargs) in server.py captures the common scaffold shared by seven of the ten tools: open scope → call impl with (db, profile_id=..., **kwargs) → model_dump(mode="json") → _check_response_size.
Each tool is defined in src/snore/mcp/tools/<name>.py and owns a register(mcp: FastMCP) -> None function. make_server calls every module's register so the tool registration logic is co-located with the tool's DB-fetch and mapping code.
| Module | Tool name | Pattern |
|---|---|---|
overview.py |
get_data_overview |
_scope_and_run |
settings.py |
get_settings_timeline |
_scope_and_run |
summary.py |
get_nightly_summary |
_scope_and_run |
events.py |
get_events |
_scope_and_run |
breath_table.py |
get_breath_table |
_scope_and_run |
windows.py |
find_windows |
_scope_and_run |
epochs.py |
compare_epochs |
_scope_and_run |
ca_analysis.py |
get_ca_analysis |
fetch inside scope, compute outside |
waveform.py |
get_waveform |
_fetch_waveform_for_tool helper |
waveform.py |
render_window |
_fetch_waveform_for_tool helper |
Tools implement progressive disclosure across three tiers:
| Tier | Tools | Description |
|---|---|---|
| 1 (primary) | overview, summary, settings, events, epochs, ca_analysis | Computed metrics — indices, percentiles, aggregates |
| 2 (secondary) | render_window | PNG charts — visual inspection of waveform windows ≤15 min |
| 3 (escape hatch) | get_waveform, breath_table | Raw arrays and per-breath rows for deep inspection |
- Database:
~/.snore/snore.db - ResMed data:
DATALOG/YYYY/*.edfon SD card or~/Downloads/OSCAR/Profiles/<Profile>/<Device>/Backup/ - OSCAR cache:
~/Downloads/OSCAR/Profiles/<Profile>/<Device>/Summaries/*.000andEvents/*.001
Core: Python 3.13+, pydantic, sqlalchemy, alembic, click, numpy, pyedflib, scipy, mne, python-dateutil, pytz, requests
API: fastapi, uvicorn, httpx
CLI: rich, plotext, pint, jinja2, packaging
Development: pytest, pytest-cov, ruff, mypy
All managed via pyproject.toml with uv
ResMed Devices (Direct Import) ✅
- AirSense 10/11, AirCurve 10/11, S9 series
- Native EDF+ format → Direct import from SD card
- ~40% of CPAP market
All Other Manufacturers (via OSCAR) ✅
- Philips, Fisher & Paykel, Löwenstein, Weinmann, DeVilbiss, BMC, and 12+ others
- Import via OSCAR desktop app → Binary cache files (.000/.001)
- OSCAR binary parser fully implemented
- 100% device coverage (all 18 OSCAR-supported manufacturers)