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>>();
|
.ReadFromJsonAsync<ApiResponse<SepsisBundleResponse>>();
|
||||||
return envelope?.Data;
|
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(
|
AnsiConsole.MarkupLine(
|
||||||
$"[cyan][[{simTime}]][/] NEWS2 = {poll.News2.TotalScore} ({poll.News2.RiskLevel})");
|
$"[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)
|
foreach (var alert in poll.NewAlerts)
|
||||||
AnsiConsole.MarkupLine(
|
AnsiConsole.MarkupLine(
|
||||||
$"[red][[{simTime}]][/] ALERT {alert.AlertType} ({alert.Severity})");
|
$"[red][[{simTime}]][/] ALERT {alert.AlertType} ({alert.Severity})");
|
||||||
@@ -78,6 +94,7 @@ public static class SimulatorConsole
|
|||||||
if (poll.SepsisBundle is not null)
|
if (poll.SepsisBundle is not null)
|
||||||
AnsiConsole.MarkupLine(
|
AnsiConsole.MarkupLine(
|
||||||
$"[cyan][[{simTime}]][/] SEPSIS BUNDLE {poll.SepsisBundle.ComplianceStatus} " +
|
$"[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)
|
public async Task PollAndDisplayAsync(Guid encounterId, string simTime)
|
||||||
{
|
{
|
||||||
var news2 = await _client.GetCurrentNews2Async(encounterId);
|
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 allAlerts = await _client.GetAlertsAsync(encounterId);
|
||||||
var newAlerts = allAlerts.Where(a => _seenAlertIds.Add(a.Id)).ToList();
|
var newAlerts = allAlerts.Where(a => _seenAlertIds.Add(a.Id)).ToList();
|
||||||
|
|
||||||
var bundle = await _client.GetSepsisBundleAsync(encounterId);
|
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);
|
SimulatorConsole.PollResults(simTime, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
public record PollResult(
|
public record PollResult(
|
||||||
News2Response? News2,
|
News2Response? News2,
|
||||||
List<AlertResponse> NewAlerts,
|
GcsResponse? Gcs,
|
||||||
|
SofaResponse? Sofa,
|
||||||
|
QsofaResponse? Qsofa,
|
||||||
|
IReadOnlyList<AlertResponse> NewAlerts,
|
||||||
SepsisBundleResponse? SepsisBundle);
|
SepsisBundleResponse? SepsisBundle);
|
||||||
@@ -99,6 +99,36 @@
|
|||||||
"source": "Manual"
|
"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,
|
"offsetMinutes": 15,
|
||||||
"type": "observation",
|
"type": "observation",
|
||||||
|
|||||||
@@ -101,6 +101,36 @@
|
|||||||
"source": "Manual"
|
"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,
|
"offsetMinutes": 2,
|
||||||
"type": "observation",
|
"type": "observation",
|
||||||
|
|||||||
@@ -110,6 +110,37 @@
|
|||||||
},
|
},
|
||||||
"note": "Nasal cannula 2L/min started on arrival"
|
"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,
|
"offsetMinutes": 0,
|
||||||
"type": "order",
|
"type": "order",
|
||||||
|
|||||||
@@ -56,6 +56,21 @@
|
|||||||
"type": "observation",
|
"type": "observation",
|
||||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
"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,
|
"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"
|
"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,
|
"offsetMinutes": 20,
|
||||||
"type": "observation",
|
"type": "observation",
|
||||||
@@ -718,6 +748,37 @@
|
|||||||
{
|
{
|
||||||
"offsetMinutes": 120,
|
"offsetMinutes": 120,
|
||||||
"type": "observation",
|
"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": {
|
"data": {
|
||||||
"code": "SUPPLEMENTAL_O2",
|
"code": "SUPPLEMENTAL_O2",
|
||||||
"value": 1,
|
"value": 1,
|
||||||
@@ -726,7 +787,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"offsetMinutes": 120,
|
"offsetMinutes": 121,
|
||||||
"type": "observation",
|
"type": "observation",
|
||||||
"data": {
|
"data": {
|
||||||
"code": "LACTATE_MMOL_L",
|
"code": "LACTATE_MMOL_L",
|
||||||
|
|||||||
@@ -98,6 +98,36 @@
|
|||||||
"source": "Manual"
|
"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,
|
"offsetMinutes": 0,
|
||||||
"type": "order",
|
"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"
|
"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,
|
"offsetMinutes": 30,
|
||||||
"type": "observation",
|
"type": "observation",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"scenario": {
|
"scenario": {
|
||||||
"id": "uti-sepsis-elderly-01",
|
"id": "uti-sepsis-elderly-01",
|
||||||
"name": "UTI Progressing to Sepsis in Elderly Patient",
|
"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,
|
"durationMinutes": 240,
|
||||||
"tags": [
|
"tags": [
|
||||||
"sepsis",
|
"sepsis",
|
||||||
@@ -98,6 +98,36 @@
|
|||||||
"source": "Manual"
|
"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,
|
"offsetMinutes": 20,
|
||||||
"type": "observation",
|
"type": "observation",
|
||||||
@@ -281,6 +311,56 @@
|
|||||||
"source": "Lab"
|
"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,
|
"offsetMinutes": 40,
|
||||||
"type": "order",
|
"type": "order",
|
||||||
@@ -450,7 +530,7 @@
|
|||||||
"unit": "bpm",
|
"unit": "bpm",
|
||||||
"source": "Device"
|
"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,
|
"offsetMinutes": 90,
|
||||||
@@ -627,7 +707,7 @@
|
|||||||
"unit": "bpm",
|
"unit": "bpm",
|
||||||
"source": "Device"
|
"source": "Device"
|
||||||
},
|
},
|
||||||
"note": "NEWS2 = 6, SIRS 3 of 4 criteria met (Temp, HR, RR)"
|
"note": "NEWS2 = 6, continued deterioration"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"offsetMinutes": 120,
|
"offsetMinutes": 120,
|
||||||
@@ -698,7 +778,7 @@
|
|||||||
"unit": "\u00d710\u00b3/\u00b5L",
|
"unit": "\u00d710\u00b3/\u00b5L",
|
||||||
"source": "Lab"
|
"source": "Lab"
|
||||||
},
|
},
|
||||||
"note": "Repeat labs \u2014 WBC elevated, lactate rising, SIRS 4/4"
|
"note": "Repeat labs \u2014 WBC elevated, lactate rising"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"offsetMinutes": 120,
|
"offsetMinutes": 120,
|
||||||
@@ -730,6 +810,48 @@
|
|||||||
"source": "Lab"
|
"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,
|
"offsetMinutes": 125,
|
||||||
"type": "medication",
|
"type": "medication",
|
||||||
@@ -1308,46 +1430,34 @@
|
|||||||
"description": "HR enters warning range (91 bpm)"
|
"description": "HR enters warning range (91 bpm)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"afterOffsetMinutes": 90,
|
"afterOffsetMinutes": 105,
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"alertType": "SEPSIS_WARNING",
|
"alertType": "QSOFA_SCREEN",
|
||||||
"description": "SIRS criteria met: Temp 38.4\u00b0C (>38.3) + HR 96 (>90) = 2 of 4"
|
"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",
|
"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,
|
"afterOffsetMinutes": 105,
|
||||||
"type": "score",
|
"type": "score",
|
||||||
"scoreType": "NEWS2",
|
"scoreType": "NEWS2",
|
||||||
"expectedMinimum": 5,
|
"expectedMinimum": 5,
|
||||||
"description": "NEWS2 reaches 5 (HR=1, RR=2, Temp=1, SpO2=1) \u2192 WARNING threshold"
|
"description": "NEWS2 reaches medium risk"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"afterOffsetMinutes": 135,
|
"afterOffsetMinutes": 135,
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"alertType": "NEWS2_EMERGENCY",
|
"alertType": "NEWS2_EMERGENCY",
|
||||||
"description": "NEWS2 reaches 8 (HR=2, RR=2, SBP=1, Temp=2, SpO2=1) \u22657 \u2192 EMERGENCY"
|
"description": "NEWS2 \u2265 7 at peak deterioration"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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)"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ public static class ScenarioValidator
|
|||||||
{
|
{
|
||||||
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP",
|
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP",
|
||||||
"TEMP_C", "SPO2", "AVPU", "SUPPLEMENTAL_O2",
|
"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()
|
private static readonly HashSet<string> ValidSources = new()
|
||||||
@@ -67,15 +72,15 @@ public static class ScenarioValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
var placedOrders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var placedOrders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
double lastOffset = -1;
|
var indexedEvents = scenario.Events
|
||||||
for (int i = 0; i < scenario.Events.Count; i++)
|
.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)
|
if (evt.OffsetMinutes < 0)
|
||||||
errors.Add($"events[{i}]: offsetMinutes cannot be negative");
|
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))
|
if (!ValidEventTypes.Contains(evt.Type))
|
||||||
errors.Add($"events[{i}]: unknown type '{evt.Type}'");
|
errors.Add($"events[{i}]: unknown type '{evt.Type}'");
|
||||||
@@ -123,7 +128,7 @@ public static class ScenarioValidator
|
|||||||
{
|
{
|
||||||
errors.Add(
|
errors.Add(
|
||||||
$"events[{i}]: order_result '{orderDescription}' has no matching prior 'order' event " +
|
$"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" }
|
"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]
|
[Fact]
|
||||||
public void CannotCreateNewSepsisWarning()
|
public void CannotCreateNewSepsisWarning()
|
||||||
{
|
{
|
||||||
var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning);
|
var act = () => AlertCreationGuard.EnsureAllowed(AlertTypeExtensions.FromDbString("SEPSIS_WARNING"));
|
||||||
act.Should().Throw<InvalidOperationException>()
|
act.Should().Throw<InvalidOperationException>()
|
||||||
.WithMessage("*deprecated*");
|
.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, "RESP_RATE", 22m);
|
||||||
await qsofa.ProcessObservationAsync(_encounterId, _patientId, "WBC_K_UL", 15m);
|
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);
|
.Should().Be(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,20 +124,20 @@ public class SepsisRefactorTests : IAsyncLifetime
|
|||||||
db.ClinicalAlerts.Add(new ClinicalAlert
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), EncounterId = _encounterId, PatientId = _patientId,
|
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,
|
Details = "Legacy SIRS alert", Status = AlertStatus.Resolved,
|
||||||
TriggeredAt = DateTimeOffset.UtcNow.AddDays(-1)
|
TriggeredAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var page = await alertService.ListByEncounterAsync(_encounterId, null, 1, 10);
|
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]
|
[Fact]
|
||||||
public void CannotCreateNewSepsisWarning()
|
public void CannotCreateNewSepsisWarning()
|
||||||
{
|
{
|
||||||
var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning);
|
var act = () => AlertCreationGuard.EnsureAllowed(AlertTypeExtensions.FromDbString("SEPSIS_WARNING"));
|
||||||
act.Should().Throw<InvalidOperationException>()
|
act.Should().Throw<InvalidOperationException>()
|
||||||
.WithMessage("*deprecated*");
|
.WithMessage("*deprecated*");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,9 @@
|
|||||||
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
|
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ public class GcsController : ControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(ApiResponse<GcsScoreResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<GcsScoreResponse>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> Current(Guid encounterId)
|
public async Task<IActionResult> Current(Guid encounterId)
|
||||||
{
|
{
|
||||||
var score = await _gcs.GetCurrentAsync(encounterId);
|
var score = await _gcs.GetCurrentAsync(encounterId);
|
||||||
if (score is null)
|
if (score is null)
|
||||||
return NotFound(ApiResponse<object>.Fail(
|
return Ok(ApiResponse<GcsScoreResponse?>.Ok(null));
|
||||||
404, "No GCS score computed for this encounter.", "NO_GCS_SCORE"));
|
|
||||||
|
|
||||||
return Ok(ApiResponse<GcsScoreResponse>.Ok(new GcsScoreResponse(
|
return Ok(ApiResponse<GcsScoreResponse>.Ok(new GcsScoreResponse(
|
||||||
score.EyeScore, score.VerbalScore, score.MotorScore,
|
score.EyeScore, score.VerbalScore, score.MotorScore,
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ public class News2Controller : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("current")]
|
[HttpGet("current")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<News2Score>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<News2Score>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> Current(Guid encounterId)
|
public async Task<IActionResult> Current(Guid encounterId)
|
||||||
{
|
{
|
||||||
var score = await _news2.GetCurrentAsync(encounterId);
|
var score = await _news2.GetCurrentAsync(encounterId);
|
||||||
if (score is null)
|
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));
|
return Ok(ApiResponse<News2Score>.Ok(score));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,11 @@ public class SepsisBundlesController : ControllerBase
|
|||||||
/// <param name="encounterId">Encounter id.</param>
|
/// <param name="encounterId">Encounter id.</param>
|
||||||
[HttpGet("api/v1/encounters/{encounterId:guid}/sepsis-bundle/current")]
|
[HttpGet("api/v1/encounters/{encounterId:guid}/sepsis-bundle/current")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<SepsisBundle>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<SepsisBundle>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> GetCurrentByEncounter(Guid encounterId)
|
public async Task<IActionResult> GetCurrentByEncounter(Guid encounterId)
|
||||||
{
|
{
|
||||||
var bundle = await _bundles.GetCurrentByEncounterAsync(encounterId);
|
var bundle = await _bundles.GetCurrentByEncounterAsync(encounterId);
|
||||||
if (bundle is null)
|
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));
|
return Ok(ApiResponse<SepsisBundle>.Ok(bundle));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ public class SofaController : ControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(ApiResponse<SofaScoreResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<SofaScoreResponse>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> Current(Guid encounterId)
|
public async Task<IActionResult> Current(Guid encounterId)
|
||||||
{
|
{
|
||||||
var score = await _sofa.GetCurrentAsync(encounterId);
|
var score = await _sofa.GetCurrentAsync(encounterId);
|
||||||
if (score is null)
|
if (score is null)
|
||||||
return NotFound(ApiResponse<object>.Fail(
|
return Ok(ApiResponse<SofaScoreResponse?>.Ok(null));
|
||||||
404, "No SOFA score computed for this encounter.", "NO_SOFA_SCORE"));
|
|
||||||
|
|
||||||
return Ok(ApiResponse<SofaScoreResponse>.Ok(MapResponse(score)));
|
return Ok(ApiResponse<SofaScoreResponse>.Ok(MapResponse(score)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,62 @@ public static class DataSeeder
|
|||||||
};
|
};
|
||||||
db.Encounters.AddRange(encounter1, encounter2);
|
db.Encounters.AddRange(encounter1, encounter2);
|
||||||
|
|
||||||
// Four alert thresholds
|
var thresholds = BuildDefaultThresholds();
|
||||||
var thresholds = new List<AlertThreshold>
|
db.AlertThresholds.AddRange(thresholds);
|
||||||
|
|
||||||
|
// Observations spanning normal, warning, and critical ranges for encounter1
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var observations = new List<Observation>
|
||||||
|
{
|
||||||
|
// Normal heart rate
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "HEART_RATE",
|
||||||
|
Value = 78, Unit = "bpm", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-30), CreatedAt = now.AddMinutes(-30) },
|
||||||
|
// Warning heart rate
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "HEART_RATE",
|
||||||
|
Value = 104, Unit = "bpm", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-15), CreatedAt = now.AddMinutes(-15) },
|
||||||
|
// Critical potassium (low)
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "POTASSIUM_MEQ_L",
|
||||||
|
Value = 2.3m, Unit = "mEq/L", Source = ObservationSource.Lab, RecordedAt = now.AddMinutes(-10), CreatedAt = now.AddMinutes(-10) },
|
||||||
|
// Normal SpO2
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SPO2",
|
||||||
|
Value = 97, Unit = "%", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-5), CreatedAt = now.AddMinutes(-5) },
|
||||||
|
// Normal temp for encounter2
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "TEMP_C",
|
||||||
|
Value = 37.1m, Unit = "°C", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-20), CreatedAt = now.AddMinutes(-20) },
|
||||||
|
// Blood pressure
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SYSTOLIC_BP",
|
||||||
|
Value = 128, Unit = "mmHg", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-25), CreatedAt = now.AddMinutes(-25) },
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "DIASTOLIC_BP",
|
||||||
|
Value = 82, Unit = "mmHg", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-25), CreatedAt = now.AddMinutes(-25) },
|
||||||
|
// Normal lactate
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "LACTATE_MMOL_L",
|
||||||
|
Value = 1.2m, Unit = "mmol/L", Source = ObservationSource.Lab, RecordedAt = now.AddMinutes(-8), CreatedAt = now.AddMinutes(-8) },
|
||||||
|
// AVPU = Alert (normal consciousness)
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "AVPU",
|
||||||
|
Value = 0, Unit = "score", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-28), CreatedAt = now.AddMinutes(-28) },
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "AVPU",
|
||||||
|
Value = 0, Unit = "score", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-18), CreatedAt = now.AddMinutes(-18) },
|
||||||
|
// Room air (no supplemental oxygen)
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SUPPLEMENTAL_O2",
|
||||||
|
Value = 0, Unit = "flag", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-27), CreatedAt = now.AddMinutes(-27) },
|
||||||
|
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "SUPPLEMENTAL_O2",
|
||||||
|
Value = 1, Unit = "flag", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-17), CreatedAt = now.AddMinutes(-17) },
|
||||||
|
};
|
||||||
|
db.Observations.AddRange(observations);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await CacheThresholdsAsync(redis, 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() {
|
new() {
|
||||||
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", DisplayName = "Heart Rate",
|
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE", DisplayName = "Heart Rate",
|
||||||
@@ -127,7 +181,6 @@ public static class DataSeeder
|
|||||||
CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m,
|
CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
},
|
},
|
||||||
// GCS components — registered for ingestion; thresholds are null (alerting is on computed total in GcsDetector)
|
|
||||||
new AlertThreshold
|
new AlertThreshold
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), ObservationCode = "GCS_EYE",
|
Id = Guid.NewGuid(), ObservationCode = "GCS_EYE",
|
||||||
@@ -192,51 +245,10 @@ public static class DataSeeder
|
|||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
db.AlertThresholds.AddRange(thresholds);
|
|
||||||
|
|
||||||
// Observations spanning normal, warning, and critical ranges for encounter1
|
private static async Task CacheThresholdsAsync(
|
||||||
var now = DateTimeOffset.UtcNow;
|
IConnectionMultiplexer redis, IEnumerable<AlertThreshold> thresholds)
|
||||||
var observations = new List<Observation>
|
|
||||||
{
|
{
|
||||||
// Normal heart rate
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "HEART_RATE",
|
|
||||||
Value = 78, Unit = "bpm", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-30), CreatedAt = now.AddMinutes(-30) },
|
|
||||||
// Warning heart rate
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "HEART_RATE",
|
|
||||||
Value = 104, Unit = "bpm", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-15), CreatedAt = now.AddMinutes(-15) },
|
|
||||||
// Critical potassium (low)
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "POTASSIUM_MEQ_L",
|
|
||||||
Value = 2.3m, Unit = "mEq/L", Source = ObservationSource.Lab, RecordedAt = now.AddMinutes(-10), CreatedAt = now.AddMinutes(-10) },
|
|
||||||
// Normal SpO2
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SPO2",
|
|
||||||
Value = 97, Unit = "%", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-5), CreatedAt = now.AddMinutes(-5) },
|
|
||||||
// Normal temp for encounter2
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "TEMP_C",
|
|
||||||
Value = 37.1m, Unit = "°C", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-20), CreatedAt = now.AddMinutes(-20) },
|
|
||||||
// Blood pressure
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SYSTOLIC_BP",
|
|
||||||
Value = 128, Unit = "mmHg", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-25), CreatedAt = now.AddMinutes(-25) },
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "DIASTOLIC_BP",
|
|
||||||
Value = 82, Unit = "mmHg", Source = ObservationSource.Device, RecordedAt = now.AddMinutes(-25), CreatedAt = now.AddMinutes(-25) },
|
|
||||||
// Normal lactate
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "LACTATE_MMOL_L",
|
|
||||||
Value = 1.2m, Unit = "mmol/L", Source = ObservationSource.Lab, RecordedAt = now.AddMinutes(-8), CreatedAt = now.AddMinutes(-8) },
|
|
||||||
// AVPU = Alert (normal consciousness)
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "AVPU",
|
|
||||||
Value = 0, Unit = "score", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-28), CreatedAt = now.AddMinutes(-28) },
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "AVPU",
|
|
||||||
Value = 0, Unit = "score", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-18), CreatedAt = now.AddMinutes(-18) },
|
|
||||||
// Room air (no supplemental oxygen)
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter1.Id, ObservationCode = "SUPPLEMENTAL_O2",
|
|
||||||
Value = 0, Unit = "flag", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-27), CreatedAt = now.AddMinutes(-27) },
|
|
||||||
new() { Id = Guid.NewGuid(), EncounterId = encounter2.Id, ObservationCode = "SUPPLEMENTAL_O2",
|
|
||||||
Value = 1, Unit = "flag", Source = ObservationSource.Manual, RecordedAt = now.AddMinutes(-17), CreatedAt = now.AddMinutes(-17) },
|
|
||||||
};
|
|
||||||
db.Observations.AddRange(observations);
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Populate Redis cache with the seeded thresholds
|
|
||||||
var cache = redis.GetDatabase();
|
var cache = redis.GetDatabase();
|
||||||
foreach (var t in thresholds)
|
foreach (var t in thresholds)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ public class SofaDetector
|
|||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly IConnectionMultiplexer _redis;
|
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
|
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 = {
|
export const api = {
|
||||||
get: (path) => request(path),
|
get: (path) => request(path),
|
||||||
|
getOptional: (path) => requestOptional(path),
|
||||||
post: (path, body) => request(path, {
|
post: (path, body) => request(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
|
|
||||||
export function fetchCurrentNews2(encounterId) {
|
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) {
|
export function fetchCurrentQsofa(encounterId) {
|
||||||
@@ -9,15 +9,15 @@ export function fetchCurrentQsofa(encounterId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fetchCurrentGcs(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) {
|
export function fetchCurrentSofa(encounterId) {
|
||||||
return api.get(`/api/v1/encounters/${encounterId}/sofa`)
|
return api.getOptional(`/api/v1/encounters/${encounterId}/sofa`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchSepsisBundle(encounterId) {
|
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) {
|
export function fetchOrders(encounterId) {
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ export const useScoringStore = defineStore('scoring', () => {
|
|||||||
const id = encounterId.value
|
const id = encounterId.value
|
||||||
try {
|
try {
|
||||||
const [g, s, n, q] = await Promise.all([
|
const [g, s, n, q] = await Promise.all([
|
||||||
clinicalApi.fetchCurrentGcs(id).catch(() => null),
|
clinicalApi.fetchCurrentGcs(id),
|
||||||
clinicalApi.fetchCurrentSofa(id).catch(() => null),
|
clinicalApi.fetchCurrentSofa(id),
|
||||||
clinicalApi.fetchCurrentNews2(id).catch(() => null),
|
clinicalApi.fetchCurrentNews2(id),
|
||||||
clinicalApi.fetchCurrentQsofa(id).catch(() => null),
|
clinicalApi.fetchCurrentQsofa(id),
|
||||||
])
|
])
|
||||||
gcs.value = g
|
gcs.value = g
|
||||||
sofa.value = s
|
sofa.value = s
|
||||||
@@ -94,6 +94,7 @@ export const useScoringStore = defineStore('scoring', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startPolling(id) {
|
function startPolling(id) {
|
||||||
|
if (encounterId.value === id && timer) return
|
||||||
stopPolling()
|
stopPolling()
|
||||||
encounterId.value = id
|
encounterId.value = id
|
||||||
refresh()
|
refresh()
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ async function loadAll() {
|
|||||||
encountersApi.fetchObservations(id),
|
encountersApi.fetchObservations(id),
|
||||||
clinicalApi.fetchNews2History(id).catch(() => []),
|
clinicalApi.fetchNews2History(id).catch(() => []),
|
||||||
clinicalApi.fetchMedications(id).catch(() => []),
|
clinicalApi.fetchMedications(id).catch(() => []),
|
||||||
clinicalApi.fetchSepsisBundle(id).catch(() => null),
|
clinicalApi.fetchSepsisBundle(id),
|
||||||
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
|
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
|
||||||
])
|
])
|
||||||
await alertStore.loadAlerts(id)
|
await alertStore.loadAlerts(id)
|
||||||
|
|||||||
Reference in New Issue
Block a user