feature: Simulator Scenarios + Clinical Validation: SOFA/GCS
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 9–12 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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 (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.
|
||||
Executable
+40
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user