doc: resmed device support planning
This commit is contained in:
180
doc/plan/resmed-support.md
Normal file
180
doc/plan/resmed-support.md
Normal 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)
|
||||
Reference in New Issue
Block a user