Files
vigilcare-clinical/VigilCare.Simulation.Core/Scenarios/ScenarioValidator.cs
T
voltsrage 24f45851e9
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 8m32s
feature: In-App Simulation Runner (Backend)
2026-08-06 01:52:53 +08:00

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));
}