# Guide 22: Clinical Scoring Engines (NEWS2, GCS, SOFA, qSOFA) ## What Are Clinical Scoring Systems? In medicine, a single vital sign (like heart rate = 110) doesn't tell you much on its own — it could be a patient exercising or a patient in septic shock. **Clinical scoring systems** combine multiple vital signs and lab values into a single number that predicts how sick a patient is and whether they need urgent intervention. Think of it like a weather severity index: temperature alone doesn't tell you if a storm is dangerous, but combining temperature, wind speed, pressure, and humidity gives you a meaningful risk score. This project implements four clinical scoring systems: | Score | Full Name | What It Measures | Parameters | Alert Threshold | |-------|-----------|-----------------|------------|-----------------| | **NEWS2** | National Early Warning Score 2 | General deterioration risk | 7 vital signs | Score >= 7 → Emergency | | **GCS** | Glasgow Coma Scale | Level of consciousness | 3 components (Eye, Verbal, Motor) | Total <= 8 → Severe (Critical) | | **qSOFA** | Quick SOFA | Bedside sepsis screen | 3 criteria | >= 2 criteria met → Screen positive | | **SOFA** | Sequential Organ Failure Assessment | Organ dysfunction severity | 6 organ systems | Delta >= 2 from baseline → Sepsis | --- ## How Scoring Engines Work (Architecture) All four scoring engines follow the same pattern: they're Kafka consumers that react to observation events. ``` Observation recorded (HTTP POST) │ ▼ PostgreSQL + Outbox │ ▼ (OutboxRelay) Kafka topic: observation.recorded │ ├──► SepsisEngineService → QsofaDetector → qSOFA evaluation ├──► News2ScoringService → News2Detector → NEWS2 score ├──► GcsScoringService → GcsDetector → GCS score │ │ │ Kafka: gcs.scored ◄──┘ │ │ ├──► SofaScoringService → SofaDetector → SOFA score ├──► TrendAnalyzerService → TrendDetector → Rate-of-change └──► WarningAlertService → WarningEvaluator → Warning alerts ``` Each engine is a separate Kafka consumer group, so they process the same observation event independently and in parallel. A single heart rate reading can simultaneously trigger NEWS2 recalculation, qSOFA re-evaluation, trend analysis, and warning threshold checking. ### The Two-Class Pattern: Calculator + Detector Each scoring system is split into two classes: - **Calculator** (static, pure logic): Contains the scoring rules — "heart rate 45 scores 3 points in NEWS2." No database, no Redis, no side effects. Easy to unit test. - **Detector** (stateful, orchestration): Manages Redis state, calls the calculator, persists scores to PostgreSQL, creates alerts, publishes events. Contains all the infrastructure plumbing. --- ## NEWS2 — National Early Warning Score 2 NEWS2 combines 7 vital sign parameters into a single risk score (0–20+). Higher scores indicate greater deterioration risk. ### The 7 Parameters and Their Scoring ```csharp public static class News2Calculator { public static readonly IReadOnlyList ParameterCodes = new[] { "RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE", "AVPU", "TEMP_C", "SUPPLEMENTAL_O2" }; public static int ScoreRespRate(decimal value) => value switch { <= 8 => 3, // Dangerously low <= 11 => 1, <= 20 => 0, // Normal range <= 24 => 2, _ => 3 // Dangerously high }; public static int ScoreHeartRate(decimal value) => value switch { <= 40 => 3, <= 50 => 1, <= 90 => 0, // Normal range <= 110 => 1, <= 130 => 2, _ => 3 }; // ... similar for SpO2, SystolicBp, Temperature, Consciousness, SupplementalO2 } ``` Each parameter scores 0–3 points. The total score determines the risk level: | Total Score | Risk Level | Alert | |-------------|-----------|-------| | 0–4 | LOW | No alert | | 5–6 or any single param = 3 | MEDIUM / LOW_MEDIUM | `NEWS2_WARNING` (Warning) | | >= 7 | HIGH | `NEWS2_EMERGENCY` (Critical) | ### How NEWS2 Aggregates Over Time The 7 parameters rarely arrive simultaneously. A nurse records respiratory rate at 14:01, heart rate at 14:03, blood pressure at 14:05. The `News2Detector` uses Redis to accumulate parameters until all 7 are present: ``` 14:01 RESP_RATE=18 → Redis: news2:{enc}:RESP_RATE = {value:18, score:0} (1/7 present) 14:03 HEART_RATE=95 → Redis: news2:{enc}:HEART_RATE = {value:95, score:1} (2/7 present) 14:05 SYSTOLIC_BP=115 → Redis: news2:{enc}:SYSTOLIC_BP = ... (3/7 present) ... (more parameters arrive) 14:12 TEMP_C=37.5 → Redis: all 7 present → compute score = 5 (MEDIUM) → persist News2Score to PostgreSQL → create NEWS2_WARNING alert ``` Each Redis key has a **4-hour TTL**. If no new respiratory rate arrives within 4 hours, that parameter expires and the next NEWS2 calculation waits for a fresh reading. ### Consciousness Resolution: GCS-First, AVPU-Fallback The consciousness parameter prefers GCS (Glasgow Coma Scale) over AVPU (a simpler alert/voice/pain/unresponsive scale). If GCS components are cached in Redis, they're used. Otherwise, the AVPU value is used: ```csharp private async Task ResolveConsciousnessScoreAsync(Guid encounterId) { // Try GCS first var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId)); if (gcsValues.All(v => v.HasValue)) { var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value; return News2Calculator.ScoreConsciousnessFromGcs(total); } // Fallback to AVPU var avpuVal = await cache.StringGetAsync( News2Calculator.ParameterKey(encounterId, "AVPU")); return cached?.Score; } ``` --- ## GCS — Glasgow Coma Scale GCS measures level of consciousness through 3 components: | Component | Range | What It Assesses | |-----------|-------|-----------------| | Eye (E) | 1–4 | Eye opening response | | Verbal (V) | 1–5 | Best verbal response | | Motor (M) | 1–6 | Best motor response | | **Total** | **3–15** | **Sum of all three** | ```csharp public static string ClassifyGcs(int total) => total switch { <= 8 => "SEVERE", // Coma — Critical alert <= 12 => "MODERATE", // Warning alert _ => "MILD" // 13-15 — Normal or near-normal }; ``` ### Component Assembly Like NEWS2, GCS components may arrive separately. The `GcsDetector` caches each component in Redis and computes the total when all 3 are present: ``` 14:00 GCS_EYE=3 → Redis: gcs:{enc}:GCS_EYE = 3 (1/3) 14:00 GCS_VERBAL=4 → Redis: gcs:{enc}:GCS_VERBAL = 4 (2/3) 14:01 GCS_MOTOR=5 → Redis: gcs:{enc}:GCS_MOTOR = 5 (3/3) → Total = 12 (MODERATE) → Persist GcsScore to PostgreSQL → Create GCS_WARNING alert → Publish gcs.scored to Kafka (for SOFA CNS rescoring) → Re-evaluate qSOFA altered mentation ``` ### Downstream Effects GCS has the most downstream effects of any scoring engine: 1. **GCS alert**: SEVERE (total <= 8) → Critical alert, MODERATE (9–12) → Warning alert 2. **Kafka `gcs.scored` event**: Triggers SOFA CNS organ re-scoring 3. **NEWS2 consciousness**: Replaces AVPU with GCS-derived score 4. **qSOFA altered mentation**: GCS < 15 counts as one of the 3 qSOFA criteria --- ## qSOFA — Quick SOFA (Sepsis Screen) qSOFA is a bedside screening tool with 3 binary criteria: | Criterion | Threshold | Meaning | |-----------|-----------|---------| | Respiratory rate | >= 22 /min | Breathing fast | | Systolic blood pressure | <= 100 mmHg | Blood pressure low | | Altered mentation | GCS < 15 or AVPU >= 1 | Not fully alert | If **2 or more criteria** are met simultaneously, a `QSOFA_SCREEN` warning alert fires, recommending SOFA labs be ordered. ### Sliding Window with Redis TTL Each criterion has a **30-minute TTL** in Redis. If a criterion is met, the key is set with a 30-minute expiry. If not met, the key is deleted immediately: ```csharp if (QsofaCalculator.MeetsCriterion(observationCode, value)) await cache.StringSetAsync(key, value.ToString(), TimeSpan.FromSeconds(1800)); else await cache.KeyDeleteAsync(key); ``` This means a patient's respiratory rate of 24 (meets criterion) at 14:00 expires automatically at 14:30 if no new high respiratory rate arrives. The qSOFA score naturally decreases as criteria expire. ### Alert Deduplication qSOFA uses `INSERT ... WHERE NOT EXISTS` to prevent duplicate screen alerts: ```sql INSERT INTO clinical_alerts (...) SELECT ... WHERE NOT EXISTS ( SELECT 1 FROM clinical_alerts WHERE encounter_id = @encounterId AND alert_type = 'QSOFA_SCREEN' AND status IN ('OPEN', 'ESCALATED') ) ``` If an open qSOFA screen already exists for this encounter, no duplicate is created. --- ## SOFA — Sequential Organ Failure Assessment SOFA is the most complex scoring system. It assesses 6 organ systems, each scored 0–4: | Organ System | Data Source | Score 0 | Score 4 | |-------------|------------|---------|---------| | Respiratory | PaO2/FiO2 ratio (or SpO2/FiO2 fallback) | >= 400 | < 100 with mechanical ventilation | | Coagulation | Platelet count | >= 150 k/µL | < 20 k/µL | | Liver | Bilirubin | < 1.2 mg/dL | >= 12 mg/dL | | Cardiovascular | MAP and vasopressor dose | MAP >= 70, no vasopressors | High-dose epinephrine/norepinephrine | | CNS | GCS total (via `gcs.scored` Kafka event) | GCS 15 | GCS < 6 | | Renal | Creatinine and urine output | Creatinine < 1.2 | Creatinine >= 5.0 or urine < 200 mL/day | Total SOFA score: 0–24 (sum of all organ scores). ### Baseline and Delta SOFA doesn't alert on the absolute score — it alerts on the **change from baseline**: 1. **Baseline established**: When >= 4 organ systems have data, the first score becomes the baseline 2. **Delta calculated**: Every subsequent score computes `delta = current total - baseline total` 3. **Alerts**: - Delta >= 2 → `SOFA_SEPSIS` (Critical) — indicates acute organ dysfunction, triggers a sepsis bundle - Delta = 1 → `SOFA_WARNING` (Warning) This is clinically important because a patient with chronic kidney disease might have a baseline SOFA of 4. A score of 4 is not alarming for them — but a sudden jump to 6 (delta = 2) indicates new organ dysfunction. ### Lab Staleness Tracking Lab values (platelets, bilirubin, creatinine) arrive infrequently — sometimes only once per day. The `SofaLabCache` tracks how old each value is: | Age | Status | Behavior | |-----|--------|----------| | < 12 hours | Current | Used as-is | | 12–24 hours | Stale | Used but flagged in `staleness_flags` JSONB | | > 24 hours | Expired | Organ score set to 0 (assume normal) | ### SpO2/FiO2 Fallback Many ward patients don't have arterial blood gas (PaO2) measurements. When PaO2 is unavailable but SpO2 (pulse oximetry) is available, the SOFA detector uses a validated proxy ratio (Rice et al., 2007): ```csharp if (pao2 is not null && fio2 is not null) respiratory = SofaCalculator.ScoreRespiratory(pao2, fio2, onMechanicalVent); else if (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null) respiratory = SofaCalculator.ScoreRespiratoryFromSpo2(spo2, fio2, onMechanicalVent); ``` --- ## How the Scores Connect The scoring engines are not independent — they feed into each other: ``` Observation arrives │ ├── NEWS2: uses RESP_RATE, SPO2, SYSTOLIC_BP, HEART_RATE, TEMP_C, │ SUPPLEMENTAL_O2, and consciousness (from GCS or AVPU) │ ├── qSOFA: uses RESP_RATE, SYSTOLIC_BP, and altered mentation (from GCS) │ ├── GCS: uses GCS_EYE, GCS_VERBAL, GCS_MOTOR │ │ │ └── publishes gcs.scored ──► SOFA (CNS organ) │ ──► NEWS2 (consciousness) │ ──► qSOFA (altered mentation) │ ├── SOFA: uses labs + vitals + GCS + medications │ │ │ └── SOFA_SEPSIS (delta >= 2) ──► Sepsis Bundle (Guide 23) │ └── Trend: uses HEART_RATE, RESP_RATE, SYSTOLIC_BP, TEMP_C, SPO2 ``` --- ## Common Patterns Across All Engines ### Alert Suppression Warning-level alerts check for suppression before firing: ```csharp if (alertType == AlertType.News2Warning) { var suppression = _services.GetRequiredService(); if (await suppression.IsSuppressedAsync(encounterId, alertType, ct)) return false; } ``` When a clinician acknowledges a WARNING alert, a Redis suppression key is set for 30 minutes. During that window, the same alert type won't fire again for that encounter. CRITICAL alerts are never suppressed. ### Duplicate Prevention Every alert creation uses `INSERT ... WHERE NOT EXISTS` to prevent duplicates. If an open alert of the same type already exists for the encounter, no new alert is created. ### Prometheus Metrics Every engine increments counters and records timing: ```csharp using var timer = _metrics.SofaScoringDuration.NewTimer(); // ... compute score ... _metrics.SofaScoresTotal.WithLabels(alertCreated ? "true" : "false").Inc(); ``` --- ## Key Takeaways - **Scoring engines are Kafka consumers** — each runs independently, processing the same observation events in parallel - **Redis aggregates parameters over time** — observations arrive individually; Redis holds partial state until all parameters are present - **Calculator + Detector separation** — pure scoring logic is testable without infrastructure; orchestration logic handles Redis, PostgreSQL, and Kafka - **TTL-based expiry prevents stale scores** — parameters automatically expire (30 minutes for qSOFA, 4 hours for NEWS2, 24 hours for SOFA labs) - **Scoring engines cascade** — GCS feeds into NEWS2, qSOFA, and SOFA. SOFA delta >= 2 triggers sepsis bundles. One observation can ripple through multiple scoring pipelines. - **Alert suppression prevents alarm fatigue** — warning alerts are silenced for 30 minutes after acknowledgment; critical alerts always fire