Ctrl K
ComunidadGuías…

importer/import_sessions.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
CPAP ETL importer: reads all ResMed EDF session files from DATALOG and
inserts/updates records in the local PostgreSQL database.

Run:
    python import_sessions.py
    python import_sessions.py --folder 20241215   # single folder
    python import_sessions.py --from 20250101     # from date onward
"""

import argparse
import os
import statistics
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from db import (
    clear_session_waveform,
    get_conn,
    get_session_db_id,
    replace_session_events,
    replace_session_metrics,
    replace_session_spo2,
    replace_session_waveform,
    upsert_session,
)
from edf_parser import parse_brp, parse_eve, parse_pld, parse_sa2, read_header

AHI_EVENT_TYPES = {"Central Apnea", "Obstructive Apnea", "Hypopnea", "Apnea"}


def _machine_tz(name: str | None = None) -> ZoneInfo:
    """Return the ZoneInfo for MACHINE_TZ, falling back to UTC on invalid input."""
    name = name or os.environ.get("MACHINE_TZ", "UTC")
    try:
        return ZoneInfo(name)
    except (ZoneInfoNotFoundError, KeyError, ValueError):
        print(f"  WARNING: unknown MACHINE_TZ={name!r}, falling back to UTC", flush=True)
        return ZoneInfo("UTC")


def _localize(naive_dt: datetime, machine_tz: ZoneInfo | None = None) -> datetime:
    """Attach MACHINE_TZ to a naive datetime from an EDF header."""
    return naive_dt.replace(tzinfo=machine_tz or _machine_tz())


def _machine_tz_for_user(conn, user_id: str) -> tuple[str, ZoneInfo]:
    with conn.cursor() as cur:
        cur.execute(
            "SELECT machine_tz FROM user_import_settings WHERE user_id = %s",
            (user_id,),
        )
        row = cur.fetchone()
    name = row[0] if row and row[0] else None
    zone = _machine_tz(name)
    return zone.key, zone


def discover_session_blocks(folder: Path) -> list:
    """
    Find all valid session blocks in a folder.

    Pairing rule: for each PLD timestamp, find the most-recent CSL timestamp
    that precedes it. CSL-only blocks (no matching PLD) are skipped.

    Returns list of dicts:
        {csl_ts, csl_path, eve_path, pld_ts, pld_path, brp_path, spo2_path}
    """
    files = list(folder.glob("*.edf"))
    if not files:
        return []

    by_ts_type = {}
    brp_paths = []
    for f in files:
        parts = f.stem.split("_")
        if len(parts) != 3:
            continue
        # timestamp = YYYYMMDD + HHMMSS
        ts = parts[0] + parts[1]
        ftype = parts[2]
        by_ts_type[(ts, ftype)] = f
        if ftype == "BRP":
            brp_paths.append(f)

    csl_timestamps = sorted(ts for (ts, t) in by_ts_type if t == "CSL")
    pld_timestamps = sorted(ts for (ts, t) in by_ts_type if t == "PLD")

    if not pld_timestamps:
        return []

    blocks = []
    for pld_ts in pld_timestamps:
        # Find the most recent CSL timestamp that is <= pld_ts
        matching_csl = None
        for csl_ts in reversed(csl_timestamps):
            if csl_ts <= pld_ts:
                matching_csl = csl_ts
                break
        if matching_csl is None:
            continue

        blocks.append({
            'csl_ts':   matching_csl,
            'csl_path': by_ts_type.get((matching_csl, 'CSL')),
            'eve_path': by_ts_type.get((matching_csl, 'EVE')),
            'pld_ts':   pld_ts,
            'pld_path': by_ts_type[(pld_ts, 'PLD')],
            'brp_path': by_ts_type.get((pld_ts, 'BRP')),
            'brp_paths': sorted(brp_paths),
            'spo2_path': by_ts_type.get((pld_ts, 'SA2')) or by_ts_type.get((pld_ts, 'SAD')),
        })

    return blocks


def derive_summary(pld_channels: dict, events: list, duration_seconds: int) -> dict:
    """Compute per-session summary statistics from PLD channels and EVE events."""
    duration_hours = duration_seconds / 3600.0

    ca = sum(1 for _, _, t in events if t == "Central Apnea")
    oa = sum(1 for _, _, t in events if t == "Obstructive Apnea")
    h = sum(1 for _, _, t in events if t == "Hypopnea")
    a = sum(1 for _, _, t in events if t == "Apnea")
    ar = sum(1 for _, _, t in events if t == "Arousal")
    ahi_events = ca + oa + h + a
    ahi = round(ahi_events / duration_hours, 2) if duration_hours > 0 else 0.0

    def safe_mean(vals):
        return round(statistics.mean(vals), 4) if vals else None

    def percentile(vals, pct):
        if not vals:
            return None
        s = sorted(vals)
        return round(s[int(pct * len(s))], 2)

    press = [v for v in pld_channels.get("Press.2s", []) if v > 0]
    leak = pld_channels.get("Leak.2s", [])
    rr = [v for v in pld_channels.get("RespRate.2s", []) if v > 0]
    tv = [v for v in pld_channels.get("TidVol.2s", []) if v > 0]
    mv = [v for v in pld_channels.get("MinVent.2s", []) if v > 0]
    snore = pld_channels.get("Snore.2s", [])
    fl = pld_channels.get("FlowLim.2s", [])

    return {
        "ahi": ahi,
        "central_apnea_count": ca,
        "obstructive_apnea_count": oa,
        "hypopnea_count": h,
        "apnea_count": a,
        "arousal_count": ar,
        "total_ahi_events": ahi_events,
        "avg_pressure": safe_mean(press),
        "p95_pressure": percentile(press, 0.95),
        "avg_leak": safe_mean(leak),
        "avg_resp_rate": safe_mean(rr),
        "avg_tidal_vol": safe_mean(tv),
        "avg_min_vent": safe_mean(mv),
        "avg_snore": safe_mean(snore),
        "avg_flow_lim": safe_mean(fl),
    }


def dedupe_events(events: list) -> list:
    deduped = []
    seen = set()
    for onset, duration, event_type in events:
        key = (event_type, round(float(onset), 1), None if duration is None else round(float(duration), 1))
        if key in seen:
            continue
        seen.add(key)
        deduped.append((onset, duration, event_type))
    return deduped


def events_for_block(events: list, csl_start: datetime, block_start: datetime, duration_seconds: int) -> list:
    block_end = block_start + timedelta(seconds=duration_seconds)
    assigned = []
    for onset, duration, event_type in events:
        event_dt = csl_start + timedelta(seconds=onset)
        if block_start <= event_dt <= block_end:
            assigned.append((onset, duration, event_type))
    return assigned


def import_folder(folder: Path, folder_date: date, conn, user_id: str):
    blocks = discover_session_blocks(folder)
    if not blocks:
        return 0

    imported = 0
    machine_tz_name, machine_tz = _machine_tz_for_user(conn, user_id)
    for block_idx, block in enumerate(blocks):
        pld_ts = block["pld_ts"]
        session_id = f"{folder_date.strftime('%Y%m%d')}_{pld_ts[8:10]}{pld_ts[10:12]}{pld_ts[12:14]}"

        try:
            # Parse PLD (required)
            pld_header, pld_channels = parse_pld(block["pld_path"])

            # Parse EVE (optional — some blocks may lack it)
            events = []
            if block['eve_path'] and block['eve_path'].exists():
                _, events = parse_eve(block['eve_path'])
            events = dedupe_events(events)

            # Get CSL start datetime for event absolute timestamps.
            # EDF timestamps are naive local machine time; attach MACHINE_TZ so
            # psycopg2 stores the correct UTC equivalent in TIMESTAMPTZ columns.
            csl_start = _localize(pld_header.start_datetime, machine_tz)  # fallback
            if block["csl_path"] and block["csl_path"].exists():
                csl_hdr = read_header(str(block["csl_path"]))
                csl_start = _localize(csl_hdr.start_datetime, machine_tz)

            pld_start = _localize(pld_header.start_datetime, machine_tz)
            duration_s = int(pld_header.num_records * pld_header.duration_per_record)
            events = events_for_block(events, csl_start, pld_start, duration_s)

            # Parse SA2/SAD (optional)
            spo2_data = None
            spo2_header = None
            spo2_start = None
            if block["spo2_path"] and block["spo2_path"].exists():
                spo2_header, spo2_data = parse_sa2(block["spo2_path"])
                spo2_start = _localize(spo2_header.start_datetime, machine_tz)

            summary = derive_summary(pld_channels, events, duration_s)

            session_data = {
                'session_id':         session_id,
                'folder_date':        folder_date,
                'block_index':        block_idx,
                'start_datetime':     pld_start,
                'pld_start_datetime': pld_start,
                'duration_seconds':   duration_s,
                'device_serial':      pld_header.device_serial or None,
                'manufacturer':       'ResMed',
                'has_spo2':           spo2_data is not None,
                'therapy_mode':       None,
                'mask_type':          None,
                'humidity_level':     None,
                'temperature_c':      None,
                'machine_tz':         machine_tz_name,
                'user_id':            user_id,
                **summary,
            }

            session_db_id = upsert_session(conn, session_data)
            replace_session_events(conn, session_db_id, events, csl_start)
            replace_session_metrics(conn, session_db_id, pld_header, pld_channels, pld_start)

            if spo2_data and spo2_header:
                replace_session_spo2(conn, session_db_id, spo2_header, spo2_data, spo2_start)
            replace_waveforms_for_block(conn, session_db_id, block, events, csl_start, machine_tz)

            conn.commit()
            imported += 1

        except Exception as e:
            conn.rollback()
            print(f"    ERROR block {block_idx} ({session_id}): {e}")

    return imported


def replace_waveforms_for_block(conn, session_db_id: str, block: dict, events: list, csl_start, machine_tz: ZoneInfo) -> bool:
    """Replace BRP waveform samples for a session using every BRP segment in the folder."""
    brp_paths = [path for path in block.get("brp_paths", []) if path and path.exists()]
    if not brp_paths or not events:
        clear_session_waveform(conn, session_db_id)
        return False

    inserted = False
    for brp_path in brp_paths:
        waveform_header, waveform = parse_brp(brp_path)
        waveform_start = _localize(waveform_header.start_datetime, machine_tz)
        replace_session_waveform(
            conn,
            session_db_id,
            waveform_header,
            waveform,
            events,
            start_datetime=waveform_start,
            csl_start_datetime=csl_start,
            delete_existing=not inserted,
        )
        inserted = True

    return inserted

def run_local_import(user_id: str, datalog_path: str, from_date: str | None = None) -> dict:
    """
    Programmatic entry point for server-triggered local imports.

    datalog_path must be an absolute path within /data/ (enforced by the caller).
    Returns stats dict: {"imported": N, "folders": N, "errors": N}
    """
    from pathlib import Path as _Path

    datalog = _Path(datalog_path)
    if not datalog.exists():
        raise FileNotFoundError(f"DATALOG path not found: {datalog_path}")

    folders = sorted([f for f in datalog.iterdir() if f.is_dir() and f.name.isdigit() and len(f.name) == 8])
    if from_date:
        folders = [f for f in folders if f.name >= from_date]

    conn = get_conn()
    stats = {"imported": 0, "folders": 0, "errors": 0}
    try:
        for folder in folders:
            try:
                folder_date = date(int(folder.name[:4]), int(folder.name[4:6]), int(folder.name[6:8]))
            except ValueError:
                continue
            try:
                n = import_folder(folder, folder_date, conn, user_id)
                if n > 0:
                    stats["imported"] += n
                    stats["folders"] += 1
            except Exception as e:
                print(f"  ERROR folder {folder.name}: {e}")
                stats["errors"] += 1
    finally:
        conn.close()

    print(f"Local import done. imported={stats['imported']} folders={stats['folders']} errors={stats['errors']}")
    return stats


def parse_args():
    parser = argparse.ArgumentParser(description="Import CPAP EDF data into PostgreSQL")
    parser.add_argument("--datalog", required=True, help="Absolute path to DATALOG folder")
    parser.add_argument("--user-id", required=True, dest="user_id", help="User UUID to associate sessions with")
    parser.add_argument("--folder", help="Import only this folder (YYYYMMDD)")
    parser.add_argument("--from", dest="from_date", help="Import folders from this date onward (YYYYMMDD)")
    return parser.parse_args()


def main():
    args = parse_args()
    datalog = Path(args.datalog)
    user_id = args.user_id
    conn = get_conn()

    if args.folder:
        folder = datalog / args.folder
        if not folder.exists():
            print(f"Folder not found: {folder}")
            sys.exit(1)
        folder_date = date(int(args.folder[:4]), int(args.folder[4:6]), int(args.folder[6:8]))
        n = import_folder(folder, folder_date, conn, user_id)
        print(f"{args.folder}: {n} session block(s) imported")
        conn.close()
        return

    folders = sorted([f for f in datalog.iterdir() if f.is_dir() and f.name.isdigit() and len(f.name) == 8])

    if args.from_date:
        folders = [f for f in folders if f.name >= args.from_date]

    total_sessions = 0
    total_folders = 0
    for folder in folders:
        try:
            folder_date = date(int(folder.name[:4]), int(folder.name[4:6]), int(folder.name[6:8]))
        except ValueError:
            continue

        print(f"  {folder.name} ... ", end="", flush=True)
        n = import_folder(folder, folder_date, conn, user_id)
        if n > 0:
            print(f"{n} block(s)")
            total_sessions += n
            total_folders += 1
        else:
            print("(empty)")

    conn.close()
    print(f"\nDone. {total_sessions} session blocks imported across {total_folders} nights.")


if __name__ == "__main__":
    main()