From 554771abfe7a167f8045b9962720620db7a282e3 Mon Sep 17 00:00:00 2001 From: prathiyuman Date: Wed, 15 Jul 2026 11:33:48 +0530 Subject: [PATCH] feat: implement 4-point demographic synchronization & patient validation --- fiveparaminte-main/contec_parser.py | 33 +++- fiveparaminte-main/contec_server.py | 22 ++- fiveparaminte-main/models.py | 10 +- fiveparaminte-main/routers/dashboard.py | 167 +++++++++++++++++++ fiveparaminte-main/schemas.py | 5 + fiveparaminte-main/static/dashboard.html | 204 ++++++++++++++++++++--- 6 files changed, 407 insertions(+), 34 deletions(-) diff --git a/fiveparaminte-main/contec_parser.py b/fiveparaminte-main/contec_parser.py index 966ebec..7663839 100644 --- a/fiveparaminte-main/contec_parser.py +++ b/fiveparaminte-main/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() diff --git a/fiveparaminte-main/contec_server.py b/fiveparaminte-main/contec_server.py index 675f4b6..975ec0e 100644 --- a/fiveparaminte-main/contec_server.py +++ b/fiveparaminte-main/contec_server.py @@ -261,11 +261,29 @@ 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 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( diff --git a/fiveparaminte-main/models.py b/fiveparaminte-main/models.py index f1fa727..9194976 100644 --- a/fiveparaminte-main/models.py +++ b/fiveparaminte-main/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/fiveparaminte-main/routers/dashboard.py b/fiveparaminte-main/routers/dashboard.py index 01bdf90..54710dc 100644 --- a/fiveparaminte-main/routers/dashboard.py +++ b/fiveparaminte-main/routers/dashboard.py @@ -269,3 +269,170 @@ 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.get("/match-patient") +def match_patient( + mrn: str = None, + name: 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. + + Call this from the dashboard iframe URL: + /api/dashboard?minimal=true&mrn=MRN001&name=John+Doe&gender=M&dob=19900115&age=36 + + Returns: + { matched: true/false, device_id, patient_id, bed_id, device_status, ... } + """ + if not mrn and not name: + return {"matched": False, "reason": "No patient identifiers supplied"} + + # Fetch all patients + all_patients = db.query(Patient).all() + now = datetime.now(timezone.utc) + + # Normalize incoming params + in_mrn = (mrn or "").strip().upper() + in_name = _normalize_name(name or "") + in_gender = _gender_code(gender or "") + in_dob = _normalize_dob(dob or "") + in_age = age + + matched_patient = None + match_score = 0 + + for p in all_patients: + score = 0 + + # --- MRN check (primary) --- + p_mrn = (p.mrn or p.patient_id or "").strip().upper() + if in_mrn and p_mrn == in_mrn: + score += 10 + elif in_mrn: + continue # MRN supplied but doesn't match → skip + + # --- Name check --- + if in_name: + p_name = _normalize_name(p.name or "") + if p_name and any(w in p_name for w in in_name): + score += 3 + + # --- Gender check --- + if in_gender and p.gender: + if _gender_code(p.gender) == in_gender: + score += 2 + + # --- DOB check --- + if in_dob and p.dob: + if _normalize_dob(p.dob) == in_dob: + score += 3 + + # --- Age check --- + if in_age is not None and p.dob: + calc_p_age = _calculate_age(p.dob) + if calc_p_age is not None and calc_p_age == in_age: + score += 2 + + if score > match_score: + match_score = score + matched_patient = p + + # To be matched, MRN must be found, and name/gender/dob must also be validated + # (Require at least a score of 12: MRN + Name, or MRN + Gender, etc.) + if not matched_patient or match_score < 12: + return {"matched": False, "reason": "No matching patient found in monitor database with 4-point verification"} + + # 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() + ) + + if not latest: + return { + "matched": True, + "patient_id": matched_patient.patient_id, + "device_id": None, + "bed_id": matched_patient.bed_id, + "reason": "Patient found but no readings 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": match_score, + } + diff --git a/fiveparaminte-main/schemas.py b/fiveparaminte-main/schemas.py index 6a8e69e..678f366 100644 --- a/fiveparaminte-main/schemas.py +++ b/fiveparaminte-main/schemas.py @@ -15,6 +15,11 @@ class NormalizedVitals(BaseModel): respiratory_rate: Optional[float] = None temperature: Optional[float] = None present_fields: Optional[list] = None + # 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/fiveparaminte-main/static/dashboard.html b/fiveparaminte-main/static/dashboard.html index aa380d4..2af6152 100644 --- a/fiveparaminte-main/static/dashboard.html +++ b/fiveparaminte-main/static/dashboard.html @@ -759,13 +759,67 @@ display: none !important; } - /* === Scrollbar === */ - ::-webkit-scrollbar { width: 6px; } - ::-webkit-scrollbar-track { background: transparent; } - ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 3px; } + /* === Verification Failure Overlay === */ + .verification-overlay { + position: fixed; + inset: 0; + background: rgba(248, 250, 252, 0.98); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; + padding: 24px; + color: var(--text-primary); + } + .verification-overlay .error-card { + background: #ffffff; + border: 1px solid rgba(220, 38, 38, 0.12); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + border-radius: 16px; + padding: 32px; + max-width: 460px; + width: 100%; + transition: all 0.3s ease; + } + .verification-overlay .error-icon { + font-size: 40px; + margin-bottom: 16px; + } + .verification-overlay h2 { + font-size: 19px; + font-weight: 700; + margin-bottom: 8px; + color: #dc2626; + } + .verification-overlay p { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; + } + .verification-overlay.loading h2 { + color: var(--spo2-color); + } + .verification-overlay.loading .error-icon { + animation: rotateGlow 2s linear infinite; + } + @keyframes rotateGlow { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + +
@@ -1025,6 +1079,65 @@ let activeDevices = []; let selectedDeviceId = localStorage.getItem('selectedDeviceId') || ''; + // Verification / Matching Mode State + let isMatchingMode = false; + let matchParams = {}; + + async function verifyPatientMatch() { + const overlay = document.getElementById('verificationOverlay'); + const icon = document.getElementById('verificationIcon'); + const title = document.getElementById('verificationTitle'); + const msg = document.getElementById('verificationMsg'); + + try { + const query = new URLSearchParams(matchParams).toString(); + const response = await fetch(`${API_BASE}/match-patient?${query}`); + if (!response.ok) throw new Error('Match verification API error'); + + const data = await response.json(); + if (data.matched) { + if (data.device_id) { + // Success - matched and connected! + overlay.style.display = 'none'; + // Hide device selector element to restrict access + const selectorContainer = document.querySelector('.device-selector-container'); + if (selectorContainer) selectorContainer.style.display = 'none'; + + if (selectedDeviceId !== data.device_id) { + selectedDeviceId = data.device_id; + clearVitalsDisplay(); + } + } else { + // Matched but device not transmitting yet + overlay.style.display = 'flex'; + overlay.className = 'verification-overlay loading'; + icon.textContent = '⏳'; + title.textContent = 'Connecting Monitor...'; + msg.textContent = `Patient ${data.patient_name || 'Record'} verified. Waiting for monitor to start transmitting...`; + selectedDeviceId = ''; + clearVitalsDisplay(); + } + } else { + // Match failed + overlay.style.display = 'flex'; + overlay.className = 'verification-overlay'; + icon.textContent = '❌'; + title.textContent = 'Access Restricted'; + msg.textContent = data.reason || 'This patient record does not match the active monitor configuration.'; + selectedDeviceId = ''; + clearVitalsDisplay(); + } + } catch (e) { + console.error("Match verification failed:", e); + // On API error, show connecting/retry state + overlay.style.display = 'flex'; + overlay.className = 'verification-overlay loading'; + icon.textContent = '🔄'; + title.textContent = 'Verification Server Offline'; + msg.textContent = 'Retrying verification match...'; + } + } + // === Initialize === document.addEventListener('DOMContentLoaded', () => { const urlParams = new URLSearchParams(window.location.search); @@ -1033,17 +1146,49 @@ document.body.classList.add('minimal-mode'); } + // Extract matching credentials from URL query params + const mrn = urlParams.get('mrn'); + const name = urlParams.get('name'); + const gender = urlParams.get('gender'); + const dob = urlParams.get('dob'); + const age = urlParams.get('age'); + + if (mrn || name) { + isMatchingMode = true; + matchParams = { mrn, name, gender, dob, age }; + + // Show verification loading overlay initially + const overlay = document.getElementById('verificationOverlay'); + overlay.style.display = 'flex'; + overlay.className = 'verification-overlay loading'; + document.getElementById('verificationIcon').textContent = '🔄'; + document.getElementById('verificationTitle').textContent = 'Verifying Patient Credentials...'; + document.getElementById('verificationMsg').textContent = 'Matching MRN, Name, Gender, DOB, and Age with live patient monitors...'; + } + initWaveforms(); updateClock(); setInterval(updateClock, 1000); - fetchDevices().then(() => { - tryWebSocket(); - startPolling(); - }); + + if (isMatchingMode) { + // In matching mode, verify credentials first, then poll/ws + verifyPatientMatch().then(() => { + tryWebSocket(); + startPolling(); + }); + // Periodically verify the match and update device mapping + setInterval(verifyPatientMatch, 5000); + } else { + fetchDevices().then(() => { + tryWebSocket(); + startPolling(); + }); + // Periodically refresh the list of active devices + setInterval(fetchDevices, 4000); + } + animateWaveforms(); fetchNetworkInfo(); - // Periodically refresh the list of active devices - setInterval(fetchDevices, 4000); }); // === Fetch Network Info === @@ -1250,14 +1395,16 @@ ws.onmessage = (event) => { const data = JSON.parse(event.data); // Filter messages by selected device or auto-select if nothing selected yet - if (!selectedDeviceId && data.device_id) { + if (!selectedDeviceId && data.device_id && !isMatchingMode) { selectDevice(data.device_id); } - if (data.device_id === selectedDeviceId) { + if (data.device_id && data.device_id === selectedDeviceId) { updateVitals(data); } // Refresh device list status - fetchDevices(); + if (!isMatchingMode) { + fetchDevices(); + } }; ws.onclose = () => { useWebSocket = false; @@ -1279,6 +1426,9 @@ } async function fetchLatestReadings() { + if (isMatchingMode && !selectedDeviceId) { + return; + } try { const url = selectedDeviceId ? `${API_BASE}/latest-readings?device_id=${encodeURIComponent(selectedDeviceId)}&limit=1` @@ -1304,19 +1454,21 @@ } // Fetch system status - const statusResp = await fetch(`${API_BASE}/status`); - if (statusResp.ok) { - const status = await statusResp.json(); - document.getElementById('totalReadings').textContent = status.total_readings_stored; - document.getElementById('apiStatus').textContent = - status.pending_transmissions > 0 - ? `${status.pending_transmissions} pending` - : 'All sent ✓'; - - // Update active ports list in guide - const portsEl = document.getElementById('activePortsList'); - if (portsEl && status.active_ports && status.active_ports.length > 0) { - portsEl.textContent = status.active_ports.join(', '); + if (!isMatchingMode) { + const statusResp = await fetch(`${API_BASE}/status`); + if (statusResp.ok) { + const status = await statusResp.json(); + document.getElementById('totalReadings').textContent = status.total_readings_stored; + document.getElementById('apiStatus').textContent = + status.pending_transmissions > 0 + ? `${status.pending_transmissions} pending` + : 'All sent ✓'; + + // Update active ports list in guide + const portsEl = document.getElementById('activePortsList'); + if (portsEl && status.active_ports && status.active_ports.length > 0) { + portsEl.textContent = status.active_ports.join(', '); + } } } } catch (e) {