Run initial test for Climate Resilience Verification Suite
Add first part of Alert Quality Analytics
This commit is contained in:
@@ -27,6 +27,11 @@ public class VigilCareApiClient
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
public void SetBearerToken(string token)
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
public async Task<PatientResponse> RegisterPatientAsync(RegisterPatientRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
|
||||
@@ -44,8 +49,34 @@ public class VigilCareApiClient
|
||||
}
|
||||
|
||||
public async Task SendObservationBatchAsync(
|
||||
Guid encounterId, List<IngestObservationRequest> observations)
|
||||
Guid encounterId, List<IngestObservationRequest> observations,
|
||||
ReplayTarget target = ReplayTarget.Central)
|
||||
{
|
||||
if (target == ReplayTarget.Gateway)
|
||||
{
|
||||
foreach (var obs in observations)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = obs.ObservationCode,
|
||||
value = obs.Value,
|
||||
unit = obs.Unit,
|
||||
source = obs.Source,
|
||||
recordedAt = obs.RecordedAt,
|
||||
idempotencyKey = obs.IdempotencyKey ?? Guid.NewGuid().ToString()
|
||||
});
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Observation ingest failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var chunk in observations.Chunk(10))
|
||||
{
|
||||
var batch = new BatchIngestRequest(chunk.ToList());
|
||||
@@ -161,4 +192,50 @@ public class VigilCareApiClient
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<QsofaResponse>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<bool> TryAcknowledgeAlertAsync(
|
||||
Guid encounterId, string alertType, string clinicianId, string? note,
|
||||
TimeSpan? waitForAlert = null, CancellationToken ct = default)
|
||||
{
|
||||
const int pollIntervalMs = 500;
|
||||
var deadline = waitForAlert.HasValue
|
||||
? DateTimeOffset.UtcNow.Add(waitForAlert.Value)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var alerts = await GetAlertsAsync(encounterId);
|
||||
var alert = alerts.FirstOrDefault(a =>
|
||||
IsMatchingAlertType(a.AlertType, alertType)
|
||||
&& IsOpenAlertStatus(a.Status));
|
||||
if (alert is not null)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"/api/v1/alerts/{alert.Id}/acknowledge",
|
||||
new { note, clinicianId }, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
if (!deadline.HasValue || DateTimeOffset.UtcNow >= deadline.Value)
|
||||
return false;
|
||||
|
||||
await Task.Delay(pollIntervalMs, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsOpenAlertStatus(string status) =>
|
||||
string.Equals(status, "OPEN", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsMatchingAlertType(string actual, string expected)
|
||||
{
|
||||
if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
static string Normalize(string s) =>
|
||||
s.Replace("_", "", StringComparison.Ordinal).ToUpperInvariant();
|
||||
|
||||
return Normalize(actual) == Normalize(expected);
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,35 @@ public static class ReplayCommand
|
||||
var pollIntervalOpt = new Option<int>("--poll-interval", () => 5, "Seconds between polls");
|
||||
var usernameOpt = new Option<string>("--username", () => "physician.demo", "API login username");
|
||||
var passwordOpt = new Option<string>("--password", () => "DemoPhysician1!", "API login password");
|
||||
var gatewayOpt = new Option<bool>("--gateway", () => false,
|
||||
"Target ward gateway API (default base URL http://localhost:5081)");
|
||||
var encounterIdOpt = new Option<Guid?>("--encounter-id",
|
||||
"Use existing encounter (required for --gateway when replica already synced)");
|
||||
var skipSetupOpt = new Option<bool>("--skip-setup", () => false,
|
||||
"Skip patient/encounter registration — use --encounter-id");
|
||||
var gatewayTokenOpt = new Option<string?>("--gateway-token",
|
||||
"Bearer token for ward gateway (default: GATEWAY_JWT env var when --gateway)");
|
||||
|
||||
var command = new Command("replay", "Replay a scenario against the API")
|
||||
{
|
||||
fileArg, speedOpt, baseUrlOpt, pollOpt, pollIntervalOpt, usernameOpt, passwordOpt
|
||||
fileArg, speedOpt, baseUrlOpt, pollOpt, pollIntervalOpt, usernameOpt, passwordOpt,
|
||||
gatewayOpt, encounterIdOpt, skipSetupOpt, gatewayTokenOpt
|
||||
};
|
||||
|
||||
command.SetHandler(async (FileInfo file, double speed, string baseUrl, bool poll, int pollInterval,
|
||||
string username, string password) =>
|
||||
command.SetHandler(async context =>
|
||||
{
|
||||
var file = context.ParseResult.GetValueForArgument(fileArg);
|
||||
var speed = context.ParseResult.GetValueForOption(speedOpt);
|
||||
var baseUrl = context.ParseResult.GetValueForOption(baseUrlOpt)!;
|
||||
var poll = context.ParseResult.GetValueForOption(pollOpt);
|
||||
var pollInterval = context.ParseResult.GetValueForOption(pollIntervalOpt);
|
||||
var username = context.ParseResult.GetValueForOption(usernameOpt)!;
|
||||
var password = context.ParseResult.GetValueForOption(passwordOpt)!;
|
||||
var gateway = context.ParseResult.GetValueForOption(gatewayOpt);
|
||||
var encounterId = context.ParseResult.GetValueForOption(encounterIdOpt);
|
||||
var skipSetup = context.ParseResult.GetValueForOption(skipSetupOpt);
|
||||
var gatewayToken = context.ParseResult.GetValueForOption(gatewayTokenOpt);
|
||||
|
||||
var scenario = ScenarioLoader.Load(file.FullName);
|
||||
var errors = ScenarioValidator.Validate(scenario);
|
||||
if (errors.Count > 0)
|
||||
@@ -29,19 +49,51 @@ public static class ReplayCommand
|
||||
return;
|
||||
}
|
||||
|
||||
using var http = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
if (skipSetup && !encounterId.HasValue)
|
||||
{
|
||||
SimulatorConsole.Error("--skip-setup requires --encounter-id");
|
||||
return;
|
||||
}
|
||||
|
||||
if (gateway && !encounterId.HasValue)
|
||||
{
|
||||
SimulatorConsole.Error("--gateway requires --encounter-id");
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveBaseUrl = gateway ? "http://localhost:5081" : baseUrl;
|
||||
using var http = new HttpClient { BaseAddress = new Uri(effectiveBaseUrl) };
|
||||
var client = new VigilCareApiClient(http);
|
||||
|
||||
SimulatorConsole.Info($"Authenticating as {username}...");
|
||||
await client.LoginAsync(username, password);
|
||||
SimulatorConsole.Info("Authenticated.");
|
||||
if (gateway)
|
||||
{
|
||||
var token = gatewayToken ?? Environment.GetEnvironmentVariable("GATEWAY_JWT");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
SimulatorConsole.Error(
|
||||
"Gateway mode requires --gateway-token or GATEWAY_JWT (ward gateway has no /auth/login endpoint)");
|
||||
return;
|
||||
}
|
||||
|
||||
client.SetBearerToken(token);
|
||||
SimulatorConsole.Info("Using gateway bearer token.");
|
||||
}
|
||||
else
|
||||
{
|
||||
SimulatorConsole.Info($"Authenticating as {username}...");
|
||||
await client.LoginAsync(username, password);
|
||||
SimulatorConsole.Info("Authenticated.");
|
||||
}
|
||||
|
||||
var poller = poll ? new ApiPoller(client) : null;
|
||||
var engine = new ReplayEngine(client, poller);
|
||||
var options = new ReplayOptions(speed, poll, pollInterval);
|
||||
var options = new ReplayOptions(
|
||||
speed, poll, pollInterval, DryRun: false,
|
||||
Target: gateway ? ReplayTarget.Gateway : ReplayTarget.Central,
|
||||
ExistingEncounterId: encounterId);
|
||||
|
||||
await engine.RunAsync(scenario, options);
|
||||
}, fileArg, speedOpt, baseUrlOpt, pollOpt, pollIntervalOpt, usernameOpt, passwordOpt);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
@@ -20,25 +20,43 @@ public class ReplayEngine
|
||||
// --- 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}");
|
||||
if (options.ExistingEncounterId.HasValue)
|
||||
{
|
||||
result.EncounterId = options.ExistingEncounterId.Value;
|
||||
if (options.Target == ReplayTarget.Gateway)
|
||||
SimulatorConsole.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
|
||||
else
|
||||
SimulatorConsole.DryRun($"Would use existing encounter {result.EncounterId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
SimulatorConsole.DryRun("Would register patient: " +
|
||||
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
|
||||
SimulatorConsole.DryRun("Would open encounter: " +
|
||||
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
|
||||
}
|
||||
}
|
||||
else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue)
|
||||
{
|
||||
result.EncounterId = options.ExistingEncounterId.Value;
|
||||
SimulatorConsole.Info($"Gateway mode — using existing encounter {result.EncounterId}");
|
||||
}
|
||||
else if (options.ExistingEncounterId.HasValue)
|
||||
{
|
||||
result.EncounterId = options.ExistingEncounterId.Value;
|
||||
SimulatorConsole.Info($"Using existing encounter {result.EncounterId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
patient = await _client.RegisterPatientAsync(new RegisterPatientRequest(
|
||||
var 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(
|
||||
var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
|
||||
scenario.Encounter.EncounterType,
|
||||
DepartmentMapper.ToApiDepartment(scenario.Encounter.Department),
|
||||
scenario.Encounter.AttendingPhysician,
|
||||
@@ -91,6 +109,9 @@ public class ReplayEngine
|
||||
case "order_result":
|
||||
await ReplayOrderResult(evt, simTimestamp, options, result);
|
||||
break;
|
||||
case "alert_ack":
|
||||
await ReplayAlertAck(evt, simTimestamp, options, result, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +136,9 @@ public class ReplayEngine
|
||||
{
|
||||
var observations = new List<IngestObservationRequest>();
|
||||
var offsetMinutes = cluster[0].OffsetMinutes;
|
||||
var recordedAt = _scenarioStartTime.AddMinutes(offsetMinutes);
|
||||
var recordedAt = options.Target == ReplayTarget.Gateway
|
||||
? DateTimeOffset.UtcNow
|
||||
: _scenarioStartTime.AddMinutes(offsetMinutes);
|
||||
|
||||
foreach (var evt in cluster)
|
||||
{
|
||||
@@ -135,7 +158,7 @@ public class ReplayEngine
|
||||
}
|
||||
|
||||
if (!options.DryRun && observations.Count > 0)
|
||||
await _client.SendObservationBatchAsync(result.EncounterId, observations);
|
||||
await _client.SendObservationBatchAsync(result.EncounterId, observations, options.Target);
|
||||
}
|
||||
|
||||
private async Task ReplayMedication(
|
||||
@@ -238,4 +261,30 @@ public class ReplayEngine
|
||||
"Lab" or "LAB" or "lab" => "LAB",
|
||||
_ => throw new InvalidOperationException($"Unknown observation source: '{source}'")
|
||||
};
|
||||
|
||||
private async Task ReplayAlertAck(
|
||||
ScenarioEvent evt, string simTime, ReplayOptions options, ReplayResult result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var alertType = evt.Data.GetProperty("alertType").GetString()!;
|
||||
var clinicianId = evt.Data.GetProperty("clinicianId").GetString()!;
|
||||
var note = evt.Data.TryGetProperty("note", out var n) ? n.GetString() : null;
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
SimulatorConsole.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var waitForAlert = options.Target == ReplayTarget.Central
|
||||
? TimeSpan.FromSeconds(30)
|
||||
: (TimeSpan?)null;
|
||||
|
||||
var ok = await _client.TryAcknowledgeAlertAsync(
|
||||
result.EncounterId, alertType, clinicianId, note, waitForAlert, ct);
|
||||
if (ok)
|
||||
SimulatorConsole.Event(simTime, $"ACK {alertType} by {clinicianId}");
|
||||
else
|
||||
SimulatorConsole.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
public enum ReplayTarget { Central, Gateway }
|
||||
|
||||
public record ReplayOptions(
|
||||
double Speed = 60,
|
||||
bool Poll = false,
|
||||
int PollIntervalSeconds = 5,
|
||||
bool DryRun = false);
|
||||
bool DryRun = false,
|
||||
ReplayTarget Target = ReplayTarget.Central,
|
||||
Guid? ExistingEncounterId = null);
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"scenario": {
|
||||
"id": "ward-outage-reconnect-01",
|
||||
"name": "ICU Ward Isolation — Critical Potassium During Uplink Loss",
|
||||
"description": "Post-surgical ICU patient with stable vitals, then critical hyperkalemia during simulated central link loss. Nurse acknowledges on ward gateway. Reconnect syncs to central without duplicate pages.",
|
||||
"durationMinutes": 90,
|
||||
"tags": ["climate-resilience", "gateway", "critical-value", "sync", "potassium"]
|
||||
},
|
||||
"patient": {
|
||||
"firstName": "James",
|
||||
"lastName": "Wu",
|
||||
"dateOfBirth": "1968-11-02",
|
||||
"gender": "Male"
|
||||
},
|
||||
"encounter": {
|
||||
"department": "Icu",
|
||||
"encounterType": "Inpatient",
|
||||
"attendingPhysician": "Dr. Elena Park",
|
||||
"roomBed": "ICU-3B-12",
|
||||
"admissionReason": "Post-op monitoring — abdominal surgery"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 82, "unit": "bpm", "source": "Device" },
|
||||
"note": "Baseline vitals — stable post-op ICU admission"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "Manual" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "SPO2", "value": 98, "unit": "%", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": { "code": "POTASSIUM_MEQ_L", "value": 4.2, "unit": "mEq/L", "source": "Lab" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 45,
|
||||
"type": "observation",
|
||||
"data": { "code": "POTASSIUM_MEQ_L", "value": 6.8, "unit": "mEq/L", "source": "Lab" },
|
||||
"note": "Critical hyperkalemia — must alert locally even if central is down"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 50,
|
||||
"type": "alert_ack",
|
||||
"data": {
|
||||
"alertType": "CRITICAL_POTASSIUM_MEQ_L",
|
||||
"clinicianId": "RN-Wu",
|
||||
"note": "Calcium gluconate ordered, ECG at bedside"
|
||||
}
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "observation",
|
||||
"data": { "code": "HEART_RATE", "value": 78, "unit": "bpm", "source": "Device" }
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 75,
|
||||
"type": "observation",
|
||||
"data": { "code": "POTASSIUM_MEQ_L", "value": 5.4, "unit": "mEq/L", "source": "Lab" },
|
||||
"note": "Repeat lab — improving after treatment"
|
||||
}
|
||||
],
|
||||
"expectedOutcomes": [
|
||||
{
|
||||
"afterOffsetMinutes": 45,
|
||||
"type": "alert",
|
||||
"alertType": "CRITICAL_POTASSIUM_MEQ_L",
|
||||
"description": "Critical potassium alert fires at K+ 6.8 mEq/L — locally on gateway during outage"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 50,
|
||||
"type": "alert",
|
||||
"alertType": "CRITICAL_POTASSIUM_MEQ_L",
|
||||
"description": "Alert acknowledged by RN-Wu — buffered for sync to central"
|
||||
},
|
||||
{
|
||||
"afterOffsetMinutes": 75,
|
||||
"type": "score",
|
||||
"scoreType": "NEWS2",
|
||||
"expectedMinimum": 0,
|
||||
"description": "NEWS2 unavailable on gateway during outage — replays on central after sync (Tier 3 deferred)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public static class ScenarioValidator
|
||||
|
||||
private static readonly HashSet<string> ValidEventTypes = new()
|
||||
{
|
||||
"observation", "order", "medication", "order_result"
|
||||
"observation", "order", "medication", "order_result", "alert_ack"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidOrderTypes = new()
|
||||
@@ -131,6 +131,19 @@ public static class ScenarioValidator
|
||||
"(sepsis bundle orders are auto-created when SOFA_SEPSIS or QSOFA_SCREEN fires)");
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.Type == "alert_ack")
|
||||
{
|
||||
var alertType = evt.Data.TryGetProperty("alertType", out var alertTypeProp)
|
||||
? alertTypeProp.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(alertType))
|
||||
errors.Add($"events[{i}]: alert_ack.alertType is required");
|
||||
|
||||
var clinicianId = evt.Data.TryGetProperty("clinicianId", out var clinicianProp)
|
||||
? clinicianProp.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(clinicianId))
|
||||
errors.Add($"events[{i}]: alert_ack.clinicianId is required");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"required": ["offsetMinutes", "type", "data"],
|
||||
"properties": {
|
||||
"offsetMinutes": { "type": "number", "minimum": 0 },
|
||||
"type": { "type": "string", "enum": ["observation", "order", "medication", "order_result"] },
|
||||
"type": { "type": "string", "enum": ["observation", "order", "order_result", "medication", "alert_ack"] },
|
||||
"data": { "type": "object" },
|
||||
"note": { "type": "string" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user