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

347 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```csharp
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:
```csharp
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** |
```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 (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:
```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 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):
```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<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:
```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