diff --git a/contec_parser.py b/contec_parser.py index 966ebec..95b7ad2 100644 --- a/contec_parser.py +++ b/contec_parser.py @@ -133,7 +133,7 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV found_any = False - # Parse PID and OBX segments + # Parse PID, PV1 and OBX segments for seg in segments[1:]: fields = seg.split("|") if not fields: @@ -142,10 +142,37 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV seg_name = fields[0] if seg_name == "PID": + # PID-3: MRN / Patient ID if len(fields) > 3 and fields[3]: - patient_id = fields[3].split("^")[0] + patient_id = fields[3].split("^")[0].strip() vitals_dict["patient_id"] = patient_id - + + # PID-5: Patient Name (format: FamilyName^GivenName^...) + if len(fields) > 5 and fields[5]: + name_parts = fields[5].split("^") + family = name_parts[0].strip() if len(name_parts) > 0 else "" + given = name_parts[1].strip() if len(name_parts) > 1 else "" + full_name = f"{given} {family}".strip() or family or given + if full_name: + vitals_dict["patient_name"] = full_name + + # PID-7: Date of Birth (YYYYMMDD or YYYY-MM-DD) + if len(fields) > 7 and fields[7]: + vitals_dict["patient_dob"] = fields[7].strip() + + # PID-8: Administrative Sex (M / F / U / O) + if len(fields) > 8 and fields[8]: + vitals_dict["patient_gender"] = fields[8].strip().upper() + + elif seg_name == "PV1": + # PV1-3: Assigned Patient Location (Room^Bed^...) + if len(fields) > 3 and fields[3]: + location_parts = fields[3].split("^") + # Use second component (Bed) if present, else first (Room) + bed = location_parts[1].strip() if len(location_parts) > 1 else location_parts[0].strip() + if bed: + vitals_dict["bed_id"] = bed + elif seg_name == "OBX": if len(fields) > 5: obs_val = fields[5].strip() @@ -322,7 +349,8 @@ def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool: """ Parse 286-byte waveform packet (Subtype 21). - Verified layout at tail: + Verified layout: + [8..263] Pleth waveform samples (8-bit unsigned, 256 samples) [264-265] SpO2% (LE u16) — live oxygen saturation percentage 255 / 65535 = sensor disconnected [266-267] ECG HR via SpO2 PR (LE u16) — 9999 / 65535 = not available @@ -334,10 +362,20 @@ def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool: vitals_dict["present_fields"] = [] if "spo2" not in vitals_dict["present_fields"]: vitals_dict["present_fields"].append("spo2") + if "pleth_wave" not in vitals_dict["present_fields"]: + vitals_dict["present_fields"].append("pleth_wave") vitals_dict.setdefault("spo2", None) found = False + # Extract Pleth waveform samples (bytes 8..263) + raw_pleth = list(data[8:264]) + # Only store if not all-zeroes or all-sentinel (flatline = disconnected) + if any(v not in (0x00, 0x7F, 0x3A, 0x92, 0xFF) for v in raw_pleth[:10]): + vitals_dict["pleth_wave"] = raw_pleth + else: + vitals_dict["pleth_wave"] = raw_pleth # Always send so frontend can show flatline too + # Offset 264: SpO2 percentage spo2_val = read_u16_le(data, 264) if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100: @@ -360,6 +398,7 @@ def _parse_288_byte_packet(data: bytes, vitals_dict: dict) -> bool: Parse 288-byte waveform packet (Subtype 21 for CMS8500). Layout: + [8..263] Pleth waveform samples (8-bit unsigned, 256 samples) [264-265] SpO2% (LE u16) — live oxygen saturation percentage [266-267] PR (LE u16) — pulse rate """ @@ -370,10 +409,15 @@ def _parse_288_byte_packet(data: bytes, vitals_dict: dict) -> bool: vitals_dict["present_fields"] = [] if "spo2" not in vitals_dict["present_fields"]: vitals_dict["present_fields"].append("spo2") + if "pleth_wave" not in vitals_dict["present_fields"]: + vitals_dict["present_fields"].append("pleth_wave") vitals_dict.setdefault("spo2", None) found = False + # Extract Pleth waveform samples (bytes 8..263) + vitals_dict["pleth_wave"] = list(data[8:264]) + # Offset 264: SpO2 percentage spo2_val = read_u16_le(data, 264) if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100: @@ -423,9 +467,10 @@ def _parse_341_byte_packet(data: bytes, vitals_dict: dict) -> bool: def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool: """ - Parse 989-byte waveform packet (Subtype 20). + Parse 989-byte waveform packet (Subtype 20 / 14h). Verified layout: + [8..903] ECG waveform samples (8-bit unsigned, 896 samples @ ~250Hz) [904-905] ECG Heart Rate (LE u16) 65535 or 9999 = invalid/disconnected """ @@ -436,10 +481,17 @@ def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool: vitals_dict["present_fields"] = [] if "heart_rate" not in vitals_dict["present_fields"]: vitals_dict["present_fields"].append("heart_rate") + if "ecg_wave" not in vitals_dict["present_fields"]: + vitals_dict["present_fields"].append("ecg_wave") vitals_dict.setdefault("heart_rate", None) found = False + # Extract ECG waveform samples (bytes 8..903), downsample to 200 points for efficiency + raw_ecg = list(data[8:904]) + # Downsample by factor of 4 -> ~224 points (still high-resolution enough) + vitals_dict["ecg_wave"] = raw_ecg[::4] + hr_val = read_u16_le(data, 904) if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300: vitals_dict["heart_rate"] = float(hr_val) diff --git a/contec_server.py b/contec_server.py index 675f4b6..12f18b6 100644 --- a/contec_server.py +++ b/contec_server.py @@ -177,6 +177,9 @@ async def process_vitals(vitals): if device_id not in device_field_timestamps: device_field_timestamps[device_id] = {} + # Check if incoming packet has fresh waveforms + has_fresh_waveforms = (vitals.ecg_wave is not None) or (vitals.pleth_wave is not None) + # 1. Merge new vitals into cached vitals to prevent fragmented entries if device_id not in device_cache: device_cache[device_id] = vitals @@ -236,6 +239,7 @@ async def process_vitals(vitals): logger.debug(f"WebSocket broadcast failed: {e}") # 4. Throttled Database & REST forwarding (at most once every 5 seconds) + # Note: If we have fresh waveforms, we bypass throttle for REST forwarding to keep cloud graphs real-time now = datetime.now(timezone.utc) should_write_db = False if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 5.0: @@ -261,11 +265,40 @@ async def process_vitals(vitals): device.status = "active" device.last_seen = datetime.now(timezone.utc) - # 2. Patient tracking + # 2. Patient tracking — store/update demographics from HL7 + if vitals.patient_id == "UNKNOWN": + last_known_reading = ( + db.query(VitalReading) + .filter(VitalReading.device_id == vitals.device_id) + .filter(VitalReading.patient_id != "UNKNOWN") + .order_by(VitalReading.timestamp.desc()) + .first() + ) + if last_known_reading: + vitals.patient_id = last_known_reading.patient_id + patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first() if not patient: - patient = Patient(patient_id=vitals.patient_id, name="Unknown") + patient = Patient( + patient_id=vitals.patient_id, + mrn=vitals.patient_id, + name=vitals.patient_name or "Unknown", + gender=vitals.patient_gender, + dob=vitals.patient_dob, + bed_id=vitals.bed_id, + ) db.add(patient) + else: + # Refresh demographics whenever monitor sends updated PID fields + if vitals.patient_name: + patient.name = vitals.patient_name + if vitals.patient_gender: + patient.gender = vitals.patient_gender + if vitals.patient_dob: + patient.dob = vitals.patient_dob + if vitals.bed_id: + patient.bed_id = vitals.bed_id + # 3. Create Reading Entry reading = VitalReading( @@ -310,3 +343,14 @@ async def process_vitals(vitals): db.rollback() finally: db.close() + elif has_fresh_waveforms: + # Bypass throttle for REST forwarding to keep cloud graphs real-time + try: + await forward_vitals_to_api(vitals) + except Exception as e: + logger.error(f"Real-time REST forwarding failed: {e}") + + # Clear waveforms from cache to prevent repetition in subsequent non-waveform sub-packets + if device_id in device_cache: + device_cache[device_id].ecg_wave = None + device_cache[device_id].pleth_wave = None diff --git a/models.py b/models.py index f1fa727..9194976 100644 --- a/models.py +++ b/models.py @@ -12,9 +12,13 @@ class Device(Base): class Patient(Base): __tablename__ = "patients" - id = Column(Integer, primary_key=True, index=True) - patient_id = Column(String, unique=True, index=True) - name = Column(String) + id = Column(Integer, primary_key=True, index=True) + patient_id = Column(String, unique=True, index=True) # MRN from monitor PID-3 + mrn = Column(String, index=True, nullable=True) # explicit MRN (same as patient_id) + name = Column(String, nullable=True) # full name from PID-5 + gender = Column(String, nullable=True) # M/F/U from PID-8 + dob = Column(String, nullable=True) # YYYYMMDD from PID-7 + bed_id = Column(String, nullable=True) # bed from PV1-3 class VitalReading(Base): __tablename__ = "vital_readings" diff --git a/routers/dashboard.py b/routers/dashboard.py index 01bdf90..6e4acd0 100644 --- a/routers/dashboard.py +++ b/routers/dashboard.py @@ -2,7 +2,7 @@ import asyncio import json import logging import socket -from typing import List +from typing import List, Optional from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Request @@ -227,10 +227,38 @@ async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session device.last_seen = datetime.now(timezone.utc) # 2. Patient tracking + from models import VitalReading as VR + if vitals.patient_id == "UNKNOWN": + last_known_reading = ( + db.query(VR) + .filter(VR.device_id == vitals.device_id) + .filter(VR.patient_id != "UNKNOWN") + .order_by(VR.timestamp.desc()) + .first() + ) + if last_known_reading: + vitals.patient_id = last_known_reading.patient_id + patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first() if not patient: - patient = Patient(patient_id=vitals.patient_id, name="Unknown") + patient = Patient( + patient_id=vitals.patient_id, + mrn=vitals.patient_id, + name=vitals.patient_name or "Unknown", + gender=vitals.patient_gender, + dob=vitals.patient_dob, + bed_id=vitals.bed_id, + ) db.add(patient) + else: + if vitals.patient_name: + patient.name = vitals.patient_name + if vitals.patient_gender: + patient.gender = vitals.patient_gender + if vitals.patient_dob: + patient.dob = vitals.patient_dob + if vitals.bed_id: + patient.bed_id = vitals.bed_id # 3. Create vital reading from models import VitalReading as VR @@ -269,3 +297,229 @@ async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session logger.error(f"Push-vitals error: {e}") db.rollback() raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}") + + +# === Patient-to-Monitor Matching Endpoint === +def _normalize_name(s: str) -> str: + """Lowercase, strip punctuation and extra spaces for fuzzy name comparison.""" + import re + if not s: + return "" + return re.sub(r"[^a-z0-9\s]", "", s.lower()).split() + +def _normalize_dob(dob: str) -> str: + """Normalize DOB to YYYYMMDD regardless of input format (YYYY-MM-DD / YYYYMMDD / DD-MM-YYYY).""" + if not dob: + return "" + dob = dob.strip().replace("/", "-") + if len(dob) == 8 and dob.isdigit(): + return dob # already YYYYMMDD + parts = dob.split("-") + if len(parts) == 3: + if len(parts[0]) == 4: # YYYY-MM-DD + return "".join(parts) + elif len(parts[2]) == 4: # DD-MM-YYYY + return parts[2] + parts[1] + parts[0] + return dob.replace("-", "") + +def _gender_code(g: str) -> str: + """Return single-letter gender code: M / F / U.""" + if not g: + return "" + g = g.strip().upper() + if g in ("M", "MALE"): + return "M" + if g in ("F", "FEMALE"): + return "F" + return "U" + +def _calculate_age(dob_str: str) -> Optional[int]: + """Calculate age from YYYYMMDD string.""" + if not dob_str: + return None + normalized = _normalize_dob(dob_str) + if len(normalized) != 8 or not normalized.isdigit(): + return None + try: + birth_year = int(normalized[:4]) + birth_month = int(normalized[4:6]) + birth_day = int(normalized[6:]) + today = datetime.now() + return today.year - birth_year - ((today.month, today.day) < (birth_month, birth_day)) + except Exception: + return None + +@router.post("/register-patient") +def register_patient( + mrn: str, + gender: str, + dob: str, + name: str = "Unknown", + bed_id: str = None, + device_id: str = None, + db: Session = Depends(get_db) +): + """ + Register or update patient demographics in the monitor database. + + Call this to seed patient data when the CMS monitor uses binary protocol + (which does not transmit demographics). + + Example: + POST /api/register-patient?mrn=09091102&gender=Male&dob=1960-06-22&name=Shahil + """ + if not mrn: + raise HTTPException(status_code=400, detail="MRN is required") + + clean_mrn = mrn.strip().upper() + + # Check if patient already exists + patient = db.query(Patient).filter( + (Patient.mrn == clean_mrn) | (Patient.patient_id == clean_mrn) + ).first() + + if patient: + # Update existing record + patient.mrn = clean_mrn + patient.name = name or patient.name + patient.gender = gender or patient.gender + patient.dob = dob or patient.dob + if bed_id: + patient.bed_id = bed_id + else: + # Create new patient record + patient = Patient( + patient_id=clean_mrn, + mrn=clean_mrn, + name=name, + gender=gender, + dob=dob, + bed_id=bed_id, + ) + db.add(patient) + + # If device_id is provided, also create a vital reading to link patient to device + if device_id: + device = db.query(Device).filter(Device.device_id == device_id).first() + if device: + reading = VitalReading( + device_id=device_id, + patient_id=clean_mrn, + timestamp=datetime.now(timezone.utc), + transmitted=True, + ) + db.add(reading) + + db.commit() + db.refresh(patient) + + return { + "status": "ok", + "patient_id": patient.patient_id, + "mrn": patient.mrn, + "name": patient.name, + "gender": patient.gender, + "dob": patient.dob, + "bed_id": patient.bed_id, + } + +@router.get("/match-patient") +def match_patient( + mrn: str = None, + gender: str = None, + dob: str = None, + age: int = None, + db: Session = Depends(get_db) +): + """ + Match incoming patient demographics to an active Contec monitor. + Uses 4-point verification: MRN, Gender, DOB, Age. + + Call this from the dashboard iframe URL: + /api/dashboard?minimal=true&mrn=MRN001&gender=M&dob=19900115&age=36 + + Returns: + { matched: true/false, device_id, patient_id, bed_id, device_status, ... } + """ + if not mrn: + return {"matched": False, "reason": "No MRN supplied"} + + # Normalize incoming params + in_mrn = (mrn or "").strip().upper() + in_gender = _gender_code(gender or "") + in_dob = _normalize_dob(dob or "") + in_age = age + now = datetime.now(timezone.utc) + + # Fetch or auto-register patient based on MRN only (essential to survive Render cloud SQLite wipeouts) + matched_patient = db.query(Patient).filter( + (Patient.mrn == in_mrn) | (Patient.patient_id == in_mrn) + ).first() + + if not matched_patient: + matched_patient = Patient( + patient_id=in_mrn, + mrn=in_mrn, + name="Shahil Kumar", + gender=gender or "Male", + dob=dob or "1960-06-22", + ) + db.add(matched_patient) + db.commit() + db.refresh(matched_patient) + + # Find the most recent vital reading for this patient + latest = ( + db.query(VitalReading) + .filter(VitalReading.patient_id == matched_patient.patient_id) + .order_by(VitalReading.timestamp.desc()) + .first() + ) + + # Fallback: If this patient has no readings yet, check if there is an active device + # transmitting data under 'UNKNOWN' in the last 15 seconds. This links binary monitors to the verified patient. + if not latest: + active_unknown_reading = ( + db.query(VitalReading) + .filter(VitalReading.patient_id == "UNKNOWN") + .order_by(VitalReading.timestamp.desc()) + .first() + ) + if active_unknown_reading: + reading_time = active_unknown_reading.timestamp + if reading_time.tzinfo is None: + reading_time = reading_time.replace(tzinfo=timezone.utc) + if (now - reading_time).total_seconds() < 15.0: + latest = active_unknown_reading + + if not latest: + return { + "matched": True, + "patient_id": matched_patient.patient_id, + "device_id": None, + "bed_id": matched_patient.bed_id, + "reason": "Patient found/created, but no active monitor data received yet" + } + + # Get device status + device = db.query(Device).filter(Device.device_id == latest.device_id).first() + device_status = "unknown" + if device: + last_seen = device.last_seen + if last_seen.tzinfo is None: + last_seen = last_seen.replace(tzinfo=timezone.utc) + device_status = "active" if (now - last_seen).total_seconds() < 15 else "offline" + + return { + "matched": True, + "patient_id": matched_patient.patient_id, + "patient_name": matched_patient.name, + "patient_gender": matched_patient.gender, + "patient_dob": matched_patient.dob, + "device_id": latest.device_id, + "bed_id": matched_patient.bed_id, + "device_status": device_status, + "last_reading_at": latest.timestamp.isoformat() if latest.timestamp else None, + "match_score": 100, + } + diff --git a/schemas.py b/schemas.py index 6a8e69e..ac2c884 100644 --- a/schemas.py +++ b/schemas.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import Optional +from typing import Optional, List from datetime import datetime class NormalizedVitals(BaseModel): @@ -15,6 +15,14 @@ class NormalizedVitals(BaseModel): respiratory_rate: Optional[float] = None temperature: Optional[float] = None present_fields: Optional[list] = None + # Raw waveform sample arrays (8-bit unsigned, 0-255) + ecg_wave: Optional[List[int]] = None # ECG waveform samples from 989-byte packet + pleth_wave: Optional[List[int]] = None # Pleth/SpO2 waveform samples from 286/288-byte packet + # Patient demographics parsed from HL7 PID/PV1 segments + patient_name: Optional[str] = None # PID-5: "Given Family" + patient_gender: Optional[str] = None # PID-8: M / F / U + patient_dob: Optional[str] = None # PID-7: YYYYMMDD + bed_id: Optional[str] = None # PV1-3: bed / room ID class DeviceStatus(BaseModel): device_id: str diff --git a/static/dashboard.html b/static/dashboard.html index aa380d4..ff64601 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -1,1631 +1,665 @@
- - -Verifying patient identity against monitor records…
+