feature: In-App Simulation Runner (Backend)
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 8m32s

This commit is contained in:
voltsrage
2026-08-06 01:52:53 +08:00
parent 943d41339c
commit 24f45851e9
83 changed files with 3974 additions and 120 deletions
@@ -0,0 +1,34 @@
namespace VigilCare.Simulation;
public class AlertExplanation
{
public List<ScoreContributor> ScoreContributors { get; set; } = new();
public TrendContext? Trend { get; set; }
public MedicationContext? MedicationContext { get; set; }
public string NarrativeSummary { get; set; } = string.Empty;
}
public class ScoreContributor
{
public string Parameter { get; set; } = string.Empty;
public int Points { get; set; }
public string? RawValue { get; set; }
public string? NormalRange { get; set; }
}
public class TrendContext
{
public string Parameter { get; set; } = string.Empty;
public double PercentChange { get; set; }
public TimeSpan Duration { get; set; }
public string Direction { get; set; } = string.Empty;
}
public class MedicationContext
{
public string DrugName { get; set; } = string.Empty;
public string Dose { get; set; } = string.Empty;
public string Route { get; set; } = string.Empty;
public DateTimeOffset AdministeredAt { get; set; }
public string? RelevanceNote { get; set; }
}
@@ -0,0 +1,21 @@
namespace VigilCare.Simulation;
public record AlertResponse(
Guid Id,
Guid EncounterId,
Guid PatientId,
string AlertType,
string Severity,
string Details,
string Status,
DateTimeOffset TriggeredAt,
DateTimeOffset? AcknowledgedAt = null,
string? AcknowledgedBy = null,
DateTimeOffset? ResolvedAt = null,
AlertExplanation? Explanation = null)
{
public string DisplaySummary =>
string.IsNullOrWhiteSpace(Explanation?.NarrativeSummary)
? Details
: Explanation!.NarrativeSummary;
}
@@ -0,0 +1,5 @@
namespace VigilCare.Simulation;
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error);
public record ApiError(string Message, string Code);
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record BatchIngestRequest(List<IngestObservationRequest> Observations);
@@ -0,0 +1,5 @@
namespace VigilCare.Simulation;
public record CreateMedicationAdministrationRequest(
string DrugName, decimal Dose, string DoseUnit, string Route,
DateTimeOffset? AdministeredAt, string AdministeredBy);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record CreateOrderRequest(string OrderType, string Description, string OrderedBy);
@@ -0,0 +1,6 @@
namespace VigilCare.Simulation;
public record EncounterResponse(
Guid Id, Guid PatientId, string EncounterType, string Status,
string Department, string AttendingPhysician, string? RoomBed,
string? AdmissionReason, DateTimeOffset AdmittedAt);
@@ -0,0 +1,5 @@
namespace VigilCare.Simulation;
public record GcsResponse(
int EyeScore, int VerbalScore, int MotorScore,
int TotalScore, string Classification, DateTimeOffset CalculatedAt);
@@ -0,0 +1,6 @@
namespace VigilCare.Simulation;
public record IngestObservationRequest(
string ObservationCode, decimal Value, string Unit,
string Source, DateTimeOffset RecordedAt, string? IdempotencyKey);
@@ -0,0 +1,11 @@
namespace VigilCare.Simulation;
public record SimLoginRequest(string Username, string Password);
public record SimLoginResponse(
string AccessToken,
DateTimeOffset ExpiresAt,
Guid UserId,
string Username,
string DisplayName,
string Role);
@@ -0,0 +1,7 @@
namespace VigilCare.Simulation;
public record News2Response(
Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree,
int RespRateScore, int Spo2Score, int SystolicBpScore,
int HeartRateScore, int ConsciousnessScore, int TemperatureScore,
int SupplementalO2Score, DateTimeOffset CalculatedAt);
@@ -0,0 +1,6 @@
namespace VigilCare.Simulation;
public record OpenEncounterRequest(
string EncounterType, string Department, string AttendingPhysician,
string? RoomBed = null, string? AdmissionReason = null);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record OrderResponse(Guid Id, string Description, string Status);
@@ -0,0 +1,5 @@
namespace VigilCare.Simulation;
public record PatientResponse(
Guid Id, string Mrn, string FirstName, string LastName,
DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record QsofaResponse(int ActiveCriteria);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record RecordOrderResultRequest(string? ResultSummary);
@@ -0,0 +1,4 @@
namespace VigilCare.Simulation;
public record RegisterPatientRequest(
string FirstName, string LastName, DateOnly DateOfBirth, string Gender);
@@ -0,0 +1,13 @@
namespace VigilCare.Simulation;
public record SepsisBundleElementResponse(string Status);
public record SepsisBundleResponse(
Guid Id, string TriggeringAlertType, string ComplianceStatus,
DateTimeOffset RecognizedAt, DateTimeOffset DeadlineAt,
DateTimeOffset? CompletedAt,
List<SepsisBundleElementResponse>? Elements = null)
{
public int ElementsCompleted =>
Elements?.Count(e => e.Status == "Completed") ?? 0;
}
@@ -0,0 +1,9 @@
namespace VigilCare.Simulation;
public record SofaResponse(
int TotalScore,
int RespiratoryScore, int CoagulationScore, int LiverScore,
int CardiovascularScore, int CnsScore, int RenalScore,
bool IsBaseline, int? DeltaFromBaseline,
SofaStalenessResponse? Staleness,
DateTimeOffset CalculatedAt);
@@ -0,0 +1,6 @@
namespace VigilCare.Simulation;
public record SofaStalenessResponse(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -0,0 +1,251 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace VigilCare.Simulation;
public class VigilCareApiClient
{
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _http;
public VigilCareApiClient(HttpClient http)
{
_http = http;
}
public async Task LoginAsync(string username, string password)
{
var response = await _http.PostAsJsonAsync("/api/v1/auth/login",
new SimLoginRequest(username, password));
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Login failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SimLoginResponse>>();
var token = envelope!.Data!.AccessToken;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
public void SetBearerToken(string token)
{
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
public async Task<PatientResponse> RegisterPatientAsync(RegisterPatientRequest req)
{
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Register patient failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
return envelope!.Data!;
}
public async Task<EncounterResponse> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
{
var response = await _http.PostAsJsonAsync($"/api/v1/patients/{patientId}/encounters", req);
response.EnsureSuccessStatusCode();
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<EncounterResponse>>();
return envelope!.Data!;
}
public async Task SendObservationBatchAsync(
Guid encounterId, List<IngestObservationRequest> observations,
ReplayTarget target = ReplayTarget.Central)
{
if (target == ReplayTarget.Gateway)
{
foreach (var obs in observations)
{
var response = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/observations",
new
{
observationCode = obs.ObservationCode,
value = obs.Value,
unit = obs.Unit,
source = obs.Source,
recordedAt = obs.RecordedAt,
idempotencyKey = obs.IdempotencyKey ?? Guid.NewGuid().ToString()
});
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Observation ingest failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
}
return;
}
foreach (var chunk in observations.Chunk(10))
{
var batch = new BatchIngestRequest(chunk.ToList());
var response = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/observations", batch);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Observation batch failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
}
}
public async Task<bool> TrySendMedicationAsync(
Guid encounterId, CreateMedicationAdministrationRequest req)
{
var response = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/medications", req);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return true;
}
public async Task<bool> TryCreateOrderAsync(
Guid encounterId, CreateOrderRequest req)
{
var response = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/orders", req);
return response.IsSuccessStatusCode;
}
public async Task<bool> TryResultOrderAsync(
Guid encounterId, string orderDescription, string? resultSummary)
{
var ordersResponse = await _http.GetAsync(
$"/api/v1/encounters/{encounterId}/orders?status=PENDING");
if (!ordersResponse.IsSuccessStatusCode)
return false;
var envelope = await ordersResponse.Content
.ReadFromJsonAsync<ApiResponse<PagedResponse<OrderResponse>>>();
var order = FindPendingOrder(envelope?.Data?.Items ?? [], orderDescription);
if (order is null)
return false;
var resultResponse = await _http.PatchAsJsonAsync(
$"/api/v1/orders/{order.Id}/result",
new RecordOrderResultRequest(resultSummary));
return resultResponse.IsSuccessStatusCode;
}
private static OrderResponse? FindPendingOrder(
List<OrderResponse> items, string description)
{
return items.FirstOrDefault(o => o.Description == description)
?? items.FirstOrDefault(o =>
description.StartsWith(o.Description, StringComparison.OrdinalIgnoreCase))
?? items.FirstOrDefault(o =>
o.Description.StartsWith(description, StringComparison.OrdinalIgnoreCase));
}
// --- Polling endpoints ---
public async Task<List<AlertResponse>> GetAlertsAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/alerts");
if (!response.IsSuccessStatusCode) return new();
var envelope = await response.Content
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>(ApiJsonOptions);
return envelope?.Data?.Items?.ToList() ?? new();
}
public async Task<News2Response?> GetCurrentNews2Async(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/news2/current");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<News2Response>>();
return envelope?.Data;
}
public async Task<SepsisBundleResponse?> GetSepsisBundleAsync(Guid encounterId)
{
var response = await _http.GetAsync(
$"/api/v1/encounters/{encounterId}/sepsis-bundle/current");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content
.ReadFromJsonAsync<ApiResponse<SepsisBundleResponse>>();
return envelope?.Data;
}
public async Task<GcsResponse?> GetCurrentGcsAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/gcs");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<GcsResponse>>();
return envelope?.Data;
}
public async Task<SofaResponse?> GetCurrentSofaAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/sofa");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SofaResponse>>();
return envelope?.Data;
}
public async Task<QsofaResponse?> GetCurrentQsofaAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/qsofa/current");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<QsofaResponse>>();
return envelope?.Data;
}
public async Task<bool> TryAcknowledgeAlertAsync(
Guid encounterId, string alertType, string clinicianId, string? note,
TimeSpan? waitForAlert = null, CancellationToken ct = default)
{
const int pollIntervalMs = 500;
var deadline = waitForAlert.HasValue
? DateTimeOffset.UtcNow.Add(waitForAlert.Value)
: (DateTimeOffset?)null;
while (true)
{
ct.ThrowIfCancellationRequested();
var alerts = await GetAlertsAsync(encounterId);
var alert = alerts.FirstOrDefault(a =>
IsMatchingAlertType(a.AlertType, alertType)
&& IsOpenAlertStatus(a.Status));
if (alert is not null)
{
var response = await _http.PostAsJsonAsync(
$"/api/v1/alerts/{alert.Id}/acknowledge",
new { note, clinicianId }, ct);
return response.IsSuccessStatusCode;
}
if (!deadline.HasValue || DateTimeOffset.UtcNow >= deadline.Value)
return false;
await Task.Delay(pollIntervalMs, ct);
}
}
private static bool IsOpenAlertStatus(string status) =>
string.Equals(status, "OPEN", StringComparison.OrdinalIgnoreCase);
private static bool IsMatchingAlertType(string actual, string expected)
{
if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
return true;
static string Normalize(string s) =>
s.Replace("_", "", StringComparison.Ordinal).ToUpperInvariant();
return Normalize(actual) == Normalize(expected);
}
}
@@ -0,0 +1,10 @@
namespace VigilCare.Simulation;
/// <summary>
/// Optional post-cluster polling hook. Console hosts display scores/alerts;
/// the API host passes null — polling is a display concern.
/// </summary>
public interface IApiPoller
{
Task PollAndDisplayAsync(Guid encounterId, string simTime);
}
@@ -0,0 +1,31 @@
namespace VigilCare.Simulation;
public interface IReplayObserver
{
void Header(string name, string? description);
void Info(string message);
void Event(string simTime, string description);
void Waiting(double deltaMinutes, int delayMs);
void Warn(string message);
void Error(string message);
void DryRun(string message);
void Completed(ReplayResult result);
/// <summary>Fired after each cluster so hosts can report progress.</summary>
void Progress(double offsetMinutes, int clusterIndex, int clusterCount);
}
public sealed class NullReplayObserver : IReplayObserver
{
public static readonly NullReplayObserver Instance = new();
public void Header(string name, string? description) { }
public void Info(string message) { }
public void Event(string simTime, string description) { }
public void Waiting(double deltaMinutes, int delayMs) { }
public void Warn(string message) { }
public void Error(string message) { }
public void DryRun(string message) { }
public void Completed(ReplayResult result) { }
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) { }
}
@@ -0,0 +1,318 @@
namespace VigilCare.Simulation;
public class ReplayEngine
{
private readonly VigilCareApiClient _client;
private readonly IApiPoller? _poller;
private readonly IReplayObserver _observer;
private readonly Func<Guid, CancellationToken, Task>? _onPatientRegistered;
private DateTimeOffset _scenarioStartTime;
public ReplayEngine(
VigilCareApiClient client,
IApiPoller? poller,
IReplayObserver? observer = null,
Func<Guid, CancellationToken, Task>? onPatientRegistered = null)
{
_client = client;
_poller = poller;
_observer = observer ?? NullReplayObserver.Instance;
_onPatientRegistered = onPatientRegistered;
}
public async Task<ReplayResult> RunAsync(
ScenarioFile scenario, ReplayOptions options, CancellationToken ct = default)
{
var result = new ReplayResult(scenario.Scenario.Id);
var startTime = DateTimeOffset.UtcNow;
_scenarioStartTime = startTime;
// --- Phase 1: Setup ---
_observer.Header(scenario.Scenario.Name, scenario.Scenario.Description);
if (options.DryRun)
{
if (options.ExistingEncounterId.HasValue)
{
result.EncounterId = options.ExistingEncounterId.Value;
if (options.Target == ReplayTarget.Gateway)
_observer.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
else
_observer.DryRun($"Would use existing encounter {result.EncounterId}");
}
else
{
_observer.DryRun("Would register patient: " +
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
_observer.DryRun("Would open encounter: " +
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
}
}
else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue)
{
result.EncounterId = options.ExistingEncounterId.Value;
_observer.Info($"Gateway mode — using existing encounter {result.EncounterId}");
}
else if (options.ExistingEncounterId.HasValue)
{
result.EncounterId = options.ExistingEncounterId.Value;
_observer.Info($"Using existing encounter {result.EncounterId}");
}
else
{
var patient = await _client.RegisterPatientAsync(new RegisterPatientRequest(
scenario.Patient.FirstName,
scenario.Patient.LastName,
DateOnly.Parse(scenario.Patient.DateOfBirth),
scenario.Patient.Gender));
if (_onPatientRegistered is not null)
await _onPatientRegistered(patient.Id, ct);
var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
scenario.Encounter.EncounterType,
DepartmentMapper.ToApiDepartment(scenario.Encounter.Department),
scenario.Encounter.AttendingPhysician,
scenario.Encounter.RoomBed,
scenario.Encounter.AdmissionReason));
_observer.Info($"Patient registered: {patient.Id} ({patient.Mrn})");
_observer.Info($"Encounter opened: {encounter.Id} ({encounter.Status})");
result.PatientId = patient.Id;
result.EncounterId = encounter.Id;
}
// --- Phase 2: Replay events ---
double lastOffset = 0;
var clusters = scenario.Events
.GroupBy(e => e.OffsetMinutes)
.OrderBy(g => g.Key)
.ToList();
for (var clusterIndex = 0; clusterIndex < clusters.Count; clusterIndex++)
{
var cluster = clusters[clusterIndex];
ct.ThrowIfCancellationRequested();
var deltaMinutes = cluster.Key - lastOffset;
if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun)
{
var delayMs = (int)(deltaMinutes * 60_000 / options.Speed);
_observer.Waiting(deltaMinutes, delayMs);
await Task.Delay(delayMs, ct);
}
var simTimestamp = FormatSimTime(cluster.Key);
var observationEvents = cluster.Where(e => e.Type == "observation").ToList();
var otherEvents = cluster.Where(e => e.Type != "observation").ToList();
if (observationEvents.Count > 0)
await ReplayObservationCluster(observationEvents, simTimestamp, options, result);
foreach (var evt in otherEvents)
{
switch (evt.Type)
{
case "order":
await ReplayOrder(
evt, simTimestamp, options, result, scenario.Encounter.AttendingPhysician);
break;
case "medication":
await ReplayMedication(evt, simTimestamp, options, result);
break;
case "order_result":
await ReplayOrderResult(evt, simTimestamp, options, result);
break;
case "alert_ack":
await ReplayAlertAck(evt, simTimestamp, options, result, ct);
break;
}
}
lastOffset = cluster.Key;
_observer.Progress(cluster.Key, clusterIndex, clusters.Count);
if (options.Poll && !options.DryRun && _poller is not null)
{
await Task.Delay(options.PollIntervalSeconds * 1000, ct);
await _poller.PollAndDisplayAsync(result.EncounterId, simTimestamp);
}
}
// --- Phase 3: Summary ---
result.Duration = DateTimeOffset.UtcNow - startTime;
if (!options.DryRun
&& options.Target == ReplayTarget.Central
&& scenario.ExpectedOutcomes is { Count: > 0 })
{
var failures = await ExpectedOutcomeValidator.ValidateAsync(
_client, result.EncounterId, scenario, ct);
result.HadExpectedOutcomes = true;
result.OutcomeFailures.AddRange(failures);
foreach (var failure in failures)
_observer.Error(failure);
}
_observer.Completed(result);
return result;
}
private async Task ReplayObservationCluster(
List<ScenarioEvent> cluster, string simTime, ReplayOptions options,
ReplayResult result)
{
var observations = new List<IngestObservationRequest>();
var offsetMinutes = cluster[0].OffsetMinutes;
var recordedAt = options.Target == ReplayTarget.Gateway
? DateTimeOffset.UtcNow
: _scenarioStartTime.AddMinutes(offsetMinutes);
foreach (var evt in cluster)
{
var code = evt.Data.GetProperty("code").GetString()!;
var value = evt.Data.GetProperty("value").GetDecimal();
var unit = evt.Data.GetProperty("unit").GetString()!;
var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual";
_observer.Event(simTime, $"{code} {value} {unit}");
result.ObservationsSent++;
if (!options.DryRun)
{
observations.Add(new IngestObservationRequest(
code, value, unit, ToApiSource(source), recordedAt, null));
}
}
if (!options.DryRun && observations.Count > 0)
await _client.SendObservationBatchAsync(result.EncounterId, observations, options.Target);
}
private async Task ReplayMedication(
ScenarioEvent evt, string simTime, ReplayOptions options, ReplayResult result)
{
var drugName = evt.Data.GetProperty("drugName").GetString()!;
var dose = evt.Data.GetProperty("dose").GetDecimal();
var doseUnit = evt.Data.GetProperty("doseUnit").GetString()!;
var route = evt.Data.GetProperty("route").GetString()!;
var administeredBy = evt.Data.GetProperty("administeredBy").GetString()!;
if (options.DryRun)
{
_observer.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}");
return;
}
var sent = await _client.TrySendMedicationAsync(result.EncounterId,
new CreateMedicationAdministrationRequest(drugName, dose, doseUnit, route, null, administeredBy));
if (sent)
{
_observer.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent");
result.MedicationsSent++;
}
else
{
_observer.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)");
result.MedicationsSkipped++;
}
}
private async Task ReplayOrder(
ScenarioEvent evt, string simTime, ReplayOptions options, ReplayResult result,
string defaultOrderedBy)
{
var description = evt.Data.GetProperty("description").GetString()!;
var orderType = evt.Data.GetProperty("orderType").GetString()!;
var orderedBy = evt.Data.TryGetProperty("orderedBy", out var ob)
? ob.GetString()! : defaultOrderedBy;
if (options.DryRun)
{
_observer.DryRun($"[{simTime}] ORDER {orderType}: {description}");
return;
}
var placed = await _client.TryCreateOrderAsync(result.EncounterId,
new CreateOrderRequest(orderType, description, orderedBy));
if (placed)
{
_observer.Event(simTime, $"ORDER {description} placed");
result.OrdersPlaced++;
}
else
{
_observer.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'");
}
}
private async Task ReplayOrderResult(
ScenarioEvent evt, string simTime, ReplayOptions options, ReplayResult result)
{
var orderDesc = evt.Data.GetProperty("orderDescription").GetString()!;
var resultSummary = evt.Data.TryGetProperty("resultSummary", out var rs)
? rs.GetString() : null;
if (options.DryRun)
{
_observer.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}");
return;
}
var ok = await _client.TryResultOrderAsync(
result.EncounterId, orderDesc, resultSummary);
if (ok)
{
_observer.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted");
result.OrdersResulted++;
}
else
{
_observer.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted");
}
}
private static string FormatSimTime(double offsetMinutes)
{
var hours = (int)(offsetMinutes / 60);
var mins = (int)(offsetMinutes % 60);
return $"{hours:D2}:{mins:D2}";
}
private static string ToApiSource(string source) => source switch
{
"Device" or "DEVICE" or "device" or "monitor" => "DEVICE",
"Manual" or "MANUAL" or "manual" => "MANUAL",
"Lab" or "LAB" or "lab" => "LAB",
_ => throw new InvalidOperationException($"Unknown observation source: '{source}'")
};
private async Task ReplayAlertAck(
ScenarioEvent evt, string simTime, ReplayOptions options, ReplayResult result,
CancellationToken ct)
{
var alertType = evt.Data.GetProperty("alertType").GetString()!;
var clinicianId = evt.Data.GetProperty("clinicianId").GetString()!;
var note = evt.Data.TryGetProperty("note", out var n) ? n.GetString() : null;
if (options.DryRun)
{
_observer.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}");
return;
}
var waitForAlert = options.Target == ReplayTarget.Central
? TimeSpan.FromSeconds(30)
: (TimeSpan?)null;
var ok = await _client.TryAcknowledgeAlertAsync(
result.EncounterId, alertType, clinicianId, note, waitForAlert, ct);
if (ok)
_observer.Event(simTime, $"ACK {alertType} by {clinicianId}");
else
_observer.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
}
}
@@ -0,0 +1,11 @@
namespace VigilCare.Simulation;
public enum ReplayTarget { Central, Gateway }
public record ReplayOptions(
double Speed = 60,
bool Poll = false,
int PollIntervalSeconds = 5,
bool DryRun = false,
ReplayTarget Target = ReplayTarget.Central,
Guid? ExistingEncounterId = null);
@@ -0,0 +1,20 @@
namespace VigilCare.Simulation;
public class ReplayResult
{
public string ScenarioId { get; }
public Guid PatientId { get; set; }
public Guid EncounterId { get; set; }
public int ObservationsSent { get; set; }
public int MedicationsSent { get; set; }
public int MedicationsSkipped { get; set; }
public int OrdersPlaced { get; set; }
public int OrdersResulted { get; set; }
public TimeSpan Duration { get; set; }
public List<string> OutcomeFailures { get; } = new();
public bool HadExpectedOutcomes { get; set; }
public bool OutcomesPassed => OutcomeFailures.Count == 0;
public ReplayResult(string scenarioId) => ScenarioId = scenarioId;
}
@@ -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));
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>VigilCare.Simulation</RootNamespace>
<Copyright>Copyright (c) 2024-2026 voltsrage. All Rights Reserved.</Copyright>
<Authors>voltsrage</Authors>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>
</Project>