feature: In-App Simulation Runner (Backend)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public static class DepartmentMapper
|
||||
{
|
||||
private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Icu"] = "ICU",
|
||||
["GeneralMedicine"] = "GENERAL_MEDICINE",
|
||||
["Emergency"] = "EMERGENCY",
|
||||
["Cardiology"] = "CARDIOLOGY",
|
||||
["Surgery"] = "SURGERY",
|
||||
["Pediatrics"] = "PEDIATRICS",
|
||||
};
|
||||
|
||||
public static string ToApiDepartment(string scenarioDepartment)
|
||||
{
|
||||
if (ScenarioToApi.TryGetValue(scenarioDepartment, out var apiValue))
|
||||
return apiValue;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown scenario department '{scenarioDepartment}'. Expected one of: {string.Join(", ", ScenarioToApi.Keys)}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public static class ExpectedOutcomeValidator
|
||||
{
|
||||
private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8);
|
||||
|
||||
public static async Task<List<string>> ValidateAsync(
|
||||
VigilCareApiClient client,
|
||||
Guid encounterId,
|
||||
ScenarioFile scenario,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (scenario.ExpectedOutcomes is not { Count: > 0 })
|
||||
return [];
|
||||
|
||||
await Task.Delay(AsyncSettleDelay, ct);
|
||||
|
||||
var failures = new List<string>();
|
||||
var alerts = await client.GetAlertsAsync(encounterId);
|
||||
|
||||
foreach (var outcome in scenario.ExpectedOutcomes)
|
||||
{
|
||||
switch (outcome.Type)
|
||||
{
|
||||
case "alert":
|
||||
failures.AddRange(ValidateAlertOutcome(outcome, alerts, scenario.Scenario.Id));
|
||||
break;
|
||||
case "score":
|
||||
failures.AddRange(await ValidateScoreOutcomeAsync(client, encounterId, outcome, scenario.Scenario.Id, ct));
|
||||
break;
|
||||
case "bundle":
|
||||
failures.AddRange(await ValidateBundleOutcomeAsync(client, encounterId, outcome, scenario.Scenario.Id, ct));
|
||||
break;
|
||||
default:
|
||||
failures.Add($"[{scenario.Scenario.Id}] Unknown expected outcome type '{outcome.Type}'");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ValidateAlertOutcome(
|
||||
ExpectedOutcome outcome, List<AlertResponse> alerts, string scenarioId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outcome.AlertType))
|
||||
{
|
||||
yield return $"[{scenarioId}] Alert outcome at T+{outcome.AfterOffsetMinutes}m missing alertType";
|
||||
yield break;
|
||||
}
|
||||
|
||||
var matching = alerts
|
||||
.Where(a => string.Equals(a.AlertType, outcome.AlertType, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (matching.Count == 0)
|
||||
{
|
||||
yield return
|
||||
$"[{scenarioId}] Expected alert {outcome.AlertType} at T+{outcome.AfterOffsetMinutes}m — not found " +
|
||||
$"(description: {outcome.Description ?? "n/a"})";
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.NarrativeContains))
|
||||
{
|
||||
var withNarrative = matching.FirstOrDefault(a =>
|
||||
a.Explanation?.NarrativeSummary?.Contains(
|
||||
outcome.NarrativeContains, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (withNarrative is null)
|
||||
{
|
||||
var summaries = matching
|
||||
.Select(a => a.Explanation?.NarrativeSummary ?? a.Details)
|
||||
.Take(2);
|
||||
yield return
|
||||
$"[{scenarioId}] Alert {outcome.AlertType} at T+{outcome.AfterOffsetMinutes}m — " +
|
||||
$"narrative does not contain '{outcome.NarrativeContains}' " +
|
||||
$"(got: {string.Join(" | ", summaries)})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<string>> ValidateScoreOutcomeAsync(
|
||||
VigilCareApiClient client,
|
||||
Guid encounterId,
|
||||
ExpectedOutcome outcome,
|
||||
string scenarioId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outcome.ScoreType))
|
||||
return [$"[{scenarioId}] Score outcome at T+{outcome.AfterOffsetMinutes}m missing scoreType"];
|
||||
|
||||
if (!outcome.ExpectedMinimum.HasValue)
|
||||
return [$"[{scenarioId}] Score outcome at T+{outcome.AfterOffsetMinutes}m missing expectedMinimum"];
|
||||
|
||||
var actual = await GetScoreAsync(client, encounterId, outcome.ScoreType, ct);
|
||||
if (actual is null)
|
||||
{
|
||||
return
|
||||
[
|
||||
$"[{scenarioId}] Expected {outcome.ScoreType} ≥ {outcome.ExpectedMinimum} at T+{outcome.AfterOffsetMinutes}m — score not available"
|
||||
];
|
||||
}
|
||||
|
||||
if (actual.Value < outcome.ExpectedMinimum.Value)
|
||||
{
|
||||
return
|
||||
[
|
||||
$"[{scenarioId}] Expected {outcome.ScoreType} ≥ {outcome.ExpectedMinimum} at T+{outcome.AfterOffsetMinutes}m — got {actual.Value} " +
|
||||
$"(description: {outcome.Description ?? "n/a"})"
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<string>> ValidateBundleOutcomeAsync(
|
||||
VigilCareApiClient client,
|
||||
Guid encounterId,
|
||||
ExpectedOutcome outcome,
|
||||
string scenarioId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var bundle = await client.GetSepsisBundleAsync(encounterId);
|
||||
if (bundle is null)
|
||||
{
|
||||
return
|
||||
[
|
||||
$"[{scenarioId}] Expected sepsis bundle at T+{outcome.AfterOffsetMinutes}m — bundle not found " +
|
||||
$"(description: {outcome.Description ?? "n/a"})"
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static async Task<int?> GetScoreAsync(
|
||||
VigilCareApiClient client,
|
||||
Guid encounterId,
|
||||
string scoreType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return scoreType.ToUpperInvariant() switch
|
||||
{
|
||||
"NEWS2" => (await client.GetCurrentNews2Async(encounterId))?.TotalScore,
|
||||
"GCS" => (await client.GetCurrentGcsAsync(encounterId))?.TotalScore,
|
||||
"SOFA" => (await client.GetCurrentSofaAsync(encounterId))?.TotalScore,
|
||||
"QSOFA" => (await client.GetCurrentQsofaAsync(encounterId))?.ActiveCriteria,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record ScenarioFile(
|
||||
ScenarioMeta Scenario,
|
||||
ScenarioPatient Patient,
|
||||
ScenarioEncounter Encounter,
|
||||
List<ScenarioEvent> Events,
|
||||
List<ExpectedOutcome>? ExpectedOutcomes);
|
||||
|
||||
public record ScenarioMeta(
|
||||
string Id, string Name, string? Description,
|
||||
int? DurationMinutes, List<string>? Tags);
|
||||
|
||||
public record ScenarioPatient(
|
||||
string FirstName, string LastName, string DateOfBirth,
|
||||
string Gender);
|
||||
|
||||
public record ScenarioEncounter(
|
||||
string Department, string EncounterType, string AttendingPhysician,
|
||||
string? RoomBed, string? AdmissionReason);
|
||||
|
||||
public record ScenarioEvent(
|
||||
double OffsetMinutes, string Type, JsonElement Data, string? Note);
|
||||
|
||||
public record ExpectedOutcome(
|
||||
double AfterOffsetMinutes, string Type,
|
||||
string? AlertType, string? ScoreType,
|
||||
double? ExpectedMinimum, string? Description,
|
||||
string? NarrativeContains = null);
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public static class ScenarioLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static ScenarioFile Load(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"Scenario file not found: {path}");
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var scenario = JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize: {path}");
|
||||
|
||||
return scenario with
|
||||
{
|
||||
Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates <c>*.json</c> in <paramref name="directory"/>, skips files that
|
||||
/// fail to deserialize, and returns pairs sorted by <see cref="ScenarioMeta.Id"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<(ScenarioFile Scenario, string Path)> LoadAll(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<(ScenarioFile, string)>();
|
||||
|
||||
var results = new List<(ScenarioFile Scenario, string Path)>();
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(directory, "*.json")
|
||||
.Where(p => !string.Equals(
|
||||
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
results.Add((Load(path), path));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip files that fail to deserialize — catalogue must stay resilient.
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(r => r.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user