feature: Console Replay Simulator
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
public record AlertResponse(
|
||||
Guid Id, string AlertType, string Severity, string Status,
|
||||
string Details, DateTimeOffset TriggeredAt);
|
||||
@@ -0,0 +1,2 @@
|
||||
public record ApiResponse<T>(bool Success, T Data, string? Error);
|
||||
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
||||
@@ -0,0 +1 @@
|
||||
public record BatchIngestRequest(List<IngestObservationRequest> Observations);
|
||||
@@ -0,0 +1,3 @@
|
||||
public record CreateMedicationAdministrationRequest(
|
||||
string DrugName, decimal Dose, string DoseUnit, string Route,
|
||||
DateTimeOffset? AdministeredAt, string AdministeredBy);
|
||||
@@ -0,0 +1 @@
|
||||
public record CreateOrderRequest(string OrderType, string Description, string OrderedBy);
|
||||
@@ -0,0 +1,4 @@
|
||||
public record EncounterResponse(
|
||||
Guid Id, Guid PatientId, string EncounterType, string Status,
|
||||
string Department, string AttendingPhysician, string? RoomBed,
|
||||
string? AdmissionReason, DateTimeOffset AdmittedAt);
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
public record IngestObservationRequest(
|
||||
string ObservationCode, decimal Value, string Unit,
|
||||
string Source, DateTimeOffset RecordedAt, string? IdempotencyKey);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
public record News2Response(
|
||||
Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree,
|
||||
int RespRateScore, int Spo2Score, int SystolicBpScore,
|
||||
int HeartRateScore, int ConsciousnessScore, int TemperatureScore,
|
||||
int SupplementalO2Score, DateTimeOffset CalculatedAt);
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
public record OpenEncounterRequest(
|
||||
string EncounterType, string Department, string AttendingPhysician,
|
||||
string? RoomBed = null, string? AdmissionReason = null);
|
||||
@@ -0,0 +1 @@
|
||||
public record OrderResponse(Guid Id, string Description, string Status);
|
||||
@@ -0,0 +1,3 @@
|
||||
public record PatientResponse(
|
||||
Guid Id, string Mrn, string FirstName, string LastName,
|
||||
DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1 @@
|
||||
public record RecordOrderResultRequest(string? ResultSummary);
|
||||
@@ -0,0 +1,2 @@
|
||||
public record RegisterPatientRequest(
|
||||
string FirstName, string LastName, DateOnly DateOfBirth, string Gender);
|
||||
@@ -0,0 +1,11 @@
|
||||
public record SepsisBundleElementResponse(string Status);
|
||||
|
||||
public record SepsisBundleResponse(
|
||||
Guid Id, string TriggeringAlertType, string ComplianceStatus,
|
||||
DateTimeOffset RecognizedAt, DateTimeOffset DeadlineAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
List<SepsisBundleElementResponse>? Elements = null)
|
||||
{
|
||||
public int ElementsCompleted =>
|
||||
Elements?.Count(e => e.Status == "Completed") ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
public class VigilCareApiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public VigilCareApiClient(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task<PatientResponse> RegisterPatientAsync(RegisterPatientRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
|
||||
return envelope!.Data;
|
||||
}
|
||||
|
||||
public async Task<EncounterResponse> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync($"/api/v1/patients/{patientId}/encounters", req);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<EncounterResponse>>();
|
||||
return envelope!.Data;
|
||||
}
|
||||
|
||||
public async Task SendObservationBatchAsync(
|
||||
Guid encounterId, List<IngestObservationRequest> observations)
|
||||
{
|
||||
foreach (var chunk in observations.Chunk(10))
|
||||
{
|
||||
var batch = new BatchIngestRequest(chunk.ToList());
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/observations", batch);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Observation batch failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TrySendMedicationAsync(
|
||||
Guid encounterId, CreateMedicationAdministrationRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/medications", req);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return false;
|
||||
response.EnsureSuccessStatusCode();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> TryCreateOrderAsync(
|
||||
Guid encounterId, CreateOrderRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/orders", req);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> TryResultOrderAsync(
|
||||
Guid encounterId, string orderDescription, string? resultSummary)
|
||||
{
|
||||
var ordersResponse = await _http.GetAsync(
|
||||
$"/api/v1/encounters/{encounterId}/orders?status=Pending");
|
||||
if (!ordersResponse.IsSuccessStatusCode)
|
||||
return false;
|
||||
|
||||
var envelope = await ordersResponse.Content
|
||||
.ReadFromJsonAsync<ApiResponse<PagedResponse<OrderResponse>>>();
|
||||
var order = FindPendingOrder(envelope?.Data.Items ?? [], orderDescription);
|
||||
if (order is null)
|
||||
return false;
|
||||
|
||||
var resultResponse = await _http.PatchAsJsonAsync(
|
||||
$"/api/v1/orders/{order.Id}/result",
|
||||
new RecordOrderResultRequest(resultSummary));
|
||||
return resultResponse.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private static OrderResponse? FindPendingOrder(
|
||||
List<OrderResponse> items, string description)
|
||||
{
|
||||
return items.FirstOrDefault(o => o.Description == description)
|
||||
?? items.FirstOrDefault(o =>
|
||||
description.StartsWith(o.Description, StringComparison.OrdinalIgnoreCase))
|
||||
?? items.FirstOrDefault(o =>
|
||||
o.Description.StartsWith(description, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// --- Polling endpoints ---
|
||||
|
||||
public async Task<List<AlertResponse>> GetAlertsAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/alerts");
|
||||
if (!response.IsSuccessStatusCode) return new();
|
||||
var envelope = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>();
|
||||
return envelope?.Data.Items.ToList() ?? new();
|
||||
}
|
||||
|
||||
public async Task<News2Response?> GetCurrentNews2Async(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/news2/current");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<News2Response>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<SepsisBundleResponse?> GetSepsisBundleAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync(
|
||||
$"/api/v1/encounters/{encounterId}/sepsis-bundle/current");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content
|
||||
.ReadFromJsonAsync<ApiResponse<SepsisBundleResponse>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.CommandLine;
|
||||
|
||||
public static class DryRunCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("scenario-file", "Path to the scenario JSON file");
|
||||
var command = new Command("dry-run", "Print event timeline without calling the API")
|
||||
{
|
||||
fileArg
|
||||
};
|
||||
|
||||
command.SetHandler(async (FileInfo file) =>
|
||||
{
|
||||
var scenario = ScenarioLoader.Load(file.FullName);
|
||||
var errors = ScenarioValidator.Validate(scenario);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SimulatorConsole.Error($"Scenario validation failed with {errors.Count} error(s):");
|
||||
foreach (var err in errors) SimulatorConsole.Error($" • {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
var engine = new ReplayEngine(client: null!, poller: null);
|
||||
await engine.RunAsync(scenario, new ReplayOptions(DryRun: true));
|
||||
}, fileArg);
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.CommandLine;
|
||||
|
||||
public static class ReplayAllCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var dirArg = new Argument<DirectoryInfo>("directory", "Directory containing scenario JSON files");
|
||||
var speedOpt = new Option<double>("--speed", () => 60);
|
||||
var baseUrlOpt = new Option<string>("--base-url", () => "http://localhost:5270");
|
||||
|
||||
var command = new Command("replay-all", "Replay all scenarios in a directory")
|
||||
{
|
||||
dirArg, speedOpt, baseUrlOpt
|
||||
};
|
||||
|
||||
command.SetHandler(async (DirectoryInfo dir, double speed, string baseUrl) =>
|
||||
{
|
||||
var files = dir.GetFiles("*.json")
|
||||
.Where(f => f.Name != "schema.json")
|
||||
.OrderBy(f => f.Name)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0)
|
||||
{
|
||||
SimulatorConsole.Warn($"No .json scenario files found in {dir.FullName}");
|
||||
return;
|
||||
}
|
||||
|
||||
SimulatorConsole.Header($"Replaying {files.Count} scenarios from {dir.Name}");
|
||||
|
||||
using var http = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
var client = new VigilCareApiClient(http);
|
||||
var engine = new ReplayEngine(client, poller: null);
|
||||
var results = new List<ReplayResult>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
SimulatorConsole.Divider();
|
||||
var scenario = ScenarioLoader.Load(file.FullName);
|
||||
var errors = ScenarioValidator.Validate(scenario);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SimulatorConsole.Error($"Skipping {file.Name}: {errors.Count} validation error(s)");
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = await engine.RunAsync(scenario, new ReplayOptions(speed));
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
SimulatorConsole.BatchSummary(results);
|
||||
}, dirArg, speedOpt, baseUrlOpt);
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.CommandLine;
|
||||
|
||||
public static class ReplayCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("scenario-file");
|
||||
var speedOpt = new Option<double>("--speed", () => 60, "Speed multiplier (0=instant, 1=realtime)");
|
||||
var baseUrlOpt = new Option<string>("--base-url", () => "http://localhost:5270");
|
||||
var pollOpt = new Option<bool>("--poll", () => false, "Poll alerts/scores after each cluster");
|
||||
var pollIntervalOpt = new Option<int>("--poll-interval", () => 5, "Seconds between polls");
|
||||
|
||||
var command = new Command("replay", "Replay a scenario against the API")
|
||||
{
|
||||
fileArg, speedOpt, baseUrlOpt, pollOpt, pollIntervalOpt
|
||||
};
|
||||
|
||||
command.SetHandler(async (FileInfo file, double speed, string baseUrl, bool poll, int pollInterval) =>
|
||||
{
|
||||
var scenario = ScenarioLoader.Load(file.FullName);
|
||||
var errors = ScenarioValidator.Validate(scenario);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SimulatorConsole.Error($"Scenario validation failed with {errors.Count} error(s):");
|
||||
foreach (var err in errors) SimulatorConsole.Error($" • {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var http = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
var client = new VigilCareApiClient(http);
|
||||
var poller = poll ? new ApiPoller(client) : null;
|
||||
var engine = new ReplayEngine(client, poller);
|
||||
var options = new ReplayOptions(speed, poll, pollInterval);
|
||||
|
||||
await engine.RunAsync(scenario, options);
|
||||
}, fileArg, speedOpt, baseUrlOpt, pollOpt, pollIntervalOpt);
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.CommandLine;
|
||||
|
||||
public static class ValidateCommand
|
||||
{
|
||||
public static Command Create()
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("scenario-file", "Path to the scenario JSON file");
|
||||
var command = new Command("validate", "Validate a scenario file without replaying")
|
||||
{
|
||||
fileArg
|
||||
};
|
||||
|
||||
command.SetHandler((FileInfo file) =>
|
||||
{
|
||||
var scenario = ScenarioLoader.Load(file.FullName);
|
||||
var errors = ScenarioValidator.Validate(scenario);
|
||||
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
SimulatorConsole.Success($"✓ {file.Name} is valid");
|
||||
SimulatorConsole.Info($" {scenario.Events.Count} events over " +
|
||||
$"{scenario.Events.Last().OffsetMinutes} minutes");
|
||||
SimulatorConsole.Info($" Patient: {scenario.Patient.FirstName} " +
|
||||
$"{scenario.Patient.LastName}");
|
||||
SimulatorConsole.Info($" Tags: {string.Join(", ", scenario.Scenario.Tags ?? new())}");
|
||||
}
|
||||
else
|
||||
{
|
||||
SimulatorConsole.Error($"✗ {file.Name} has {errors.Count} error(s):");
|
||||
foreach (var err in errors)
|
||||
SimulatorConsole.Error($" • {err}");
|
||||
}
|
||||
}, fileArg);
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -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}'")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
public record ReplayOptions(
|
||||
double Speed = 60,
|
||||
bool Poll = false,
|
||||
int PollIntervalSeconds = 5,
|
||||
bool DryRun = false);
|
||||
@@ -0,0 +1,14 @@
|
||||
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 ReplayResult(string scenarioId) => ScenarioId = scenarioId;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Spectre.Console;
|
||||
|
||||
public static class SimulatorConsole
|
||||
{
|
||||
public static void Header(string title, string? description = null)
|
||||
{
|
||||
AnsiConsole.Write(new Rule($"[bold]{title}[/]").LeftJustified());
|
||||
if (description is not null)
|
||||
AnsiConsole.MarkupLine($"[dim]{description}[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
public static void Event(string simTime, string message) =>
|
||||
AnsiConsole.MarkupLine($"[green][[{simTime}]][/] {Markup.Escape(message)}");
|
||||
|
||||
public static void Warn(string message) =>
|
||||
AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(message)}[/]");
|
||||
|
||||
public static void Error(string message) =>
|
||||
AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]");
|
||||
|
||||
public static void Info(string message) =>
|
||||
AnsiConsole.MarkupLine($"[blue]{Markup.Escape(message)}[/]");
|
||||
|
||||
public static void DryRun(string message) =>
|
||||
AnsiConsole.MarkupLine($"[dim]DRY-RUN:[/] {Markup.Escape(message)}");
|
||||
|
||||
public static void Success(string message) =>
|
||||
AnsiConsole.MarkupLine($"[bold green]{Markup.Escape(message)}[/]");
|
||||
|
||||
public static void Wait(double deltaMinutes, int delayMs) =>
|
||||
AnsiConsole.MarkupLine(
|
||||
$"[dim] ⏳ waiting {deltaMinutes:F0}m simulated ({delayMs / 1000.0:F1}s real)...[/]");
|
||||
|
||||
public static void Divider() =>
|
||||
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("dim")));
|
||||
|
||||
public static void Summary(ReplayResult r)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
var table = new Table().AddColumn("Metric").AddColumn("Value");
|
||||
table.AddRow("Scenario", r.ScenarioId);
|
||||
table.AddRow("Observations sent", r.ObservationsSent.ToString());
|
||||
table.AddRow("Medications sent", r.MedicationsSent.ToString());
|
||||
if (r.MedicationsSkipped > 0)
|
||||
table.AddRow("Medications skipped", r.MedicationsSkipped.ToString());
|
||||
table.AddRow("Orders placed", r.OrdersPlaced.ToString());
|
||||
table.AddRow("Orders resulted", r.OrdersResulted.ToString());
|
||||
table.AddRow("Wall-clock time", $"{r.Duration.TotalSeconds:F1}s");
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
public static void BatchSummary(List<ReplayResult> results)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.Write(new Rule("[bold]Batch Summary[/]").LeftJustified());
|
||||
var table = new Table()
|
||||
.AddColumn("Scenario")
|
||||
.AddColumn("Observations")
|
||||
.AddColumn("Medications")
|
||||
.AddColumn("Time");
|
||||
foreach (var r in results)
|
||||
table.AddRow(r.ScenarioId, r.ObservationsSent.ToString(),
|
||||
r.MedicationsSent.ToString(), $"{r.Duration.TotalSeconds:F1}s");
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
public static void PollResults(string simTime, PollResult poll)
|
||||
{
|
||||
if (poll.News2 is not null)
|
||||
AnsiConsole.MarkupLine(
|
||||
$"[cyan][[{simTime}]][/] NEWS2 = {poll.News2.TotalScore} ({poll.News2.RiskLevel})");
|
||||
|
||||
foreach (var alert in poll.NewAlerts)
|
||||
AnsiConsole.MarkupLine(
|
||||
$"[red][[{simTime}]][/] ALERT {alert.AlertType} ({alert.Severity})");
|
||||
|
||||
if (poll.SepsisBundle is not null)
|
||||
AnsiConsole.MarkupLine(
|
||||
$"[cyan][[{simTime}]][/] SEPSIS BUNDLE {poll.SepsisBundle.ComplianceStatus} " +
|
||||
$"({poll.SepsisBundle.ElementsCompleted}/4)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
public class ApiPoller
|
||||
{
|
||||
private readonly VigilCareApiClient _client;
|
||||
private readonly HashSet<Guid> _seenAlertIds = new();
|
||||
|
||||
public ApiPoller(VigilCareApiClient client) => _client = client;
|
||||
|
||||
public async Task PollAndDisplayAsync(Guid encounterId, string simTime)
|
||||
{
|
||||
var news2 = await _client.GetCurrentNews2Async(encounterId);
|
||||
|
||||
var allAlerts = await _client.GetAlertsAsync(encounterId);
|
||||
var newAlerts = allAlerts.Where(a => _seenAlertIds.Add(a.Id)).ToList();
|
||||
|
||||
var bundle = await _client.GetSepsisBundleAsync(encounterId);
|
||||
|
||||
var result = new PollResult(news2, newAlerts, bundle);
|
||||
SimulatorConsole.PollResults(simTime, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public record PollResult(
|
||||
News2Response? News2,
|
||||
List<AlertResponse> NewAlerts,
|
||||
SepsisBundleResponse? SepsisBundle);
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.CommandLine;
|
||||
|
||||
var rootCommand = new RootCommand("VigilCare Clinical Replay Simulator");
|
||||
|
||||
rootCommand.AddCommand(ReplayCommand.Create());
|
||||
rootCommand.AddCommand(ReplayAllCommand.Create());
|
||||
rootCommand.AddCommand(ValidateCommand.Create());
|
||||
rootCommand.AddCommand(DryRunCommand.Create());
|
||||
|
||||
return await rootCommand.InvokeAsync(args);
|
||||
@@ -0,0 +1,199 @@
|
||||
# VigilCare Scenario Generator Prompt
|
||||
|
||||
Use the following prompt with Claude or any LLM to generate clinical scenario JSON files.
|
||||
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
You are a clinical scenario generator for a hospital patient monitoring system called VigilCare. Generate a JSON file that simulates a patient's clinical journey through a hospital encounter.
|
||||
|
||||
### JSON Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": {
|
||||
"id": "kebab-case-id",
|
||||
"name": "Human-readable scenario name",
|
||||
"description": "1-2 sentence clinical summary",
|
||||
"durationMinutes": 240,
|
||||
"tags": ["sepsis", "ed", "deterioration"]
|
||||
},
|
||||
"patient": {
|
||||
"firstName": "Eleanor",
|
||||
"lastName": "Chen",
|
||||
"dateOfBirth": "1954-03-15",
|
||||
"gender": "Female"
|
||||
},
|
||||
"encounter": {
|
||||
"department": "Emergency",
|
||||
"encounterType": "Emergency | Inpatient | Outpatient",
|
||||
"attendingPhysician": "Dr. Sarah Mitchell",
|
||||
"roomBed": "ED-12A",
|
||||
"admissionReason": "Fever, confusion, dysuria"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 88,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "medication",
|
||||
"data": {
|
||||
"drugName": "ceftriaxone",
|
||||
"dose": 1,
|
||||
"doseUnit": "g",
|
||||
"route": "IV",
|
||||
"administeredBy": "nurse-rn-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "order",
|
||||
"data": {
|
||||
"orderType": "Lab",
|
||||
"description": "Urinalysis with culture",
|
||||
"orderedBy": "Dr. Sarah Mitchell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "order_result",
|
||||
"data": {
|
||||
"orderDescription": "SEP-1: Blood cultures",
|
||||
"resultSummary": "Pending gram stain"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expectedOutcomes": [
|
||||
{
|
||||
"afterOffsetMinutes": 30,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_HEART_RATE",
|
||||
"description": "HR enters warning range"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 120,
|
||||
"type": "alert",
|
||||
"alertType": "SEPSIS_WARNING",
|
||||
"description": "SIRS criteria met"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 180,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 7,
|
||||
"description": "NEWS2 reaches HIGH risk"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 120,
|
||||
"type": "bundle",
|
||||
"description": "Sepsis bundle auto-created with 4 orders"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Available Observation Codes
|
||||
|
||||
| Code | Unit | Normal Range | Warning Range | Critical Range |
|
||||
|------|------|-------------|---------------|----------------|
|
||||
| HEART_RATE | bpm | 51–90 | 41–50 or 91–110 | ≤40 or ≥111 |
|
||||
| RESP_RATE | /min | 12–20 | 9–11 or 21–24 | ≤8 or ≥25 |
|
||||
| SYSTOLIC_BP | mmHg | 111–219 | 101–110 | ≤100 or ≥220 |
|
||||
| DIASTOLIC_BP | mmHg | 60–90 | 50–59 or 91–100 | ≤49 or ≥101 |
|
||||
| TEMP_C | °C | 36.1–38.0 | 35.1–36.0 or 38.1–39.0 | ≤35.0 or ≥39.1 |
|
||||
| SPO2 | % | 96–100 | 94–95 | ≤93 |
|
||||
| AVPU | score | 0 (Alert) | — | 1 (Voice) 2 (Pain) 3 (Unresponsive) |
|
||||
| SUPPLEMENTAL_O2 | flag | 0 (No) | — | 1 (Yes) |
|
||||
| WBC_K_UL | ×10³/µL | 4.5–11.0 | — | <4.0 or >12.0 |
|
||||
| POTASSIUM_MEQ_L | mEq/L | 3.5–5.0 | 3.0–3.4 or 5.1–5.5 | <3.0 or >5.5 |
|
||||
| LACTATE_MMOL_L | mmol/L | 0.5–1.5 | 1.6–2.0 | >2.0 |
|
||||
| GLUCOSE_MG_DL | mg/dL | 70–140 | 141–180 | <70 or >180 |
|
||||
|
||||
### Clinical Scoring Rules
|
||||
|
||||
**NEWS2** (computed from: HEART_RATE, RESP_RATE, SYSTOLIC_BP, TEMP_C, SPO2, AVPU, SUPPLEMENTAL_O2):
|
||||
- Total score 5–6 → `NEWS2_WARNING` alert
|
||||
- Total score ≥7 OR any single parameter scores 3 → `NEWS2_EMERGENCY` alert
|
||||
|
||||
**SIRS** (≥2 of 4 criteria → `SEPSIS_WARNING`):
|
||||
- Temperature >38.3°C or <36.0°C
|
||||
- Heart rate >90 bpm
|
||||
- Respiratory rate >20/min
|
||||
- WBC >12.0 or <4.0 ×10³/µL
|
||||
|
||||
**qSOFA** (≥2 of 3 criteria → `QSOFA_WARNING`):
|
||||
- Respiratory rate ≥22/min
|
||||
- Systolic BP ≤100 mmHg
|
||||
- AVPU ≥1 (any altered mentation)
|
||||
|
||||
**Trend alerts** — rapid rise or fall in a vital sign within a 30-minute sliding window triggers `RAPID_DETERIORATION`.
|
||||
|
||||
**Sepsis bundle** — when `SEPSIS_WARNING` or `QSOFA_WARNING` fires, the system auto-creates a sepsis bundle with 4 orders (use these **exact** descriptions in `order_result` events; do **not** add `order` events for them):
|
||||
|
||||
| Description |
|
||||
|---|
|
||||
| `SEP-1: Blood cultures` |
|
||||
| `SEP-1: Serum lactate` |
|
||||
| `SEP-1: Broad-spectrum antibiotics` |
|
||||
| `SEP-1: IV fluid bolus` |
|
||||
|
||||
Place `order_result` events for bundle orders **after** the sepsis alert would fire. For any other lab/imaging order, add an `order` event before `order_result`.
|
||||
|
||||
### Order event types
|
||||
|
||||
Place order (required before `order_result` unless sepsis bundle auto-order):
|
||||
```json
|
||||
{
|
||||
"offsetMinutes": 40,
|
||||
"type": "order",
|
||||
"data": {
|
||||
"orderType": "Lab",
|
||||
"description": "Urinalysis with culture",
|
||||
"orderedBy": "Dr. Sarah Mitchell"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Valid `orderType` values: `Lab`, `Imaging`, `Medication`, `Procedure`. `orderedBy` defaults to `encounter.attendingPhysician` if omitted.
|
||||
|
||||
### Rules for Generating Realistic Scenarios
|
||||
|
||||
1. **Vital signs arrive in clusters.** In real hospitals, nurses take a full set of vitals every 15–60 minutes. Each cluster should include at minimum: HEART_RATE, RESP_RATE, SYSTOLIC_BP, TEMP_C, SPO2, AVPU. Add SUPPLEMENTAL_O2 only when oxygen is administered. Add lab values (WBC, LACTATE, POTASSIUM, GLUCOSE) only at specific lab draw times, not every cluster.
|
||||
|
||||
2. **Deterioration is gradual.** A patient does not jump from HR 80 to HR 130 in one reading. Realistic deterioration progresses over 2–6 hours: HR 80 → 88 → 95 → 102 → 110 → 118. Each step is 15–60 minutes apart.
|
||||
|
||||
3. **Vital signs correlate.** Sepsis shows rising HR + rising temp + rising RR + falling BP together. Hemorrhagic shock shows rising HR + falling BP + falling SpO2. Respiratory failure shows falling SpO2 + rising RR. Don't change vital signs independently.
|
||||
|
||||
4. **Include physiological noise.** Real vital signs fluctuate. A stable HR of 75 might read 73, 76, 74, 77 across 4 readings. Add ±2–5% variation on stable values.
|
||||
|
||||
5. **Start with a baseline.** The first event cluster (offsetMinutes: 0) should be a complete set of normal or near-normal vitals establishing the patient's baseline.
|
||||
|
||||
6. **offsetMinutes must be non-decreasing.** Events within the same minute are fine (a full vital sign set arrives at the same offset). Never go backwards.
|
||||
|
||||
7. **Max 10 observations per offset.** The API accepts at most 10 observations per batch. A full vital set is 7 codes (HR, RR, SBP, TEMP, SpO2, AVPU, plus SUPPLEMENTAL_O2 when on oxygen). If you add lab values at the same minute, keep the total ≤10 — e.g. put labs at `offsetMinutes + 1` when vitals + labs would exceed 10.
|
||||
|
||||
8. **Use realistic source values.** Observation source is `"Device"` for continuous monitoring (HR, SpO2, BP) or `"Manual"` for nurse-measured values (temp, AVPU, supplemental O2). Lab draws (WBC, lactate, potassium, glucose) are `"Lab"`.
|
||||
|
||||
9. **Medications need clinical justification.** Only include medication events that make clinical sense for the scenario. Include the medication type events only if the scenario involves treatment response.
|
||||
|
||||
10. **Orders before results.** Every `order_result` must have a matching pending order. Use an `order` event first for routine labs/imaging. Sepsis bundle orders are auto-created — only use `order_result` with the exact SEP-1 descriptions above.
|
||||
|
||||
11. **Expected outcomes must be achievable.** Only list expected outcomes that the given vital sign trajectory will actually trigger based on the scoring rules above. Calculate NEWS2 scores mentally. Count SIRS criteria. Check qSOFA thresholds.
|
||||
|
||||
### Generate This Scenario
|
||||
|
||||
- 72-year-old with UTI → sepsis over 4 hours
|
||||
- Baseline normals → rising temp, HR, RR → SIRS triggers → qSOFA triggers → bundle auto-created
|
||||
- Includes order results completing the sepsis bundle
|
||||
- Expected: `SEPSIS_WARNING`, `QSOFA_WARNING`, `NEWS2_EMERGENCY`, sepsis bundle `COMPLIANT`
|
||||
|
||||
Output ONLY the JSON file. No explanation or commentary.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,985 @@
|
||||
{
|
||||
"scenario": {
|
||||
"id": "hypothermia-elderly-01",
|
||||
"name": "Elderly Hypothermia with Bradycardia \u2014 Rewarming Recovery",
|
||||
"description": "81-year-old female found outdoors in winter with core temp 33.5\u00b0C on arrival. Presents with hypothermia-induced bradycardia, hypotension, and altered mentation. Gradual active rewarming over 4 hours with warm IV fluids and warming blankets leads to full recovery of vitals and consciousness.",
|
||||
"durationMinutes": 240,
|
||||
"tags": [
|
||||
"hypothermia",
|
||||
"bradycardia",
|
||||
"elderly",
|
||||
"rewarming",
|
||||
"recovery",
|
||||
"emergency",
|
||||
"winter"
|
||||
]
|
||||
},
|
||||
"patient": {
|
||||
"firstName": "Margaret",
|
||||
"lastName": "Thornton",
|
||||
"dateOfBirth": "1945-01-14",
|
||||
"gender": "Female"
|
||||
},
|
||||
"encounter": {
|
||||
"department": "Emergency",
|
||||
"encounterType": "Emergency",
|
||||
"attendingPhysician": "Dr. Elena Vasquez",
|
||||
"roomBed": "ED-03A",
|
||||
"admissionReason": "Found unresponsive outdoors in winter, hypothermia, bradycardia"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 48,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "Arrival \u2014 severe hypothermia, bradycardia, altered mentation. NEWS2=14 (EMERGENCY)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 10,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 96,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 54,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 33.5,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 93,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 1,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SUPPLEMENTAL_O2",
|
||||
"value": 1,
|
||||
"unit": "flag",
|
||||
"source": "Manual"
|
||||
},
|
||||
"note": "Nasal cannula 2L/min started on arrival"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "order",
|
||||
"data": {
|
||||
"orderType": "Procedure",
|
||||
"description": "Active warming protocol \u2014 forced-air warming blanket",
|
||||
"orderedBy": "Dr. Elena Vasquez"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 5,
|
||||
"type": "order_result",
|
||||
"data": {
|
||||
"orderDescription": "Active warming protocol \u2014 forced-air warming blanket",
|
||||
"resultSummary": "Bair Hugger warming blanket applied, set to 43\u00b0C"
|
||||
},
|
||||
"note": "Warming blanket order initiated"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 8,
|
||||
"type": "medication",
|
||||
"data": {
|
||||
"drugName": "sodium chloride 0.9% (warmed to 40\u00b0C)",
|
||||
"dose": 1000,
|
||||
"doseUnit": "mL",
|
||||
"route": "IV",
|
||||
"administeredBy": "nurse-rn-2"
|
||||
},
|
||||
"note": "Warm IV fluids \u2014 first bolus for active core rewarming and volume support"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "GLUCOSE_MG_DL",
|
||||
"value": 74,
|
||||
"unit": "mg/dL",
|
||||
"source": "Lab"
|
||||
},
|
||||
"note": "Initial labs \u2014 glucose low-normal, potassium borderline high (hypothermia shifts K+ extracellularly)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "POTASSIUM_MEQ_L",
|
||||
"value": 5.4,
|
||||
"unit": "mEq/L",
|
||||
"source": "Lab"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "LACTATE_MMOL_L",
|
||||
"value": 2.3,
|
||||
"unit": "mmol/L",
|
||||
"source": "Lab"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 20,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 47,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "Minimal change \u2014 rewarming just started. NEWS2=14 (EMERGENCY)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 20,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 10,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 20,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 94,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 20,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 53,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 20,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 33.7,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 20,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 93,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 20,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 1,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 20,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SUPPLEMENTAL_O2",
|
||||
"value": 1,
|
||||
"unit": "flag",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 46,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "Slow rewarming \u2014 temp barely above 34\u00b0C. SpO2 slightly improved with O2. NEWS2=13 (EMERGENCY)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 11,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 95,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 55,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 34.0,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 94,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 1,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SUPPLEMENTAL_O2",
|
||||
"value": 1,
|
||||
"unit": "flag",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 50,
|
||||
"type": "medication",
|
||||
"data": {
|
||||
"drugName": "sodium chloride 0.9% (warmed to 40\u00b0C)",
|
||||
"dose": 500,
|
||||
"doseUnit": "mL",
|
||||
"route": "IV",
|
||||
"administeredBy": "nurse-rn-2"
|
||||
},
|
||||
"note": "Second warm IV fluid bolus \u2014 continued active rewarming"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 48,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "One hour into rewarming \u2014 temp 34.3\u00b0C, still bradycardic. NEWS2=13 (EMERGENCY)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 11,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 97,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 56,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 34.3,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 94,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 1,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SUPPLEMENTAL_O2",
|
||||
"value": 1,
|
||||
"unit": "flag",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 50,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "Temp approaching 35\u00b0C \u2014 HR and RR beginning to improve. NEWS2=12 (EMERGENCY)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 12,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 100,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 58,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 34.8,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 95,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 1,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SUPPLEMENTAL_O2",
|
||||
"value": 1,
|
||||
"unit": "flag",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 54,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "Temp crosses 35\u00b0C threshold \u2014 major improvement. HR normalizing, BP improving. NEWS2=7 (EMERGENCY \u2014 AVPU still 3)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 13,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 104,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 62,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 35.2,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 96,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 1,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SUPPLEMENTAL_O2",
|
||||
"value": 1,
|
||||
"unit": "flag",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 125,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "GLUCOSE_MG_DL",
|
||||
"value": 88,
|
||||
"unit": "mg/dL",
|
||||
"source": "Lab"
|
||||
},
|
||||
"note": "Repeat labs at T+120 \u2014 glucose normalizing, potassium trending down as rewarming drives K+ intracellularly"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 125,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "POTASSIUM_MEQ_L",
|
||||
"value": 4.6,
|
||||
"unit": "mEq/L",
|
||||
"source": "Lab"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 125,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "LACTATE_MMOL_L",
|
||||
"value": 1.7,
|
||||
"unit": "mmol/L",
|
||||
"source": "Lab"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 58,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "Significant recovery \u2014 patient responding to name, temp 35.8\u00b0C. NEWS2=4 (below WARNING threshold)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 14,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 108,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 65,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 35.8,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 97,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 0,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SUPPLEMENTAL_O2",
|
||||
"value": 1,
|
||||
"unit": "flag",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 62,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "Temp in normal range \u2014 all vitals normalizing. O2 weaned off. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 15,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 112,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 68,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 36.2,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 97,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 0,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 210,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 65,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "Continued monitoring \u2014 stable, fully alert. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 210,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 15,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 210,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 118,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 210,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 70,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 210,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 36.4,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 210,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 98,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 210,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 0,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 240,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 68,
|
||||
"unit": "bpm",
|
||||
"source": "Device"
|
||||
},
|
||||
"note": "End of scenario \u2014 near-normal vitals, temp 36.5\u00b0C. Full recovery. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 240,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "RESP_RATE",
|
||||
"value": 16,
|
||||
"unit": "/min",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 240,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SYSTOLIC_BP",
|
||||
"value": 122,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 240,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "DIASTOLIC_BP",
|
||||
"value": 72,
|
||||
"unit": "mmHg",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 240,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "TEMP_C",
|
||||
"value": 36.5,
|
||||
"unit": "\u00b0C",
|
||||
"source": "Manual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 240,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "SPO2",
|
||||
"value": 98,
|
||||
"unit": "%",
|
||||
"source": "Device"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 240,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "AVPU",
|
||||
"value": 0,
|
||||
"unit": "score",
|
||||
"source": "Manual"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expectedOutcomes": [
|
||||
{
|
||||
"afterOffsetMinutes": 0,
|
||||
"type": "alert",
|
||||
"alertType": "NEWS2_EMERGENCY",
|
||||
"description": "NEWS2=14 on arrival: Temp 33.5\u21923, HR 48\u21921, SBP 96\u21922, RR 10\u21921, SpO2 93\u21922, AVPU 1\u21923, O2\u21922. Total \u22657 with multiple single-param 3s"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 0,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 14,
|
||||
"description": "Initial NEWS2 of 14 \u2014 critically elevated due to severe hypothermia with multi-organ effects"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 0,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_HEART_RATE",
|
||||
"description": "HR 48 bpm in warning range (41-50) \u2014 hypothermia-induced bradycardia"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 0,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_POTASSIUM",
|
||||
"description": "Potassium 5.4 mEq/L \u2014 borderline high (warning range 5.1-5.5) due to hypothermic cellular shifts"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 90,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 12,
|
||||
"description": "NEWS2 decreasing to 12 at T+90: Temp 34.8\u21923, HR 50\u21921, SBP 100\u21922, RR 12\u21920, SpO2 95\u21921, AVPU 1\u21923, O2\u21922. Still EMERGENCY"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 120,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 7,
|
||||
"description": "NEWS2 drops to 7 at T+120: Temp 35.2\u21921, HR 54\u21920, SBP 104\u21921, RR 13\u21920, SpO2 96\u21920, AVPU 1\u21923, O2\u21922. Still EMERGENCY due to AVPU=3"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 120,
|
||||
"type": "alert",
|
||||
"alertType": "NEWS2_EMERGENCY",
|
||||
"description": "NEWS2 still \u22657 at T+120 \u2014 AVPU single-param score of 3 alone triggers EMERGENCY even as other parameters normalize"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 150,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 0,
|
||||
"description": "NEWS2 drops to 4 at T+150: Temp 35.8\u21921, HR 58\u21920, SBP 108\u21921, RR 14\u21920, SpO2 97\u21920, AVPU 0\u21920, O2\u21922. Below WARNING threshold \u2014 recovery evident"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 180,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 0,
|
||||
"description": "NEWS2 drops to 0 at T+180: all vitals in normal range, O2 weaned off. Full recovery from hypothermia"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 240,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 0,
|
||||
"description": "NEWS2 remains 0 at end of scenario \u2014 sustained normothermia at 36.5\u00b0C with normal hemodynamics"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
{
|
||||
"scenario": {
|
||||
"id": "medication-false-alarm-01",
|
||||
"name": "Medication-Induced False Alarm (Stable Patient on Beta-Blocker)",
|
||||
"description": "45-year-old female on metoprolol (beta-blocker) with baseline low HR (~52 bpm) and controlled hypertension. Vitals fluctuate near warning thresholds but patient never truly deteriorates. Tests the system's alert behavior near boundaries and alert-fatigue patterns.",
|
||||
"durationMinutes": 180,
|
||||
"tags": ["false-alarm", "beta-blocker", "bradycardia", "boundary-testing", "alert-fatigue", "stable"]
|
||||
},
|
||||
"patient": {
|
||||
"firstName": "Sandra",
|
||||
"lastName": "Kowalski",
|
||||
"dateOfBirth": "1981-03-14",
|
||||
"gender": "Female"
|
||||
},
|
||||
"encounter": {
|
||||
"department": "GeneralMedicine",
|
||||
"encounterType": "Inpatient",
|
||||
"attendingPhysician": "Dr. Helen Tranh",
|
||||
"roomBed": "GM-214A",
|
||||
"admissionReason": "Observation for controlled hypertension, medication titration"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 54, "unit": "bpm", "source": "Device" },
|
||||
"note": "Baseline vitals — patient on scheduled metoprolol, resting comfortably. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 136, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 78, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 98, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "medication",
|
||||
"data": {
|
||||
"drugName": "metoprolol tartrate",
|
||||
"dose": 25,
|
||||
"doseUnit": "mg",
|
||||
"route": "PO",
|
||||
"administeredBy": "nurse-rn-12"
|
||||
},
|
||||
"note": "Scheduled beta-blocker dose — expected to maintain low HR"
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 50, "unit": "bpm", "source": "Device" },
|
||||
"note": "HR dips to lower boundary of warning range (41-50) post-dose. NEWS2=1 (HR=1)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 134, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 98, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 15,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 30,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 49, "unit": "bpm", "source": "Device" },
|
||||
"note": "HR remains in warning range — metoprolol peak effect. NEWS2=1 (HR=1)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 30,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 30,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 132, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 30,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 75, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 30,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 37.0, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 30,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 97, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 30,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 52, "unit": "bpm", "source": "Device" },
|
||||
"note": "HR recovers to normal range (51-90). NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 130, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 74, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 98, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 51, "unit": "bpm", "source": "Device" },
|
||||
"note": "Routine check — all within normal limits. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 133, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 37.0, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 97, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 75,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 48, "unit": "bpm", "source": "Device" },
|
||||
"note": "HR dips again after position change — back into warning range. NEWS2=1 (HR=1)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 75,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 16, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 75,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 128, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 75,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 73, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 75,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 75,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 98, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 75,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 53, "unit": "bpm", "source": "Device" },
|
||||
"note": "HR recovers again — normal range. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 131, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 75, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 37.0, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 97, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 90,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 105,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 55, "unit": "bpm", "source": "Device" },
|
||||
"note": "Stable — metoprolol wearing off slightly before next dose. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 105,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 105,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 130, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 105,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 74, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 105,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 105,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 98, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 105,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "medication",
|
||||
"data": {
|
||||
"drugName": "metoprolol tartrate",
|
||||
"dose": 25,
|
||||
"doseUnit": "mg",
|
||||
"route": "PO",
|
||||
"administeredBy": "nurse-rn-12"
|
||||
},
|
||||
"note": "Second scheduled metoprolol dose"
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 52, "unit": "bpm", "source": "Device" },
|
||||
"note": "Pre-dose vitals taken with medication. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 126, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 72, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 37.0, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 97, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 120,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 135,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 48, "unit": "bpm", "source": "Device" },
|
||||
"note": "Post-dose HR dip — second metoprolol taking effect. NEWS2=1 (HR=1)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 135,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 135,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 112, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 135,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 68, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 135,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 135,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 98, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 135,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 47, "unit": "bpm", "source": "Device" },
|
||||
"note": "Lowest HR of scenario — SBP also drifts near warning boundary. NEWS2=2 (HR=1, SBP=1)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 110, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 66, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 97, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 150,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 165,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 50, "unit": "bpm", "source": "Device" },
|
||||
"note": "HR still in warning but recovering, SBP back to normal. NEWS2=1 (HR=1)"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 165,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 165,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 114, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 165,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 70, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 165,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 37.0, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 165,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 98, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 165,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
},
|
||||
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 52, "unit": "bpm", "source": "Device" },
|
||||
"note": "End of scenario — patient stable, HR back in normal range. NEWS2=0"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": { "code": "SYSTOLIC_BP", "value": 118, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": { "code": "DIASTOLIC_BP", "value": 72, "unit": "mmHg", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 97, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 180,
|
||||
"type": "observation",
|
||||
"data": { "code": "AVPU", "value": 0, "unit": "score", "source": "Manual" }
|
||||
}
|
||||
],
|
||||
"expectedOutcomes": [
|
||||
{
|
||||
"afterOffsetMinutes": 15,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_HEART_RATE",
|
||||
"description": "HR drops to 50 bpm (warning range 41-50) after first metoprolol dose — expected medication effect"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 15,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 1,
|
||||
"description": "NEWS2=1 (HR=1 only) — below WARNING threshold of 5, no escalation needed"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 30,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_HEART_RATE",
|
||||
"description": "HR 49 bpm — sustained warning-range bradycardia during metoprolol peak effect"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 45,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 0,
|
||||
"description": "NEWS2=0 — HR recovers to 52 bpm (normal range), all parameters normal"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 75,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_HEART_RATE",
|
||||
"description": "HR dips to 48 bpm after position change — transient, self-resolving"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 135,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_HEART_RATE",
|
||||
"description": "HR 48 bpm after second metoprolol dose — repeat of earlier pattern"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 150,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_HEART_RATE",
|
||||
"description": "HR 47 bpm — lowest point of scenario, still within warning (not critical)"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 150,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_SYSTOLIC_BP",
|
||||
"description": "SBP 110 mmHg — briefly enters warning range (101-110), self-resolves by T+165"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 150,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 2,
|
||||
"description": "Peak NEWS2=2 (HR=1 + SBP=1) — maximum score in scenario, well below WARNING threshold of 5"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 165,
|
||||
"type": "alert",
|
||||
"alertType": "WARNING_HEART_RATE",
|
||||
"description": "HR 50 bpm — still in warning range but trending upward toward recovery"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 180,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 0,
|
||||
"description": "NEWS2=0 at end of scenario — patient fully stable, no active alerts. Confirms no escalation occurred."
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json;
|
||||
|
||||
public record ScenarioFile(
|
||||
ScenarioMeta Scenario,
|
||||
ScenarioPatient Patient,
|
||||
ScenarioEncounter Encounter,
|
||||
List<ScenarioEvent> Events,
|
||||
List<ExpectedOutcome>? ExpectedOutcomes);
|
||||
|
||||
public record ScenarioMeta(
|
||||
string Id, string Name, string? Description,
|
||||
int? DurationMinutes, List<string>? Tags);
|
||||
|
||||
public record ScenarioPatient(
|
||||
string FirstName, string LastName, string DateOfBirth,
|
||||
string Gender);
|
||||
|
||||
public record ScenarioEncounter(
|
||||
string Department, string EncounterType, string AttendingPhysician,
|
||||
string? RoomBed, string? AdmissionReason);
|
||||
|
||||
public record ScenarioEvent(
|
||||
double OffsetMinutes, string Type, JsonElement Data, string? Note);
|
||||
|
||||
public record ExpectedOutcome(
|
||||
double AfterOffsetMinutes, string Type,
|
||||
string? AlertType, string? ScoreType,
|
||||
double? ExpectedMinimum, string? Description);
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
|
||||
public static class ScenarioLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static ScenarioFile Load(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"Scenario file not found: {path}");
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var scenario = JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize: {path}");
|
||||
|
||||
return scenario with
|
||||
{
|
||||
Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
public static class ScenarioValidator
|
||||
{
|
||||
private static readonly HashSet<string> ValidCodes = new()
|
||||
{
|
||||
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP",
|
||||
"TEMP_C", "SPO2", "AVPU", "SUPPLEMENTAL_O2",
|
||||
"WBC_K_UL", "POTASSIUM_MEQ_L", "LACTATE_MMOL_L", "GLUCOSE_MG_DL"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidSources = new()
|
||||
{
|
||||
"Manual", "Device", "Lab"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidDepartments = new()
|
||||
{
|
||||
"Icu", "GeneralMedicine", "Emergency", "Cardiology", "Surgery", "Pediatrics"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidEncounterTypes = new()
|
||||
{
|
||||
"Inpatient", "Outpatient", "Emergency"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidEventTypes = new()
|
||||
{
|
||||
"observation", "order", "medication", "order_result"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidOrderTypes = new()
|
||||
{
|
||||
"Lab", "Imaging", "Medication", "Procedure"
|
||||
};
|
||||
|
||||
private static readonly string[] SepsisBundleOrderPrefixes =
|
||||
[
|
||||
"SEP-1: Blood cultures",
|
||||
"SEP-1: Serum lactate",
|
||||
"SEP-1: Broad-spectrum antibiotics",
|
||||
"SEP-1: IV fluid bolus"
|
||||
];
|
||||
|
||||
public static List<string> Validate(ScenarioFile scenario)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scenario.Scenario.Id))
|
||||
errors.Add("scenario.id is required");
|
||||
if (string.IsNullOrWhiteSpace(scenario.Scenario.Name))
|
||||
errors.Add("scenario.name is required");
|
||||
if (string.IsNullOrWhiteSpace(scenario.Encounter.AttendingPhysician))
|
||||
errors.Add("encounter.attendingPhysician is required");
|
||||
if (!ValidDepartments.Contains(scenario.Encounter.Department))
|
||||
errors.Add($"encounter.department '{scenario.Encounter.Department}' is not valid");
|
||||
if (!ValidEncounterTypes.Contains(scenario.Encounter.EncounterType))
|
||||
errors.Add($"encounter.encounterType '{scenario.Encounter.EncounterType}' is not valid");
|
||||
if (scenario.Events.Count == 0)
|
||||
errors.Add("events array is empty");
|
||||
|
||||
foreach (var group in scenario.Events
|
||||
.Where(e => e.Type == "observation")
|
||||
.GroupBy(e => e.OffsetMinutes))
|
||||
{
|
||||
if (group.Count() > 10)
|
||||
errors.Add(
|
||||
$"offsetMinutes {group.Key}: {group.Count()} observations exceeds API batch limit of 10");
|
||||
}
|
||||
|
||||
var placedOrders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
double lastOffset = -1;
|
||||
for (int i = 0; i < scenario.Events.Count; i++)
|
||||
{
|
||||
var evt = scenario.Events[i];
|
||||
if (evt.OffsetMinutes < 0)
|
||||
errors.Add($"events[{i}]: offsetMinutes cannot be negative");
|
||||
if (evt.OffsetMinutes < lastOffset)
|
||||
errors.Add($"events[{i}]: offsetMinutes goes backwards ({evt.OffsetMinutes} < {lastOffset})");
|
||||
lastOffset = evt.OffsetMinutes;
|
||||
|
||||
if (!ValidEventTypes.Contains(evt.Type))
|
||||
errors.Add($"events[{i}]: unknown type '{evt.Type}'");
|
||||
|
||||
if (evt.Type == "observation")
|
||||
{
|
||||
var code = evt.Data.TryGetProperty("code", out var codeProp) ? codeProp.GetString() : null;
|
||||
if (code is null || !ValidCodes.Contains(code))
|
||||
errors.Add($"events[{i}]: unknown observation code '{code}'");
|
||||
|
||||
var source = evt.Data.TryGetProperty("source", out var sourceProp) ? sourceProp.GetString() : null;
|
||||
if (source is not null && !ValidSources.Contains(source))
|
||||
errors.Add($"events[{i}]: unknown observation source '{source}'");
|
||||
}
|
||||
|
||||
if (evt.Type == "order")
|
||||
{
|
||||
var description = evt.Data.TryGetProperty("description", out var descProp)
|
||||
? descProp.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
errors.Add($"events[{i}]: order.description is required");
|
||||
|
||||
var orderType = evt.Data.TryGetProperty("orderType", out var typeProp)
|
||||
? typeProp.GetString() : null;
|
||||
if (orderType is null || !ValidOrderTypes.Contains(orderType))
|
||||
errors.Add($"events[{i}]: order.orderType '{orderType}' is not valid");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
placedOrders.Add(description);
|
||||
}
|
||||
|
||||
if (evt.Type == "order_result")
|
||||
{
|
||||
var orderDescription = evt.Data.TryGetProperty("orderDescription", out var descProp)
|
||||
? descProp.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(orderDescription))
|
||||
{
|
||||
errors.Add($"events[{i}]: order_result.orderDescription is required");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!placedOrders.Contains(orderDescription)
|
||||
&& !IsSepsisBundleOrder(orderDescription)
|
||||
&& !placedOrders.Any(p => orderDescription.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
errors.Add(
|
||||
$"events[{i}]: order_result '{orderDescription}' has no matching prior 'order' event " +
|
||||
"(sepsis bundle orders are auto-created when SEPSIS_WARNING or QSOFA_WARNING fires)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static bool IsSepsisBundleOrder(string description) =>
|
||||
SepsisBundleOrderPrefixes.Any(p =>
|
||||
description.Equals(p, StringComparison.OrdinalIgnoreCase)
|
||||
|| description.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "VigilCare Clinical Scenario",
|
||||
"type": "object",
|
||||
"required": ["scenario", "patient", "encounter", "events"],
|
||||
"properties": {
|
||||
"scenario": {
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"durationMinutes": { "type": "integer", "minimum": 1 },
|
||||
"tags": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"patient": {
|
||||
"type": "object",
|
||||
"required": ["firstName", "lastName", "dateOfBirth", "gender"],
|
||||
"properties": {
|
||||
"firstName": { "type": "string" },
|
||||
"lastName": { "type": "string" },
|
||||
"dateOfBirth": { "type": "string", "format": "date" },
|
||||
"gender": { "type": "string", "enum": ["Male", "Female"] }
|
||||
}
|
||||
},
|
||||
"encounter": {
|
||||
"type": "object",
|
||||
"required": ["department", "encounterType", "attendingPhysician"],
|
||||
"properties": {
|
||||
"department": { "type": "string", "enum": ["Icu", "GeneralMedicine", "Emergency", "Cardiology", "Surgery", "Pediatrics"] },
|
||||
"encounterType": { "type": "string", "enum": ["Inpatient", "Outpatient", "Emergency"] },
|
||||
"attendingPhysician": { "type": "string" },
|
||||
"roomBed": { "type": "string" },
|
||||
"admissionReason": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["offsetMinutes", "type", "data"],
|
||||
"properties": {
|
||||
"offsetMinutes": { "type": "number", "minimum": 0 },
|
||||
"type": { "type": "string", "enum": ["observation", "order", "medication", "order_result"] },
|
||||
"data": { "type": "object" },
|
||||
"note": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"expectedOutcomes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["afterOffsetMinutes", "type"],
|
||||
"properties": {
|
||||
"afterOffsetMinutes": { "type": "number", "minimum": 0 },
|
||||
"type": { "type": "string", "enum": ["alert", "score", "bundle"] },
|
||||
"alertType": { "type": "string" },
|
||||
"scoreType": { "type": "string" },
|
||||
"expectedMinimum": { "type": "number" },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user