303 lines
12 KiB
C#
303 lines
12 KiB
C#
public class ReplayEngine
|
|
{
|
|
private readonly VigilCareApiClient _client;
|
|
private readonly ApiPoller? _poller;
|
|
private DateTimeOffset _scenarioStartTime;
|
|
|
|
public ReplayEngine(VigilCareApiClient client, ApiPoller? poller)
|
|
{
|
|
_client = client;
|
|
_poller = poller;
|
|
}
|
|
|
|
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 ---
|
|
SimulatorConsole.Header(scenario.Scenario.Name, scenario.Scenario.Description);
|
|
|
|
if (options.DryRun)
|
|
{
|
|
if (options.ExistingEncounterId.HasValue)
|
|
{
|
|
result.EncounterId = options.ExistingEncounterId.Value;
|
|
if (options.Target == ReplayTarget.Gateway)
|
|
SimulatorConsole.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
|
|
else
|
|
SimulatorConsole.DryRun($"Would use existing encounter {result.EncounterId}");
|
|
}
|
|
else
|
|
{
|
|
SimulatorConsole.DryRun("Would register patient: " +
|
|
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
|
|
SimulatorConsole.DryRun("Would open encounter: " +
|
|
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
|
|
}
|
|
}
|
|
else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue)
|
|
{
|
|
result.EncounterId = options.ExistingEncounterId.Value;
|
|
SimulatorConsole.Info($"Gateway mode — using existing encounter {result.EncounterId}");
|
|
}
|
|
else if (options.ExistingEncounterId.HasValue)
|
|
{
|
|
result.EncounterId = options.ExistingEncounterId.Value;
|
|
SimulatorConsole.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));
|
|
|
|
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));
|
|
|
|
SimulatorConsole.Info($"Patient registered: {patient.Id} ({patient.Mrn})");
|
|
SimulatorConsole.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();
|
|
|
|
foreach (var cluster in clusters)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var deltaMinutes = cluster.Key - lastOffset;
|
|
if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun)
|
|
{
|
|
var delayMs = (int)(deltaMinutes * 60_000 / options.Speed);
|
|
SimulatorConsole.Wait(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;
|
|
|
|
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)
|
|
SimulatorConsole.Error(failure);
|
|
}
|
|
|
|
SimulatorConsole.Summary(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";
|
|
|
|
SimulatorConsole.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)
|
|
{
|
|
SimulatorConsole.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)
|
|
{
|
|
SimulatorConsole.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent");
|
|
result.MedicationsSent++;
|
|
}
|
|
else
|
|
{
|
|
SimulatorConsole.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)
|
|
{
|
|
SimulatorConsole.DryRun($"[{simTime}] ORDER {orderType}: {description}");
|
|
return;
|
|
}
|
|
|
|
var placed = await _client.TryCreateOrderAsync(result.EncounterId,
|
|
new CreateOrderRequest(orderType, description, orderedBy));
|
|
|
|
if (placed)
|
|
{
|
|
SimulatorConsole.Event(simTime, $"ORDER {description} placed");
|
|
result.OrdersPlaced++;
|
|
}
|
|
else
|
|
{
|
|
SimulatorConsole.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)
|
|
{
|
|
SimulatorConsole.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}");
|
|
return;
|
|
}
|
|
|
|
var ok = await _client.TryResultOrderAsync(
|
|
result.EncounterId, orderDesc, resultSummary);
|
|
|
|
if (ok)
|
|
{
|
|
SimulatorConsole.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted");
|
|
result.OrdersResulted++;
|
|
}
|
|
else
|
|
{
|
|
SimulatorConsole.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)
|
|
{
|
|
SimulatorConsole.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)
|
|
SimulatorConsole.Event(simTime, $"ACK {alertType} by {clinicianId}");
|
|
else
|
|
SimulatorConsole.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
|
|
}
|
|
} |