Compare commits
3 Commits
408e460d0c
...
91882c98cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
91882c98cc
|
|||
|
2ed6f81034
|
|||
|
451ee3d768
|
22
CLAUDE.md
22
CLAUDE.md
@@ -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
2
Cargo.lock
generated
@@ -647,6 +647,8 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tidal-core",
|
||||
"tidal-devices",
|
||||
"tidal-store",
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
337
crates/tidal-cli/src/export.rs
Normal file
337
crates/tidal-cli/src/export.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
137
doc/plan/export.md
Normal 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
|
||||
Reference in New Issue
Block a user