Ctrl K
ComunidadGuías…

importer/db.py

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

Archivo original ↗Preguntar a KiBot
"""
PostgreSQL connection and upsert helpers for the CPAP importer.
"""

import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any

import psycopg2
import psycopg2.extras


def _load_dotenv() -> None:
    env_path = Path(__file__).resolve().parent.parent / ".env"
    if not env_path.exists():
        return
    for raw_line in env_path.read_text().splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        os.environ.setdefault(key.strip(), value.strip())


_load_dotenv()

_raw_dsn = os.environ.get("DATABASE_URL", "dbname=cpap")
DB_DSN = _raw_dsn.replace("postgresql+psycopg2://", "postgresql://", 1)


def get_conn() -> Any:
    """Establish a connection to the PostgreSQL database.

    Returns:
        A psycopg2 connection object initialized with DB_DSN.
    """
    return psycopg2.connect(DB_DSN)


def session_exists(conn: Any, user_id: str, session_id: str) -> bool:
    """Check if a session already exists in the database.

    Args:
        conn: The psycopg2 database connection.
        user_id: The UUID string of the user.
        session_id: The unique session identifier string.

    Returns:
        True if the session exists, False otherwise.
    """
    with conn.cursor() as cur:
        cur.execute(
            "SELECT 1 FROM sessions WHERE user_id = %s AND session_id = %s",
            (user_id, session_id),
        )
        return cur.fetchone() is not None


def get_session_db_id(conn: Any, user_id: str, session_id: str) -> str | None:
    """Retrieve the primary database ID of a session.

    Args:
        conn: The psycopg2 database connection.
        user_id: The UUID string of the user.
        session_id: The unique session identifier string.

    Returns:
        The UUID string of the session from the database, or None if not found.
    """
    with conn.cursor() as cur:
        cur.execute(
            "SELECT id FROM sessions WHERE user_id = %s AND session_id = %s",
            (user_id, session_id),
        )
        row = cur.fetchone()
        return row[0] if row else None


def upsert_session(conn: Any, data: dict) -> int:
    """Insert a new session or update an existing one.

    Args:
        conn: The psycopg2 database connection.
        data: A dictionary containing all required column values for the
            sessions table.

    Returns:
        The database ID of the inserted or updated session.
    """
    data.setdefault("manufacturer", None)
    sql = """
    INSERT INTO sessions (
        session_id, folder_date, block_index, start_datetime, pld_start_datetime,
        duration_seconds, device_serial, ahi,
        central_apnea_count, obstructive_apnea_count, hypopnea_count,
        apnea_count, arousal_count, total_ahi_events,
        avg_pressure, p95_pressure, avg_leak, avg_resp_rate, avg_tidal_vol,
        avg_min_vent, avg_snore, avg_flow_lim, has_spo2,
        therapy_mode, mask_type, humidity_level, temperature_c,
        machine_tz, manufacturer, user_id, updated_at
    ) VALUES (
        %(session_id)s, %(folder_date)s, %(block_index)s, %(start_datetime)s, %(pld_start_datetime)s,
        %(duration_seconds)s, %(device_serial)s, %(ahi)s,
        %(central_apnea_count)s, %(obstructive_apnea_count)s, %(hypopnea_count)s,
        %(apnea_count)s, %(arousal_count)s, %(total_ahi_events)s,
        %(avg_pressure)s, %(p95_pressure)s, %(avg_leak)s, %(avg_resp_rate)s, %(avg_tidal_vol)s,
        %(avg_min_vent)s, %(avg_snore)s, %(avg_flow_lim)s, %(has_spo2)s,
        %(therapy_mode)s, %(mask_type)s, %(humidity_level)s, %(temperature_c)s,
        %(machine_tz)s, %(manufacturer)s, %(user_id)s, NOW()
    )
    ON CONFLICT (user_id, session_id) DO UPDATE SET
        folder_date             = EXCLUDED.folder_date,
        block_index             = EXCLUDED.block_index,
        start_datetime          = EXCLUDED.start_datetime,
        pld_start_datetime      = EXCLUDED.pld_start_datetime,
        duration_seconds        = EXCLUDED.duration_seconds,
        device_serial           = EXCLUDED.device_serial,
        ahi                     = EXCLUDED.ahi,
        central_apnea_count     = EXCLUDED.central_apnea_count,
        obstructive_apnea_count = EXCLUDED.obstructive_apnea_count,
        hypopnea_count          = EXCLUDED.hypopnea_count,
        apnea_count             = EXCLUDED.apnea_count,
        arousal_count           = EXCLUDED.arousal_count,
        total_ahi_events        = EXCLUDED.total_ahi_events,
        avg_pressure            = EXCLUDED.avg_pressure,
        p95_pressure            = EXCLUDED.p95_pressure,
        avg_leak                = EXCLUDED.avg_leak,
        avg_resp_rate           = EXCLUDED.avg_resp_rate,
        avg_tidal_vol           = EXCLUDED.avg_tidal_vol,
        avg_min_vent            = EXCLUDED.avg_min_vent,
        avg_snore               = EXCLUDED.avg_snore,
        avg_flow_lim            = EXCLUDED.avg_flow_lim,
        has_spo2                = EXCLUDED.has_spo2,
        therapy_mode            = EXCLUDED.therapy_mode,
        mask_type               = EXCLUDED.mask_type,
        humidity_level          = EXCLUDED.humidity_level,
        temperature_c           = EXCLUDED.temperature_c,
        machine_tz              = EXCLUDED.machine_tz,
        manufacturer            = COALESCE(NULLIF(EXCLUDED.manufacturer, ''), NULLIF(sessions.manufacturer, '')),
        -- user_id intentionally excluded: re-import must not change ownership
        updated_at              = NOW()
    RETURNING id
    """
    with conn.cursor() as cur:
        cur.execute(sql, data)
        return cur.fetchone()[0]


def replace_session_events(conn, session_db_id: int, events: list, csl_start: datetime):
    """Delete existing events for this session and insert the new list."""
    with conn.cursor() as cur:
        cur.execute("DELETE FROM session_events WHERE session_id = %s", (session_db_id,))

    if not events:
        return

    rows = []
    for onset, duration, event_type in _dedupe_events(events):
        event_dt = csl_start + timedelta(seconds=onset)
        rows.append((session_db_id, event_type, onset, duration if duration is not None else None, event_dt))

    sql = """
    INSERT INTO session_events (session_id, event_type, onset_seconds, duration_seconds, event_datetime)
    VALUES %s
    """
    with conn.cursor() as cur:
        psycopg2.extras.execute_values(cur, sql, rows)


def replace_session_metrics(conn, session_db_id: int, header, channels: dict, start_datetime: datetime | None = None):
    """Delete existing metrics for this session and bulk-insert all PLD time-series rows."""
    with conn.cursor() as cur:
        cur.execute("DELETE FROM session_metrics WHERE session_id = %s", (session_db_id,))

    labels = [
        "MaskPress.2s",
        "Press.2s",
        "EprPress.2s",
        "Leak.2s",
        "RespRate.2s",
        "TidVol.2s",
        "MinVent.2s",
        "Snore.2s",
        "FlowLim.2s",
    ]

    # Determine samples per record from first non-Crc16 signal
    data_signals = [s for s in header.signals if s.label != "Crc16"]
    spr = data_signals[0].num_samples_per_record  # 30 samples per 60s record = 2s epochs
    dur = header.duration_per_record  # 60.0 seconds
    epoch = dur / spr  # 2.0 seconds
    pld_start = start_datetime or header.start_datetime

    rows = []
    for rec in range(header.num_records):
        for si in range(spr):
            abs_idx = rec * spr + si
            ts = pld_start + timedelta(seconds=rec * dur + si * epoch)
            row = [session_db_id, ts]
            for label in labels:
                vals = channels.get(label)
                row.append(round(vals[abs_idx], 4) if vals and abs_idx < len(vals) else None)
            rows.append(tuple(row))

    sql = """
    INSERT INTO session_metrics
        (session_id, ts, mask_pressure, pressure, epr_pressure, leak, resp_rate,
         tidal_vol, min_vent, snore, flow_lim)
    VALUES %s
    """
    with conn.cursor() as cur:
        psycopg2.extras.execute_values(cur, sql, rows, page_size=5000)


def replace_session_spo2(conn, session_db_id: int, header, spo2_data: tuple, start_datetime: datetime | None = None):
    """Delete existing SpO2 rows and insert new ones."""
    with conn.cursor() as cur:
        cur.execute("DELETE FROM session_spo2 WHERE session_id = %s", (session_db_id,))

    pulse_vals, spo2_vals = spo2_data
    spo2_sig = header.signals[1]  # SpO2.1s at 1 Hz
    spr = spo2_sig.num_samples_per_record  # 1 sample/second per record
    dur = header.duration_per_record
    pld_start = start_datetime or header.start_datetime

    rows = []
    for rec in range(header.num_records):
        for si in range(spr):
            abs_idx = rec * spr + si
            ts = pld_start + timedelta(seconds=rec * dur + si)
            p = pulse_vals[abs_idx] if abs_idx < len(pulse_vals) else None
            s = spo2_vals[abs_idx] if abs_idx < len(spo2_vals) else None
            rows.append((session_db_id, ts, s if s != -1 else None, p if p != -1 else None))

    sql = "INSERT INTO session_spo2 (session_id, ts, spo2, pulse) VALUES %s"
    with conn.cursor() as cur:
        psycopg2.extras.execute_values(cur, sql, rows, page_size=5000)


def replace_session_waveform(
    conn,
    session_db_id: int,
    header,
    channels: dict,
    events: list | None = None,
    before_seconds: int = 120,
    after_seconds: int = 180,
    start_datetime=None,
    csl_start_datetime=None,
    delete_existing: bool = True,
):
    """
    Delete existing BRP waveform rows and bulk-insert event-focused samples.

    Full-night BRP is large: a typical 7-hour night is ~630k rows at 25 Hz.
    The Event Inspector only needs windows around scored events, so by default
    we store merged event windows rather than the entire night.
    """
    if delete_existing:
        clear_session_waveform(conn, session_db_id)

    flow_vals = channels.get("Flow.40ms")
    pressure_vals = channels.get("Press.40ms")
    if not flow_vals and not pressure_vals:
        return

    data_signals = [s for s in header.signals if s.label != "Crc16"]
    if not data_signals:
        return

    spr = data_signals[0].num_samples_per_record
    dur = header.duration_per_record
    if spr <= 0 or dur <= 0:
        return

    epoch = dur / spr
    start = start_datetime or header.start_datetime
    total_samples = spr * header.num_records
    windows = _merge_waveform_windows(
        _events_relative_to_waveform(events or [], csl_start_datetime, start),
        before_seconds,
        after_seconds,
    )
    if not windows:
        return

    rows = []
    for start_idx, end_idx in windows:
        start_idx = max(0, int(start_idx / epoch))
        end_idx = min(total_samples, int(end_idx / epoch) + 1)
        for idx in range(start_idx, end_idx):
            ts = start + timedelta(seconds=idx * epoch)
            flow = flow_vals[idx] if flow_vals and idx < len(flow_vals) else None
            pressure = pressure_vals[idx] if pressure_vals and idx < len(pressure_vals) else None
            rows.append(
                (
                    session_db_id,
                    ts,
                    round(flow, 4) if flow is not None else None,
                    round(pressure, 2) if pressure is not None else None,
                )
            )

    if not rows:
        return

    sql = "INSERT INTO session_waveform (session_id, ts, flow, pressure) VALUES %s"
    with conn.cursor() as cur:
        psycopg2.extras.execute_values(cur, sql, rows, page_size=5000)


def clear_session_waveform(conn, session_db_id: int):
    with conn.cursor() as cur:
        cur.execute("DELETE FROM session_waveform WHERE session_id = %s", (session_db_id,))


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_relative_to_waveform(events: list, csl_start: datetime | None, waveform_start: datetime) -> list:
    if csl_start is None:
        return events

    relative_events = []
    for onset, duration, event_type in events:
        event_dt = csl_start + timedelta(seconds=onset)
        relative_onset = (event_dt - waveform_start).total_seconds()
        relative_events.append((relative_onset, duration, event_type))
    return relative_events


def _merge_waveform_windows(events: list, before_seconds: int, after_seconds: int) -> list[tuple[float, float]]:
    windows = []
    for onset, duration, _event_type in events:
        duration = duration or 0
        windows.append((max(0, onset - before_seconds), onset + duration + after_seconds))
    if not windows:
        return []

    windows.sort()
    merged = [windows[0]]
    for start, end in windows[1:]:
        prev_start, prev_end = merged[-1]
        if start <= prev_end:
            merged[-1] = (prev_start, max(prev_end, end))
        else:
            merged.append((start, end))
    return merged