feature: In-App Simulation Runner (Backend)
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 8m32s

This commit is contained in:
voltsrage
2026-08-06 01:52:53 +08:00
parent 943d41339c
commit 24f45851e9
83 changed files with 3974 additions and 120 deletions
@@ -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 &gt; 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");
}
}
+76 -40
View File
@@ -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}'")
};
}
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);
+34 -1
View File
@@ -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
}
}
+9
View File
@@ -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
}
}