417 lines
21 KiB
Markdown
417 lines
21 KiB
Markdown
# 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
|
|
lowers blood pressure and heart rate). Forty-five minutes later, their systolic BP reads
|
|
95 mmHg. Without medication context, the system fires a `WARNING_SYSTOLIC_BP` alert, a
|
|
NEWS2 score increase, and potentially a `RAPID_DETERIORATION` trend alert — all because
|
|
the BP dropped. But the BP dropped *because the medication is working as intended*.
|
|
|
|
Clinicians who see these false positives repeatedly stop trusting the alert system. At
|
|
that point, the system is worse than useless — it trains people to ignore alerts,
|
|
including the real ones.
|
|
|
|
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)
|
|
> administered 45 min ago
|
|
|
|
That single line changes the clinical interpretation from "something is wrong" to "the
|
|
medication is working — keep monitoring."
|
|
|
|
---
|
|
|
|
## How the pieces fit together
|
|
|
|
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.
|
|
|
|
```
|
|
Nurse records medication
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ MedicationsController│ ◄── thin HTTP layer, no business logic
|
|
│ POST /encounters/ │
|
|
│ {id}/medications │
|
|
└────────┬────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ FluentValidation │ ◄── rejects bad input before it reaches the service
|
|
│ (auto-registered) │ (empty drug name, zero dose, future timestamps)
|
|
└────────┬────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ MedicationService │ ◄── checks encounter exists and is active,
|
|
│ │ persists to PostgreSQL
|
|
└─────────────────────┘
|
|
|
|
... time passes, vital signs arrive ...
|
|
|
|
New observation arrives (e.g. SYSTOLIC_BP = 95)
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ WarningEvaluator │ ◄── or News2Detector for composite scores
|
|
│ │
|
|
│ 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 ◄ │ MedicationCorrelationHelper
|
|
│ 5. INSERT alert │ (idempotent — skips if one already open)
|
|
└────────┬────────────┘
|
|
│
|
|
step 4 calls
|
|
│
|
|
▼
|
|
┌────────────────────────────┐
|
|
│ MedicationCorrelationHelper│
|
|
│ │
|
|
│ "Was a relevant drug │
|
|
│ given to this patient │
|
|
│ within the last 90 │
|
|
│ minutes?" │
|
|
│ │
|
|
│ Uses MedicationService │
|
|
│ .GetRecentForEncounterAsync│
|
|
│ to query PostgreSQL │
|
|
│ │
|
|
│ Uses DrugVitalMappings │
|
|
│ from config to decide │
|
|
│ which drugs are "relevant"│
|
|
│ to which vital signs │
|
|
└────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Component-by-component: what it does and why it was built that way
|
|
|
|
### 1. MedicationAdministration entity
|
|
|
|
**What it is:** A database table (`medication_administrations`) that records when a drug
|
|
was given to a patient. Each row captures the drug name, dose, unit, route (PO = oral,
|
|
IV = intravenous, etc.), timestamp, and who gave it.
|
|
|
|
**Why it's a separate table (not a column on `observations`):**
|
|
Medications and vital sign observations are fundamentally different things. An
|
|
observation is a measurement — the patient's heart rate *is* 105 bpm right now. A
|
|
medication administration is an action — a nurse *gave* metoprolol 25mg at 10:15 AM.
|
|
They have different fields (dose, route, administered_by vs. value, unit, source),
|
|
different lifecycles, and different access patterns. Mixing them into one table would
|
|
require nullable columns everywhere and make queries harder to reason about.
|
|
|
|
**Why `decimal(10,4)` for dose:**
|
|
Medication doses can be fractional — 0.5mg of atropine, 2.5mg of metoprolol. Using an
|
|
integer would lose precision. Using `float`/`double` introduces floating-point rounding
|
|
errors (0.1 + 0.2 = 0.30000000000000004), which is unacceptable for medical dosing.
|
|
`decimal` stores exact values. The `(10,4)` precision allows doses up to 999999.9999,
|
|
which covers everything from micrograms to grams.
|
|
|
|
**Why two composite indexes:**
|
|
- `(encounter_id, administered_at)` — the correlation query asks "what drugs were given
|
|
to this encounter in the last 90 minutes?" This index makes that query fast.
|
|
- `(encounter_id, drug_name)` — supports the list endpoint when filtering by drug.
|
|
|
|
Without these indexes, every correlation check would do a full table scan on the
|
|
medication_administrations table. On a busy hospital with thousands of medication records
|
|
per day, that would add visible latency to every alert evaluation.
|
|
|
|
**Why `DeleteBehavior.Restrict` on the Encounter FK:**
|
|
If someone accidentally tries to delete an encounter that has medication records, the
|
|
database will reject the delete rather than silently cascade-deleting the medication
|
|
history. Medical records should never be silently deleted.
|
|
|
|
---
|
|
|
|
### 2. MedicationCorrelationOptions (configuration)
|
|
|
|
**What it is:** A C# class that maps drug names to the vital signs they affect. For
|
|
example, metoprolol maps to `["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"]` because
|
|
beta-blockers lower blood pressure and heart rate. It also holds the correlation window
|
|
(default 90 minutes).
|
|
|
|
**Why this is config, not a database table:**
|
|
Drug-vital mappings change rarely — new drugs don't appear every week, and the
|
|
physiological effects of existing drugs don't change. Putting them in `appsettings.json`
|
|
means:
|
|
- They're version-controlled with the code (you can see in git who changed them and why)
|
|
- They're loaded once at startup, not queried from the database on every alert
|
|
- They can be overridden per environment (test vs. production) using the standard
|
|
ASP.NET Core configuration hierarchy
|
|
- No migration needed when a mapping changes — just update the config and restart
|
|
|
|
If this were a database table, you'd need an admin UI, migration scripts, and a cache
|
|
layer to avoid querying the table on every observation. The config approach avoids all of
|
|
that complexity for data that changes maybe once a quarter.
|
|
|
|
**Why 90 minutes as the default window:**
|
|
Most oral medications reach peak effect within 60-90 minutes. IV medications act faster
|
|
(minutes), but their effects also persist. A 90-minute window catches most therapeutic
|
|
scenarios. If it were too short (30 minutes), oral medications like metoprolol (peak
|
|
effect at 60 minutes) would be missed. If it were too long (6 hours), unrelated
|
|
medication effects would pollute the annotation. The value is configurable per
|
|
environment if a clinical team wants to adjust it.
|
|
|
|
**Why drug names are lowercase keys:**
|
|
Drug names in clinical systems come in all forms — "Metoprolol", "METOPROLOL",
|
|
"metoprolol". By normalizing keys to lowercase in the config and doing case-insensitive
|
|
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
|
|
|
|
**What it is:** The business logic layer for medication administration CRUD. It handles
|
|
creating records, listing them with pagination, looking up by ID, and the critical
|
|
`GetRecentForEncounterAsync` method used by the correlation helper.
|
|
|
|
**Why the service throws exceptions instead of returning error codes:**
|
|
The project uses a pattern where services throw typed domain exceptions
|
|
(`NotFoundException`, `ConflictException`) and a global `ExceptionHandlerMiddleware`
|
|
converts them into HTTP responses (404, 409). This pattern means:
|
|
- The service doesn't know or care about HTTP — it could be called from a Kafka consumer,
|
|
a background job, or a test, and the error handling still works
|
|
- The controller stays thin (no `if (result.IsError) return NotFound(...)` chains)
|
|
- Error handling is consistent across every endpoint — one middleware, one set of rules
|
|
|
|
**Why `GetRecentForEncounterAsync` reverses the mapping:**
|
|
The config maps drugs → vital signs (metoprolol → SYSTOLIC_BP). But the correlation
|
|
query starts from the other direction: "I have a SYSTOLIC_BP alert — which drugs affect
|
|
SYSTOLIC_BP?" The method inverts the mapping at query time: it scans all entries in
|
|
`DrugVitalMappings` to find keys whose values contain the target observation code, then
|
|
queries the database for those specific drug names.
|
|
|
|
You might ask: "Why not store the mapping both ways?" Because maintaining two maps that
|
|
must stay in sync is a bug waiting to happen. The drug → vitals direction is the natural
|
|
way clinicians think about it ("metoprolol affects blood pressure"), so that's how it's
|
|
stored. The reverse lookup happens in code, where it's guaranteed to be consistent.
|
|
|
|
**Why the DB query uses `relevantDrugs.Contains(m.DrugName.ToLower())`:**
|
|
This translates to a SQL `WHERE drug_name IN (...)` clause, which PostgreSQL handles
|
|
efficiently with the composite index. The alternative — loading all medications and
|
|
filtering in memory — would transfer unnecessary data from the database. Let the database
|
|
do what it's good at.
|
|
|
|
---
|
|
|
|
### 4. MedicationsController
|
|
|
|
**What it is:** Three HTTP endpoints:
|
|
- `POST /api/v1/encounters/{encounterId}/medications` — record a new administration
|
|
- `GET /api/v1/encounters/{encounterId}/medications` — list with pagination and optional
|
|
`since` filter
|
|
- `GET /api/v1/medications/{id}` — look up a single record
|
|
|
|
**Why the controller is almost empty:**
|
|
The controller's only job is to translate HTTP concepts (route parameters, query strings,
|
|
status codes) into service calls and back. All business logic lives in
|
|
`MedicationService`. This is the same pattern used by `OrdersController` and every other
|
|
controller in the project. The benefit: you can test business logic by calling the
|
|
service directly (no need to spin up an HTTP server), and the controller is so simple
|
|
that bugs in it are immediately obvious.
|
|
|
|
**Why `POST` returns 201, not 200:**
|
|
HTTP 201 means "a new resource was created." This follows REST conventions and tells the
|
|
client that the medication record now exists and can be fetched at the returned URL. It's
|
|
a small detail, but API consumers (other teams, integration partners) expect it.
|
|
|
|
---
|
|
|
|
### 5. FluentValidation
|
|
|
|
**What it is:** A validator that rejects invalid requests before they reach the service
|
|
layer. It checks: drug name is not empty, dose is greater than zero, future timestamps
|
|
are rejected (a medication can't be recorded as given tomorrow), and string fields don't
|
|
exceed database column limits.
|
|
|
|
**Why validation is separate from the service:**
|
|
Validation and business rules are different things. Validation asks "is this request
|
|
well-formed?" (non-empty drug name, positive dose). Business rules ask "is this operation
|
|
allowed?" (encounter must be active). Keeping them separate means:
|
|
- Validation runs automatically via FluentValidation middleware — no manual calls needed
|
|
- Validation errors return 400 (bad request), business rule violations return 404/409
|
|
- The service can trust that inputs are well-formed and focus on domain logic
|
|
- Validators are auto-registered by scanning the assembly — adding a new validator just
|
|
means creating a class
|
|
|
|
**Why `AdministeredAt` allows up to 5 minutes in the future:**
|
|
Clock skew. The nurse's tablet might be a few minutes ahead of the server. Rejecting a
|
|
timestamp that's 30 seconds in the future because of clock drift would be frustrating.
|
|
Five minutes is generous enough to handle clock differences without allowing genuinely
|
|
wrong timestamps (like recording a medication as given tomorrow).
|
|
|
|
---
|
|
|
|
### 6. MedicationCorrelationHelper
|
|
|
|
**What it is:** The bridge between the medication subsystem and the alerting subsystem. It
|
|
has one public method: `TryAnnotateDetailsAsync`. Given an encounter ID, an observation
|
|
code, and the existing alert details string, it checks if a relevant medication was given
|
|
within the correlation window. If yes, it appends a note. If no, it returns the original
|
|
string unchanged.
|
|
|
|
**Why it's a separate class (not inline in WarningEvaluator):**
|
|
Two different alert paths need medication annotation: `WarningEvaluator` (for threshold
|
|
warnings) and `News2Detector` (for composite NEWS2 scores). If the annotation logic lived
|
|
inside `WarningEvaluator`, it would need to be duplicated in `News2Detector`. The helper
|
|
class means both callers share the same logic, and if the annotation format changes, it
|
|
changes in one place.
|
|
|
|
**Why it annotates rather than suppresses:**
|
|
A patient's BP of 95 after metoprolol is *expected* but still *clinically relevant*. The
|
|
BP is genuinely low. If a nurse doubled the dose by mistake, or if the patient is
|
|
hypotensive beyond what the medication should cause, the alert needs to fire. Suppressing
|
|
it entirely would be a patient safety risk. Annotation gives the clinician the context to
|
|
make that judgment — it says "this might be the medication" without saying "ignore this."
|
|
|
|
Smart suppression (automatically silencing alerts that are clearly medication-related) is
|
|
deferred to a future phase because it requires more sophisticated logic: was the drop
|
|
proportional to the dose? Is the patient also septic? These judgment calls are hard to
|
|
encode safely in software.
|
|
|
|
**Why it only annotates the most recent matching medication:**
|
|
If a patient received metoprolol at 9:00 AM and again at 10:30 AM, and their BP drops at
|
|
11:00 AM, the 10:30 AM dose is the one the clinician cares about — it's the most likely
|
|
cause. Listing every medication in the window would clutter the alert. The most recent
|
|
match gives the most actionable context.
|
|
|
|
---
|
|
|
|
## What is NOT annotated and why
|
|
|
|
| Alert path | Annotated? | Reason |
|
|
|---|---|---|
|
|
| `WarningEvaluator` | Yes | Threshold warnings are the primary source of medication-related false positives |
|
|
| `News2Detector` | Yes | NEWS2 is a composite of 7 vital signs — any of which could be medication-affected |
|
|
| `TrendDetector` | No | A rapid trajectory change after medication is still clinically important — the *rate* of change matters even if the medication explains it |
|
|
| `SirsDetector` / `QsofaDetector` | No | Sepsis alerts should never be downplayed by medication context — missing sepsis kills patients |
|
|
| Critical alerts (sync path) | No | Critical values (e.g., SBP below 70) are emergencies regardless of medication context — patient safety takes priority |
|
|
|
|
This is a deliberate clinical safety decision. The cost of a false negative (missing a
|
|
real emergency because the system said "it's just the medication") is much higher than the
|
|
cost of a false positive (an extra annotation on an expected alert).
|
|
|
|
---
|
|
|
|
## How the annotation hooks into existing alert creation
|
|
|
|
Both `WarningEvaluator` and `News2Detector` already follow a pattern:
|
|
|
|
1. Check if a threshold is breached
|
|
2. Build a details string describing the breach
|
|
3. INSERT the alert into the database
|
|
|
|
Phase 15 inserts one step between 2 and 3:
|
|
|
|
```
|
|
2. Build details string
|
|
2.5 ► Call MedicationCorrelationHelper.TryAnnotateDetailsAsync ◄
|
|
3. INSERT the alert (with the possibly-annotated details)
|
|
```
|
|
|
|
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.
|
|
|
|
The annotated details string is also included in the outbox event payload, so downstream
|
|
consumers (notifications, dashboards) receive the medication context without needing
|
|
their own correlation logic.
|
|
|
|
### News2Detector annotation strategy
|
|
|
|
NEWS2 is different from single-vital warnings because it's a composite score of 7
|
|
parameters (respiratory rate, SpO2, systolic BP, heart rate, consciousness,
|
|
temperature, supplemental O2). A single NEWS2 alert doesn't correspond to one
|
|
observation code — it's a rollup.
|
|
|
|
The annotation loops through all 7 parameter codes and checks if any has a correlated
|
|
medication. It appends the first match found. This keeps the annotation concise (one
|
|
drug per alert) while still catching the most relevant context. If a patient received
|
|
both morphine (affects RESP_RATE) and metoprolol (affects SYSTOLIC_BP), the annotation
|
|
shows whichever parameter code comes first in the loop — which is fine, because the
|
|
purpose is to flag "there's medication context here" rather than to provide a complete
|
|
pharmaceutical review.
|
|
|
|
---
|
|
|
|
## Data flow summary
|
|
|
|
```
|
|
HTTP POST
|
|
│
|
|
▼
|
|
┌────────────────┐
|
|
│ Medications │
|
|
│ Controller │
|
|
└───────┬────────┘
|
|
│
|
|
▼
|
|
┌────────────────┐ ┌─────────────────┐
|
|
│ Medication │──────►│ PostgreSQL │
|
|
│ Service │ │ medication_ │
|
|
└────────────────┘ │ administrations │
|
|
└────────┬────────┘
|
|
│
|
|
... later, observation arrives ... │
|
|
│
|
|
┌────────────────┐ │
|
|
│ Warning │ │
|
|
│ Evaluator │ │
|
|
│ │ │
|
|
│ builds details│ │
|
|
│ │ │ │
|
|
│ ▼ │ │
|
|
│ Correlation │◄───────────────┘
|
|
│ Helper │ queries recent meds
|
|
│ │ │ for this encounter +
|
|
│ ▼ │ observation code
|
|
│ annotated │
|
|
│ details │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ INSERT alert │──────► PostgreSQL clinical_alerts
|
|
│ + outbox event│──────► outbox_events → Kafka
|
|
└────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Testing strategy
|
|
|
|
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
|
|
pagination work? Does the time-window filter exclude old records? These tests call
|
|
`IMedicationService` methods directly, bypassing HTTP.
|
|
|
|
**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 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
|
|
mocks would miss.
|