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