139 lines
5.7 KiB
C#
139 lines
5.7 KiB
C#
public static class ScenarioValidator
|
|
{
|
|
private static readonly HashSet<string> ValidCodes = new()
|
|
{
|
|
"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"
|
|
};
|
|
|
|
private static readonly HashSet<string> ValidSources = new()
|
|
{
|
|
"Manual", "Device", "Lab"
|
|
};
|
|
|
|
private static readonly HashSet<string> ValidDepartments = new()
|
|
{
|
|
"Icu", "GeneralMedicine", "Emergency", "Cardiology", "Surgery", "Pediatrics"
|
|
};
|
|
|
|
private static readonly HashSet<string> ValidEncounterTypes = new()
|
|
{
|
|
"Inpatient", "Outpatient", "Emergency"
|
|
};
|
|
|
|
private static readonly HashSet<string> ValidEventTypes = new()
|
|
{
|
|
"observation", "order", "medication", "order_result"
|
|
};
|
|
|
|
private static readonly HashSet<string> ValidOrderTypes = new()
|
|
{
|
|
"Lab", "Imaging", "Medication", "Procedure"
|
|
};
|
|
|
|
private static readonly string[] SepsisBundleOrderPrefixes =
|
|
[
|
|
"SEP-1: Blood cultures",
|
|
"SEP-1: Serum lactate",
|
|
"SEP-1: Broad-spectrum antibiotics",
|
|
"SEP-1: IV fluid bolus"
|
|
];
|
|
|
|
public static List<string> Validate(ScenarioFile scenario)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
if (string.IsNullOrWhiteSpace(scenario.Scenario.Id))
|
|
errors.Add("scenario.id is required");
|
|
if (string.IsNullOrWhiteSpace(scenario.Scenario.Name))
|
|
errors.Add("scenario.name is required");
|
|
if (string.IsNullOrWhiteSpace(scenario.Encounter.AttendingPhysician))
|
|
errors.Add("encounter.attendingPhysician is required");
|
|
if (!ValidDepartments.Contains(scenario.Encounter.Department))
|
|
errors.Add($"encounter.department '{scenario.Encounter.Department}' is not valid");
|
|
if (!ValidEncounterTypes.Contains(scenario.Encounter.EncounterType))
|
|
errors.Add($"encounter.encounterType '{scenario.Encounter.EncounterType}' is not valid");
|
|
if (scenario.Events.Count == 0)
|
|
errors.Add("events array is empty");
|
|
|
|
foreach (var group in scenario.Events
|
|
.Where(e => e.Type == "observation")
|
|
.GroupBy(e => e.OffsetMinutes))
|
|
{
|
|
if (group.Count() > 10)
|
|
errors.Add(
|
|
$"offsetMinutes {group.Key}: {group.Count()} observations exceeds API batch limit of 10");
|
|
}
|
|
|
|
var placedOrders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
double lastOffset = -1;
|
|
for (int i = 0; i < scenario.Events.Count; i++)
|
|
{
|
|
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}'");
|
|
|
|
if (evt.Type == "observation")
|
|
{
|
|
var code = evt.Data.TryGetProperty("code", out var codeProp) ? codeProp.GetString() : null;
|
|
if (code is null || !ValidCodes.Contains(code))
|
|
errors.Add($"events[{i}]: unknown observation code '{code}'");
|
|
|
|
var source = evt.Data.TryGetProperty("source", out var sourceProp) ? sourceProp.GetString() : null;
|
|
if (source is not null && !ValidSources.Contains(source))
|
|
errors.Add($"events[{i}]: unknown observation source '{source}'");
|
|
}
|
|
|
|
if (evt.Type == "order")
|
|
{
|
|
var description = evt.Data.TryGetProperty("description", out var descProp)
|
|
? descProp.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(description))
|
|
errors.Add($"events[{i}]: order.description is required");
|
|
|
|
var orderType = evt.Data.TryGetProperty("orderType", out var typeProp)
|
|
? typeProp.GetString() : null;
|
|
if (orderType is null || !ValidOrderTypes.Contains(orderType))
|
|
errors.Add($"events[{i}]: order.orderType '{orderType}' is not valid");
|
|
|
|
if (!string.IsNullOrWhiteSpace(description))
|
|
placedOrders.Add(description);
|
|
}
|
|
|
|
if (evt.Type == "order_result")
|
|
{
|
|
var orderDescription = evt.Data.TryGetProperty("orderDescription", out var descProp)
|
|
? descProp.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(orderDescription))
|
|
{
|
|
errors.Add($"events[{i}]: order_result.orderDescription is required");
|
|
continue;
|
|
}
|
|
|
|
if (!placedOrders.Contains(orderDescription)
|
|
&& !IsSepsisBundleOrder(orderDescription)
|
|
&& !placedOrders.Any(p => orderDescription.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
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)");
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
private static bool IsSepsisBundleOrder(string description) =>
|
|
SepsisBundleOrderPrefixes.Any(p =>
|
|
description.Equals(p, StringComparison.OrdinalIgnoreCase)
|
|
|| description.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
|
}
|