doc: resmed device support planning

This commit is contained in:
2026-04-09 17:36:23 +03:00
parent 5823e5b29e
commit ab160e35b7

180
doc/plan/resmed-support.md Normal file
View File

@@ -0,0 +1,180 @@
# Plan: Add ResMed AirSense 10 Device Support
## Context
Tidal currently supports only Lowenstein devices. The user has a ResMed AirSense 10 AutoSet SD card copy at `~/resmed` and needs parser support added. This is critical health data -- we must only decode values we're certain about, using confirmed formats from real data analysis.
The good news: ResMed uses standard EDF and EDF+ formats throughout, with plain English event annotation strings. No reverse engineering of numeric IDs is needed. The event format, signal scaling, and file structure are all well-defined from the SD card data.
## ResMed SD Card Format Summary
```
resmed/
├── Identification.tgt # Plain text: #PNA, #SRN, #FGT, #MID
├── STR.edf # Summary statistics (defer)
├── DATALOG/YYYYMMDD/
│ ├── YYYYMMDD_HHMMSS_EVE.edf # EDF+D: events as TAL annotations
│ ├── YYYYMMDD_HHMMSS_CSL.edf # EDF+D: CSR start/end annotations
│ ├── YYYYMMDD_HHMMSS_BRP.edf # EDF: Flow 25Hz + Press 25Hz
│ ├── YYYYMMDD_HHMMSS_PLD.edf # EDF: 9 therapy signals at 0.5Hz
│ └── YYYYMMDD_HHMMSS_SAD.edf # EDF: SpO2 + Pulse at 1Hz
```
**Session model:** Each BRP file = one therapy session (mask-on period). PLD/SAD are companion files with timestamps ~1s after BRP. CSL+EVE span entire recording periods (may contain multiple sessions). Events from EVE are mapped to sessions by temporal overlap.
**Confirmed event strings (EDF+ TAL format):**
- "Central Apnea" -> CentralApnea (duration in seconds)
- "Obstructive Apnea" -> ObstructiveApnea (duration in seconds)
- "Hypopnea" -> Hypopnea (duration reported as 0 = unmeasured, NOT sub-threshold)
- "Recording starts" -> skip (sentinel)
- "CSR Start" / "CSR End" -> defer (Cheyne-Stokes, from CSL files)
**Pressure unit:** ResMed uses cmH2O. Convert to hPa: `* 0.980665`
## Module Structure
```
crates/tidal-devices/src/resmed/
├── mod.rs # pub use session::ResmedParser
├── identification.rs # Identification.tgt -> DeviceInfo
├── edf.rs # Standard EDF header + signal data parser
├── edf_annotations.rs # EDF+D TAL annotation parser (EVE/CSL)
├── session.rs # DeviceParser impl, session discovery + assembly
└── signals.rs # PLD/SAD -> SignalBlock
```
## Implementation Steps
### Step 1: `resmed/edf.rs` -- Standard EDF parser
Parse 256-byte global header + per-signal headers. Key types:
```rust
pub struct EdfHeader {
pub start_datetime: NaiveDateTime, // from EDF+ Startdate "DD-MMM-YYYY" + time
pub num_records: i32,
pub record_duration_secs: f64,
pub num_signals: usize,
pub header_bytes: usize,
pub is_edf_plus_d: bool,
pub signals: Vec<EdfSignalHeader>,
}
```
Start time from EDF+ Startdate field (offset 88): `"Startdate DD-MMM-YYYY"` with 4-digit year. Time from offset 176: `HH.MM.SS`.
Signal data: standard EDF formula `phys_min + (raw - dig_min) * (phys_max - phys_min) / (dig_max - dig_min)`.
**Note:** NOT refactoring existing `wmedf.rs` (Lowenstein-specific quirks). Clean new parser.
### Step 2: `resmed/identification.rs` -- Device identity
Parse `Identification.tgt` key-value file:
- `#PNA` -> model (replace `_` with space)
- `#SRN` -> serial
- `#FGT` -> firmware
- manufacturer: `Manufacturer::ResMed`
### Step 3: `resmed/edf_annotations.rs` -- TAL event parser
Parse EDF+D Time-stamped Annotation Lists from EVE files:
- Format: `+onset\x15duration\x14text\x14\0`
- onset/duration in seconds (convert to centiseconds for tidal-core: `* 100`)
Mapping (ONLY confirmed strings):
| String | EventType |
|---|---|
| "Central Apnea" | CentralApnea |
| "Obstructive Apnea" | ObstructiveApnea |
| "Hypopnea" | Hypopnea |
| "Recording starts" | skip |
| unrecognized | warn + skip |
**Do NOT apply AASM 10s reclassification to ResMed hypopneas** -- duration=0 means unmeasured, not sub-threshold.
### Step 4: `resmed/signals.rs` -- Signal extraction
PLD signals (0.5Hz, confirmed from EDF headers):
| EDF Label | SignalLabel | Unit |
|---|---|---|
| Press.2s | Pressure | cmH2O -> hPa |
| EprPress.2s | EPAPTarget | cmH2O -> hPa |
| MaskPress.2s | Unknown("MaskPressure") | cmH2O -> hPa |
| Leak.2s | LeakFlow | L/s |
| RespRate.2s | BreathFrequency | bpm |
| TidVol.2s | BreathVolume | L |
| MinVent.2s | MinuteVolume | L/min |
| Snore.2s | Unknown("Snore") | unitless |
| FlowLim.2s | Unknown("FlowLimitation") | unitless |
SAD signals (1Hz):
| EDF Label | SignalLabel | Unit |
|---|---|---|
| Pulse.1s | HeartRate | bpm |
| SpO2.1s | SpO2 | % |
Skip Crc16 channels everywhere.
### Step 5: `resmed/session.rs` -- Session discovery + DeviceParser
```rust
pub struct ResmedParser;
impl DeviceParser for ResmedParser {
fn can_parse(path: &Path) -> bool {
path.join("Identification.tgt").exists() && path.join("DATALOG").exists()
}
fn parse_sessions(path: &Path) -> Result<Vec<Session>> { ... }
}
```
Session discovery:
1. Parse `Identification.tgt` -> DeviceInfo
2. Scan `DATALOG/YYYYMMDD/` for `*_BRP.edf` files (each = one session)
3. Extract timestamp from filename: `YYYYMMDD_HHMMSS`
4. Match PLD/SAD by timestamp proximity (within 2s of BRP)
5. Find EVE files per date directory
6. Map EVE events to sessions: event absolute time = EVE start + onset, assign to session whose time range contains it
7. Session duration = `num_records * record_duration_secs` from BRP header
Session ID: `"{serial}-{YYYYMMDD_HHMMSS}"` (e.g., `"23254651676-20260408_215123"`)
TherapySettings: `mode: Apap` (from product name containing "AutoSet"), pressure min/max from PLD header physical range (converted to hPa). Note: these are device maximums, not configured range -- document this limitation.
### Step 6: `resmed/mod.rs`
Wire up modules and re-export `ResmedParser`.
### Step 7: Consumer integration
**`crates/tidal-devices/src/lib.rs`:** Add `pub mod resmed;`
**`crates/tidal-cli/src/main.rs` (~line 122):** Add `else if ResmedParser::can_parse(&path)` branch with identical import logic.
**`crates/tidal-api/src/upload.rs` (~line 69):** Add `else if ResmedParser::can_parse(tmpdir.path())` branch.
## What we are NOT implementing (deferred)
- BRP high-res waveform data (25Hz flow/pressure) -- defer until signal visualization
- STR.edf summary parsing -- useful for validation later
- SETTINGS .tgt files -- proprietary hex format, partially decoded
- CRC validation
- CSR Start/End events from CSL files
## Critical Files to Modify
| File | Change |
|---|---|
| `crates/tidal-devices/src/lib.rs` | Add `pub mod resmed;` |
| `crates/tidal-devices/src/resmed/` (new) | 6 new files |
| `crates/tidal-cli/src/main.rs` | Add ResmedParser detection (~L122) |
| `crates/tidal-api/src/upload.rs` | Add ResmedParser detection (~L69) |
## Verification
1. `cargo build` -- must compile cleanly
2. `cargo test` -- existing tests must pass
3. `cargo run --bin tidal -- import ~/resmed` -- should detect ResMed, parse sessions, show AHI
4. Verify session count matches BRP file count (17 sessions across 3 dates)
5. Verify event counts: 69 central + 19 obstructive + 11 hypopnea = 99 total events
6. Verify device info: serial=23254651676, model="AirSense 10 AutoSet", firmware="24_M36_V26"
7. Check signal data: PLD channels have non-zero samples, pressure values in reasonable range (4-20 hPa)