Run initial test for Climate Resilience Verification Suite

Add first part of Alert Quality Analytics
This commit is contained in:
voltsrage
2026-06-24 03:01:00 +08:00
parent 032cd1d240
commit 185dc93fa1
50 changed files with 4116 additions and 55 deletions
@@ -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);
}
}
+61 -9
View File
@@ -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;
}
+60 -11
View File
@@ -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");
}
}
+5 -1
View File
@@ -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;
+1 -1
View File
@@ -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" }
}