Files
vigilcare-clinical/VigilCare.Simulator/Engine/ReplayEngine.cs
T
2026-06-24 03:01:00 +08:00

290 lines
11 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;
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");
}
}