feature: In-App Simulation Runner (Backend)
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public class AlertExplanation
|
||||
{
|
||||
public List<ScoreContributor> ScoreContributors { get; set; } = new();
|
||||
public TrendContext? Trend { get; set; }
|
||||
public MedicationContext? MedicationContext { get; set; }
|
||||
public string NarrativeSummary { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ScoreContributor
|
||||
{
|
||||
public string Parameter { get; set; } = string.Empty;
|
||||
public int Points { get; set; }
|
||||
public string? RawValue { get; set; }
|
||||
public string? NormalRange { get; set; }
|
||||
}
|
||||
|
||||
public class TrendContext
|
||||
{
|
||||
public string Parameter { get; set; } = string.Empty;
|
||||
public double PercentChange { get; set; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
public string Direction { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class MedicationContext
|
||||
{
|
||||
public string DrugName { get; set; } = string.Empty;
|
||||
public string Dose { get; set; } = string.Empty;
|
||||
public string Route { get; set; } = string.Empty;
|
||||
public DateTimeOffset AdministeredAt { get; set; }
|
||||
public string? RelevanceNote { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record AlertResponse(
|
||||
Guid Id,
|
||||
Guid EncounterId,
|
||||
Guid PatientId,
|
||||
string AlertType,
|
||||
string Severity,
|
||||
string Details,
|
||||
string Status,
|
||||
DateTimeOffset TriggeredAt,
|
||||
DateTimeOffset? AcknowledgedAt = null,
|
||||
string? AcknowledgedBy = null,
|
||||
DateTimeOffset? ResolvedAt = null,
|
||||
AlertExplanation? Explanation = null)
|
||||
{
|
||||
public string DisplaySummary =>
|
||||
string.IsNullOrWhiteSpace(Explanation?.NarrativeSummary)
|
||||
? Details
|
||||
: Explanation!.NarrativeSummary;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error);
|
||||
public record ApiError(string Message, string Code);
|
||||
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record BatchIngestRequest(List<IngestObservationRequest> Observations);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record CreateMedicationAdministrationRequest(
|
||||
string DrugName, decimal Dose, string DoseUnit, string Route,
|
||||
DateTimeOffset? AdministeredAt, string AdministeredBy);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record CreateOrderRequest(string OrderType, string Description, string OrderedBy);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
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 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record GcsResponse(
|
||||
int EyeScore, int VerbalScore, int MotorScore,
|
||||
int TotalScore, string Classification, DateTimeOffset CalculatedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
|
||||
public record IngestObservationRequest(
|
||||
string ObservationCode, decimal Value, string Unit,
|
||||
string Source, DateTimeOffset RecordedAt, string? IdempotencyKey);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record SimLoginRequest(string Username, string Password);
|
||||
|
||||
public record SimLoginResponse(
|
||||
string AccessToken,
|
||||
DateTimeOffset ExpiresAt,
|
||||
Guid UserId,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string Role);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
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,6 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
|
||||
public record OpenEncounterRequest(
|
||||
string EncounterType, string Department, string AttendingPhysician,
|
||||
string? RoomBed = null, string? AdmissionReason = null);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record OrderResponse(Guid Id, string Description, string Status);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record PatientResponse(
|
||||
Guid Id, string Mrn, string FirstName, string LastName,
|
||||
DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record QsofaResponse(int ActiveCriteria);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record RecordOrderResultRequest(string? ResultSummary);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record RegisterPatientRequest(
|
||||
string FirstName, string LastName, DateOnly DateOfBirth, string Gender);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
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,9 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record SofaResponse(
|
||||
int TotalScore,
|
||||
int RespiratoryScore, int CoagulationScore, int LiverScore,
|
||||
int CardiovascularScore, int CnsScore, int RenalScore,
|
||||
bool IsBaseline, int? DeltaFromBaseline,
|
||||
SofaStalenessResponse? Staleness,
|
||||
DateTimeOffset CalculatedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public record SofaStalenessResponse(
|
||||
IReadOnlyList<string> StaleComponents,
|
||||
IReadOnlyList<string> MissingComponents,
|
||||
bool UsedSpO2Fallback);
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public class VigilCareApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public VigilCareApiClient(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task LoginAsync(string username, string password)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new SimLoginRequest(username, password));
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Login failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SimLoginResponse>>();
|
||||
var token = envelope!.Data!.AccessToken;
|
||||
_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);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Register patient failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
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,
|
||||
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());
|
||||
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>>>(ApiJsonOptions);
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task<GcsResponse?> GetCurrentGcsAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/gcs");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<GcsResponse>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<SofaResponse?> GetCurrentSofaAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/sofa");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SofaResponse>>();
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
public async Task<QsofaResponse?> GetCurrentQsofaAsync(Guid encounterId)
|
||||
{
|
||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/qsofa/current");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Optional post-cluster polling hook. Console hosts display scores/alerts;
|
||||
/// the API host passes null — polling is a display concern.
|
||||
/// </summary>
|
||||
public interface IApiPoller
|
||||
{
|
||||
Task PollAndDisplayAsync(Guid encounterId, string simTime);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public interface IReplayObserver
|
||||
{
|
||||
void Header(string name, string? description);
|
||||
void Info(string message);
|
||||
void Event(string simTime, string description);
|
||||
void Waiting(double deltaMinutes, int delayMs);
|
||||
void Warn(string message);
|
||||
void Error(string message);
|
||||
void DryRun(string message);
|
||||
void Completed(ReplayResult result);
|
||||
|
||||
/// <summary>Fired after each cluster so hosts can report progress.</summary>
|
||||
void Progress(double offsetMinutes, int clusterIndex, int clusterCount);
|
||||
}
|
||||
|
||||
public sealed class NullReplayObserver : IReplayObserver
|
||||
{
|
||||
public static readonly NullReplayObserver Instance = new();
|
||||
|
||||
public void Header(string name, string? description) { }
|
||||
public void Info(string message) { }
|
||||
public void Event(string simTime, string description) { }
|
||||
public void Waiting(double deltaMinutes, int delayMs) { }
|
||||
public void Warn(string message) { }
|
||||
public void Error(string message) { }
|
||||
public void DryRun(string message) { }
|
||||
public void Completed(ReplayResult result) { }
|
||||
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) { }
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public class ReplayEngine
|
||||
{
|
||||
private readonly VigilCareApiClient _client;
|
||||
private readonly IApiPoller? _poller;
|
||||
private readonly IReplayObserver _observer;
|
||||
private readonly Func<Guid, CancellationToken, Task>? _onPatientRegistered;
|
||||
private DateTimeOffset _scenarioStartTime;
|
||||
|
||||
public ReplayEngine(
|
||||
VigilCareApiClient client,
|
||||
IApiPoller? poller,
|
||||
IReplayObserver? observer = null,
|
||||
Func<Guid, CancellationToken, Task>? onPatientRegistered = null)
|
||||
{
|
||||
_client = client;
|
||||
_poller = poller;
|
||||
_observer = observer ?? NullReplayObserver.Instance;
|
||||
_onPatientRegistered = onPatientRegistered;
|
||||
}
|
||||
|
||||
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 ---
|
||||
_observer.Header(scenario.Scenario.Name, scenario.Scenario.Description);
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
if (options.ExistingEncounterId.HasValue)
|
||||
{
|
||||
result.EncounterId = options.ExistingEncounterId.Value;
|
||||
if (options.Target == ReplayTarget.Gateway)
|
||||
_observer.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
|
||||
else
|
||||
_observer.DryRun($"Would use existing encounter {result.EncounterId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_observer.DryRun("Would register patient: " +
|
||||
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
|
||||
_observer.DryRun("Would open encounter: " +
|
||||
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
|
||||
}
|
||||
}
|
||||
else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue)
|
||||
{
|
||||
result.EncounterId = options.ExistingEncounterId.Value;
|
||||
_observer.Info($"Gateway mode — using existing encounter {result.EncounterId}");
|
||||
}
|
||||
else if (options.ExistingEncounterId.HasValue)
|
||||
{
|
||||
result.EncounterId = options.ExistingEncounterId.Value;
|
||||
_observer.Info($"Using existing encounter {result.EncounterId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var patient = await _client.RegisterPatientAsync(new RegisterPatientRequest(
|
||||
scenario.Patient.FirstName,
|
||||
scenario.Patient.LastName,
|
||||
DateOnly.Parse(scenario.Patient.DateOfBirth),
|
||||
scenario.Patient.Gender));
|
||||
|
||||
if (_onPatientRegistered is not null)
|
||||
await _onPatientRegistered(patient.Id, ct);
|
||||
|
||||
var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
|
||||
scenario.Encounter.EncounterType,
|
||||
DepartmentMapper.ToApiDepartment(scenario.Encounter.Department),
|
||||
scenario.Encounter.AttendingPhysician,
|
||||
scenario.Encounter.RoomBed,
|
||||
scenario.Encounter.AdmissionReason));
|
||||
|
||||
_observer.Info($"Patient registered: {patient.Id} ({patient.Mrn})");
|
||||
_observer.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();
|
||||
|
||||
for (var clusterIndex = 0; clusterIndex < clusters.Count; clusterIndex++)
|
||||
{
|
||||
var cluster = clusters[clusterIndex];
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var deltaMinutes = cluster.Key - lastOffset;
|
||||
if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun)
|
||||
{
|
||||
var delayMs = (int)(deltaMinutes * 60_000 / options.Speed);
|
||||
_observer.Waiting(deltaMinutes, delayMs);
|
||||
await Task.Delay(delayMs, ct);
|
||||
}
|
||||
|
||||
var simTimestamp = FormatSimTime(cluster.Key);
|
||||
var observationEvents = cluster.Where(e => e.Type == "observation").ToList();
|
||||
var otherEvents = cluster.Where(e => e.Type != "observation").ToList();
|
||||
|
||||
if (observationEvents.Count > 0)
|
||||
await ReplayObservationCluster(observationEvents, simTimestamp, options, result);
|
||||
|
||||
foreach (var evt in otherEvents)
|
||||
{
|
||||
switch (evt.Type)
|
||||
{
|
||||
case "order":
|
||||
await ReplayOrder(
|
||||
evt, simTimestamp, options, result, scenario.Encounter.AttendingPhysician);
|
||||
break;
|
||||
case "medication":
|
||||
await ReplayMedication(evt, simTimestamp, options, result);
|
||||
break;
|
||||
case "order_result":
|
||||
await ReplayOrderResult(evt, simTimestamp, options, result);
|
||||
break;
|
||||
case "alert_ack":
|
||||
await ReplayAlertAck(evt, simTimestamp, options, result, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lastOffset = cluster.Key;
|
||||
_observer.Progress(cluster.Key, clusterIndex, clusters.Count);
|
||||
|
||||
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;
|
||||
|
||||
if (!options.DryRun
|
||||
&& options.Target == ReplayTarget.Central
|
||||
&& scenario.ExpectedOutcomes is { Count: > 0 })
|
||||
{
|
||||
var failures = await ExpectedOutcomeValidator.ValidateAsync(
|
||||
_client, result.EncounterId, scenario, ct);
|
||||
result.HadExpectedOutcomes = true;
|
||||
result.OutcomeFailures.AddRange(failures);
|
||||
foreach (var failure in failures)
|
||||
_observer.Error(failure);
|
||||
}
|
||||
|
||||
_observer.Completed(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task ReplayObservationCluster(
|
||||
List<ScenarioEvent> cluster, string simTime, ReplayOptions options,
|
||||
ReplayResult result)
|
||||
{
|
||||
var observations = new List<IngestObservationRequest>();
|
||||
var offsetMinutes = cluster[0].OffsetMinutes;
|
||||
var recordedAt = options.Target == ReplayTarget.Gateway
|
||||
? DateTimeOffset.UtcNow
|
||||
: _scenarioStartTime.AddMinutes(offsetMinutes);
|
||||
|
||||
foreach (var evt in cluster)
|
||||
{
|
||||
var code = evt.Data.GetProperty("code").GetString()!;
|
||||
var value = evt.Data.GetProperty("value").GetDecimal();
|
||||
var unit = evt.Data.GetProperty("unit").GetString()!;
|
||||
var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual";
|
||||
|
||||
_observer.Event(simTime, $"{code} {value} {unit}");
|
||||
result.ObservationsSent++;
|
||||
|
||||
if (!options.DryRun)
|
||||
{
|
||||
observations.Add(new IngestObservationRequest(
|
||||
code, value, unit, ToApiSource(source), recordedAt, null));
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.DryRun && observations.Count > 0)
|
||||
await _client.SendObservationBatchAsync(result.EncounterId, observations, options.Target);
|
||||
}
|
||||
|
||||
private async Task ReplayMedication(
|
||||
ScenarioEvent evt, string simTime, ReplayOptions options, ReplayResult result)
|
||||
{
|
||||
var drugName = evt.Data.GetProperty("drugName").GetString()!;
|
||||
var dose = evt.Data.GetProperty("dose").GetDecimal();
|
||||
var doseUnit = evt.Data.GetProperty("doseUnit").GetString()!;
|
||||
var route = evt.Data.GetProperty("route").GetString()!;
|
||||
var administeredBy = evt.Data.GetProperty("administeredBy").GetString()!;
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
_observer.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)
|
||||
{
|
||||
_observer.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent");
|
||||
result.MedicationsSent++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_observer.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)
|
||||
{
|
||||
_observer.DryRun($"[{simTime}] ORDER {orderType}: {description}");
|
||||
return;
|
||||
}
|
||||
|
||||
var placed = await _client.TryCreateOrderAsync(result.EncounterId,
|
||||
new CreateOrderRequest(orderType, description, orderedBy));
|
||||
|
||||
if (placed)
|
||||
{
|
||||
_observer.Event(simTime, $"ORDER {description} placed");
|
||||
result.OrdersPlaced++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_observer.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)
|
||||
{
|
||||
_observer.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}");
|
||||
return;
|
||||
}
|
||||
|
||||
var ok = await _client.TryResultOrderAsync(
|
||||
result.EncounterId, orderDesc, resultSummary);
|
||||
|
||||
if (ok)
|
||||
{
|
||||
_observer.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted");
|
||||
result.OrdersResulted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_observer.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSimTime(double offsetMinutes)
|
||||
{
|
||||
var hours = (int)(offsetMinutes / 60);
|
||||
var mins = (int)(offsetMinutes % 60);
|
||||
return $"{hours:D2}:{mins:D2}";
|
||||
}
|
||||
|
||||
private static string ToApiSource(string source) => source switch
|
||||
{
|
||||
"Device" or "DEVICE" or "device" or "monitor" => "DEVICE",
|
||||
"Manual" or "MANUAL" or "manual" => "MANUAL",
|
||||
"Lab" or "LAB" or "lab" => "LAB",
|
||||
_ => throw new InvalidOperationException($"Unknown observation source: '{source}'")
|
||||
};
|
||||
|
||||
private async Task ReplayAlertAck(
|
||||
ScenarioEvent evt, string simTime, ReplayOptions options, ReplayResult result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var alertType = evt.Data.GetProperty("alertType").GetString()!;
|
||||
var clinicianId = evt.Data.GetProperty("clinicianId").GetString()!;
|
||||
var note = evt.Data.TryGetProperty("note", out var n) ? n.GetString() : null;
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
_observer.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)
|
||||
_observer.Event(simTime, $"ACK {alertType} by {clinicianId}");
|
||||
else
|
||||
_observer.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public enum ReplayTarget { Central, Gateway }
|
||||
|
||||
public record ReplayOptions(
|
||||
double Speed = 60,
|
||||
bool Poll = false,
|
||||
int PollIntervalSeconds = 5,
|
||||
bool DryRun = false,
|
||||
ReplayTarget Target = ReplayTarget.Central,
|
||||
Guid? ExistingEncounterId = null);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
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 List<string> OutcomeFailures { get; } = new();
|
||||
public bool HadExpectedOutcomes { get; set; }
|
||||
|
||||
public bool OutcomesPassed => OutcomeFailures.Count == 0;
|
||||
|
||||
public ReplayResult(string scenarioId) => ScenarioId = scenarioId;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public static class DepartmentMapper
|
||||
{
|
||||
private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Icu"] = "ICU",
|
||||
["GeneralMedicine"] = "GENERAL_MEDICINE",
|
||||
["Emergency"] = "EMERGENCY",
|
||||
["Cardiology"] = "CARDIOLOGY",
|
||||
["Surgery"] = "SURGERY",
|
||||
["Pediatrics"] = "PEDIATRICS",
|
||||
};
|
||||
|
||||
public static string ToApiDepartment(string scenarioDepartment)
|
||||
{
|
||||
if (ScenarioToApi.TryGetValue(scenarioDepartment, out var apiValue))
|
||||
return apiValue;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown scenario department '{scenarioDepartment}'. Expected one of: {string.Join(", ", ScenarioToApi.Keys)}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
public static class ExpectedOutcomeValidator
|
||||
{
|
||||
private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8);
|
||||
|
||||
public static async Task<List<string>> ValidateAsync(
|
||||
VigilCareApiClient client,
|
||||
Guid encounterId,
|
||||
ScenarioFile scenario,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (scenario.ExpectedOutcomes is not { Count: > 0 })
|
||||
return [];
|
||||
|
||||
await Task.Delay(AsyncSettleDelay, ct);
|
||||
|
||||
var failures = new List<string>();
|
||||
var alerts = await client.GetAlertsAsync(encounterId);
|
||||
|
||||
foreach (var outcome in scenario.ExpectedOutcomes)
|
||||
{
|
||||
switch (outcome.Type)
|
||||
{
|
||||
case "alert":
|
||||
failures.AddRange(ValidateAlertOutcome(outcome, alerts, scenario.Scenario.Id));
|
||||
break;
|
||||
case "score":
|
||||
failures.AddRange(await ValidateScoreOutcomeAsync(client, encounterId, outcome, scenario.Scenario.Id, ct));
|
||||
break;
|
||||
case "bundle":
|
||||
failures.AddRange(await ValidateBundleOutcomeAsync(client, encounterId, outcome, scenario.Scenario.Id, ct));
|
||||
break;
|
||||
default:
|
||||
failures.Add($"[{scenario.Scenario.Id}] Unknown expected outcome type '{outcome.Type}'");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ValidateAlertOutcome(
|
||||
ExpectedOutcome outcome, List<AlertResponse> alerts, string scenarioId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outcome.AlertType))
|
||||
{
|
||||
yield return $"[{scenarioId}] Alert outcome at T+{outcome.AfterOffsetMinutes}m missing alertType";
|
||||
yield break;
|
||||
}
|
||||
|
||||
var matching = alerts
|
||||
.Where(a => string.Equals(a.AlertType, outcome.AlertType, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (matching.Count == 0)
|
||||
{
|
||||
yield return
|
||||
$"[{scenarioId}] Expected alert {outcome.AlertType} at T+{outcome.AfterOffsetMinutes}m — not found " +
|
||||
$"(description: {outcome.Description ?? "n/a"})";
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outcome.NarrativeContains))
|
||||
{
|
||||
var withNarrative = matching.FirstOrDefault(a =>
|
||||
a.Explanation?.NarrativeSummary?.Contains(
|
||||
outcome.NarrativeContains, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (withNarrative is null)
|
||||
{
|
||||
var summaries = matching
|
||||
.Select(a => a.Explanation?.NarrativeSummary ?? a.Details)
|
||||
.Take(2);
|
||||
yield return
|
||||
$"[{scenarioId}] Alert {outcome.AlertType} at T+{outcome.AfterOffsetMinutes}m — " +
|
||||
$"narrative does not contain '{outcome.NarrativeContains}' " +
|
||||
$"(got: {string.Join(" | ", summaries)})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<string>> ValidateScoreOutcomeAsync(
|
||||
VigilCareApiClient client,
|
||||
Guid encounterId,
|
||||
ExpectedOutcome outcome,
|
||||
string scenarioId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outcome.ScoreType))
|
||||
return [$"[{scenarioId}] Score outcome at T+{outcome.AfterOffsetMinutes}m missing scoreType"];
|
||||
|
||||
if (!outcome.ExpectedMinimum.HasValue)
|
||||
return [$"[{scenarioId}] Score outcome at T+{outcome.AfterOffsetMinutes}m missing expectedMinimum"];
|
||||
|
||||
var actual = await GetScoreAsync(client, encounterId, outcome.ScoreType, ct);
|
||||
if (actual is null)
|
||||
{
|
||||
return
|
||||
[
|
||||
$"[{scenarioId}] Expected {outcome.ScoreType} ≥ {outcome.ExpectedMinimum} at T+{outcome.AfterOffsetMinutes}m — score not available"
|
||||
];
|
||||
}
|
||||
|
||||
if (actual.Value < outcome.ExpectedMinimum.Value)
|
||||
{
|
||||
return
|
||||
[
|
||||
$"[{scenarioId}] Expected {outcome.ScoreType} ≥ {outcome.ExpectedMinimum} at T+{outcome.AfterOffsetMinutes}m — got {actual.Value} " +
|
||||
$"(description: {outcome.Description ?? "n/a"})"
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<string>> ValidateBundleOutcomeAsync(
|
||||
VigilCareApiClient client,
|
||||
Guid encounterId,
|
||||
ExpectedOutcome outcome,
|
||||
string scenarioId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var bundle = await client.GetSepsisBundleAsync(encounterId);
|
||||
if (bundle is null)
|
||||
{
|
||||
return
|
||||
[
|
||||
$"[{scenarioId}] Expected sepsis bundle at T+{outcome.AfterOffsetMinutes}m — bundle not found " +
|
||||
$"(description: {outcome.Description ?? "n/a"})"
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static async Task<int?> GetScoreAsync(
|
||||
VigilCareApiClient client,
|
||||
Guid encounterId,
|
||||
string scoreType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return scoreType.ToUpperInvariant() switch
|
||||
{
|
||||
"NEWS2" => (await client.GetCurrentNews2Async(encounterId))?.TotalScore,
|
||||
"GCS" => (await client.GetCurrentGcsAsync(encounterId))?.TotalScore,
|
||||
"SOFA" => (await client.GetCurrentSofaAsync(encounterId))?.TotalScore,
|
||||
"QSOFA" => (await client.GetCurrentQsofaAsync(encounterId))?.ActiveCriteria,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
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,
|
||||
string? NarrativeContains = null);
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates <c>*.json</c> in <paramref name="directory"/>, skips files that
|
||||
/// fail to deserialize, and returns pairs sorted by <see cref="ScenarioMeta.Id"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<(ScenarioFile Scenario, string Path)> LoadAll(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<(ScenarioFile, string)>();
|
||||
|
||||
var results = new List<(ScenarioFile Scenario, string Path)>();
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(directory, "*.json")
|
||||
.Where(p => !string.Equals(
|
||||
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
results.Add((Load(path), path));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip files that fail to deserialize — catalogue must stay resilient.
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(r => r.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
namespace VigilCare.Simulation;
|
||||
|
||||
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",
|
||||
// Phase 25 — GCS components
|
||||
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR",
|
||||
// Phase 26 — SOFA lab / respiratory inputs
|
||||
"PAO2_MMHG", "FIO2_PCT", "PLATELET_K_UL",
|
||||
"BILIRUBIN_MG_DL", "CREATININE_MG_DL", "URINE_OUTPUT_ML_H",
|
||||
};
|
||||
|
||||
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", "alert_ack"
|
||||
};
|
||||
|
||||
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);
|
||||
var indexedEvents = scenario.Events
|
||||
.Select((evt, i) => (evt, i))
|
||||
.OrderBy(x => x.evt.OffsetMinutes)
|
||||
.ThenBy(x => x.i);
|
||||
|
||||
foreach (var (evt, i) in indexedEvents)
|
||||
{
|
||||
if (evt.OffsetMinutes < 0)
|
||||
errors.Add($"events[{i}]: offsetMinutes cannot be negative");
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
private static bool IsSepsisBundleOrder(string description) =>
|
||||
SepsisBundleOrderPrefixes.Any(p =>
|
||||
description.Equals(p, StringComparison.OrdinalIgnoreCase)
|
||||
|| description.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>VigilCare.Simulation</RootNamespace>
|
||||
<Copyright>Copyright (c) 2024-2026 voltsrage. All Rights Reserved.</Copyright>
|
||||
<Authors>voltsrage</Authors>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user