update docs and prep for frontend
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
|
||||
|
||||
**Implementation status:** All fourteen planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion (patient demographics, encounter enrichment, 12 observation codes), warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, and qSOFA scoring with sepsis bundle compliance tracking. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
||||
**Implementation status:** Sixteen planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion (patient demographics, encounter enrichment, 12 observation codes), warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA scoring with sepsis bundle compliance tracking, medication administration with alert correlation annotations, and the console replay simulator. Ward-dashboard backend APIs (`GET /encounters` list with clinical summaries, `GET /qsofa/current`, CORS for a frontend on port 5173) are also in place. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
||||
|
||||
## Domain Model — How It Maps to a Real Clinical System
|
||||
|
||||
@@ -67,6 +67,10 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
||||
- **NEWS2 Composite Scoring Engine** — `News2ScoringService` Kafka consumer (`news2-scoring`) evaluates seven vital parameters per encounter (`RESP_RATE`, `SPO2`, `SYSTOLIC_BP`, `HEART_RATE`, `AVPU`, `TEMP_C`, `SUPPLEMENTAL_O2`) using Redis keys with a 4-hour TTL; when all seven are present, computes the official NEWS2 aggregate score, persists to `news2_scores`, and creates `NEWS2_WARNING` (score 5–6 or single param = 3) or `NEWS2_EMERGENCY` (score ≥ 7) alerts idempotently; `GET /encounters/:id/news2/current` and `/history` expose score history; Prometheus `news2_scores_total` and `news2_scoring_duration_seconds`
|
||||
- **Trend Detection Engine** — `TrendAnalyzerService` Kafka consumer (`trend-analyzer`) tracks rate-of-change for five vital parameters (`HEART_RATE`, `RESP_RATE`, `SYSTOLIC_BP`, `TEMP_C`, `SPO2`) using Redis sliding-window history; when velocity exceeds configured thresholds (e.g. 72→95 bpm in 30 min), creates a `RAPID_DETERIORATION` alert even if the current value is below warning thresholds; Prometheus `trend_alerts_total` and `trend_analysis_duration_seconds`
|
||||
- **Alert Suppression Windows** — acknowledging a suppressible alert (`WARNING_*`, `NEWS2_WARNING`) sets a Redis key `suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min from `AlertSuppression` config; optional per-code override via `alert_thresholds.suppression_window_minutes`); `WarningEvaluator` and `News2Detector` check suppression before creating new warning alerts; critical alerts (`CRITICAL_*`, `NEWS2_EMERGENCY`, `SEPSIS_WARNING`, `RAPID_DETERIORATION`) are never suppressed; observations and NEWS2 scores continue to persist during suppression; Prometheus `alert_suppressions_total`
|
||||
- **Medication Administration** — `POST /encounters/:id/medications` records drug administrations (name, dose, route, timestamp, administered-by); `GET /encounters/:id/medications` lists with optional `since` filter; `GET /medications/:id` detail; active-encounter guard; FluentValidation on request DTOs
|
||||
- **Medication Correlation Annotations** — `MedicationCorrelationHelper` appends medication context to warning and NEWS2 alert details when a mapped drug was administered within the correlation window (default 90 min); drug-to-vital mappings in `MedicationCorrelation` config (`appsettings.json`); annotates rather than suppresses — alerts still fire; sepsis, trend, and critical sync-path alerts are never annotated; design rationale in `docs/decisions/medication-correlation-design.md`
|
||||
- **Ward Dashboard APIs** — `GET /encounters` returns paginated `WardEncounterSummary` rows (patient name/MRN, room/bed, department, status, latest NEWS2 score, live qSOFA criteria count from Redis, sepsis bundle status, open alert count); filterable by `status` and `department`; `GET /encounters/:id/qsofa/current` exposes Redis-backed qSOFA state; CORS policy `Dashboard` allows configured origins (default `http://localhost:5173`)
|
||||
- **Console Replay Simulator** — standalone `VigilCare.Simulator` .NET console app replays JSON scenario files against the live API with configurable speed (`--speed 0` instant, `60` = 60× faster); commands: `replay`, `replay-all`, `validate`, `dry-run`; optional `--poll` shows alerts, NEWS2, and sepsis bundle state during replay; eight sample scenarios in `VigilCare.Simulator/Scenarios/List/`; user guide in `docs/simulator-guide.md`
|
||||
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
|
||||
- **Data Lake Writer** — `DataLakeWriterService` (consumer group `data-lake-writer`) buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes date-partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/`), and commits Kafka offsets only after at least one successful upload; shutdown flush uses an uncanceled token so MinIO writes complete on Ctrl+C; `kafka_partition` and `kafka_offset` columns provide audit lineage
|
||||
- **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ
|
||||
@@ -97,8 +101,8 @@ IHostedServices (background):
|
||||
OutboxRelayService → PostgreSQL outbox → Kafka (every 500ms)
|
||||
EsIndexerService → Kafka → Elasticsearch (consumer group: es-indexer)
|
||||
SepsisEngineService → Kafka → Redis SIRS + qSOFA state → PostgreSQL alert → SepsisAlertHandler → SepsisBundleService (consumer group: sepsis-engine)
|
||||
WarningAlertService → Kafka → WarningEvaluator → PostgreSQL WARNING alert (consumer group: warning-evaluator)
|
||||
News2ScoringService → Kafka → News2Detector → Redis NEWS2 state → PostgreSQL score + alert (consumer group: news2-scoring)
|
||||
WarningAlertService → Kafka → WarningEvaluator (+ MedicationCorrelationHelper) → PostgreSQL WARNING alert (consumer group: warning-evaluator)
|
||||
News2ScoringService → Kafka → News2Detector (+ MedicationCorrelationHelper) → Redis NEWS2 state → PostgreSQL score + alert (consumer group: news2-scoring)
|
||||
TrendAnalyzerService → Kafka → TrendDetector → Redis trend history → PostgreSQL RAPID_DETERIORATION alert (consumer group: trend-analyzer)
|
||||
AlertSuppressionService → Redis suppress:{enc}:{type} keys set on acknowledge; read by WarningEvaluator + News2Detector
|
||||
NotificationPublisherService → Kafka → RabbitMQ paging.queue (consumer group: notification-publisher)
|
||||
@@ -147,7 +151,9 @@ VigilCareClinicalAPI/
|
||||
├── appsettings.json # Connection strings, Kafka, Elasticsearch, RabbitMQ, MinIO, Serilog, ReconciliationJobs
|
||||
├── Controllers/
|
||||
│ ├── PatientsController.cs # Patient CRUD, search by name/MRN
|
||||
│ ├── EncountersController.cs # Encounter open, status PATCH, timeline
|
||||
│ ├── EncountersController.cs # Encounter list (ward summary), get, status PATCH, timeline
|
||||
│ ├── MedicationsController.cs # Medication administration create, list, get
|
||||
│ ├── QsofaController.cs # Current qSOFA criteria count (Redis-backed)
|
||||
│ ├── ObservationsController.cs # Ingest POST, cursor-paginated GET
|
||||
│ ├── AlertThresholdsController.cs # Threshold CRUD + cache invalidation
|
||||
│ ├── AlertsController.cs # Alert list (global + per-encounter), acknowledge, resolve
|
||||
@@ -167,7 +173,8 @@ VigilCareClinicalAPI/
|
||||
│ │ ├── OutboxEvent.cs # topic + payload JSONB + processed_at
|
||||
│ │ ├── ReconciliationAlert.cs
|
||||
│ │ ├── SepsisBundle.cs # Four-element treatment bundle with 1-hour compliance deadline
|
||||
│ │ └── SepsisBundleElement.cs # Individual bundle element linked to a clinical order
|
||||
│ │ ├── SepsisBundleElement.cs # Individual bundle element linked to a clinical order
|
||||
│ │ └── MedicationAdministration.cs # Drug administration record per encounter
|
||||
│ └── Enums/
|
||||
│ ├── EncounterStatus.cs # Scheduled, Active, Discharged, Cancelled
|
||||
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
|
||||
@@ -196,13 +203,17 @@ VigilCareClinicalAPI/
|
||||
│ ├── OrderService.cs # Order lifecycle; status machine; calls SepsisBundleService.OnOrderResultedAsync on result
|
||||
│ ├── News2Service.cs # Current score + cursor-paginated history from PostgreSQL
|
||||
│ ├── SepsisBundleService.cs # Bundle creation, element completion, compliance evaluation
|
||||
│ ├── WarningEvaluator.cs # Warning-range evaluation; suppression check; idempotent alert INSERT
|
||||
│ ├── MedicationService.cs # Medication CRUD; GetRecentForEncounterAsync for correlation
|
||||
│ ├── QsofaService.cs # Redis-backed qSOFA criteria count for API/dashboard
|
||||
│ ├── WarningEvaluator.cs # Warning-range evaluation; suppression + medication annotation; idempotent INSERT
|
||||
│ ├── AnalyticsService.cs # Elasticsearch query wrappers
|
||||
│ └── PlausibilityValidator.cs # Per-code numeric range guard
|
||||
├── Trend/
|
||||
│ ├── TrendCalculator.cs # Pure static rate-of-change logic
|
||||
│ └── TrendDetector.cs # Redis history + RAPID_DETERIORATION alert creation
|
||||
├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, …
|
||||
├── Medication/
|
||||
│ └── MedicationCorrelationHelper.cs # Appends drug context to warning/NEWS2 alert details
|
||||
├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, CreateMedicationAdministration, …
|
||||
├── Observability/
|
||||
│ └── Metrics/
|
||||
│ └── ClinicalMetrics.cs # Fifteen Prometheus metric families (counters, histograms, gauges)
|
||||
@@ -236,7 +247,9 @@ VigilCareClinicalAPI/
|
||||
├── Configuration/
|
||||
│ ├── KafkaOptions.cs / KafkaTopicOptions.cs
|
||||
│ ├── RabbitMqOptions.cs / MinioOptions.cs
|
||||
│ └── ReconciliationJobOptions.cs
|
||||
│ ├── ReconciliationJobOptions.cs
|
||||
│ ├── MedicationCorrelationOptions.cs # Drug-vital mappings + correlation window
|
||||
│ └── DashboardOptions.cs # CORS origins for ward dashboard frontend
|
||||
├── Sepsis/
|
||||
│ ├── SirsDetector.cs # Redis SIRS state management (SET/DEL/MGET)
|
||||
│ ├── SirsEvaluator.cs # Per-code criterion evaluation
|
||||
@@ -262,6 +275,9 @@ VigilCareClinicalAPI/
|
||||
│ ├── Observation/ObservationRow.cs # Parquet row contract for observation events
|
||||
│ ├── Alert/AlertRow.cs # Parquet row contract for alert events
|
||||
│ ├── Encounter/EncounterStatusRow.cs # Parquet row contract for encounter status events
|
||||
│ ├── Encounter/WardEncounterSummary.cs # Denormalized row for ward encounter list
|
||||
│ ├── Medication/CreateMedicationAdministrationRequest.cs
|
||||
│ ├── Qsofa/QsofaCurrentResponse.cs
|
||||
│ └── Sepsis/QsofaResult.cs, QsofaOutcome.cs # qSOFA detector result and outcome enum
|
||||
├── Data/
|
||||
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
|
||||
@@ -308,7 +324,21 @@ tests/
|
||||
├── AlertSuppressionTests.cs # Suppression on acknowledge, read-side skip, TTL expiry
|
||||
├── QsofaCalculatorTests.cs # Boundary tests for three qSOFA criteria
|
||||
├── QsofaDetectorTests.cs # qSOFA detector — two-criteria alert, normalization, idempotency
|
||||
└── SepsisBundleTests.cs # Bundle creation from SIRS/qSOFA, element completion, compliance outcomes
|
||||
├── SepsisBundleTests.cs # Bundle creation from SIRS/qSOFA, element completion, compliance outcomes
|
||||
├── MedicationServiceTests.cs # Medication CRUD, discharged encounter rejection, pagination
|
||||
├── MedicationCorrelationTests.cs # End-to-end warning/NEWS2 annotation with medication context
|
||||
├── MedicationValidationTests.cs # FluentValidation 400 on invalid medication requests
|
||||
├── EncountersListTests.cs # Ward encounter list filters and summary fields
|
||||
└── QsofaCurrentTests.cs # qSOFA current API — Redis state, criteria breakdown
|
||||
|
||||
VigilCare.Simulator/ # Phase 16 — console replay simulator (HTTP-only, no direct DB/Kafka)
|
||||
├── Program.cs # CLI: replay, replay-all, validate, dry-run
|
||||
├── Commands/ # System.CommandLine command handlers
|
||||
├── Client/VigilCareApiClient.cs # Typed HTTP client for all API endpoints
|
||||
├── Engine/ReplayEngine.cs # Scenario replay with speed multiplier + event logging
|
||||
├── Polling/ApiPoller.cs # Optional post-event alert/score/bundle polling
|
||||
├── Scenarios/ # schema.json, ScenarioLoader, ScenarioValidator
|
||||
└── Scenarios/List/ # Eight sample scenarios (sepsis, NEWS2, stable, medication, …)
|
||||
|
||||
scripts/
|
||||
├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache
|
||||
@@ -322,13 +352,17 @@ scripts/
|
||||
├── run-phase10-verification.sh # Phase 10 — 12 Redis thresholds, clinical enrichment, ES pipeline, integration tests
|
||||
├── run-phase11-verification.sh # Phase 11 — warning alerts, orders API, validation, integration tests
|
||||
├── run-phase12-verification.sh # Phase 12 — NEWS2 end-to-end pipeline, API, ES, Prometheus, integration tests
|
||||
└── run-phase13-verification.sh # Phase 13 — trend detection, alert suppression, consumer lag, integration tests
|
||||
├── run-phase13-verification.sh # Phase 13 — trend detection, alert suppression, consumer lag, integration tests
|
||||
├── run-phase14-verification.sh # Phase 14 — qSOFA, sepsis bundle compliance, integration tests
|
||||
└── run-phase15-verification.sh # Phase 15 — medication administration + correlation annotations
|
||||
|
||||
docs/
|
||||
├── plans/ # Phase 1–13 implementation and verification guides
|
||||
├── plans/ # Phase implementation and verification guides
|
||||
├── simulator-guide.md # VigilCare.Simulator user guide
|
||||
├── decisions/
|
||||
│ ├── data-lake-design.md # Parquet vs JSON, partitioning, replay rationale
|
||||
│ └── sepsis-engine-design.md # SIRS sliding window and idempotent alert design
|
||||
│ ├── sepsis-engine-design.md # SIRS sliding window and idempotent alert design
|
||||
│ └── medication-correlation-design.md # Drug-vital mapping and annotation rationale
|
||||
├── docker-compose-usage-and-troubleshooting.md
|
||||
└── vigilcare-clinical-api-prd.md # Product requirements and phase roadmap
|
||||
```
|
||||
@@ -458,6 +492,18 @@ On startup the application:
|
||||
|
||||
Swagger UI is available at `http://localhost:5270/swagger` in Development (API binds to `0.0.0.0:5270` per `launchSettings.json`).
|
||||
|
||||
### Run the Simulator
|
||||
|
||||
With the API running, replay a scenario from the repository root:
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- replay \
|
||||
VigilCare.Simulator/Scenarios/List/uti-sepsis-elderly-01.json \
|
||||
--speed 60 --poll
|
||||
```
|
||||
|
||||
Other commands: `validate <file>`, `dry-run <file>`, `replay-all <directory>`. See `docs/simulator-guide.md` for the full user guide.
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
@@ -487,6 +533,11 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
|
||||
| `QsofaCalculatorTests` | 14 | Boundary tests for three qSOFA criteria (RESP_RATE, SYSTOLIC_BP, AVPU) |
|
||||
| `QsofaDetectorTests` | 14 | qSOFA detector — two-criteria alert, normalization key delete, idempotent duplicate, non-qSOFA code ignored |
|
||||
| `SepsisBundleTests` | 14 | Bundle creation from SIRS/qSOFA, four auto-orders, element completion, compliant/non-compliant outcomes, monitor marks overdue bundles, idempotency |
|
||||
| `MedicationServiceTests` | 15 | Medication create/list on active encounter, discharged encounter 409, pagination, `since` filter |
|
||||
| `MedicationCorrelationTests` | 15 | Warning and NEWS2 alert details annotated when correlated drug administered |
|
||||
| `MedicationValidationTests` | 15 | FluentValidation 400 on empty drug name, zero dose, future `administeredAt` |
|
||||
| `EncountersListTests` | — | Ward encounter list — status/department filters, summary fields |
|
||||
| `QsofaCurrentTests` | — | `GET /qsofa/current` — criteria count and breakdown from Redis |
|
||||
|
||||
### Verification Scripts
|
||||
|
||||
@@ -499,6 +550,8 @@ With the API running (`dotnet run`) and Docker Compose up:
|
||||
./scripts/run-phase11-verification.sh # Warning alert pipeline, orders API, FluentValidation, Phase 11 integration tests
|
||||
./scripts/run-phase12-verification.sh # NEWS2 end-to-end pipeline, API, Elasticsearch, Prometheus, Phase 12 integration tests
|
||||
./scripts/run-phase13-verification.sh # Trend detection, alert suppression, consumer lag, Phase 13 integration tests
|
||||
./scripts/run-phase14-verification.sh # qSOFA, sepsis bundle compliance, Phase 14 integration tests
|
||||
./scripts/run-phase15-verification.sh # Medication administration + correlation annotation pipeline
|
||||
```
|
||||
|
||||
Phase 13 unit/integration tests only:
|
||||
@@ -507,6 +560,12 @@ Phase 13 unit/integration tests only:
|
||||
dotnet test --filter "FullyQualifiedName~Trend|FullyQualifiedName~Suppression"
|
||||
```
|
||||
|
||||
Phase 15 unit/integration tests only:
|
||||
|
||||
```bash
|
||||
dotnet test --filter "FullyQualifiedName~Medication"
|
||||
```
|
||||
|
||||
Per-phase test runners (subset of `dotnet test`):
|
||||
|
||||
```bash
|
||||
@@ -608,10 +667,16 @@ Error response:
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/encounters` | Paginated ward list with clinical summaries; optional `status`, `department` filters |
|
||||
| POST | `/patients/{id}/encounters` | Open an encounter |
|
||||
| GET | `/encounters/{id}` | Encounter detail with recent observations and open alerts |
|
||||
| PATCH | `/encounters/{id}/status` | Advance encounter status |
|
||||
| GET | `/encounters/{id}/timeline` | Merged chronological view: status changes, observations, alerts |
|
||||
| GET | `/encounters/{id}/qsofa/current` | Current qSOFA active criteria count (0–3) from Redis |
|
||||
|
||||
**GET `/encounters` query params:** `status` (DB literal, e.g. `ACTIVE`), `department` (DB literal, e.g. `ICU`), `page`, `pageSize`
|
||||
|
||||
**Ward summary fields:** `encounterId`, `patientId`, `mrn`, `firstName`, `lastName`, `roomBed`, `department`, `status`, `news2Score`, `news2RiskLevel`, `qsofaScore`, `sepsisActive`, `sepsisBundleStatus`, `openAlertCount`
|
||||
|
||||
**Encounter status machine:**
|
||||
|
||||
@@ -830,6 +895,27 @@ Scores are computed asynchronously by `News2ScoringService` after observations a
|
||||
|
||||
Bundles are created automatically by `SepsisAlertHandler` when a SIRS or qSOFA alert fires. Four clinical orders (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation) are auto-created with `orderedBy: sepsis-bundle-engine`. As orders are resulted via `PATCH /orders/{id}/result`, the corresponding bundle element is marked complete. When all four elements are done, the bundle transitions to `COMPLIANT` (within the 1-hour deadline) or `NON_COMPLIANT`.
|
||||
|
||||
### Medications
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/encounters/{id}/medications` | Record a medication administration for an active encounter |
|
||||
| GET | `/encounters/{id}/medications` | List administrations; optional `since` ISO 8601 filter; paginated |
|
||||
| GET | `/medications/{id}` | Medication administration detail |
|
||||
|
||||
**POST body:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `drugName` | string | yes | Drug name (case-insensitive for correlation lookups) |
|
||||
| `dose` | decimal | yes | Must be > 0 |
|
||||
| `doseUnit` | string | yes | e.g. `mg`, `g`, `mcg` |
|
||||
| `route` | string | yes | e.g. `PO`, `IV` |
|
||||
| `administeredAt` | DateTimeOffset | no | Defaults to server time if omitted |
|
||||
| `administeredBy` | string | yes | Clinician or nurse identifier |
|
||||
|
||||
When a correlated drug was given within the `MedicationCorrelation.CorrelationWindowMinutes` window (default 90), subsequent warning and NEWS2 alerts for affected vitals include an annotation in `details` — e.g. `— note: metoprolol 25mg (PO) administered 45 min ago`. See `docs/decisions/medication-correlation-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
@@ -988,6 +1074,21 @@ Indexes: unique `(bundle_id, element_type)`, `(order_id)`
|
||||
|
||||
Check constraints: `element_type IN (...)`, `status IN ('PENDING', 'COMPLETED')`
|
||||
|
||||
### MedicationAdministration
|
||||
|
||||
```
|
||||
id Guid PK
|
||||
encounterId Guid FK → Encounter (Restrict on delete)
|
||||
drugName string required (max 200)
|
||||
dose decimal(10,4) required
|
||||
doseUnit string required (max 20)
|
||||
route string required (max 20)
|
||||
administeredAt DateTimeOffset required
|
||||
administeredBy string required (max 200)
|
||||
```
|
||||
|
||||
Indexes: `(encounter_id, administered_at)`, `(encounter_id, drug_name)`
|
||||
|
||||
### OutboxEvent
|
||||
|
||||
```
|
||||
@@ -1145,6 +1246,8 @@ The qSOFA (quick Sequential Organ Failure Assessment) engine evaluates three org
|
||||
|
||||
Redis key pattern: `qsofa:{encounterId}:{code}` with 30-minute TTL. When a criterion normalizes, the key is deleted immediately. When ≥ 2 of 3 criteria are active simultaneously and no open `QSOFA_WARNING` alert exists, the engine inserts a `CRITICAL` alert with details formatted as `"qSOFA score 2/3: RESP_RATE=24, SYSTOLIC_BP=95"`. A successful qSOFA alert triggers sepsis bundle creation via `SepsisAlertHandler`.
|
||||
|
||||
**API:** `GET /encounters/{id}/qsofa/current` returns `activeCriteria` (0–3) and per-criterion values from Redis via `QsofaService` — used by ward dashboards and the simulator poll loop.
|
||||
|
||||
**Clinical distinction:** SIRS detects systemic inflammation (infection response); qSOFA detects organ dysfunction (Sepsis-3 consensus). Both can fire independently for the same encounter. The sepsis bundle is idempotent — if one is already in progress, the second alert does not create a duplicate.
|
||||
|
||||
---
|
||||
@@ -1279,7 +1382,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
|
||||
|
||||
## Implemented Phases
|
||||
|
||||
Fourteen phases from the project roadmap are implemented and verified. Integration tests (`dotnet test` — 106 test methods) and per-phase verification scripts cover Phases 8–14.
|
||||
Sixteen phases from the project roadmap are implemented and verified. Integration tests (`dotnet test` — 116 test methods) and per-phase verification scripts cover Phases 8–15.
|
||||
|
||||
| Phase | Feature | Status |
|
||||
|---|---|---|
|
||||
@@ -1297,5 +1400,9 @@ Fourteen phases from the project roadmap are implemented and verified. Integrati
|
||||
| 12 | NEWS2 composite scoring (`News2Calculator`, `News2Detector`, `News2ScoringService`); `news2_scores` table; `NEWS2_WARNING` / `NEWS2_EMERGENCY` alert types; `News2Controller` (current + history); ES `news2Score` / `news2RiskLevel` projection; Prometheus NEWS2 metrics; `News2CalculatorTests`, `News2DetectorTests`; `run-phase12-verification.sh` | Done |
|
||||
| 13 | Trend detection (`TrendCalculator`, `TrendDetector`, `TrendAnalyzerService`); `RAPID_DETERIORATION` alert type; alert suppression windows (`AlertSuppressionService`, Redis `suppress:{enc}:{type}`); `TrendCalculatorTests`, `TrendDetectorTests`, `AlertSuppressionTests`; `run-phase13-verification.sh` | Done |
|
||||
| 14 | qSOFA scoring engine (`QsofaCalculator`, `QsofaDetector`); `QSOFA_WARNING` alert type; sepsis bundle compliance (`SepsisBundle`, `SepsisBundleElement`, `SepsisBundleService`); auto-created treatment orders with 1-hour deadline; `SepsisAlertHandler` bridge; `SepsisBundleMonitorService` (5-min overdue scan); `SepsisBundlesController` API; ES projection of bundle status; Kafka topics `sepsis.bundle.created` / `sepsis.bundle.updated`; Prometheus `qsofa_detections_total` and `sepsis_bundle_compliance_total`; `QsofaCalculatorTests`, `QsofaDetectorTests`, `SepsisBundleTests` | Done |
|
||||
| 15 | Medication administration (`MedicationAdministration`, `MedicationsController`, `MedicationService`); drug-vital correlation config (`MedicationCorrelationOptions`); `MedicationCorrelationHelper` annotates `WarningEvaluator` and `News2Detector` alert details; `medication_administrations` table + migration; `MedicationServiceTests`, `MedicationCorrelationTests`, `MedicationValidationTests`; `run-phase15-verification.sh`; design doc in `docs/decisions/medication-correlation-design.md` | Done |
|
||||
| 16 | Console replay simulator (`VigilCare.Simulator`); scenario JSON schema; CLI commands `replay`, `replay-all`, `validate`, `dry-run`; speed multiplier and optional API polling; eight sample scenarios; `docs/simulator-guide.md` | Done |
|
||||
|
||||
**Ward dashboard backend (in progress):** `GET /encounters` ward list with `WardEncounterSummary`, `GET /encounters/{id}/qsofa/current`, `DashboardOptions` CORS — `EncountersListTests`, `QsofaCurrentTests`.
|
||||
|
||||
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
public record ApiResponse<T>(bool Success, T Data, string? Error);
|
||||
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error);
|
||||
public record ApiError(string Message, string Code);
|
||||
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
||||
@@ -65,7 +65,7 @@ public class VigilCareApiClient
|
||||
Guid encounterId, string orderDescription, string? resultSummary)
|
||||
{
|
||||
var ordersResponse = await _http.GetAsync(
|
||||
$"/api/v1/encounters/{encounterId}/orders?status=Pending");
|
||||
$"/api/v1/encounters/{encounterId}/orders?status=PENDING");
|
||||
if (!ordersResponse.IsSuccessStatusCode)
|
||||
return false;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public class ReplayEngine
|
||||
|
||||
encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
|
||||
scenario.Encounter.EncounterType,
|
||||
scenario.Encounter.Department,
|
||||
DepartmentMapper.ToApiDepartment(scenario.Encounter.Department),
|
||||
scenario.Encounter.AttendingPhysician,
|
||||
scenario.Encounter.RoomBed,
|
||||
scenario.Encounter.AdmissionReason));
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
public static class DepartmentMapper
|
||||
{
|
||||
private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Icu"] = "ICU",
|
||||
["GeneralMedicine"] = "GENERAL_MEDICINE",
|
||||
["Emergency"] = "EMERGENCY",
|
||||
["Cardiology"] = "CARDIOLOGY",
|
||||
["Surgery"] = "SURGERY",
|
||||
["Pediatrics"] = "PEDIATRICS",
|
||||
};
|
||||
|
||||
public static string ToApiDepartment(string scenarioDepartment)
|
||||
{
|
||||
if (ScenarioToApi.TryGetValue(scenarioDepartment, out var apiValue))
|
||||
return apiValue;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown scenario department '{scenarioDepartment}'. Expected one of: {string.Join(", ", ScenarioToApi.Keys)}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class EncountersListTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private Guid _activeIcuEncounterId;
|
||||
private Guid _activeSurgeryEncounterId;
|
||||
private Guid _dischargedEncounterId;
|
||||
|
||||
public EncountersListTests(ApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
|
||||
var patientIcu = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(), Mrn = "MRN-WARD-001", FirstName = "Alice", LastName = "Icu",
|
||||
DateOfBirth = new DateOnly(1970, 3, 1), Gender = "F", CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var patientSurgery = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(), Mrn = "MRN-WARD-002", FirstName = "Bob", LastName = "Surgery",
|
||||
DateOfBirth = new DateOnly(1965, 7, 12), Gender = "M", CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_activeIcuEncounterId = Guid.NewGuid();
|
||||
_activeSurgeryEncounterId = Guid.NewGuid();
|
||||
_dischargedEncounterId = Guid.NewGuid();
|
||||
|
||||
db.Patients.AddRange(patientIcu, patientSurgery);
|
||||
db.Encounters.AddRange(
|
||||
new Encounter
|
||||
{
|
||||
Id = _activeIcuEncounterId, PatientId = patientIcu.Id,
|
||||
EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active,
|
||||
Department = Department.Icu, AttendingPhysician = "Dr. Ward",
|
||||
RoomBed = "ICU-3", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-2),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new Encounter
|
||||
{
|
||||
Id = _activeSurgeryEncounterId, PatientId = patientSurgery.Id,
|
||||
EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active,
|
||||
Department = Department.Surgery, AttendingPhysician = "Dr. Ward",
|
||||
RoomBed = "S-12", AdmittedAt = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new Encounter
|
||||
{
|
||||
Id = _dischargedEncounterId, PatientId = patientSurgery.Id,
|
||||
EncounterType = EncounterType.Outpatient, Status = EncounterStatus.Discharged,
|
||||
Department = Department.Surgery, AttendingPhysician = "Dr. Ward",
|
||||
AdmittedAt = DateTimeOffset.UtcNow.AddDays(-3),
|
||||
DischargedAt = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-3)
|
||||
});
|
||||
|
||||
db.News2Scores.Add(new News2Score
|
||||
{
|
||||
Id = Guid.NewGuid(), EncounterId = _activeIcuEncounterId, PatientId = patientIcu.Id,
|
||||
TotalScore = 7, RiskLevel = "HIGH", CalculatedAt = DateTimeOffset.UtcNow,
|
||||
RespRateScore = 1, Spo2Score = 0, SystolicBpScore = 1, HeartRateScore = 1,
|
||||
ConsciousnessScore = 0, TemperatureScore = 0, SupplementalO2Score = 0
|
||||
});
|
||||
|
||||
db.ClinicalAlerts.Add(new ClinicalAlert
|
||||
{
|
||||
Id = Guid.NewGuid(), EncounterId = _activeIcuEncounterId, PatientId = patientIcu.Id,
|
||||
AlertType = AlertType.News2Emergency, Severity = AlertSeverity.Critical,
|
||||
Details = "NEWS2 score 7", Status = AlertStatus.Open,
|
||||
TriggeredAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task ListActiveEncounters_ReturnsSummariesWithPatientNames()
|
||||
{
|
||||
var resp = await _client.GetAsync("/api/v1/encounters?status=ACTIVE&page=1&pageSize=20");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var items = body!.RootElement.GetProperty("data").GetProperty("items");
|
||||
items.GetArrayLength().Should().Be(2);
|
||||
|
||||
var icu = items.EnumerateArray()
|
||||
.First(i => i.GetProperty("encounterId").GetGuid() == _activeIcuEncounterId);
|
||||
|
||||
icu.GetProperty("firstName").GetString().Should().Be("Alice");
|
||||
icu.GetProperty("lastName").GetString().Should().Be("Icu");
|
||||
icu.GetProperty("mrn").GetString().Should().Be("MRN-WARD-001");
|
||||
icu.GetProperty("roomBed").GetString().Should().Be("ICU-3");
|
||||
icu.GetProperty("news2Score").GetInt32().Should().Be(7);
|
||||
icu.GetProperty("openAlertCount").GetInt32().Should().Be(1);
|
||||
icu.GetProperty("qsofaScore").GetInt32().Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByDepartment_FiltersCorrectly()
|
||||
{
|
||||
var resp = await _client.GetAsync("/api/v1/encounters?status=ACTIVE&department=SURGERY");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var items = body!.RootElement.GetProperty("data").GetProperty("items");
|
||||
items.GetArrayLength().Should().Be(1);
|
||||
items[0].GetProperty("encounterId").GetGuid().Should().Be(_activeSurgeryEncounterId);
|
||||
items[0].GetProperty("firstName").GetString().Should().Be("Bob");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListInvalidStatus_Returns400()
|
||||
{
|
||||
var resp = await _client.GetAsync("/api/v1/encounters?status=Active");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class QsofaCurrentTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private Guid _encounterId;
|
||||
private Guid _patientId;
|
||||
|
||||
public QsofaCurrentTests(ApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(), Mrn = "MRN-QSOFA-API", FirstName = "qSOFA", LastName = "Api",
|
||||
DateOfBirth = new DateOnly(1960, 1, 1), Gender = "M", CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active, Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. qSOFA", AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Patients.Add(patient);
|
||||
db.Encounters.Add(encounter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_patientId = patient.Id;
|
||||
_encounterId = encounter.Id;
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var cache = redis.GetDatabase();
|
||||
foreach (var key in QsofaCalculator.AllCriterionKeys(_encounterId))
|
||||
await cache.KeyDeleteAsync(key);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Current_AfterBaselineVitals_ReturnsZeroCriteria()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 16m);
|
||||
|
||||
var resp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/qsofa/current");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
body!.RootElement.GetProperty("data").GetProperty("activeCriteria").GetInt32()
|
||||
.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Current_AfterTwoCriteriaMet_ReturnsTwo()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m);
|
||||
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
|
||||
|
||||
var resp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/qsofa/current");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var data = (await resp.Content.ReadFromJsonAsync<JsonDocument>())!
|
||||
.RootElement.GetProperty("data");
|
||||
data.GetProperty("activeCriteria").GetInt32().Should().Be(2);
|
||||
data.GetProperty("criteria").GetProperty("respRate").GetDecimal().Should().Be(24m);
|
||||
data.GetProperty("criteria").GetProperty("systolicBp").GetDecimal().Should().Be(95m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Current_UnknownEncounter_Returns404()
|
||||
{
|
||||
var resp = await _client.GetAsync($"/api/v1/encounters/{Guid.NewGuid()}/qsofa/current");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public class DashboardOptions
|
||||
{
|
||||
public const string Section = "Dashboard";
|
||||
|
||||
public string[] CorsOrigins { get; set; } = ["http://localhost:5173"];
|
||||
}
|
||||
@@ -13,6 +13,59 @@ public class EncountersController : ControllerBase
|
||||
|
||||
public EncountersController(IEncounterService encounters) => _encounters = encounters;
|
||||
|
||||
/// <summary>
|
||||
/// Lists encounters for ward dashboards with denormalized clinical summary fields.
|
||||
/// </summary>
|
||||
/// <param name="status">Optional status filter (DB literal, e.g. ACTIVE).</param>
|
||||
/// <param name="department">Optional department filter (DB literal, e.g. ICU).</param>
|
||||
/// <param name="page">Page number (1-based).</param>
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? department,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
EncounterStatus? parsedStatus = null;
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
try
|
||||
{
|
||||
parsedStatus = EncounterStatusExtensions.FromDbString(status);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid status filter.", "INVALID_STATUS"));
|
||||
}
|
||||
}
|
||||
|
||||
Department? parsedDepartment = null;
|
||||
if (!string.IsNullOrEmpty(department))
|
||||
{
|
||||
try
|
||||
{
|
||||
parsedDepartment = DepartmentExtensions.FromDbString(department);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return BadRequest(ApiResponse<object>.Fail(400, "Invalid department filter.", "INVALID_DEPARTMENT"));
|
||||
}
|
||||
}
|
||||
|
||||
var result = await _encounters.ListAsync(parsedStatus, parsedDepartment, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
page = result.Page,
|
||||
pageSize = result.PageSize,
|
||||
totalCount = result.TotalCount,
|
||||
totalPages = result.TotalPages
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an encounter with patient, recent observations, and open alerts.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// qSOFA scoring: current active criteria count per encounter (Redis-backed).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/qsofa")]
|
||||
[Produces("application/json")]
|
||||
public class QsofaController : ControllerBase
|
||||
{
|
||||
private readonly IQsofaService _qsofa;
|
||||
|
||||
public QsofaController(IQsofaService qsofa) => _qsofa = qsofa;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current qSOFA active criteria count (0–3) for an encounter.
|
||||
/// </summary>
|
||||
[HttpGet("current")]
|
||||
[ProducesResponseType(typeof(ApiResponse<QsofaCurrentResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Current(Guid encounterId)
|
||||
{
|
||||
var score = await _qsofa.GetCurrentAsync(encounterId);
|
||||
return Ok(ApiResponse<QsofaCurrentResponse>.Ok(score));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
public record WardEncounterSummary(
|
||||
Guid EncounterId,
|
||||
Guid PatientId,
|
||||
string Mrn,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string? RoomBed,
|
||||
Department Department,
|
||||
EncounterStatus Status,
|
||||
int? News2Score,
|
||||
string? News2RiskLevel,
|
||||
int QsofaScore,
|
||||
bool SepsisActive,
|
||||
SepsisBundleComplianceStatus? SepsisBundleStatus,
|
||||
int OpenAlertCount);
|
||||
@@ -0,0 +1,8 @@
|
||||
public record QsofaCurrentResponse(
|
||||
int ActiveCriteria,
|
||||
QsofaCriteriaState Criteria);
|
||||
|
||||
public record QsofaCriteriaState(
|
||||
decimal? RespRate,
|
||||
decimal? SystolicBp,
|
||||
decimal? Avpu);
|
||||
@@ -74,6 +74,23 @@ try
|
||||
builder.Services.Configure<MedicationCorrelationOptions>(
|
||||
builder.Configuration.GetSection(MedicationCorrelationOptions.SectionName));
|
||||
|
||||
builder.Services.Configure<DashboardOptions>(
|
||||
builder.Configuration.GetSection(DashboardOptions.Section));
|
||||
|
||||
var dashboardOptions = builder.Configuration
|
||||
.GetSection(DashboardOptions.Section)
|
||||
.Get<DashboardOptions>() ?? new DashboardOptions();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Dashboard", policy =>
|
||||
{
|
||||
policy.WithOrigins(dashboardOptions.CorsOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IPatientService, PatientService>();
|
||||
builder.Services.AddScoped<IEncounterService, EncounterService>();
|
||||
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
|
||||
@@ -83,6 +100,7 @@ try
|
||||
builder.Services.AddScoped<IOrderService, OrderService>();
|
||||
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||
builder.Services.AddScoped<INews2Service, News2Service>();
|
||||
builder.Services.AddScoped<IQsofaService, QsofaService>();
|
||||
builder.Services.AddScoped<ISepsisBundleService, SepsisBundleService>();
|
||||
builder.Services.AddScoped<SepsisAlertHandler>();
|
||||
builder.Services.AddScoped<SirsDetector>();
|
||||
@@ -185,6 +203,8 @@ try
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseMiddleware<ExceptionHandlerMiddleware>();
|
||||
|
||||
app.UseCors("Dashboard");
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
|
||||
@@ -14,8 +14,13 @@ public class EncounterService : IEncounterService
|
||||
};
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IQsofaService _qsofa;
|
||||
|
||||
public EncounterService(AppDbContext db) => _db = db;
|
||||
public EncounterService(AppDbContext db, IQsofaService qsofa)
|
||||
{
|
||||
_db = db;
|
||||
_qsofa = qsofa;
|
||||
}
|
||||
|
||||
public async Task<Encounter> GetByIdAsync(Guid id)
|
||||
{
|
||||
@@ -31,6 +36,88 @@ public class EncounterService : IEncounterService
|
||||
return encounter;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<WardEncounterSummary>> ListAsync(
|
||||
EncounterStatus? status, Department? department, int page, int pageSize)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var query = _db.Encounters
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Patient)
|
||||
.AsQueryable();
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(e => e.Status == status.Value);
|
||||
|
||||
if (department.HasValue)
|
||||
query = query.Where(e => e.Department == department.Value);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
var encounters = await query
|
||||
.OrderByDescending(e => e.AdmittedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
if (encounters.Count == 0)
|
||||
return new PagedResult<WardEncounterSummary>([], page, pageSize, total);
|
||||
|
||||
var encounterIds = encounters.Select(e => e.Id).ToList();
|
||||
|
||||
var news2ByEncounter = (await _db.News2Scores
|
||||
.AsNoTracking()
|
||||
.Where(s => encounterIds.Contains(s.EncounterId))
|
||||
.OrderByDescending(s => s.CalculatedAt)
|
||||
.ToListAsync())
|
||||
.GroupBy(s => s.EncounterId)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var openAlertCounts = await _db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
.Where(a => encounterIds.Contains(a.EncounterId) && a.Status == AlertStatus.Open)
|
||||
.GroupBy(a => a.EncounterId)
|
||||
.Select(g => new { EncounterId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.EncounterId, x => x.Count);
|
||||
|
||||
var bundlesByEncounter = (await _db.SepsisBundles
|
||||
.AsNoTracking()
|
||||
.Where(b => encounterIds.Contains(b.EncounterId))
|
||||
.OrderByDescending(b => b.RecognizedAt)
|
||||
.ToListAsync())
|
||||
.GroupBy(b => b.EncounterId)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var summaries = new List<WardEncounterSummary>(encounters.Count);
|
||||
foreach (var encounter in encounters)
|
||||
{
|
||||
news2ByEncounter.TryGetValue(encounter.Id, out var news2);
|
||||
bundlesByEncounter.TryGetValue(encounter.Id, out var bundle);
|
||||
openAlertCounts.TryGetValue(encounter.Id, out var openCount);
|
||||
|
||||
var qsofaScore = await _qsofa.GetActiveCriteriaCountAsync(encounter.Id);
|
||||
|
||||
summaries.Add(new WardEncounterSummary(
|
||||
encounter.Id,
|
||||
encounter.PatientId,
|
||||
encounter.Patient.Mrn,
|
||||
encounter.Patient.FirstName,
|
||||
encounter.Patient.LastName,
|
||||
encounter.RoomBed,
|
||||
encounter.Department,
|
||||
encounter.Status,
|
||||
news2?.TotalScore,
|
||||
news2?.RiskLevel,
|
||||
qsofaScore,
|
||||
bundle is not null && bundle.ComplianceStatus != SepsisBundleComplianceStatus.Compliant,
|
||||
bundle?.ComplianceStatus,
|
||||
openCount));
|
||||
}
|
||||
|
||||
return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<EncounterStatusTransitionResult> TransitionStatusAsync(
|
||||
Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
public interface IEncounterService
|
||||
{
|
||||
Task<Encounter> GetByIdAsync(Guid id);
|
||||
Task<PagedResult<WardEncounterSummary>> ListAsync(
|
||||
EncounterStatus? status, Department? department, int page, int pageSize);
|
||||
Task<EncounterStatusTransitionResult> TransitionStatusAsync(
|
||||
Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null);
|
||||
Task<object> GetTimelineAsync(Guid encounterId);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
public interface IQsofaService
|
||||
{
|
||||
Task<QsofaCurrentResponse> GetCurrentAsync(Guid encounterId);
|
||||
Task<int> GetActiveCriteriaCountAsync(Guid encounterId);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class QsofaService : IQsofaService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
|
||||
public QsofaService(AppDbContext db, IConnectionMultiplexer redis)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
}
|
||||
|
||||
public async Task<QsofaCurrentResponse> GetCurrentAsync(Guid encounterId)
|
||||
{
|
||||
await EnsureEncounterExistsAsync(encounterId);
|
||||
|
||||
var values = await ReadCriterionValuesAsync(encounterId);
|
||||
return BuildResponse(values);
|
||||
}
|
||||
|
||||
public async Task<int> GetActiveCriteriaCountAsync(Guid encounterId)
|
||||
{
|
||||
var values = await ReadCriterionValuesAsync(encounterId);
|
||||
return QsofaCalculator.CountActiveCriteria(values);
|
||||
}
|
||||
|
||||
private async Task EnsureEncounterExistsAsync(Guid encounterId)
|
||||
{
|
||||
var exists = await _db.Encounters.AnyAsync(e => e.Id == encounterId);
|
||||
if (!exists)
|
||||
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
||||
}
|
||||
|
||||
private async Task<RedisValue[]> ReadCriterionValuesAsync(Guid encounterId)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
return await cache.StringGetAsync(QsofaCalculator.AllCriterionKeys(encounterId));
|
||||
}
|
||||
|
||||
private static QsofaCurrentResponse BuildResponse(RedisValue[] values)
|
||||
{
|
||||
return new QsofaCurrentResponse(
|
||||
QsofaCalculator.CountActiveCriteria(values),
|
||||
new QsofaCriteriaState(
|
||||
ParseOptionalDecimal(values[0]),
|
||||
ParseOptionalDecimal(values[1]),
|
||||
ParseOptionalDecimal(values[2])));
|
||||
}
|
||||
|
||||
private static decimal? ParseOptionalDecimal(RedisValue value) =>
|
||||
value.HasValue ? decimal.Parse(value.ToString()) : null;
|
||||
}
|
||||
@@ -36,6 +36,9 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Dashboard": {
|
||||
"CorsOrigins": [ "http://localhost:5173" ]
|
||||
},
|
||||
"Kafka": {
|
||||
"BootstrapServers": "localhost:9092",
|
||||
"Topics": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Medication Correlation Design Decisions
|
||||
|
||||
**Status:** Implemented (Phase 15). Medication administration CRUD, `MedicationCorrelationHelper`, and integration with `WarningEvaluator` and `News2Detector` are in production. Verification: `./scripts/run-phase15-verification.sh` and `MedicationCorrelationTests`. The simulator scenario `VigilCare.Simulator/Scenarios/List/medication-false-alarm-01.json` exercises the end-to-end flow.
|
||||
|
||||
## The problem this solves
|
||||
|
||||
A patient with a blood pressure of 140/90 receives metoprolol (a beta-blocker that
|
||||
@@ -12,7 +14,7 @@ Clinicians who see these false positives repeatedly stop trusting the alert syst
|
||||
that point, the system is worse than useless — it trains people to ignore alerts,
|
||||
including the real ones.
|
||||
|
||||
Phase 15 solves this by **annotating** alerts with medication context. The alert still
|
||||
Phase 15 addresses this by **annotating** alerts with medication context. The alert still
|
||||
fires (the BP is genuinely low and may need monitoring), but the details say:
|
||||
|
||||
> SYSTOLIC_BP value 95 is below warning low of 90. — note: metoprolol 25mg (PO)
|
||||
@@ -25,7 +27,7 @@ medication is working — keep monitoring."
|
||||
|
||||
## How the pieces fit together
|
||||
|
||||
There are six components in Phase 15. Here is how a request flows through them, starting
|
||||
There are six components. Here is how a request flows through them, starting
|
||||
from when a nurse records a medication and ending when an annotated alert is created.
|
||||
|
||||
```
|
||||
@@ -61,7 +63,7 @@ New observation arrives (e.g. SYSTOLIC_BP = 95)
|
||||
│ 1. Load threshold │ (from Redis cache)
|
||||
│ 2. Check breach │ (is 95 < warningLow of 90?)
|
||||
│ 3. Build details │ ("SYSTOLIC_BP value 95 is below warning low of 90.")
|
||||
│ 4. ► Annotate ◄ │ NEW in Phase 15
|
||||
│ 4. ► Annotate ◄ │ MedicationCorrelationHelper
|
||||
│ 5. INSERT alert │ (idempotent — skips if one already open)
|
||||
└────────┬────────────┘
|
||||
│
|
||||
@@ -163,6 +165,10 @@ Drug names in clinical systems come in all forms — "Metoprolol", "METOPROLOL",
|
||||
lookups, the correlation works regardless of how the nurse typed the drug name. This
|
||||
avoids a class of bugs where correlation silently fails because the case doesn't match.
|
||||
|
||||
The shipped `appsettings.json` includes mappings for common cardiovascular, vasopressor,
|
||||
opioid, sedative, diuretic, and antibiotic agents — not just metoprolol. Add or adjust
|
||||
entries under `MedicationCorrelation:DrugVitalMappings` without a migration.
|
||||
|
||||
---
|
||||
|
||||
### 3. MedicationService
|
||||
@@ -307,7 +313,7 @@ Both `WarningEvaluator` and `News2Detector` already follow a pattern:
|
||||
2. Build a details string describing the breach
|
||||
3. INSERT the alert into the database
|
||||
|
||||
Phase 15 adds one step between 2 and 3:
|
||||
Phase 15 inserts one step between 2 and 3:
|
||||
|
||||
```
|
||||
2. Build details string
|
||||
@@ -315,7 +321,7 @@ Phase 15 adds one step between 2 and 3:
|
||||
3. INSERT the alert (with the possibly-annotated details)
|
||||
```
|
||||
|
||||
This minimal insertion point means no changes to the threshold logic, the idempotent
|
||||
This insertion point required no changes to the threshold logic, the idempotent
|
||||
INSERT pattern, the outbox event publishing, or the alert suppression logic. Each of
|
||||
those systems continues to work exactly as before.
|
||||
|
||||
@@ -384,7 +390,7 @@ pharmaceutical review.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
The tests are structured in three files, each targeting a different layer:
|
||||
Twelve tests across three files cover the medication subsystem:
|
||||
|
||||
**MedicationServiceTests (4 tests)** — tests the service layer directly. Can a medication
|
||||
be created on an active encounter? Does a discharged encounter get rejected? Does
|
||||
@@ -393,14 +399,17 @@ pagination work? Does the time-window filter exclude old records? These tests ca
|
||||
|
||||
**MedicationCorrelationTests (5 tests)** — tests the full integration from medication
|
||||
recording through alert creation. These seed a medication into the database, then invoke
|
||||
`WarningEvaluator.EvaluateAsync` and check whether the resulting alert's `details` field
|
||||
contains the medication annotation. This is the most important test file because it
|
||||
verifies the end-to-end behavior that Phase 15 exists to provide.
|
||||
`WarningEvaluator.EvaluateAsync` (and NEWS2 paths where applicable) and check whether the
|
||||
resulting alert's `details` field contains the medication annotation. This is the most
|
||||
important test file because it verifies the end-to-end behavior the feature exists to provide.
|
||||
|
||||
**MedicationValidationTests (3 tests)** — tests the HTTP validation layer. These send
|
||||
invalid requests via `HttpClient` and assert 400 responses. They don't seed encounters
|
||||
because the validator rejects the request before the service layer runs.
|
||||
|
||||
Run with `dotnet test --filter "FullyQualifiedName~Medication"` or
|
||||
`./scripts/run-phase15-verification.sh` (requires API + Docker Compose).
|
||||
|
||||
All tests run against a real PostgreSQL database and real Redis instance (using test
|
||||
containers on different ports). No mocking. This means the tests catch real issues like
|
||||
SQL translation failures, index problems, and configuration registration mistakes that
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
# VigilCare Clinical Simulator -- User Guide
|
||||
|
||||
Welcome! The VigilCare Simulator lets you replay realistic hospital patient scenarios against the VigilCare Clinical API. You can watch a patient's vitals change over time, see alerts fire (NEWS2, SIRS/qSOFA, sepsis bundles), and observe how the system detects clinical deterioration -- all without real patients.
|
||||
|
||||
Think of it as a flight simulator, but for clinical decision support.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [Quick Start](#2-quick-start)
|
||||
3. [Available Commands](#3-available-commands)
|
||||
4. [Understanding Scenarios](#4-understanding-scenarios)
|
||||
5. [Available Scenarios](#5-available-scenarios)
|
||||
6. [Controlling Replay Speed](#6-controlling-replay-speed)
|
||||
7. [Reading the Output](#7-reading-the-output)
|
||||
8. [Creating Your Own Scenarios](#8-creating-your-own-scenarios)
|
||||
9. [Troubleshooting](#9-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
You need two things installed:
|
||||
|
||||
- **.NET 8 SDK** -- Download from [dotnet.microsoft.com](https://dotnet.microsoft.com/download/dotnet/8.0)
|
||||
- **The VigilCare API running locally** -- The simulator sends data to the API, so it must be up first
|
||||
|
||||
### Starting the API
|
||||
|
||||
From the project root directory, run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
dotnet run --project VigilCare.Api
|
||||
```
|
||||
|
||||
The API starts on `http://localhost:5270` by default.
|
||||
|
||||
> See [docker-compose-usage-and-troubleshooting.md](docker-compose-usage-and-troubleshooting.md) if you have trouble with Docker.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quick Start
|
||||
|
||||
Open a terminal in the project root and run:
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- replay \
|
||||
VigilCare.Simulator/Scenarios/List/uti-sepsis-elderly-01.json \
|
||||
--speed 60 --poll
|
||||
```
|
||||
|
||||
This replays a 4-hour UTI-to-sepsis progression in about 4 seconds, showing alerts as they fire.
|
||||
|
||||
That's it! Read on for more detail.
|
||||
|
||||
---
|
||||
|
||||
## 3. Available Commands
|
||||
|
||||
The simulator has four commands. All are run with `dotnet run --project VigilCare.Simulator -- <command>`.
|
||||
|
||||
### replay -- Run a scenario against the API
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- replay <scenario-file> [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--speed <number>` | 60 | How fast to run (see [Speed](#6-controlling-replay-speed)) |
|
||||
| `--base-url <url>` | `http://localhost:5270` | API address (change if your API runs elsewhere) |
|
||||
| `--poll` | off | Show alerts and scores after each set of vitals |
|
||||
| `--poll-interval <seconds>` | 5 | How often to check for alerts when polling |
|
||||
|
||||
**Example -- run the stable baseline scenario in real-time with polling:**
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- replay \
|
||||
VigilCare.Simulator/Scenarios/List/stable-baseline-01.json \
|
||||
--speed 1 --poll
|
||||
```
|
||||
|
||||
### replay-all -- Run every scenario in a folder
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- replay-all <directory> [options]
|
||||
```
|
||||
|
||||
Runs all `.json` scenario files in the given directory, one after another. Accepts `--speed` and `--base-url`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- replay-all \
|
||||
VigilCare.Simulator/Scenarios/List --speed 60
|
||||
```
|
||||
|
||||
### validate -- Check a scenario file for errors
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- validate <scenario-file>
|
||||
```
|
||||
|
||||
Checks that the JSON is well-formed and all fields are valid. Does **not** contact the API.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- validate \
|
||||
VigilCare.Simulator/Scenarios/List/stable-baseline-01.json
|
||||
```
|
||||
|
||||
### dry-run -- Preview the timeline without touching the API
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- dry-run <scenario-file>
|
||||
```
|
||||
|
||||
Prints exactly what *would* happen (every vital sign, medication, and order) without sending anything.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- dry-run \
|
||||
VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Understanding Scenarios
|
||||
|
||||
Each scenario is a JSON file that tells a clinical story. It contains:
|
||||
|
||||
- **Patient** -- Name, date of birth, gender
|
||||
- **Encounter** -- Department, encounter type, attending physician, room/bed
|
||||
- **Events** -- A timeline of observations (vitals, labs), medications, and orders
|
||||
|
||||
Here is what a simplified scenario looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": {
|
||||
"id": "stable-baseline-01",
|
||||
"name": "Stable Baseline -- Routine Inpatient Monitoring",
|
||||
"description": "52-year-old female admitted for elective cholecystectomy...",
|
||||
"durationMinutes": 480,
|
||||
"tags": ["stable", "baseline", "control", "surgery"]
|
||||
},
|
||||
"patient": {
|
||||
"firstName": "Linda",
|
||||
"lastName": "Weston",
|
||||
"dateOfBirth": "1974-02-18",
|
||||
"gender": "Female"
|
||||
},
|
||||
"encounter": {
|
||||
"department": "Surgery",
|
||||
"encounterType": "Inpatient",
|
||||
"attendingPhysician": "Dr. James Nakamura",
|
||||
"roomBed": "SURG-204B",
|
||||
"admissionReason": "Elective laparoscopic cholecystectomy"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"offsetMinutes": 0,
|
||||
"type": "observation",
|
||||
"data": {
|
||||
"code": "HEART_RATE",
|
||||
"value": 72,
|
||||
"unit": "bpm"
|
||||
},
|
||||
"note": "Post-op arrival, patient alert and comfortable"
|
||||
},
|
||||
{
|
||||
"offsetMinutes": 60,
|
||||
"type": "medication",
|
||||
"data": {
|
||||
"drugName": "acetaminophen",
|
||||
"dose": 1000,
|
||||
"doseUnit": "mg",
|
||||
"route": "PO",
|
||||
"administeredBy": "RN Davis"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The key concept is **offsetMinutes** -- each event happens at a certain number of minutes after the scenario starts. The simulator waits the appropriate amount of time (adjusted by your speed setting) before sending each event.
|
||||
|
||||
### Event Types
|
||||
|
||||
| Type | What It Represents | Example |
|
||||
|------|--------------------|---------|
|
||||
| `observation` | A vital sign or lab result | Heart rate 110 bpm, Temperature 39.2 C |
|
||||
| `medication` | A drug being administered | Ceftriaxone 1g IV |
|
||||
| `order` | A clinical order being placed | "Blood cultures", "Chest X-ray" |
|
||||
| `order_result` | Result of a prior order | "Positive for E. coli" |
|
||||
|
||||
### Vital Sign Codes
|
||||
|
||||
These are the observation codes used in scenarios:
|
||||
|
||||
| Code | What It Measures | Unit | Normal Range |
|
||||
|------|-----------------|------|-------------|
|
||||
| `HEART_RATE` | Heart rate | bpm | 51--90 |
|
||||
| `RESP_RATE` | Respiratory rate | /min | 12--20 |
|
||||
| `SYSTOLIC_BP` | Systolic blood pressure | mmHg | 111--219 |
|
||||
| `DIASTOLIC_BP` | Diastolic blood pressure | mmHg | 60--90 |
|
||||
| `TEMP_C` | Temperature | C | 36.1--38.0 |
|
||||
| `SPO2` | Oxygen saturation | % | 96--100 |
|
||||
| `AVPU` | Consciousness level | score | 0 = Alert |
|
||||
| `SUPPLEMENTAL_O2` | On supplemental oxygen? | flag | 0 = No |
|
||||
| `WBC_K_UL` | White blood cell count | x10^3/uL | 4.5--11.0 |
|
||||
| `LACTATE_MMOL_L` | Serum lactate | mmol/L | 0.5--1.5 |
|
||||
| `POTASSIUM_MEQ_L` | Potassium | mEq/L | 3.5--5.0 |
|
||||
| `GLUCOSE_MG_DL` | Blood glucose | mg/dL | 70--140 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Available Scenarios
|
||||
|
||||
The simulator ships with 8 scenarios covering different clinical situations:
|
||||
|
||||
| Scenario | Clinical Story | Duration |
|
||||
|----------|---------------|----------|
|
||||
| **stable-baseline-01** | Post-op cholecystectomy, all vitals normal. Control case -- no alerts should fire. | 8 hours |
|
||||
| **uti-sepsis-elderly-01** | 78-year-old with UTI progressing to sepsis. SIRS criteria met, sepsis bundle triggered. | 4 hours |
|
||||
| **cardiac-arrest-post-mi-01** | Post-MI patient deteriorating into cardiogenic shock. Rapid HR/BP changes. | Varies |
|
||||
| **post-op-hemorrhage-01** | Surgical patient with internal bleeding. Rising HR, falling BP and SpO2. | Varies |
|
||||
| **respiratory-failure-asthma-01** | Asthma exacerbation progressing to respiratory failure. Falling SpO2, rising RR. | Varies |
|
||||
| **dka-electrolyte-01** | Diabetic ketoacidosis with potassium and glucose derangement. | Varies |
|
||||
| **hypothermia-elderly-01** | Elderly patient with severe hypothermia. Slow HR, dropping temperature. | Varies |
|
||||
| **medication-false-alarm-01** | Beta-blocker causing bradycardia. Tests whether the system correctly handles medication-induced vital changes. | 3 hours |
|
||||
|
||||
All scenario files are in: `VigilCare.Simulator/Scenarios/List/`
|
||||
|
||||
---
|
||||
|
||||
## 6. Controlling Replay Speed
|
||||
|
||||
The `--speed` option controls how fast simulated time passes:
|
||||
|
||||
| Speed | Meaning | A 4-hour scenario takes... |
|
||||
|-------|---------|---------------------------|
|
||||
| `0` | Instant -- no waiting, all events fire immediately | < 1 second |
|
||||
| `1` | Real-time -- 1 simulated minute = 1 real minute | 4 hours |
|
||||
| `10` | 10x -- 1 simulated minute = 6 real seconds | 24 minutes |
|
||||
| `60` | 60x (default) -- 1 simulated minute = 1 real second | 4 minutes |
|
||||
| `120` | 120x -- 1 simulated minute = 0.5 real seconds | 2 minutes |
|
||||
|
||||
**Recommendations:**
|
||||
- **For demos / presentations:** Use `--speed 10` with `--poll` so people can follow along
|
||||
- **For quick testing:** Use `--speed 60` or `--speed 0`
|
||||
- **For the most realistic experience:** Use `--speed 1` (real-time, a 4-hour scenario takes 4 hours)
|
||||
|
||||
---
|
||||
|
||||
## 7. Reading the Output
|
||||
|
||||
When you run a `replay`, the simulator prints a colored timeline. Here's what to look for:
|
||||
|
||||
```
|
||||
────────────────── Stable Baseline -- Routine Inpatient Monitoring ──────────────────
|
||||
52-year-old female admitted for elective cholecystectomy...
|
||||
|
||||
Patient registered: 3fa85f64-... (MRN-12345)
|
||||
Encounter opened: 7c9e6679-... (active)
|
||||
|
||||
[00:00] HEART_RATE 72 bpm
|
||||
[00:00] RESP_RATE 14 /min
|
||||
[00:00] SYSTOLIC_BP 124 mmHg
|
||||
[00:00] SPO2 98 %
|
||||
|
||||
... waiting 60m simulated (1.0s real) ...
|
||||
|
||||
[01:00] HEART_RATE 74 bpm
|
||||
[01:00] medication: acetaminophen 1000 mg PO
|
||||
```
|
||||
|
||||
**With `--poll` enabled, you also see clinical scoring:**
|
||||
|
||||
```
|
||||
[01:30] NEWS2 = 5 (Medium)
|
||||
[01:30] ALERT SEPSIS_WARNING (Critical)
|
||||
[01:30] SEPSIS BUNDLE InProgress (2/4 completed)
|
||||
```
|
||||
|
||||
**At the end, a summary table appears:**
|
||||
|
||||
```
|
||||
Metric Value
|
||||
-----------------------------------
|
||||
Scenario uti-sepsis-elderly-01
|
||||
Observations sent 42
|
||||
Medications sent 2
|
||||
Orders placed 3
|
||||
Wall-clock time 12.3s
|
||||
```
|
||||
|
||||
### What the Alerts Mean
|
||||
|
||||
| Alert | Meaning |
|
||||
|-------|---------|
|
||||
| `NEWS2_LOW` | NEWS2 score 1--4: Low risk, routine monitoring |
|
||||
| `NEWS2_MEDIUM` | NEWS2 score 5--6 or single parameter score of 3: Urgent review needed |
|
||||
| `NEWS2_HIGH` | NEWS2 score 7+: Emergency response needed |
|
||||
| `SEPSIS_WARNING` | SIRS criteria met (2+ of: temp, HR, RR, WBC abnormal) |
|
||||
| `QSOFA_WARNING` | qSOFA criteria met (2+ of: altered mentation, RR >= 22, SBP <= 100) |
|
||||
| `RAPID_DETERIORATION` | Sudden significant change in vital signs |
|
||||
|
||||
---
|
||||
|
||||
## 8. Creating Your Own Scenarios
|
||||
|
||||
You can create new clinical scenarios by writing a JSON file. There are two ways:
|
||||
|
||||
### Option A: Write it manually
|
||||
|
||||
1. Copy an existing scenario from `VigilCare.Simulator/Scenarios/List/` as a template
|
||||
2. Modify the patient, encounter, and events to match your clinical story
|
||||
3. Make sure `offsetMinutes` values are in ascending order
|
||||
4. Validate it:
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- validate ./my-scenario.json
|
||||
```
|
||||
|
||||
### Option B: Use AI to generate it
|
||||
|
||||
There is a generation prompt file at `VigilCare.Simulator/Scenarios/GENERATE_PROMPT.md`. You can paste its contents into ChatGPT, Claude, or another AI assistant, describe the clinical case you want, and it will generate a valid scenario JSON file for you.
|
||||
|
||||
### Validation Rules
|
||||
|
||||
The simulator enforces these rules on scenario files:
|
||||
|
||||
- **scenario.id** and **scenario.name** are required
|
||||
- **encounter.department** must be one of: `Icu`, `GeneralMedicine`, `Emergency`, `Cardiology`, `Surgery`, `Pediatrics`
|
||||
- **encounter.encounterType** must be one of: `Inpatient`, `Outpatient`, `Emergency`
|
||||
- **events** must be non-empty, with `offsetMinutes` in ascending order
|
||||
- **observation codes** must match one of the 12 valid codes listed above
|
||||
- Maximum **10 observations per time point** (API batch limit)
|
||||
- **order_result** events must reference a matching prior order (or a sepsis bundle auto-order)
|
||||
|
||||
### Sepsis Bundle Auto-Orders
|
||||
|
||||
When the system detects sepsis (SIRS or qSOFA criteria met), it automatically creates four orders:
|
||||
- `SEP-1: Blood cultures`
|
||||
- `SEP-1: Serum lactate`
|
||||
- `SEP-1: Broad-spectrum antibiotics`
|
||||
- `SEP-1: IV fluid bolus`
|
||||
|
||||
You do **not** need to create `order` events for these. Just add `order_result` events referencing them to simulate bundle completion.
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
### "Connection refused" error
|
||||
|
||||
The API is not running. Start it first:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
dotnet run --project VigilCare.Api
|
||||
```
|
||||
|
||||
### "scenario.json has N error(s)"
|
||||
|
||||
Run `validate` to see what's wrong:
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- validate ./your-scenario.json
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Invalid department name (must match exact casing: `Icu`, not `ICU`)
|
||||
- `offsetMinutes` out of order
|
||||
- Missing required fields (`scenario.id`, `scenario.name`, `encounter.attendingPhysician`)
|
||||
|
||||
### Medications show "skipped"
|
||||
|
||||
The medication tracking endpoint may not be available. This is non-fatal -- the scenario continues.
|
||||
|
||||
### "dotnet" command not found
|
||||
|
||||
Install .NET 8 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com/download/dotnet/8.0).
|
||||
|
||||
### I want to point the simulator at a different API
|
||||
|
||||
Use `--base-url`:
|
||||
|
||||
```bash
|
||||
dotnet run --project VigilCare.Simulator -- replay scenario.json \
|
||||
--base-url http://192.168.1.50:5270
|
||||
```
|
||||
Reference in New Issue
Block a user