feature: Simulator Scenarios + Clinical Validation: SOFA/GCS

This commit is contained in:
voltsrage
2026-06-21 04:55:39 +08:00
parent bf46e6554a
commit 3638dd0669
39 changed files with 1619 additions and 213 deletions
@@ -0,0 +1,3 @@
public record GcsResponse(
int EyeScore, int VerbalScore, int MotorScore,
int TotalScore, string Classification, DateTimeOffset CalculatedAt);
@@ -0,0 +1 @@
public record QsofaResponse(int ActiveCriteria);
@@ -0,0 +1,7 @@
public record SofaResponse(
int TotalScore,
int RespiratoryScore, int CoagulationScore, int LiverScore,
int CardiovascularScore, int CnsScore, int RenalScore,
bool IsBaseline, int? DeltaFromBaseline,
SofaStalenessResponse? Staleness,
DateTimeOffset CalculatedAt);
@@ -0,0 +1,4 @@
public record SofaStalenessResponse(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -119,4 +119,28 @@ public class VigilCareApiClient
.ReadFromJsonAsync<ApiResponse<SepsisBundleResponse>>();
return envelope?.Data;
}
public async Task<GcsResponse?> GetCurrentGcsAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/gcs");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<GcsResponse>>();
return envelope?.Data;
}
public async Task<SofaResponse?> GetCurrentSofaAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/sofa");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SofaResponse>>();
return envelope?.Data;
}
public async Task<QsofaResponse?> GetCurrentQsofaAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/qsofa/current");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<QsofaResponse>>();
return envelope?.Data;
}
}
+18 -1
View File
@@ -71,6 +71,22 @@ public static class SimulatorConsole
AnsiConsole.MarkupLine(
$"[cyan][[{simTime}]][/] NEWS2 = {poll.News2.TotalScore} ({poll.News2.RiskLevel})");
if (poll.Gcs is not null)
AnsiConsole.MarkupLine(
$"[cyan][[{simTime}]][/] GCS = {poll.Gcs.TotalScore}/15 ({poll.Gcs.Classification}) " +
$"E{poll.Gcs.EyeScore} V{poll.Gcs.VerbalScore} M{poll.Gcs.MotorScore}");
if (poll.Sofa is not null)
{
var delta = poll.Sofa.DeltaFromBaseline is int d ? $" Δ+{d}" : "";
AnsiConsole.MarkupLine(
$"[cyan][[{simTime}]][/] SOFA = {poll.Sofa.TotalScore}/24{delta}");
}
if (poll.Qsofa is not null)
AnsiConsole.MarkupLine(
$"[cyan][[{simTime}]][/] qSOFA screen = {poll.Qsofa.ActiveCriteria}/3");
foreach (var alert in poll.NewAlerts)
AnsiConsole.MarkupLine(
$"[red][[{simTime}]][/] ALERT {alert.AlertType} ({alert.Severity})");
@@ -78,6 +94,7 @@ public static class SimulatorConsole
if (poll.SepsisBundle is not null)
AnsiConsole.MarkupLine(
$"[cyan][[{simTime}]][/] SEPSIS BUNDLE {poll.SepsisBundle.ComplianceStatus} " +
$"({poll.SepsisBundle.ElementsCompleted}/4)");
$"({poll.SepsisBundle.ElementsCompleted}/4) " +
$"trigger={poll.SepsisBundle.TriggeringAlertType}");
}
}
+4 -1
View File
@@ -8,13 +8,16 @@ public class ApiPoller
public async Task PollAndDisplayAsync(Guid encounterId, string simTime)
{
var news2 = await _client.GetCurrentNews2Async(encounterId);
var gcs = await _client.GetCurrentGcsAsync(encounterId);
var sofa = await _client.GetCurrentSofaAsync(encounterId);
var qsofa = await _client.GetCurrentQsofaAsync(encounterId);
var allAlerts = await _client.GetAlertsAsync(encounterId);
var newAlerts = allAlerts.Where(a => _seenAlertIds.Add(a.Id)).ToList();
var bundle = await _client.GetSepsisBundleAsync(encounterId);
var result = new PollResult(news2, newAlerts, bundle);
var result = new PollResult(news2, gcs, sofa, qsofa, newAlerts, bundle);
SimulatorConsole.PollResults(simTime, result);
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
public record PollResult(
News2Response? News2,
List<AlertResponse> NewAlerts,
GcsResponse? Gcs,
SofaResponse? Sofa,
QsofaResponse? Qsofa,
IReadOnlyList<AlertResponse> NewAlerts,
SepsisBundleResponse? SepsisBundle);
@@ -99,6 +99,36 @@
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_EYE",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_VERBAL",
"value": 5,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_MOTOR",
"value": 6,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 15,
"type": "observation",
@@ -101,6 +101,36 @@
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_EYE",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_VERBAL",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_MOTOR",
"value": 6,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 2,
"type": "observation",
@@ -110,6 +110,37 @@
},
"note": "Nasal cannula 2L/min started on arrival"
},
{
"offsetMinutes": 1,
"type": "observation",
"data": {
"code": "GCS_EYE",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 1,
"type": "observation",
"data": {
"code": "GCS_VERBAL",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 1,
"type": "observation",
"data": {
"code": "GCS_MOTOR",
"value": 5,
"unit": "score",
"source": "Manual"
},
"note": "GCS 13 — sluggish, confused from hypothermia"
},
{
"offsetMinutes": 0,
"type": "order",
@@ -56,6 +56,21 @@
"type": "observation",
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_EYE", "value": 4, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_VERBAL", "value": 5, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_MOTOR", "value": 6, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 0,
@@ -0,0 +1,168 @@
{
"scenario": {
"id": "neurological-decline-gcs-01",
"name": "Traumatic Brain Injury — Progressive GCS Decline",
"description": "Post-trauma patient with initial GCS 14 (E4V4M6). Over 3 hours, GCS declines to 6. Triggers GCS_WARNING then GCS_CRITICAL. Vitals remain stable — neuro-specific deterioration, not sepsis.",
"durationMinutes": 180,
"tags": ["gcs", "neurological", "no-sepsis", "news2"]
},
"patient": {
"firstName": "David",
"lastName": "Okonkwo",
"dateOfBirth": "1981-07-22",
"gender": "Male"
},
"encounter": {
"department": "Icu",
"encounterType": "Inpatient",
"attendingPhysician": "Dr. Amara Singh",
"roomBed": "ICU-1A-3",
"admissionReason": "TBI — motor vehicle collision, GCS 14 on arrival"
},
"events": [
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 72, "unit": "bpm", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "RESP_RATE", "value": 16, "unit": "/min", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SYSTOLIC_BP", "value": 135, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "DIASTOLIC_BP", "value": 82, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SPO2", "value": 99, "unit": "%", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SUPPLEMENTAL_O2", "value": 0, "unit": "flag", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_EYE", "value": 4, "unit": "score", "source": "Manual" },
"note": "GCS 14 — confused speech"
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_VERBAL", "value": 4, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_MOTOR", "value": 6, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "GCS_EYE", "value": 3, "unit": "score", "source": "Manual" },
"note": "GCS 12 → GCS_WARNING"
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "GCS_VERBAL", "value": 3, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "GCS_MOTOR", "value": 6, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 68, "unit": "bpm", "source": "Device" }
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "SYSTOLIC_BP", "value": 142, "unit": "mmHg", "source": "Device" },
"note": "Cushing response — HTN with bradycardia developing"
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "GCS_EYE", "value": 2, "unit": "score", "source": "Manual" },
"note": "GCS 9 — still GCS_WARNING band"
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "GCS_VERBAL", "value": 3, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "GCS_MOTOR", "value": 4, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 58, "unit": "bpm", "source": "Device" }
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "SYSTOLIC_BP", "value": 168, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 150,
"type": "observation",
"data": { "code": "GCS_EYE", "value": 1, "unit": "score", "source": "Manual" },
"note": "GCS 6 → GCS_CRITICAL"
},
{
"offsetMinutes": 150,
"type": "observation",
"data": { "code": "GCS_VERBAL", "value": 2, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 150,
"type": "observation",
"data": { "code": "GCS_MOTOR", "value": 3, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 155,
"type": "order",
"data": {
"orderType": "Imaging",
"orderCode": "CT_HEAD",
"description": "STAT CT head — GCS decline from 14 to 6",
"orderedBy": "Dr. Amara Singh"
}
}
],
"expectedOutcomes": [
{
"afterOffsetMinutes": 60,
"type": "alert",
"alertType": "GCS_WARNING",
"description": "GCS 12 in 912 band"
},
{
"afterOffsetMinutes": 150,
"type": "alert",
"alertType": "GCS_CRITICAL",
"description": "GCS 6 ≤ 8"
}
]
}
@@ -110,6 +110,36 @@
},
"note": "Nasal cannula 2L/min \u2014 standard post-op order"
},
{
"offsetMinutes": 1,
"type": "observation",
"data": {
"code": "GCS_EYE",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 1,
"type": "observation",
"data": {
"code": "GCS_VERBAL",
"value": 5,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 1,
"type": "observation",
"data": {
"code": "GCS_MOTOR",
"value": 6,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 20,
"type": "observation",
@@ -718,6 +748,37 @@
{
"offsetMinutes": 120,
"type": "observation",
"data": {
"code": "GCS_EYE",
"value": 3,
"unit": "score",
"source": "Manual"
},
"note": "GCS 11 \u2014 hemorrhagic shock, declining consciousness"
},
{
"offsetMinutes": 120,
"type": "observation",
"data": {
"code": "GCS_VERBAL",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 120,
"type": "observation",
"data": {
"code": "GCS_MOTOR",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 121,
"type": "observation",
"data": {
"code": "SUPPLEMENTAL_O2",
"value": 1,
@@ -726,7 +787,7 @@
}
},
{
"offsetMinutes": 120,
"offsetMinutes": 121,
"type": "observation",
"data": {
"code": "LACTATE_MMOL_L",
@@ -98,6 +98,36 @@
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_EYE",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_VERBAL",
"value": 5,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_MOTOR",
"value": 6,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "order",
@@ -0,0 +1,263 @@
{
"scenario": {
"id": "sepsis-sofa-progression-01",
"name": "Pneumonia → Multi-Organ Dysfunction (SOFA Pathway)",
"description": "ICU patient with community-acquired pneumonia. Initial SOFA baseline ~2. Over 4 hours, respiratory failure, thrombocytopenia, and renal impairment develop. qSOFA screen fires early; SOFA delta ≥ 2 triggers sepsis bundle. GCS remains 15 until late decline to 13.",
"durationMinutes": 240,
"tags": ["sofa", "sepsis", "multi-organ", "gcs", "qsofa-screen"]
},
"patient": {
"firstName": "Margaret",
"lastName": "Torres",
"dateOfBirth": "1954-03-18",
"gender": "Female"
},
"encounter": {
"department": "Icu",
"encounterType": "Inpatient",
"attendingPhysician": "Dr. Elena Vasquez",
"roomBed": "ICU-2A-6",
"admissionReason": "Community-acquired pneumonia, worsening dyspnea"
},
"events": [
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 88, "unit": "bpm", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "RESP_RATE", "value": 18, "unit": "/min", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SYSTOLIC_BP", "value": 128, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "DIASTOLIC_BP", "value": 78, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "TEMP_C", "value": 38.2, "unit": "°C", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SPO2", "value": 94, "unit": "%", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SUPPLEMENTAL_O2", "value": 1, "unit": "flag", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_EYE", "value": 4, "unit": "score", "source": "Manual" },
"note": "Baseline GCS 15 — fully alert"
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_VERBAL", "value": 5, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_MOTOR", "value": 6, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 5,
"type": "observation",
"data": { "code": "PAO2_MMHG", "value": 85, "unit": "mmHg", "source": "Lab" },
"note": "ABG baseline — P/F ~283 at FiO2 30%"
},
{
"offsetMinutes": 5,
"type": "observation",
"data": { "code": "FIO2_PCT", "value": 30, "unit": "%", "source": "Manual" }
},
{
"offsetMinutes": 5,
"type": "observation",
"data": { "code": "PLATELET_K_UL", "value": 185, "unit": "k/µL", "source": "Lab" }
},
{
"offsetMinutes": 5,
"type": "observation",
"data": { "code": "BILIRUBIN_MG_DL", "value": 0.9, "unit": "mg/dL", "source": "Lab" }
},
{
"offsetMinutes": 5,
"type": "observation",
"data": { "code": "CREATININE_MG_DL", "value": 1.0, "unit": "mg/dL", "source": "Lab" }
},
{
"offsetMinutes": 5,
"type": "observation",
"data": { "code": "LACTATE_MMOL_L", "value": 1.8, "unit": "mmol/L", "source": "Lab" }
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 102, "unit": "bpm", "source": "Device" },
"note": "Mild tachycardia; qSOFA RR ≥ 22"
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "RESP_RATE", "value": 24, "unit": "/min", "source": "Device" }
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "SYSTOLIC_BP", "value": 105, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 60,
"type": "observation",
"data": { "code": "TEMP_C", "value": 38.9, "unit": "°C", "source": "Manual" }
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "RESP_RATE", "value": 28, "unit": "/min", "source": "Device" }
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "SYSTOLIC_BP", "value": 92, "unit": "mmHg", "source": "Device" },
"note": "SBP ≤ 100 → qSOFA ≥ 2 → QSOFA_SCREEN"
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "DIASTOLIC_BP", "value": 58, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "SPO2", "value": 89, "unit": "%", "source": "Device" }
},
{
"offsetMinutes": 125,
"type": "observation",
"data": { "code": "PAO2_MMHG", "value": 62, "unit": "mmHg", "source": "Lab" },
"note": "Repeat ABG — P/F 155 at FiO2 40% → SOFA Resp 2"
},
{
"offsetMinutes": 125,
"type": "observation",
"data": { "code": "FIO2_PCT", "value": 40, "unit": "%", "source": "Manual" }
},
{
"offsetMinutes": 125,
"type": "observation",
"data": { "code": "PLATELET_K_UL", "value": 88, "unit": "k/µL", "source": "Lab" },
"note": "Thrombocytopenia → SOFA Coag 1"
},
{
"offsetMinutes": 125,
"type": "observation",
"data": { "code": "CREATININE_MG_DL", "value": 2.3, "unit": "mg/dL", "source": "Lab" },
"note": "AKI → SOFA Renal 2"
},
{
"offsetMinutes": 125,
"type": "observation",
"data": { "code": "LACTATE_MMOL_L", "value": 3.1, "unit": "mmol/L", "source": "Lab" }
},
{
"offsetMinutes": 130,
"type": "medication",
"data": {
"drugName": "Norepinephrine",
"dose": 0.05,
"doseUnit": "mcg/kg/min",
"route": "IV",
"administeredBy": "RN-Torres"
},
"note": "Vasopressor → SOFA CV 3; delta ≥ 2 → SOFA_SEPSIS + bundle"
},
{
"offsetMinutes": 180,
"type": "observation",
"data": { "code": "PAO2_MMHG", "value": 55, "unit": "mmHg", "source": "Lab" }
},
{
"offsetMinutes": 180,
"type": "observation",
"data": { "code": "FIO2_PCT", "value": 60, "unit": "%", "source": "Manual" }
},
{
"offsetMinutes": 180,
"type": "observation",
"data": { "code": "PLATELET_K_UL", "value": 42, "unit": "k/µL", "source": "Lab" }
},
{
"offsetMinutes": 180,
"type": "observation",
"data": { "code": "CREATININE_MG_DL", "value": 3.8, "unit": "mg/dL", "source": "Lab" }
},
{
"offsetMinutes": 200,
"type": "observation",
"data": { "code": "GCS_EYE", "value": 3, "unit": "score", "source": "Manual" },
"note": "GCS 13 — SOFA CNS 1"
},
{
"offsetMinutes": 200,
"type": "observation",
"data": { "code": "GCS_VERBAL", "value": 4, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 200,
"type": "observation",
"data": { "code": "GCS_MOTOR", "value": 6, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 210,
"type": "order",
"data": {
"orderType": "Lab",
"orderCode": "BLOOD_CULTURE",
"description": "Blood cultures x2 — sepsis workup",
"orderedBy": "Dr. Elena Vasquez"
}
},
{
"offsetMinutes": 210,
"type": "order",
"data": {
"orderType": "Lab",
"orderCode": "SERUM_LACTATE",
"description": "Repeat lactate",
"orderedBy": "Dr. Elena Vasquez"
}
}
],
"expectedOutcomes": [
{
"afterOffsetMinutes": 120,
"type": "alert",
"alertType": "QSOFA_SCREEN",
"description": "qSOFA ≥ 2 — bedside screen positive, recommend SOFA labs"
},
{
"afterOffsetMinutes": 130,
"type": "alert",
"alertType": "SOFA_SEPSIS",
"description": "SOFA delta ≥ 2 from baseline after labs + vasopressor"
},
{
"afterOffsetMinutes": 130,
"type": "bundle",
"description": "Sepsis bundle auto-created; triggeringAlertType = SOFA_SEPSIS"
}
]
}
@@ -0,0 +1,166 @@
{
"scenario": {
"id": "sofa-partial-spo2-fallback-01",
"name": "Ward Patient — Partial SOFA with SpO₂/FiO₂ Proxy",
"description": "General medicine patient with worsening pneumonia. No ABG — SpO₂/FiO₂ used for respiratory SOFA. Partial scoring when labs missing; carry-forward and staleness flags when data ages.",
"durationMinutes": 360,
"tags": ["sofa", "partial-scoring", "spo2-fallback", "carry-forward", "ward"]
},
"patient": {
"firstName": "Priya",
"lastName": "Sharma",
"dateOfBirth": "1970-01-14",
"gender": "Female"
},
"encounter": {
"department": "GeneralMedicine",
"encounterType": "Inpatient",
"attendingPhysician": "Dr. Noah Chen",
"roomBed": "GM-8B-2",
"admissionReason": "Worsening pneumonia, transferred from ED"
},
"events": [
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 92, "unit": "bpm", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "RESP_RATE", "value": 20, "unit": "/min", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SYSTOLIC_BP", "value": 118, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "DIASTOLIC_BP", "value": 72, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SPO2", "value": 93, "unit": "%", "source": "Device" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "FIO2_PCT", "value": 28, "unit": "%", "source": "Manual" },
"note": "Nasal cannula 2L — S/F ~332, SOFA Resp 0"
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "SUPPLEMENTAL_O2", "value": 1, "unit": "flag", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "TEMP_C", "value": 38.5, "unit": "°C", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_EYE", "value": 4, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_VERBAL", "value": 5, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "GCS_MOTOR", "value": 6, "unit": "score", "source": "Manual" }
},
{
"offsetMinutes": 30,
"type": "observation",
"data": { "code": "CREATININE_MG_DL", "value": 1.1, "unit": "mg/dL", "source": "Lab" },
"note": "Partial SOFA — no platelets/bilirubin yet"
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "PLATELET_K_UL", "value": 165, "unit": "k/µL", "source": "Lab" }
},
{
"offsetMinutes": 120,
"type": "observation",
"data": { "code": "BILIRUBIN_MG_DL", "value": 0.8, "unit": "mg/dL", "source": "Lab" },
"note": "Baseline SOFA now more complete"
},
{
"offsetMinutes": 180,
"type": "observation",
"data": { "code": "SPO2", "value": 88, "unit": "%", "source": "Device" }
},
{
"offsetMinutes": 180,
"type": "observation",
"data": { "code": "FIO2_PCT", "value": 40, "unit": "%", "source": "Manual" },
"note": "S/F 220 → SOFA Resp ~1; usedSpO2Fallback true"
},
{
"offsetMinutes": 180,
"type": "observation",
"data": { "code": "RESP_RATE", "value": 26, "unit": "/min", "source": "Device" }
},
{
"offsetMinutes": 240,
"type": "observation",
"data": { "code": "SPO2", "value": 84, "unit": "%", "source": "Device" }
},
{
"offsetMinutes": 240,
"type": "observation",
"data": { "code": "FIO2_PCT", "value": 60, "unit": "%", "source": "Manual" },
"note": "S/F 140 → SOFA Resp 2"
},
{
"offsetMinutes": 240,
"type": "observation",
"data": { "code": "CREATININE_MG_DL", "value": 1.9, "unit": "mg/dL", "source": "Lab" }
},
{
"offsetMinutes": 240,
"type": "observation",
"data": { "code": "PLATELET_K_UL", "value": 110, "unit": "k/µL", "source": "Lab" },
"note": "SOFA Coag 1"
},
{
"offsetMinutes": 300,
"type": "observation",
"data": { "code": "SYSTOLIC_BP", "value": 88, "unit": "mmHg", "source": "Device" }
},
{
"offsetMinutes": 300,
"type": "observation",
"data": { "code": "DIASTOLIC_BP", "value": 52, "unit": "mmHg", "source": "Device" },
"note": "MAP 64 → SOFA CV 1"
},
{
"offsetMinutes": 300,
"type": "observation",
"data": { "code": "CREATININE_MG_DL", "value": 2.5, "unit": "mg/dL", "source": "Lab" },
"note": "SOFA Renal 2; delta may reach ≥ 2"
}
],
"expectedOutcomes": [
{
"afterOffsetMinutes": 180,
"type": "score",
"scoreType": "SOFA",
"description": "SOFA computed with usedSpO2Fallback in staleness flags"
},
{
"afterOffsetMinutes": 300,
"type": "alert",
"alertType": "SOFA_SEPSIS",
"description": "Multi-organ decline — delta ≥ 2 from baseline"
}
]
}
@@ -99,6 +99,36 @@
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_EYE",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_VERBAL",
"value": 5,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_MOTOR",
"value": 6,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 30,
"type": "observation",
@@ -2,7 +2,7 @@
"scenario": {
"id": "uti-sepsis-elderly-01",
"name": "UTI Progressing to Sepsis in Elderly Patient",
"description": "72-year-old female presenting with UTI symptoms who deteriorates into sepsis over 4 hours, triggering SIRS, qSOFA, and NEWS2 alerts with sepsis bundle compliance.",
"description": "72-year-old female with UTI progressing to sepsis over 4 hours. qSOFA screen fires first; SOFA delta \u2265 2 triggers sepsis bundle. NEWS2 and bundle compliance still exercised.",
"durationMinutes": 240,
"tags": [
"sepsis",
@@ -98,6 +98,36 @@
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_EYE",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_VERBAL",
"value": 4,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 0,
"type": "observation",
"data": {
"code": "GCS_MOTOR",
"value": 6,
"unit": "score",
"source": "Manual"
}
},
{
"offsetMinutes": 20,
"type": "observation",
@@ -281,6 +311,56 @@
"source": "Lab"
}
},
{
"offsetMinutes": 45,
"type": "observation",
"data": {
"code": "PAO2_MMHG",
"value": 92,
"unit": "mmHg",
"source": "Lab"
}
},
{
"offsetMinutes": 45,
"type": "observation",
"data": {
"code": "FIO2_PCT",
"value": 28,
"unit": "%",
"source": "Manual"
}
},
{
"offsetMinutes": 45,
"type": "observation",
"data": {
"code": "PLATELET_K_UL",
"value": 210,
"unit": "k/\u00b5L",
"source": "Lab"
}
},
{
"offsetMinutes": 45,
"type": "observation",
"data": {
"code": "BILIRUBIN_MG_DL",
"value": 0.7,
"unit": "mg/dL",
"source": "Lab"
}
},
{
"offsetMinutes": 45,
"type": "observation",
"data": {
"code": "CREATININE_MG_DL",
"value": 1.0,
"unit": "mg/dL",
"source": "Lab"
}
},
{
"offsetMinutes": 40,
"type": "order",
@@ -450,7 +530,7 @@
"unit": "bpm",
"source": "Device"
},
"note": "SIRS criteria met: Temp >38.3 + HR >90 \u2192 SEPSIS_WARNING"
"note": "Temp >38.3 + HR >90 \u2014 early sepsis physiology"
},
{
"offsetMinutes": 90,
@@ -627,7 +707,7 @@
"unit": "bpm",
"source": "Device"
},
"note": "NEWS2 = 6, SIRS 3 of 4 criteria met (Temp, HR, RR)"
"note": "NEWS2 = 6, continued deterioration"
},
{
"offsetMinutes": 120,
@@ -698,7 +778,7 @@
"unit": "\u00d710\u00b3/\u00b5L",
"source": "Lab"
},
"note": "Repeat labs \u2014 WBC elevated, lactate rising, SIRS 4/4"
"note": "Repeat labs \u2014 WBC elevated, lactate rising"
},
{
"offsetMinutes": 120,
@@ -730,6 +810,48 @@
"source": "Lab"
}
},
{
"offsetMinutes": 125,
"type": "observation",
"data": {
"code": "PAO2_MMHG",
"value": 68,
"unit": "mmHg",
"source": "Lab"
},
"note": "Worsening gas exchange \u2014 SOFA Resp \u2191"
},
{
"offsetMinutes": 125,
"type": "observation",
"data": {
"code": "FIO2_PCT",
"value": 40,
"unit": "%",
"source": "Manual"
}
},
{
"offsetMinutes": 125,
"type": "observation",
"data": {
"code": "PLATELET_K_UL",
"value": 95,
"unit": "k/\u00b5L",
"source": "Lab"
}
},
{
"offsetMinutes": 125,
"type": "observation",
"data": {
"code": "CREATININE_MG_DL",
"value": 2.1,
"unit": "mg/dL",
"source": "Lab"
},
"note": "AKI \u2014 SOFA Renal \u2191; delta \u2265 2 \u2192 SOFA_SEPSIS"
},
{
"offsetMinutes": 125,
"type": "medication",
@@ -1308,46 +1430,34 @@
"description": "HR enters warning range (91 bpm)"
},
{
"afterOffsetMinutes": 90,
"afterOffsetMinutes": 105,
"type": "alert",
"alertType": "SEPSIS_WARNING",
"description": "SIRS criteria met: Temp 38.4\u00b0C (>38.3) + HR 96 (>90) = 2 of 4"
"alertType": "QSOFA_SCREEN",
"description": "qSOFA \u2265 2 \u2014 screen positive, recommend SOFA labs"
},
{
"afterOffsetMinutes": 90,
"afterOffsetMinutes": 125,
"type": "alert",
"alertType": "SOFA_SEPSIS",
"description": "SOFA delta \u2265 2 from baseline after repeat labs"
},
{
"afterOffsetMinutes": 125,
"type": "bundle",
"description": "Sepsis bundle auto-created with 4 orders: blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation"
"description": "Sepsis bundle auto-created; triggeringAlertType = SOFA_SEPSIS"
},
{
"afterOffsetMinutes": 105,
"type": "score",
"scoreType": "NEWS2",
"expectedMinimum": 5,
"description": "NEWS2 reaches 5 (HR=1, RR=2, Temp=1, SpO2=1) \u2192 WARNING threshold"
"description": "NEWS2 reaches medium risk"
},
{
"afterOffsetMinutes": 135,
"type": "alert",
"alertType": "NEWS2_EMERGENCY",
"description": "NEWS2 reaches 8 (HR=2, RR=2, SBP=1, Temp=2, SpO2=1) \u22657 \u2192 EMERGENCY"
},
{
"afterOffsetMinutes": 135,
"type": "score",
"scoreType": "NEWS2",
"expectedMinimum": 7,
"description": "NEWS2 \u22657 sustained through peak deterioration"
},
{
"afterOffsetMinutes": 150,
"type": "alert",
"alertType": "QSOFA_WARNING",
"description": "qSOFA 3/3: RR 24 (\u226522) + SBP 98 (\u2264100) + AVPU 1 (\u22651)"
},
{
"afterOffsetMinutes": 130,
"type": "bundle",
"description": "Sepsis bundle COMPLIANT \u2014 all 4 orders completed within 40 minutes (blood cultures T+92, lactate T+95, ceftriaxone T+125, IV fluids T+130)"
"description": "NEWS2 \u2265 7 at peak deterioration"
}
]
}
@@ -4,7 +4,12 @@ public static class ScenarioValidator
{
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP",
"TEMP_C", "SPO2", "AVPU", "SUPPLEMENTAL_O2",
"WBC_K_UL", "POTASSIUM_MEQ_L", "LACTATE_MMOL_L", "GLUCOSE_MG_DL"
"WBC_K_UL", "POTASSIUM_MEQ_L", "LACTATE_MMOL_L", "GLUCOSE_MG_DL",
// Phase 25 — GCS components
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR",
// Phase 26 — SOFA lab / respiratory inputs
"PAO2_MMHG", "FIO2_PCT", "PLATELET_K_UL",
"BILIRUBIN_MG_DL", "CREATININE_MG_DL", "URINE_OUTPUT_ML_H",
};
private static readonly HashSet<string> ValidSources = new()
@@ -67,15 +72,15 @@ public static class ScenarioValidator
}
var placedOrders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
double lastOffset = -1;
for (int i = 0; i < scenario.Events.Count; i++)
var indexedEvents = scenario.Events
.Select((evt, i) => (evt, i))
.OrderBy(x => x.evt.OffsetMinutes)
.ThenBy(x => x.i);
foreach (var (evt, i) in indexedEvents)
{
var evt = scenario.Events[i];
if (evt.OffsetMinutes < 0)
errors.Add($"events[{i}]: offsetMinutes cannot be negative");
if (evt.OffsetMinutes < lastOffset)
errors.Add($"events[{i}]: offsetMinutes goes backwards ({evt.OffsetMinutes} < {lastOffset})");
lastOffset = evt.OffsetMinutes;
if (!ValidEventTypes.Contains(evt.Type))
errors.Add($"events[{i}]: unknown type '{evt.Type}'");
@@ -123,7 +128,7 @@ public static class ScenarioValidator
{
errors.Add(
$"events[{i}]: order_result '{orderDescription}' has no matching prior 'order' event " +
"(sepsis bundle orders are auto-created when SEPSIS_WARNING or QSOFA_WARNING fires)");
"(sepsis bundle orders are auto-created when SOFA_SEPSIS or QSOFA_SCREEN fires)");
}
}
}
+14
View File
@@ -63,6 +63,20 @@
"description": { "type": "string" }
}
}
},
"observationCodes": {
"type": "array",
"description": "Valid observation codes",
"items": {
"enum": [
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP",
"TEMP_C", "SPO2", "AVPU", "SUPPLEMENTAL_O2",
"WBC_K_UL", "POTASSIUM_MEQ_L", "LACTATE_MMOL_L", "GLUCOSE_MG_DL",
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR",
"PAO2_MMHG", "FIO2_PCT", "PLATELET_K_UL",
"BILIRUBIN_MG_DL", "CREATININE_MG_DL", "URINE_OUTPUT_ML_H"
]
}
}
}
}
@@ -5,7 +5,7 @@ public class AlertCreationGuardTests
[Fact]
public void CannotCreateNewSepsisWarning()
{
var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning);
var act = () => AlertCreationGuard.EnsureAllowed(AlertTypeExtensions.FromDbString("SEPSIS_WARNING"));
act.Should().Throw<InvalidOperationException>()
.WithMessage("*deprecated*");
}
@@ -0,0 +1,112 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Integration")]
public class ClinicalRefactorEndToEndTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
public ClinicalRefactorEndToEndTests(ApiFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task SofaProgressionScenario_EndToEnd()
{
var scenario = ScenarioReplayHelper.Load("sepsis-sofa-progression-01.json");
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario);
await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.QsofaScreen, TimeSpan.FromSeconds(30));
await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(30));
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId);
bundle.TriggeringAlertType.Should().Be("SOFA_SEPSIS");
}
[Fact]
public async Task GcsDeclineScenario_EndToEnd()
{
var scenario = ScenarioReplayHelper.Load("neurological-decline-gcs-01.json");
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario);
await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.GcsWarning, TimeSpan.FromSeconds(30));
await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.GcsCritical, TimeSpan.FromSeconds(30));
await ScenarioReplayHelper.AssertNoAlertTypeAsync(
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(5));
await ScenarioReplayHelper.AssertNoAlertTypeAsync(
_fixture.Services, encounterId, AlertTypeExtensions.FromDbString("SEPSIS_WARNING"), TimeSpan.FromSeconds(1));
}
[Fact]
public async Task PartialSofaScenario_StalenessFlags()
{
var scenario = ScenarioReplayHelper.Load("sofa-partial-spo2-fallback-01.json");
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario);
var sofa = await ScenarioReplayHelper.WaitForSofaScoreAsync(
_fixture.Services, encounterId, TimeSpan.FromSeconds(45));
sofa.StalenessFlags.Should().NotBeNullOrEmpty();
var flags = JsonSerializer.Deserialize<SofaStalenessFlags>(
sofa.StalenessFlags!,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
flags!.UsedSpO2Fallback.Should().BeTrue("expected SpO2/FiO2 proxy for respiratory SOFA");
}
[Theory]
[InlineData("stable-baseline-01.json")]
[InlineData("cardiac-arrest-post-mi-01.json")]
[InlineData("post-op-hemorrhage-01.json")]
[InlineData("respiratory-failure-asthma-01.json")]
[InlineData("dka-electrolyte-01.json")]
[InlineData("medication-false-alarm-01.json")]
[InlineData("hypothermia-elderly-01.json")]
[InlineData("uti-sepsis-elderly-01.json")]
public void ExistingScenarios_ValidateWithoutErrors(string fileName)
{
var scenario = ScenarioReplayHelper.Load(fileName);
var errors = ScenarioValidator.Validate(scenario);
errors.Should().BeEmpty(string.Join("; ", errors));
}
[Fact]
public async Task UtiSepsisScenario_NowUsesSofa()
{
var scenario = ScenarioReplayHelper.Load("uti-sepsis-elderly-01.json");
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario);
await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(45));
await ScenarioReplayHelper.AssertNoAlertTypeAsync(
_fixture.Services, encounterId, AlertTypeExtensions.FromDbString("SEPSIS_WARNING"), TimeSpan.FromSeconds(5));
}
}
@@ -0,0 +1,155 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
public static class ScenarioReplayHelper
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public static string ScenariosDirectory =>
Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..", "..", "..", "..",
"VigilCare.Simulator", "Scenarios", "List"));
public static ScenarioFile Load(string fileName)
{
var path = Path.Combine(ScenariosDirectory, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize {path}");
}
public static async Task<(Guid PatientId, Guid EncounterId)> ReplayObservationsAsync(
HttpClient client,
ScenarioFile scenario,
CancellationToken ct = default)
{
var patientResp = await client.PostAsJsonAsync("/api/v1/patients", new
{
firstName = scenario.Patient.FirstName,
lastName = scenario.Patient.LastName,
dateOfBirth = scenario.Patient.DateOfBirth,
gender = scenario.Patient.Gender,
}, ct);
patientResp.EnsureSuccessStatusCode();
var patient = (await patientResp.Content.ReadFromJsonAsync<ApiEnvelope<PatientDto>>(ct))!.Data;
var encounterResp = await client.PostAsJsonAsync(
$"/api/v1/patients/{patient.Id}/encounters",
new
{
encounterType = scenario.Encounter.EncounterType,
department = scenario.Encounter.Department,
attendingPhysician = scenario.Encounter.AttendingPhysician,
roomBed = scenario.Encounter.RoomBed,
admissionReason = scenario.Encounter.AdmissionReason,
}, ct);
encounterResp.EnsureSuccessStatusCode();
var encounter = (await encounterResp.Content.ReadFromJsonAsync<ApiEnvelope<EncounterDto>>(ct))!.Data;
var start = DateTimeOffset.UtcNow;
foreach (var cluster in scenario.Events
.Where(e => e.Type == "observation")
.GroupBy(e => e.OffsetMinutes)
.OrderBy(g => g.Key))
{
var recordedAt = start.AddMinutes(cluster.Key);
var batch = cluster.Select(evt =>
{
var code = evt.Data.GetProperty("code").GetString()!;
var value = evt.Data.GetProperty("value").GetDecimal();
var unit = evt.Data.GetProperty("unit").GetString()!;
var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual";
return new
{
observationCode = code,
value,
unit,
source = MapSource(source),
recordedAt,
};
}).ToList();
foreach (var chunk in batch.Chunk(10))
{
var resp = await client.PostAsJsonAsync(
$"/api/v1/encounters/{encounter.Id}/observations",
new { observations = chunk }, ct);
resp.EnsureSuccessStatusCode();
}
}
return (patient.Id, encounter.Id);
}
public static async Task<SofaScore> WaitForSofaScoreAsync(
IServiceProvider services,
Guid encounterId,
TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sofa = await db.SofaScores
.Where(s => s.EncounterId == encounterId)
.OrderByDescending(s => s.CalculatedAt)
.FirstOrDefaultAsync();
if (sofa is not null)
return sofa;
await Task.Delay(500);
}
throw new TimeoutException($"Timed out waiting for SOFA score on encounter {encounterId}");
}
public static async Task WaitForAlertTypeAsync(
IServiceProvider services,
Guid encounterId,
AlertType alertType,
TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (await db.ClinicalAlerts.AnyAsync(a =>
a.EncounterId == encounterId && a.AlertType == alertType))
return;
await Task.Delay(500);
}
throw new TimeoutException($"Timed out waiting for {alertType} on encounter {encounterId}");
}
public static async Task AssertNoAlertTypeAsync(
IServiceProvider services,
Guid encounterId,
AlertType alertType,
TimeSpan settleDelay)
{
await Task.Delay(settleDelay);
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var found = await db.ClinicalAlerts.AnyAsync(a =>
a.EncounterId == encounterId && a.AlertType == alertType);
if (found)
throw new InvalidOperationException($"Unexpected {alertType} alert on encounter {encounterId}");
}
private static string MapSource(string source) => source.ToLowerInvariant() switch
{
"device" or "monitor" => "Device",
"lab" => "Lab",
_ => "Manual",
};
private record ApiEnvelope<T>(T Data);
private record PatientDto(Guid Id);
private record EncounterDto(Guid Id);
}
@@ -53,7 +53,7 @@ public class SepsisRefactorTests : IAsyncLifetime
await qsofa.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 22m);
await qsofa.ProcessObservationAsync(_encounterId, _patientId, "WBC_K_UL", 15m);
(await db.ClinicalAlerts.CountAsync(a => a.AlertType == AlertType.SepsisWarning))
(await db.ClinicalAlerts.CountAsync(a => a.AlertType == AlertTypeExtensions.FromDbString("SEPSIS_WARNING")))
.Should().Be(0);
}
@@ -124,20 +124,20 @@ public class SepsisRefactorTests : IAsyncLifetime
db.ClinicalAlerts.Add(new ClinicalAlert
{
Id = Guid.NewGuid(), EncounterId = _encounterId, PatientId = _patientId,
AlertType = AlertType.SepsisWarning, Severity = AlertSeverity.Critical,
AlertType = AlertTypeExtensions.FromDbString("SEPSIS_WARNING"), Severity = AlertSeverity.Critical,
Details = "Legacy SIRS alert", Status = AlertStatus.Resolved,
TriggeredAt = DateTimeOffset.UtcNow.AddDays(-1)
});
await db.SaveChangesAsync();
var page = await alertService.ListByEncounterAsync(_encounterId, null, 1, 10);
page.Items.Should().ContainSingle(a => a.AlertType == AlertType.SepsisWarning);
page.Items.Should().ContainSingle(a => a.AlertType == AlertTypeExtensions.FromDbString("SEPSIS_WARNING"));
}
[Fact]
public void CannotCreateNewSepsisWarning()
{
var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning);
var act = () => AlertCreationGuard.EnsureAllowed(AlertTypeExtensions.FromDbString("SEPSIS_WARNING"));
act.Should().Throw<InvalidOperationException>()
.WithMessage("*deprecated*");
}
@@ -28,4 +28,9 @@
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\VigilCare.Simulator\Scenarios\ScenarioFile.cs" Link="Scenarios\ScenarioFile.cs" />
<Compile Include="..\VigilCare.Simulator\Scenarios\ScenarioValidator.cs" Link="Scenarios\ScenarioValidator.cs" />
</ItemGroup>
</Project>
@@ -11,13 +11,11 @@ public class GcsController : ControllerBase
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<GcsScoreResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(Guid encounterId)
{
var score = await _gcs.GetCurrentAsync(encounterId);
if (score is null)
return NotFound(ApiResponse<object>.Fail(
404, "No GCS score computed for this encounter.", "NO_GCS_SCORE"));
return Ok(ApiResponse<GcsScoreResponse?>.Ok(null));
return Ok(ApiResponse<GcsScoreResponse>.Ok(new GcsScoreResponse(
score.EyeScore, score.VerbalScore, score.MotorScore,
@@ -18,12 +18,11 @@ public class News2Controller : ControllerBase
/// </summary>
[HttpGet("current")]
[ProducesResponseType(typeof(ApiResponse<News2Score>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(Guid encounterId)
{
var score = await _news2.GetCurrentAsync(encounterId);
if (score is null)
return NotFound(ApiResponse<object>.Fail(404, "No NEWS2 score computed for this encounter.", "NO_NEWS2_SCORE"));
return Ok(ApiResponse<News2Score?>.Ok(null));
return Ok(ApiResponse<News2Score>.Ok(score));
}
@@ -17,12 +17,11 @@ public class SepsisBundlesController : ControllerBase
/// <param name="encounterId">Encounter id.</param>
[HttpGet("api/v1/encounters/{encounterId:guid}/sepsis-bundle/current")]
[ProducesResponseType(typeof(ApiResponse<SepsisBundle>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetCurrentByEncounter(Guid encounterId)
{
var bundle = await _bundles.GetCurrentByEncounterAsync(encounterId);
if (bundle is null)
return NotFound(ApiResponse<object>.Fail(404, "No sepsis bundle exists for this encounter.", "BUNDLE_NOT_FOUND"));
return Ok(ApiResponse<SepsisBundle?>.Ok(null));
return Ok(ApiResponse<SepsisBundle>.Ok(bundle));
}
@@ -12,13 +12,11 @@ public class SofaController : ControllerBase
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<SofaScoreResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(Guid encounterId)
{
var score = await _sofa.GetCurrentAsync(encounterId);
if (score is null)
return NotFound(ApiResponse<object>.Fail(
404, "No SOFA score computed for this encounter.", "NO_SOFA_SCORE"));
return Ok(ApiResponse<SofaScoreResponse?>.Ok(null));
return Ok(ApiResponse<SofaScoreResponse>.Ok(MapResponse(score)));
}
+157 -145
View File
@@ -48,150 +48,7 @@ public static class DataSeeder
};
db.Encounters.AddRange(encounter1, encounter2);
// Four alert thresholds
var thresholds = new List<AlertThreshold>
{
new() {
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", DisplayName = "Heart Rate",
Unit = "bpm", CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "TEMP_C", DisplayName = "Body Temperature",
Unit = "°C", CriticalLow = 35.0m, WarningLow = 36.0m, WarningHigh = 38.3m, CriticalHigh = 40.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "POTASSIUM_MEQ_L", DisplayName = "Serum Potassium",
Unit = "mEq/L", CriticalLow = 2.5m, WarningLow = 3.5m, WarningHigh = 5.0m, CriticalHigh = 6.5m,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "SPO2", DisplayName = "Oxygen Saturation",
Unit = "%", CriticalLow = 88, WarningLow = 92, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "RESP_RATE",
DisplayName = "Respiratory Rate", Unit = "breaths/min",
CriticalLow = null, WarningLow = 12m, WarningHigh = 20m, CriticalHigh = 30m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "WBC_K_UL",
DisplayName = "White Blood Cell Count", Unit = "k/µL",
CriticalLow = 2.0m, WarningLow = 4.0m, WarningHigh = 12.0m, CriticalHigh = 20.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "SYSTOLIC_BP",
DisplayName = "Systolic Blood Pressure", Unit = "mmHg",
CriticalLow = 70m, WarningLow = 90m, WarningHigh = 160m, CriticalHigh = 180m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "DIASTOLIC_BP",
DisplayName = "Diastolic Blood Pressure", Unit = "mmHg",
CriticalLow = 40m, WarningLow = 60m, WarningHigh = 90m, CriticalHigh = 110m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "LACTATE_MMOL_L",
DisplayName = "Serum Lactate", Unit = "mmol/L",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 4.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "AVPU",
DisplayName = "AVPU Consciousness Level", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = 2m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "SUPPLEMENTAL_O2",
DisplayName = "Supplemental Oxygen", Unit = "flag",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GLUCOSE_MG_DL",
DisplayName = "Blood Glucose", Unit = "mg/dL",
CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m,
CreatedAt = DateTimeOffset.UtcNow
},
// GCS components — registered for ingestion; thresholds are null (alerting is on computed total in GcsDetector)
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_EYE",
DisplayName = "GCS Eye Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_VERBAL",
DisplayName = "GCS Verbal Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_MOTOR",
DisplayName = "GCS Motor Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "PAO2_MMHG",
DisplayName = "Partial Pressure O2 (Arterial)", Unit = "mmHg",
CriticalLow = 60m, WarningLow = 80m, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "FIO2_PCT",
DisplayName = "Fraction of Inspired O2", Unit = "%",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "PLATELET_K_UL",
DisplayName = "Platelet Count", Unit = "k/µL",
CriticalLow = 20m, WarningLow = 50m, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "BILIRUBIN_MG_DL",
DisplayName = "Total Bilirubin", Unit = "mg/dL",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 6.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "CREATININE_MG_DL",
DisplayName = "Serum Creatinine", Unit = "mg/dL",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 3.5m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "URINE_OUTPUT_ML_H",
DisplayName = "Urine Output", Unit = "mL/h",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
};
var thresholds = BuildDefaultThresholds();
db.AlertThresholds.AddRange(thresholds);
// Observations spanning normal, warning, and critical ranges for encounter1
@@ -235,8 +92,163 @@ public static class DataSeeder
db.Observations.AddRange(observations);
await db.SaveChangesAsync();
await CacheThresholdsAsync(redis, thresholds);
}
// Populate Redis cache with the seeded thresholds
public static async Task SeedThresholdsOnlyAsync(AppDbContext db, IConnectionMultiplexer redis)
{
var thresholds = BuildDefaultThresholds();
db.AlertThresholds.AddRange(thresholds);
await db.SaveChangesAsync();
await CacheThresholdsAsync(redis, thresholds);
}
public static List<AlertThreshold> BuildDefaultThresholds() => new()
{
new() {
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", DisplayName = "Heart Rate",
Unit = "bpm", CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "TEMP_C", DisplayName = "Body Temperature",
Unit = "°C", CriticalLow = 35.0m, WarningLow = 36.0m, WarningHigh = 38.3m, CriticalHigh = 40.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "POTASSIUM_MEQ_L", DisplayName = "Serum Potassium",
Unit = "mEq/L", CriticalLow = 2.5m, WarningLow = 3.5m, WarningHigh = 5.0m, CriticalHigh = 6.5m,
CreatedAt = DateTimeOffset.UtcNow
},
new() {
Id = Guid.NewGuid(), ObservationCode = "SPO2", DisplayName = "Oxygen Saturation",
Unit = "%", CriticalLow = 88, WarningLow = 92, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "RESP_RATE",
DisplayName = "Respiratory Rate", Unit = "breaths/min",
CriticalLow = null, WarningLow = 12m, WarningHigh = 20m, CriticalHigh = 30m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "WBC_K_UL",
DisplayName = "White Blood Cell Count", Unit = "k/µL",
CriticalLow = 2.0m, WarningLow = 4.0m, WarningHigh = 12.0m, CriticalHigh = 20.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "SYSTOLIC_BP",
DisplayName = "Systolic Blood Pressure", Unit = "mmHg",
CriticalLow = 70m, WarningLow = 90m, WarningHigh = 160m, CriticalHigh = 180m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "DIASTOLIC_BP",
DisplayName = "Diastolic Blood Pressure", Unit = "mmHg",
CriticalLow = 40m, WarningLow = 60m, WarningHigh = 90m, CriticalHigh = 110m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "LACTATE_MMOL_L",
DisplayName = "Serum Lactate", Unit = "mmol/L",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 4.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "AVPU",
DisplayName = "AVPU Consciousness Level", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = 2m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "SUPPLEMENTAL_O2",
DisplayName = "Supplemental Oxygen", Unit = "flag",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GLUCOSE_MG_DL",
DisplayName = "Blood Glucose", Unit = "mg/dL",
CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_EYE",
DisplayName = "GCS Eye Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_VERBAL",
DisplayName = "GCS Verbal Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_MOTOR",
DisplayName = "GCS Motor Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "PAO2_MMHG",
DisplayName = "Partial Pressure O2 (Arterial)", Unit = "mmHg",
CriticalLow = 60m, WarningLow = 80m, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "FIO2_PCT",
DisplayName = "Fraction of Inspired O2", Unit = "%",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "PLATELET_K_UL",
DisplayName = "Platelet Count", Unit = "k/µL",
CriticalLow = 20m, WarningLow = 50m, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "BILIRUBIN_MG_DL",
DisplayName = "Total Bilirubin", Unit = "mg/dL",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 6.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "CREATININE_MG_DL",
DisplayName = "Serum Creatinine", Unit = "mg/dL",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 3.5m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "URINE_OUTPUT_ML_H",
DisplayName = "Urine Output", Unit = "mL/h",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
};
private static async Task CacheThresholdsAsync(
IConnectionMultiplexer redis, IEnumerable<AlertThreshold> thresholds)
{
var cache = redis.GetDatabase();
foreach (var t in thresholds)
{
+2 -1
View File
@@ -9,7 +9,8 @@ public class SofaDetector
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly IConnectionMultiplexer _redis;
@@ -0,0 +1,21 @@
```markdown
# Clinical Refactor — SOFA / GCS Interview Questions
## Q1: Why did you replace SIRS with SOFA for sepsis detection?
SIRS (Systemic Inflammatory Response Syndrome) uses four criteria — temperature, heart rate, respiratory rate, white blood cell count — to screen for sepsis. The problem is non-specificity: a patient who just exercised, is anxious, or has a mild viral infection can meet ≥ 2 criteria. The Sepsis-3 consensus (2016) replaced SIRS with SOFA (Sequential Organ Failure Assessment) because SOFA measures actual organ dysfunction across six systems. A delta SOFA ≥ 2 from baseline in the presence of suspected infection is both more specific and more clinically actionable than SIRS. We kept qSOFA as a bedside screening tool that recommends ordering SOFA labs, matching the Sepsis-3 two-tier approach.
## Q2: How does your system handle the fact that SOFA requires lab values that aren't continuously available?
Three strategies: **carry-forward with staleness** — the most recent lab value is cached in Redis with a configurable TTL (default 24 hours) and classified as CURRENT (< 12h), STALE (1224h), or EXPIRED (> 24h). Stale values are still used for scoring but flagged in `sofa_scores.staleness_flags` JSON so clinicians know the score is based on older data. Second, **SpO₂/FiO₂ fallback** — when arterial blood gas isn't available (common on general wards), we use the SpO₂/FiO₂ ratio as a proxy for the respiratory component, per Rice et al. (2007). Third, **partial scoring** — SOFA baseline is only established when ≥ 4 of 6 organ systems have data, preventing false low baselines that would inflate the delta.
## Q3: Why did you keep qSOFA after removing SIRS, and how does the screening workflow differ from the old SIRS alert?
qSOFA is a validated bedside screening tool — three simple criteria (respiratory rate, blood pressure, mental status) that any nurse can assess without labs. The key change is clinical positioning: under SIRS, meeting ≥ 2 criteria immediately declared sepsis and triggered the treatment bundle. Under the new workflow, qSOFA ≥ 2 creates a WARNING-level screening alert (`QSOFA_SCREEN`) that recommends ordering SOFA labs (PaO₂/FiO₂, platelets, bilirubin, creatinine). Only when those labs confirm organ dysfunction — SOFA delta ≥ 2 — does the system declare sepsis (`SOFA_SEPSIS`) and trigger the bundle. This prevents false-positive bundle activations that occurred with SIRS.
## Q4: How did you handle the GCS → SOFA → NEWS2 → qSOFA dependency chain?
GCS feeds into three downstream systems: SOFA CNS scoring (GCS → 04 organ score), NEWS2 consciousness (GCS 15 = score 0, GCS < 15 = score 3), and qSOFA altered mentation (GCS < 15 = criterion met). Architecturally, the GCS Kafka consumer computes the total and caches it in Redis. Downstream consumers (SOFA, NEWS2, qSOFA) read GCS from Redis when triggered. The resolution order is **GCS-first with AVPU-fallback** — if GCS components exist, they take priority; if only AVPU is available (legacy data), the old mapping still works. This avoided a breaking migration while making GCS the preferred consciousness assessment going forward.
```
Also update `docs/clinical-scoring-refactor-summary.md` — add a "Phase 29 scenarios" section listing the three new scenario IDs and what each validates.
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
echo "=== Phase 28 verification ==="
echo "1. Dashboard unit tests"
cd "${ROOT_DIR}/vigilcare-dashboard"
npm test
echo "2. Manual UI checks (requires running stack + dashboard dev server)"
BASE_URL="${BASE_URL:-http://localhost:5270}"
DASH_URL="${DASH_URL:-http://localhost:5173}"
ENCOUNTER_ID="${ENCOUNTER_ID:?Set ENCOUNTER_ID to an active encounter UUID}"
post_gcs() {
local eye="$1" verbal="$2" motor="$3"
curl -sf -X POST "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/observations" \
-H "Content-Type: application/json" \
-d "{\"observations\":[
{\"observationCode\":\"GCS_EYE\",\"value\":${eye},\"unit\":\"score\",\"source\":\"Manual\",\"recordedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"},
{\"observationCode\":\"GCS_VERBAL\",\"value\":${verbal},\"unit\":\"score\",\"source\":\"Manual\",\"recordedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"},
{\"observationCode\":\"GCS_MOTOR\",\"value\":${motor},\"unit\":\"score\",\"source\":\"Manual\",\"recordedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}
]}"
}
post_gcs 3 4 5
curl -sf "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/gcs" | jq -e '.data.totalScore == 12'
curl -sf "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/sofa" || echo "(SOFA 404 OK if labs not yet ingested)"
echo "Open ${DASH_URL}/encounters/${ENCOUNTER_ID} and verify:"
echo " - ScoresPanel: NEWS2, GCS, SOFA, qSOFA Screen sections"
echo " - GCS form: E=3 V=4 M=5 → total 12 Moderate"
echo " - SofaScorePanel: 6-organ grid or Labs pending"
echo " - SepsisBundlePanel: SOFA trigger label or qSOFA screen message"
echo " - Alert Center: new alert type labels; no SIRS except legacy"
echo "Phase 28 verification complete."
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SIM_DIR="${ROOT_DIR}/VigilCare.Simulator"
BASE_URL="${BASE_URL:-http://localhost:5270}"
echo "=== Phase 29 — Clinical Refactor Validation ==="
echo "1. Validate all scenario JSON files"
for scenario in "${SIM_DIR}"/Scenarios/List/*.json; do
dotnet run --project "${SIM_DIR}" -- validate "${scenario}"
done
echo "2. Integration tests"
dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" \
--filter "FullyQualifiedName~ClinicalRefactorEndToEnd" \
--no-restore
echo "3. Replay new scenarios (requires running API at ${BASE_URL})"
cd "${SIM_DIR}"
dotnet run -- replay Scenarios/List/sepsis-sofa-progression-01.json --speed 0 --poll --base-url "${BASE_URL}"
dotnet run -- replay Scenarios/List/neurological-decline-gcs-01.json --speed 0 --poll --base-url "${BASE_URL}"
dotnet run -- replay Scenarios/List/sofa-partial-spo2-fallback-01.json --speed 0 --poll --base-url "${BASE_URL}"
echo "4. Replay-all backward compat"
dotnet run -- replay-all Scenarios/List --speed 0 --base-url "${BASE_URL}"
echo "5. Dashboard tests (Phase 28 UI)"
cd "${ROOT_DIR}/vigilcare-dashboard"
npm test
echo "Phase 29 verification complete."
echo "Manual: open patient detail after sepsis-sofa-progression replay — verify GCS/SOFA panels and SOFA-triggered bundle."
+15
View File
@@ -13,8 +13,23 @@ async function request(path, options = {}) {
return envelope.data
}
/** Returns null when the resource does not exist yet (HTTP 404 or empty optional payload). */
async function requestOptional(path) {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json' },
})
const envelope = await res.json()
if (res.status === 404) return null
if (!res.ok || !envelope.success) {
const msg = envelope.error?.message ?? `API ${res.status}: ${path}`
throw new Error(msg)
}
return envelope.data ?? null
}
export const api = {
get: (path) => request(path),
getOptional: (path) => requestOptional(path),
post: (path, body) => request(path, {
method: 'POST',
body: body !== undefined ? JSON.stringify(body) : undefined,
+4 -4
View File
@@ -1,7 +1,7 @@
import { api } from './client'
export function fetchCurrentNews2(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/news2/current`)
return api.getOptional(`/api/v1/encounters/${encounterId}/news2/current`)
}
export function fetchCurrentQsofa(encounterId) {
@@ -9,15 +9,15 @@ export function fetchCurrentQsofa(encounterId) {
}
export function fetchCurrentGcs(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/gcs`)
return api.getOptional(`/api/v1/encounters/${encounterId}/gcs`)
}
export function fetchCurrentSofa(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/sofa`)
return api.getOptional(`/api/v1/encounters/${encounterId}/sofa`)
}
export function fetchSepsisBundle(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/sepsis-bundle/current`)
return api.getOptional(`/api/v1/encounters/${encounterId}/sepsis-bundle/current`)
}
export function fetchOrders(encounterId) {
+5 -4
View File
@@ -68,10 +68,10 @@ export const useScoringStore = defineStore('scoring', () => {
const id = encounterId.value
try {
const [g, s, n, q] = await Promise.all([
clinicalApi.fetchCurrentGcs(id).catch(() => null),
clinicalApi.fetchCurrentSofa(id).catch(() => null),
clinicalApi.fetchCurrentNews2(id).catch(() => null),
clinicalApi.fetchCurrentQsofa(id).catch(() => null),
clinicalApi.fetchCurrentGcs(id),
clinicalApi.fetchCurrentSofa(id),
clinicalApi.fetchCurrentNews2(id),
clinicalApi.fetchCurrentQsofa(id),
])
gcs.value = g
sofa.value = s
@@ -94,6 +94,7 @@ export const useScoringStore = defineStore('scoring', () => {
}
function startPolling(id) {
if (encounterId.value === id && timer) return
stopPolling()
encounterId.value = id
refresh()
@@ -93,7 +93,7 @@ async function loadAll() {
encountersApi.fetchObservations(id),
clinicalApi.fetchNews2History(id).catch(() => []),
clinicalApi.fetchMedications(id).catch(() => []),
clinicalApi.fetchSepsisBundle(id).catch(() => null),
clinicalApi.fetchSepsisBundle(id),
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
])
await alertStore.loadAlerts(id)