fix: No token refresh or revocation mechanism~
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
# 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<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 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<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) | 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<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
|
||||
Reference in New Issue
Block a user