feature: Trend Detection & Alert Suppression Windows

This commit is contained in:
voltsrage
2026-06-18 21:18:13 +08:00
parent e6f7989298
commit 3c54feb38a
31 changed files with 2751 additions and 18 deletions
+39 -12
View File
@@ -59,12 +59,14 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, increments `openAlertCount` on alert events, and stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients) - **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, increments `openAlertCount` on alert events, and stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients)
- **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates SIRS criteria (temperature, heart rate, respiratory rate, WBC) per encounter using Redis keys with a 30-minute TTL sliding window; on ≥2 active criteria, inserts a `SEPSIS_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`) - **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates SIRS criteria (temperature, heart rate, respiratory rate, WBC) per encounter using Redis keys with a 30-minute TTL sliding window; on ≥2 active criteria, inserts a `SEPSIS_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`)
- **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 56 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` - **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 56 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`
- **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` - **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 - **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 - **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
- **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope with field-level `details` - **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope with field-level `details`
- **Input Validation** — FluentValidation validators on all request DTOs (patient registration, encounter open, observation ingest, alert acknowledge, alert thresholds, orders); invalid requests return 400 before reaching the service layer - **Input Validation** — FluentValidation validators on all request DTOs (patient registration, encounter open, observation ingest, alert acknowledge, alert thresholds, orders); invalid requests return 400 before reaching the service layer
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; ten application metric families via `ClinicalMetrics` and three background collectors (`AlertsUnacknowledgedCollector`, `OutboxPendingCollector`, `KafkaConsumerLagCollector`); Grafana clinical dashboard (`http://localhost:3101`, admin/admin) with `alerts_unacknowledged_gauge` as the primary safety panel; per-request correlation IDs in request logs and `X-Correlation-Id` response headers - **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; thirteen application metric families via `ClinicalMetrics` and three background collectors (`AlertsUnacknowledgedCollector`, `OutboxPendingCollector`, `KafkaConsumerLagCollector`); Grafana clinical dashboard (`http://localhost:3101`, admin/admin) with `alerts_unacknowledged_gauge` as the primary safety panel; per-request correlation IDs in request logs and `X-Correlation-Id` response headers
- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only) - **Swagger UI** — OpenAPI spec via Swashbuckle (Development only)
--- ---
@@ -78,7 +80,7 @@ HTTP request
→ Controllers → Controllers
→ Services → Services
├── PostgreSQL (EF Core — writes, keyed reads) ├── PostgreSQL (EF Core — writes, keyed reads)
├── Redis (threshold cache, SIRS state, NEWS2 parameter state) ├── Redis (threshold cache, SIRS state, NEWS2 parameter state, trend history, alert suppression keys)
└── OutboxEvent (same transaction as domain write) └── OutboxEvent (same transaction as domain write)
IHostedServices (background): IHostedServices (background):
@@ -91,6 +93,8 @@ IHostedServices (background):
SepsisEngineService → Kafka → Redis SIRS state → PostgreSQL alert (consumer group: sepsis-engine) SepsisEngineService → Kafka → Redis SIRS state → PostgreSQL alert (consumer group: sepsis-engine)
WarningAlertService → Kafka → WarningEvaluator → PostgreSQL WARNING alert (consumer group: warning-evaluator) WarningAlertService → Kafka → WarningEvaluator → PostgreSQL WARNING alert (consumer group: warning-evaluator)
News2ScoringService → Kafka → News2Detector → Redis NEWS2 state → PostgreSQL score + alert (consumer group: news2-scoring) News2ScoringService → Kafka → News2Detector → 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) NotificationPublisherService → Kafka → RabbitMQ paging.queue (consumer group: notification-publisher)
PagingWorkerService → RabbitMQ paging.queue → log page → NACK on timeout (or requeue on shutdown) PagingWorkerService → RabbitMQ paging.queue → log page → NACK on timeout (or requeue on shutdown)
EscalationWorkerService → RabbitMQ escalation.queue → update alert status EscalationWorkerService → RabbitMQ escalation.queue → update alert status
@@ -100,7 +104,7 @@ IHostedServices (background):
AlertsUnacknowledgedCollector → polls PostgreSQL every 30s → alerts_unacknowledged_gauge AlertsUnacknowledgedCollector → polls PostgreSQL every 30s → alerts_unacknowledged_gauge
OutboxPendingCollector → polls outbox every 30s → outbox_pending_events OutboxPendingCollector → polls outbox every 30s → outbox_pending_events
KafkaConsumerLagCollector → polls four consumer groups every 30s → kafka_consumer_lag KafkaConsumerLagCollector → polls four consumer groups every 30s → kafka_consumer_lag
ClinicalMetrics (singleton) → inline counters/histogram from ingest, SIRS, NEWS2, escalation paths ClinicalMetrics (singleton) → inline counters/histogram from ingest, SIRS, NEWS2, trend, suppression, escalation paths
``` ```
**Why Kafka and RabbitMQ coexist:** Kafka is an append-only log — the same observation event reaches the Elasticsearch indexer, the sepsis engine, and the data lake independently without coordination. Each consumer holds its own offset and can replay from the beginning. RabbitMQ handles the action side: one message, one worker, one page. A duplicate page at 3am is a patient safety concern, not a minor inconvenience — RabbitMQ's acknowledgment-then-delete model is correct here. The DLQ TTL-based escalation has no equivalent in Kafka. **Why Kafka and RabbitMQ coexist:** Kafka is an append-only log — the same observation event reaches the Elasticsearch indexer, the sepsis engine, and the data lake independently without coordination. Each consumer holds its own offset and can replay from the beginning. RabbitMQ handles the action side: one message, one worker, one page. A duplicate page at 3am is a patient safety concern, not a minor inconvenience — RabbitMQ's acknowledgment-then-delete model is correct here. The DLQ TTL-based escalation has no equivalent in Kafka.
@@ -174,16 +178,20 @@ VigilCareClinicalAPI/
│ ├── AlertThresholdService.cs # CRUD + Redis write-through invalidation │ ├── AlertThresholdService.cs # CRUD + Redis write-through invalidation
│ ├── ObservationService.cs # Ingest transaction: idempotency → plausibility → threshold → alert → outbox; emits Prometheus counters │ ├── ObservationService.cs # Ingest transaction: idempotency → plausibility → threshold → alert → outbox; emits Prometheus counters
│ ├── ObservationQueryService.cs # Cursor-paginated history │ ├── ObservationQueryService.cs # Cursor-paginated history
│ ├── AlertService.cs # Acknowledge, resolve, list │ ├── AlertService.cs # Acknowledge (sets suppression), resolve, list
│ ├── AlertSuppressionService.cs # Redis suppress:{enc}:{type} TTL keys
│ ├── OrderService.cs # Order lifecycle; status machine; ConflictException on illegal transitions │ ├── OrderService.cs # Order lifecycle; status machine; ConflictException on illegal transitions
│ ├── News2Service.cs # Current score + cursor-paginated history from PostgreSQL │ ├── News2Service.cs # Current score + cursor-paginated history from PostgreSQL
│ ├── WarningEvaluator.cs # Warning-range threshold evaluation; idempotent alert INSERT │ ├── WarningEvaluator.cs # Warning-range evaluation; suppression check; idempotent alert INSERT
│ ├── AnalyticsService.cs # Elasticsearch query wrappers │ ├── AnalyticsService.cs # Elasticsearch query wrappers
│ └── PlausibilityValidator.cs # Per-code numeric range guard │ └── 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, … ├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, …
├── Observability/ ├── Observability/
│ └── Metrics/ │ └── Metrics/
│ └── ClinicalMetrics.cs # Ten Prometheus metric families (counters, histograms, gauges) │ └── ClinicalMetrics.cs # Thirteen Prometheus metric families (counters, histograms, gauges)
├── BackgroundServices/ ├── BackgroundServices/
│ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup │ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup
│ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config │ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config
@@ -198,6 +206,7 @@ VigilCareClinicalAPI/
│ ├── SepsisEngineService.cs # consumer group: sepsis-engine; SIRS eval via Redis TTL keys │ ├── SepsisEngineService.cs # consumer group: sepsis-engine; SIRS eval via Redis TTL keys
│ ├── WarningAlertService.cs # consumer group: warning-evaluator; observation.recorded → WARNING alerts │ ├── WarningAlertService.cs # consumer group: warning-evaluator; observation.recorded → WARNING alerts
│ ├── News2ScoringService.cs # consumer group: news2-scoring; observation.recorded → NEWS2 score + alert │ ├── News2ScoringService.cs # consumer group: news2-scoring; observation.recorded → NEWS2 score + alert
│ ├── TrendAnalyzerService.cs # consumer group: trend-analyzer; observation.recorded → RAPID_DETERIORATION alert
│ ├── Notifications/ │ ├── Notifications/
│ │ ├── NotificationPublisherService.cs # consumer group: notification-publisher; alert.generated → RabbitMQ paging.queue │ │ ├── NotificationPublisherService.cs # consumer group: notification-publisher; alert.generated → RabbitMQ paging.queue
│ │ ├── PagingWorkerService.cs # RabbitMQ consumer; logs page; NACK on ack timeout → DLQ, requeue on graceful shutdown │ │ ├── PagingWorkerService.cs # RabbitMQ consumer; logs page; NACK on ack timeout → DLQ, requeue on graceful shutdown
@@ -274,7 +283,10 @@ tests/
├── OrderLifecycleTests.cs # Orders API — create, list, record result, illegal transition 409 ├── OrderLifecycleTests.cs # Orders API — create, list, record result, illegal transition 409
├── ValidationTests.cs # FluentValidation — empty fields, threshold ordering, order description ├── ValidationTests.cs # FluentValidation — empty fields, threshold ordering, order description
├── News2CalculatorTests.cs # Boundary tests for all seven NEWS2 scoring tables ├── News2CalculatorTests.cs # Boundary tests for all seven NEWS2 scoring tables
── News2DetectorTests.cs # NEWS2 detector — score tiers, alerts, idempotency, incomplete set ── News2DetectorTests.cs # NEWS2 detector — score tiers, alerts, idempotency, incomplete set
├── TrendCalculatorTests.cs # Pure unit tests — rate-of-change, threshold direction, describe
├── TrendDetectorTests.cs # Trend detector — rapid climb, stable, idempotent, non-trend code
└── AlertSuppressionTests.cs # Suppression on acknowledge, read-side skip, TTL expiry
scripts/ scripts/
├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache ├── run-api-redis-tests.sh # Phase 1 — patient/encounter/threshold + Redis cache
@@ -287,10 +299,11 @@ scripts/
├── run-phase9-verification.sh # Phase 9 — data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema ├── run-phase9-verification.sh # Phase 9 — data lake tests, Kafka offsets, MinIO Parquet, DuckDB schema
├── run-phase10-verification.sh # Phase 10 — 12 Redis thresholds, clinical enrichment, ES pipeline, integration tests ├── 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-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-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
docs/ docs/
├── plans/ # Phase 112 implementation and verification guides ├── plans/ # Phase 113 implementation and verification guides
├── decisions/ ├── decisions/
│ ├── data-lake-design.md # Parquet vs JSON, partitioning, replay rationale │ ├── 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
@@ -438,6 +451,9 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
| `ValidationTests` | 11 | FluentValidation 400 on empty first name, invalid threshold order, empty order description | | `ValidationTests` | 11 | FluentValidation 400 on empty first name, invalid threshold order, empty order description |
| `News2CalculatorTests` | 12 | Boundary tests for all seven NEWS2 scoring tables and risk-level determination | | `News2CalculatorTests` | 12 | Boundary tests for all seven NEWS2 scoring tables and risk-level determination |
| `News2DetectorTests` | 12 | NEWS2 detector — score tiers, alert creation, incomplete parameters, idempotency | | `News2DetectorTests` | 12 | NEWS2 detector — score tiers, alert creation, incomplete parameters, idempotency |
| `TrendCalculatorTests` | 13 | Pure unit tests — delta/time rate, SPO2/BP decline direction, describe formatting |
| `TrendDetectorTests` | 13 | Trend detector — rapid HR climb alert, stable high HR, idempotency, non-trend code |
| `AlertSuppressionTests` | 13 | Acknowledge sets Redis key, suppressed warning skipped, critical/NEWS2 emergency never suppressed, TTL expiry |
### Verification Scripts ### Verification Scripts
@@ -449,6 +465,13 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-phase10-verification.sh # 12 Redis thresholds, clinical enrichment, ES pipeline, Phase 10 integration tests ./scripts/run-phase10-verification.sh # 12 Redis thresholds, clinical enrichment, ES pipeline, Phase 10 integration tests
./scripts/run-phase11-verification.sh # Warning alert pipeline, orders API, FluentValidation, Phase 11 integration tests ./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-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
```
Phase 13 unit/integration tests only:
```bash
dotnet test --filter "FullyQualifiedName~Trend|FullyQualifiedName~Suppression"
``` ```
Per-phase test runners (subset of `dotnet test`): Per-phase test runners (subset of `dotnet test`):
@@ -481,16 +504,19 @@ See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manua
## Prometheus Metrics ## Prometheus Metrics
`GET /metrics` exposes ten application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and histograms are updated inline during request handling and background processing. `GET /metrics` exposes thirteen application metric families registered in `ClinicalMetrics`. Three background collectors poll PostgreSQL and Kafka every 30 seconds; counters and histograms are updated inline during request handling and background processing.
| Metric | Type | Labels | Source | | Metric | Type | Labels | Source |
|---|---|---|---| |---|---|---|---|
| `observations_ingested_total` | Counter | `observation_code`, `source` | `ObservationService` on each committed observation | | `observations_ingested_total` | Counter | `observation_code`, `source` | `ObservationService` on each committed observation |
| `observation_ingest_duration_seconds` | Histogram | — | `ObservationService` — full ingest transaction to COMMIT | | `observation_ingest_duration_seconds` | Histogram | — | `ObservationService` — full ingest transaction to COMMIT |
| `clinical_alerts_total` | Counter | `alert_type`, `severity` | `ObservationService` (threshold breach), `SirsDetector`, `News2Detector` | | `clinical_alerts_total` | Counter | `alert_type`, `severity` | `ObservationService`, `SirsDetector`, `News2Detector`, `TrendDetector`, `WarningEvaluator` |
| `sirs_detections_total` | Counter | — | `SirsDetector` — only on successful idempotent insert | | `sirs_detections_total` | Counter | — | `SirsDetector` — only on successful idempotent insert |
| `news2_scores_total` | Counter | `risk_level` | `News2Detector` — on each persisted score (`LOW`, `MEDIUM`, `HIGH`, …) | | `news2_scores_total` | Counter | `risk_level` | `News2Detector` — on each persisted score (`LOW`, `MEDIUM`, `HIGH`, …) |
| `news2_scoring_duration_seconds` | Histogram | — | `News2Detector` — Redis update through score persistence | | `news2_scoring_duration_seconds` | Histogram | — | `News2Detector` — Redis update through score persistence |
| `trend_alerts_total` | Counter | `observation_code` | `TrendDetector` — on each `RAPID_DETERIORATION` alert created |
| `trend_analysis_duration_seconds` | Histogram | — | `TrendDetector` — per-observation trend evaluation |
| `alert_suppressions_total` | Counter | `alert_type` | `AlertSuppressionService` — on each suppression window set after acknowledge |
| `escalations_total` | Counter | — | `EscalationWorkerService` on DLQ escalation | | `escalations_total` | Counter | — | `EscalationWorkerService` on DLQ escalation |
| `alerts_unacknowledged_gauge` | Gauge | — | `AlertsUnacknowledgedCollector` — open CRITICAL alerts older than 5 minutes | | `alerts_unacknowledged_gauge` | Gauge | — | `AlertsUnacknowledgedCollector` — open CRITICAL alerts older than 5 minutes |
| `outbox_pending_events` | Gauge | — | `OutboxPendingCollector` — unprocessed outbox rows | | `outbox_pending_events` | Gauge | — | `OutboxPendingCollector` — unprocessed outbox rows |
@@ -988,7 +1014,7 @@ Exchange: `clinical.notifications.exchange` (direct)
| Topic | Partition key | Consumer groups | | Topic | Partition key | Consumer groups |
|---|---|---| |---|---|---|
| `observation.recorded` | `encounterId` | `es-indexer`, `sepsis-engine`, `warning-evaluator`, `news2-scoring`, `data-lake-writer` | | `observation.recorded` | `encounterId` | `es-indexer`, `sepsis-engine`, `warning-evaluator`, `news2-scoring`, `trend-analyzer`, `data-lake-writer` |
| `alert.generated` | `encounterId` | `es-indexer`, `notification-publisher`, `data-lake-writer` | | `alert.generated` | `encounterId` | `es-indexer`, `notification-publisher`, `data-lake-writer` |
| `encounter.status.changed` | `encounterId` | `es-indexer`, `data-lake-writer` | | `encounter.status.changed` | `encounterId` | `es-indexer`, `data-lake-writer` |
@@ -1142,5 +1168,6 @@ Twelve phases from the project roadmap are implemented and verified. Integration
| 10 | Clinical data model expansion — `BloodType`, patient allergies/emergency contact, encounter room/bed/admission/discharge fields; five new observation codes (`SYSTOLIC_BP`, `DIASTOLIC_BP`, `LACTATE_MMOL_L`, `AVPU`, `SUPPLEMENTAL_O2`); `GLUCOSE_MG_DL` threshold fix; 12 seeded thresholds; `ClinicalDemographicsAndObservationTests`; `run-phase10-verification.sh` | Done | | 10 | Clinical data model expansion — `BloodType`, patient allergies/emergency contact, encounter room/bed/admission/discharge fields; five new observation codes (`SYSTOLIC_BP`, `DIASTOLIC_BP`, `LACTATE_MMOL_L`, `AVPU`, `SUPPLEMENTAL_O2`); `GLUCOSE_MG_DL` threshold fix; 12 seeded thresholds; `ClinicalDemographicsAndObservationTests`; `run-phase10-verification.sh` | Done |
| 11 | Warning alert consumer (`WarningAlertService` / `warning-evaluator`); 10 `Warning*` alert types; Orders API (`OrdersController`, `OrderService`); FluentValidation on all request DTOs; `WarningAlertTests`, `OrderLifecycleTests`, `ValidationTests`; `run-phase11-verification.sh` | Done | | 11 | Warning alert consumer (`WarningAlertService` / `warning-evaluator`); 10 `Warning*` alert types; Orders API (`OrdersController`, `OrderService`); FluentValidation on all request DTOs; `WarningAlertTests`, `OrderLifecycleTests`, `ValidationTests`; `run-phase11-verification.sh` | Done |
| 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 | | 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 |
**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. **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.
@@ -0,0 +1,194 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Integration")]
public class AlertSuppressionTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private Guid _patientId;
private Guid _encounterId;
public AlertSuppressionTests(ApiFixture fixture) => _fixture = fixture;
public async Task InitializeAsync() => await ResetAndSeedThresholdAsync();
public Task DisposeAsync() => Task.CompletedTask;
private async Task ResetAndSeedThresholdAsync()
{
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-SUP-001", FirstName = "Suppress", LastName = "Test",
DateOfBirth = new DateOnly(1982, 8, 10), Gender = "M", CreatedAt = DateTimeOffset.UtcNow
};
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = Department.Icu,
AttendingPhysician = "Dr. Suppress", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
db.AlertThresholds.Add(new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "HEART_RATE",
DisplayName = "Heart Rate", Unit = "bpm",
CriticalLow = 30, WarningLow = 50, WarningHigh = 100, CriticalHigh = 150,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await redis.GetDatabase().StringSetAsync("threshold:HEART_RATE",
"""{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}""");
_patientId = patient.Id;
_encounterId = encounter.Id;
}
[Fact]
public async Task AcknowledgeWarning_SetsSuppressionKey()
{
await ResetAndSeedThresholdAsync();
var alertId = await SeedAlertAsync(AlertType.WarningHeartRate, AlertSeverity.Warning);
using var scope = _fixture.Services.CreateScope();
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring"));
var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.WarningHeartRate);
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeTrue();
}
[Fact]
public async Task SuppressedWarning_SkipsAlertCreation()
{
await ResetAndSeedThresholdAsync();
var alertId = await SeedAlertAsync(AlertType.WarningHeartRate, AlertSeverity.Warning);
using var scope = _fixture.Services.CreateScope();
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring"));
await alerts.ResolveAsync(alertId);
var created = await evaluator.EvaluateAsync(
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m);
created.Should().BeFalse();
(await db.ClinicalAlerts.CountAsync()).Should().Be(1,
"suppressed warning must not create a new alert after resolve");
}
[Fact]
public async Task CriticalAlert_NotSuppressible()
{
await ResetAndSeedThresholdAsync();
var alertId = await SeedAlertAsync(AlertType.CriticalHeartRate, AlertSeverity.Critical);
using var scope = _fixture.Services.CreateScope();
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("dr-1", "treating"));
var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.CriticalHeartRate);
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse();
}
[Fact]
public async Task News2Emergency_NotSuppressible()
{
await ResetAndSeedThresholdAsync();
var alertId = await SeedAlertAsync(AlertType.News2Emergency, AlertSeverity.Critical);
using var scope = _fixture.Services.CreateScope();
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("dr-1", "reviewed"));
var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.News2Emergency);
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse();
}
[Fact]
public async Task News2Warning_Suppressible()
{
await ResetAndSeedThresholdAsync();
var alertId = await SeedAlertAsync(AlertType.News2Warning, AlertSeverity.Warning);
using var scope = _fixture.Services.CreateScope();
var alerts = scope.ServiceProvider.GetRequiredService<IAlertService>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await alerts.AcknowledgeAsync(alertId, new AcknowledgeAlertRequest("nurse-1", "monitoring"));
var key = AlertSuppressionService.SuppressionKey(_encounterId, AlertType.News2Warning);
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeTrue();
}
[Fact]
public async Task SuppressionExpires_AllowsNewAlert()
{
await ResetAndSeedThresholdAsync();
using var scope = _fixture.Services.CreateScope();
var suppression = scope.ServiceProvider.GetRequiredService<IAlertSuppressionService>();
var evaluator = scope.ServiceProvider.GetRequiredService<WarningEvaluator>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await suppression.SetSuppressionAsync(
_encounterId, AlertType.WarningHeartRate, TimeSpan.FromSeconds(1));
var blocked = await evaluator.EvaluateAsync(
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m);
blocked.Should().BeFalse();
await Task.Delay(1500);
var created = await evaluator.EvaluateAsync(
Guid.NewGuid(), _encounterId, _patientId, "HEART_RATE", 105m);
created.Should().BeTrue();
var alert = await db.ClinicalAlerts.SingleAsync();
alert.AlertType.Should().Be(AlertType.WarningHeartRate);
}
private async Task<Guid> SeedAlertAsync(AlertType alertType, AlertSeverity severity)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = new ClinicalAlert
{
Id = Guid.NewGuid(),
EncounterId = _encounterId,
PatientId = _patientId,
AlertType = alertType,
Severity = severity,
Details = $"Test {alertType.ToDbString()} alert.",
Status = AlertStatus.Open,
TriggeredAt = DateTimeOffset.UtcNow
};
db.ClinicalAlerts.Add(alert);
await db.SaveChangesAsync();
return alert.Id;
}
}
@@ -32,12 +32,17 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
// Apply migrations against the test database on first run // Apply migrations before the host starts — background services such as
// ThresholdCacheLoader query the database during StartAsync.
var connectionString = "Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connectionString)
.Options;
await using (var migrateDb = new AppDbContext(options))
await migrateDb.Database.MigrateAsync();
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
// Flush the test Redis database (db=1) to avoid cross-test cache pollution
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>(); var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
var server = redis.GetServer(redis.GetEndPoints().First()); var server = redis.GetServer(redis.GetEndPoints().First());
await server.FlushDatabaseAsync(1); await server.FlushDatabaseAsync(1);
@@ -0,0 +1,58 @@
using FluentAssertions;
public class TrendCalculatorTests
{
private static readonly DateTimeOffset BaseTime =
new(2026, 6, 18, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void ComputeRatePerMinute_TwoEntries_ReturnsCorrectRate()
{
var entries = new List<TrendHistoryEntry>
{
new(72m, BaseTime),
new(95m, BaseTime.AddMinutes(10))
};
var rate = TrendCalculator.ComputeRatePerMinute(entries, windowMinutes: 30);
rate.Should().Be(2.3m);
}
[Fact]
public void ComputeRatePerMinute_SingleEntry_ReturnsNull()
{
var entries = new List<TrendHistoryEntry> { new(72m, BaseTime) };
TrendCalculator.ComputeRatePerMinute(entries, windowMinutes: 30)
.Should().BeNull();
}
[Fact]
public void ExceedsThreshold_HeartRateRise_ReturnsTrue()
{
TrendCalculator.ExceedsThreshold("HEART_RATE", 0.6m, 0.5m).Should().BeTrue();
}
[Fact]
public void ExceedsThreshold_Spo2Decline_ReturnsTrue()
{
TrendCalculator.ExceedsThreshold("SPO2", -0.3m, 0.2m).Should().BeTrue();
}
[Fact]
public void ExceedsThreshold_StableRate_ReturnsFalse()
{
TrendCalculator.ExceedsThreshold("HEART_RATE", 0.3m, 0.5m).Should().BeFalse();
}
[Fact]
public void DescribeTrend_FormatsCorrectly()
{
TrendCalculator.DescribeTrend("HEART_RATE", 0.77m, 95m)
.Should().Be("Rapid rise: HEART_RATE rising at 0.77/min (current 95)");
TrendCalculator.DescribeTrend("SPO2", -0.25m, 92m)
.Should().Be("Rapid decline: SPO2 falling at 0.25/min (current 92)");
}
}
@@ -0,0 +1,147 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Integration")]
public class TrendDetectorTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private Guid _encounterId;
private Guid _patientId;
private static readonly DateTimeOffset BaseTime =
new(2026, 6, 18, 10, 0, 0, TimeSpan.Zero);
public TrendDetectorTests(ApiFixture fixture) => _fixture = fixture;
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-TREND-001", FirstName = "Trend", LastName = "Test",
DateOfBirth = new DateOnly(1970, 3, 1), Gender = "F",
CreatedAt = DateTimeOffset.UtcNow
};
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = Department.Icu,
AttendingPhysician = "Dr. Trend", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
await db.SaveChangesAsync();
_patientId = patient.Id;
_encounterId = encounter.Id;
await ClearTrendHistoryAsync(scope.ServiceProvider);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task RapidHeartRateClimb_CreatesAlert()
{
using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 72m, BaseTime);
var result = await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10));
result.Outcome.Should().Be(TrendOutcome.RapidDeterioration);
result.AlertCreated.Should().BeTrue();
result.RatePerMinute.Should().Be(2.3m);
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts.SingleAsync();
alert.AlertType.Should().Be(AlertType.RapidDeterioration);
alert.Severity.Should().Be(AlertSeverity.Warning);
alert.Details.Should().Contain("HEART_RATE");
}
[Fact]
public async Task StableHighHeartRate_NoTrendAlert()
{
using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime);
await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10));
var result = await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(20));
result.Outcome.Should().Be(TrendOutcome.Stable);
result.AlertCreated.Should().BeFalse();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
}
[Fact]
public async Task InsufficientHistory_NoAlert()
{
using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
var result = await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 72m, BaseTime);
result.Outcome.Should().Be(TrendOutcome.InsufficientHistory);
result.AlertCreated.Should().BeFalse();
}
[Fact]
public async Task NonTrendCode_Ignored()
{
using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
var result = await detector.ProcessObservationAsync(
_encounterId, _patientId, "POTASSIUM_MEQ_L", 4.0m, BaseTime);
result.Outcome.Should().Be(TrendOutcome.NotTrendCode);
var key = TrendCalculator.HistoryKey(_encounterId, "POTASSIUM_MEQ_L");
(await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse();
}
[Fact]
public async Task DuplicateTrendAlert_Idempotent()
{
using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 72m, BaseTime);
await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10));
var second = await detector.ProcessObservationAsync(
_encounterId, _patientId, "HEART_RATE", 98m, BaseTime.AddMinutes(20));
second.Outcome.Should().Be(TrendOutcome.AlertAlreadyOpen);
second.AlertCreated.Should().BeFalse();
(await db.ClinicalAlerts.CountAsync()).Should().Be(1,
"second rapid climb must not duplicate while first RAPID_DETERIORATION is open");
}
private async Task ClearTrendHistoryAsync(IServiceProvider services)
{
var redis = services.GetRequiredService<IConnectionMultiplexer>();
var cache = redis.GetDatabase();
foreach (var key in TrendCalculator.AllHistoryKeys(_encounterId))
await cache.KeyDeleteAsync(key);
}
}
@@ -0,0 +1,85 @@
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Options;
public class TrendAnalyzerService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger<TrendAnalyzerService> _logger;
public TrendAnalyzerService(
IServiceProvider services,
IOptions<KafkaOptions> kafkaOptions,
ILogger<TrendAnalyzerService> logger)
{
_services = services;
_kafkaOptions = kafkaOptions.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "trend-analyzer",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
_logger.LogInformation("TrendAnalyzerService started — consumer group: trend-analyzer");
try
{
while (!stoppingToken.IsCancellationRequested)
{
ConsumeResult<string, string>? result = null;
try
{
result = consumer.Consume(stoppingToken);
var evt = JsonSerializer.Deserialize<TrendObservationEvent>(
result.Message.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
using var scope = _services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
var outcome = await detector.ProcessObservationAsync(
evt.EncounterId,
evt.PatientId,
evt.ObservationCode,
evt.Value,
evt.RecordedAt,
stoppingToken);
if (outcome.Outcome == TrendOutcome.RapidDeterioration)
_logger.LogInformation(
"RAPID_DETERIORATION alert via consumer — encounter={Id} code={Code} rate={Rate}/min",
evt.EncounterId, outcome.ObservationCode, outcome.RatePerMinute);
consumer.Commit(result);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"TrendAnalyzerService failed on topic={Topic} offset={Offset} — not committing",
result?.Topic, result?.Offset.Value);
await Task.Delay(2000, stoppingToken);
}
}
}
finally
{
consumer.Close();
}
}
}
@@ -0,0 +1,7 @@
public class SuppressionOptions
{
public const string SectionName = "AlertSuppression";
/// <summary>Default suppression window after acknowledgment (minutes).</summary>
public int DefaultWindowMinutes { get; set; } = 30;
}
@@ -0,0 +1,23 @@
public class TrendDetectionOptions
{
public const string SectionName = "TrendDetection";
/// <summary>Sliding window for rate-of-change calculation (minutes).</summary>
public int WindowMinutes { get; set; } = 30;
/// <summary>Maximum history entries stored per parameter in Redis.</summary>
public int MaxHistoryEntries { get; set; } = 10;
/// <summary>Redis TTL for trend history keys (seconds). Default 2 hours.</summary>
public int HistoryTtlSeconds { get; set; } = 7200;
/// <summary>Per-code rate thresholds: units per minute.</summary>
public Dictionary<string, decimal> RateThresholdsPerMinute { get; set; } = new()
{
["HEART_RATE"] = 0.5m, // 15 bpm rise in 30 min
["RESP_RATE"] = 0.3m, // 9 breaths/min rise in 30 min
["SYSTOLIC_BP"] = 1.0m, // 30 mmHg drop in 30 min (negative rate checked separately)
["TEMP_C"] = 0.05m, // 1.5 °C rise in 30 min
["SPO2"] = 0.2m // 6% drop in 30 min (negative rate)
};
}
@@ -15,6 +15,7 @@ public class AlertThresholdConfiguration : IEntityTypeConfiguration<AlertThresho
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)"); builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)"); builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)"); builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
builder.Property(t => t.SuppressionWindowMinutes).HasColumnName("suppression_window_minutes");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(t => t.ObservationCode).IsUnique(); builder.HasIndex(t => t.ObservationCode).IsUnique();
@@ -8,5 +8,6 @@ public class AlertThreshold
public decimal? WarningLow { get; set; } public decimal? WarningLow { get; set; }
public decimal? WarningHigh { get; set; } public decimal? WarningHigh { get; set; }
public decimal? CriticalHigh { get; set; } public decimal? CriticalHigh { get; set; }
public int? SuppressionWindowMinutes { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
} }
@@ -26,7 +26,9 @@ public enum AlertType
WarningGlucoseMgDl, WarningGlucoseMgDl,
News2Warning, News2Warning,
News2Emergency News2Emergency,
RapidDeterioration,
} }
public static class AlertTypeExtensions public static class AlertTypeExtensions
@@ -57,6 +59,7 @@ public static class AlertTypeExtensions
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL", AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
AlertType.News2Warning => "NEWS2_WARNING", AlertType.News2Warning => "NEWS2_WARNING",
AlertType.News2Emergency => "NEWS2_EMERGENCY", AlertType.News2Emergency => "NEWS2_EMERGENCY",
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
_ => throw new ArgumentOutOfRangeException(nameof(t)) _ => throw new ArgumentOutOfRangeException(nameof(t))
}; };
@@ -86,6 +89,7 @@ public static class AlertTypeExtensions
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl, "WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
"NEWS2_WARNING" => AlertType.News2Warning, "NEWS2_WARNING" => AlertType.News2Warning,
"NEWS2_EMERGENCY" => AlertType.News2Emergency, "NEWS2_EMERGENCY" => AlertType.News2Emergency,
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'") _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
}; };
@@ -122,4 +126,31 @@ public static class AlertTypeExtensions
_ => throw new ArgumentOutOfRangeException( _ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'") nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
}; };
public static bool IsSuppressible(this AlertType t) => t switch
{
AlertType.SepsisWarning or AlertType.News2Emergency => false,
AlertType.CriticalHeartRate or AlertType.CriticalTempC or AlertType.CriticalPotassiumMeqL
or AlertType.CriticalSpo2 or AlertType.CriticalRespRate or AlertType.CriticalWbcKUl
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
or AlertType.CriticalGlucoseMgDl => false,
AlertType.RapidDeterioration => false, // trajectory alerts are never suppressed
_ => true // all Warning* types and News2Warning
};
public static string? ObservationCodeForWarning(this AlertType t) => t switch
{
AlertType.WarningHeartRate => "HEART_RATE",
AlertType.WarningTempC => "TEMP_C",
AlertType.WarningPotassiumMeqL => "POTASSIUM_MEQ_L",
AlertType.WarningSpo2 => "SPO2",
AlertType.WarningRespRate => "RESP_RATE",
AlertType.WarningWbcKUl => "WBC_K_UL",
AlertType.WarningSystolicBp => "SYSTOLIC_BP",
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
_ => null
};
} }
@@ -0,0 +1,8 @@
public enum TrendOutcome
{
NotTrendCode,
InsufficientHistory,
Stable,
RapidDeterioration,
AlertAlreadyOpen
}
@@ -0,0 +1,716 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260618121634_AddRapidDeteriorationAlertType")]
partial class AddRapidDeteriorationAlertType
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("admitted_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("AttendingPhysician")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("attending_physician");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at");
b.Property<string>("EncounterType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("encounter_type");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("RoomBed")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'SCHEDULED'");
b.HasKey("Id");
b.HasIndex("PatientId", "AdmittedAt");
b.HasIndex("Status", "AdmittedAt")
.HasFilter("status = 'ACTIVE'");
b.ToTable("encounters", null, t =>
{
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
});
});
modelBuilder.Entity("News2Score", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CalculatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("calculated_at");
b.Property<int>("ConsciousnessScore")
.HasColumnType("integer")
.HasColumnName("consciousness_score");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<bool>("HasSingleParamThree")
.HasColumnType("boolean")
.HasColumnName("has_single_param_three");
b.Property<int>("HeartRateScore")
.HasColumnType("integer")
.HasColumnName("heart_rate_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("RespRateScore")
.HasColumnType("integer")
.HasColumnName("resp_rate_score");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("risk_level");
b.Property<int>("Spo2Score")
.HasColumnType("integer")
.HasColumnName("spo2_score");
b.Property<int>("SupplementalO2Score")
.HasColumnType("integer")
.HasColumnName("supplemental_o2_score");
b.Property<int>("SystolicBpScore")
.HasColumnType("integer")
.HasColumnName("systolic_bp_score");
b.Property<int>("TemperatureScore")
.HasColumnType("integer")
.HasColumnName("temperature_score");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.HasIndex("PatientId", "CalculatedAt");
b.ToTable("news2_scores", null, t =>
{
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
});
});
modelBuilder.Entity("Observation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("IdempotencyKey")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("source")
.HasDefaultValueSql("'MANUAL'");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("IdempotencyKey")
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
b.ToTable("observations", null, t =>
{
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
});
});
modelBuilder.Entity("Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("order_type");
b.Property<DateTimeOffset>("OrderedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("ordered_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("OrderedBy")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("ordered_by");
b.Property<string>("ResultSummary")
.HasColumnType("text")
.HasColumnName("result_summary");
b.Property<DateTimeOffset?>("ResultedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resulted_at");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("EncounterId", "OrderedAt");
b.HasIndex("Status", "OrderedAt")
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
b.ToTable("orders", null, t =>
{
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
});
});
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("topic");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasFilter("processed_at IS NULL");
b.ToTable("outbox_events", (string)null);
});
modelBuilder.Entity("Patient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Allergies")
.HasColumnType("text")
.HasColumnName("allergies");
b.Property<string>("BloodType")
.HasMaxLength(5)
.HasColumnType("character varying(5)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("emergency_contact_name");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("emergency_contact_phone");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("gender");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("active")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("Mrn")
.IsUnique();
b.ToTable("patients", (string)null);
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CheckType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("check_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.HasKey("Id");
b.HasIndex("EncounterId");
b.HasIndex("PatientId");
b.HasIndex("CheckType", "EncounterId")
.HasFilter("resolved_at IS NULL");
b.ToTable("reconciliation_alerts", null, t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("News2Score", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddRapidDeteriorationAlertType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
CHECK (alert_type IN (
'SEPSIS_WARNING',
'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L',
'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL',
'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L',
'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL',
'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L',
'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL',
'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L',
'WARNING_GLUCOSE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,720 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260618124632_AddSuppressionWindowMinutes")]
partial class AddSuppressionWindowMinutes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<int?>("SuppressionWindowMinutes")
.HasColumnType("integer")
.HasColumnName("suppression_window_minutes");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("admitted_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("AttendingPhysician")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("attending_physician");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at");
b.Property<string>("EncounterType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("encounter_type");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("RoomBed")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'SCHEDULED'");
b.HasKey("Id");
b.HasIndex("PatientId", "AdmittedAt");
b.HasIndex("Status", "AdmittedAt")
.HasFilter("status = 'ACTIVE'");
b.ToTable("encounters", null, t =>
{
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
});
});
modelBuilder.Entity("News2Score", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CalculatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("calculated_at");
b.Property<int>("ConsciousnessScore")
.HasColumnType("integer")
.HasColumnName("consciousness_score");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<bool>("HasSingleParamThree")
.HasColumnType("boolean")
.HasColumnName("has_single_param_three");
b.Property<int>("HeartRateScore")
.HasColumnType("integer")
.HasColumnName("heart_rate_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("RespRateScore")
.HasColumnType("integer")
.HasColumnName("resp_rate_score");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("risk_level");
b.Property<int>("Spo2Score")
.HasColumnType("integer")
.HasColumnName("spo2_score");
b.Property<int>("SupplementalO2Score")
.HasColumnType("integer")
.HasColumnName("supplemental_o2_score");
b.Property<int>("SystolicBpScore")
.HasColumnType("integer")
.HasColumnName("systolic_bp_score");
b.Property<int>("TemperatureScore")
.HasColumnType("integer")
.HasColumnName("temperature_score");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.HasIndex("PatientId", "CalculatedAt");
b.ToTable("news2_scores", null, t =>
{
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
});
});
modelBuilder.Entity("Observation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("IdempotencyKey")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("source")
.HasDefaultValueSql("'MANUAL'");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("IdempotencyKey")
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
b.ToTable("observations", null, t =>
{
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
});
});
modelBuilder.Entity("Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("order_type");
b.Property<DateTimeOffset>("OrderedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("ordered_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("OrderedBy")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("ordered_by");
b.Property<string>("ResultSummary")
.HasColumnType("text")
.HasColumnName("result_summary");
b.Property<DateTimeOffset?>("ResultedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resulted_at");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("EncounterId", "OrderedAt");
b.HasIndex("Status", "OrderedAt")
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
b.ToTable("orders", null, t =>
{
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
});
});
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("topic");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasFilter("processed_at IS NULL");
b.ToTable("outbox_events", (string)null);
});
modelBuilder.Entity("Patient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Allergies")
.HasColumnType("text")
.HasColumnName("allergies");
b.Property<string>("BloodType")
.HasMaxLength(5)
.HasColumnType("character varying(5)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("emergency_contact_name");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("emergency_contact_phone");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("gender");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("active")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("Mrn")
.IsUnique();
b.ToTable("patients", (string)null);
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CheckType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("check_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.HasKey("Id");
b.HasIndex("EncounterId");
b.HasIndex("PatientId");
b.HasIndex("CheckType", "EncounterId")
.HasFilter("resolved_at IS NULL");
b.ToTable("reconciliation_alerts", null, t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("News2Score", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSuppressionWindowMinutes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "suppression_window_minutes",
table: "alert_thresholds",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "suppression_window_minutes",
table: "alert_thresholds");
}
}
}
@@ -55,6 +55,10 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnType("character varying(50)") .HasColumnType("character varying(50)")
.HasColumnName("observation_code"); .HasColumnName("observation_code");
b.Property<int?>("SuppressionWindowMinutes")
.HasColumnType("integer")
.HasColumnName("suppression_window_minutes");
b.Property<string>("Unit") b.Property<string>("Unit")
.IsRequired() .IsRequired()
.HasMaxLength(20) .HasMaxLength(20)
@@ -0,0 +1 @@
public record TrendHistoryEntry(decimal Value, DateTimeOffset RecordedAt);
@@ -0,0 +1,6 @@
public record TrendObservationEvent(
Guid EncounterId,
Guid PatientId,
string ObservationCode,
decimal Value,
DateTimeOffset RecordedAt);
@@ -0,0 +1,5 @@
public record TrendResult(
TrendOutcome Outcome,
string? ObservationCode = null,
decimal? RatePerMinute = null,
bool AlertCreated = false);
@@ -153,6 +153,16 @@ public class News2Detector
int totalScore, string riskLevel, int[] paramScores, int totalScore, string riskLevel, int[] paramScores,
CancellationToken ct) CancellationToken ct)
{ {
if (alertType == AlertType.News2Warning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
{
_logger.LogDebug("NEWS2_WARNING suppressed for encounter {Id}", encounterId);
return false;
}
}
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -36,6 +36,16 @@ public sealed class ClinicalMetrics
"escalations_total", "escalations_total",
"Total alert escalations processed through the DLQ escalation path."); "Total alert escalations processed through the DLQ escalation path.");
public readonly Counter TrendAlertsTotal = Metrics.CreateCounter(
"trend_alerts_total",
"Total RAPID_DETERIORATION alerts generated, labeled by observation code.",
labelNames: new[] { "observation_code" });
public readonly Counter AlertSuppressionsTotal = Metrics.CreateCounter(
"alert_suppressions_total",
"Total alert suppression windows set after acknowledgment.",
labelNames: new[] { "alert_type" });
// --- Histograms --- // --- Histograms ---
// Measures the full ingest transaction: Redis cache lookup + alert evaluation + // Measures the full ingest transaction: Redis cache lookup + alert evaluation +
@@ -57,6 +67,14 @@ public sealed class ClinicalMetrics
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 } Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
}); });
public readonly Histogram TrendAnalysisDuration = Metrics.CreateHistogram(
"trend_analysis_duration_seconds",
"Time to evaluate trend for one observation.",
new HistogramConfiguration
{
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
});
// --- Gauges (set by background collectors, not incremented inline) --- // --- Gauges (set by background collectors, not incremented inline) ---
// The most clinically significant panel. A non-zero value means a patient's // The most clinically significant panel. A non-zero value means a patient's
+9
View File
@@ -65,6 +65,12 @@ try
builder.Services.Configure<DataLakeOptions>( builder.Services.Configure<DataLakeOptions>(
builder.Configuration.GetSection(DataLakeOptions.Section)); builder.Configuration.GetSection(DataLakeOptions.Section));
builder.Services.Configure<TrendDetectionOptions>(
builder.Configuration.GetSection(TrendDetectionOptions.SectionName));
builder.Services.Configure<SuppressionOptions>(
builder.Configuration.GetSection(SuppressionOptions.SectionName));
builder.Services.AddScoped<IPatientService, PatientService>(); builder.Services.AddScoped<IPatientService, PatientService>();
builder.Services.AddScoped<IEncounterService, EncounterService>(); builder.Services.AddScoped<IEncounterService, EncounterService>();
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>(); builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
@@ -81,6 +87,8 @@ try
builder.Services.AddScoped<ReconciliationPublisher>(); builder.Services.AddScoped<ReconciliationPublisher>();
builder.Services.AddScoped<WarningEvaluator>(); builder.Services.AddScoped<WarningEvaluator>();
builder.Services.AddScoped<News2Detector>(); builder.Services.AddScoped<News2Detector>();
builder.Services.AddScoped<TrendDetector>();
builder.Services.AddSingleton<IAlertSuppressionService, AlertSuppressionService>();
builder.Services.AddHostedService<ThresholdCacheLoader>(); builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>(); builder.Services.AddHostedService<KafkaTopicProvisioner>();
@@ -100,6 +108,7 @@ try
builder.Services.AddHostedService<DataLakeWriterService>(); builder.Services.AddHostedService<DataLakeWriterService>();
builder.Services.AddHostedService<WarningAlertService>(); builder.Services.AddHostedService<WarningAlertService>();
builder.Services.AddHostedService<News2ScoringService>(); builder.Services.AddHostedService<News2ScoringService>();
builder.Services.AddHostedService<TrendAnalyzerService>();
builder.Services.AddControllers() builder.Services.AddControllers()
+39 -1
View File
@@ -1,11 +1,17 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
public class AlertService : IAlertService public class AlertService : IAlertService
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IServiceProvider _services;
public AlertService(AppDbContext db) => _db = db; public AlertService(AppDbContext db, IServiceProvider services)
{
_db = db;
_services = services;
}
public async Task<PagedResult<ClinicalAlert>> ListByEncounterAsync( public async Task<PagedResult<ClinicalAlert>> ListByEncounterAsync(
Guid encounterId, AlertStatus? status, int page, int pageSize) Guid encounterId, AlertStatus? status, int page, int pageSize)
@@ -100,10 +106,42 @@ public class AlertService : IAlertService
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}); });
if (alert.AlertType.IsSuppressible())
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
var options = _services.GetRequiredService<IOptions<SuppressionOptions>>().Value;
var windowMinutes = await ResolveSuppressionWindowMinutesAsync(
alert.AlertType, options.DefaultWindowMinutes);
await suppression.SetSuppressionAsync(
alert.EncounterId,
alert.AlertType,
TimeSpan.FromMinutes(windowMinutes));
}
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return alert; return alert;
} }
private async Task<int> ResolveSuppressionWindowMinutesAsync(
AlertType alertType, int defaultWindowMinutes)
{
if (alertType == AlertType.News2Warning)
return defaultWindowMinutes;
var observationCode = alertType.ObservationCodeForWarning();
if (observationCode is null)
return defaultWindowMinutes;
var overrideMinutes = await _db.AlertThresholds
.AsNoTracking()
.Where(t => t.ObservationCode == observationCode)
.Select(t => t.SuppressionWindowMinutes)
.FirstOrDefaultAsync();
return overrideMinutes ?? defaultWindowMinutes;
}
public async Task<ClinicalAlert> ResolveAsync(Guid id) public async Task<ClinicalAlert> ResolveAsync(Guid id)
{ {
var alert = await _db.ClinicalAlerts.FindAsync(id); var alert = await _db.ClinicalAlerts.FindAsync(id);
@@ -0,0 +1,31 @@
using StackExchange.Redis;
public class AlertSuppressionService : IAlertSuppressionService
{
private readonly IConnectionMultiplexer _redis;
private readonly ClinicalMetrics _metrics;
public AlertSuppressionService(IConnectionMultiplexer redis, ClinicalMetrics metrics)
{
_redis = redis;
_metrics = metrics;
}
public static string SuppressionKey(Guid encounterId, AlertType alertType) =>
$"suppress:{encounterId}:{alertType.ToDbString()}";
public async Task SetSuppressionAsync(
Guid encounterId, AlertType alertType, TimeSpan ttl, CancellationToken ct = default)
{
var cache = _redis.GetDatabase();
await cache.StringSetAsync(SuppressionKey(encounterId, alertType), "1", ttl);
_metrics.AlertSuppressionsTotal.WithLabels(alertType.ToDbString()).Inc();
}
public async Task<bool> IsSuppressedAsync(
Guid encounterId, AlertType alertType, CancellationToken ct = default)
{
var cache = _redis.GetDatabase();
return await cache.KeyExistsAsync(SuppressionKey(encounterId, alertType));
}
}
@@ -0,0 +1,5 @@
public interface IAlertSuppressionService
{
Task SetSuppressionAsync(Guid encounterId, AlertType alertType, TimeSpan ttl, CancellationToken ct = default);
Task<bool> IsSuppressedAsync(Guid encounterId, AlertType alertType, CancellationToken ct = default);
}
@@ -35,6 +35,15 @@ public class WarningEvaluator
// critical alerts are created synchronously by the ingest path. // critical alerts are created synchronously by the ingest path.
if (IsCriticalBreach(value, threshold)) return false; if (IsCriticalBreach(value, threshold)) return false;
var alertType = AlertTypeExtensions.WarningFor(observationCode);
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
{
_logger.LogDebug(
"Warning alert suppressed for encounter {Id} type {Type}", encounterId, alertType);
return false;
}
return await TryCreateWarningAlertAsync( return await TryCreateWarningAlertAsync(
observationId, encounterId, patientId, observationCode, value, threshold, ct); observationId, encounterId, patientId, observationCode, value, threshold, ct);
} }
@@ -0,0 +1,62 @@
using StackExchange.Redis;
public static class TrendCalculator
{
// NEWS2-relevant codes that support trend detection.
public static readonly IReadOnlyList<string> TrendCodes = new[]
{
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "TEMP_C", "SPO2"
};
public static bool IsTrendCode(string observationCode) =>
TrendCodes.Contains(observationCode);
public static string HistoryKey(Guid encounterId, string code) =>
$"trend:{encounterId}:{code}";
public static RedisKey[] AllHistoryKeys(Guid encounterId) =>
TrendCodes.Select(c => (RedisKey)HistoryKey(encounterId, c)).ToArray();
/// <summary>
/// Computes rate of change (units per minute) between the oldest and newest
/// entries within the window. Returns null if fewer than 2 entries or window exceeded.
/// </summary>
public static decimal? ComputeRatePerMinute(
IReadOnlyList<TrendHistoryEntry> entries,
int windowMinutes)
{
if (entries.Count < 2) return null;
var newest = entries[^1];
var oldest = entries[0];
var deltaMinutes = (newest.RecordedAt - oldest.RecordedAt).TotalMinutes;
if (deltaMinutes <= 0 || deltaMinutes > windowMinutes) return null;
return (newest.Value - oldest.Value) / (decimal)deltaMinutes;
}
/// <summary>
/// Returns true if the rate exceeds the configured threshold.
/// For SPO2 and SYSTOLIC_BP, checks negative rate (decline) as clinically significant.
/// </summary>
public static bool ExceedsThreshold(
string observationCode,
decimal ratePerMinute,
decimal thresholdPerMinute) =>
observationCode switch
{
"SPO2" or "SYSTOLIC_BP" => ratePerMinute <= -thresholdPerMinute,
_ => ratePerMinute >= thresholdPerMinute
};
public static string DescribeTrend(
string observationCode, decimal ratePerMinute, decimal currentValue) =>
observationCode switch
{
"SPO2" or "SYSTOLIC_BP" =>
$"Rapid decline: {observationCode} falling at {Math.Abs(ratePerMinute):F2}/min (current {currentValue})",
_ =>
$"Rapid rise: {observationCode} rising at {ratePerMinute:F2}/min (current {currentValue})"
};
}
+152
View File
@@ -0,0 +1,152 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Prometheus;
using StackExchange.Redis;
public class TrendDetector
{
private readonly IConnectionMultiplexer _redis;
private readonly IServiceProvider _services;
private readonly TrendDetectionOptions _options;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<TrendDetector> _logger;
public TrendDetector(
ILogger<TrendDetector> logger,
ClinicalMetrics metrics,
IServiceProvider services,
IConnectionMultiplexer redis,
IOptions<TrendDetectionOptions> options)
{
_logger = logger;
_metrics = metrics;
_services = services;
_redis = redis;
_options = options.Value;
}
public async Task<TrendResult> ProcessObservationAsync(
Guid encounterId,
Guid patientId,
string observationCode,
decimal value,
DateTimeOffset recordedAt,
CancellationToken ct = default)
{
if (!TrendCalculator.IsTrendCode(observationCode))
return new TrendResult(TrendOutcome.NotTrendCode);
using var timer = _metrics.TrendAnalysisDuration.NewTimer();
var cache = _redis.GetDatabase();
var key = TrendCalculator.HistoryKey(encounterId, observationCode);
// Append new entry to history list
var entry = new TrendHistoryEntry(value, recordedAt);
var historyJson = await cache.StringGetAsync(key);
var history = historyJson.HasValue
? JsonSerializer.Deserialize<List<TrendHistoryEntry>>(historyJson!) ?? new()
: new List<TrendHistoryEntry>();
history.Add(entry);
// Trim to max entries and evict entries outside window
var cutoff = recordedAt.AddMinutes(-_options.WindowMinutes);
history = history
.Where(e => e.RecordedAt >= cutoff)
.TakeLast(_options.MaxHistoryEntries)
.ToList();
await cache.StringSetAsync(
key,
JsonSerializer.Serialize(history),
TimeSpan.FromSeconds(_options.HistoryTtlSeconds));
if (history.Count < 2)
return new TrendResult(TrendOutcome.InsufficientHistory, observationCode);
var rate = TrendCalculator.ComputeRatePerMinute(history, _options.WindowMinutes);
if (rate is null)
return new TrendResult(TrendOutcome.InsufficientHistory, observationCode);
if (!_options.RateThresholdsPerMinute.TryGetValue(observationCode, out var threshold))
return new TrendResult(TrendOutcome.Stable, observationCode, rate);
if (!TrendCalculator.ExceedsThreshold(observationCode, rate.Value, threshold))
return new TrendResult(TrendOutcome.Stable, observationCode, rate);
var details = TrendCalculator.DescribeTrend(observationCode, rate.Value, value);
var created = await TryCreateAlertAsync(
encounterId, patientId, observationCode, details, rate.Value, ct);
return new TrendResult(
created ? TrendOutcome.RapidDeterioration : TrendOutcome.AlertAlreadyOpen,
observationCode, rate, created);
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
string observationCode, string details, decimal ratePerMinute,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await using var tx = await db.Database.BeginTransactionAsync(ct);
var alertId = Guid.NewGuid();
var triggeredAt = DateTimeOffset.UtcNow;
var fullDetails = $"{details} — velocity {ratePerMinute:F2}/min over {_options.WindowMinutes}min window.";
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
'RAPID_DETERIORATION', 'WARNING', {fullDetails}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = 'RAPID_DETERIORATION'
AND details LIKE {$"%{observationCode}%"}
AND status IN ('OPEN', 'ESCALATED')
)
""", ct);
if (affected == 0)
{
await tx.RollbackAsync(ct);
return false;
}
db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
{
alertId,
encounterId,
patientId,
alertType = "RAPID_DETERIORATION",
severity = "Warning",
details = fullDetails,
triggeredAt,
observationCode,
ratePerMinute,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_metrics.TrendAlertsTotal.WithLabels(observationCode).Inc();
_metrics.ClinicalAlertsTotal
.WithLabels("RAPID_DETERIORATION", "Warning").Inc();
return true;
}
}
+15
View File
@@ -78,5 +78,20 @@
"DataLake": { "DataLake": {
"FlushCount": 1000, "FlushCount": 1000,
"FlushIntervalSeconds": 300 "FlushIntervalSeconds": 300
},
"TrendDetection": {
"WindowMinutes": 30,
"MaxHistoryEntries": 10,
"HistoryTtlSeconds": 7200,
"RateThresholdsPerMinute": {
"HEART_RATE": 0.5,
"RESP_RATE": 0.3,
"SYSTOLIC_BP": 1.0,
"TEMP_C": 0.05,
"SPO2": 0.2
}
},
"AlertSuppression": {
"DefaultWindowMinutes": 30
} }
} }
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
COMPOSE_FILE="${COMPOSE_FILE:-${ROOT_DIR}/docker-compose.yml}"
BASE_URL="${BASE_URL:-http://localhost:5270}"
REDIS_PORT="${REDIS_PORT:-6382}"
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5436}"
PGDATABASE="${PGDATABASE:-vigilcare}"
PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-password}"
TEST_PROJECT="${TEST_PROJECT:-${ROOT_DIR}/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj}"
TEST_FILTER="${TEST_FILTER:-FullyQualifiedName~Trend|FullyQualifiedName~Suppression}"
FULL_TEST="${FULL_TEST:-0}"
TREND_CONSUMER_GROUP="${TREND_CONSUMER_GROUP:-trend-analyzer}"
WARNING_CONSUMER_GROUP="${WARNING_CONSUMER_GROUP:-warning-evaluator}"
TREND_WAIT_SECS="${TREND_WAIT_SECS:-45}"
WARNING_WAIT_SECS="${WARNING_WAIT_SECS:-45}"
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
TMP_FILES=()
cleanup() {
local f
for f in "${TMP_FILES[@]}"; do
rm -f "${f}" "${f}.status" 2>/dev/null || true
done
}
trap cleanup EXIT
need() {
command -v "$1" >/dev/null 2>&1 || {
echo "Missing dependency: $1"
exit 1
}
}
need curl
need jq
need dotnet
if ! command -v docker >/dev/null 2>&1 || [[ ! -f "${COMPOSE_FILE}" ]]; then
echo "Missing dependency: docker compose (${COMPOSE_FILE})"
exit 1
fi
compose() {
docker compose -f "${COMPOSE_FILE}" "$@"
}
kafka_exec() {
compose exec -T kafka "$@"
}
redis_cmd() {
if command -v redis-cli >/dev/null 2>&1; then
redis-cli -p "${REDIS_PORT}" "$@"
else
compose exec -T redis redis-cli "$@"
fi
}
psql_cmd() {
local sql="$1"
if command -v psql >/dev/null 2>&1; then
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
else
compose exec -T postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
fi
}
request() {
local method="$1"
local url="$2"
local body="${3:-}"
local tmp
tmp="$(mktemp)"
TMP_FILES+=("${tmp}")
local status
if [[ -n "${body}" ]]; then
status="$(curl -sS -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}" \
-H "Content-Type: application/json" -d "${body}")"
else
status="$(curl -sS -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}")"
fi
echo "${status}" > "${tmp}.status"
echo "${tmp}"
}
assert_status() {
local expected="$1"
local body_file="$2"
local status
status="$(<"${body_file}.status")"
if [[ "${status}" != "${expected}" ]]; then
echo "Expected HTTP ${expected}, got ${status}"
cat "${body_file}"
echo
exit 1
fi
}
consumer_group_lag() {
local group="$1"
kafka_exec /opt/kafka/bin/kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--describe \
--group "${group}" 2>/dev/null | \
awk 'NR > 1 && $1 != "" { sum += $6 } END { print sum + 0 }'
}
wait_for_consumer_lag_zero() {
local group="$1"
local max_secs="$2"
local elapsed=0
local lag="unknown"
while (( elapsed < max_secs )); do
lag="$(consumer_group_lag "${group}")"
if [[ "${lag}" == "0" ]]; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
echo "Consumer group ${group} lag did not reach zero within ${max_secs}s (lag=${lag})"
exit 1
}
ingest_hr() {
local encounter_id="$1"
local value="$2"
local recorded_at="${3:-${RECORDED_AT}}"
jq -nc \
--arg recordedAt "${recorded_at}" \
--argjson value "${value}" \
'{observations:[{"observationCode":"HEART_RATE","value":$value,"unit":"bpm","source":"DEVICE","recordedAt":$recordedAt}]}'
}
TOTAL_STEPS=10
echo "Phase 13 verification starting..."
echo "Repo root: ${ROOT_DIR}"
echo "API: ${BASE_URL}"
echo "[1/${TOTAL_STEPS}] Infrastructure preflight: API, PostgreSQL, Redis, Kafka, threshold cache"
api_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)"
[[ "${api_status}" == "200" ]] || { echo "API not ready (${api_status}) — run docker compose up -d and dotnet run"; exit 1; }
redis_cmd PING >/dev/null || { echo "Redis not reachable on port ${REDIS_PORT}"; exit 1; }
psql_cmd "SELECT 1" >/dev/null || { echo "PostgreSQL not reachable"; exit 1; }
kafka_exec /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null || {
echo "Kafka not ready"
exit 1
}
redis_cmd EXISTS "threshold:HEART_RATE" | grep -q '^1$' || {
echo "Missing Redis threshold:HEART_RATE — restart API to run ThresholdCacheLoader"
exit 1
}
echo "OK: infrastructure preflight passed"
echo "[2/${TOTAL_STEPS}] Register patient + open active encounter"
patient_payload="$(jq -nc \
--arg fn "Phase13" \
--arg ln "Verify${SCRIPT_RUN_ID}" \
'{firstName:$fn,lastName:$ln,dateOfBirth:"1985-06-01",gender:"M"}')"
patient_resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")"
assert_status "201" "${patient_resp}"
patient_id="$(jq -r '.data.id' "${patient_resp}")"
enc_payload='{"encounterType":"Inpatient","department":"ICU","attendingPhysician":"Dr. Phase13"}'
enc_resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload}")"
assert_status "201" "${enc_resp}"
encounter_id="$(jq -r '.data.id' "${enc_resp}")"
echo "OK: patient=${patient_id} encounter=${encounter_id}"
echo "[3/${TOTAL_STEPS}] Ingest HR 72, wait 5s, ingest HR 95 → assert RAPID_DETERIORATION"
baseline_at="$(date -u -d '10 minutes ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-10M +"%Y-%m-%dT%H:%M:%SZ")"
climb_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
hr72_payload="$(ingest_hr "${encounter_id}" 72 "${baseline_at}")"
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr72_payload}")"
assert_status "201" "${obs_resp}"
sleep 5
hr95_payload="$(ingest_hr "${encounter_id}" 95 "${climb_at}")"
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr95_payload}")"
assert_status "201" "${obs_resp}"
wait_for_consumer_lag_zero "${TREND_CONSUMER_GROUP}" "${TREND_WAIT_SECS}"
trend_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'RAPID_DETERIORATION'")"
[[ "${trend_count}" == "1" ]] || {
echo "Expected 1 RAPID_DETERIORATION alert, found ${trend_count}"
psql_cmd "SELECT alert_type, severity, details FROM clinical_alerts WHERE encounter_id = '${encounter_id}'" || true
exit 1
}
echo "OK: RAPID_DETERIORATION alert created"
echo "[4/${TOTAL_STEPS}] Re-ingest HR 95 → assert no duplicate trend alert"
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr95_payload}")"
assert_status "201" "${obs_resp}"
wait_for_consumer_lag_zero "${TREND_CONSUMER_GROUP}" "${TREND_WAIT_SECS}"
trend_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'RAPID_DETERIORATION'")"
[[ "${trend_count}" == "1" ]] || {
echo "Expected idempotent RAPID_DETERIORATION count=1, found ${trend_count}"
exit 1
}
echo "OK: no duplicate RAPID_DETERIORATION alert"
echo "[5/${TOTAL_STEPS}] Ingest HR 105 (warning range) → assert WARNING_HEART_RATE created"
hr105_payload="$(ingest_hr "${encounter_id}" 105)"
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr105_payload}")"
assert_status "201" "${obs_resp}"
wait_for_consumer_lag_zero "${WARNING_CONSUMER_GROUP}" "${WARNING_WAIT_SECS}"
warning_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'WARNING_HEART_RATE'")"
[[ "${warning_count}" == "1" ]] || {
echo "Expected 1 WARNING_HEART_RATE alert, found ${warning_count}"
psql_cmd "SELECT alert_type, severity, status FROM clinical_alerts WHERE encounter_id = '${encounter_id}'" || true
exit 1
}
echo "OK: WARNING_HEART_RATE alert created"
echo "[6/${TOTAL_STEPS}] Acknowledge warning alert → assert Redis suppress key exists"
alert_id="$(psql_cmd "SELECT id FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'WARNING_HEART_RATE' LIMIT 1")"
ack_resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_id}/acknowledge" \
'{"clinicianId":"nurse-1","note":"monitoring"}')"
assert_status "200" "${ack_resp}"
suppress_key="suppress:${encounter_id}:WARNING_HEART_RATE"
redis_cmd EXISTS "${suppress_key}" | grep -q '^1$' || {
echo "Expected Redis key ${suppress_key}"
redis_cmd KEYS "suppress:${encounter_id}:*" || true
exit 1
}
echo "OK: suppression key ${suppress_key} exists"
echo "[7/${TOTAL_STEPS}] Re-ingest HR 105 → assert no new WARNING_HEART_RATE (suppressed)"
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr105_payload}")"
assert_status "201" "${obs_resp}"
wait_for_consumer_lag_zero "${WARNING_CONSUMER_GROUP}" "${WARNING_WAIT_SECS}"
warning_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'WARNING_HEART_RATE'")"
[[ "${warning_count}" == "1" ]] || {
echo "Expected suppressed WARNING_HEART_RATE count=1, found ${warning_count}"
exit 1
}
echo "OK: no new WARNING_HEART_RATE while suppressed"
echo "[8/${TOTAL_STEPS}] Ingest HR 160 (critical) → assert CRITICAL_HEART_RATE fires despite suppression"
hr160_payload="$(ingest_hr "${encounter_id}" 160)"
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${hr160_payload}")"
assert_status "201" "${obs_resp}"
critical_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'CRITICAL_HEART_RATE'")"
[[ "${critical_count}" == "1" ]] || {
echo "Expected 1 CRITICAL_HEART_RATE alert despite active WARNING suppression, found ${critical_count}"
psql_cmd "SELECT alert_type, severity, status FROM clinical_alerts WHERE encounter_id = '${encounter_id}'" || true
exit 1
}
echo "OK: CRITICAL_HEART_RATE created despite suppression window"
echo "[9/${TOTAL_STEPS}] Consumer lag = 0 for trend-analyzer and warning-evaluator"
wait_for_consumer_lag_zero "${TREND_CONSUMER_GROUP}" "${TREND_WAIT_SECS}"
wait_for_consumer_lag_zero "${WARNING_CONSUMER_GROUP}" "${WARNING_WAIT_SECS}"
echo "OK: trend-analyzer and warning-evaluator lag = 0"
echo "[10/${TOTAL_STEPS}] Run Phase 13 unit/integration tests"
dotnet test "${TEST_PROJECT}" --filter "${TEST_FILTER}"
if [[ "${FULL_TEST}" == "1" ]]; then
echo "Running full test suite (FULL_TEST=1)"
dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.sln" 2>/dev/null || dotnet test "${ROOT_DIR}"
fi
echo
echo "Phase 13 verification checks passed."
echo "Encounter id: ${encounter_id}"