From 5c7e37471b01d224666920821011fc61a6dc4097 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Fri, 19 Jun 2026 10:47:55 +0800 Subject: [PATCH] feature: Medication Tracking & Vital Sign Correlation --- .../Helpers/DbResetHelper.cs | 1 + .../MedicationCorrelationTests.cs | 195 ++++ .../MedicationServiceTests.cs | 154 +++ .../MedicationValidationTests.cs | 43 + .../MedicationCorrelationOptions.cs | 83 ++ .../Controllers/MedicationsController.cs | 71 ++ VigilCareClinicalAPI/Data/AppDbContext.cs | 1 + .../MedicationAdministrationConfiguration.cs | 27 + .../Entities/MedicationAdministration.cs | 13 + .../Medication/MedicationCorrelationHelper.cs | 39 + ...MedicationAdministrationsTable.Designer.cs | 932 ++++++++++++++++++ ...21003_AddMedicationAdministrationsTable.cs | 56 ++ .../Migrations/AppDbContextModelSnapshot.cs | 65 ++ .../CreateMedicationAdministrationRequest.cs | 7 + VigilCareClinicalAPI/News2/News2Detector.cs | 12 + VigilCareClinicalAPI/Program.cs | 7 +- .../Services/Interfaces/IMedicationService.cs | 10 + .../Services/MedicationService.cs | 102 ++ .../Services/PlausibilityValidator.cs | 6 +- .../Services/WarningEvaluator.cs | 4 + ...edicationAdministrationRequestValidator.cs | 18 + VigilCareClinicalAPI/appsettings.json | 60 ++ docs/scenarios/cardiac-ward-01.json | 88 ++ docs/scenarios/hypothermia-elderly-01.json | 84 ++ docs/scenarios/medication-false-alarm-01.json | 79 ++ docs/scenarios/post-op-pain-01.json | 87 ++ docs/scenarios/respiratory-failure-01.json | 96 ++ docs/scenarios/sepsis-ed-01.json | 111 +++ docs/scenarios/stable-baseline-01.json | 65 ++ 29 files changed, 2512 insertions(+), 4 deletions(-) create mode 100644 VigilCareClinicalAPI.Tests/MedicationCorrelationTests.cs create mode 100644 VigilCareClinicalAPI.Tests/MedicationServiceTests.cs create mode 100644 VigilCareClinicalAPI.Tests/MedicationValidationTests.cs create mode 100644 VigilCareClinicalAPI/Configuration/MedicationCorrelationOptions.cs create mode 100644 VigilCareClinicalAPI/Controllers/MedicationsController.cs create mode 100644 VigilCareClinicalAPI/Data/Configurations/MedicationAdministrationConfiguration.cs create mode 100644 VigilCareClinicalAPI/Domains/Entities/MedicationAdministration.cs create mode 100644 VigilCareClinicalAPI/Medication/MedicationCorrelationHelper.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260619021003_AddMedicationAdministrationsTable.Designer.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260619021003_AddMedicationAdministrationsTable.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Medication/CreateMedicationAdministrationRequest.cs create mode 100644 VigilCareClinicalAPI/Services/Interfaces/IMedicationService.cs create mode 100644 VigilCareClinicalAPI/Services/MedicationService.cs create mode 100644 VigilCareClinicalAPI/Validators/CreateMedicationAdministrationRequestValidator.cs create mode 100644 docs/scenarios/cardiac-ward-01.json create mode 100644 docs/scenarios/hypothermia-elderly-01.json create mode 100644 docs/scenarios/medication-false-alarm-01.json create mode 100644 docs/scenarios/post-op-pain-01.json create mode 100644 docs/scenarios/respiratory-failure-01.json create mode 100644 docs/scenarios/sepsis-ed-01.json create mode 100644 docs/scenarios/stable-baseline-01.json diff --git a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs index 84d7726..1f78050 100644 --- a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs +++ b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs @@ -11,6 +11,7 @@ public static class DbResetHelper try { await db.Database.ExecuteSqlRawAsync(@" + DELETE FROM medication_administrations; DELETE FROM sepsis_bundle_elements; DELETE FROM sepsis_bundles; DELETE FROM reconciliation_alerts; diff --git a/VigilCareClinicalAPI.Tests/MedicationCorrelationTests.cs b/VigilCareClinicalAPI.Tests/MedicationCorrelationTests.cs new file mode 100644 index 0000000..7de2c22 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/MedicationCorrelationTests.cs @@ -0,0 +1,195 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +[Collection("Integration")] +public class MedicationCorrelationTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private Guid _patientId; + private Guid _encounterId; + + public MedicationCorrelationTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() => await ResetAndSeedAsync(); + + public Task DisposeAsync() => Task.CompletedTask; + + private async Task ResetAndSeedAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + var patient = new Patient + { + Id = Guid.NewGuid(), Mrn = "MRN-CORR-001", FirstName = "Correlation", LastName = "Test", + DateOfBirth = new DateOnly(1970, 6, 10), 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. Correlation", AdmittedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Patients.Add(patient); + db.Encounters.Add(encounter); + + db.AlertThresholds.Add(new AlertThreshold + { + Id = Guid.NewGuid(), ObservationCode = "SYSTOLIC_BP", + DisplayName = "Systolic BP", Unit = "mmHg", + CriticalLow = 70, WarningLow = 90, WarningHigh = 180, CriticalHigh = 220, + CreatedAt = DateTimeOffset.UtcNow + }); + db.AlertThresholds.Add(new AlertThreshold + { + Id = Guid.NewGuid(), ObservationCode = "RESP_RATE", + DisplayName = "Respiratory Rate", Unit = "breaths/min", + CriticalLow = 5, WarningLow = 8, WarningHigh = 25, CriticalHigh = 35, + CreatedAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + var redis = scope.ServiceProvider.GetRequiredService(); + var cache = redis.GetDatabase(1); + await cache.StringSetAsync("threshold:SYSTOLIC_BP", + """{"ObservationCode":"SYSTOLIC_BP","CriticalLow":70,"WarningLow":90,"WarningHigh":180,"CriticalHigh":220}"""); + await cache.StringSetAsync("threshold:RESP_RATE", + """{"ObservationCode":"RESP_RATE","CriticalLow":5,"WarningLow":8,"WarningHigh":25,"CriticalHigh":35}"""); + + _patientId = patient.Id; + _encounterId = encounter.Id; + } + + [Fact] + public async Task MetoprololThenLowBp_AnnotatedDetails() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var medService = scope.ServiceProvider.GetRequiredService(); + await medService.CreateAsync(_encounterId, + new CreateMedicationAdministrationRequest( + "metoprolol", 25m, "mg", "PO", + DateTimeOffset.UtcNow.AddMinutes(-45), "nurse-1")); + + var evaluator = scope.ServiceProvider.GetRequiredService(); + var created = await evaluator.EvaluateAsync( + Guid.NewGuid(), _encounterId, _patientId, "SYSTOLIC_BP", 85m); + + created.Should().BeTrue(); + + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts + .SingleAsync(a => a.AlertType == AlertType.WarningSystolicBp); + alert.Details.Should().Contain("metoprolol"); + alert.Details.Should().Contain("min ago"); + alert.Details.Should().Contain("note:"); + } + + [Fact] + public async Task LowBpWithoutMedication_Unannotated() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var evaluator = scope.ServiceProvider.GetRequiredService(); + var created = await evaluator.EvaluateAsync( + Guid.NewGuid(), _encounterId, _patientId, "SYSTOLIC_BP", 85m); + + created.Should().BeTrue(); + + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts + .SingleAsync(a => a.AlertType == AlertType.WarningSystolicBp); + alert.Details.Should().NotContain("note:"); + } + + [Fact] + public async Task UnrelatedDrug_NoAnnotation() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var medService = scope.ServiceProvider.GetRequiredService(); + // Insulin maps to GLUCOSE_MG_DL, not SYSTOLIC_BP + await medService.CreateAsync(_encounterId, + new CreateMedicationAdministrationRequest( + "insulin", 10m, "units", "SubQ", + DateTimeOffset.UtcNow.AddMinutes(-30), "nurse-1")); + + var evaluator = scope.ServiceProvider.GetRequiredService(); + var created = await evaluator.EvaluateAsync( + Guid.NewGuid(), _encounterId, _patientId, "SYSTOLIC_BP", 85m); + + created.Should().BeTrue(); + + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts + .SingleAsync(a => a.AlertType == AlertType.WarningSystolicBp); + alert.Details.Should().NotContain("note:"); + alert.Details.Should().NotContain("insulin"); + } + + [Fact] + public async Task MedicationOutsideWindow_NoAnnotation() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Metoprolol administered 2 hours ago — outside 90-min window + db.MedicationAdministrations.Add(new MedicationAdministration + { + Id = Guid.NewGuid(), + EncounterId = _encounterId, + DrugName = "metoprolol", + Dose = 25m, + DoseUnit = "mg", + Route = "PO", + AdministeredAt = DateTimeOffset.UtcNow.AddMinutes(-120), + AdministeredBy = "nurse-1" + }); + await db.SaveChangesAsync(); + + var evaluator = scope.ServiceProvider.GetRequiredService(); + var created = await evaluator.EvaluateAsync( + Guid.NewGuid(), _encounterId, _patientId, "SYSTOLIC_BP", 85m); + + created.Should().BeTrue(); + + var alert = await db.ClinicalAlerts + .SingleAsync(a => a.AlertType == AlertType.WarningSystolicBp); + alert.Details.Should().NotContain("note:"); + alert.Details.Should().NotContain("metoprolol"); + } + + [Fact] + public async Task MorphineThenLowRespRate_Annotated() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var medService = scope.ServiceProvider.GetRequiredService(); + await medService.CreateAsync(_encounterId, + new CreateMedicationAdministrationRequest( + "morphine", 4m, "mg", "IV", + DateTimeOffset.UtcNow.AddMinutes(-20), "nurse-1")); + + var evaluator = scope.ServiceProvider.GetRequiredService(); + var created = await evaluator.EvaluateAsync( + Guid.NewGuid(), _encounterId, _patientId, "RESP_RATE", 7m); + + created.Should().BeTrue(); + + var db = scope.ServiceProvider.GetRequiredService(); + var alert = await db.ClinicalAlerts + .SingleAsync(a => a.AlertType == AlertType.WarningRespRate); + alert.Details.Should().Contain("morphine"); + alert.Details.Should().Contain("min ago"); + alert.Details.Should().Contain("note:"); + } +} diff --git a/VigilCareClinicalAPI.Tests/MedicationServiceTests.cs b/VigilCareClinicalAPI.Tests/MedicationServiceTests.cs new file mode 100644 index 0000000..3fa7e9a --- /dev/null +++ b/VigilCareClinicalAPI.Tests/MedicationServiceTests.cs @@ -0,0 +1,154 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +[Collection("Integration")] +public class MedicationServiceTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private Guid _patientId; + private Guid _activeEncounterId; + private Guid _dischargedEncounterId; + + public MedicationServiceTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() => await ResetAndSeedAsync(); + + public Task DisposeAsync() => Task.CompletedTask; + + private async Task ResetAndSeedAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + var patient = new Patient + { + Id = Guid.NewGuid(), Mrn = "MRN-MED-001", FirstName = "Med", LastName = "Test", + DateOfBirth = new DateOnly(1980, 3, 15), Gender = "F", CreatedAt = DateTimeOffset.UtcNow + }; + var activeEncounter = new Encounter + { + Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, + Status = EncounterStatus.Active, Department = Department.Icu, + AttendingPhysician = "Dr. Medication", AdmittedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow + }; + var dischargedEncounter = new Encounter + { + Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, + Status = EncounterStatus.Discharged, Department = Department.Icu, + AttendingPhysician = "Dr. Medication", AdmittedAt = DateTimeOffset.UtcNow.AddDays(-3), + DischargedAt = DateTimeOffset.UtcNow.AddHours(-1), + CreatedAt = DateTimeOffset.UtcNow + }; + db.Patients.Add(patient); + db.Encounters.Add(activeEncounter); + db.Encounters.Add(dischargedEncounter); + await db.SaveChangesAsync(); + + _patientId = patient.Id; + _activeEncounterId = activeEncounter.Id; + _dischargedEncounterId = dischargedEncounter.Id; + } + + [Fact] + public async Task Create_OnActiveEncounter_Succeeds() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + var med = await service.CreateAsync(_activeEncounterId, + new CreateMedicationAdministrationRequest( + "metoprolol", 25m, "mg", "PO", null, "nurse-1")); + + med.Should().NotBeNull(); + med.DrugName.Should().Be("metoprolol"); + med.Dose.Should().Be(25m); + med.DoseUnit.Should().Be("mg"); + med.Route.Should().Be("PO"); + med.EncounterId.Should().Be(_activeEncounterId); + + var db = scope.ServiceProvider.GetRequiredService(); + var persisted = await db.MedicationAdministrations.FindAsync(med.Id); + persisted.Should().NotBeNull(); + } + + [Fact] + public async Task Create_OnDischargedEncounter_ThrowsConflict() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + var act = () => service.CreateAsync(_dischargedEncounterId, + new CreateMedicationAdministrationRequest( + "metoprolol", 25m, "mg", "PO", null, "nurse-1")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ListByEncounter_ReturnsPaginated() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + await service.CreateAsync(_activeEncounterId, + new CreateMedicationAdministrationRequest( + "metoprolol", 25m, "mg", "PO", null, "nurse-1")); + await service.CreateAsync(_activeEncounterId, + new CreateMedicationAdministrationRequest( + "morphine", 4m, "mg", "IV", null, "nurse-2")); + await service.CreateAsync(_activeEncounterId, + new CreateMedicationAdministrationRequest( + "insulin", 10m, "units", "SubQ", null, "nurse-1")); + + var result = await service.ListByEncounterAsync(_activeEncounterId, null, 1, 2); + + result.TotalCount.Should().Be(3); + result.Items.Should().HaveCount(2); + result.TotalPages.Should().Be(2); + } + + [Fact] + public async Task GetRecentForEncounter_FiltersByWindow() + { + await ResetAndSeedAsync(); + + using var scope = _fixture.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Recent metoprolol — within 90-min window + await service.CreateAsync(_activeEncounterId, + new CreateMedicationAdministrationRequest( + "metoprolol", 25m, "mg", "PO", + DateTimeOffset.UtcNow.AddMinutes(-30), "nurse-1")); + + // Old metoprolol — outside 90-min window + db.MedicationAdministrations.Add(new MedicationAdministration + { + Id = Guid.NewGuid(), + EncounterId = _activeEncounterId, + DrugName = "metoprolol", + Dose = 50m, + DoseUnit = "mg", + Route = "PO", + AdministeredAt = DateTimeOffset.UtcNow.AddMinutes(-120), + AdministeredBy = "nurse-2" + }); + await db.SaveChangesAsync(); + + var recent = await service.GetRecentForEncounterAsync( + _activeEncounterId, "SYSTOLIC_BP", 90); + + recent.Should().HaveCount(1); + recent[0].Dose.Should().Be(25m); + } +} diff --git a/VigilCareClinicalAPI.Tests/MedicationValidationTests.cs b/VigilCareClinicalAPI.Tests/MedicationValidationTests.cs new file mode 100644 index 0000000..470cdaa --- /dev/null +++ b/VigilCareClinicalAPI.Tests/MedicationValidationTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; + +[Collection("Integration")] +public class MedicationValidationTests +{ + private readonly HttpClient _client; + + public MedicationValidationTests(ApiFixture fixture) => _client = fixture.CreateClient(); + + [Fact] + public async Task EmptyDrugName_Returns400() + { + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{Guid.NewGuid()}/medications", + new { drugName = "", dose = 25, doseUnit = "mg", route = "PO", administeredBy = "nurse-1" }); + + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ZeroDose_Returns400() + { + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{Guid.NewGuid()}/medications", + new { drugName = "metoprolol", dose = 0, doseUnit = "mg", route = "PO", administeredBy = "nurse-1" }); + + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task FutureAdministeredAt_Returns400() + { + var futureTime = DateTimeOffset.UtcNow.AddHours(1); + var resp = await _client.PostAsJsonAsync( + $"/api/v1/encounters/{Guid.NewGuid()}/medications", + new { drugName = "metoprolol", dose = 25, doseUnit = "mg", route = "PO", + administeredBy = "nurse-1", administeredAt = futureTime }); + + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} diff --git a/VigilCareClinicalAPI/Configuration/MedicationCorrelationOptions.cs b/VigilCareClinicalAPI/Configuration/MedicationCorrelationOptions.cs new file mode 100644 index 0000000..14ead09 --- /dev/null +++ b/VigilCareClinicalAPI/Configuration/MedicationCorrelationOptions.cs @@ -0,0 +1,83 @@ +public class MedicationCorrelationOptions +{ + public const string SectionName = "MedicationCorrelation"; + + /// Lookback window for medication-vital correlation (minutes). + public int CorrelationWindowMinutes { get; set; } = 90; + + /// + /// Drug name (lowercase) → observation codes that may be affected. + /// Keys are normalized to lowercase for case-insensitive lookup. + /// + public Dictionary DrugVitalMappings { get; set; } = new() + { + // Beta-blockers + ["metoprolol"] = new[] { "SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP" }, + ["labetalol"] = new[] { "SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP" }, + ["atenolol"] = new[] { "SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP" }, + ["propranolol"] = new[] { "SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP" }, + ["esmolol"] = new[] { "SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP" }, + ["carvedilol"] = new[] { "SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP" }, + + // Vasopressors / inotropes + ["norepinephrine"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE" }, + ["epinephrine"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE", "GLUCOSE_MG_DL" }, + ["vasopressin"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP" }, + ["dopamine"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE" }, + ["dobutamine"] = new[] { "SYSTOLIC_BP", "HEART_RATE" }, + ["phenylephrine"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE" }, + + // Calcium channel blockers + ["diltiazem"] = new[] { "HEART_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP" }, + ["verapamil"] = new[] { "HEART_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP" }, + ["amlodipine"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP" }, + ["nicardipine"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE" }, + + // Antihypertensives + ["nitroglycerin"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE" }, + ["nitroprusside"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE" }, + ["hydralazine"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE" }, + + // Opioids + ["morphine"] = new[] { "RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE" }, + ["fentanyl"] = new[] { "RESP_RATE", "SPO2", "HEART_RATE" }, + ["hydromorphone"] = new[] { "RESP_RATE", "SPO2", "SYSTOLIC_BP" }, + ["remifentanil"] = new[] { "RESP_RATE", "SPO2", "HEART_RATE" }, + + // Sedatives / anaesthetics + ["propofol"] = new[] { "RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE" }, + ["midazolam"] = new[] { "RESP_RATE", "SPO2" }, + ["lorazepam"] = new[] { "RESP_RATE", "SPO2" }, + ["ketamine"] = new[] { "HEART_RATE", "SYSTOLIC_BP", "RESP_RATE" }, + + // Antiarrhythmics + ["amiodarone"] = new[] { "HEART_RATE", "SYSTOLIC_BP" }, + ["adenosine"] = new[] { "HEART_RATE" }, + ["digoxin"] = new[] { "HEART_RATE" }, + ["atropine"] = new[] { "HEART_RATE" }, + + // Anticoagulants + ["heparin"] = new[] { "HEART_RATE" }, + + // Antipyretics + ["acetaminophen"] = new[] { "TEMP_C" }, + ["ibuprofen"] = new[] { "TEMP_C" }, + + // Glucose management + ["insulin"] = new[] { "GLUCOSE_MG_DL" }, + ["dextrose"] = new[] { "GLUCOSE_MG_DL" }, + ["glucagon"] = new[] { "GLUCOSE_MG_DL" }, + + // Corticosteroids + ["dexamethasone"] = new[] { "GLUCOSE_MG_DL", "TEMP_C" }, + ["methylprednisolone"] = new[] { "GLUCOSE_MG_DL", "TEMP_C" }, + ["hydrocortisone"] = new[] { "GLUCOSE_MG_DL", "TEMP_C" }, + ["prednisone"] = new[] { "GLUCOSE_MG_DL", "TEMP_C" }, + + // Diuretics + ["furosemide"] = new[] { "SYSTOLIC_BP", "DIASTOLIC_BP" }, + + // Bronchodilators + ["albuterol"] = new[] { "HEART_RATE", "SPO2" } + }; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/MedicationsController.cs b/VigilCareClinicalAPI/Controllers/MedicationsController.cs new file mode 100644 index 0000000..0e05848 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/MedicationsController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; + + +/// +/// Medication administration recording and lookup. +/// +[ApiController] +[Produces("application/json")] +public class MedicationsController : ControllerBase +{ + private readonly IMedicationService _medications; + + public MedicationsController(IMedicationService medications) => _medications = medications; + + /// + /// Records a medication administration for an encounter. + /// + /// Encounter id. + /// Medication administration details. + /// The created medication administration record. + [HttpPost("api/v1/encounters/{encounterId:guid}/medications")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task Create(Guid encounterId, [FromBody] CreateMedicationAdministrationRequest req) + { + var med = await _medications.CreateAsync(encounterId, req); + return StatusCode(201, ApiResponse.Created(med)); + } + + /// + /// Lists medication administrations for an encounter with optional time filter. + /// + /// Encounter id. + /// Optional ISO 8601 cutoff — only returns administrations at or after this time. + /// Page number (1-based). + /// Results per page. + /// A paginated list of medication administrations. + [HttpGet("api/v1/encounters/{encounterId:guid}/medications")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task ListByEncounter( + Guid encounterId, + [FromQuery] DateTimeOffset? since, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var result = await _medications.ListByEncounterAsync(encounterId, since, page, pageSize); + return Ok(ApiResponse.Ok(new + { + items = result.Items, + page = result.Page, + pageSize = result.PageSize, + totalCount = result.TotalCount, + totalPages = result.TotalPages + })); + } + + /// + /// Gets a single medication administration by id. + /// + /// Medication administration id. + /// The medication administration record. + [HttpGet("api/v1/medications/{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Get(Guid id) + { + var med = await _medications.GetByIdAsync(id); + return Ok(ApiResponse.Ok(med)); + } +} diff --git a/VigilCareClinicalAPI/Data/AppDbContext.cs b/VigilCareClinicalAPI/Data/AppDbContext.cs index 8032060..d6e9f26 100644 --- a/VigilCareClinicalAPI/Data/AppDbContext.cs +++ b/VigilCareClinicalAPI/Data/AppDbContext.cs @@ -15,6 +15,7 @@ public class AppDbContext : DbContext public DbSet News2Scores => Set(); public DbSet SepsisBundles => Set(); public DbSet SepsisBundleElements => Set(); + public DbSet MedicationAdministrations => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/VigilCareClinicalAPI/Data/Configurations/MedicationAdministrationConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/MedicationAdministrationConfiguration.cs new file mode 100644 index 0000000..6e6d6f7 --- /dev/null +++ b/VigilCareClinicalAPI/Data/Configurations/MedicationAdministrationConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class MedicationAdministrationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("medication_administrations"); + builder.HasKey(m => m.Id); + builder.Property(m => m.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(m => m.EncounterId).HasColumnName("encounter_id").IsRequired(); + builder.Property(m => m.DrugName).HasColumnName("drug_name").HasMaxLength(200).IsRequired(); + builder.Property(m => m.Dose).HasColumnName("dose").HasPrecision(10, 4).IsRequired(); + builder.Property(m => m.DoseUnit).HasColumnName("dose_unit").HasMaxLength(20).IsRequired(); + builder.Property(m => m.Route).HasColumnName("route").HasMaxLength(50).IsRequired(); + builder.Property(m => m.AdministeredAt).HasColumnName("administered_at").IsRequired(); + builder.Property(m => m.AdministeredBy).HasColumnName("administered_by").HasMaxLength(100).IsRequired(); + + builder.HasOne(m => m.Encounter) + .WithMany() + .HasForeignKey(m => m.EncounterId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(m => new { m.EncounterId, m.AdministeredAt }); + builder.HasIndex(m => new { m.EncounterId, m.DrugName }); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Entities/MedicationAdministration.cs b/VigilCareClinicalAPI/Domains/Entities/MedicationAdministration.cs new file mode 100644 index 0000000..b7f1da8 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Entities/MedicationAdministration.cs @@ -0,0 +1,13 @@ +public class MedicationAdministration +{ + public Guid Id { get; set; } + public Guid EncounterId { get; set; } + public string DrugName { get; set; } = null!; + public decimal Dose { get; set; } + public string DoseUnit { get; set; } = null!; + public string Route { get; set; } = null!; + public DateTimeOffset AdministeredAt { get; set; } + public string AdministeredBy { get; set; } = null!; + + public Encounter Encounter { get; set; } = null!; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Medication/MedicationCorrelationHelper.cs b/VigilCareClinicalAPI/Medication/MedicationCorrelationHelper.cs new file mode 100644 index 0000000..0080c0f --- /dev/null +++ b/VigilCareClinicalAPI/Medication/MedicationCorrelationHelper.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Options; + +public class MedicationCorrelationHelper +{ + private readonly IMedicationService _medicationService; + private readonly MedicationCorrelationOptions _options; + + public MedicationCorrelationHelper( + IMedicationService medicationService, + IOptions options) + { + _medicationService = medicationService; + _options = options.Value; + } + + /// + /// Appends medication context to alert details if a correlated administration + /// exists within the lookback window. Returns the original details if none found. + /// + public async Task TryAnnotateDetailsAsync( + Guid encounterId, + string observationCode, + string details, + CancellationToken ct = default) + { + var recent = await _medicationService.GetRecentForEncounterAsync( + encounterId, observationCode, _options.CorrelationWindowMinutes); + + if (recent.Count == 0) + return details; + + // Use the most recent correlated administration + var med = recent[0]; + var minutesAgo = (int)(DateTimeOffset.UtcNow - med.AdministeredAt).TotalMinutes; + + return $"{details} — note: {med.DrugName} {med.Dose}{med.DoseUnit} " + + $"({med.Route}) administered {minutesAgo} min ago"; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Migrations/20260619021003_AddMedicationAdministrationsTable.Designer.cs b/VigilCareClinicalAPI/Migrations/20260619021003_AddMedicationAdministrationsTable.Designer.cs new file mode 100644 index 0000000..0a0f469 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260619021003_AddMedicationAdministrationsTable.Designer.cs @@ -0,0 +1,932 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260619021003_AddMedicationAdministrationsTable")] + partial class AddMedicationAdministrationsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.ToTable("clinical_alerts", null, t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + + modelBuilder.Entity("News2Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("ConsciousnessScore") + .HasColumnType("integer") + .HasColumnName("consciousness_score"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("HasSingleParamThree") + .HasColumnType("boolean") + .HasColumnName("has_single_param_three"); + + b.Property("HeartRateScore") + .HasColumnType("integer") + .HasColumnName("heart_rate_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRateScore") + .HasColumnType("integer") + .HasColumnName("resp_rate_score"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("risk_level"); + + b.Property("Spo2Score") + .HasColumnType("integer") + .HasColumnName("spo2_score"); + + b.Property("SupplementalO2Score") + .HasColumnType("integer") + .HasColumnName("supplemental_o2_score"); + + b.Property("SystolicBpScore") + .HasColumnType("integer") + .HasColumnName("systolic_bp_score"); + + b.Property("TemperatureScore") + .HasColumnType("integer") + .HasColumnName("temperature_score"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.HasIndex("PatientId", "CalculatedAt"); + + b.ToTable("news2_scores", null, t => + { + t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ComplianceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("compliance_status") + .HasDefaultValueSql("'IN_PROGRESS'"); + + b.Property("DeadlineAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deadline_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("RecognizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recognized_at"); + + b.Property("TriggeringAlertId") + .HasColumnType("uuid") + .HasColumnName("triggering_alert_id"); + + b.Property("TriggeringAlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("triggering_alert_type"); + + b.HasKey("Id"); + + b.HasIndex("ComplianceStatus"); + + b.HasIndex("TriggeringAlertId"); + + b.HasIndex("EncounterId", "RecognizedAt"); + + b.ToTable("sepsis_bundles", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')"); + }); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ElementType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("element_type"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("BundleId", "ElementType") + .IsUnique(); + + b.ToTable("sepsis_bundle_elements", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')"); + + t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')"); + }); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("News2Score", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalAlert", "TriggeringAlert") + .WithMany() + .HasForeignKey("TriggeringAlertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("TriggeringAlert"); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.HasOne("SepsisBundle", "Bundle") + .WithMany("Elements") + .HasForeignKey("BundleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Bundle"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260619021003_AddMedicationAdministrationsTable.cs b/VigilCareClinicalAPI/Migrations/20260619021003_AddMedicationAdministrationsTable.cs new file mode 100644 index 0000000..7890b39 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260619021003_AddMedicationAdministrationsTable.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddMedicationAdministrationsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "medication_administrations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + encounter_id = table.Column(type: "uuid", nullable: false), + drug_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + dose = table.Column(type: "numeric(10,4)", precision: 10, scale: 4, nullable: false), + dose_unit = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + route = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + administered_at = table.Column(type: "timestamp with time zone", nullable: false), + administered_by = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_medication_administrations", x => x.id); + table.ForeignKey( + name: "FK_medication_administrations_encounters_encounter_id", + column: x => x.encounter_id, + principalTable: "encounters", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_medication_administrations_encounter_id_administered_at", + table: "medication_administrations", + columns: new[] { "encounter_id", "administered_at" }); + + migrationBuilder.CreateIndex( + name: "IX_medication_administrations_encounter_id_drug_name", + table: "medication_administrations", + columns: new[] { "encounter_id", "drug_name" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "medication_administrations"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index 59580ca..4ee79aa 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -250,6 +250,60 @@ namespace VigilCareClinicalAPI.Migrations }); }); + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + modelBuilder.Entity("News2Score", b => { b.Property("Id") @@ -753,6 +807,17 @@ namespace VigilCareClinicalAPI.Migrations b.Navigation("Patient"); }); + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + modelBuilder.Entity("News2Score", b => { b.HasOne("Encounter", "Encounter") diff --git a/VigilCareClinicalAPI/Models/Records/Medication/CreateMedicationAdministrationRequest.cs b/VigilCareClinicalAPI/Models/Records/Medication/CreateMedicationAdministrationRequest.cs new file mode 100644 index 0000000..07c04cf --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Medication/CreateMedicationAdministrationRequest.cs @@ -0,0 +1,7 @@ +public record CreateMedicationAdministrationRequest( + string DrugName, + decimal Dose, + string DoseUnit, + string Route, + DateTimeOffset? AdministeredAt, // null = now + string AdministeredBy); \ No newline at end of file diff --git a/VigilCareClinicalAPI/News2/News2Detector.cs b/VigilCareClinicalAPI/News2/News2Detector.cs index 18dfd64..222f2c5 100644 --- a/VigilCareClinicalAPI/News2/News2Detector.cs +++ b/VigilCareClinicalAPI/News2/News2Detector.cs @@ -172,6 +172,18 @@ public class News2Detector var triggeredAt = DateTimeOffset.UtcNow; var details = BuildDetails(totalScore, riskLevel, paramScores); + var correlation = scope.ServiceProvider.GetRequiredService(); + var annotatedParts = new List(); + foreach (var code in News2Calculator.ParameterCodes) + { + var part = await correlation.TryAnnotateDetailsAsync( + encounterId, code, "", ct); + if (part.StartsWith(" — note:")) + annotatedParts.Add(part.TrimStart(' ', '—').Trim()); + } + if (annotatedParts.Count > 0) + details += " — " + string.Join("; ", annotatedParts.Distinct()); + var affected = await db.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO clinical_alerts (id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at) diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index b3f6796..621f946 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -71,6 +71,9 @@ try builder.Services.Configure( builder.Configuration.GetSection(SuppressionOptions.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection(MedicationCorrelationOptions.SectionName)); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -92,7 +95,9 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); - + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/VigilCareClinicalAPI/Services/Interfaces/IMedicationService.cs b/VigilCareClinicalAPI/Services/Interfaces/IMedicationService.cs new file mode 100644 index 0000000..45008b6 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IMedicationService.cs @@ -0,0 +1,10 @@ +public interface IMedicationService +{ + Task CreateAsync( + Guid encounterId, CreateMedicationAdministrationRequest req); + Task> ListByEncounterAsync( + Guid encounterId, DateTimeOffset? since, int page, int pageSize); + Task GetByIdAsync(Guid id); + Task> GetRecentForEncounterAsync( + Guid encounterId, string observationCode, int windowMinutes); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/MedicationService.cs b/VigilCareClinicalAPI/Services/MedicationService.cs new file mode 100644 index 0000000..7ec293c --- /dev/null +++ b/VigilCareClinicalAPI/Services/MedicationService.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +public class MedicationService : IMedicationService +{ + private readonly AppDbContext _db; + private readonly MedicationCorrelationOptions _correlationOptions; + + public MedicationService( + AppDbContext db, + IOptions correlationOptions) + { + _db = db; + _correlationOptions = correlationOptions.Value; + } + + public async Task CreateAsync( + Guid encounterId, CreateMedicationAdministrationRequest req) + { + var encounter = await _db.Encounters.FindAsync(encounterId); + if (encounter is null) + throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); + + if (encounter.Status != EncounterStatus.Active) + throw new ConflictException( + "Cannot record medications for a non-active encounter.", + "ENCOUNTER_NOT_ACTIVE"); + + var med = new MedicationAdministration + { + Id = Guid.NewGuid(), + EncounterId = encounterId, + DrugName = req.DrugName.Trim(), + Dose = req.Dose, + DoseUnit = req.DoseUnit.Trim(), + Route = req.Route.Trim(), + AdministeredAt = req.AdministeredAt ?? DateTimeOffset.UtcNow, + AdministeredBy = req.AdministeredBy.Trim() + }; + + _db.MedicationAdministrations.Add(med); + await _db.SaveChangesAsync(); + return med; + } + + public async Task GetByIdAsync(Guid id) + { + var med = await _db.MedicationAdministrations + .AsNoTracking() + .Include(m => m.Encounter) + .FirstOrDefaultAsync(m => m.Id == id); + + if (med is null) + throw new NotFoundException("Medication administration not found.", "MEDICATION_NOT_FOUND"); + + return med; + } + + public async Task> GetRecentForEncounterAsync( + Guid encounterId, string observationCode, int windowMinutes) + { + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-windowMinutes); + var mappings = _correlationOptions.DrugVitalMappings; + + // Find drugs that affect this observation code + var relevantDrugs = mappings + .Where(kv => kv.Value.Contains(observationCode, StringComparer.OrdinalIgnoreCase)) + .Select(kv => kv.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (relevantDrugs.Count == 0) + return Array.Empty(); + + return await _db.MedicationAdministrations + .AsNoTracking() + .Where(m => m.EncounterId == encounterId + && m.AdministeredAt >= cutoff + && relevantDrugs.Contains(m.DrugName.ToLower())) + .OrderByDescending(m => m.AdministeredAt) + .ToListAsync(); + } + + public async Task> ListByEncounterAsync( + Guid encounterId, DateTimeOffset? since, int page, int pageSize) + { + var query = _db.MedicationAdministrations + .AsNoTracking() + .Where(m => m.EncounterId == encounterId); + + if (since.HasValue) + query = query.Where(m => m.AdministeredAt >= since.Value); + + var total = await query.CountAsync(); + var items = await query + .OrderByDescending(m => m.AdministeredAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult(items, page, pageSize, total); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/PlausibilityValidator.cs b/VigilCareClinicalAPI/Services/PlausibilityValidator.cs index 75967c6..998fc82 100644 --- a/VigilCareClinicalAPI/Services/PlausibilityValidator.cs +++ b/VigilCareClinicalAPI/Services/PlausibilityValidator.cs @@ -6,12 +6,12 @@ public static class PlausibilityValidator private static readonly Dictionary _ranges = new() { ["HEART_RATE"] = (1, 300), - ["TEMP_C"] = (20, 50), - ["POTASSIUM_MEQ_L"] = (0.1m, 15), + ["TEMP_C"] = (15, 50), + ["POTASSIUM_MEQ_L"] = (0.1m, 12), ["SPO2"] = (50, 100), ["RESP_RATE"] = (1, 80), ["WBC_K_UL"] = (0.1m, 500), - ["GLUCOSE_MG_DL"] = (10, 1500), + ["GLUCOSE_MG_DL"] = (10, 1000), ["SYSTOLIC_BP"] = (40, 300), ["DIASTOLIC_BP"] = (20, 200), ["LACTATE_MMOL_L"] = (0.1m, 30), diff --git a/VigilCareClinicalAPI/Services/WarningEvaluator.cs b/VigilCareClinicalAPI/Services/WarningEvaluator.cs index cd07bd0..39cc083 100644 --- a/VigilCareClinicalAPI/Services/WarningEvaluator.cs +++ b/VigilCareClinicalAPI/Services/WarningEvaluator.cs @@ -90,6 +90,10 @@ public class WarningEvaluator var triggeredAt = DateTimeOffset.UtcNow; var details = BuildWarningDetails(observationCode, value, threshold); + var correlation = scope.ServiceProvider.GetRequiredService(); + details = await correlation.TryAnnotateDetailsAsync( + encounterId, observationCode, details, ct); + var affected = await db.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO clinical_alerts (id, encounter_id, patient_id, observation_id, alert_type, severity, details, status, triggered_at) diff --git a/VigilCareClinicalAPI/Validators/CreateMedicationAdministrationRequestValidator.cs b/VigilCareClinicalAPI/Validators/CreateMedicationAdministrationRequestValidator.cs new file mode 100644 index 0000000..206ddba --- /dev/null +++ b/VigilCareClinicalAPI/Validators/CreateMedicationAdministrationRequestValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +public class CreateMedicationAdministrationRequestValidator + : AbstractValidator +{ + public CreateMedicationAdministrationRequestValidator() + { + RuleFor(x => x.DrugName).NotEmpty().MaximumLength(200); + RuleFor(x => x.Dose).GreaterThan(0); + RuleFor(x => x.DoseUnit).NotEmpty().MaximumLength(20); + RuleFor(x => x.Route).NotEmpty().MaximumLength(50); + RuleFor(x => x.AdministeredBy).NotEmpty().MaximumLength(100); + RuleFor(x => x.AdministeredAt) + .LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5)) + .When(x => x.AdministeredAt.HasValue) + .WithMessage("AdministeredAt cannot be in the future."); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/appsettings.json b/VigilCareClinicalAPI/appsettings.json index 939e8fb..aec5ec9 100644 --- a/VigilCareClinicalAPI/appsettings.json +++ b/VigilCareClinicalAPI/appsettings.json @@ -93,5 +93,65 @@ }, "AlertSuppression": { "DefaultWindowMinutes": 30 + }, + "MedicationCorrelation": { + "CorrelationWindowMinutes": 90, + "DrugVitalMappings": { + "metoprolol": ["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"], + "labetalol": ["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"], + "atenolol": ["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"], + "propranolol": ["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"], + "esmolol": ["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"], + "carvedilol": ["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"], + + "norepinephrine": ["SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE"], + "epinephrine": ["SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE", "GLUCOSE_MG_DL"], + "vasopressin": ["SYSTOLIC_BP", "DIASTOLIC_BP"], + "dopamine": ["SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE"], + "dobutamine": ["SYSTOLIC_BP", "HEART_RATE"], + "phenylephrine": ["SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE"], + + "diltiazem": ["HEART_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP"], + "verapamil": ["HEART_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP"], + "amlodipine": ["SYSTOLIC_BP", "DIASTOLIC_BP"], + "nicardipine": ["SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE"], + + "nitroglycerin": ["SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE"], + "nitroprusside": ["SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE"], + "hydralazine": ["SYSTOLIC_BP", "DIASTOLIC_BP", "HEART_RATE"], + + "morphine": ["RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE"], + "fentanyl": ["RESP_RATE", "SPO2", "HEART_RATE"], + "hydromorphone": ["RESP_RATE", "SPO2", "SYSTOLIC_BP"], + "remifentanil": ["RESP_RATE", "SPO2", "HEART_RATE"], + + "propofol": ["RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE"], + "midazolam": ["RESP_RATE", "SPO2"], + "lorazepam": ["RESP_RATE", "SPO2"], + "ketamine": ["HEART_RATE", "SYSTOLIC_BP", "RESP_RATE"], + + "amiodarone": ["HEART_RATE", "SYSTOLIC_BP"], + "adenosine": ["HEART_RATE"], + "digoxin": ["HEART_RATE"], + "atropine": ["HEART_RATE"], + + "heparin": ["HEART_RATE"], + + "acetaminophen": ["TEMP_C"], + "ibuprofen": ["TEMP_C"], + + "insulin": ["GLUCOSE_MG_DL"], + "dextrose": ["GLUCOSE_MG_DL"], + "glucagon": ["GLUCOSE_MG_DL"], + + "dexamethasone": ["GLUCOSE_MG_DL", "TEMP_C"], + "methylprednisolone": ["GLUCOSE_MG_DL", "TEMP_C"], + "hydrocortisone": ["GLUCOSE_MG_DL", "TEMP_C"], + "prednisone": ["GLUCOSE_MG_DL", "TEMP_C"], + + "furosemide": ["SYSTOLIC_BP", "DIASTOLIC_BP"], + + "albuterol": ["HEART_RATE", "SPO2"] + } } } diff --git a/docs/scenarios/cardiac-ward-01.json b/docs/scenarios/cardiac-ward-01.json new file mode 100644 index 0000000..8856178 --- /dev/null +++ b/docs/scenarios/cardiac-ward-01.json @@ -0,0 +1,88 @@ +{ + "scenario": { + "id": "cardiac-ward-01", + "name": "Cardiac Ward — Atrial Fibrillation with Rapid Ventricular Response", + "description": "78-year-old woman on cardiac ward with known AF. Rate-controlled on admission but develops rapid ventricular response over 3 hours. Heart rate climbs while other vitals remain relatively stable — tests whether the system correctly alerts on isolated tachycardia escalation.", + "durationMinutes": 180, + "tags": ["cardiac", "afib", "tachycardia", "single-parameter"] + }, + "patient": { + "firstName": "Margaret", + "lastName": "Tsai", + "dateOfBirth": "1948-01-28", + "gender": "Female", + "mrn": "SIM-005" + }, + "encounter": { + "department": "Cardiac", + "room": "CARD-2A", + "bed": "1", + "encounterType": "Inpatient", + "chiefComplaint": "Atrial fibrillation — rate control monitoring" + }, + "events": [ + { "offsetMinutes": 0, "type": "observation", "data": { "code": "HEART_RATE", "value": 82, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "RESP_RATE", "value": 16, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 138, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 84, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "TEMP_C", "value": 36.6, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 30, "type": "observation", "data": { "code": "HEART_RATE", "value": 88, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "RESP_RATE", "value": 16, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 134, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "TEMP_C", "value": 36.7, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 60, "type": "observation", "data": { "code": "HEART_RATE", "value": 98, "unit": "bpm", "source": "monitor" }, "note": "HR climbing — AF losing rate control" }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "RESP_RATE", "value": 17, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 130, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 82, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "TEMP_C", "value": 36.7, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 90, "type": "observation", "data": { "code": "HEART_RATE", "value": 112, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "RESP_RATE", "value": 18, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 126, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 80, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SPO2", "value": 95, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 120, "type": "observation", "data": { "code": "HEART_RATE", "value": 128, "unit": "bpm", "source": "monitor" }, "note": "Rapid ventricular response" }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "RESP_RATE", "value": 20, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 118, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SPO2", "value": 94, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 130, "type": "medication", "data": { "drugName": "metoprolol", "dose": 5, "doseUnit": "mg", "route": "IV", "administeredBy": "nurse-rn-3" }, "note": "IV metoprolol for rate control" }, + + { "offsetMinutes": 150, "type": "observation", "data": { "code": "HEART_RATE", "value": 136, "unit": "bpm", "source": "monitor" }, "note": "Not yet responding to IV metoprolol" }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "RESP_RATE", "value": 22, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 112, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 72, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SPO2", "value": 93, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 180, "type": "observation", "data": { "code": "HEART_RATE", "value": 108, "unit": "bpm", "source": "monitor" }, "note": "Rate starting to respond" }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "RESP_RATE", "value": 19, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 120, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SPO2", "value": 95, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } } + ], + "expectedOutcomes": [ + { "afterOffsetMinutes": 60, "type": "alert", "alertType": "WARNING_HEART_RATE", "description": "HR 98 enters warning range" }, + { "afterOffsetMinutes": 90, "type": "alert", "alertType": "RAPID_DETERIORATION", "description": "HR climbed 82→112 in 90 minutes" }, + { "afterOffsetMinutes": 120, "type": "score", "scoreType": "NEWS2", "expectedMinimum": 5, "description": "NEWS2 medium risk from HR + SpO2 contributions" }, + { "afterOffsetMinutes": 120, "type": "alert", "alertType": "NEWS2_WARNING", "description": "NEWS2 5-6" }, + { "afterOffsetMinutes": 150, "type": "alert", "alertType": "NEWS2_EMERGENCY", "description": "NEWS2 ≥7 — HR scores 3 (≥131), SpO2 falling" } + ] +} diff --git a/docs/scenarios/hypothermia-elderly-01.json b/docs/scenarios/hypothermia-elderly-01.json new file mode 100644 index 0000000..a2c24f8 --- /dev/null +++ b/docs/scenarios/hypothermia-elderly-01.json @@ -0,0 +1,84 @@ +{ + "scenario": { + "id": "hypothermia-elderly-01", + "name": "Elderly Hypothermia — Subtle Deterioration", + "description": "85-year-old woman brought from nursing home with low-grade hypothermia and confusion. Vitals look deceptively near-normal except low temperature and altered mentation. Tests whether the system catches the NEWS2 escalation from temperature and AVPU contributions that are easy to miss clinically.", + "durationMinutes": 180, + "tags": ["elderly", "hypothermia", "subtle", "avpu", "nursing-home"] + }, + "patient": { + "firstName": "Mei-Ling", + "lastName": "Wu", + "dateOfBirth": "1941-04-12", + "gender": "Female", + "mrn": "SIM-006" + }, + "encounter": { + "department": "Emergency", + "room": "ED-5", + "bed": "B", + "encounterType": "Emergency", + "chiefComplaint": "Found confused and cold at nursing home, not eating for 2 days" + }, + "events": [ + { "offsetMinutes": 0, "type": "observation", "data": { "code": "HEART_RATE", "value": 68, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 118, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 72, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "TEMP_C", "value": 35.8, "unit": "°C", "source": "manual" }, "note": "Low-normal temperature" }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "AVPU", "value": 1, "unit": "score", "source": "manual" }, "note": "Responds to voice — confused" }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "GLUCOSE_MG_DL", "value": 62, "unit": "mg/dL", "source": "lab" }, "note": "Borderline hypoglycemia" }, + + { "offsetMinutes": 45, "type": "observation", "data": { "code": "HEART_RATE", "value": 64, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 45, "type": "observation", "data": { "code": "RESP_RATE", "value": 13, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 45, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 114, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 45, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 70, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 45, "type": "observation", "data": { "code": "TEMP_C", "value": 35.4, "unit": "°C", "source": "manual" }, "note": "Temperature dropping further" }, + { "offsetMinutes": 45, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 45, "type": "observation", "data": { "code": "AVPU", "value": 1, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 90, "type": "observation", "data": { "code": "HEART_RATE", "value": 58, "unit": "bpm", "source": "monitor" }, "note": "Bradycardic — hypothermia effect" }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "RESP_RATE", "value": 11, "unit": "/min", "source": "manual" }, "note": "RR dropping — warning range" }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 108, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 66, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "TEMP_C", "value": 35.0, "unit": "°C", "source": "manual" }, "note": "Hypothermic — critical range" }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SPO2", "value": 95, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "AVPU", "value": 2, "unit": "score", "source": "manual" }, "note": "Now responds to pain only" }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "GLUCOSE_MG_DL", "value": 54, "unit": "mg/dL", "source": "lab" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "WBC_K_UL", "value": 3.8, "unit": "×10³/µL", "source": "lab" }, "note": "Low WBC — possible immunosuppression" }, + + { "offsetMinutes": 120, "type": "observation", "data": { "code": "HEART_RATE", "value": 54, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "RESP_RATE", "value": 10, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 104, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 62, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "TEMP_C", "value": 34.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SPO2", "value": 94, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "AVPU", "value": 2, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 150, "type": "observation", "data": { "code": "HEART_RATE", "value": 50, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "RESP_RATE", "value": 9, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 100, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 58, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "TEMP_C", "value": 34.5, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SPO2", "value": 93, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "AVPU", "value": 2, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 180, "type": "observation", "data": { "code": "HEART_RATE", "value": 46, "unit": "bpm", "source": "monitor" }, "note": "HR in critical range" }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "RESP_RATE", "value": 8, "unit": "/min", "source": "manual" }, "note": "RR critical" }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 96, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 54, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "TEMP_C", "value": 34.2, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SPO2", "value": 91, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "AVPU", "value": 3, "unit": "score", "source": "manual" }, "note": "Unresponsive — ICU transfer" } + ], + "expectedOutcomes": [ + { "afterOffsetMinutes": 45, "type": "alert", "alertType": "WARNING_TEMP_C", "description": "Temp 35.4 enters warning range" }, + { "afterOffsetMinutes": 90, "type": "alert", "alertType": "WARNING_RESP_RATE", "description": "RR 11 enters warning range" }, + { "afterOffsetMinutes": 90, "type": "score", "scoreType": "NEWS2", "expectedMinimum": 7, "description": "NEWS2 HIGH — temp 3pts + AVPU 3pts + RR + low WBC" }, + { "afterOffsetMinutes": 90, "type": "alert", "alertType": "NEWS2_EMERGENCY", "description": "NEWS2 ≥7 with AVPU score 3" }, + { "afterOffsetMinutes": 90, "type": "alert", "alertType": "QSOFA_WARNING", "description": "qSOFA ≥2: AVPU 2 + RR borderline — depends on exact threshold" }, + { "afterOffsetMinutes": 90, "type": "alert", "alertType": "SEPSIS_WARNING", "description": "SIRS: temp <36, WBC <4 — possible sepsis in elderly" }, + { "afterOffsetMinutes": 180, "type": "alert", "alertType": "RAPID_DETERIORATION", "description": "Multi-parameter decline: HR, RR, temp, SpO2 all trending down" } + ] +} diff --git a/docs/scenarios/medication-false-alarm-01.json b/docs/scenarios/medication-false-alarm-01.json new file mode 100644 index 0000000..c77ff6f --- /dev/null +++ b/docs/scenarios/medication-false-alarm-01.json @@ -0,0 +1,79 @@ +{ + "scenario": { + "id": "medication-false-alarm-01", + "name": "Post-Metoprolol BP Drop — Expected Pharmacology", + "description": "68-year-old man admitted for hypertension management. Receives metoprolol for high BP. BP and HR drop into warning range afterward — this is the expected drug effect, not deterioration. Tests whether clinicians rate the post-medication alerts as false positives.", + "durationMinutes": 180, + "tags": ["medication", "false-positive", "hypertension", "expected-response"] + }, + "patient": { + "firstName": "David", + "lastName": "Huang", + "dateOfBirth": "1958-11-03", + "gender": "Male", + "mrn": "SIM-004" + }, + "encounter": { + "department": "Medical", + "room": "MED-7A", + "bed": "2", + "encounterType": "Inpatient", + "chiefComplaint": "Uncontrolled hypertension, headache" + }, + "events": [ + { "offsetMinutes": 0, "type": "observation", "data": { "code": "HEART_RATE", "value": 92, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "RESP_RATE", "value": 16, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 168, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 98, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SPO2", "value": 97, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 30, "type": "medication", "data": { "drugName": "metoprolol", "dose": 50, "doseUnit": "mg", "route": "PO", "administeredBy": "nurse-rn-2" }, "note": "Metoprolol administered for BP control" }, + + { "offsetMinutes": 60, "type": "observation", "data": { "code": "HEART_RATE", "value": 78, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 142, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 88, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SPO2", "value": 97, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 90, "type": "observation", "data": { "code": "HEART_RATE", "value": 64, "unit": "bpm", "source": "monitor" }, "note": "HR dropping — expected metoprolol effect" }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 118, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SPO2", "value": 98, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 120, "type": "observation", "data": { "code": "HEART_RATE", "value": 56, "unit": "bpm", "source": "monitor" }, "note": "HR in low range — metoprolol peak effect" }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 106, "unit": "mmHg", "source": "manual" }, "note": "SBP approaching warning range" }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 68, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "TEMP_C", "value": 36.7, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SPO2", "value": 97, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 150, "type": "observation", "data": { "code": "HEART_RATE", "value": 52, "unit": "bpm", "source": "monitor" }, "note": "HR 52 — warning range but expected" }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "RESP_RATE", "value": 13, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 102, "unit": "mmHg", "source": "manual" }, "note": "SBP 102 — warning range but expected" }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 64, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "TEMP_C", "value": 36.7, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SPO2", "value": 98, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 180, "type": "observation", "data": { "code": "HEART_RATE", "value": 58, "unit": "bpm", "source": "monitor" }, "note": "Stabilizing — effect wearing off slightly" }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 110, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 70, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SPO2", "value": 97, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } } + ], + "expectedOutcomes": [ + { "afterOffsetMinutes": 120, "type": "alert", "alertType": "WARNING_SYSTOLIC_BP", "description": "SBP 106 enters warning range — should be annotated with metoprolol context if Phase 15 active" }, + { "afterOffsetMinutes": 150, "type": "alert", "alertType": "WARNING_HEART_RATE", "description": "HR 52 enters low warning range — expected metoprolol effect" }, + { "afterOffsetMinutes": 150, "type": "alert", "alertType": "RAPID_DETERIORATION", "description": "HR dropped 92→52 and SBP dropped 168→102 — trajectory alert, but pharmacologically expected" } + ] +} diff --git a/docs/scenarios/post-op-pain-01.json b/docs/scenarios/post-op-pain-01.json new file mode 100644 index 0000000..8434566 --- /dev/null +++ b/docs/scenarios/post-op-pain-01.json @@ -0,0 +1,87 @@ +{ + "scenario": { + "id": "post-op-pain-01", + "name": "Post-Operative Pain Crisis — Opioid Effect on Respiratory Rate", + "description": "45-year-old woman post-abdominal surgery with escalating pain. Morphine administered at 30 minutes. Respiratory rate drops to warning range afterward — expected opioid effect. Heart rate rises from pain before medication, then normalizes. Tests whether clinicians distinguish pain-related tachycardia from pathological tachycardia, and opioid-related respiratory depression from deterioration.", + "durationMinutes": 180, + "tags": ["post-op", "pain", "opioid", "respiratory-depression", "medication-effect"] + }, + "patient": { + "firstName": "Sarah", + "lastName": "Lin", + "dateOfBirth": "1981-09-17", + "gender": "Female", + "mrn": "SIM-007" + }, + "encounter": { + "department": "Surgical", + "room": "SURG-6C", + "bed": "1", + "encounterType": "Inpatient", + "chiefComplaint": "Post-op day 0 — open appendectomy (complicated appendicitis)" + }, + "events": [ + { "offsetMinutes": 0, "type": "observation", "data": { "code": "HEART_RATE", "value": 86, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "RESP_RATE", "value": 16, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 134, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 82, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "TEMP_C", "value": 37.4, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SPO2", "value": 97, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 20, "type": "observation", "data": { "code": "HEART_RATE", "value": 104, "unit": "bpm", "source": "monitor" }, "note": "Tachycardia from pain" }, + { "offsetMinutes": 20, "type": "observation", "data": { "code": "RESP_RATE", "value": 20, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 20, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 146, "unit": "mmHg", "source": "manual" }, "note": "Hypertensive from pain" }, + { "offsetMinutes": 20, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 92, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 20, "type": "observation", "data": { "code": "TEMP_C", "value": 37.5, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 20, "type": "observation", "data": { "code": "SPO2", "value": 97, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 20, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 30, "type": "medication", "data": { "drugName": "morphine", "dose": 4, "doseUnit": "mg", "route": "IV", "administeredBy": "nurse-rn-4" }, "note": "Morphine for post-op pain" }, + + { "offsetMinutes": 60, "type": "observation", "data": { "code": "HEART_RATE", "value": 82, "unit": "bpm", "source": "monitor" }, "note": "HR normalizing with pain relief" }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 126, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 78, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "TEMP_C", "value": 37.4, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 90, "type": "observation", "data": { "code": "HEART_RATE", "value": 76, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "RESP_RATE", "value": 10, "unit": "/min", "source": "manual" }, "note": "RR dropping — morphine respiratory depression" }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 118, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 72, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "TEMP_C", "value": 37.3, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SPO2", "value": 95, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 120, "type": "observation", "data": { "code": "HEART_RATE", "value": 74, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "RESP_RATE", "value": 9, "unit": "/min", "source": "manual" }, "note": "RR 9 — warning range" }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 116, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 70, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "TEMP_C", "value": 37.2, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SPO2", "value": 94, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 150, "type": "observation", "data": { "code": "HEART_RATE", "value": 76, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "RESP_RATE", "value": 11, "unit": "/min", "source": "manual" }, "note": "RR recovering slowly" }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 120, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 74, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "TEMP_C", "value": 37.3, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SPO2", "value": 95, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 180, "type": "observation", "data": { "code": "HEART_RATE", "value": 78, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "RESP_RATE", "value": 13, "unit": "/min", "source": "manual" }, "note": "RR normalizing" }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 122, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "TEMP_C", "value": 37.4, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } } + ], + "expectedOutcomes": [ + { "afterOffsetMinutes": 20, "type": "alert", "alertType": "WARNING_HEART_RATE", "description": "HR 104 — pain-related tachycardia, not pathological" }, + { "afterOffsetMinutes": 90, "type": "alert", "alertType": "WARNING_RESP_RATE", "description": "RR 10 enters warning range — morphine respiratory depression" }, + { "afterOffsetMinutes": 120, "type": "alert", "alertType": "RAPID_DETERIORATION", "description": "RR dropped 20→9 — trajectory alert but pharmacologically expected" } + ] +} diff --git a/docs/scenarios/respiratory-failure-01.json b/docs/scenarios/respiratory-failure-01.json new file mode 100644 index 0000000..07b34e2 --- /dev/null +++ b/docs/scenarios/respiratory-failure-01.json @@ -0,0 +1,96 @@ +{ + "scenario": { + "id": "respiratory-failure-01", + "name": "Post-Surgical Respiratory Deterioration", + "description": "55-year-old man on surgical ward after abdominal surgery. Stable for 2 hours, then gradual respiratory deterioration: falling SpO2, rising RR, supplemental oxygen started. NEWS2 reaches HIGH risk by hour 3.", + "durationMinutes": 240, + "tags": ["respiratory", "surgical", "deterioration", "supplemental-o2"] + }, + "patient": { + "firstName": "Robert", + "lastName": "Wang", + "dateOfBirth": "1971-08-22", + "gender": "Male", + "mrn": "SIM-002" + }, + "encounter": { + "department": "Surgical", + "room": "SURG-4B", + "bed": "1", + "encounterType": "Inpatient", + "chiefComplaint": "Post-op day 1 — laparoscopic cholecystectomy" + }, + "events": [ + { "offsetMinutes": 0, "type": "observation", "data": { "code": "HEART_RATE", "value": 76, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 132, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 82, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "TEMP_C", "value": 37.0, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SPO2", "value": 98, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 60, "type": "observation", "data": { "code": "HEART_RATE", "value": 78, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 130, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 80, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "TEMP_C", "value": 37.1, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SPO2", "value": 97, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 120, "type": "observation", "data": { "code": "HEART_RATE", "value": 82, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "RESP_RATE", "value": 18, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 126, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 78, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "TEMP_C", "value": 37.3, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SPO2", "value": 95, "unit": "%", "source": "monitor" }, "note": "SpO2 starting to drift down" }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 140, "type": "observation", "data": { "code": "HEART_RATE", "value": 88, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 140, "type": "observation", "data": { "code": "RESP_RATE", "value": 21, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 140, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 124, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 140, "type": "observation", "data": { "code": "TEMP_C", "value": 37.4, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 140, "type": "observation", "data": { "code": "SPO2", "value": 93, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 140, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SUPPLEMENTAL_O2", "value": 1, "unit": "flag", "source": "manual" }, "note": "Nurse starts 2L nasal cannula" }, + + { "offsetMinutes": 160, "type": "observation", "data": { "code": "HEART_RATE", "value": 94, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 160, "type": "observation", "data": { "code": "RESP_RATE", "value": 24, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 160, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 120, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 160, "type": "observation", "data": { "code": "TEMP_C", "value": 37.5, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 160, "type": "observation", "data": { "code": "SPO2", "value": 92, "unit": "%", "source": "monitor" }, "note": "SpO2 92 despite supplemental O2" }, + { "offsetMinutes": 160, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 180, "type": "observation", "data": { "code": "HEART_RATE", "value": 102, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "RESP_RATE", "value": 26, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 118, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "TEMP_C", "value": 37.6, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SPO2", "value": 90, "unit": "%", "source": "monitor" }, "note": "SpO2 dropping further" }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SUPPLEMENTAL_O2", "value": 1, "unit": "flag", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 210, "type": "observation", "data": { "code": "HEART_RATE", "value": 108, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 210, "type": "observation", "data": { "code": "RESP_RATE", "value": 28, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 210, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 116, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 210, "type": "observation", "data": { "code": "TEMP_C", "value": 37.7, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 210, "type": "observation", "data": { "code": "SPO2", "value": 88, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 210, "type": "observation", "data": { "code": "SUPPLEMENTAL_O2", "value": 1, "unit": "flag", "source": "manual" } }, + { "offsetMinutes": 210, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 240, "type": "observation", "data": { "code": "HEART_RATE", "value": 112, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "RESP_RATE", "value": 30, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 114, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "TEMP_C", "value": 37.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "SPO2", "value": 86, "unit": "%", "source": "monitor" }, "note": "Critical desaturation — ICU consult called" }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "SUPPLEMENTAL_O2", "value": 1, "unit": "flag", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } } + ], + "expectedOutcomes": [ + { "afterOffsetMinutes": 140, "type": "alert", "alertType": "WARNING_RESP_RATE", "description": "RR 21 enters warning range" }, + { "afterOffsetMinutes": 140, "type": "score", "scoreType": "NEWS2", "expectedMinimum": 4, "description": "NEWS2 rising from SpO2 and RR contributions" }, + { "afterOffsetMinutes": 160, "type": "alert", "alertType": "NEWS2_WARNING", "description": "NEWS2 5-6 with supplemental O2 + low SpO2 + high RR" }, + { "afterOffsetMinutes": 160, "type": "alert", "alertType": "RAPID_DETERIORATION", "description": "SpO2 dropping 98→92 over short window" }, + { "afterOffsetMinutes": 180, "type": "alert", "alertType": "NEWS2_EMERGENCY", "description": "NEWS2 ≥7 with critical SpO2 on supplemental O2" }, + { "afterOffsetMinutes": 210, "type": "alert", "alertType": "WARNING_HEART_RATE", "description": "HR 108 enters warning range" } + ] +} diff --git a/docs/scenarios/sepsis-ed-01.json b/docs/scenarios/sepsis-ed-01.json new file mode 100644 index 0000000..715d147 --- /dev/null +++ b/docs/scenarios/sepsis-ed-01.json @@ -0,0 +1,111 @@ +{ + "scenario": { + "id": "sepsis-ed-01", + "name": "ED Sepsis Deterioration — UTI Source", + "description": "72-year-old woman presents to ED with UTI symptoms. Over 4 hours she develops sepsis: rising temperature, tachycardia, hypotension. Antibiotics started at 1 hour. Sepsis bundle completed by hour 2.", + "durationMinutes": 240, + "tags": ["sepsis", "ed", "deterioration", "bundle-completion"] + }, + "patient": { + "firstName": "Eleanor", + "lastName": "Chen", + "dateOfBirth": "1954-03-15", + "gender": "Female", + "mrn": "SIM-001" + }, + "encounter": { + "department": "Emergency", + "room": "ED-12", + "bed": "A", + "encounterType": "Emergency", + "chiefComplaint": "Fever, confusion, dysuria for 2 days" + }, + "events": [ + { "offsetMinutes": 0, "type": "observation", "data": { "code": "HEART_RATE", "value": 88, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "RESP_RATE", "value": 18, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 128, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 78, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "TEMP_C", "value": 38.2, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SPO2", "value": 97, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 30, "type": "observation", "data": { "code": "HEART_RATE", "value": 94, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "RESP_RATE", "value": 20, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 122, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 74, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "TEMP_C", "value": 38.6, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + { "offsetMinutes": 30, "type": "observation", "data": { "code": "WBC_K_UL", "value": 14.2, "unit": "×10³/µL", "source": "lab" }, "note": "Initial labs — elevated WBC" }, + + { "offsetMinutes": 60, "type": "observation", "data": { "code": "HEART_RATE", "value": 105, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "RESP_RATE", "value": 22, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 112, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 68, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "TEMP_C", "value": 39.1, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SPO2", "value": 95, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 65, "type": "medication", "data": { "drugName": "ceftriaxone", "dose": 2, "doseUnit": "g", "route": "IV", "administeredBy": "nurse-rn-1" }, "note": "Broad-spectrum antibiotics started" }, + + { "offsetMinutes": 75, "type": "order_result", "data": { "orderDescription": "SEP-1: Blood cultures", "resultValue": "2 sets drawn — pending", "resultedBy": "nurse-rn-1" } }, + { "offsetMinutes": 80, "type": "order_result", "data": { "orderDescription": "SEP-1: Serum lactate", "resultValue": "2.8 mmol/L", "resultedBy": "lab-tech-1" } }, + + { "offsetMinutes": 90, "type": "observation", "data": { "code": "HEART_RATE", "value": 112, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "RESP_RATE", "value": 24, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 102, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 62, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "TEMP_C", "value": 39.3, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "SPO2", "value": 94, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "AVPU", "value": 1, "unit": "score", "source": "manual" }, "note": "Patient confused — responds to voice" }, + { "offsetMinutes": 90, "type": "observation", "data": { "code": "LACTATE_MMOL_L", "value": 2.8, "unit": "mmol/L", "source": "lab" } }, + + { "offsetMinutes": 100, "type": "order_result", "data": { "orderDescription": "SEP-1: Broad-spectrum antibiotics", "resultValue": "Ceftriaxone 2g IV administered", "resultedBy": "nurse-rn-1" } }, + { "offsetMinutes": 105, "type": "order_result", "data": { "orderDescription": "SEP-1: IV fluid bolus", "resultValue": "1L NS bolus started", "resultedBy": "nurse-rn-1" } }, + + { "offsetMinutes": 120, "type": "observation", "data": { "code": "HEART_RATE", "value": 118, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "RESP_RATE", "value": 26, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 96, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 58, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "TEMP_C", "value": 39.4, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SPO2", "value": 93, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "AVPU", "value": 1, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 150, "type": "observation", "data": { "code": "HEART_RATE", "value": 115, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "RESP_RATE", "value": 24, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 100, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 60, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "TEMP_C", "value": 39.0, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "SPO2", "value": 94, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "AVPU", "value": 1, "unit": "score", "source": "manual" } }, + { "offsetMinutes": 150, "type": "observation", "data": { "code": "LACTATE_MMOL_L", "value": 3.4, "unit": "mmol/L", "source": "lab" }, "note": "Repeat lactate — rising" }, + + { "offsetMinutes": 180, "type": "observation", "data": { "code": "HEART_RATE", "value": 108, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "RESP_RATE", "value": 22, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 105, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 64, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "TEMP_C", "value": 38.7, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SPO2", "value": 95, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" }, "note": "Mentation improving — alert again" }, + + { "offsetMinutes": 240, "type": "observation", "data": { "code": "HEART_RATE", "value": 98, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "RESP_RATE", "value": 20, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 112, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 68, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "TEMP_C", "value": 38.3, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "SPO2", "value": 96, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "LACTATE_MMOL_L", "value": 2.1, "unit": "mmol/L", "source": "lab" }, "note": "Lactate improving after fluids" } + ], + "expectedOutcomes": [ + { "afterOffsetMinutes": 30, "type": "alert", "alertType": "WARNING_TEMP_C", "description": "Temperature 38.6 enters warning range" }, + { "afterOffsetMinutes": 60, "type": "alert", "alertType": "WARNING_HEART_RATE", "description": "HR 105 enters warning range" }, + { "afterOffsetMinutes": 60, "type": "alert", "alertType": "SEPSIS_WARNING", "description": "SIRS criteria met: temp >38.3, HR >90, RR >20, WBC >12" }, + { "afterOffsetMinutes": 90, "type": "alert", "alertType": "QSOFA_WARNING", "description": "qSOFA ≥2: RR 24, SBP 102, AVPU 1" }, + { "afterOffsetMinutes": 90, "type": "bundle", "description": "Sepsis bundle auto-created with 4 orders" }, + { "afterOffsetMinutes": 105, "type": "bundle", "description": "All 4 bundle elements completed — COMPLIANT" }, + { "afterOffsetMinutes": 120, "type": "score", "scoreType": "NEWS2", "expectedMinimum": 7, "description": "NEWS2 reaches HIGH risk" }, + { "afterOffsetMinutes": 120, "type": "alert", "alertType": "NEWS2_EMERGENCY", "description": "NEWS2 ≥7 or single parameter 3" }, + { "afterOffsetMinutes": 120, "type": "alert", "alertType": "RAPID_DETERIORATION", "description": "HR climbed 88→118 and SBP dropped 128→96 rapidly" } + ] +} diff --git a/docs/scenarios/stable-baseline-01.json b/docs/scenarios/stable-baseline-01.json new file mode 100644 index 0000000..02b877b --- /dev/null +++ b/docs/scenarios/stable-baseline-01.json @@ -0,0 +1,65 @@ +{ + "scenario": { + "id": "stable-baseline-01", + "name": "Stable Observation Patient — Control", + "description": "40-year-old healthy male admitted for 4-hour observation after minor outpatient procedure. Vitals remain normal throughout with natural physiological noise. No alerts should fire. This is a control scenario to test for false positives.", + "durationMinutes": 240, + "tags": ["stable", "control", "observation", "no-alerts"] + }, + "patient": { + "firstName": "James", + "lastName": "Liu", + "dateOfBirth": "1986-05-10", + "gender": "Male", + "mrn": "SIM-003" + }, + "encounter": { + "department": "Day Surgery", + "room": "DS-3", + "bed": "A", + "encounterType": "Observation", + "chiefComplaint": "Post-procedure observation — arthroscopic knee surgery" + }, + "events": [ + { "offsetMinutes": 0, "type": "observation", "data": { "code": "HEART_RATE", "value": 72, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 124, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "SPO2", "value": 99, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 0, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 60, "type": "observation", "data": { "code": "HEART_RATE", "value": 75, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "RESP_RATE", "value": 13, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 120, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 74, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "TEMP_C", "value": 36.7, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "SPO2", "value": 98, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 60, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 120, "type": "observation", "data": { "code": "HEART_RATE", "value": 70, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "RESP_RATE", "value": 15, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 126, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 78, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "TEMP_C", "value": 36.9, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "SPO2", "value": 99, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 120, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 180, "type": "observation", "data": { "code": "HEART_RATE", "value": 73, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 122, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "TEMP_C", "value": 36.8, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "SPO2", "value": 98, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 180, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } }, + + { "offsetMinutes": 240, "type": "observation", "data": { "code": "HEART_RATE", "value": 74, "unit": "bpm", "source": "monitor" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "SYSTOLIC_BP", "value": 124, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "DIASTOLIC_BP", "value": 76, "unit": "mmHg", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "TEMP_C", "value": 36.7, "unit": "°C", "source": "manual" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "SPO2", "value": 99, "unit": "%", "source": "monitor" } }, + { "offsetMinutes": 240, "type": "observation", "data": { "code": "AVPU", "value": 0, "unit": "score", "source": "manual" } } + ], + "expectedOutcomes": [] +}