Compare commits

...

3 Commits

Author SHA1 Message Date
91882c98cc Add tidal export with therapy period grouping and multi-format output
Sessions are grouped into therapy periods by temporal proximity (< 4h
gap between session end and next start). Supports --format text, json,
csv, and markdown with optional --events for event-level detail.

Text and markdown formats are designed for sharing with clinicians,
including device info, per-period AHI breakdown, and methodology note.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:17:12 +02:00
2ed6f81034 doc: export planning 2026-03-28 18:02:03 +02:00
451ee3d768 Map all remaining unknown Löwenstein event IDs, add session detail view
Reverse-engineer RespEventIDs 4, 102, 103, 106, 108, 113, 141, 181,
304, 305, 308, 309, 1102, and 1230-1237 from 53 sessions of device
data. Zero unknown events remain across the full dataset.

Add PressureOptimisation event type for auto-titration cycles.
Add `tidal session <id> [--events]` for per-session detail with device
info, therapy settings, AHI breakdown, event counts, and timeline.

Tidal does not respect manufacturer Visible="0" directives — data
about a user belongs to the user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:44:54 +02:00
10 changed files with 981 additions and 13 deletions

View File

@@ -69,32 +69,54 @@ mnt/flash/
**Confirmed RespEventID mapping:**
All events are surfaced to the user. The device marks some events `Visible="0"` but Tidal does not respect manufacturer visibility directives — data about a user belongs to the user.
| ID | EventType | Notes |
|----|-----------|-------|
| 1 | ObstructiveApnea | Duration ~1200cs = 12s |
| 2 | CentralApnea | Duration ~1200cs = 12s |
| 3 | MixedApnea | |
| 4 | MixedApnea | Unclassified apnea (device could not determine OA/CA/MA), Duration ~1200cs |
| 101 | RERA | Respiratory Effort Related Arousal |
| 102 | RERA | RERA variant, Strength 0-9 |
| 103 | FlowLimitation | Intermediate severity variant, Strength 34-87 |
| 106 | Hypopnea | Alternative detection criteria, possibly FOT-based |
| 108 | FlowLimitation | Strong respiratory effort variant, Strength 11-97 (never zero) |
| 111 | Hypopnea | Obstructive, often paired with 1008 pressure response |
| 112 | Snore | Strength 0-100 |
| 113 | Snore | Snore variant, Strength 40-45 |
| 121 | FlowLimitation | Always paired with 1129 (skip 1129) |
| 131 | FlowLimitation | Mild, Strength varies |
| 141 | LargeLeak | Extended leak episode, paired with 1102 |
| 151 | Hypopnea | Central/mixed variant |
| 161 | RERA | |
| 171 | SessionStart | |
| 181 | PressureOptimisation | Auto-titration cycle, Duration 1163-1915cs, Pressure field active (11.5-16.0 hPa) |
| 231 | MaskOff | Large leak, mask removed |
| 261 | PressureChange | Paired with 1261 (skip 1261) |
| 262 | TherapyPause | Paired with 1262 (skip 1262) |
| 304 | SessionEnd | Pre-shutdown marker, Visible="0", Duration=0 |
| 305 | SessionEnd | Shutdown transition, Visible="0", variable duration |
| 306 | SessionEnd | Visible="0" |
| 307 | SessionEnd | |
| 308 | TherapyPause | Mid-session pause marker, Duration=0, paired with 309 |
| 309 | TherapyPause | Pause duration, follows 308, Duration=20-300cs |
| 330 | (skip) | Unknown, Duration always 100 |
| 1007 | CentralApnea | FOT-detected central variant |
| 1008 | PressureIncrease | Pressure=100 means 10.0 hPa response |
| 1101 | (skip) | FOT obstructive detection signal |
| 1102 | (skip) | FOT pair signal for 141 (large leak) |
| 1111 | (skip) | FOT central detection signal |
| 1112 | MixedApnea | FOT mixed signal |
| 1126 | LargeLeak | |
| 1129 | (skip) | FlowLimitation pair signal |
| 1230 | SessionStart | Init sentinel, EndTime always 1cs, once per session |
| 1231 | SessionEnd | Termination marker, Strength=1 (inverse of 1230) |
| 1232 | SessionStart | Init parameter frame, paired with 1230 |
| 1233 | SessionStart | Therapy-active confirmation, EndTime ~1200cs |
| 1234 | SessionStart | Config checkpoint, appears at session start and end |
| 1235 | SessionStart | Sensor verification pulse, 32% of sessions |
| 1237 | SessionStart | Init handshake completion, EndTime always 11cs |
| 1238 | SessionStart | Session initialisation |
**DeviceEvent ParameterID mapping (at Time="0"):**

2
Cargo.lock generated
View File

@@ -647,6 +647,8 @@ dependencies = [
"chrono",
"clap",
"dirs",
"serde",
"serde_json",
"tidal-core",
"tidal-devices",
"tidal-store",

View File

@@ -143,12 +143,14 @@ tidal import ~/lowenstein/therapy_extracted --user-id patient-b --from 2026-03-1
tidal sessions
```
### Session detail (planned)
### Session detail
```bash
# Summary: device info, therapy settings, AHI breakdown, event counts by type
tidal session 300306-003336
# Include a chronological event timeline
tidal session 300306-003336 --events
tidal session 300306-003336 --signals pressure,flow,spo2
```
---

View File

@@ -15,4 +15,6 @@ anyhow = "1"
clap = { version = "4", features = ["derive"] }
chrono = "0.4"
dirs = "6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }

View File

@@ -0,0 +1,337 @@
use std::fmt::Write;
use chrono::{DateTime, Utc};
use serde::Serialize;
use tidal_core::analysis::{self, TherapyPeriod, PeriodSummary, SessionSummary};
use tidal_core::entities::{DeviceInfo, Session};
pub struct ExportContext<'a> {
pub periods: &'a [TherapyPeriod],
pub device: &'a DeviceInfo,
pub user_id: &'a str,
pub from: Option<DateTime<Utc>>,
pub to: Option<DateTime<Utc>>,
pub show_events: bool,
}
// ---------------------------------------------------------------------------
// Text
// ---------------------------------------------------------------------------
pub fn render_text(ctx: &ExportContext) -> String {
let mut out = String::new();
writeln!(out, "Tidal PAP Therapy Report").unwrap();
writeln!(out, "========================").unwrap();
writeln!(out, "Device: {} {} — serial {}, firmware {}",
ctx.device.manufacturer, ctx.device.model, ctx.device.serial, ctx.device.firmware).unwrap();
write_date_range(&mut out, "Date range:", ctx.from, ctx.to);
writeln!(out, "User: {}", ctx.user_id).unwrap();
writeln!(out).unwrap();
for group in ctx.periods {
let ns = analysis::summarise_period(group);
let header = format!("Therapy period {}", group.date);
writeln!(out, "{}", header).unwrap();
writeln!(out, "{}", "-".repeat(header.len())).unwrap();
for session in &group.sessions {
let s = analysis::summarise(session);
write_session_line_text(&mut out, session, &s);
}
writeln!(out).unwrap();
write_period_summary_text(&mut out, &ns);
writeln!(out).unwrap();
if ctx.show_events {
for session in &group.sessions {
write_event_timeline_text(&mut out, session);
}
}
}
writeln!(out, "AHI per AASM 2012: (Obstructive + Central + Mixed + Hypopnea) / therapy hours").unwrap();
writeln!(out, "Generated by Tidal · not a medical device").unwrap();
out
}
fn write_session_line_text(out: &mut String, session: &Session, s: &SessionSummary) {
writeln!(out, " Session {} {} {:.1}h AHI {:.1} (OA:{} CA:{} MA:{} H:{})",
session.id.0,
session.started_at.format("%H:%M"),
s.duration_hrs,
s.ahi,
s.obstructive_count, s.central_count, s.mixed_count, s.hypopnea_count,
).unwrap();
}
fn write_period_summary_text(out: &mut String, ns: &PeriodSummary) {
writeln!(out, " Period summary: {} sessions, {:.1}h therapy, AHI {:.1}",
ns.session_count, ns.total_duration_hrs, ns.combined_ahi).unwrap();
writeln!(out, " Obstructive: {} Central: {} Mixed: {} Hypopnea: {}",
ns.obstructive_count, ns.central_count, ns.mixed_count, ns.hypopnea_count).unwrap();
}
fn write_event_timeline_text(out: &mut String, session: &Session) {
writeln!(out, " Events for session {} ({})", session.id.0, session.started_at.format("%H:%M")).unwrap();
writeln!(out, " {:>8} {:>6} {:<20} {:>5} {:>8}", "offset", "dur", "type", "str", "hPa").unwrap();
for event in &session.events {
let offset_s = event.end_offset_cs as f32 / 100.0;
let dur_s = event.duration_cs as f32 / 100.0;
let strength = event.strength.map(|s| format!("{}", s)).unwrap_or_default();
let pressure = event.pressure_hpa.map(|p| format!("{:.1}", p)).unwrap_or_default();
writeln!(out, " {:>7.1}s {:>5.1}s {:<20} {:>5} {:>8}",
offset_s, dur_s, event.event_type, strength, pressure).unwrap();
}
writeln!(out).unwrap();
}
// ---------------------------------------------------------------------------
// Markdown
// ---------------------------------------------------------------------------
pub fn render_markdown(ctx: &ExportContext) -> String {
let mut out = String::new();
writeln!(out, "# Tidal PAP Therapy Report").unwrap();
writeln!(out).unwrap();
writeln!(out, "**Device:** {} {} — serial {}, firmware {}",
ctx.device.manufacturer, ctx.device.model, ctx.device.serial, ctx.device.firmware).unwrap();
write_date_range_md(&mut out, ctx.from, ctx.to);
writeln!(out, "**User:** {}", ctx.user_id).unwrap();
writeln!(out).unwrap();
for group in ctx.periods {
let ns = analysis::summarise_period(group);
writeln!(out, "## Therapy period {}", group.date).unwrap();
writeln!(out).unwrap();
writeln!(out, "| Session | Start | Duration | AHI | OA | CA | MA | Hypopnea |").unwrap();
writeln!(out, "|---------|-------|----------|-----|----|----|----|---------:|").unwrap();
for session in &group.sessions {
let s = analysis::summarise(session);
writeln!(out, "| {} | {} | {:.1}h | **{:.1}** | {} | {} | {} | {} |",
session.id.0,
session.started_at.format("%H:%M"),
s.duration_hrs, s.ahi,
s.obstructive_count, s.central_count, s.mixed_count, s.hypopnea_count,
).unwrap();
}
writeln!(out).unwrap();
writeln!(out, "**Period summary:** {} sessions, **{:.1}h** therapy, **AHI {:.1}**",
ns.session_count, ns.total_duration_hrs, ns.combined_ahi).unwrap();
writeln!(out, "Obstructive: {} · Central: {} · Mixed: {} · Hypopnea: {}",
ns.obstructive_count, ns.central_count, ns.mixed_count, ns.hypopnea_count).unwrap();
writeln!(out).unwrap();
if ctx.show_events {
for session in &group.sessions {
write_event_timeline_md(&mut out, session);
}
}
}
writeln!(out, "---").unwrap();
writeln!(out).unwrap();
writeln!(out, "*AHI per AASM 2012: (Obstructive + Central + Mixed + Hypopnea) / therapy hours*").unwrap();
writeln!(out, "*Generated by Tidal · not a medical device*").unwrap();
out
}
fn write_event_timeline_md(out: &mut String, session: &Session) {
writeln!(out, "### Events: {} ({})", session.id.0, session.started_at.format("%H:%M")).unwrap();
writeln!(out).unwrap();
writeln!(out, "| Offset | Duration | Type | Strength | Pressure |").unwrap();
writeln!(out, "|-------:|---------:|------|----------|--------:|").unwrap();
for event in &session.events {
let offset_s = event.end_offset_cs as f32 / 100.0;
let dur_s = event.duration_cs as f32 / 100.0;
let strength = event.strength.map(|s| format!("{}", s)).unwrap_or_default();
let pressure = event.pressure_hpa.map(|p| format!("{:.1}", p)).unwrap_or_default();
writeln!(out, "| {:.1}s | {:.1}s | {} | {} | {} |",
offset_s, dur_s, event.event_type, strength, pressure).unwrap();
}
writeln!(out).unwrap();
}
// ---------------------------------------------------------------------------
// JSON
// ---------------------------------------------------------------------------
#[derive(Serialize)]
struct JsonReport {
generated_at: String,
device: JsonDevice,
user_id: String,
date_range: JsonDateRange,
methodology: String,
periods: Vec<JsonPeriod>,
}
#[derive(Serialize)]
struct JsonDevice {
manufacturer: String,
model: String,
serial: String,
firmware: String,
}
#[derive(Serialize)]
struct JsonDateRange {
from: Option<String>,
to: Option<String>,
}
#[derive(Serialize)]
struct JsonPeriod {
date: String,
summary: JsonSummary,
sessions: Vec<JsonSession>,
}
#[derive(Serialize)]
struct JsonSummary {
session_count: usize,
duration_hrs: f32,
ahi: f32,
obstructive_count: usize,
central_count: usize,
mixed_count: usize,
hypopnea_count: usize,
}
#[derive(Serialize)]
struct JsonSession {
id: String,
started_at: String,
duration_secs: u32,
summary: JsonSummary,
events: Vec<JsonEvent>,
}
#[derive(Serialize)]
struct JsonEvent {
end_offset_cs: u32,
duration_cs: u32,
event_type: String,
strength: Option<u8>,
pressure_hpa: Option<f32>,
source: String,
}
pub fn render_json(ctx: &ExportContext) -> String {
let report = JsonReport {
generated_at: Utc::now().to_rfc3339(),
device: JsonDevice {
manufacturer: ctx.device.manufacturer.to_string(),
model: ctx.device.model.clone(),
serial: ctx.device.serial.clone(),
firmware: ctx.device.firmware.clone(),
},
user_id: ctx.user_id.to_owned(),
date_range: JsonDateRange {
from: ctx.from.map(|d| d.to_rfc3339()),
to: ctx.to.map(|d| d.to_rfc3339()),
},
methodology: "AASM 2012: (Obstructive + Central + Mixed + Hypopnea) / therapy hours".into(),
periods: ctx.periods.iter().map(|group| {
let ns = analysis::summarise_period(group);
JsonPeriod {
date: group.date.to_string(),
summary: period_summary_to_json(&ns),
sessions: group.sessions.iter().map(|session| {
let s = analysis::summarise(session);
JsonSession {
id: session.id.0.clone(),
started_at: session.started_at.to_rfc3339(),
duration_secs: session.duration_secs,
summary: JsonSummary {
session_count: 1,
duration_hrs: s.duration_hrs,
ahi: s.ahi,
obstructive_count: s.obstructive_count,
central_count: s.central_count,
mixed_count: s.mixed_count,
hypopnea_count: s.hypopnea_count,
},
events: session.events.iter().map(|e| JsonEvent {
end_offset_cs: e.end_offset_cs,
duration_cs: e.duration_cs,
event_type: e.event_type.to_string(),
strength: e.strength,
pressure_hpa: e.pressure_hpa,
source: e.source.to_string(),
}).collect(),
}
}).collect(),
}
}).collect(),
};
serde_json::to_string_pretty(&report).unwrap()
}
fn period_summary_to_json(ns: &PeriodSummary) -> JsonSummary {
JsonSummary {
session_count: ns.session_count,
duration_hrs: ns.total_duration_hrs,
ahi: ns.combined_ahi,
obstructive_count: ns.obstructive_count,
central_count: ns.central_count,
mixed_count: ns.mixed_count,
hypopnea_count: ns.hypopnea_count,
}
}
// ---------------------------------------------------------------------------
// CSV
// ---------------------------------------------------------------------------
pub fn render_csv(ctx: &ExportContext) -> String {
let mut out = String::new();
writeln!(out, "period_date,session_id,session_start,device_serial,event_offset_s,event_duration_s,event_type,strength,pressure_hpa").unwrap();
for group in ctx.periods {
for session in &group.sessions {
for event in &session.events {
let offset_s = event.end_offset_cs as f32 / 100.0;
let dur_s = event.duration_cs as f32 / 100.0;
let strength = event.strength.map(|s| s.to_string()).unwrap_or_default();
let pressure = event.pressure_hpa.map(|p| format!("{:.1}", p)).unwrap_or_default();
writeln!(out, "{},{},{},{},{:.1},{:.1},{},{},{}",
group.date,
session.id.0,
session.started_at.to_rfc3339(),
session.device.serial,
offset_s, dur_s,
event.event_type,
strength, pressure,
).unwrap();
}
}
}
out
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn write_date_range(out: &mut String, label: &str, from: Option<DateTime<Utc>>, to: Option<DateTime<Utc>>) {
match (from, to) {
(Some(f), Some(t)) => writeln!(out, "{} {} to {}", label, f.format("%Y-%m-%d"), t.format("%Y-%m-%d")).unwrap(),
(Some(f), None) => writeln!(out, "{} from {}", label, f.format("%Y-%m-%d")).unwrap(),
(None, Some(t)) => writeln!(out, "{} to {}", label, t.format("%Y-%m-%d")).unwrap(),
(None, None) => writeln!(out, "{} all imported sessions", label).unwrap(),
}
}
fn write_date_range_md(out: &mut String, from: Option<DateTime<Utc>>, to: Option<DateTime<Utc>>) {
match (from, to) {
(Some(f), Some(t)) => writeln!(out, "**Date range:** {} to {}", f.format("%Y-%m-%d"), t.format("%Y-%m-%d")).unwrap(),
(Some(f), None) => writeln!(out, "**Date range:** from {}", f.format("%Y-%m-%d")).unwrap(),
(None, Some(t)) => writeln!(out, "**Date range:** to {}", t.format("%Y-%m-%d")).unwrap(),
(None, None) => writeln!(out, "**Date range:** all imported sessions").unwrap(),
}
}

View File

@@ -1,6 +1,8 @@
mod export;
use anyhow::{Context, Result};
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueEnum};
use std::fs;
use std::path::PathBuf;
use tidal_core::analysis;
@@ -34,6 +36,40 @@ enum Command {
},
/// List imported sessions
Sessions,
/// Show detail for a specific session
Session {
/// Session ID (e.g. 300306-003336)
id: String,
/// Show individual events
#[arg(long)]
events: bool,
},
/// Export therapy data grouped by therapy period
Export {
/// Include only sessions starting at or after this date/time
#[arg(long, value_parser = parse_datetime)]
from: Option<DateTime<Utc>>,
/// Include only sessions starting at or before this date/time
#[arg(long, value_parser = parse_datetime)]
to: Option<DateTime<Utc>>,
/// User ID (defaults to local config user)
#[arg(long)]
user_id: Option<String>,
/// Output format
#[arg(long, value_enum, default_value = "text")]
format: ExportFormat,
/// Include individual events in text/markdown output
#[arg(long)]
events: bool,
},
}
#[derive(Clone, ValueEnum)]
enum ExportFormat {
Text,
Json,
Csv,
Markdown,
}
fn parse_datetime(s: &str) -> Result<DateTime<Utc>, String> {
@@ -177,6 +213,149 @@ fn main() -> Result<()> {
}
Ok(())
}
Command::Session { id, events: show_events } => {
let session = store.get_session(&default_user_id, &id, false)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let session = match session {
Some(s) => s,
None => {
println!("Session '{}' not found", id);
return Ok(());
}
};
let summary = analysis::summarise(&session);
// Header
println!("Session {}", session.id.0);
println!();
// Device
println!("Device");
println!(" Manufacturer: {}", session.device.manufacturer);
println!(" Model: {}", session.device.model);
println!(" Serial: {}", session.device.serial);
println!(" Firmware: {}", session.device.firmware);
println!();
// Timing
println!("Timing");
println!(" Started: {}", session.started_at.format("%Y-%m-%d %H:%M:%S UTC"));
println!(" Duration: {:.1}h ({} min)", summary.duration_hrs, session.duration_secs / 60);
println!();
// Therapy settings
println!("Therapy");
println!(" Mode: {}", session.settings.mode);
println!(" Pressure: {:.1} {:.1} hPa", session.settings.pressure_min_hpa, session.settings.pressure_max_hpa);
if let Some(epap) = session.settings.epap_hpa {
println!(" EPAP: {:.1} hPa", epap);
}
if let Some(ipap) = session.settings.ipap_hpa {
println!(" IPAP: {:.1} hPa", ipap);
}
println!();
// AHI breakdown
println!("AHI {:.1}", summary.ahi);
println!(" Obstructive: {}", summary.obstructive_count);
println!(" Central: {}", summary.central_count);
println!(" Mixed: {}", summary.mixed_count);
println!(" Hypopnea: {}", summary.hypopnea_count);
println!();
// Event counts by type
use std::collections::BTreeMap;
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for event in &session.events {
*counts.entry(event.event_type.to_string()).or_default() += 1;
}
println!("Events {} total", session.events.len());
for (et, count) in &counts {
println!(" {:<20} {}", et, count);
}
// Event timeline
if show_events {
println!();
println!("Timeline");
println!(" {:>8} {:>6} {:<20} {:>5} {:>8}", "offset", "dur", "type", "str", "hPa");
println!(" {:>8} {:>6} {:<20} {:>5} {:>8}", "------", "---", "----", "---", "---");
for event in &session.events {
let offset_s = event.end_offset_cs as f32 / 100.0;
let dur_s = event.duration_cs as f32 / 100.0;
let strength = event.strength
.map(|s| format!("{}", s))
.unwrap_or_default();
let pressure = event.pressure_hpa
.map(|p| format!("{:.1}", p))
.unwrap_or_default();
println!(
" {:>7.1}s {:>5.1}s {:<20} {:>5} {:>8}",
offset_s, dur_s, event.event_type, strength, pressure,
);
}
}
Ok(())
}
Command::Export { from, to, user_id: explicit_user_id, format, events: show_events } => {
let export_user_id = match explicit_user_id {
Some(ref id) => {
store.ensure_user(id, None)
.map_err(|e| anyhow::anyhow!("{}", e))?;
id.clone()
}
None => default_user_id.clone(),
};
let rows = store.list_sessions(&export_user_id)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let filtered_rows: Vec<_> = rows.into_iter().filter(|r| {
from.map_or(true, |f| r.started_at >= f)
&& to.map_or(true, |t| r.started_at <= t)
}).collect();
if filtered_rows.is_empty() {
eprintln!("No sessions found for the specified criteria");
return Ok(());
}
// Load full sessions
let mut sessions = Vec::new();
for row in &filtered_rows {
if let Some(session) = store.get_session(&export_user_id, &row.id, false)
.map_err(|e| anyhow::anyhow!("{}", e))? {
sessions.push(session);
}
}
let periods = analysis::group_into_periods(&sessions, 4.0);
// Use device info from first session
let device = &sessions[0].device;
let ctx = export::ExportContext {
periods: &periods,
device,
user_id: &export_user_id,
from,
to,
show_events,
};
let output = match format {
ExportFormat::Text => export::render_text(&ctx),
ExportFormat::Markdown => export::render_markdown(&ctx),
ExportFormat::Json => export::render_json(&ctx),
ExportFormat::Csv => export::render_csv(&ctx),
};
print!("{}", output);
Ok(())
}
}
}

View File

@@ -1,7 +1,8 @@
//! Analysis algorithms over Session data.
//! Computes AHI, event statistics, and therapy summaries.
//! Computes AHI, event statistics, night grouping, and therapy summaries.
//! All consumers (CLI, GUI, API) call into this layer.
use chrono::{Duration, NaiveDate, Timelike};
use crate::entities::{Session, EventType};
pub struct SessionSummary {
@@ -47,3 +48,244 @@ pub fn summarise(session: &Session) -> SessionSummary {
hypopnea_count,
}
}
// ---------------------------------------------------------------------------
// Therapy period grouping
// ---------------------------------------------------------------------------
/// A contiguous group of therapy sessions (e.g. a night's sleep, a nap,
/// or a clinic test). Sessions are grouped by temporal proximity.
pub struct TherapyPeriod {
/// The calendar date this period belongs to. Sessions starting before
/// noon are attributed to the previous calendar date (sleep medicine
/// convention: a 00:34 session belongs to the "night of" the day before).
pub date: NaiveDate,
/// Sessions in chronological order.
pub sessions: Vec<Session>,
}
/// Aggregate summary across all sessions in a therapy period.
pub struct PeriodSummary {
pub session_count: usize,
pub total_duration_hrs: f32,
pub combined_ahi: f32,
pub obstructive_count: usize,
pub central_count: usize,
pub mixed_count: usize,
pub hypopnea_count: usize,
}
/// Group sessions into therapy periods based on temporal proximity.
///
/// Two consecutive sessions belong to the same period if the gap between
/// the end of one session and the start of the next is less than
/// `gap_hours` hours. Sessions are returned in chronological order
/// within each group, and groups are sorted chronologically.
pub fn group_into_periods(sessions: &[Session], gap_hours: f32) -> Vec<TherapyPeriod> {
if sessions.is_empty() {
return Vec::new();
}
let mut sorted: Vec<Session> = sessions.to_vec();
sorted.sort_by_key(|s| s.started_at);
let gap_secs = (gap_hours * 3600.0) as i64;
let mut groups: Vec<Vec<Session>> = Vec::new();
let mut current: Vec<Session> = vec![sorted.remove(0)];
for session in sorted {
let prev = current.last().unwrap();
let prev_end = prev.started_at + Duration::seconds(prev.duration_secs as i64);
let gap = (session.started_at - prev_end).num_seconds();
if gap < gap_secs {
current.push(session);
} else {
groups.push(std::mem::take(&mut current));
current.push(session);
}
}
if !current.is_empty() {
groups.push(current);
}
groups.into_iter().map(|sessions| {
let date = date_for(&sessions[0]);
TherapyPeriod { date, sessions }
}).collect()
}
/// Compute aggregate summary for a therapy period.
pub fn summarise_period(group: &TherapyPeriod) -> PeriodSummary {
let mut total_duration_hrs: f32 = 0.0;
let mut obstructive_count: usize = 0;
let mut central_count: usize = 0;
let mut mixed_count: usize = 0;
let mut hypopnea_count: usize = 0;
for session in &group.sessions {
let s = summarise(session);
total_duration_hrs += s.duration_hrs;
obstructive_count += s.obstructive_count;
central_count += s.central_count;
mixed_count += s.mixed_count;
hypopnea_count += s.hypopnea_count;
}
let combined_ahi = if total_duration_hrs > 0.0 {
(obstructive_count + central_count + mixed_count + hypopnea_count) as f32
/ total_duration_hrs
} else {
0.0
};
PeriodSummary {
session_count: group.sessions.len(),
total_duration_hrs,
combined_ahi,
obstructive_count,
central_count,
mixed_count,
hypopnea_count,
}
}
/// Derive the calendar date for a therapy period. Sessions starting before
/// noon are attributed to the previous calendar date.
fn date_for(session: &Session) -> NaiveDate {
let dt = session.started_at.naive_utc();
if dt.hour() < 12 {
dt.date() - Duration::days(1)
} else {
dt.date()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use crate::entities::*;
fn make_session(id: &str, year: i32, month: u32, day: u32, hour: u32, min: u32, dur_secs: u32, events: Vec<Event>) -> Session {
Session {
id: SessionId(id.to_owned()),
device: DeviceInfo {
manufacturer: Manufacturer::Lowenstein,
model: "prisma SMART".into(),
serial: "300306".into(),
firmware: "5.05.0015".into(),
},
started_at: Utc.with_ymd_and_hms(year, month, day, hour, min, 0).unwrap(),
duration_secs: dur_secs,
settings: TherapySettings {
mode: TherapyMode::Apap,
pressure_min_hpa: 4.0,
pressure_max_hpa: 16.0,
epap_hpa: None,
ipap_hpa: None,
},
events,
signals: None,
}
}
fn apnea_event(et: EventType) -> Event {
Event {
end_offset_cs: 1200,
duration_cs: 1200,
event_type: et,
strength: None,
pressure_hpa: None,
source: EventSource::DeviceReported,
}
}
#[test]
fn group_real_world_pattern() {
// Mirrors the actual data: standalone afternoon, evening cluster, isolated
let sessions = vec![
make_session("003300", 2026, 3, 6, 0, 15, 720, vec![]),
make_session("003338", 2026, 3, 26, 16, 12, 60, vec![]),
make_session("003339", 2026, 3, 26, 22, 12, 120, vec![]),
make_session("003340", 2026, 3, 26, 22, 36, 540, vec![]),
make_session("003341", 2026, 3, 27, 0, 34, 360, vec![]),
make_session("003342", 2026, 3, 27, 1, 39, 30, vec![]),
make_session("003343", 2026, 3, 27, 1, 40, 360, vec![]),
make_session("003344", 2026, 3, 27, 2, 54, 1320, vec![]),
];
let groups = group_into_periods(&sessions, 4.0);
assert_eq!(groups.len(), 3);
// Group 1: isolated session from March 6
assert_eq!(groups[0].sessions.len(), 1);
assert_eq!(groups[0].sessions[0].id.0, "003300");
assert_eq!(groups[0].date, NaiveDate::from_ymd_opt(2026, 3, 5).unwrap());
// Group 2: standalone afternoon session
assert_eq!(groups[1].sessions.len(), 1);
assert_eq!(groups[1].sessions[0].id.0, "003338");
assert_eq!(groups[1].date, NaiveDate::from_ymd_opt(2026, 3, 26).unwrap());
// Group 3: evening/overnight cluster (6 sessions)
assert_eq!(groups[2].sessions.len(), 6);
assert_eq!(groups[2].sessions[0].id.0, "003339");
assert_eq!(groups[2].date, NaiveDate::from_ymd_opt(2026, 3, 26).unwrap());
}
#[test]
fn empty_input() {
let groups = group_into_periods(&[], 4.0);
assert!(groups.is_empty());
}
#[test]
fn single_session() {
let sessions = vec![make_session("001", 2026, 3, 26, 23, 0, 3600, vec![])];
let groups = group_into_periods(&sessions, 4.0);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].sessions.len(), 1);
assert_eq!(groups[0].date, NaiveDate::from_ymd_opt(2026, 3, 26).unwrap());
}
#[test]
fn date_before_noon_uses_previous_day() {
let sessions = vec![make_session("001", 2026, 3, 6, 1, 0, 3600, vec![])];
let groups = group_into_periods(&sessions, 4.0);
assert_eq!(groups[0].date, NaiveDate::from_ymd_opt(2026, 3, 5).unwrap());
}
#[test]
fn date_after_noon_uses_same_day() {
let sessions = vec![make_session("001", 2026, 3, 5, 16, 0, 3600, vec![])];
let groups = group_into_periods(&sessions, 4.0);
assert_eq!(groups[0].date, NaiveDate::from_ymd_opt(2026, 3, 5).unwrap());
}
#[test]
fn summarise_period_aggregates() {
let sessions = vec![
make_session("001", 2026, 3, 26, 23, 0, 3600, vec![
apnea_event(EventType::ObstructiveApnea),
apnea_event(EventType::CentralApnea),
]),
make_session("002", 2026, 3, 27, 1, 0, 7200, vec![
apnea_event(EventType::ObstructiveApnea),
apnea_event(EventType::Hypopnea),
apnea_event(EventType::Hypopnea),
]),
];
let groups = group_into_periods(&sessions, 4.0);
assert_eq!(groups.len(), 1);
let ns = summarise_period(&groups[0]);
assert_eq!(ns.session_count, 2);
assert!((ns.total_duration_hrs - 3.0).abs() < 0.01);
assert_eq!(ns.obstructive_count, 2);
assert_eq!(ns.central_count, 1);
assert_eq!(ns.hypopnea_count, 2);
// AHI = 5 events / 3 hours = 1.667
assert!((ns.combined_ahi - 1.667).abs() < 0.01);
}
}

View File

@@ -93,6 +93,7 @@ pub enum EventType {
// Device responses
PressureIncrease,
PressureOptimisation,
LargeLeak,
MaskOff,
@@ -219,6 +220,7 @@ impl fmt::Display for EventType {
Self::Snore => f.write_str("Snore"),
Self::RERA => f.write_str("RERA"),
Self::PressureIncrease => f.write_str("PressureIncrease"),
Self::PressureOptimisation => f.write_str("PressureOptimisation"),
Self::LargeLeak => f.write_str("LargeLeak"),
Self::MaskOff => f.write_str("MaskOff"),
Self::SessionStart => f.write_str("SessionStart"),
@@ -242,6 +244,7 @@ impl FromStr for EventType {
"Snore" => Self::Snore,
"RERA" => Self::RERA,
"PressureIncrease" => Self::PressureIncrease,
"PressureOptimisation" => Self::PressureOptimisation,
"LargeLeak" => Self::LargeLeak,
"MaskOff" => Self::MaskOff,
"SessionStart" => Self::SessionStart,

View File

@@ -43,39 +43,81 @@ struct RawRespEvent {
strength: u8,
}
/// Mapped RespEventID values confirmed from therapy-sw.log + event XML
/// cross-reference against session 003336 (25.03.2026 23:32:42)
/// Mapped RespEventID values confirmed from reverse engineering of
/// prisma SMART (WM 100 TD) SD card event XML across 53 sessions.
///
/// All events are surfaced to the user. The device marks some events
/// Visible="0" but we do not respect that — data about a user belongs
/// to the user.
fn map_resp_event_id(id: u32) -> Option<EventType> {
match id {
// Apneas
1 => Some(EventType::ObstructiveApnea),
2 => Some(EventType::CentralApnea),
3 => Some(EventType::MixedApnea),
// Apnea — unclassified (device could not determine OA/CA/MA)
4 => Some(EventType::MixedApnea), // unclassified apnea, Duration ~1200cs
// Respiratory events
101 => Some(EventType::RERA),
102 => Some(EventType::RERA), // RERA variant, Strength 0-9
103 => Some(EventType::FlowLimitation), // intermediate severity variant
106 => Some(EventType::Hypopnea), // alternative detection criteria
108 => Some(EventType::FlowLimitation), // strong respiratory effort variant
111 => Some(EventType::Hypopnea), // obstructive hypopnea
112 => Some(EventType::Snore),
113 => Some(EventType::Snore), // snore variant, Strength 40-45
121 => Some(EventType::FlowLimitation), // always paired with 1129
131 => Some(EventType::FlowLimitation), // mild flow limitation
141 => Some(EventType::LargeLeak), // extended leak episode, paired with 1102
151 => Some(EventType::Hypopnea), // central/mixed hypopnea
161 => Some(EventType::RERA),
181 => Some(EventType::PressureOptimisation), // auto-titration cycle, Pressure field active
// Session lifecycle
171 => Some(EventType::SessionStart),
231 => Some(EventType::MaskOff), // large leak / mask off
261 => Some(EventType::PressureChange), // paired with 1261
262 => Some(EventType::TherapyPause), // paired with 1262
304 => Some(EventType::SessionEnd), // pre-shutdown marker, Visible="0"
305 => Some(EventType::SessionEnd), // shutdown transition, Visible="0"
306 => Some(EventType::SessionEnd),
307 => Some(EventType::SessionEnd),
330 => None, // unknown, skip
1001..=1099 => None, // device parameter echoes
308 => Some(EventType::TherapyPause), // mid-session pause, Duration=0, paired with 309
309 => Some(EventType::TherapyPause), // pause duration, follows 308
330 => None, // unknown, always Duration=100, skip
// FOT detection signals
1007 => Some(EventType::CentralApnea), // FOT-detected central
1008 => Some(EventType::PressureIncrease),
1101 => None, // FOT obstructive signal
1111 => None, // FOT central signal
1101 => None, // FOT obstructive detection signal
1102 => None, // FOT pair signal for 141 (large leak)
1111 => None, // FOT central detection signal
1112 => Some(EventType::MixedApnea), // FOT mixed signal
1118 => None,
1118 => None, // FOT signal, unclassified
// Device response events
1126 => Some(EventType::LargeLeak),
1129 => None, // flow limitation pair, skip
1238 => Some(EventType::SessionStart),
1129 => None, // flow limitation pair signal
// Session init/shutdown protocol (1230-1238)
1230 => Some(EventType::SessionStart), // init sentinel, EndTime=1cs
1231 => Some(EventType::SessionEnd), // termination marker, Strength=1
1232 => Some(EventType::SessionStart), // init parameter frame
1233 => Some(EventType::SessionStart), // therapy-active confirmation
1234 => Some(EventType::SessionStart), // config checkpoint (start+end)
1235 => Some(EventType::SessionStart), // sensor verification pulse
1237 => Some(EventType::SessionStart), // init handshake completion
1238 => Some(EventType::SessionStart), // session initialisation
// Device parameter echoes (1001-1099) — internal config replay
1001..=1099 => None,
// Pressure/therapy pair signals
1261 => None, // pressure change pair
1262 => None, // therapy pause pair
_ => Some(EventType::Unknown(id)),
}
}

137
doc/plan/export.md Normal file
View File

@@ -0,0 +1,137 @@
# Plan: `tidal export` with night grouping and multi-format output
## Context
Therapy data needs to be exportable in formats suitable for sharing with medical professionals (text, markdown) and for programmatic analysis (JSON, CSV). Sessions naturally cluster into "nights" — a person falls asleep, wakes briefly (mask adjustment, bathroom), and resumes. These clusters should be grouped and summarised together, as that's how sleep physicians reason about therapy data.
## Night grouping algorithm
- Sort sessions by `started_at` ascending
- Compute each session's end: `started_at + duration_secs`
- If the next session starts within 4 hours of the previous session's end, same group
- `night_date`: derived from first session in group — if start hour < 12:00, use previous calendar date (sleep medicine convention: a 00:34 session belongs to the "night of" the day before)
- Sessions isolated by > 4h gaps on both sides form standalone groups
## Files to modify
- `crates/tidal-core/src/analysis.rs` — add `NightGroup`, `NightSummary`, `group_into_nights()`, `summarise_night()`
- `crates/tidal-cli/src/main.rs` — add `Export` command, `ExportFormat` enum, handler
- `crates/tidal-cli/Cargo.toml` — add `serde_json`, `serde`
## Files to create
- `crates/tidal-cli/src/export.rs` — format renderers: `render_text`, `render_markdown`, `render_json`, `render_csv`
## Step 1: Night grouping in tidal-core
Add to `crates/tidal-core/src/analysis.rs`:
```rust
pub struct NightGroup {
pub night_date: chrono::NaiveDate,
pub sessions: Vec<Session>,
}
pub struct NightSummary {
pub total_duration_hrs: f32,
pub combined_ahi: f32,
pub obstructive_count: usize,
pub central_count: usize,
pub mixed_count: usize,
pub hypopnea_count: usize,
pub session_count: usize,
}
pub fn group_into_nights(sessions: &[Session], gap_hours: f32) -> Vec<NightGroup>
pub fn summarise_night(group: &NightGroup) -> NightSummary
```
`group_into_nights` clones and sorts sessions, groups by gap threshold, assigns `night_date`. `summarise_night` sums across all sessions using existing `summarise()` per session, then recomputes combined AHI from totals.
Add unit tests: 3 groups from the real data pattern (standalone at 16:12, cluster 22:12-02:54, isolated 20 days away), edge cases (empty, single, boundary).
## Step 2: Add `Export` command to CLI
In `main.rs`:
```
tidal export [--from <datetime>] [--to <datetime>] [--user-id <id>] --format text|json|csv|markdown [--events]
```
- `--format` defaults to `text`
- `--events` includes individual event timelines in text/markdown output (JSON always includes events; CSV is inherently event-level)
- `--from`/`--to`/`--user-id` reuse existing patterns
Handler:
1. Resolve user ID
2. `list_sessions` → filter by date → `get_session` for each (no signals)
3. `group_into_nights(&sessions, 4.0)`
4. Dispatch to renderer, print to stdout
## Step 3: Format renderers in `export.rs`
**`render_text`** — Plain text, printable:
```
Tidal PAP Therapy Report
========================
Device: Löwenstein prisma (type 16) — serial 300306, firmware 5.05.0015
Date range: 2026-03-16 to 2026-03-27
User: patient-b
Night of 2026-03-26
Session 300306-003339 22:12 0.0h AHI 676.7 (OA:3 CA:2 MA:2 H:7)
Session 300306-003340 22:36 0.2h AHI 362.5 (OA:4 CA:5 MA:3 H:45)
...
Night summary: 6 sessions, 0.8h therapy, AHI 571.2
Obstructive: 20 Central: 15 Mixed: 19 Hypopnea: 142
AHI per AASM 2012: (Obstructive + Central + Mixed + Hypopnea) / therapy hours
Generated by Tidal · not a medical device
```
**`render_markdown`** — Same structure with `#` headers, `|` tables, `**bold**`.
**`render_json`** — Struct:
```json
{
"generated_at": "...",
"device": { ... },
"user_id": "...",
"date_range": { "from": "...", "to": "..." },
"methodology": "AASM 2012",
"nights": [
{
"date": "2026-03-26",
"summary": { ... },
"sessions": [ { "id": "...", "summary": { ... }, "events": [ ... ] } ]
}
]
}
```
Define CLI-local structs deriving `Serialize` for this. Add `serde_json = "1"` and `serde = { version = "1", features = ["derive"] }` to tidal-cli deps.
**`render_csv`** — One row per event:
```
night_date,session_id,session_start,device_serial,event_offset_s,event_duration_s,event_type,strength,pressure_hpa
```
## Step 4: Tests
In `analysis.rs`:
- `group_into_nights` with real-world pattern (standalone, cluster, isolated) → 3 groups
- `summarise_night` aggregation correctness
- Night date: session at 01:00 March 6 → night_date March 5
- Night date: session at 16:00 March 5 → night_date March 5
- Empty input → empty output
## Verification
1. `cargo build` — no errors
2. `cargo test -p tidal-core` — grouping tests pass
3. Manual against real data:
- `tidal export --format text --from 2026-03-26 --to 2026-03-27`
- `tidal export --format markdown --from 2026-03-26 --to 2026-03-27 --events`
- `tidal export --format json --from 2026-03-26 --to 2026-03-27`
- `tidal export --format csv --from 2026-03-26 --to 2026-03-27`
4. Verify night grouping: 003338 (16:12) standalone, 003339-003344 as one night, 003300 standalone