public static class ScenarioValidator { private static readonly HashSet 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 ValidSources = new() { "Manual", "Device", "Lab" }; private static readonly HashSet ValidDepartments = new() { "Icu", "GeneralMedicine", "Emergency", "Cardiology", "Surgery", "Pediatrics" }; private static readonly HashSet ValidEncounterTypes = new() { "Inpatient", "Outpatient", "Emergency" }; private static readonly HashSet ValidEventTypes = new() { "observation", "order", "medication", "order_result", "alert_ack" }; private static readonly HashSet 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 Validate(ScenarioFile scenario) { var errors = new List(); 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(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)); }