Files
vigilcare-clinical/docs/guides/22-clinical-scoring-engines.md
T

14 KiB
Raw Blame History

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 (020+). Higher scores indicate greater deterioration risk.

The 7 Parameters and Their Scoring

public static class News2Calculator
{
    public static readonly IReadOnlyList<string> 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 03 points. The total score determines the risk level:

Total Score Risk Level Alert
04 LOW No alert
56 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:

private async Task<int?> 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) 14 Eye opening response
Verbal (V) 15 Best verbal response
Motor (M) 16 Best motor response
Total 315 Sum of all three
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 (912) → 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:

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:

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 04:

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: 024 (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
1224 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):

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:

if (alertType == AlertType.News2Warning)
{
    var suppression = _services.GetRequiredService<IAlertSuppressionService>();
    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:

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