feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -63,6 +63,51 @@ public class AlertQualityMetricsService : IAlertQualityMetricsService
|
||||
: weightedResolveSeconds / snapshots.Sum(s => s.ResolvedCount));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AlertFeedbackQualityRow>> ListFeedbackAsync(
|
||||
DateTimeOffset periodStart, DateTimeOffset periodEnd, string? scenarioId)
|
||||
{
|
||||
// Left-join ClinicalAlert → SimulationRun on EncounterId for attribution.
|
||||
var query =
|
||||
from f in _db.AlertFeedbacks.AsNoTracking()
|
||||
join a in _db.ClinicalAlerts.AsNoTracking() on f.AlertId equals a.Id
|
||||
join r in _db.SimulationRuns.AsNoTracking()
|
||||
on a.EncounterId equals r.EncounterId into runs
|
||||
from r in runs.DefaultIfEmpty()
|
||||
where f.CreatedAt >= periodStart && f.CreatedAt <= periodEnd
|
||||
select new { Feedback = f, Alert = a, Run = r };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scenarioId))
|
||||
{
|
||||
var needle = scenarioId.ToLowerInvariant();
|
||||
query = query.Where(x =>
|
||||
x.Run != null
|
||||
&& x.Run.ScenarioId != null
|
||||
&& x.Run.ScenarioId.ToLower() == needle);
|
||||
}
|
||||
|
||||
var rows = await query
|
||||
.OrderByDescending(x => x.Feedback.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// Distinct by feedback id in case multiple SimulationRun rows share an EncounterId.
|
||||
return rows
|
||||
.GroupBy(x => x.Feedback.Id)
|
||||
.Select(g =>
|
||||
{
|
||||
var x = g.First();
|
||||
return new AlertFeedbackQualityRow(
|
||||
x.Feedback.Id,
|
||||
x.Alert.Id,
|
||||
x.Alert.AlertType.ToDbString(),
|
||||
x.Feedback.FeedbackType.ToDbString(),
|
||||
x.Feedback.Comment,
|
||||
x.Feedback.CreatedAt,
|
||||
x.Run?.ScenarioId,
|
||||
x.Run?.SessionId);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static AlertQualityMetricResponse Map(AlertQualityMetric m) =>
|
||||
new(
|
||||
m.Id,
|
||||
@@ -84,4 +129,4 @@ public class AlertQualityMetricsService : IAlertQualityMetricsService
|
||||
m.AvgSecondsToAcknowledge,
|
||||
m.AvgSecondsToResolution,
|
||||
m.ComputedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,12 @@ public interface IAlertQualityMetricsService
|
||||
|
||||
Task<AlertQualitySummaryResponse> GetSummaryAsync(
|
||||
DateTimeOffset from, DateTimeOffset to);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-alert feedback rows for analytics export / by-scenario breakdown.
|
||||
/// When <paramref name="scenarioId"/> is set, only rows attributed to that
|
||||
/// simulation scenario are returned (join via SimulationRun.EncounterId).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AlertFeedbackQualityRow>> ListFeedbackAsync(
|
||||
DateTimeOffset periodStart, DateTimeOffset periodEnd, string? scenarioId);
|
||||
}
|
||||
|
||||
@@ -44,8 +44,12 @@ public sealed class ScenarioCatalog : IScenarioCatalog
|
||||
}
|
||||
|
||||
var disk = Directory.EnumerateFiles(_directory, "*.json")
|
||||
.Where(p => !string.Equals(
|
||||
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(p =>
|
||||
{
|
||||
var name = Path.GetFileName(p);
|
||||
return !string.Equals(name, "schema.json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(name, "sessions.json", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.Select(p => (Path: p, LastWriteUtc: File.GetLastWriteTimeUtc(p)))
|
||||
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public interface ISessionCatalog
|
||||
{
|
||||
IReadOnlyList<SessionPreset> ListSessions();
|
||||
SessionPreset? GetById(string sessionId);
|
||||
}
|
||||
|
||||
public sealed record SessionPreset(
|
||||
string Id,
|
||||
string Name,
|
||||
string Goal,
|
||||
int EstimatedMinutes,
|
||||
double DefaultSpeed,
|
||||
IReadOnlyList<string> Scenarios);
|
||||
|
||||
/// <summary>
|
||||
/// Loads <c>sessions.json</c> from the scenario directory (or its parent) and
|
||||
/// validates every referenced scenario id against <see cref="IScenarioCatalog"/>
|
||||
/// at construction — a typo fails loudly at startup.
|
||||
/// </summary>
|
||||
public sealed class SessionCatalog : ISessionCatalog
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly IReadOnlyList<SessionPreset> _sessions;
|
||||
|
||||
public SessionCatalog(IOptions<SimulationOptions> options, IScenarioCatalog scenarios)
|
||||
{
|
||||
var directory = options.Value.ScenarioDirectory;
|
||||
var path = ResolveManifestPath(directory)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Simulation session manifest not found. Expected sessions.json in or beside " +
|
||||
$"ScenarioDirectory '{Path.GetFullPath(directory)}'.");
|
||||
|
||||
_sessions = LoadAndValidate(path, scenarios);
|
||||
}
|
||||
|
||||
public IReadOnlyList<SessionPreset> ListSessions() => _sessions;
|
||||
|
||||
public SessionPreset? GetById(string sessionId) =>
|
||||
_sessions.FirstOrDefault(s =>
|
||||
string.Equals(s.Id, sessionId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
internal static string? ResolveManifestPath(string scenarioDirectory)
|
||||
{
|
||||
var inDir = Path.Combine(scenarioDirectory, "sessions.json");
|
||||
if (File.Exists(inDir))
|
||||
return inDir;
|
||||
|
||||
var parent = Directory.GetParent(Path.GetFullPath(scenarioDirectory))?.FullName;
|
||||
if (parent is not null)
|
||||
{
|
||||
var beside = Path.Combine(parent, "sessions.json");
|
||||
if (File.Exists(beside))
|
||||
return beside;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<SessionPreset> LoadAndValidate(
|
||||
string path, IScenarioCatalog scenarios)
|
||||
{
|
||||
SessionsDocument doc;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
doc = JsonSerializer.Deserialize<SessionsDocument>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Session manifest '{path}' deserialized to null.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not InvalidOperationException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to load simulation session manifest '{path}': {ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (doc.Sessions is null || doc.Sessions.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Session manifest '{path}' contains no sessions.");
|
||||
|
||||
var presets = new List<SessionPreset>(doc.Sessions.Count);
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var entry in doc.Sessions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Id))
|
||||
throw new InvalidOperationException(
|
||||
$"Session manifest '{path}' has a session with an empty id.");
|
||||
|
||||
if (entry.Scenarios is null || entry.Scenarios.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Session '{entry.Id}' in '{path}' lists no scenarios.");
|
||||
|
||||
foreach (var scenarioId in entry.Scenarios)
|
||||
{
|
||||
if (scenarios.GetById(scenarioId) is null)
|
||||
missing.Add($"{entry.Id} → {scenarioId}");
|
||||
}
|
||||
|
||||
presets.Add(new SessionPreset(
|
||||
entry.Id,
|
||||
entry.Name ?? entry.Id,
|
||||
entry.Goal ?? string.Empty,
|
||||
entry.EstimatedMinutes,
|
||||
entry.DefaultSpeed <= 0 ? 60 : entry.DefaultSpeed,
|
||||
entry.Scenarios.AsReadOnly()));
|
||||
}
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Session manifest references unknown scenario id(s): " +
|
||||
string.Join("; ", missing));
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
private sealed class SessionsDocument
|
||||
{
|
||||
public List<SessionEntry>? Sessions { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SessionEntry
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string? Name { get; set; }
|
||||
public string? Goal { get; set; }
|
||||
public int EstimatedMinutes { get; set; }
|
||||
public double DefaultSpeed { get; set; }
|
||||
public List<string>? Scenarios { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public interface ISimulationPurgeService
|
||||
{
|
||||
Task<SimulationDataSummary> GetSummaryAsync(CancellationToken ct);
|
||||
Task<SimulationPurgeResult> PurgeAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public record SimulationDataSummary(
|
||||
int SimulatedPatients,
|
||||
int Encounters,
|
||||
int Observations,
|
||||
int Alerts,
|
||||
int ActiveRuns);
|
||||
|
||||
public record SimulationPurgeResult(
|
||||
int PatientsDeleted,
|
||||
int EncountersDeleted,
|
||||
int AlertsDeleted,
|
||||
int RunsCleared);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all patients with <c>IsSimulated = true</c> and their dependents.
|
||||
/// Never deletes by encounter or date range — only the simulated-patient predicate.
|
||||
/// </summary>
|
||||
public sealed class SimulationPurgeService : ISimulationPurgeService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ISimulationRunner _runner;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public SimulationPurgeService(
|
||||
AppDbContext db,
|
||||
ISimulationRunner runner,
|
||||
IAuditService audit)
|
||||
{
|
||||
_db = db;
|
||||
_runner = runner;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<SimulationDataSummary> GetSummaryAsync(CancellationToken ct)
|
||||
{
|
||||
var patientIds = await _db.Patients
|
||||
.Where(p => p.IsSimulated)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (patientIds.Count == 0)
|
||||
{
|
||||
return new SimulationDataSummary(
|
||||
0, 0, 0, 0,
|
||||
_runner.ListRuns().Count(r =>
|
||||
r.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running));
|
||||
}
|
||||
|
||||
var encounterIds = await _db.Encounters
|
||||
.Where(e => patientIds.Contains(e.PatientId))
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var observations = encounterIds.Count == 0
|
||||
? 0
|
||||
: await _db.Observations.CountAsync(o => encounterIds.Contains(o.EncounterId), ct);
|
||||
|
||||
var alerts = await _db.ClinicalAlerts
|
||||
.CountAsync(a => patientIds.Contains(a.PatientId), ct);
|
||||
|
||||
var activeRuns = _runner.ListRuns().Count(r =>
|
||||
r.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||
|
||||
return new SimulationDataSummary(
|
||||
patientIds.Count,
|
||||
encounterIds.Count,
|
||||
observations,
|
||||
alerts,
|
||||
activeRuns);
|
||||
}
|
||||
|
||||
public async Task<SimulationPurgeResult> PurgeAsync(CancellationToken ct)
|
||||
{
|
||||
if (_runner.HasActiveRuns())
|
||||
throw new ConflictException(
|
||||
"Cannot purge simulated data while runs are Pending or Running. Stop all runs first.",
|
||||
"SIMULATION_PURGE_ACTIVE_RUNS");
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var patientIds = await _db.Patients
|
||||
.Where(p => p.IsSimulated)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (patientIds.Count == 0)
|
||||
{
|
||||
var emptyRunsCleared = await ClearSimulationRunsAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
_runner.ClearRegistry();
|
||||
|
||||
var emptyResult = new SimulationPurgeResult(0, 0, 0, emptyRunsCleared);
|
||||
await WriteAuditAsync(emptyResult);
|
||||
return emptyResult;
|
||||
}
|
||||
|
||||
var encounterIds = await _db.Encounters
|
||||
.Where(e => patientIds.Contains(e.PatientId))
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var alertIds = await _db.ClinicalAlerts
|
||||
.Where(a => patientIds.Contains(a.PatientId))
|
||||
.Select(a => a.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var alertsDeleted = alertIds.Count;
|
||||
var encountersDeleted = encounterIds.Count;
|
||||
var patientsDeleted = patientIds.Count;
|
||||
|
||||
// FK order — Restrict relationships require dependents first.
|
||||
if (encounterIds.Count > 0)
|
||||
{
|
||||
await _db.MedicationAdministrations
|
||||
.Where(m => encounterIds.Contains(m.EncounterId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
var bundleIds = await _db.SepsisBundles
|
||||
.Where(b => encounterIds.Contains(b.EncounterId))
|
||||
.Select(b => b.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (bundleIds.Count > 0)
|
||||
{
|
||||
await _db.SepsisBundleElements
|
||||
.Where(e => bundleIds.Contains(e.BundleId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await _db.SepsisBundles
|
||||
.Where(b => bundleIds.Contains(b.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
await _db.Orders
|
||||
.Where(o => encounterIds.Contains(o.EncounterId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
await _db.ReconciliationAlerts
|
||||
.Where(r => r.EncounterId != null && encounterIds.Contains(r.EncounterId.Value))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
var encounterKeys = encounterIds.Select(id => id.ToString()).ToList();
|
||||
await _db.OutboxEvents
|
||||
.Where(o => o.PartitionKey != null && encounterKeys.Contains(o.PartitionKey))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
await _db.Observations
|
||||
.Where(o => encounterIds.Contains(o.EncounterId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
await _db.News2Scores
|
||||
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await _db.SofaScores
|
||||
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await _db.GcsScores
|
||||
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await _db.QsofaEvaluations
|
||||
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
if (alertIds.Count > 0)
|
||||
{
|
||||
// AlertFeedback cascades with alert, but delete explicitly for clarity.
|
||||
await _db.AlertFeedbacks
|
||||
.Where(f => alertIds.Contains(f.AlertId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await _db.ClinicalAlerts
|
||||
.Where(a => alertIds.Contains(a.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
// Window aggregates are not patient-scoped; clear them so the next tester
|
||||
// starts with a clean quality dashboard after a ward reset.
|
||||
await _db.AlertQualityMetrics.ExecuteDeleteAsync(ct);
|
||||
|
||||
await _db.PhiAccessLogs
|
||||
.Where(l => l.PatientId != null && patientIds.Contains(l.PatientId.Value))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
var resourceIds = patientIds.Concat(encounterIds).ToList();
|
||||
if (resourceIds.Count > 0)
|
||||
{
|
||||
await _db.ExternalResourceIdentifiers
|
||||
.Where(x => resourceIds.Contains(x.InternalId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
if (encounterIds.Count > 0)
|
||||
{
|
||||
await _db.Encounters
|
||||
.Where(e => encounterIds.Contains(e.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
await _db.Patients
|
||||
.Where(p => patientIds.Contains(p.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
var runsCleared = await ClearSimulationRunsAsync(ct);
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
_runner.ClearRegistry();
|
||||
|
||||
var result = new SimulationPurgeResult(
|
||||
patientsDeleted, encountersDeleted, alertsDeleted, runsCleared);
|
||||
await WriteAuditAsync(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<int> ClearSimulationRunsAsync(CancellationToken ct)
|
||||
{
|
||||
var count = await _db.SimulationRuns.CountAsync(ct);
|
||||
if (count > 0)
|
||||
await _db.SimulationRuns.ExecuteDeleteAsync(ct);
|
||||
return count;
|
||||
}
|
||||
|
||||
private async Task WriteAuditAsync(SimulationPurgeResult result)
|
||||
{
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.SimulationDataPurged,
|
||||
"SimulationData",
|
||||
Guid.NewGuid(),
|
||||
newValue: new
|
||||
{
|
||||
result.PatientsDeleted,
|
||||
result.EncountersDeleted,
|
||||
result.AlertsDeleted,
|
||||
result.RunsCleared,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ public sealed class SimulationRunState
|
||||
public string ScenarioId { get; init; } = null!;
|
||||
public string ScenarioName { get; init; } = null!;
|
||||
public double Speed { get; init; }
|
||||
public string? SessionId { get; init; }
|
||||
public string StartedByUserId { get; init; } = null!;
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
public double TotalOffsetMinutes { get; init; }
|
||||
@@ -105,6 +106,7 @@ public sealed class SimulationRunState
|
||||
ScenarioId = ScenarioId,
|
||||
ScenarioName = ScenarioName,
|
||||
Speed = Speed,
|
||||
SessionId = SessionId,
|
||||
StartedByUserId = StartedByUserId,
|
||||
StartedAt = StartedAt,
|
||||
TotalOffsetMinutes = TotalOffsetMinutes,
|
||||
|
||||
@@ -8,15 +8,23 @@ public interface ISimulationRunner
|
||||
IReadOnlyList<SimulationRunState> ListRuns();
|
||||
SimulationRunState? GetRun(Guid runId);
|
||||
Task<SimulationRunState> StartAsync(
|
||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct);
|
||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct,
|
||||
string? sessionId = null);
|
||||
Task<SimulationSessionState> StartSessionAsync(
|
||||
string sessionId, double? speed, string userId, CancellationToken ct);
|
||||
bool Cancel(Guid runId);
|
||||
bool HasActiveRuns();
|
||||
void ClearRegistry();
|
||||
}
|
||||
|
||||
public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
{
|
||||
private static readonly TimeSpan SessionStagger = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, RunContext> _runs = new();
|
||||
private readonly ISimulationClientFactory _clientFactory;
|
||||
private readonly IScenarioCatalog _catalog;
|
||||
private readonly ISessionCatalog _sessions;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly SimulationOptions _options;
|
||||
private readonly ILogger<SimulationRunner> _logger;
|
||||
@@ -24,12 +32,14 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
public SimulationRunner(
|
||||
ISimulationClientFactory clientFactory,
|
||||
IScenarioCatalog catalog,
|
||||
ISessionCatalog sessions,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<SimulationOptions> options,
|
||||
ILogger<SimulationRunner> logger)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_catalog = catalog;
|
||||
_sessions = sessions;
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
@@ -44,8 +54,15 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
public SimulationRunState? GetRun(Guid runId) =>
|
||||
_runs.TryGetValue(runId, out var ctx) ? ctx.State.Snapshot() : null;
|
||||
|
||||
public bool HasActiveRuns() =>
|
||||
_runs.Values.Any(c =>
|
||||
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||
|
||||
public void ClearRegistry() => _runs.Clear();
|
||||
|
||||
public async Task<SimulationRunState> StartAsync(
|
||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct)
|
||||
string scenarioId, double speed, string startedByUserId, CancellationToken ct,
|
||||
string? sessionId = null)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
|
||||
@@ -68,34 +85,77 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
$"Maximum concurrent simulation runs ({_options.MaxConcurrentRuns}) reached.",
|
||||
"SIMULATION_CONCURRENCY_LIMIT");
|
||||
|
||||
var totalOffset = scenario.Events.Count == 0
|
||||
? 0
|
||||
: scenario.Events.Max(e => e.OffsetMinutes);
|
||||
return await RegisterAndStartAsync(scenario, speed, startedByUserId, sessionId, ct);
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid();
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var state = new SimulationRunState
|
||||
public async Task<SimulationSessionState> StartSessionAsync(
|
||||
string sessionId, double? speed, string userId, CancellationToken ct)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
throw new ValidationException("sessionId is required.", "SIMULATION_SESSION_REQUIRED");
|
||||
|
||||
var preset = _sessions.GetById(sessionId)
|
||||
?? throw new ValidationException(
|
||||
$"Unknown session '{sessionId}'.", "SIMULATION_SESSION_UNKNOWN");
|
||||
|
||||
var resolvedSpeed = speed ?? preset.DefaultSpeed;
|
||||
if (resolvedSpeed <= 0 || resolvedSpeed > _options.MaxSpeed)
|
||||
throw new ValidationException(
|
||||
$"speed must be > 0 and <= {_options.MaxSpeed}.", "SIMULATION_SPEED_INVALID");
|
||||
|
||||
// Resolve every scenario up front so a typo never half-starts a session.
|
||||
var scenarios = new List<ScenarioFile>(preset.Scenarios.Count);
|
||||
foreach (var id in preset.Scenarios)
|
||||
{
|
||||
RunId = runId,
|
||||
ScenarioId = scenario.Scenario.Id,
|
||||
ScenarioName = scenario.Scenario.Name,
|
||||
Speed = speed,
|
||||
StartedByUserId = startedByUserId,
|
||||
var scenario = _catalog.GetById(id)
|
||||
?? throw new ValidationException(
|
||||
$"Unknown scenario '{id}' in session '{preset.Id}'.",
|
||||
"SIMULATION_SCENARIO_UNKNOWN");
|
||||
scenarios.Add(scenario);
|
||||
}
|
||||
|
||||
// All-or-nothing admission — check capacity before starting anything.
|
||||
var activeCount = _runs.Values.Count(c =>
|
||||
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
|
||||
if (activeCount + scenarios.Count > _options.MaxConcurrentRuns)
|
||||
throw new ConflictException(
|
||||
$"Session '{preset.Id}' needs {scenarios.Count} runs but only " +
|
||||
$"{_options.MaxConcurrentRuns - activeCount} slot(s) remain " +
|
||||
$"(max {_options.MaxConcurrentRuns}).",
|
||||
"SIMULATION_CONCURRENCY_LIMIT");
|
||||
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var started = new List<SimulationRunState>(scenarios.Count);
|
||||
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < scenarios.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
await Task.Delay(SessionStagger, ct);
|
||||
|
||||
var state = await RegisterAndStartAsync(
|
||||
scenarios[i], resolvedSpeed, userId, preset.Id, ct);
|
||||
started.Add(state);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var run in started)
|
||||
Cancel(run.RunId);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SimulationSessionState
|
||||
{
|
||||
SessionId = preset.Id,
|
||||
Name = preset.Name,
|
||||
StartedAt = startedAt,
|
||||
TotalOffsetMinutes = totalOffset,
|
||||
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
|
||||
RunIds = started.Select(s => s.RunId).ToList(),
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -131,6 +191,44 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SimulationRunState> RegisterAndStartAsync(
|
||||
ScenarioFile scenario,
|
||||
double speed,
|
||||
string startedByUserId,
|
||||
string? sessionId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
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,
|
||||
SessionId = sessionId,
|
||||
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();
|
||||
}
|
||||
|
||||
private async Task ExecuteAsync(RunContext ctx, CancellationToken _)
|
||||
{
|
||||
var runId = ctx.State.RunId;
|
||||
@@ -198,6 +296,7 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
ScenarioName = state.ScenarioName,
|
||||
Speed = state.Speed,
|
||||
Status = SimulationRunStatus.Pending,
|
||||
SessionId = state.SessionId,
|
||||
StartedByUserId = state.StartedByUserId,
|
||||
StartedAt = state.StartedAt,
|
||||
TotalOffsetMinutes = state.TotalOffsetMinutes,
|
||||
@@ -221,6 +320,7 @@ public sealed class SimulationRunner : ISimulationRunner, IHostedService
|
||||
row.Status = status;
|
||||
row.PatientId = state.PatientId;
|
||||
row.EncounterId = state.EncounterId;
|
||||
row.SessionId = state.SessionId;
|
||||
row.ObservationsSent = state.ObservationsSent;
|
||||
row.MedicationsSent = state.MedicationsSent;
|
||||
row.OrdersPlaced = state.OrdersPlaced;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
public sealed class SimulationSessionState
|
||||
{
|
||||
public string SessionId { get; init; } = null!;
|
||||
public string Name { get; init; } = null!;
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
public IReadOnlyList<Guid> RunIds { get; init; } = Array.Empty<Guid>();
|
||||
}
|
||||
Reference in New Issue
Block a user