fix: sync root repository files with updated fiveparaminte-main code

This commit is contained in:
2026-07-15 16:13:43 +05:30
parent 4a0aae6003
commit 2011544f53
6 changed files with 977 additions and 1581 deletions
+56 -4
View File
@@ -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)
+46 -2
View File
@@ -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
+7 -3
View File
@@ -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"
+256 -2
View File
@@ -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,
}
+9 -1
View File
@@ -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
+650 -1616
View File
File diff suppressed because it is too large Load Diff