feature: Simulator Scenarios + Clinical Validation: SOFA/GCS
This commit is contained in:
@@ -5,7 +5,7 @@ public class AlertCreationGuardTests
|
||||
[Fact]
|
||||
public void CannotCreateNewSepsisWarning()
|
||||
{
|
||||
var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning);
|
||||
var act = () => AlertCreationGuard.EnsureAllowed(AlertTypeExtensions.FromDbString("SEPSIS_WARNING"));
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*deprecated*");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class ClinicalRefactorEndToEndTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ClinicalRefactorEndToEndTests(ApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task SofaProgressionScenario_EndToEnd()
|
||||
{
|
||||
var scenario = ScenarioReplayHelper.Load("sepsis-sofa-progression-01.json");
|
||||
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario);
|
||||
|
||||
await ScenarioReplayHelper.WaitForAlertTypeAsync(
|
||||
_fixture.Services, encounterId, AlertType.QsofaScreen, TimeSpan.FromSeconds(30));
|
||||
|
||||
await ScenarioReplayHelper.WaitForAlertTypeAsync(
|
||||
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(30));
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId);
|
||||
bundle.TriggeringAlertType.Should().Be("SOFA_SEPSIS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GcsDeclineScenario_EndToEnd()
|
||||
{
|
||||
var scenario = ScenarioReplayHelper.Load("neurological-decline-gcs-01.json");
|
||||
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario);
|
||||
|
||||
await ScenarioReplayHelper.WaitForAlertTypeAsync(
|
||||
_fixture.Services, encounterId, AlertType.GcsWarning, TimeSpan.FromSeconds(30));
|
||||
|
||||
await ScenarioReplayHelper.WaitForAlertTypeAsync(
|
||||
_fixture.Services, encounterId, AlertType.GcsCritical, TimeSpan.FromSeconds(30));
|
||||
|
||||
await ScenarioReplayHelper.AssertNoAlertTypeAsync(
|
||||
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(5));
|
||||
|
||||
await ScenarioReplayHelper.AssertNoAlertTypeAsync(
|
||||
_fixture.Services, encounterId, AlertTypeExtensions.FromDbString("SEPSIS_WARNING"), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PartialSofaScenario_StalenessFlags()
|
||||
{
|
||||
var scenario = ScenarioReplayHelper.Load("sofa-partial-spo2-fallback-01.json");
|
||||
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario);
|
||||
|
||||
var sofa = await ScenarioReplayHelper.WaitForSofaScoreAsync(
|
||||
_fixture.Services, encounterId, TimeSpan.FromSeconds(45));
|
||||
|
||||
sofa.StalenessFlags.Should().NotBeNullOrEmpty();
|
||||
var flags = JsonSerializer.Deserialize<SofaStalenessFlags>(
|
||||
sofa.StalenessFlags!,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
flags!.UsedSpO2Fallback.Should().BeTrue("expected SpO2/FiO2 proxy for respiratory SOFA");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("stable-baseline-01.json")]
|
||||
[InlineData("cardiac-arrest-post-mi-01.json")]
|
||||
[InlineData("post-op-hemorrhage-01.json")]
|
||||
[InlineData("respiratory-failure-asthma-01.json")]
|
||||
[InlineData("dka-electrolyte-01.json")]
|
||||
[InlineData("medication-false-alarm-01.json")]
|
||||
[InlineData("hypothermia-elderly-01.json")]
|
||||
[InlineData("uti-sepsis-elderly-01.json")]
|
||||
public void ExistingScenarios_ValidateWithoutErrors(string fileName)
|
||||
{
|
||||
var scenario = ScenarioReplayHelper.Load(fileName);
|
||||
var errors = ScenarioValidator.Validate(scenario);
|
||||
errors.Should().BeEmpty(string.Join("; ", errors));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UtiSepsisScenario_NowUsesSofa()
|
||||
{
|
||||
var scenario = ScenarioReplayHelper.Load("uti-sepsis-elderly-01.json");
|
||||
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario);
|
||||
|
||||
await ScenarioReplayHelper.WaitForAlertTypeAsync(
|
||||
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(45));
|
||||
|
||||
await ScenarioReplayHelper.AssertNoAlertTypeAsync(
|
||||
_fixture.Services, encounterId, AlertTypeExtensions.FromDbString("SEPSIS_WARNING"), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public static class ScenarioReplayHelper
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static string ScenariosDirectory =>
|
||||
Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..", "..", "..", "..",
|
||||
"VigilCare.Simulator", "Scenarios", "List"));
|
||||
|
||||
public static ScenarioFile Load(string fileName)
|
||||
{
|
||||
var path = Path.Combine(ScenariosDirectory, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize {path}");
|
||||
}
|
||||
|
||||
public static async Task<(Guid PatientId, Guid EncounterId)> ReplayObservationsAsync(
|
||||
HttpClient client,
|
||||
ScenarioFile scenario,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var patientResp = await client.PostAsJsonAsync("/api/v1/patients", new
|
||||
{
|
||||
firstName = scenario.Patient.FirstName,
|
||||
lastName = scenario.Patient.LastName,
|
||||
dateOfBirth = scenario.Patient.DateOfBirth,
|
||||
gender = scenario.Patient.Gender,
|
||||
}, ct);
|
||||
patientResp.EnsureSuccessStatusCode();
|
||||
var patient = (await patientResp.Content.ReadFromJsonAsync<ApiEnvelope<PatientDto>>(ct))!.Data;
|
||||
|
||||
var encounterResp = await client.PostAsJsonAsync(
|
||||
$"/api/v1/patients/{patient.Id}/encounters",
|
||||
new
|
||||
{
|
||||
encounterType = scenario.Encounter.EncounterType,
|
||||
department = scenario.Encounter.Department,
|
||||
attendingPhysician = scenario.Encounter.AttendingPhysician,
|
||||
roomBed = scenario.Encounter.RoomBed,
|
||||
admissionReason = scenario.Encounter.AdmissionReason,
|
||||
}, ct);
|
||||
encounterResp.EnsureSuccessStatusCode();
|
||||
var encounter = (await encounterResp.Content.ReadFromJsonAsync<ApiEnvelope<EncounterDto>>(ct))!.Data;
|
||||
|
||||
var start = DateTimeOffset.UtcNow;
|
||||
foreach (var cluster in scenario.Events
|
||||
.Where(e => e.Type == "observation")
|
||||
.GroupBy(e => e.OffsetMinutes)
|
||||
.OrderBy(g => g.Key))
|
||||
{
|
||||
var recordedAt = start.AddMinutes(cluster.Key);
|
||||
var batch = cluster.Select(evt =>
|
||||
{
|
||||
var code = evt.Data.GetProperty("code").GetString()!;
|
||||
var value = evt.Data.GetProperty("value").GetDecimal();
|
||||
var unit = evt.Data.GetProperty("unit").GetString()!;
|
||||
var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual";
|
||||
return new
|
||||
{
|
||||
observationCode = code,
|
||||
value,
|
||||
unit,
|
||||
source = MapSource(source),
|
||||
recordedAt,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
foreach (var chunk in batch.Chunk(10))
|
||||
{
|
||||
var resp = await client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{encounter.Id}/observations",
|
||||
new { observations = chunk }, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
return (patient.Id, encounter.Id);
|
||||
}
|
||||
|
||||
public static async Task<SofaScore> WaitForSofaScoreAsync(
|
||||
IServiceProvider services,
|
||||
Guid encounterId,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var sofa = await db.SofaScores
|
||||
.Where(s => s.EncounterId == encounterId)
|
||||
.OrderByDescending(s => s.CalculatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (sofa is not null)
|
||||
return sofa;
|
||||
await Task.Delay(500);
|
||||
}
|
||||
throw new TimeoutException($"Timed out waiting for SOFA score on encounter {encounterId}");
|
||||
}
|
||||
|
||||
public static async Task WaitForAlertTypeAsync(
|
||||
IServiceProvider services,
|
||||
Guid encounterId,
|
||||
AlertType alertType,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
if (await db.ClinicalAlerts.AnyAsync(a =>
|
||||
a.EncounterId == encounterId && a.AlertType == alertType))
|
||||
return;
|
||||
await Task.Delay(500);
|
||||
}
|
||||
throw new TimeoutException($"Timed out waiting for {alertType} on encounter {encounterId}");
|
||||
}
|
||||
|
||||
public static async Task AssertNoAlertTypeAsync(
|
||||
IServiceProvider services,
|
||||
Guid encounterId,
|
||||
AlertType alertType,
|
||||
TimeSpan settleDelay)
|
||||
{
|
||||
await Task.Delay(settleDelay);
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var found = await db.ClinicalAlerts.AnyAsync(a =>
|
||||
a.EncounterId == encounterId && a.AlertType == alertType);
|
||||
if (found)
|
||||
throw new InvalidOperationException($"Unexpected {alertType} alert on encounter {encounterId}");
|
||||
}
|
||||
|
||||
private static string MapSource(string source) => source.ToLowerInvariant() switch
|
||||
{
|
||||
"device" or "monitor" => "Device",
|
||||
"lab" => "Lab",
|
||||
_ => "Manual",
|
||||
};
|
||||
|
||||
private record ApiEnvelope<T>(T Data);
|
||||
private record PatientDto(Guid Id);
|
||||
private record EncounterDto(Guid Id);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public class SepsisRefactorTests : IAsyncLifetime
|
||||
await qsofa.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 22m);
|
||||
await qsofa.ProcessObservationAsync(_encounterId, _patientId, "WBC_K_UL", 15m);
|
||||
|
||||
(await db.ClinicalAlerts.CountAsync(a => a.AlertType == AlertType.SepsisWarning))
|
||||
(await db.ClinicalAlerts.CountAsync(a => a.AlertType == AlertTypeExtensions.FromDbString("SEPSIS_WARNING")))
|
||||
.Should().Be(0);
|
||||
}
|
||||
|
||||
@@ -124,20 +124,20 @@ public class SepsisRefactorTests : IAsyncLifetime
|
||||
db.ClinicalAlerts.Add(new ClinicalAlert
|
||||
{
|
||||
Id = Guid.NewGuid(), EncounterId = _encounterId, PatientId = _patientId,
|
||||
AlertType = AlertType.SepsisWarning, Severity = AlertSeverity.Critical,
|
||||
AlertType = AlertTypeExtensions.FromDbString("SEPSIS_WARNING"), Severity = AlertSeverity.Critical,
|
||||
Details = "Legacy SIRS alert", Status = AlertStatus.Resolved,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var page = await alertService.ListByEncounterAsync(_encounterId, null, 1, 10);
|
||||
page.Items.Should().ContainSingle(a => a.AlertType == AlertType.SepsisWarning);
|
||||
page.Items.Should().ContainSingle(a => a.AlertType == AlertTypeExtensions.FromDbString("SEPSIS_WARNING"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CannotCreateNewSepsisWarning()
|
||||
{
|
||||
var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning);
|
||||
var act = () => AlertCreationGuard.EnsureAllowed(AlertTypeExtensions.FromDbString("SEPSIS_WARNING"));
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*deprecated*");
|
||||
}
|
||||
|
||||
@@ -24,8 +24,13 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\VigilCare.Simulator\Scenarios\ScenarioFile.cs" Link="Scenarios\ScenarioFile.cs" />
|
||||
<Compile Include="..\VigilCare.Simulator\Scenarios\ScenarioValidator.cs" Link="Scenarios\ScenarioValidator.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user