feature: In-App Simulation Runner (Backend)
This commit is contained in:
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public class AlertExplanation
|
public class AlertExplanation
|
||||||
{
|
{
|
||||||
public List<ScoreContributor> ScoreContributors { get; set; } = new();
|
public List<ScoreContributor> ScoreContributors { get; set; } = new();
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record AlertResponse(
|
public record AlertResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid EncounterId,
|
Guid EncounterId,
|
||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error);
|
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error);
|
||||||
public record ApiError(string Message, string Code);
|
public record ApiError(string Message, string Code);
|
||||||
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
||||||
+3
-1
@@ -1 +1,3 @@
|
|||||||
public record BatchIngestRequest(List<IngestObservationRequest> Observations);
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
|
public record BatchIngestRequest(List<IngestObservationRequest> Observations);
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record CreateMedicationAdministrationRequest(
|
public record CreateMedicationAdministrationRequest(
|
||||||
string DrugName, decimal Dose, string DoseUnit, string Route,
|
string DrugName, decimal Dose, string DoseUnit, string Route,
|
||||||
DateTimeOffset? AdministeredAt, string AdministeredBy);
|
DateTimeOffset? AdministeredAt, string AdministeredBy);
|
||||||
+2
@@ -1 +1,3 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record CreateOrderRequest(string OrderType, string Description, string OrderedBy);
|
public record CreateOrderRequest(string OrderType, string Description, string OrderedBy);
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record EncounterResponse(
|
public record EncounterResponse(
|
||||||
Guid Id, Guid PatientId, string EncounterType, string Status,
|
Guid Id, Guid PatientId, string EncounterType, string Status,
|
||||||
string Department, string AttendingPhysician, string? RoomBed,
|
string Department, string AttendingPhysician, string? RoomBed,
|
||||||
string? AdmissionReason, DateTimeOffset AdmittedAt);
|
string? AdmissionReason, DateTimeOffset AdmittedAt);
|
||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record GcsResponse(
|
public record GcsResponse(
|
||||||
int EyeScore, int VerbalScore, int MotorScore,
|
int EyeScore, int VerbalScore, int MotorScore,
|
||||||
int TotalScore, string Classification, DateTimeOffset CalculatedAt);
|
int TotalScore, string Classification, DateTimeOffset CalculatedAt);
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
|
|
||||||
public record IngestObservationRequest(
|
public record IngestObservationRequest(
|
||||||
string ObservationCode, decimal Value, string Unit,
|
string ObservationCode, decimal Value, string Unit,
|
||||||
string Source, DateTimeOffset RecordedAt, string? IdempotencyKey);
|
string Source, DateTimeOffset RecordedAt, string? IdempotencyKey);
|
||||||
|
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record SimLoginRequest(string Username, string Password);
|
public record SimLoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public record SimLoginResponse(
|
public record SimLoginResponse(
|
||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record News2Response(
|
public record News2Response(
|
||||||
Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree,
|
Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree,
|
||||||
int RespRateScore, int Spo2Score, int SystolicBpScore,
|
int RespRateScore, int Spo2Score, int SystolicBpScore,
|
||||||
int HeartRateScore, int ConsciousnessScore, int TemperatureScore,
|
int HeartRateScore, int ConsciousnessScore, int TemperatureScore,
|
||||||
int SupplementalO2Score, DateTimeOffset CalculatedAt);
|
int SupplementalO2Score, DateTimeOffset CalculatedAt);
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
|
|
||||||
public record OpenEncounterRequest(
|
public record OpenEncounterRequest(
|
||||||
string EncounterType, string Department, string AttendingPhysician,
|
string EncounterType, string Department, string AttendingPhysician,
|
||||||
string? RoomBed = null, string? AdmissionReason = null);
|
string? RoomBed = null, string? AdmissionReason = null);
|
||||||
+2
@@ -1 +1,3 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record OrderResponse(Guid Id, string Description, string Status);
|
public record OrderResponse(Guid Id, string Description, string Status);
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record PatientResponse(
|
public record PatientResponse(
|
||||||
Guid Id, string Mrn, string FirstName, string LastName,
|
Guid Id, string Mrn, string FirstName, string LastName,
|
||||||
DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt);
|
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);
|
||||||
+2
@@ -1,2 +1,4 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record RegisterPatientRequest(
|
public record RegisterPatientRequest(
|
||||||
string FirstName, string LastName, DateOnly DateOfBirth, string Gender);
|
string FirstName, string LastName, DateOnly DateOfBirth, string Gender);
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record SepsisBundleElementResponse(string Status);
|
public record SepsisBundleElementResponse(string Status);
|
||||||
|
|
||||||
public record SepsisBundleResponse(
|
public record SepsisBundleResponse(
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record SofaResponse(
|
public record SofaResponse(
|
||||||
int TotalScore,
|
int TotalScore,
|
||||||
int RespiratoryScore, int CoagulationScore, int LiverScore,
|
int RespiratoryScore, int CoagulationScore, int LiverScore,
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record SofaStalenessResponse(
|
public record SofaStalenessResponse(
|
||||||
IReadOnlyList<string> StaleComponents,
|
IReadOnlyList<string> StaleComponents,
|
||||||
IReadOnlyList<string> MissingComponents,
|
IReadOnlyList<string> MissingComponents,
|
||||||
bool UsedSpO2Fallback);
|
bool UsedSpO2Fallback);
|
||||||
+3
-1
@@ -2,6 +2,8 @@ using System.Net.Http.Headers;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public class VigilCareApiClient
|
public class VigilCareApiClient
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
@@ -246,4 +248,4 @@ public class VigilCareApiClient
|
|||||||
|
|
||||||
return Normalize(actual) == Normalize(expected);
|
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) { }
|
||||||
|
}
|
||||||
+44
-29
@@ -1,13 +1,23 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public class ReplayEngine
|
public class ReplayEngine
|
||||||
{
|
{
|
||||||
private readonly VigilCareApiClient _client;
|
private readonly VigilCareApiClient _client;
|
||||||
private readonly ApiPoller? _poller;
|
private readonly IApiPoller? _poller;
|
||||||
|
private readonly IReplayObserver _observer;
|
||||||
|
private readonly Func<Guid, CancellationToken, Task>? _onPatientRegistered;
|
||||||
private DateTimeOffset _scenarioStartTime;
|
private DateTimeOffset _scenarioStartTime;
|
||||||
|
|
||||||
public ReplayEngine(VigilCareApiClient client, ApiPoller? poller)
|
public ReplayEngine(
|
||||||
|
VigilCareApiClient client,
|
||||||
|
IApiPoller? poller,
|
||||||
|
IReplayObserver? observer = null,
|
||||||
|
Func<Guid, CancellationToken, Task>? onPatientRegistered = null)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
_poller = poller;
|
_poller = poller;
|
||||||
|
_observer = observer ?? NullReplayObserver.Instance;
|
||||||
|
_onPatientRegistered = onPatientRegistered;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ReplayResult> RunAsync(
|
public async Task<ReplayResult> RunAsync(
|
||||||
@@ -18,7 +28,7 @@ public class ReplayEngine
|
|||||||
_scenarioStartTime = startTime;
|
_scenarioStartTime = startTime;
|
||||||
|
|
||||||
// --- Phase 1: Setup ---
|
// --- Phase 1: Setup ---
|
||||||
SimulatorConsole.Header(scenario.Scenario.Name, scenario.Scenario.Description);
|
_observer.Header(scenario.Scenario.Name, scenario.Scenario.Description);
|
||||||
|
|
||||||
if (options.DryRun)
|
if (options.DryRun)
|
||||||
{
|
{
|
||||||
@@ -26,27 +36,27 @@ public class ReplayEngine
|
|||||||
{
|
{
|
||||||
result.EncounterId = options.ExistingEncounterId.Value;
|
result.EncounterId = options.ExistingEncounterId.Value;
|
||||||
if (options.Target == ReplayTarget.Gateway)
|
if (options.Target == ReplayTarget.Gateway)
|
||||||
SimulatorConsole.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
|
_observer.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
|
||||||
else
|
else
|
||||||
SimulatorConsole.DryRun($"Would use existing encounter {result.EncounterId}");
|
_observer.DryRun($"Would use existing encounter {result.EncounterId}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SimulatorConsole.DryRun("Would register patient: " +
|
_observer.DryRun("Would register patient: " +
|
||||||
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
|
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
|
||||||
SimulatorConsole.DryRun("Would open encounter: " +
|
_observer.DryRun("Would open encounter: " +
|
||||||
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
|
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue)
|
else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue)
|
||||||
{
|
{
|
||||||
result.EncounterId = options.ExistingEncounterId.Value;
|
result.EncounterId = options.ExistingEncounterId.Value;
|
||||||
SimulatorConsole.Info($"Gateway mode — using existing encounter {result.EncounterId}");
|
_observer.Info($"Gateway mode — using existing encounter {result.EncounterId}");
|
||||||
}
|
}
|
||||||
else if (options.ExistingEncounterId.HasValue)
|
else if (options.ExistingEncounterId.HasValue)
|
||||||
{
|
{
|
||||||
result.EncounterId = options.ExistingEncounterId.Value;
|
result.EncounterId = options.ExistingEncounterId.Value;
|
||||||
SimulatorConsole.Info($"Using existing encounter {result.EncounterId}");
|
_observer.Info($"Using existing encounter {result.EncounterId}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -56,6 +66,9 @@ public class ReplayEngine
|
|||||||
DateOnly.Parse(scenario.Patient.DateOfBirth),
|
DateOnly.Parse(scenario.Patient.DateOfBirth),
|
||||||
scenario.Patient.Gender));
|
scenario.Patient.Gender));
|
||||||
|
|
||||||
|
if (_onPatientRegistered is not null)
|
||||||
|
await _onPatientRegistered(patient.Id, ct);
|
||||||
|
|
||||||
var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
|
var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
|
||||||
scenario.Encounter.EncounterType,
|
scenario.Encounter.EncounterType,
|
||||||
DepartmentMapper.ToApiDepartment(scenario.Encounter.Department),
|
DepartmentMapper.ToApiDepartment(scenario.Encounter.Department),
|
||||||
@@ -63,8 +76,8 @@ public class ReplayEngine
|
|||||||
scenario.Encounter.RoomBed,
|
scenario.Encounter.RoomBed,
|
||||||
scenario.Encounter.AdmissionReason));
|
scenario.Encounter.AdmissionReason));
|
||||||
|
|
||||||
SimulatorConsole.Info($"Patient registered: {patient.Id} ({patient.Mrn})");
|
_observer.Info($"Patient registered: {patient.Id} ({patient.Mrn})");
|
||||||
SimulatorConsole.Info($"Encounter opened: {encounter.Id} ({encounter.Status})");
|
_observer.Info($"Encounter opened: {encounter.Id} ({encounter.Status})");
|
||||||
result.PatientId = patient.Id;
|
result.PatientId = patient.Id;
|
||||||
result.EncounterId = encounter.Id;
|
result.EncounterId = encounter.Id;
|
||||||
}
|
}
|
||||||
@@ -76,15 +89,16 @@ public class ReplayEngine
|
|||||||
.OrderBy(g => g.Key)
|
.OrderBy(g => g.Key)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var cluster in clusters)
|
for (var clusterIndex = 0; clusterIndex < clusters.Count; clusterIndex++)
|
||||||
{
|
{
|
||||||
|
var cluster = clusters[clusterIndex];
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var deltaMinutes = cluster.Key - lastOffset;
|
var deltaMinutes = cluster.Key - lastOffset;
|
||||||
if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun)
|
if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun)
|
||||||
{
|
{
|
||||||
var delayMs = (int)(deltaMinutes * 60_000 / options.Speed);
|
var delayMs = (int)(deltaMinutes * 60_000 / options.Speed);
|
||||||
SimulatorConsole.Wait(deltaMinutes, delayMs);
|
_observer.Waiting(deltaMinutes, delayMs);
|
||||||
await Task.Delay(delayMs, ct);
|
await Task.Delay(delayMs, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +130,7 @@ public class ReplayEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastOffset = cluster.Key;
|
lastOffset = cluster.Key;
|
||||||
|
_observer.Progress(cluster.Key, clusterIndex, clusters.Count);
|
||||||
|
|
||||||
if (options.Poll && !options.DryRun && _poller is not null)
|
if (options.Poll && !options.DryRun && _poller is not null)
|
||||||
{
|
{
|
||||||
@@ -136,10 +151,10 @@ public class ReplayEngine
|
|||||||
result.HadExpectedOutcomes = true;
|
result.HadExpectedOutcomes = true;
|
||||||
result.OutcomeFailures.AddRange(failures);
|
result.OutcomeFailures.AddRange(failures);
|
||||||
foreach (var failure in failures)
|
foreach (var failure in failures)
|
||||||
SimulatorConsole.Error(failure);
|
_observer.Error(failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
SimulatorConsole.Summary(result);
|
_observer.Completed(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +175,7 @@ public class ReplayEngine
|
|||||||
var unit = evt.Data.GetProperty("unit").GetString()!;
|
var unit = evt.Data.GetProperty("unit").GetString()!;
|
||||||
var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual";
|
var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual";
|
||||||
|
|
||||||
SimulatorConsole.Event(simTime, $"{code} {value} {unit}");
|
_observer.Event(simTime, $"{code} {value} {unit}");
|
||||||
result.ObservationsSent++;
|
result.ObservationsSent++;
|
||||||
|
|
||||||
if (!options.DryRun)
|
if (!options.DryRun)
|
||||||
@@ -185,7 +200,7 @@ public class ReplayEngine
|
|||||||
|
|
||||||
if (options.DryRun)
|
if (options.DryRun)
|
||||||
{
|
{
|
||||||
SimulatorConsole.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}");
|
_observer.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,12 +209,12 @@ public class ReplayEngine
|
|||||||
|
|
||||||
if (sent)
|
if (sent)
|
||||||
{
|
{
|
||||||
SimulatorConsole.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent");
|
_observer.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent");
|
||||||
result.MedicationsSent++;
|
result.MedicationsSent++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SimulatorConsole.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)");
|
_observer.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)");
|
||||||
result.MedicationsSkipped++;
|
result.MedicationsSkipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +230,7 @@ public class ReplayEngine
|
|||||||
|
|
||||||
if (options.DryRun)
|
if (options.DryRun)
|
||||||
{
|
{
|
||||||
SimulatorConsole.DryRun($"[{simTime}] ORDER {orderType}: {description}");
|
_observer.DryRun($"[{simTime}] ORDER {orderType}: {description}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +239,12 @@ public class ReplayEngine
|
|||||||
|
|
||||||
if (placed)
|
if (placed)
|
||||||
{
|
{
|
||||||
SimulatorConsole.Event(simTime, $"ORDER {description} placed");
|
_observer.Event(simTime, $"ORDER {description} placed");
|
||||||
result.OrdersPlaced++;
|
result.OrdersPlaced++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SimulatorConsole.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'");
|
_observer.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +257,7 @@ public class ReplayEngine
|
|||||||
|
|
||||||
if (options.DryRun)
|
if (options.DryRun)
|
||||||
{
|
{
|
||||||
SimulatorConsole.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}");
|
_observer.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +266,12 @@ public class ReplayEngine
|
|||||||
|
|
||||||
if (ok)
|
if (ok)
|
||||||
{
|
{
|
||||||
SimulatorConsole.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted");
|
_observer.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted");
|
||||||
result.OrdersResulted++;
|
result.OrdersResulted++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SimulatorConsole.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted");
|
_observer.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +300,7 @@ public class ReplayEngine
|
|||||||
|
|
||||||
if (options.DryRun)
|
if (options.DryRun)
|
||||||
{
|
{
|
||||||
SimulatorConsole.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}");
|
_observer.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,8 +311,8 @@ public class ReplayEngine
|
|||||||
var ok = await _client.TryAcknowledgeAlertAsync(
|
var ok = await _client.TryAcknowledgeAlertAsync(
|
||||||
result.EncounterId, alertType, clinicianId, note, waitForAlert, ct);
|
result.EncounterId, alertType, clinicianId, note, waitForAlert, ct);
|
||||||
if (ok)
|
if (ok)
|
||||||
SimulatorConsole.Event(simTime, $"ACK {alertType} by {clinicianId}");
|
_observer.Event(simTime, $"ACK {alertType} by {clinicianId}");
|
||||||
else
|
else
|
||||||
SimulatorConsole.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
|
_observer.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public enum ReplayTarget { Central, Gateway }
|
public enum ReplayTarget { Central, Gateway }
|
||||||
|
|
||||||
public record ReplayOptions(
|
public record ReplayOptions(
|
||||||
@@ -6,4 +8,4 @@ public record ReplayOptions(
|
|||||||
int PollIntervalSeconds = 5,
|
int PollIntervalSeconds = 5,
|
||||||
bool DryRun = false,
|
bool DryRun = false,
|
||||||
ReplayTarget Target = ReplayTarget.Central,
|
ReplayTarget Target = ReplayTarget.Central,
|
||||||
Guid? ExistingEncounterId = null);
|
Guid? ExistingEncounterId = null);
|
||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public class ReplayResult
|
public class ReplayResult
|
||||||
{
|
{
|
||||||
public string ScenarioId { get; }
|
public string ScenarioId { get; }
|
||||||
@@ -15,4 +17,4 @@ public class ReplayResult
|
|||||||
public bool OutcomesPassed => OutcomeFailures.Count == 0;
|
public bool OutcomesPassed => OutcomeFailures.Count == 0;
|
||||||
|
|
||||||
public ReplayResult(string scenarioId) => ScenarioId = scenarioId;
|
public ReplayResult(string scenarioId) => ScenarioId = scenarioId;
|
||||||
}
|
}
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public static class DepartmentMapper
|
public static class DepartmentMapper
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase)
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public static class ExpectedOutcomeValidator
|
public static class ExpectedOutcomeValidator
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8);
|
private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8);
|
||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public record ScenarioFile(
|
public record ScenarioFile(
|
||||||
ScenarioMeta Scenario,
|
ScenarioMeta Scenario,
|
||||||
ScenarioPatient Patient,
|
ScenarioPatient Patient,
|
||||||
@@ -26,4 +28,4 @@ public record ExpectedOutcome(
|
|||||||
double AfterOffsetMinutes, string Type,
|
double AfterOffsetMinutes, string Type,
|
||||||
string? AlertType, string? ScoreType,
|
string? AlertType, string? ScoreType,
|
||||||
double? ExpectedMinimum, string? Description,
|
double? ExpectedMinimum, string? Description,
|
||||||
string? NarrativeContains = null);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
|
namespace VigilCare.Simulation;
|
||||||
|
|
||||||
public static class ScenarioValidator
|
public static class ScenarioValidator
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> ValidCodes = new()
|
private static readonly HashSet<string> ValidCodes = new()
|
||||||
@@ -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>
|
||||||
@@ -1 +0,0 @@
|
|||||||
public record QsofaResponse(int ActiveCriteria);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
public record RecordOrderResultRequest(string? ResultSummary);
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public static class DryRunCommand
|
public static class DryRunCommand
|
||||||
{
|
{
|
||||||
@@ -21,7 +22,7 @@ public static class DryRunCommand
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var engine = new ReplayEngine(client: null!, poller: null);
|
var engine = new ReplayEngine(client: null!, poller: null, new ConsoleReplayObserver());
|
||||||
await engine.RunAsync(scenario, new ReplayOptions(DryRun: true));
|
await engine.RunAsync(scenario, new ReplayOptions(DryRun: true));
|
||||||
}, fileArg);
|
}, fileArg);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public static class ReplayAllCommand
|
public static class ReplayAllCommand
|
||||||
{
|
{
|
||||||
@@ -38,7 +39,7 @@ public static class ReplayAllCommand
|
|||||||
await client.LoginAsync(username, password);
|
await client.LoginAsync(username, password);
|
||||||
SimulatorConsole.Info("Authenticated.");
|
SimulatorConsole.Info("Authenticated.");
|
||||||
|
|
||||||
var engine = new ReplayEngine(client, poller: null);
|
var engine = new ReplayEngine(client, poller: null, new ConsoleReplayObserver());
|
||||||
var results = new List<ReplayResult>();
|
var results = new List<ReplayResult>();
|
||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public static class ReplayCommand
|
public static class ReplayCommand
|
||||||
{
|
{
|
||||||
@@ -86,7 +87,7 @@ public static class ReplayCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
var poller = poll ? new ApiPoller(client) : null;
|
var poller = poll ? new ApiPoller(client) : null;
|
||||||
var engine = new ReplayEngine(client, poller);
|
var engine = new ReplayEngine(client, poller, new ConsoleReplayObserver());
|
||||||
var options = new ReplayOptions(
|
var options = new ReplayOptions(
|
||||||
speed, poll, pollInterval, DryRun: false,
|
speed, poll, pollInterval, DryRun: false,
|
||||||
Target: gateway ? ReplayTarget.Gateway : ReplayTarget.Central,
|
Target: gateway ? ReplayTarget.Gateway : ReplayTarget.Central,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public static class ValidateCommand
|
public static class ValidateCommand
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public static class MimicGenerateCommand
|
public static class MimicGenerateCommand
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public record MimicGenerateOptions(
|
public record MimicGenerateOptions(
|
||||||
int? MaxHours = null,
|
int? MaxHours = null,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
public sealed class ConsoleReplayObserver : IReplayObserver
|
||||||
|
{
|
||||||
|
public void Header(string name, string? description) =>
|
||||||
|
SimulatorConsole.Header(name, description);
|
||||||
|
|
||||||
|
public void Info(string message) =>
|
||||||
|
SimulatorConsole.Info(message);
|
||||||
|
|
||||||
|
public void Event(string simTime, string description) =>
|
||||||
|
SimulatorConsole.Event(simTime, description);
|
||||||
|
|
||||||
|
public void Waiting(double deltaMinutes, int delayMs) =>
|
||||||
|
SimulatorConsole.Wait(deltaMinutes, delayMs);
|
||||||
|
|
||||||
|
public void Warn(string message) =>
|
||||||
|
SimulatorConsole.Warn(message);
|
||||||
|
|
||||||
|
public void Error(string message) =>
|
||||||
|
SimulatorConsole.Error(message);
|
||||||
|
|
||||||
|
public void DryRun(string message) =>
|
||||||
|
SimulatorConsole.DryRun(message);
|
||||||
|
|
||||||
|
public void Completed(ReplayResult result) =>
|
||||||
|
SimulatorConsole.Summary(result);
|
||||||
|
|
||||||
|
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount)
|
||||||
|
{
|
||||||
|
// Console progress is already visible via Event/Wait; no extra output.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public static class SimulatorConsole
|
public static class SimulatorConsole
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
public class ApiPoller
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
public class ApiPoller : IApiPoller
|
||||||
{
|
{
|
||||||
private readonly VigilCareApiClient _client;
|
private readonly VigilCareApiClient _client;
|
||||||
private readonly HashSet<Guid> _seenAlertIds = new();
|
private readonly HashSet<Guid> _seenAlertIds = new();
|
||||||
@@ -20,4 +22,4 @@ public class ApiPoller
|
|||||||
var result = new PollResult(news2, gcs, sofa, qsofa, newAlerts, bundle);
|
var result = new PollResult(news2, gcs, sofa, qsofa, newAlerts, bundle);
|
||||||
SimulatorConsole.PollResults(simTime, result);
|
SimulatorConsole.PollResults(simTime, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public record PollResult(
|
public record PollResult(
|
||||||
News2Response? News2,
|
News2Response? News2,
|
||||||
GcsResponse? Gcs,
|
GcsResponse? Gcs,
|
||||||
SofaResponse? Sofa,
|
SofaResponse? Sofa,
|
||||||
QsofaResponse? Qsofa,
|
QsofaResponse? Qsofa,
|
||||||
IReadOnlyList<AlertResponse> NewAlerts,
|
IReadOnlyList<AlertResponse> NewAlerts,
|
||||||
SepsisBundleResponse? SepsisBundle);
|
SepsisBundleResponse? SepsisBundle);
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,4 +19,8 @@
|
|||||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway", "Vi
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway.Tests", "VigilCare.WardGateway.Tests\VigilCare.WardGateway.Tests.csproj", "{1C6D261E-488B-4541-8995-12C5029441A6}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway.Tests", "VigilCare.WardGateway.Tests\VigilCare.WardGateway.Tests.csproj", "{1C6D261E-488B-4541-8995-12C5029441A6}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.Simulation.Core", "VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj", "{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -54,5 +56,9 @@ Global
|
|||||||
{1C6D261E-488B-4541-8995-12C5029441A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{1C6D261E-488B-4541-8995-12C5029441A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.Build.0 = Release|Any CPU
|
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using FluentAssertions;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
[Collection("Integration")]
|
[Collection("Integration")]
|
||||||
public class ClinicalRefactorEndToEndTests : IAsyncLifetime
|
public class ClinicalRefactorEndToEndTests : IAsyncLifetime
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
|
|
||||||
@@ -24,6 +26,12 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
|||||||
public static int RabbitPort { get; } =
|
public static int RabbitPort { get; } =
|
||||||
int.TryParse(Environment.GetEnvironmentVariable("RabbitMq__Port"), out var p) ? p : 5674;
|
int.TryParse(Environment.GetEnvironmentVariable("RabbitMq__Port"), out var p) ? p : 5674;
|
||||||
|
|
||||||
|
public static string SimulationScenarioDirectory { get; } = Path.GetFullPath(Path.Combine(
|
||||||
|
AppContext.BaseDirectory, "Fixtures", "Scenarios"));
|
||||||
|
|
||||||
|
public TestSimulationClientFactory SimulationClientFactory =>
|
||||||
|
Services.GetRequiredService<TestSimulationClientFactory>();
|
||||||
|
|
||||||
// Override configuration to point at a test database — never run tests against
|
// Override configuration to point at a test database — never run tests against
|
||||||
// the development database; a botched rollback could corrupt seed data.
|
// the development database; a botched rollback could corrupt seed data.
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
@@ -62,6 +70,16 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
|||||||
});
|
});
|
||||||
|
|
||||||
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
|
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
|
||||||
|
|
||||||
|
// Win over appsettings.Testing.json catalogue path / concurrency defaults.
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Simulation:Enabled"] = "true",
|
||||||
|
["Simulation:ScenarioDirectory"] = SimulationScenarioDirectory,
|
||||||
|
["Simulation:MaxConcurrentRuns"] = "2",
|
||||||
|
["Simulation:MaxSpeed"] = "600",
|
||||||
|
["Simulation:RunHistoryLimit"] = "50",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
@@ -77,6 +95,15 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
|||||||
.AddScheme<AuthenticationSchemeOptions, TestingAuthHandler>(
|
.AddScheme<AuthenticationSchemeOptions, TestingAuthHandler>(
|
||||||
TestingAuthHandler.SchemeName, _ => { });
|
TestingAuthHandler.SchemeName, _ => { });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<ISimulationClientFactory>();
|
||||||
|
services.AddSingleton<TestSimulationClientFactory>(_ =>
|
||||||
|
new TestSimulationClientFactory(this));
|
||||||
|
services.AddSingleton<ISimulationClientFactory>(sp =>
|
||||||
|
sp.GetRequiredService<TestSimulationClientFactory>());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"scenario": {
|
||||||
|
"id": "minimal-sim-01",
|
||||||
|
"name": "Minimal Simulation Fixture",
|
||||||
|
"description": "Three observation clusters for Phase 36 CI tests.",
|
||||||
|
"durationMinutes": 2,
|
||||||
|
"tags": ["test", "minimal"]
|
||||||
|
},
|
||||||
|
"patient": {
|
||||||
|
"firstName": "Sim",
|
||||||
|
"lastName": "Fixture",
|
||||||
|
"dateOfBirth": "1980-01-15",
|
||||||
|
"gender": "Female"
|
||||||
|
},
|
||||||
|
"encounter": {
|
||||||
|
"department": "GeneralMedicine",
|
||||||
|
"encounterType": "Inpatient",
|
||||||
|
"attendingPhysician": "Dr. Test",
|
||||||
|
"roomBed": "T-1",
|
||||||
|
"admissionReason": "Simulation fixture"
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"offsetMinutes": 0,
|
||||||
|
"type": "observation",
|
||||||
|
"data": { "code": "HEART_RATE", "value": 72, "unit": "bpm", "source": "Manual" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offsetMinutes": 0,
|
||||||
|
"type": "observation",
|
||||||
|
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Manual" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offsetMinutes": 1,
|
||||||
|
"type": "observation",
|
||||||
|
"data": { "code": "HEART_RATE", "value": 74, "unit": "bpm", "source": "Manual" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"offsetMinutes": 2,
|
||||||
|
"type": "observation",
|
||||||
|
"data": { "code": "HEART_RATE", "value": 70, "unit": "bpm", "source": "Manual" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ public static class DbResetHelper
|
|||||||
DELETE FROM alert_thresholds;
|
DELETE FROM alert_thresholds;
|
||||||
DELETE FROM clinical_audit_logs;
|
DELETE FROM clinical_audit_logs;
|
||||||
DELETE FROM clinical_users;
|
DELETE FROM clinical_users;
|
||||||
|
DELETE FROM simulation_runs;
|
||||||
DELETE FROM patients;
|
DELETE FROM patients;
|
||||||
");
|
");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
public static class ScenarioReplayHelper
|
public static class ScenarioReplayHelper
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class SimulationEndpointTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public SimulationEndpointTests(ApiFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
_client = fixture.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
|
||||||
|
|
||||||
|
_fixture.SimulationClientFactory.HangOnCreate = false;
|
||||||
|
_fixture.SimulationClientFactory.FailOnCreate = false;
|
||||||
|
|
||||||
|
_client.DefaultRequestHeaders.Remove("X-Test-Role");
|
||||||
|
_client.DefaultRequestHeaders.Remove("X-Test-User-Id");
|
||||||
|
_client.AsAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Config_ReturnsEnabledFalse_WhenDisabled()
|
||||||
|
{
|
||||||
|
using var factory = _fixture.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
{
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Simulation:Enabled"] = "false",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
client.AsAdmin();
|
||||||
|
|
||||||
|
var resp = await client.GetAsync("/api/v1/simulation/config");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("data").GetProperty("enabled").GetBoolean().Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Scenarios_WhenDisabled_Returns404()
|
||||||
|
{
|
||||||
|
using var factory = _fixture.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
{
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Simulation:Enabled"] = "false",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
client.AsAdmin();
|
||||||
|
|
||||||
|
var resp = await client.GetAsync("/api/v1/simulation/scenarios");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Scenarios_AsNurse_Returns200()
|
||||||
|
{
|
||||||
|
_client.AsNurse();
|
||||||
|
|
||||||
|
var resp = await _client.GetAsync("/api/v1/simulation/scenarios");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var items = body.GetProperty("data");
|
||||||
|
items.GetArrayLength().Should().BeGreaterThan(0);
|
||||||
|
items[0].GetProperty("id").GetString().Should().Be("minimal-sim-01");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartRun_AsIntegrationRole_Returns403()
|
||||||
|
{
|
||||||
|
_client.AsIntegration();
|
||||||
|
|
||||||
|
var resp = await _client.PostAsJsonAsync("/api/v1/simulation/runs", new
|
||||||
|
{
|
||||||
|
scenarioId = "minimal-sim-01",
|
||||||
|
speed = 600
|
||||||
|
});
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartRun_WritesAuditLog()
|
||||||
|
{
|
||||||
|
_client.AsPhysician();
|
||||||
|
|
||||||
|
var resp = await _client.PostAsJsonAsync("/api/v1/simulation/runs", new
|
||||||
|
{
|
||||||
|
scenarioId = "minimal-sim-01",
|
||||||
|
speed = 600
|
||||||
|
});
|
||||||
|
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var runId = body.GetProperty("data").GetProperty("runId").GetGuid();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var audit = await db.ClinicalAuditLogs
|
||||||
|
.Where(a => a.Action == AuditAction.SimulationRunStarted && a.EntityId == runId)
|
||||||
|
.SingleOrDefaultAsync();
|
||||||
|
|
||||||
|
audit.Should().NotBeNull();
|
||||||
|
audit!.EntityType.Should().Be("SimulationRun");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Config_WhenEnabled_ReturnsLimits()
|
||||||
|
{
|
||||||
|
_client.AsNurse();
|
||||||
|
|
||||||
|
var resp = await _client.GetAsync("/api/v1/simulation/config");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var data = body.GetProperty("data");
|
||||||
|
data.GetProperty("enabled").GetBoolean().Should().BeTrue();
|
||||||
|
data.GetProperty("maxSpeed").GetDouble().Should().Be(600);
|
||||||
|
data.GetProperty("maxConcurrentRuns").GetInt32().Should().Be(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class SimulationRunnerTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private ISimulationRunner _runner = null!;
|
||||||
|
private TestSimulationClientFactory _clientFactory = null!;
|
||||||
|
|
||||||
|
public SimulationRunnerTests(ApiFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
|
||||||
|
|
||||||
|
_runner = _fixture.Services.GetRequiredService<ISimulationRunner>();
|
||||||
|
_clientFactory = _fixture.SimulationClientFactory;
|
||||||
|
_clientFactory.HangOnCreate = false;
|
||||||
|
_clientFactory.FailOnCreate = false;
|
||||||
|
|
||||||
|
foreach (var run in _runner.ListRuns())
|
||||||
|
_runner.Cancel(run.RunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = false;
|
||||||
|
_clientFactory.FailOnCreate = false;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_UnknownScenario_Returns422()
|
||||||
|
{
|
||||||
|
var act = () => _runner.StartAsync("does-not-exist", 60, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
var ex = await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
ex.Which.ErrorCode.Should().Be("SIMULATION_SCENARIO_UNKNOWN");
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
(await db.SimulationRuns.CountAsync()).Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_SpeedAboveMax_Returns422()
|
||||||
|
{
|
||||||
|
var act = () => _runner.StartAsync("minimal-sim-01", 601, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
var ex = await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
ex.Which.ErrorCode.Should().Be("SIMULATION_SPEED_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_AtConcurrencyLimit_Returns409()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = true;
|
||||||
|
|
||||||
|
await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
|
||||||
|
await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
var act = () => _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
|
||||||
|
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||||
|
ex.Which.ErrorCode.Should().Be("SIMULATION_CONCURRENCY_LIMIT");
|
||||||
|
|
||||||
|
foreach (var run in _runner.ListRuns())
|
||||||
|
_runner.Cancel(run.RunId);
|
||||||
|
|
||||||
|
await WaitForAsync(() => _runner.ListRuns().All(r =>
|
||||||
|
r.Status is SimulationRunStatus.Cancelled or SimulationRunStatus.Failed
|
||||||
|
or SimulationRunStatus.Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_CreatesPatientMarkedSimulated()
|
||||||
|
{
|
||||||
|
var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None);
|
||||||
|
var completed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed);
|
||||||
|
|
||||||
|
completed.PatientId.Should().NotBeNull();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var patient = await db.Patients.SingleAsync(p => p.Id == completed.PatientId);
|
||||||
|
patient.IsSimulated.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Run_ProgressAdvances()
|
||||||
|
{
|
||||||
|
var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
await WaitForAsync(() =>
|
||||||
|
{
|
||||||
|
var current = _runner.GetRun(state.RunId);
|
||||||
|
return current is not null && current.LastOffsetMinutes > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
var mid = _runner.GetRun(state.RunId)!;
|
||||||
|
mid.LastOffsetMinutes.Should().BeGreaterThan(0);
|
||||||
|
|
||||||
|
var completed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed);
|
||||||
|
completed.ProgressPercent.Should().Be(100);
|
||||||
|
completed.ObservationsSent.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stop_CancelsRun_StatusCancelled()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = true;
|
||||||
|
var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
_runner.Cancel(state.RunId).Should().BeTrue();
|
||||||
|
var cancelled = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled);
|
||||||
|
cancelled.Status.Should().Be(SimulationRunStatus.Cancelled);
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
(await db.Observations.CountAsync()).Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stop_AlreadyCompleted_IsNoOpSuccess()
|
||||||
|
{
|
||||||
|
var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None);
|
||||||
|
await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed);
|
||||||
|
|
||||||
|
_runner.Cancel(state.RunId).Should().BeTrue();
|
||||||
|
var after = _runner.GetRun(state.RunId)!;
|
||||||
|
after.Status.Should().Be(SimulationRunStatus.Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Run_Failure_RecordsFailureReason()
|
||||||
|
{
|
||||||
|
_clientFactory.FailOnCreate = true;
|
||||||
|
var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
var failed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Failed);
|
||||||
|
failed.FailureReason.Should().NotBeNullOrWhiteSpace();
|
||||||
|
failed.FailureReason.Should().Contain("Login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Shutdown_CancelsActiveRuns()
|
||||||
|
{
|
||||||
|
_clientFactory.HangOnCreate = true;
|
||||||
|
var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
|
||||||
|
|
||||||
|
var hosted = (IHostedService)_fixture.Services.GetRequiredService<SimulationRunner>();
|
||||||
|
await hosted.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var after = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled);
|
||||||
|
after.Status.Should().Be(SimulationRunStatus.Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SimulationRunState> WaitForRunAsync(
|
||||||
|
Guid runId, SimulationRunStatus expected, TimeSpan? timeout = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WaitForAsync(() => _runner.GetRun(runId)?.Status == expected, timeout);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
var actual = _runner.GetRun(runId);
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"Expected run {runId} status {expected}, but was {actual?.Status}. " +
|
||||||
|
$"FailureReason={actual?.FailureReason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _runner.GetRun(runId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||||
|
{
|
||||||
|
var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
|
||||||
|
while (DateTimeOffset.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (condition())
|
||||||
|
return;
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Condition was not met within the timeout.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loopback client for WebApplicationFactory tests. Uses X-Test-Role INTEGRATION
|
||||||
|
/// instead of JWT login (Testing auth scheme ignores Bearer tokens).
|
||||||
|
/// Uses Server.CreateHandler() to avoid TestServer re-entrancy deadlocks.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TestSimulationClientFactory : ISimulationClientFactory
|
||||||
|
{
|
||||||
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
|
||||||
|
public TestSimulationClientFactory(WebApplicationFactory<Program> factory) =>
|
||||||
|
_factory = factory;
|
||||||
|
|
||||||
|
/// <summary>When true, CreateAsync blocks until cancelled — for concurrency/shutdown tests.</summary>
|
||||||
|
public bool HangOnCreate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, CreateAsync throws — for failure-path tests.</summary>
|
||||||
|
public bool FailOnCreate { get; set; }
|
||||||
|
|
||||||
|
public async Task<VigilCareApiClient> CreateAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (FailOnCreate)
|
||||||
|
throw new HttpRequestException("Login failed (401 Unauthorized): invalid credentials");
|
||||||
|
|
||||||
|
if (HangOnCreate)
|
||||||
|
await Task.Delay(Timeout.Infinite, ct);
|
||||||
|
|
||||||
|
var http = new HttpClient(_factory.Server.CreateHandler(), disposeHandler: true)
|
||||||
|
{
|
||||||
|
BaseAddress = _factory.Server.BaseAddress ?? new Uri("http://localhost"),
|
||||||
|
};
|
||||||
|
http.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION");
|
||||||
|
return new VigilCareApiClient(http);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
|
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
|
||||||
|
<ProjectReference Include="..\VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\VigilCare.Simulator\Scenarios\ScenarioFile.cs" Link="Scenarios\ScenarioFile.cs" />
|
<None Include="Fixtures\Scenarios\**\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<Compile Include="..\VigilCare.Simulator\Scenarios\ScenarioValidator.cs" Link="Scenarios\ScenarioValidator.cs" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ public static class ClinicalPermissions
|
|||||||
public const string AuditRead = "audit:read";
|
public const string AuditRead = "audit:read";
|
||||||
public const string UsersAdmin = "users:admin";
|
public const string UsersAdmin = "users:admin";
|
||||||
public const string AlertsFeedback = "alerts:feedback";
|
public const string AlertsFeedback = "alerts:feedback";
|
||||||
|
public const string SimulationRun = "simulation:run";
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ public static class ClinicalRolePermissionMap
|
|||||||
ClinicalPermissions.OrdersWrite,
|
ClinicalPermissions.OrdersWrite,
|
||||||
ClinicalPermissions.MedicationsWrite,
|
ClinicalPermissions.MedicationsWrite,
|
||||||
ClinicalPermissions.AlertsFeedback,
|
ClinicalPermissions.AlertsFeedback,
|
||||||
|
ClinicalPermissions.SimulationRun,
|
||||||
},
|
},
|
||||||
[ClinicalRole.Physician] = new(StringComparer.Ordinal)
|
[ClinicalRole.Physician] = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
@@ -33,6 +34,7 @@ public static class ClinicalRolePermissionMap
|
|||||||
ClinicalPermissions.OrdersWrite,
|
ClinicalPermissions.OrdersWrite,
|
||||||
ClinicalPermissions.MedicationsWrite,
|
ClinicalPermissions.MedicationsWrite,
|
||||||
ClinicalPermissions.AlertsFeedback,
|
ClinicalPermissions.AlertsFeedback,
|
||||||
|
ClinicalPermissions.SimulationRun,
|
||||||
},
|
},
|
||||||
[ClinicalRole.Admin] = new(StringComparer.Ordinal)
|
[ClinicalRole.Admin] = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
@@ -54,6 +56,7 @@ public static class ClinicalRolePermissionMap
|
|||||||
ClinicalPermissions.AuditRead,
|
ClinicalPermissions.AuditRead,
|
||||||
ClinicalPermissions.UsersAdmin,
|
ClinicalPermissions.UsersAdmin,
|
||||||
ClinicalPermissions.AlertsFeedback,
|
ClinicalPermissions.AlertsFeedback,
|
||||||
|
ClinicalPermissions.SimulationRun,
|
||||||
},
|
},
|
||||||
[ClinicalRole.Integration] = new(StringComparer.Ordinal)
|
[ClinicalRole.Integration] = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
@@ -61,6 +64,10 @@ public static class ClinicalRolePermissionMap
|
|||||||
ClinicalPermissions.EncountersWrite,
|
ClinicalPermissions.EncountersWrite,
|
||||||
ClinicalPermissions.ObservationsIngest,
|
ClinicalPermissions.ObservationsIngest,
|
||||||
ClinicalPermissions.MedicationsWrite,
|
ClinicalPermissions.MedicationsWrite,
|
||||||
|
// Phase 36 — simulation runner (Integration) must place orders and ack
|
||||||
|
// alerts so sepsis-bundle / alert_ack scenario timelines are complete.
|
||||||
|
ClinicalPermissions.OrdersWrite,
|
||||||
|
ClinicalPermissions.AlertsAcknowledge,
|
||||||
ClinicalPermissions.FhirIngest,
|
ClinicalPermissions.FhirIngest,
|
||||||
ClinicalPermissions.FhirRead,
|
ClinicalPermissions.FhirRead,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
public class SimulationOptions
|
||||||
|
{
|
||||||
|
public const string Section = "Simulation";
|
||||||
|
|
||||||
|
/// <summary>Master switch. When false, no simulation endpoints or services are registered.</summary>
|
||||||
|
public bool Enabled { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>Directory containing scenario JSON files.</summary>
|
||||||
|
public string ScenarioDirectory { get; set; } = "Scenarios";
|
||||||
|
|
||||||
|
/// <summary>Base address the runner posts to (the API's own address).</summary>
|
||||||
|
public string LoopbackBaseUrl { get; set; } = "http://localhost:5270";
|
||||||
|
|
||||||
|
/// <summary>Service account the runner authenticates as.</summary>
|
||||||
|
public string RunnerUsername { get; set; } = "simulation.runner";
|
||||||
|
|
||||||
|
public string RunnerPassword { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Concurrent scenario runs allowed (Phase 38 ward population needs > 1).</summary>
|
||||||
|
public int MaxConcurrentRuns { get; set; } = 8;
|
||||||
|
|
||||||
|
/// <summary>Upper bound on replay speed multiplier requested by a client.</summary>
|
||||||
|
public double MaxSpeed { get; set; } = 600;
|
||||||
|
|
||||||
|
/// <summary>Completed runs retained in the in-memory registry.</summary>
|
||||||
|
public int RunHistoryLimit { get; set; } = 50;
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-app scenario catalogue and run control for clinical testing sessions.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/simulation")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Authorize]
|
||||||
|
public class SimulationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly SimulationOptions _options;
|
||||||
|
private readonly ISimulationRunner? _runner;
|
||||||
|
private readonly IScenarioCatalog? _catalog;
|
||||||
|
private readonly ICurrentUserService _currentUser;
|
||||||
|
private readonly IAuditService _audit;
|
||||||
|
|
||||||
|
public SimulationController(
|
||||||
|
IOptions<SimulationOptions> options,
|
||||||
|
IServiceProvider services,
|
||||||
|
ICurrentUserService currentUser,
|
||||||
|
IAuditService audit)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_runner = services.GetService<ISimulationRunner>();
|
||||||
|
_catalog = services.GetService<IScenarioCatalog>();
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feature-detect simulation availability without requiring simulation:run.
|
||||||
|
/// Returns enabled=false when the feature is off (never 404).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("config")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SimulationConfigResponse>), StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetConfig()
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return Ok(ApiResponse<SimulationConfigResponse>.Ok(new SimulationConfigResponse(Enabled: false)));
|
||||||
|
|
||||||
|
return Ok(ApiResponse<SimulationConfigResponse>.Ok(new SimulationConfigResponse(
|
||||||
|
Enabled: true,
|
||||||
|
MaxSpeed: _options.MaxSpeed,
|
||||||
|
MaxConcurrentRuns: _options.MaxConcurrentRuns)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists available scenario files from the configured scenario directory.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("scenarios")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<List<ScenarioSummaryResponse>>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult ListScenarios()
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
var items = _catalog!.ListScenarios()
|
||||||
|
.Select(s => new ScenarioSummaryResponse(
|
||||||
|
s.Scenario.Id,
|
||||||
|
s.Scenario.Name,
|
||||||
|
s.Scenario.Description,
|
||||||
|
s.Scenario.DurationMinutes,
|
||||||
|
s.Scenario.Tags,
|
||||||
|
s.Encounter.Department,
|
||||||
|
s.Events.Count,
|
||||||
|
s.ExpectedOutcomes?.Count ?? 0))
|
||||||
|
.ToList();
|
||||||
|
return Ok(ApiResponse<List<ScenarioSummaryResponse>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a background scenario replay.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("runs")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> StartRun(
|
||||||
|
[FromBody] StartSimulationRunRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
|
||||||
|
var userId = _currentUser.UserId?.ToString()
|
||||||
|
?? throw new ValidationException("Authenticated user id is required.", "SIMULATION_USER_REQUIRED");
|
||||||
|
|
||||||
|
var state = await _runner!.StartAsync(req.ScenarioId, req.Speed, userId, ct);
|
||||||
|
|
||||||
|
await _audit.WriteAsync(
|
||||||
|
AuditAction.SimulationRunStarted,
|
||||||
|
"SimulationRun",
|
||||||
|
state.RunId,
|
||||||
|
newValue: new
|
||||||
|
{
|
||||||
|
state.ScenarioId,
|
||||||
|
state.ScenarioName,
|
||||||
|
state.Speed,
|
||||||
|
StartedBy = userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return StatusCode(201, ApiResponse<SimulationRunResponse>.Created(ToResponse(state)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists active and recent simulation runs from the in-memory registry.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("runs")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<List<SimulationRunResponse>>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult ListRuns()
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
var items = _runner!.ListRuns().Select(ToResponse).ToList();
|
||||||
|
return Ok(ApiResponse<List<SimulationRunResponse>>.Ok(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets one simulation run by id.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("runs/{runId:guid}")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult GetRun(Guid runId)
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
var state = _runner!.GetRun(runId)
|
||||||
|
?? throw new NotFoundException($"Simulation run '{runId}' was not found.");
|
||||||
|
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops an in-flight run. Idempotent — stopping a finished run succeeds as a no-op.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("runs/{runId:guid}/stop")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> StopRun(Guid runId)
|
||||||
|
{
|
||||||
|
EnsureEnabled();
|
||||||
|
|
||||||
|
if (!_runner!.Cancel(runId))
|
||||||
|
throw new NotFoundException($"Simulation run '{runId}' was not found.");
|
||||||
|
|
||||||
|
var state = _runner.GetRun(runId)
|
||||||
|
?? throw new NotFoundException($"Simulation run '{runId}' was not found.");
|
||||||
|
|
||||||
|
await _audit.WriteAsync(
|
||||||
|
AuditAction.SimulationRunStopped,
|
||||||
|
"SimulationRun",
|
||||||
|
runId,
|
||||||
|
newValue: new
|
||||||
|
{
|
||||||
|
state.ScenarioId,
|
||||||
|
state.Status,
|
||||||
|
StoppedBy = _currentUser.UserId?.ToString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureEnabled()
|
||||||
|
{
|
||||||
|
if (!_options.Enabled || _runner is null || _catalog is null)
|
||||||
|
throw new NotFoundException("Simulation endpoints are not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimulationRunResponse ToResponse(SimulationRunState state) =>
|
||||||
|
new(
|
||||||
|
state.RunId,
|
||||||
|
state.ScenarioId,
|
||||||
|
state.ScenarioName,
|
||||||
|
state.Status.ToDbString(),
|
||||||
|
state.Speed,
|
||||||
|
state.PatientId,
|
||||||
|
state.EncounterId,
|
||||||
|
state.PatientDisplayName,
|
||||||
|
state.StartedAt,
|
||||||
|
state.ElapsedRealSeconds,
|
||||||
|
state.LastOffsetMinutes,
|
||||||
|
state.TotalOffsetMinutes,
|
||||||
|
state.ProgressPercent,
|
||||||
|
state.ObservationsSent,
|
||||||
|
state.MedicationsSent,
|
||||||
|
state.OrdersPlaced,
|
||||||
|
state.FailureReason);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>();
|
public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>();
|
||||||
public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>();
|
public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<SimulationRun> SimulationRuns => Set<SimulationRun>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class PatientConfiguration : IEntityTypeConfiguration<Patient>
|
|||||||
.HasColumnName("name_search_token")
|
.HasColumnName("name_search_token")
|
||||||
.HasMaxLength(64);
|
.HasMaxLength(64);
|
||||||
builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||||
|
builder.Property(p => p.IsSimulated).HasColumnName("is_simulated").HasDefaultValue(false);
|
||||||
|
|
||||||
// MRN uses exact-match unique index — MRN lookups are always equality checks,
|
// MRN uses exact-match unique index — MRN lookups are always equality checks,
|
||||||
// never LIKE/ILIKE. A B-tree unique index satisfies O(log n) point lookup.
|
// never LIKE/ILIKE. A B-tree unique index satisfies O(log n) point lookup.
|
||||||
@@ -33,5 +34,9 @@ public class PatientConfiguration : IEntityTypeConfiguration<Patient>
|
|||||||
// at this scale (pg_trgm GIN would be warranted at >500k patients).
|
// at this scale (pg_trgm GIN would be warranted at >500k patients).
|
||||||
builder.HasIndex(p => p.Mrn).IsUnique();
|
builder.HasIndex(p => p.Mrn).IsUnique();
|
||||||
builder.HasIndex(p => p.NameSearchToken);
|
builder.HasIndex(p => p.NameSearchToken);
|
||||||
|
// Filtered index keeps Phase 38 simulated-patient purge cheap.
|
||||||
|
builder.HasIndex(p => p.IsSimulated)
|
||||||
|
.HasDatabaseName("IX_Patients_IsSimulated")
|
||||||
|
.HasFilter("is_simulated = true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class SimulationRunConfiguration : IEntityTypeConfiguration<SimulationRun>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SimulationRun> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("simulation_runs");
|
||||||
|
builder.HasKey(r => r.Id);
|
||||||
|
builder.Property(r => r.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
builder.Property(r => r.ScenarioId).HasColumnName("scenario_id").HasMaxLength(100).IsRequired();
|
||||||
|
builder.Property(r => r.ScenarioName).HasColumnName("scenario_name").HasMaxLength(200).IsRequired();
|
||||||
|
builder.Property(r => r.Speed).HasColumnName("speed");
|
||||||
|
builder.Property(r => r.Status).HasColumnName("status").HasMaxLength(20).IsRequired()
|
||||||
|
.HasConversion(
|
||||||
|
v => v.ToDbString(),
|
||||||
|
v => SimulationRunStatusExtensions.FromDbString(v));
|
||||||
|
builder.Property(r => r.PatientId).HasColumnName("patient_id");
|
||||||
|
builder.Property(r => r.EncounterId).HasColumnName("encounter_id");
|
||||||
|
builder.Property(r => r.StartedByUserId).HasColumnName("started_by_user_id").HasMaxLength(100).IsRequired();
|
||||||
|
builder.Property(r => r.StartedAt).HasColumnName("started_at");
|
||||||
|
builder.Property(r => r.CompletedAt).HasColumnName("completed_at");
|
||||||
|
builder.Property(r => r.ObservationsSent).HasColumnName("observations_sent");
|
||||||
|
builder.Property(r => r.MedicationsSent).HasColumnName("medications_sent");
|
||||||
|
builder.Property(r => r.OrdersPlaced).HasColumnName("orders_placed");
|
||||||
|
builder.Property(r => r.LastOffsetMinutes).HasColumnName("last_offset_minutes");
|
||||||
|
builder.Property(r => r.TotalOffsetMinutes).HasColumnName("total_offset_minutes");
|
||||||
|
builder.Property(r => r.FailureReason).HasColumnName("failure_reason").HasMaxLength(2000);
|
||||||
|
|
||||||
|
builder.HasIndex(r => new { r.Status, r.StartedAt })
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasDatabaseName("IX_simulation_runs_status_started_at");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,49 +2,85 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
public static class UserSeeder
|
public static class UserSeeder
|
||||||
{
|
{
|
||||||
public static async Task SeedAsync(AppDbContext db)
|
public static readonly Guid SimulationRunnerUserId =
|
||||||
|
Guid.Parse("55555555-5555-5555-5555-555555555555");
|
||||||
|
|
||||||
|
public static async Task SeedAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
bool simulationEnabled = false,
|
||||||
|
string? simulationRunnerPassword = null)
|
||||||
{
|
{
|
||||||
if (await db.ClinicalUsers.AnyAsync())
|
if (!await db.ClinicalUsers.AnyAsync())
|
||||||
|
{
|
||||||
|
db.ClinicalUsers.AddRange(
|
||||||
|
new ClinicalUser
|
||||||
|
{
|
||||||
|
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||||
|
Username = "nurse.demo",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
|
||||||
|
DisplayName = "Demo Nurse",
|
||||||
|
Role = ClinicalRole.Nurse,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
},
|
||||||
|
new ClinicalUser
|
||||||
|
{
|
||||||
|
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||||
|
Username = "physician.demo",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
|
||||||
|
DisplayName = "Dr. Demo Physician",
|
||||||
|
Role = ClinicalRole.Physician,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
},
|
||||||
|
new ClinicalUser
|
||||||
|
{
|
||||||
|
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
||||||
|
Username = "admin.demo",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
|
||||||
|
DisplayName = "Demo Admin",
|
||||||
|
Role = ClinicalRole.Admin,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
},
|
||||||
|
new ClinicalUser
|
||||||
|
{
|
||||||
|
Id = Guid.Parse("44444444-4444-4444-4444-444444444444"),
|
||||||
|
Username = "integration.mirth",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"),
|
||||||
|
DisplayName = "Mirth Connect",
|
||||||
|
Role = ClinicalRole.Integration,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simulationEnabled)
|
||||||
|
await EnsureSimulationRunnerAsync(db, simulationRunnerPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the loopback simulation runner account when Simulation:Enabled.
|
||||||
|
/// Idempotent — safe to call on an existing database that already has demo users.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task EnsureSimulationRunnerAsync(
|
||||||
|
AppDbContext db, string? password)
|
||||||
|
{
|
||||||
|
if (await db.ClinicalUsers.AnyAsync(u => u.Username == "simulation.runner"))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
db.ClinicalUsers.AddRange(
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
new ClinicalUser
|
throw new InvalidOperationException(
|
||||||
{
|
"Simulation:Enabled requires Simulation:RunnerPassword to seed simulation.runner.");
|
||||||
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
|
||||||
Username = "nurse.demo",
|
db.ClinicalUsers.Add(new ClinicalUser
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
|
{
|
||||||
DisplayName = "Demo Nurse",
|
Id = SimulationRunnerUserId,
|
||||||
Role = ClinicalRole.Nurse,
|
Username = "simulation.runner",
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||||
},
|
DisplayName = "Simulation Runner",
|
||||||
new ClinicalUser
|
Role = ClinicalRole.Integration,
|
||||||
{
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
});
|
||||||
Username = "physician.demo",
|
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
|
|
||||||
DisplayName = "Dr. Demo Physician",
|
|
||||||
Role = ClinicalRole.Physician,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
},
|
|
||||||
new ClinicalUser
|
|
||||||
{
|
|
||||||
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
|
||||||
Username = "admin.demo",
|
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
|
|
||||||
DisplayName = "Demo Admin",
|
|
||||||
Role = ClinicalRole.Admin,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
},
|
|
||||||
new ClinicalUser
|
|
||||||
{
|
|
||||||
Id = Guid.Parse("44444444-4444-4444-4444-444444444444"),
|
|
||||||
Username = "integration.mirth",
|
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"),
|
|
||||||
DisplayName = "Mirth Connect",
|
|
||||||
Role = ClinicalRole.Integration,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,8 @@ public class Patient
|
|||||||
public string Status { get; set; } = "active";
|
public string Status { get; set; } = "active";
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when this patient was created by the simulation runner. Never set for ingested clinical data.</summary>
|
||||||
|
public bool IsSimulated { get; set; }
|
||||||
|
|
||||||
public ICollection<Encounter> Encounters { get; set; } = new List<Encounter>();
|
public ICollection<Encounter> Encounters { get; set; } = new List<Encounter>();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
public class SimulationRun
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string ScenarioId { get; set; } = null!;
|
||||||
|
public string ScenarioName { get; set; } = null!;
|
||||||
|
public double Speed { get; set; }
|
||||||
|
public SimulationRunStatus Status { get; set; }
|
||||||
|
public Guid? PatientId { get; set; }
|
||||||
|
public Guid? EncounterId { get; set; }
|
||||||
|
public string StartedByUserId { get; set; } = null!;
|
||||||
|
public DateTimeOffset StartedAt { get; set; }
|
||||||
|
public DateTimeOffset? CompletedAt { get; set; }
|
||||||
|
public int ObservationsSent { get; set; }
|
||||||
|
public int MedicationsSent { get; set; }
|
||||||
|
public int OrdersPlaced { get; set; }
|
||||||
|
public double LastOffsetMinutes { get; set; }
|
||||||
|
public double TotalOffsetMinutes { get; set; }
|
||||||
|
public string? FailureReason { get; set; }
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ public enum AuditAction
|
|||||||
AlertFeedbackSubmitted,
|
AlertFeedbackSubmitted,
|
||||||
UserLogout,
|
UserLogout,
|
||||||
TokenRefreshed,
|
TokenRefreshed,
|
||||||
|
SimulationRunStarted,
|
||||||
|
SimulationRunStopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class AuditActionExtensions
|
public static class AuditActionExtensions
|
||||||
@@ -34,6 +36,8 @@ public static class AuditActionExtensions
|
|||||||
AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED",
|
AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED",
|
||||||
AuditAction.UserLogout => "USER_LOGOUT",
|
AuditAction.UserLogout => "USER_LOGOUT",
|
||||||
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
|
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
|
||||||
|
AuditAction.SimulationRunStarted => "SIMULATION_RUN_STARTED",
|
||||||
|
AuditAction.SimulationRunStopped => "SIMULATION_RUN_STOPPED",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +57,8 @@ public static class AuditActionExtensions
|
|||||||
"ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted,
|
"ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted,
|
||||||
"USER_LOGOUT" => AuditAction.UserLogout,
|
"USER_LOGOUT" => AuditAction.UserLogout,
|
||||||
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
|
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
|
||||||
|
"SIMULATION_RUN_STARTED" => AuditAction.SimulationRunStarted,
|
||||||
|
"SIMULATION_RUN_STOPPED" => AuditAction.SimulationRunStopped,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
public enum SimulationRunStatus { Pending, Running, Completed, Cancelled, Failed }
|
||||||
|
|
||||||
|
public static class SimulationRunStatusExtensions
|
||||||
|
{
|
||||||
|
public static string ToDbString(this SimulationRunStatus s) => s switch
|
||||||
|
{
|
||||||
|
SimulationRunStatus.Pending => "PENDING",
|
||||||
|
SimulationRunStatus.Running => "RUNNING",
|
||||||
|
SimulationRunStatus.Completed => "COMPLETED",
|
||||||
|
SimulationRunStatus.Cancelled => "CANCELLED",
|
||||||
|
SimulationRunStatus.Failed => "FAILED",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(s))
|
||||||
|
};
|
||||||
|
|
||||||
|
public static SimulationRunStatus FromDbString(string v) => v switch
|
||||||
|
{
|
||||||
|
"PENDING" => SimulationRunStatus.Pending,
|
||||||
|
"RUNNING" => SimulationRunStatus.Running,
|
||||||
|
"COMPLETED" => SimulationRunStatus.Completed,
|
||||||
|
"CANCELLED" => SimulationRunStatus.Cancelled,
|
||||||
|
"FAILED" => SimulationRunStatus.Failed,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown simulation run status: '{v}'")
|
||||||
|
};
|
||||||
|
}
|
||||||
+2019
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareClinicalAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSimulationSupport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "is_simulated",
|
||||||
|
table: "patients",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "simulation_runs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
scenario_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
scenario_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
speed = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
encounter_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
started_by_user_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
observations_sent = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
medications_sent = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
orders_placed = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
last_offset_minutes = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
total_offset_minutes = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
failure_reason = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_simulation_runs", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Patients_IsSimulated",
|
||||||
|
table: "patients",
|
||||||
|
column: "is_simulated",
|
||||||
|
filter: "is_simulated = true");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_simulation_runs_status_started_at",
|
||||||
|
table: "simulation_runs",
|
||||||
|
columns: new[] { "status", "started_at" },
|
||||||
|
descending: new[] { false, true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "simulation_runs");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Patients_IsSimulated",
|
||||||
|
table: "patients");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "is_simulated",
|
||||||
|
table: "patients");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1166,6 +1166,12 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
.HasColumnType("character varying(10)")
|
.HasColumnType("character varying(10)")
|
||||||
.HasColumnName("gender");
|
.HasColumnName("gender");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSimulated")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_simulated");
|
||||||
|
|
||||||
b.Property<string>("LastName")
|
b.Property<string>("LastName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -1193,6 +1199,10 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("IsSimulated")
|
||||||
|
.HasDatabaseName("IX_Patients_IsSimulated")
|
||||||
|
.HasFilter("is_simulated = true");
|
||||||
|
|
||||||
b.HasIndex("Mrn")
|
b.HasIndex("Mrn")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
@@ -1524,6 +1534,92 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SimulationRun", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("completed_at");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EncounterId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("encounter_id");
|
||||||
|
|
||||||
|
b.Property<string>("FailureReason")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)")
|
||||||
|
.HasColumnName("failure_reason");
|
||||||
|
|
||||||
|
b.Property<double>("LastOffsetMinutes")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("last_offset_minutes");
|
||||||
|
|
||||||
|
b.Property<int>("MedicationsSent")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("medications_sent");
|
||||||
|
|
||||||
|
b.Property<int>("ObservationsSent")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("observations_sent");
|
||||||
|
|
||||||
|
b.Property<int>("OrdersPlaced")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("orders_placed");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PatientId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("patient_id");
|
||||||
|
|
||||||
|
b.Property<string>("ScenarioId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("scenario_id");
|
||||||
|
|
||||||
|
b.Property<string>("ScenarioName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)")
|
||||||
|
.HasColumnName("scenario_name");
|
||||||
|
|
||||||
|
b.Property<double>("Speed")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("speed");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("started_at");
|
||||||
|
|
||||||
|
b.Property<string>("StartedByUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("started_by_user_id");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<double>("TotalOffsetMinutes")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("total_offset_minutes");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Status", "StartedAt")
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasDatabaseName("IX_simulation_runs_status_started_at");
|
||||||
|
|
||||||
|
b.ToTable("simulation_runs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SofaScore", b =>
|
modelBuilder.Entity("SofaScore", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
public record SimulationConfigResponse(
|
||||||
|
bool Enabled,
|
||||||
|
double? MaxSpeed = null,
|
||||||
|
int? MaxConcurrentRuns = null);
|
||||||
|
|
||||||
|
public record ScenarioSummaryResponse(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
int? DurationMinutes,
|
||||||
|
IReadOnlyList<string>? Tags,
|
||||||
|
string Department,
|
||||||
|
int EventCount,
|
||||||
|
int ExpectedOutcomeCount);
|
||||||
|
|
||||||
|
public record StartSimulationRunRequest(string ScenarioId, double Speed = 60);
|
||||||
|
|
||||||
|
public record SimulationRunResponse(
|
||||||
|
Guid RunId,
|
||||||
|
string ScenarioId,
|
||||||
|
string ScenarioName,
|
||||||
|
string Status,
|
||||||
|
double Speed,
|
||||||
|
Guid? PatientId,
|
||||||
|
Guid? EncounterId,
|
||||||
|
string PatientDisplayName,
|
||||||
|
DateTimeOffset StartedAt,
|
||||||
|
double ElapsedRealSeconds,
|
||||||
|
double LastOffsetMinutes,
|
||||||
|
double TotalOffsetMinutes,
|
||||||
|
double ProgressPercent,
|
||||||
|
int ObservationsSent,
|
||||||
|
int MedicationsSent,
|
||||||
|
int OrdersPlaced,
|
||||||
|
string? FailureReason);
|
||||||
@@ -167,6 +167,23 @@ try
|
|||||||
builder.Services.Configure<AlertQualityOptions>(
|
builder.Services.Configure<AlertQualityOptions>(
|
||||||
builder.Configuration.GetSection(AlertQualityOptions.Section));
|
builder.Configuration.GetSection(AlertQualityOptions.Section));
|
||||||
|
|
||||||
|
builder.Services.Configure<SimulationOptions>(
|
||||||
|
builder.Configuration.GetSection(SimulationOptions.Section));
|
||||||
|
|
||||||
|
var simulationOptions = builder.Configuration
|
||||||
|
.GetSection(SimulationOptions.Section).Get<SimulationOptions>() ?? new();
|
||||||
|
|
||||||
|
if (simulationOptions.Enabled)
|
||||||
|
{
|
||||||
|
builder.Services.AddHttpClient("simulation-loopback", c =>
|
||||||
|
c.BaseAddress = new Uri(simulationOptions.LoopbackBaseUrl));
|
||||||
|
builder.Services.AddSingleton<ISimulationClientFactory, SimulationClientFactory>();
|
||||||
|
builder.Services.AddSingleton<IScenarioCatalog, ScenarioCatalog>();
|
||||||
|
builder.Services.AddSingleton<SimulationRunner>();
|
||||||
|
builder.Services.AddSingleton<ISimulationRunner>(sp => sp.GetRequiredService<SimulationRunner>());
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<SimulationRunner>());
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("Dashboard", policy =>
|
options.AddPolicy("Dashboard", policy =>
|
||||||
@@ -345,6 +362,13 @@ try
|
|||||||
|| args.Contains("create-admin")
|
|| args.Contains("create-admin")
|
||||||
|| args.Contains("register-gateway");
|
|| args.Contains("register-gateway");
|
||||||
|
|
||||||
|
if (simulationOptions.Enabled && !isCliCommand)
|
||||||
|
{
|
||||||
|
Log.Warning(
|
||||||
|
"Simulation mode ENABLED — scenario replay endpoints are exposed. " +
|
||||||
|
"Do not run this configuration against real patient data.");
|
||||||
|
}
|
||||||
|
|
||||||
// Demo data — including the seeded demo users with well-known passwords —
|
// Demo data — including the seeded demo users with well-known passwords —
|
||||||
// must never be created in production. Seeding:EnableDemoData defaults to
|
// must never be created in production. Seeding:EnableDemoData defaults to
|
||||||
// true so local development and the existing verification scripts are
|
// true so local development and the existing verification scripts are
|
||||||
@@ -360,7 +384,16 @@ try
|
|||||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
await DataSeeder.SeedAsync(db, redis);
|
await DataSeeder.SeedAsync(db, redis);
|
||||||
await GatewayRegistrySeeder.SeedAsync(db);
|
await GatewayRegistrySeeder.SeedAsync(db);
|
||||||
await UserSeeder.SeedAsync(db);
|
await UserSeeder.SeedAsync(
|
||||||
|
db,
|
||||||
|
simulationEnabled: simulationOptions.Enabled,
|
||||||
|
simulationRunnerPassword: simulationOptions.RunnerPassword);
|
||||||
|
}
|
||||||
|
else if (simulationOptions.Enabled && !isCliCommand && !app.Environment.IsEnvironment("Testing"))
|
||||||
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await UserSeeder.EnsureSimulationRunnerAsync(db, simulationOptions.RunnerPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.Environment.IsEnvironment("Testing"))
|
if (!app.Environment.IsEnvironment("Testing"))
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
public interface ISimulationClientFactory
|
||||||
|
{
|
||||||
|
Task<VigilCareApiClient> CreateAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
public sealed class RunStateReplayObserver : IReplayObserver
|
||||||
|
{
|
||||||
|
private readonly SimulationRunState _state;
|
||||||
|
|
||||||
|
public RunStateReplayObserver(SimulationRunState state) => _state = state;
|
||||||
|
|
||||||
|
public void Header(string name, string? description) { }
|
||||||
|
|
||||||
|
public void Info(string message) { }
|
||||||
|
|
||||||
|
public void Event(string simTime, string description) =>
|
||||||
|
_state.NoteEvent(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) => SyncFromResult(result);
|
||||||
|
|
||||||
|
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) =>
|
||||||
|
_state.UpdateOffset(offsetMinutes);
|
||||||
|
|
||||||
|
public void SyncFromResult(ReplayResult result)
|
||||||
|
{
|
||||||
|
_state.ApplyResultCounters(
|
||||||
|
result.ObservationsSent,
|
||||||
|
result.MedicationsSent,
|
||||||
|
result.OrdersPlaced);
|
||||||
|
_state.SetIds(
|
||||||
|
result.PatientId == Guid.Empty ? null : result.PatientId,
|
||||||
|
result.EncounterId == Guid.Empty ? null : result.EncounterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
public interface IScenarioCatalog
|
||||||
|
{
|
||||||
|
IReadOnlyList<ScenarioFile> ListScenarios();
|
||||||
|
ScenarioFile? GetById(string scenarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScenarioCatalog : IScenarioCatalog
|
||||||
|
{
|
||||||
|
private readonly string _directory;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private IReadOnlyList<(ScenarioFile Scenario, string Path, DateTime LastWriteUtc)> _entries =
|
||||||
|
Array.Empty<(ScenarioFile, string, DateTime)>();
|
||||||
|
|
||||||
|
public ScenarioCatalog(Microsoft.Extensions.Options.IOptions<SimulationOptions> options)
|
||||||
|
{
|
||||||
|
_directory = options.Value.ScenarioDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ScenarioFile> ListScenarios()
|
||||||
|
{
|
||||||
|
RefreshIfNeeded();
|
||||||
|
return _entries.Select(e => e.Scenario).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScenarioFile? GetById(string scenarioId)
|
||||||
|
{
|
||||||
|
RefreshIfNeeded();
|
||||||
|
return _entries
|
||||||
|
.Select(e => e.Scenario)
|
||||||
|
.FirstOrDefault(s => string.Equals(
|
||||||
|
s.Scenario.Id, scenarioId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshIfNeeded()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_directory))
|
||||||
|
{
|
||||||
|
_entries = Array.Empty<(ScenarioFile, string, DateTime)>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disk = Directory.EnumerateFiles(_directory, "*.json")
|
||||||
|
.Where(p => !string.Equals(
|
||||||
|
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(p => (Path: p, LastWriteUtc: File.GetLastWriteTimeUtc(p)))
|
||||||
|
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var unchanged = _entries.Count == disk.Count
|
||||||
|
&& _entries.Zip(disk, (cached, onDisk) =>
|
||||||
|
cached.Path == onDisk.Path && cached.LastWriteUtc == onDisk.LastWriteUtc)
|
||||||
|
.All(eq => eq);
|
||||||
|
|
||||||
|
if (unchanged)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var loaded = new List<(ScenarioFile Scenario, string Path, DateTime LastWriteUtc)>();
|
||||||
|
foreach (var file in disk)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loaded.Add((ScenarioLoader.Load(file.Path), file.Path, file.LastWriteUtc));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Skip corrupt files — catalogue must stay resilient.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_entries = loaded
|
||||||
|
.OrderBy(e => e.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
public sealed class SimulationClientFactory : ISimulationClientFactory
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _http;
|
||||||
|
private readonly SimulationOptions _options;
|
||||||
|
|
||||||
|
public SimulationClientFactory(IHttpClientFactory http, IOptions<SimulationOptions> options)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VigilCareApiClient> CreateAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.RunnerPassword))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Simulation:RunnerPassword is required when Simulation:Enabled is true.");
|
||||||
|
|
||||||
|
var http = _http.CreateClient("simulation-loopback");
|
||||||
|
var client = new VigilCareApiClient(http);
|
||||||
|
await client.LoginAsync(_options.RunnerUsername, _options.RunnerPassword);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
public sealed class SimulationRunState
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private DateTimeOffset? _completedAt;
|
||||||
|
|
||||||
|
public Guid RunId { get; init; }
|
||||||
|
public string ScenarioId { get; init; } = null!;
|
||||||
|
public string ScenarioName { get; init; } = null!;
|
||||||
|
public double Speed { get; init; }
|
||||||
|
public string StartedByUserId { get; init; } = null!;
|
||||||
|
public DateTimeOffset StartedAt { get; init; }
|
||||||
|
public double TotalOffsetMinutes { get; init; }
|
||||||
|
public string PatientDisplayName { get; init; } = null!;
|
||||||
|
|
||||||
|
public SimulationRunStatus Status { get; private set; } = SimulationRunStatus.Pending;
|
||||||
|
public Guid? PatientId { get; private set; }
|
||||||
|
public Guid? EncounterId { get; private set; }
|
||||||
|
public int ObservationsSent { get; private set; }
|
||||||
|
public int MedicationsSent { get; private set; }
|
||||||
|
public int OrdersPlaced { get; private set; }
|
||||||
|
public double LastOffsetMinutes { get; private set; }
|
||||||
|
public double ProgressPercent { get; private set; }
|
||||||
|
public string? FailureReason { get; private set; }
|
||||||
|
|
||||||
|
public double ElapsedRealSeconds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var end = _completedAt ?? DateTimeOffset.UtcNow;
|
||||||
|
return (end - StartedAt).TotalSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkRunning()
|
||||||
|
{
|
||||||
|
lock (_gate) Status = SimulationRunStatus.Running;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetIds(Guid? patientId, Guid? encounterId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (patientId.HasValue) PatientId = patientId;
|
||||||
|
if (encounterId.HasValue) EncounterId = encounterId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateOffset(double offsetMinutes)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
LastOffsetMinutes = offsetMinutes;
|
||||||
|
ProgressPercent = TotalOffsetMinutes <= 0
|
||||||
|
? 100
|
||||||
|
: Math.Clamp(offsetMinutes / TotalOffsetMinutes * 100.0, 0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyResultCounters(int observationsSent, int medicationsSent, int ordersPlaced)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ObservationsSent = observationsSent;
|
||||||
|
MedicationsSent = medicationsSent;
|
||||||
|
OrdersPlaced = ordersPlaced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NoteEvent(string description)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (description.StartsWith("MEDICATION", StringComparison.Ordinal))
|
||||||
|
MedicationsSent++;
|
||||||
|
else if (description.StartsWith("ORDER ", StringComparison.Ordinal))
|
||||||
|
OrdersPlaced++;
|
||||||
|
else if (!description.StartsWith("ORDER_RESULT", StringComparison.Ordinal)
|
||||||
|
&& !description.StartsWith("ACK ", StringComparison.Ordinal))
|
||||||
|
ObservationsSent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkTerminal(SimulationRunStatus status, string? failureReason = null)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
Status = status;
|
||||||
|
FailureReason = failureReason;
|
||||||
|
_completedAt = DateTimeOffset.UtcNow;
|
||||||
|
if (status == SimulationRunStatus.Completed)
|
||||||
|
ProgressPercent = 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SimulationRunState Snapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var copy = new SimulationRunState
|
||||||
|
{
|
||||||
|
RunId = RunId,
|
||||||
|
ScenarioId = ScenarioId,
|
||||||
|
ScenarioName = ScenarioName,
|
||||||
|
Speed = Speed,
|
||||||
|
StartedByUserId = StartedByUserId,
|
||||||
|
StartedAt = StartedAt,
|
||||||
|
TotalOffsetMinutes = TotalOffsetMinutes,
|
||||||
|
PatientDisplayName = PatientDisplayName,
|
||||||
|
};
|
||||||
|
copy.Status = Status;
|
||||||
|
copy.PatientId = PatientId;
|
||||||
|
copy.EncounterId = EncounterId;
|
||||||
|
copy.ObservationsSent = ObservationsSent;
|
||||||
|
copy.MedicationsSent = MedicationsSent;
|
||||||
|
copy.OrdersPlaced = OrdersPlaced;
|
||||||
|
copy.LastOffsetMinutes = LastOffsetMinutes;
|
||||||
|
copy.ProgressPercent = ProgressPercent;
|
||||||
|
copy.FailureReason = FailureReason;
|
||||||
|
copy._completedAt = _completedAt;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using VigilCare.Simulation;
|
||||||
|
|
||||||
|
public interface ISimulationRunner
|
||||||
|
{
|
||||||
|
IReadOnlyList<SimulationRunState> ListRuns();
|
||||||
|
SimulationRunState? GetRun(Guid runId);
|
||||||
|
Task<SimulationRunState> StartAsync(
|
||||||
|
string scenarioId, double speed, string startedByUserId, CancellationToken ct);
|
||||||
|
bool Cancel(Guid runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, RunContext> _runs = new();
|
||||||
|
private readonly ISimulationClientFactory _clientFactory;
|
||||||
|
private readonly IScenarioCatalog _catalog;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly SimulationOptions _options;
|
||||||
|
private readonly ILogger<SimulationRunner> _logger;
|
||||||
|
|
||||||
|
public SimulationRunner(
|
||||||
|
ISimulationClientFactory clientFactory,
|
||||||
|
IScenarioCatalog catalog,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<SimulationOptions> options,
|
||||||
|
ILogger<SimulationRunner> logger)
|
||||||
|
{
|
||||||
|
_clientFactory = clientFactory;
|
||||||
|
_catalog = catalog;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SimulationRunState> ListRuns() =>
|
||||||
|
_runs.Values
|
||||||
|
.Select(c => c.State.Snapshot())
|
||||||
|
.OrderByDescending(s => s.StartedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public SimulationRunState? GetRun(Guid runId) =>
|
||||||
|
_runs.TryGetValue(runId, out var ctx) ? ctx.State.Snapshot() : null;
|
||||||
|
|
||||||
|
public async Task<SimulationRunState> StartAsync(
|
||||||
|
string scenarioId, double speed, string startedByUserId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(scenarioId))
|
||||||
|
throw new ValidationException("scenarioId is required.", "SIMULATION_SCENARIO_REQUIRED");
|
||||||
|
|
||||||
|
if (speed <= 0 || speed > _options.MaxSpeed)
|
||||||
|
throw new ValidationException(
|
||||||
|
$"speed must be > 0 and <= {_options.MaxSpeed}.", "SIMULATION_SPEED_INVALID");
|
||||||
|
|
||||||
|
var scenario = _catalog.GetById(scenarioId)
|
||||||
|
?? throw new ValidationException(
|
||||||
|
$"Unknown scenario '{scenarioId}'.", "SIMULATION_SCENARIO_UNKNOWN");
|
||||||
|
|
||||||
|
var activeCount = _runs.Values.Count(c =>
|
||||||
|
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||||
|
if (activeCount >= _options.MaxConcurrentRuns)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Maximum concurrent simulation runs ({_options.MaxConcurrentRuns}) reached.",
|
||||||
|
"SIMULATION_CONCURRENCY_LIMIT");
|
||||||
|
|
||||||
|
var totalOffset = scenario.Events.Count == 0
|
||||||
|
? 0
|
||||||
|
: scenario.Events.Max(e => e.OffsetMinutes);
|
||||||
|
|
||||||
|
var runId = Guid.NewGuid();
|
||||||
|
var startedAt = DateTimeOffset.UtcNow;
|
||||||
|
var state = new SimulationRunState
|
||||||
|
{
|
||||||
|
RunId = runId,
|
||||||
|
ScenarioId = scenario.Scenario.Id,
|
||||||
|
ScenarioName = scenario.Scenario.Name,
|
||||||
|
Speed = speed,
|
||||||
|
StartedByUserId = startedByUserId,
|
||||||
|
StartedAt = startedAt,
|
||||||
|
TotalOffsetMinutes = totalOffset,
|
||||||
|
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
|
||||||
|
};
|
||||||
|
|
||||||
|
await PersistNewRunAsync(state, ct);
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var ctx = new RunContext(state, cts, scenario);
|
||||||
|
if (!_runs.TryAdd(runId, ctx))
|
||||||
|
throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED");
|
||||||
|
|
||||||
|
_ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None);
|
||||||
|
|
||||||
|
return state.Snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Cancel(Guid runId)
|
||||||
|
{
|
||||||
|
if (!_runs.TryGetValue(runId, out var ctx))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (ctx.State.Status is SimulationRunStatus.Completed
|
||||||
|
or SimulationRunStatus.Cancelled
|
||||||
|
or SimulationRunStatus.Failed)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
ctx.Cts.Cancel();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var ctx in _runs.Values)
|
||||||
|
{
|
||||||
|
if (ctx.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running)
|
||||||
|
ctx.Cts.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTimeOffset.UtcNow < deadline
|
||||||
|
&& _runs.Values.Any(c =>
|
||||||
|
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running))
|
||||||
|
{
|
||||||
|
await Task.Delay(50, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteAsync(RunContext ctx, CancellationToken _)
|
||||||
|
{
|
||||||
|
var runId = ctx.State.RunId;
|
||||||
|
ctx.State.MarkRunning();
|
||||||
|
await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Running);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = await _clientFactory.CreateAsync(ctx.Cts.Token);
|
||||||
|
var observer = new RunStateReplayObserver(ctx.State);
|
||||||
|
var engine = new ReplayEngine(
|
||||||
|
client,
|
||||||
|
poller: null,
|
||||||
|
observer,
|
||||||
|
onPatientRegistered: (patientId, ct) => MarkPatientSimulatedAsync(patientId, ct));
|
||||||
|
|
||||||
|
var result = await engine.RunAsync(
|
||||||
|
ctx.Scenario,
|
||||||
|
new ReplayOptions(Speed: ctx.State.Speed, Poll: false),
|
||||||
|
ctx.Cts.Token);
|
||||||
|
|
||||||
|
observer.SyncFromResult(result);
|
||||||
|
ctx.State.MarkTerminal(SimulationRunStatus.Completed);
|
||||||
|
await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Completed);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
ctx.State.MarkTerminal(SimulationRunStatus.Cancelled);
|
||||||
|
await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Cancelled);
|
||||||
|
_logger.LogInformation("Simulation run {RunId} cancelled", runId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ctx.State.MarkTerminal(SimulationRunStatus.Failed, ex.Message);
|
||||||
|
await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Failed, ex.Message);
|
||||||
|
_logger.LogError(ex, "Simulation run {RunId} failed", runId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ctx.Cts.Dispose();
|
||||||
|
TrimHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkPatientSimulatedAsync(Guid patientId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var patient = await db.Patients.FirstOrDefaultAsync(p => p.Id == patientId, ct);
|
||||||
|
if (patient is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
patient.IsSimulated = true;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistNewRunAsync(SimulationRunState state, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
db.SimulationRuns.Add(new SimulationRun
|
||||||
|
{
|
||||||
|
Id = state.RunId,
|
||||||
|
ScenarioId = state.ScenarioId,
|
||||||
|
ScenarioName = state.ScenarioName,
|
||||||
|
Speed = state.Speed,
|
||||||
|
Status = SimulationRunStatus.Pending,
|
||||||
|
StartedByUserId = state.StartedByUserId,
|
||||||
|
StartedAt = state.StartedAt,
|
||||||
|
TotalOffsetMinutes = state.TotalOffsetMinutes,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateRunRowAsync(
|
||||||
|
SimulationRunState state,
|
||||||
|
SimulationRunStatus status,
|
||||||
|
string? failureReason = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var row = await db.SimulationRuns.FirstOrDefaultAsync(r => r.Id == state.RunId);
|
||||||
|
if (row is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
row.Status = status;
|
||||||
|
row.PatientId = state.PatientId;
|
||||||
|
row.EncounterId = state.EncounterId;
|
||||||
|
row.ObservationsSent = state.ObservationsSent;
|
||||||
|
row.MedicationsSent = state.MedicationsSent;
|
||||||
|
row.OrdersPlaced = state.OrdersPlaced;
|
||||||
|
row.LastOffsetMinutes = state.LastOffsetMinutes;
|
||||||
|
row.TotalOffsetMinutes = state.TotalOffsetMinutes;
|
||||||
|
row.FailureReason = failureReason ?? state.FailureReason;
|
||||||
|
if (status is SimulationRunStatus.Completed
|
||||||
|
or SimulationRunStatus.Cancelled
|
||||||
|
or SimulationRunStatus.Failed)
|
||||||
|
{
|
||||||
|
row.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to persist simulation run {RunId} status {Status}",
|
||||||
|
state.RunId, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrimHistory()
|
||||||
|
{
|
||||||
|
var terminal = _runs.Values
|
||||||
|
.Where(c => c.State.Status is SimulationRunStatus.Completed
|
||||||
|
or SimulationRunStatus.Cancelled
|
||||||
|
or SimulationRunStatus.Failed)
|
||||||
|
.OrderByDescending(c => c.State.StartedAt)
|
||||||
|
.Skip(_options.RunHistoryLimit)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var old in terminal)
|
||||||
|
_runs.TryRemove(old.State.RunId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RunContext(
|
||||||
|
SimulationRunState state,
|
||||||
|
CancellationTokenSource cts,
|
||||||
|
ScenarioFile scenario)
|
||||||
|
{
|
||||||
|
public SimulationRunState State { get; } = state;
|
||||||
|
public CancellationTokenSource Cts { get; } = cts;
|
||||||
|
public ScenarioFile Scenario { get; } = scenario;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
|
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
|
||||||
|
<ProjectReference Include="..\VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -39,5 +39,10 @@
|
|||||||
"LogListAccess": true
|
"LogListAccess": true
|
||||||
},
|
},
|
||||||
"Swagger": { "Enabled": false },
|
"Swagger": { "Enabled": false },
|
||||||
"Seeding": { "EnableDemoData": false }
|
"Seeding": { "EnableDemoData": false },
|
||||||
}
|
"Simulation": {
|
||||||
|
// Patient-safety gate: never expose scenario replay against real care data.
|
||||||
|
// Flip only for dedicated training/staging environments with synthetic patients.
|
||||||
|
"Enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,5 +22,15 @@
|
|||||||
"DataLake": {
|
"DataLake": {
|
||||||
"FlushCount": 3,
|
"FlushCount": 3,
|
||||||
"FlushIntervalSeconds": 10
|
"FlushIntervalSeconds": 10
|
||||||
|
},
|
||||||
|
"Simulation": {
|
||||||
|
"Enabled": true,
|
||||||
|
"ScenarioDirectory": "../VigilCare.Simulator/Scenarios/List",
|
||||||
|
"LoopbackBaseUrl": "http://localhost:5270",
|
||||||
|
"RunnerUsername": "simulation.runner",
|
||||||
|
"RunnerPassword": "DemoSimulation1!",
|
||||||
|
"MaxConcurrentRuns": 8,
|
||||||
|
"MaxSpeed": 600,
|
||||||
|
"RunHistoryLimit": 50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,5 +228,14 @@
|
|||||||
},
|
},
|
||||||
"Seeding": {
|
"Seeding": {
|
||||||
"EnableDemoData": true
|
"EnableDemoData": true
|
||||||
|
},
|
||||||
|
"Simulation": {
|
||||||
|
"Enabled": false,
|
||||||
|
"ScenarioDirectory": "Scenarios",
|
||||||
|
"LoopbackBaseUrl": "http://localhost:5270",
|
||||||
|
"RunnerUsername": "simulation.runner",
|
||||||
|
"MaxConcurrentRuns": 8,
|
||||||
|
"MaxSpeed": 600,
|
||||||
|
"RunHistoryLimit": 50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user