159 lines
6.5 KiB
C#
159 lines
6.5 KiB
C#
namespace VigilCare.Simulation;
|
|
|
|
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",
|
|
// 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()
|
|
{
|
|
"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", "alert_ack"
|
|
};
|
|
|
|
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);
|
|
var indexedEvents = scenario.Events
|
|
.Select((evt, i) => (evt, i))
|
|
.OrderBy(x => x.evt.OffsetMinutes)
|
|
.ThenBy(x => x.i);
|
|
|
|
foreach (var (evt, i) in indexedEvents)
|
|
{
|
|
if (evt.OffsetMinutes < 0)
|
|
errors.Add($"events[{i}]: offsetMinutes cannot be negative");
|
|
|
|
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 SOFA_SEPSIS or QSOFA_SCREEN fires)");
|
|
}
|
|
}
|
|
|
|
if (evt.Type == "alert_ack")
|
|
{
|
|
var alertType = evt.Data.TryGetProperty("alertType", out var alertTypeProp)
|
|
? alertTypeProp.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(alertType))
|
|
errors.Add($"events[{i}]: alert_ack.alertType is required");
|
|
|
|
var clinicianId = evt.Data.TryGetProperty("clinicianId", out var clinicianProp)
|
|
? clinicianProp.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(clinicianId))
|
|
errors.Add($"events[{i}]: alert_ack.clinicianId is required");
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
private static bool IsSepsisBundleOrder(string description) =>
|
|
SepsisBundleOrderPrefixes.Any(p =>
|
|
description.Equals(p, StringComparison.OrdinalIgnoreCase)
|
|
|| description.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
|
}
|