feature: Console Replay Simulator
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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);
|
||||
|
||||
PatientResponse patient;
|
||||
EncounterResponse encounter;
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
SimulatorConsole.DryRun("Would register patient: " +
|
||||
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
|
||||
SimulatorConsole.DryRun("Would open encounter: " +
|
||||
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
|
||||
}
|
||||
else
|
||||
{
|
||||
patient = await _client.RegisterPatientAsync(new RegisterPatientRequest(
|
||||
scenario.Patient.FirstName,
|
||||
scenario.Patient.LastName,
|
||||
DateOnly.Parse(scenario.Patient.DateOfBirth),
|
||||
scenario.Patient.Gender));
|
||||
|
||||
encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
|
||||
scenario.Encounter.EncounterType,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 = _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);
|
||||
}
|
||||
|
||||
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}'")
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user