300 lines
13 KiB
C#
300 lines
13 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StackExchange.Redis;
|
|
|
|
public static class ExplainableAlertsTestHelper
|
|
{
|
|
public static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
|
|
private static readonly DateTimeOffset TrendBaseTime =
|
|
new(2026, 6, 18, 10, 0, 0, TimeSpan.Zero);
|
|
|
|
public static async Task ResetAsync(ApiFixture fixture)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
await DbResetHelper.ResetAsync(db);
|
|
|
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
|
var server = redis.GetServer(redis.GetEndPoints().First());
|
|
await server.FlushDatabaseAsync(1);
|
|
}
|
|
|
|
public static async Task<(Guid EncounterId, Guid PatientId)> SeedEncounterAsync(ApiFixture fixture)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = $"MRN-EXP-{Guid.NewGuid():N}"[..16],
|
|
FirstName = "Explain", LastName = "Test",
|
|
DateOfBirth = new DateOnly(1970, 1, 1), Gender = "M",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. Explain", AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
await db.SaveChangesAsync();
|
|
|
|
return (encounter.Id, patient.Id);
|
|
}
|
|
|
|
public static async Task FeedNews2VitalsAsync(
|
|
ApiFixture fixture, Guid encounterId, Guid patientId,
|
|
int respRate, decimal spo2, decimal systolicBp, decimal heartRate,
|
|
decimal tempC, decimal supplementalO2, decimal avpu = 0m)
|
|
{
|
|
await ClearNews2KeysAsync(fixture, encounterId);
|
|
|
|
using var scope = fixture.Services.CreateScope();
|
|
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
|
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "RESP_RATE", respRate);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "SPO2", spo2);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "SYSTOLIC_BP", systolicBp);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "HEART_RATE", heartRate);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "AVPU", avpu);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "TEMP_C", tempC);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "SUPPLEMENTAL_O2", supplementalO2);
|
|
}
|
|
|
|
public static async Task<(Guid EncounterId, Guid PatientId)> SeedEncounterWithVitalsAsync(
|
|
ApiFixture fixture,
|
|
int respRate, decimal spo2, decimal systolicBp, decimal heartRate,
|
|
decimal tempC, decimal supplementalO2, decimal avpu = 0m)
|
|
{
|
|
var (encounterId, patientId) = await SeedEncounterAsync(fixture);
|
|
await FeedNews2VitalsAsync(
|
|
fixture, encounterId, patientId, respRate, spo2, systolicBp, heartRate,
|
|
tempC, supplementalO2, avpu);
|
|
return (encounterId, patientId);
|
|
}
|
|
|
|
public static async Task<(Guid EncounterId, Guid PatientId)> SeedSofaBaselineAsync(ApiFixture fixture)
|
|
{
|
|
var (encounterId, patientId) = await SeedEncounterAsync(fixture);
|
|
await SeedSofaBaselineInputsAsync(fixture, encounterId, patientId);
|
|
return (encounterId, patientId);
|
|
}
|
|
|
|
public static async Task AdvanceSofaDeltaAsync(
|
|
ApiFixture fixture, Guid encounterId, Guid patientId, int delta = 2)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
|
|
|
|
if (delta >= 2)
|
|
{
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "PLATELET_K_UL", 20m, DateTimeOffset.UtcNow);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "CREATININE_MG_DL", 4.5m, DateTimeOffset.UtcNow);
|
|
}
|
|
else
|
|
{
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "PLATELET_K_UL", 120m, DateTimeOffset.UtcNow);
|
|
}
|
|
}
|
|
|
|
public static async Task RecordGcsAsync(
|
|
ApiFixture fixture, Guid encounterId, Guid patientId, int eye, int verbal, int motor)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var detector = scope.ServiceProvider.GetRequiredService<GcsDetector>();
|
|
await ClearGcsKeysAsync(fixture, encounterId);
|
|
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "GCS_EYE", eye);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "GCS_VERBAL", verbal);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "GCS_MOTOR", motor);
|
|
}
|
|
|
|
public static async Task SimulateRapidRespRateRiseAsync(
|
|
ApiFixture fixture, Guid encounterId, Guid patientId)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
|
|
await ClearTrendKeysAsync(fixture, encounterId);
|
|
|
|
await detector.ProcessObservationAsync(
|
|
encounterId, patientId, "RESP_RATE", 14m, TrendBaseTime);
|
|
await detector.ProcessObservationAsync(
|
|
encounterId, patientId, "RESP_RATE", 28m, TrendBaseTime.AddMinutes(10));
|
|
}
|
|
|
|
public static async Task AdministerMedicationAsync(
|
|
ApiFixture fixture, Guid encounterId, string drugName, decimal dose, string unit)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var medService = scope.ServiceProvider.GetRequiredService<IMedicationService>();
|
|
await medService.CreateAsync(encounterId,
|
|
new CreateMedicationAdministrationRequest(
|
|
drugName, dose, unit, "PO",
|
|
DateTimeOffset.UtcNow.AddMinutes(-30), "nurse-test"));
|
|
}
|
|
|
|
public static async Task RecordVitalAsync(
|
|
ApiFixture fixture, Guid encounterId, Guid patientId, string code, decimal value)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
|
await detector.ProcessObservationAsync(encounterId, patientId, code, value);
|
|
}
|
|
|
|
public static async Task<ClinicalAlert> WaitForAlertAsync(
|
|
ApiFixture fixture, Guid encounterId, AlertType alertType)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var alert = await db.ClinicalAlerts
|
|
.AsNoTracking()
|
|
.Where(a => a.EncounterId == encounterId && a.AlertType == alertType)
|
|
.OrderByDescending(a => a.TriggeredAt)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (alert is null)
|
|
throw new InvalidOperationException(
|
|
$"Expected {alertType} alert on encounter {encounterId} but none was found.");
|
|
|
|
return alert;
|
|
}
|
|
|
|
public static async Task<T> GetAlertAsync<T>(ApiFixture fixture, Guid alertId)
|
|
{
|
|
using var client = fixture.CreateClient();
|
|
var response = await client.GetAsync($"/api/v1/alerts/{alertId}");
|
|
response.EnsureSuccessStatusCode();
|
|
var body = await response.Content.ReadFromJsonAsync<ApiResponse<T>>(JsonOptions);
|
|
return body!.Data!;
|
|
}
|
|
|
|
public static async Task<AlertResponse> WaitForFirstAlertWithExplanationAsync(
|
|
ApiFixture fixture, Guid encounterId)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var alert = await db.ClinicalAlerts
|
|
.AsNoTracking()
|
|
.Where(a => a.EncounterId == encounterId && a.Explanation != null)
|
|
.OrderByDescending(a => a.TriggeredAt)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (alert is null)
|
|
throw new InvalidOperationException(
|
|
$"Expected an alert with explanation on encounter {encounterId}.");
|
|
|
|
return await GetAlertAsync<AlertResponse>(fixture, alert.Id);
|
|
}
|
|
|
|
public static async Task<AlertResponse> GetAnyAlertWithExplanationAsync(ApiFixture fixture)
|
|
{
|
|
await SeedEncounterWithVitalsAsync(
|
|
fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
|
|
tempC: 37.0m, supplementalO2: 0m);
|
|
using var scope = fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var alert = await db.ClinicalAlerts
|
|
.AsNoTracking()
|
|
.Where(a => a.Explanation != null)
|
|
.OrderByDescending(a => a.TriggeredAt)
|
|
.FirstAsync();
|
|
|
|
return await GetAlertAsync<AlertResponse>(fixture, alert.Id);
|
|
}
|
|
|
|
public static async Task<Guid> SeedLegacyAlertWithoutExplanationAsync(ApiFixture fixture)
|
|
{
|
|
var (encounterId, patientId) = await SeedEncounterAsync(fixture);
|
|
using var scope = fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var alertId = Guid.NewGuid();
|
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
|
{
|
|
Id = alertId,
|
|
EncounterId = encounterId,
|
|
PatientId = patientId,
|
|
AlertType = AlertType.CriticalPotassiumMeqL,
|
|
Severity = AlertSeverity.Critical,
|
|
Details = "Potassium 2.1 mEq/L is below critical low.",
|
|
Status = AlertStatus.Open,
|
|
TriggeredAt = DateTimeOffset.UtcNow.AddDays(-1),
|
|
Explanation = null
|
|
});
|
|
await db.SaveChangesAsync();
|
|
return alertId;
|
|
}
|
|
|
|
public static async Task<News2ReplayResult> RunNews2HighScoreReplayAsync(ApiFixture fixture)
|
|
{
|
|
var (encounterId, patientId) = await SeedEncounterWithVitalsAsync(
|
|
fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
|
|
tempC: 37.0m, supplementalO2: 0m);
|
|
|
|
using var scope = fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var score = await db.News2Scores
|
|
.Where(s => s.EncounterId == encounterId)
|
|
.OrderByDescending(s => s.CalculatedAt)
|
|
.FirstAsync();
|
|
var alert = await db.ClinicalAlerts
|
|
.Where(a => a.EncounterId == encounterId)
|
|
.OrderByDescending(a => a.TriggeredAt)
|
|
.FirstAsync();
|
|
|
|
return new News2ReplayResult(score.TotalScore, alert.AlertType);
|
|
}
|
|
|
|
private static async Task SeedSofaBaselineInputsAsync(
|
|
ApiFixture fixture, Guid encounterId, Guid patientId)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
|
|
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "PAO2_MMHG", 100m, DateTimeOffset.UtcNow);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "FIO2_PCT", 40m, DateTimeOffset.UtcNow);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "PLATELET_K_UL", 180m, DateTimeOffset.UtcNow);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "BILIRUBIN_MG_DL", 1.0m, DateTimeOffset.UtcNow);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "SYSTOLIC_BP", 120m, DateTimeOffset.UtcNow);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "DIASTOLIC_BP", 80m, DateTimeOffset.UtcNow);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "CREATININE_MG_DL", 1.0m, DateTimeOffset.UtcNow);
|
|
await detector.ProcessObservationAsync(encounterId, patientId, "URINE_OUTPUT_ML_H", 50m, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static async Task ClearNews2KeysAsync(ApiFixture fixture, Guid encounterId)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var cache = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>().GetDatabase();
|
|
foreach (var key in News2Calculator.AllParameterKeys(encounterId))
|
|
await cache.KeyDeleteAsync(key);
|
|
}
|
|
|
|
private static async Task ClearGcsKeysAsync(ApiFixture fixture, Guid encounterId)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var cache = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>().GetDatabase();
|
|
foreach (var key in GcsCalculator.AllComponentKeys(encounterId))
|
|
await cache.KeyDeleteAsync(key);
|
|
}
|
|
|
|
private static async Task ClearTrendKeysAsync(ApiFixture fixture, Guid encounterId)
|
|
{
|
|
using var scope = fixture.Services.CreateScope();
|
|
var cache = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>().GetDatabase();
|
|
foreach (var key in TrendCalculator.AllHistoryKeys(encounterId))
|
|
await cache.KeyDeleteAsync(key);
|
|
}
|
|
|
|
public record News2ReplayResult(int TotalScore, AlertType AlertType);
|
|
}
|