feature: In-App Simulation Runner (Backend)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user