feature: In-App Simulation Runner (Backend)
This commit is contained in:
@@ -18,4 +18,5 @@ public static class ClinicalPermissions
|
||||
public const string AuditRead = "audit:read";
|
||||
public const string UsersAdmin = "users:admin";
|
||||
public const string AlertsFeedback = "alerts:feedback";
|
||||
public const string SimulationRun = "simulation:run";
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public static class ClinicalRolePermissionMap
|
||||
ClinicalPermissions.OrdersWrite,
|
||||
ClinicalPermissions.MedicationsWrite,
|
||||
ClinicalPermissions.AlertsFeedback,
|
||||
ClinicalPermissions.SimulationRun,
|
||||
},
|
||||
[ClinicalRole.Physician] = new(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -33,6 +34,7 @@ public static class ClinicalRolePermissionMap
|
||||
ClinicalPermissions.OrdersWrite,
|
||||
ClinicalPermissions.MedicationsWrite,
|
||||
ClinicalPermissions.AlertsFeedback,
|
||||
ClinicalPermissions.SimulationRun,
|
||||
},
|
||||
[ClinicalRole.Admin] = new(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public static class ClinicalRolePermissionMap
|
||||
ClinicalPermissions.AuditRead,
|
||||
ClinicalPermissions.UsersAdmin,
|
||||
ClinicalPermissions.AlertsFeedback,
|
||||
ClinicalPermissions.SimulationRun,
|
||||
},
|
||||
[ClinicalRole.Integration] = new(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -61,6 +64,10 @@ public static class ClinicalRolePermissionMap
|
||||
ClinicalPermissions.EncountersWrite,
|
||||
ClinicalPermissions.ObservationsIngest,
|
||||
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.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<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<SimulationRun> SimulationRuns => Set<SimulationRun>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ public class PatientConfiguration : IEntityTypeConfiguration<Patient>
|
||||
.HasColumnName("name_search_token")
|
||||
.HasMaxLength(64);
|
||||
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,
|
||||
// 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).
|
||||
builder.HasIndex(p => p.Mrn).IsUnique();
|
||||
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 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;
|
||||
|
||||
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
|
||||
});
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
throw new InvalidOperationException(
|
||||
"Simulation:Enabled requires Simulation:RunnerPassword to seed simulation.runner.");
|
||||
|
||||
db.ClinicalUsers.Add(new ClinicalUser
|
||||
{
|
||||
Id = SimulationRunnerUserId,
|
||||
Username = "simulation.runner",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||
DisplayName = "Simulation Runner",
|
||||
Role = ClinicalRole.Integration,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,8 @@ public class Patient
|
||||
public string Status { get; set; } = "active";
|
||||
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>();
|
||||
}
|
||||
@@ -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,
|
||||
UserLogout,
|
||||
TokenRefreshed,
|
||||
SimulationRunStarted,
|
||||
SimulationRunStopped,
|
||||
}
|
||||
|
||||
public static class AuditActionExtensions
|
||||
@@ -34,6 +36,8 @@ public static class AuditActionExtensions
|
||||
AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED",
|
||||
AuditAction.UserLogout => "USER_LOGOUT",
|
||||
AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
|
||||
AuditAction.SimulationRunStarted => "SIMULATION_RUN_STARTED",
|
||||
AuditAction.SimulationRunStopped => "SIMULATION_RUN_STOPPED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
||||
};
|
||||
|
||||
@@ -53,6 +57,8 @@ public static class AuditActionExtensions
|
||||
"ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted,
|
||||
"USER_LOGOUT" => AuditAction.UserLogout,
|
||||
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
|
||||
"SIMULATION_RUN_STARTED" => AuditAction.SimulationRunStarted,
|
||||
"SIMULATION_RUN_STOPPED" => AuditAction.SimulationRunStopped,
|
||||
_ => 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)")
|
||||
.HasColumnName("gender");
|
||||
|
||||
b.Property<bool>("IsSimulated")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_simulated");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -1193,6 +1199,10 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsSimulated")
|
||||
.HasDatabaseName("IX_Patients_IsSimulated")
|
||||
.HasFilter("is_simulated = true");
|
||||
|
||||
b.HasIndex("Mrn")
|
||||
.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 =>
|
||||
{
|
||||
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.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 =>
|
||||
{
|
||||
options.AddPolicy("Dashboard", policy =>
|
||||
@@ -345,6 +362,13 @@ try
|
||||
|| args.Contains("create-admin")
|
||||
|| 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 —
|
||||
// must never be created in production. Seeding:EnableDemoData defaults to
|
||||
// true so local development and the existing verification scripts are
|
||||
@@ -360,7 +384,16 @@ try
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
await DataSeeder.SeedAsync(db, redis);
|
||||
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"))
|
||||
|
||||
@@ -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>
|
||||
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
|
||||
<ProjectReference Include="..\VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -39,5 +39,10 @@
|
||||
"LogListAccess": true
|
||||
},
|
||||
"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": {
|
||||
"FlushCount": 3,
|
||||
"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": {
|
||||
"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