diff --git a/VigilCare.Simulator/Client/Models/GcsResponse.cs b/VigilCare.Simulator/Client/Models/GcsResponse.cs new file mode 100644 index 0000000..2624704 --- /dev/null +++ b/VigilCare.Simulator/Client/Models/GcsResponse.cs @@ -0,0 +1,3 @@ +public record GcsResponse( + int EyeScore, int VerbalScore, int MotorScore, + int TotalScore, string Classification, DateTimeOffset CalculatedAt); \ No newline at end of file diff --git a/VigilCare.Simulator/Client/Models/QsofaResponse.cs b/VigilCare.Simulator/Client/Models/QsofaResponse.cs new file mode 100644 index 0000000..aae190c --- /dev/null +++ b/VigilCare.Simulator/Client/Models/QsofaResponse.cs @@ -0,0 +1 @@ +public record QsofaResponse(int ActiveCriteria); \ No newline at end of file diff --git a/VigilCare.Simulator/Client/Models/SofaResponse.cs b/VigilCare.Simulator/Client/Models/SofaResponse.cs new file mode 100644 index 0000000..9ce7ecd --- /dev/null +++ b/VigilCare.Simulator/Client/Models/SofaResponse.cs @@ -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); diff --git a/VigilCare.Simulator/Client/Models/SofaStalenessResponse.cs b/VigilCare.Simulator/Client/Models/SofaStalenessResponse.cs new file mode 100644 index 0000000..069adbb --- /dev/null +++ b/VigilCare.Simulator/Client/Models/SofaStalenessResponse.cs @@ -0,0 +1,4 @@ +public record SofaStalenessResponse( + IReadOnlyList StaleComponents, + IReadOnlyList MissingComponents, + bool UsedSpO2Fallback); \ No newline at end of file diff --git a/VigilCare.Simulator/Client/VigilCareApiClient.cs b/VigilCare.Simulator/Client/VigilCareApiClient.cs index dc0cab8..54bd18e 100644 --- a/VigilCare.Simulator/Client/VigilCareApiClient.cs +++ b/VigilCare.Simulator/Client/VigilCareApiClient.cs @@ -119,4 +119,28 @@ public class VigilCareApiClient .ReadFromJsonAsync>(); return envelope?.Data; } + + public async Task GetCurrentGcsAsync(Guid encounterId) + { + var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/gcs"); + if (!response.IsSuccessStatusCode) return null; + var envelope = await response.Content.ReadFromJsonAsync>(); + return envelope?.Data; + } + + public async Task GetCurrentSofaAsync(Guid encounterId) + { + var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/sofa"); + if (!response.IsSuccessStatusCode) return null; + var envelope = await response.Content.ReadFromJsonAsync>(); + return envelope?.Data; + } + + public async Task 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>(); + return envelope?.Data; + } } \ No newline at end of file diff --git a/VigilCare.Simulator/Output/SimulatorConsole.cs b/VigilCare.Simulator/Output/SimulatorConsole.cs index e89a8dc..46f4d23 100644 --- a/VigilCare.Simulator/Output/SimulatorConsole.cs +++ b/VigilCare.Simulator/Output/SimulatorConsole.cs @@ -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}"); } } \ No newline at end of file diff --git a/VigilCare.Simulator/Polling/ApiPoller.cs b/VigilCare.Simulator/Polling/ApiPoller.cs index 8975382..115a488 100644 --- a/VigilCare.Simulator/Polling/ApiPoller.cs +++ b/VigilCare.Simulator/Polling/ApiPoller.cs @@ -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); } } \ No newline at end of file diff --git a/VigilCare.Simulator/Polling/PollResult.cs b/VigilCare.Simulator/Polling/PollResult.cs index 7ebed10..9c5be30 100644 --- a/VigilCare.Simulator/Polling/PollResult.cs +++ b/VigilCare.Simulator/Polling/PollResult.cs @@ -1,4 +1,7 @@ public record PollResult( News2Response? News2, - List NewAlerts, + GcsResponse? Gcs, + SofaResponse? Sofa, + QsofaResponse? Qsofa, + IReadOnlyList NewAlerts, SepsisBundleResponse? SepsisBundle); \ No newline at end of file diff --git a/VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json b/VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json index da0d3ca..ab7a7ae 100644 --- a/VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json +++ b/VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json @@ -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", diff --git a/VigilCare.Simulator/Scenarios/List/dka-electrolyte-01.json b/VigilCare.Simulator/Scenarios/List/dka-electrolyte-01.json index 43734b0..1b47f36 100644 --- a/VigilCare.Simulator/Scenarios/List/dka-electrolyte-01.json +++ b/VigilCare.Simulator/Scenarios/List/dka-electrolyte-01.json @@ -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", diff --git a/VigilCare.Simulator/Scenarios/List/hypothermia-elderly-01.json b/VigilCare.Simulator/Scenarios/List/hypothermia-elderly-01.json index cbaa5f7..b3fb5f5 100644 --- a/VigilCare.Simulator/Scenarios/List/hypothermia-elderly-01.json +++ b/VigilCare.Simulator/Scenarios/List/hypothermia-elderly-01.json @@ -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", diff --git a/VigilCare.Simulator/Scenarios/List/medication-false-alarm-01.json b/VigilCare.Simulator/Scenarios/List/medication-false-alarm-01.json index 0d6a240..921689e 100644 --- a/VigilCare.Simulator/Scenarios/List/medication-false-alarm-01.json +++ b/VigilCare.Simulator/Scenarios/List/medication-false-alarm-01.json @@ -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, diff --git a/VigilCare.Simulator/Scenarios/List/neurological-decline-gcs-01.json b/VigilCare.Simulator/Scenarios/List/neurological-decline-gcs-01.json new file mode 100644 index 0000000..8f23e85 --- /dev/null +++ b/VigilCare.Simulator/Scenarios/List/neurological-decline-gcs-01.json @@ -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 9–12 band" + }, + { + "afterOffsetMinutes": 150, + "type": "alert", + "alertType": "GCS_CRITICAL", + "description": "GCS 6 ≤ 8" + } + ] +} \ No newline at end of file diff --git a/VigilCare.Simulator/Scenarios/List/post-op-hemorrhage-01.json b/VigilCare.Simulator/Scenarios/List/post-op-hemorrhage-01.json index e517658..7caf41c 100644 --- a/VigilCare.Simulator/Scenarios/List/post-op-hemorrhage-01.json +++ b/VigilCare.Simulator/Scenarios/List/post-op-hemorrhage-01.json @@ -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", diff --git a/VigilCare.Simulator/Scenarios/List/respiratory-failure-asthma-01.json b/VigilCare.Simulator/Scenarios/List/respiratory-failure-asthma-01.json index 977b34f..e1e87fc 100644 --- a/VigilCare.Simulator/Scenarios/List/respiratory-failure-asthma-01.json +++ b/VigilCare.Simulator/Scenarios/List/respiratory-failure-asthma-01.json @@ -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", diff --git a/VigilCare.Simulator/Scenarios/List/sepsis-sofa-progression-01.json b/VigilCare.Simulator/Scenarios/List/sepsis-sofa-progression-01.json new file mode 100644 index 0000000..97f98c3 --- /dev/null +++ b/VigilCare.Simulator/Scenarios/List/sepsis-sofa-progression-01.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/VigilCare.Simulator/Scenarios/List/sofa-partial-spo2-fallback-01.json b/VigilCare.Simulator/Scenarios/List/sofa-partial-spo2-fallback-01.json new file mode 100644 index 0000000..8d38b0d --- /dev/null +++ b/VigilCare.Simulator/Scenarios/List/sofa-partial-spo2-fallback-01.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/VigilCare.Simulator/Scenarios/List/stable-baseline-01.json b/VigilCare.Simulator/Scenarios/List/stable-baseline-01.json index 131244d..524df3e 100644 --- a/VigilCare.Simulator/Scenarios/List/stable-baseline-01.json +++ b/VigilCare.Simulator/Scenarios/List/stable-baseline-01.json @@ -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", diff --git a/VigilCare.Simulator/Scenarios/List/uti-sepsis-elderly-01.json b/VigilCare.Simulator/Scenarios/List/uti-sepsis-elderly-01.json index 05d07b1..448d5d5 100644 --- a/VigilCare.Simulator/Scenarios/List/uti-sepsis-elderly-01.json +++ b/VigilCare.Simulator/Scenarios/List/uti-sepsis-elderly-01.json @@ -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" } ] } diff --git a/VigilCare.Simulator/Scenarios/ScenarioValidator.cs b/VigilCare.Simulator/Scenarios/ScenarioValidator.cs index ff98baa..470f54c 100644 --- a/VigilCare.Simulator/Scenarios/ScenarioValidator.cs +++ b/VigilCare.Simulator/Scenarios/ScenarioValidator.cs @@ -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 ValidSources = new() @@ -67,15 +72,15 @@ public static class ScenarioValidator } var placedOrders = new HashSet(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)"); } } } diff --git a/VigilCare.Simulator/Scenarios/schema.json b/VigilCare.Simulator/Scenarios/schema.json index 5a6f123..8659d7c 100644 --- a/VigilCare.Simulator/Scenarios/schema.json +++ b/VigilCare.Simulator/Scenarios/schema.json @@ -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" + ] + } } } } \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/AlertCreationGuardTests.cs b/VigilCareClinicalAPI.Tests/AlertCreationGuardTests.cs index f287f9f..904bd15 100644 --- a/VigilCareClinicalAPI.Tests/AlertCreationGuardTests.cs +++ b/VigilCareClinicalAPI.Tests/AlertCreationGuardTests.cs @@ -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() .WithMessage("*deprecated*"); } diff --git a/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs b/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs new file mode 100644 index 0000000..2c52b53 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs @@ -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(); + await DbResetHelper.ResetAsync(db); + + var redis = scope.ServiceProvider.GetRequiredService(); + 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(); + 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( + 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)); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs new file mode 100644 index 0000000..8f5d885 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs @@ -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(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>(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>(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 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(); + 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(); + 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(); + 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 Data); + private record PatientDto(Guid Id); + private record EncounterDto(Guid Id); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/SepsisRefactorTests.cs b/VigilCareClinicalAPI.Tests/SepsisRefactorTests.cs index 47f6c17..12d7f8c 100644 --- a/VigilCareClinicalAPI.Tests/SepsisRefactorTests.cs +++ b/VigilCareClinicalAPI.Tests/SepsisRefactorTests.cs @@ -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() .WithMessage("*deprecated*"); } diff --git a/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj b/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj index f03a013..166fbb2 100644 --- a/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj +++ b/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj @@ -24,8 +24,13 @@ - - + + + + + + + diff --git a/VigilCareClinicalAPI/Controllers/GcsController.cs b/VigilCareClinicalAPI/Controllers/GcsController.cs index 3bb1437..fdbefc8 100644 --- a/VigilCareClinicalAPI/Controllers/GcsController.cs +++ b/VigilCareClinicalAPI/Controllers/GcsController.cs @@ -11,13 +11,11 @@ public class GcsController : ControllerBase [HttpGet] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Current(Guid encounterId) { var score = await _gcs.GetCurrentAsync(encounterId); if (score is null) - return NotFound(ApiResponse.Fail( - 404, "No GCS score computed for this encounter.", "NO_GCS_SCORE")); + return Ok(ApiResponse.Ok(null)); return Ok(ApiResponse.Ok(new GcsScoreResponse( score.EyeScore, score.VerbalScore, score.MotorScore, diff --git a/VigilCareClinicalAPI/Controllers/News2Controller.cs b/VigilCareClinicalAPI/Controllers/News2Controller.cs index a6745a0..29f0dca 100644 --- a/VigilCareClinicalAPI/Controllers/News2Controller.cs +++ b/VigilCareClinicalAPI/Controllers/News2Controller.cs @@ -18,12 +18,11 @@ public class News2Controller : ControllerBase /// [HttpGet("current")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Current(Guid encounterId) { var score = await _news2.GetCurrentAsync(encounterId); if (score is null) - return NotFound(ApiResponse.Fail(404, "No NEWS2 score computed for this encounter.", "NO_NEWS2_SCORE")); + return Ok(ApiResponse.Ok(null)); return Ok(ApiResponse.Ok(score)); } diff --git a/VigilCareClinicalAPI/Controllers/SepsisBundlesController.cs b/VigilCareClinicalAPI/Controllers/SepsisBundlesController.cs index 510e34e..0881db1 100644 --- a/VigilCareClinicalAPI/Controllers/SepsisBundlesController.cs +++ b/VigilCareClinicalAPI/Controllers/SepsisBundlesController.cs @@ -17,12 +17,11 @@ public class SepsisBundlesController : ControllerBase /// Encounter id. [HttpGet("api/v1/encounters/{encounterId:guid}/sepsis-bundle/current")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task GetCurrentByEncounter(Guid encounterId) { var bundle = await _bundles.GetCurrentByEncounterAsync(encounterId); if (bundle is null) - return NotFound(ApiResponse.Fail(404, "No sepsis bundle exists for this encounter.", "BUNDLE_NOT_FOUND")); + return Ok(ApiResponse.Ok(null)); return Ok(ApiResponse.Ok(bundle)); } diff --git a/VigilCareClinicalAPI/Controllers/SofaController.cs b/VigilCareClinicalAPI/Controllers/SofaController.cs index 22f4f6a..a0b5e2a 100644 --- a/VigilCareClinicalAPI/Controllers/SofaController.cs +++ b/VigilCareClinicalAPI/Controllers/SofaController.cs @@ -12,13 +12,11 @@ public class SofaController : ControllerBase [HttpGet] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task Current(Guid encounterId) { var score = await _sofa.GetCurrentAsync(encounterId); if (score is null) - return NotFound(ApiResponse.Fail( - 404, "No SOFA score computed for this encounter.", "NO_SOFA_SCORE")); + return Ok(ApiResponse.Ok(null)); return Ok(ApiResponse.Ok(MapResponse(score))); } diff --git a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs index 6edb123..f3c4b59 100644 --- a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs +++ b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs @@ -48,150 +48,7 @@ public static class DataSeeder }; db.Encounters.AddRange(encounter1, encounter2); - // Four alert thresholds - var thresholds = new List - { - 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 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 thresholds) + { var cache = redis.GetDatabase(); foreach (var t in thresholds) { diff --git a/VigilCareClinicalAPI/Sofa/SofaDetector.cs b/VigilCareClinicalAPI/Sofa/SofaDetector.cs index 52fa7ba..2bf8e54 100644 --- a/VigilCareClinicalAPI/Sofa/SofaDetector.cs +++ b/VigilCareClinicalAPI/Sofa/SofaDetector.cs @@ -9,7 +9,8 @@ public class SofaDetector { private static readonly JsonSerializerOptions JsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private readonly IConnectionMultiplexer _redis; diff --git a/docs/decisions/clinical-refactor-sofa-gcs.md b/docs/decisions/clinical-refactor-sofa-gcs.md new file mode 100644 index 0000000..1a59acd --- /dev/null +++ b/docs/decisions/clinical-refactor-sofa-gcs.md @@ -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 (12–24h), 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 → 0–4 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. \ No newline at end of file diff --git a/scripts/run-phase28-verification.sh b/scripts/run-phase28-verification.sh new file mode 100755 index 0000000..2a9f92b --- /dev/null +++ b/scripts/run-phase28-verification.sh @@ -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." diff --git a/scripts/run-phase29-verification.sh b/scripts/run-phase29-verification.sh new file mode 100644 index 0000000..b175c6c --- /dev/null +++ b/scripts/run-phase29-verification.sh @@ -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." \ No newline at end of file diff --git a/vigilcare-dashboard/src/api/client.js b/vigilcare-dashboard/src/api/client.js index cb53607..d670215 100644 --- a/vigilcare-dashboard/src/api/client.js +++ b/vigilcare-dashboard/src/api/client.js @@ -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, diff --git a/vigilcare-dashboard/src/api/clinical.js b/vigilcare-dashboard/src/api/clinical.js index 03162cc..cb41727 100644 --- a/vigilcare-dashboard/src/api/clinical.js +++ b/vigilcare-dashboard/src/api/clinical.js @@ -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) { diff --git a/vigilcare-dashboard/src/stores/scoring.js b/vigilcare-dashboard/src/stores/scoring.js index f5e6be7..abc64b6 100644 --- a/vigilcare-dashboard/src/stores/scoring.js +++ b/vigilcare-dashboard/src/stores/scoring.js @@ -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() diff --git a/vigilcare-dashboard/src/views/PatientDetail.vue b/vigilcare-dashboard/src/views/PatientDetail.vue index 9694cc3..52b2584 100644 --- a/vigilcare-dashboard/src/views/PatientDetail.vue +++ b/vigilcare-dashboard/src/views/PatientDetail.vue @@ -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)