Full SOFA Score: Data Layer + Scoring Engine

Glasgow Coma Scale: Data Layer + Scoring Engine
This commit is contained in:
voltsrage
2026-06-21 01:09:50 +08:00
parent 78c043e4d3
commit 93ea473d2b
62 changed files with 7133 additions and 72 deletions
+122 -13
View File
@@ -2,11 +2,11 @@
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation. A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
**Implementation status:** Nineteen planned phases are complete — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion (patient demographics, encounter enrichment, 12 observation codes), warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA scoring with sepsis bundle compliance tracking, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard** (virtual ward, patient detail, alert center, vital sign charts, NEWS2 history, replay controls, alert reasoning), and **clinician feedback mode** (structured alert ratings, feedback summary, JSON/CSV export). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses). **Implementation status:** Twenty-one planned phases are complete through Phase 26 — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA scoring with sepsis bundle compliance tracking, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, and **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**. See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
## Domain Model — How It Maps to a Real Clinical System ## Domain Model — How It Maps to a Real Clinical System
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, SIRS/sepsis, qSOFA) aggregate multiple vitals into acuity scores. When sepsis is suspected (via SIRS or qSOFA), a four-element treatment bundle is automatically created with a one-hour compliance deadline. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently. In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, SIRS/sepsis, qSOFA) aggregate multiple vitals and labs into acuity scores. When sepsis is suspected (via SIRS, qSOFA, or SOFA delta ≥ 2 from baseline), treatment bundles and sepsis alerts are triggered. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently.
``` ```
Patient ─────────────────────────── one patient = one MRN, many lifetime encounters Patient ─────────────────────────── one patient = one MRN, many lifetime encounters
@@ -60,11 +60,13 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Clinical Order Management** — `POST /encounters/:id/orders` create; `GET /encounters/:id/orders` list with optional status filter; `GET /orders/:id` detail; `PATCH /orders/:id/status` status transitions; `PATCH /orders/:id/result` record result and transition to `Resulted`; status machine enforces `Pending → InProgress → Resulted` and terminal `Cancelled` - **Clinical Order Management** — `POST /encounters/:id/orders` create; `GET /encounters/:id/orders` list with optional status filter; `GET /orders/:id` detail; `PATCH /orders/:id/status` status transitions; `PATCH /orders/:id/result` record result and transition to `Resulted`; status machine enforces `Pending → InProgress → Resulted` and terminal `Cancelled`
- **Clinical Alert Lifecycle** — paginated alert list per encounter and globally; acknowledge with clinician ID and optional note; resolve (must be acknowledged first); global list filterable by status, severity, and department - **Clinical Alert Lifecycle** — paginated alert list per encounter and globally; acknowledge with clinician ID and optional note; resolve (must be acknowledged first); global list filterable by status, severity, and department
- **Outbox Relay** — `IHostedService` polling every 500ms; reads unprocessed outbox rows, publishes to Kafka, marks processed; partitioned by `encounterId` for per-encounter ordering - **Outbox Relay** — `IHostedService` polling every 500ms; reads unprocessed outbox rows, publishes to Kafka, marks processed; partitioned by `encounterId` for per-encounter ordering
- **Kafka Pipeline** — three topics (`observation.recorded`, `alert.generated`, `encounter.status.changed`) with six partitions each; KRaft mode, no Zookeeper; `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner` - **Kafka Pipeline** — core topics (`observation.recorded`, `alert.generated`, `encounter.status.changed`, `gcs.scored`, sepsis bundle topics) with six partitions each; KRaft mode, no Zookeeper; `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner` (including `gcs.scored` for SOFA CNS re-scoring)
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, increments `openAlertCount` on alert events, stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated, and projects `sepsisBundleStatus` / `sepsisBundleElementsCompleted` / `sepsisBundleDeadlineAt` from sepsis bundle events; 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, stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated, and projects `sepsisBundleStatus` / `sepsisBundleElementsCompleted` / `sepsisBundleDeadlineAt` from sepsis bundle events; 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 both SIRS and qSOFA criteria per encounter using Redis keys with a 30-minute TTL sliding window; SIRS evaluates temperature, heart rate, respiratory rate, and WBC; qSOFA evaluates respiratory rate ≥ 22, systolic BP ≤ 100, and altered mentation (AVPU ≥ 1); on ≥ 2 active criteria in either system, inserts a `SEPSIS_WARNING` or `QSOFA_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`); both alert types trigger automatic sepsis bundle creation via `SepsisAlertHandler` - **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates both SIRS and qSOFA criteria per encounter using Redis keys with a 30-minute TTL sliding window; SIRS evaluates temperature, heart rate, respiratory rate, and WBC; qSOFA evaluates respiratory rate ≥ 22, systolic BP ≤ 100, and altered mentation (AVPU ≥ 1); on ≥ 2 active criteria in either system, inserts a `SEPSIS_WARNING` or `QSOFA_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`); both alert types trigger automatic sepsis bundle creation via `SepsisAlertHandler`
- **Sepsis Bundle Compliance** — `SepsisBundleService` creates a four-element treatment bundle (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation) when a SIRS or qSOFA alert fires; each element maps to an auto-created clinical order (`orderedBy: sepsis-bundle-engine`); one-hour compliance deadline from recognition; `OrderService.RecordResult` calls back to `OnOrderResultedAsync` to mark elements complete; final element completion sets bundle to `COMPLIANT` or `NON_COMPLIANT`; `SepsisBundleMonitorService` scans every 5 minutes for overdue in-progress bundles past their deadline and marks them `NON_COMPLIANT`; idempotent — only one in-progress bundle per encounter; `GET /encounters/:id/sepsis-bundle/current` and `GET /sepsis-bundles/:id` expose bundle state; Kafka topics `sepsis.bundle.created` / `sepsis.bundle.updated`; Prometheus `qsofa_detections_total` and `sepsis_bundle_compliance_total` - **Sepsis Bundle Compliance** — `SepsisBundleService` creates a four-element treatment bundle (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation) when a SIRS or qSOFA alert fires; each element maps to an auto-created clinical order (`orderedBy: sepsis-bundle-engine`); one-hour compliance deadline from recognition; `OrderService.RecordResult` calls back to `OnOrderResultedAsync` to mark elements complete; final element completion sets bundle to `COMPLIANT` or `NON_COMPLIANT`; `SepsisBundleMonitorService` scans every 5 minutes for overdue in-progress bundles past their deadline and marks them `NON_COMPLIANT`; idempotent — only one in-progress bundle per encounter; `GET /encounters/:id/sepsis-bundle/current` and `GET /sepsis-bundles/:id` expose bundle state; Kafka topics `sepsis.bundle.created` / `sepsis.bundle.updated`; Prometheus `qsofa_detections_total` and `sepsis_bundle_compliance_total`
- **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; consciousness resolves GCS-first with AVPU fallback; `GET /encounters/:id/news2/current` and `/history` expose score history; Prometheus `news2_scores_total` and `news2_scoring_duration_seconds`
- **Glasgow Coma Scale (GCS) Scoring** — `GcsScoringService` Kafka consumer (`gcs-scoring`) tracks three components (`GCS_EYE`, `GCS_VERBAL`, `GCS_MOTOR`) in Redis; when all three are present, computes total score and classification (`MILD` / `MODERATE` / `SEVERE`), persists to `gcs_scores`, creates `GCS_CRITICAL` (total ≤ 8) or `GCS_WARNING` (912) alerts idempotently, and publishes `gcs.scored` via outbox for downstream SOFA CNS re-scoring; feeds NEWS2 consciousness and qSOFA altered mentation; `GET /encounters/:id/gcs` exposes the latest score; Prometheus `gcs_scores_total`
- **SOFA Organ-Dysfunction Scoring** — `SofaScoringService` Kafka consumer (`sofa-scoring`) subscribes to `observation.recorded` and `gcs.scored`; scores six organ systems (respiratory, coagulation, liver, cardiovascular, CNS, renal) from Redis lab cache with carry-forward staleness, MAP derivation, SpO₂/FiO₂ fallback, and vasopressor detection from `MedicationAdministration`; persists to `sofa_scores` with baseline tracking (≥ 4 populated organ systems) and delta-from-baseline; delta ≥ 2 creates `SOFA_SEPSIS` (CRITICAL), delta = 1 creates `SOFA_WARNING`; skips stale Kafka events when the encounter row no longer exists; `GET /encounters/:id/sofa` and `/sofa/history` expose scores; Prometheus `sofa_scores_total` and `sofa_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` - **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` - **Alert Suppression Windows** — acknowledging a suppressible alert (`WARNING_*`, `NEWS2_WARNING`) sets a Redis key `suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min from `AlertSuppression` config; optional per-code override via `alert_thresholds.suppression_window_minutes`); `WarningEvaluator` and `News2Detector` check suppression before creating new warning alerts; critical alerts (`CRITICAL_*`, `NEWS2_EMERGENCY`, `SEPSIS_WARNING`, `RAPID_DETERIORATION`) are never suppressed; observations and NEWS2 scores continue to persist during suppression; Prometheus `alert_suppressions_total`
- **Medication Administration** — `POST /encounters/:id/medications` records drug administrations (name, dose, route, timestamp, administered-by); `GET /encounters/:id/medications` lists with optional `since` filter; `GET /medications/:id` detail; active-encounter guard; FluentValidation on request DTOs - **Medication Administration** — `POST /encounters/:id/medications` records drug administrations (name, dose, route, timestamp, administered-by); `GET /encounters/:id/medications` lists with optional `since` filter; `GET /medications/:id` detail; active-encounter guard; FluentValidation on request DTOs
@@ -78,7 +80,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **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`; fifteen 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`; application metric families via `ClinicalMetrics` (including GCS and SOFA scoring) 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)
--- ---
@@ -105,6 +107,8 @@ IHostedServices (background):
SepsisEngineService → Kafka → Redis SIRS + qSOFA state → PostgreSQL alert → SepsisAlertHandler → SepsisBundleService (consumer group: sepsis-engine) SepsisEngineService → Kafka → Redis SIRS + qSOFA state → PostgreSQL alert → SepsisAlertHandler → SepsisBundleService (consumer group: sepsis-engine)
WarningAlertService → Kafka → WarningEvaluator (+ MedicationCorrelationHelper) → PostgreSQL WARNING alert (consumer group: warning-evaluator) WarningAlertService → Kafka → WarningEvaluator (+ MedicationCorrelationHelper) → PostgreSQL WARNING alert (consumer group: warning-evaluator)
News2ScoringService → Kafka → News2Detector (+ MedicationCorrelationHelper) → Redis NEWS2 state → PostgreSQL score + alert (consumer group: news2-scoring) News2ScoringService → Kafka → News2Detector (+ MedicationCorrelationHelper) → Redis NEWS2 state → PostgreSQL score + alert (consumer group: news2-scoring)
GcsScoringService → Kafka → GcsDetector → Redis GCS components → PostgreSQL gcs_scores + gcs.scored outbox (consumer group: gcs-scoring)
SofaScoringService → Kafka (observation.recorded + gcs.scored) → SofaDetector → Redis SOFA lab cache → PostgreSQL sofa_scores + delta alerts (consumer group: sofa-scoring)
TrendAnalyzerService → Kafka → TrendDetector → Redis trend history → PostgreSQL RAPID_DETERIORATION alert (consumer group: trend-analyzer) 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 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)
@@ -162,6 +166,8 @@ VigilCareClinicalAPI/
│ ├── AlertsController.cs # Alert list (global + per-encounter), acknowledge, resolve │ ├── AlertsController.cs # Alert list (global + per-encounter), acknowledge, resolve
│ ├── OrdersController.cs # Order create, list, get, status transition, record result │ ├── OrdersController.cs # Order create, list, get, status transition, record result
│ ├── News2Controller.cs # Current NEWS2 score and cursor-paginated history │ ├── News2Controller.cs # Current NEWS2 score and cursor-paginated history
│ ├── GcsController.cs # Latest GCS score per encounter
│ ├── SofaController.cs # Current SOFA score and cursor-paginated history
│ ├── SepsisBundlesController.cs # Current bundle per encounter, bundle detail by ID │ ├── SepsisBundlesController.cs # Current bundle per encounter, bundle detail by ID
│ └── AnalyticsController.cs # Elasticsearch-backed patient search, trend, alert summary, population │ └── AnalyticsController.cs # Elasticsearch-backed patient search, trend, alert summary, population
├── Domains/ ├── Domains/
@@ -173,6 +179,8 @@ VigilCareClinicalAPI/
│ │ ├── ClinicalAlert.cs # open → acknowledged → resolved / escalated │ │ ├── ClinicalAlert.cs # open → acknowledged → resolved / escalated
│ │ ├── Order.cs │ │ ├── Order.cs
│ │ ├── News2Score.cs # Composite score with seven component scores + risk level │ │ ├── News2Score.cs # Composite score with seven component scores + risk level
│ │ ├── GcsScore.cs # Eye/verbal/motor components, total, classification
│ │ ├── SofaScore.cs # Six organ-system scores, baseline flag, delta, staleness JSON
│ │ ├── OutboxEvent.cs # topic + payload JSONB + processed_at │ │ ├── OutboxEvent.cs # topic + payload JSONB + processed_at
│ │ ├── ReconciliationAlert.cs │ │ ├── ReconciliationAlert.cs
│ │ ├── SepsisBundle.cs # Four-element treatment bundle with 1-hour compliance deadline │ │ ├── SepsisBundle.cs # Four-element treatment bundle with 1-hour compliance deadline
@@ -205,6 +213,8 @@ VigilCareClinicalAPI/
│ ├── AlertSuppressionService.cs # Redis suppress:{enc}:{type} TTL keys │ ├── AlertSuppressionService.cs # Redis suppress:{enc}:{type} TTL keys
│ ├── OrderService.cs # Order lifecycle; status machine; calls SepsisBundleService.OnOrderResultedAsync on result │ ├── OrderService.cs # Order lifecycle; status machine; calls SepsisBundleService.OnOrderResultedAsync on result
│ ├── News2Service.cs # Current score + cursor-paginated history from PostgreSQL │ ├── News2Service.cs # Current score + cursor-paginated history from PostgreSQL
│ ├── GcsService.cs # Latest GCS score from PostgreSQL
│ ├── SofaService.cs # Current, baseline, and history SOFA scores
│ ├── SepsisBundleService.cs # Bundle creation, element completion, compliance evaluation │ ├── SepsisBundleService.cs # Bundle creation, element completion, compliance evaluation
│ ├── MedicationService.cs # Medication CRUD; GetRecentForEncounterAsync for correlation │ ├── MedicationService.cs # Medication CRUD; GetRecentForEncounterAsync for correlation
│ ├── QsofaService.cs # Redis-backed qSOFA criteria count for API/dashboard │ ├── QsofaService.cs # Redis-backed qSOFA criteria count for API/dashboard
@@ -219,7 +229,7 @@ VigilCareClinicalAPI/
├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, CreateMedicationAdministration, … ├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, CreateMedicationAdministration, …
├── Observability/ ├── Observability/
│ └── Metrics/ │ └── Metrics/
│ └── ClinicalMetrics.cs # Fifteen Prometheus metric families (counters, histograms, gauges) │ └── ClinicalMetrics.cs # 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
@@ -234,6 +244,8 @@ 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
│ ├── GcsScoringService.cs # consumer group: gcs-scoring; observation.recorded → GCS score + alert
│ ├── SofaScoringService.cs # consumer group: sofa-scoring; observation.recorded + gcs.scored → SOFA score + delta alerts
│ ├── TrendAnalyzerService.cs # consumer group: trend-analyzer; observation.recorded → RAPID_DETERIORATION alert │ ├── TrendAnalyzerService.cs # consumer group: trend-analyzer; observation.recorded → RAPID_DETERIORATION alert
│ ├── SepsisBundleMonitorService.cs # Polls every 5 min; marks overdue in-progress bundles NON_COMPLIANT │ ├── SepsisBundleMonitorService.cs # Polls every 5 min; marks overdue in-progress bundles NON_COMPLIANT
│ ├── Notifications/ │ ├── Notifications/
@@ -262,6 +274,15 @@ VigilCareClinicalAPI/
├── News2/ ├── News2/
│ ├── News2Calculator.cs # Pure static NEWS2 scoring tables (no I/O) │ ├── News2Calculator.cs # Pure static NEWS2 scoring tables (no I/O)
│ └── News2Detector.cs # Redis parameter state, score persistence, alert creation │ └── News2Detector.cs # Redis parameter state, score persistence, alert creation
├── Gcs/
│ ├── GcsCalculator.cs # GCS total, classification, NEWS2/qSOFA/SOFA mappings
│ └── GcsDetector.cs # Redis component state, score persistence, gcs.scored outbox
├── Sofa/
│ ├── SofaCalculator.cs # Six organ-system SOFA scoring (04 each)
│ ├── SofaDetector.cs # Lab cache compose, baseline/delta, alert creation
│ ├── SofaLabCache.cs # Redis carry-forward with staleness classification
│ └── SofaVasopressorResolver.cs # Vasopressor dose from MedicationAdministration + Redis cache
├── Services/MapCalculator.cs # MAP from systolic + diastolic BP
├── Elasticsearch/Documents/ ├── Elasticsearch/Documents/
│ ├── PatientEncounterDocument.cs │ ├── PatientEncounterDocument.cs
│ ├── ObservationDocument.cs │ ├── ObservationDocument.cs
@@ -332,7 +353,9 @@ tests/
├── MedicationCorrelationTests.cs # End-to-end warning/NEWS2 annotation with medication context ├── MedicationCorrelationTests.cs # End-to-end warning/NEWS2 annotation with medication context
├── MedicationValidationTests.cs # FluentValidation 400 on invalid medication requests ├── MedicationValidationTests.cs # FluentValidation 400 on invalid medication requests
├── EncountersListTests.cs # Ward encounter list filters and summary fields ├── EncountersListTests.cs # Ward encounter list filters and summary fields
── QsofaCurrentTests.cs # qSOFA current API — Redis state, criteria breakdown ── QsofaCurrentTests.cs # qSOFA current API — Redis state, criteria breakdown
├── GcsScoringTests.cs # GCS component scoring, alerts, NEWS2/qSOFA integration paths
└── SofaScoringTests.cs # SOFA organ scores, baseline, delta alerts, carry-forward, vasopressors
VigilCare.Simulator/ # Phase 16 — console replay simulator (HTTP-only, no direct DB/Kafka) VigilCare.Simulator/ # Phase 16 — console replay simulator (HTTP-only, no direct DB/Kafka)
├── Program.cs # CLI: replay, replay-all, validate, dry-run ├── Program.cs # CLI: replay, replay-all, validate, dry-run
@@ -368,7 +391,9 @@ scripts/
├── 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 ├── run-phase13-verification.sh # Phase 13 — trend detection, alert suppression, consumer lag, integration tests
├── run-phase14-verification.sh # Phase 14 — qSOFA, sepsis bundle compliance, integration tests ├── run-phase14-verification.sh # Phase 14 — qSOFA, sepsis bundle compliance, integration tests
── run-phase15-verification.sh # Phase 15 — medication administration + correlation annotations ── run-phase15-verification.sh # Phase 15 — medication administration + correlation annotations
├── run-phase25-verification.sh # Phase 25 — GCS scoring integration tests + manual API checks
└── run-phase26-verification.sh # Phase 26 — SOFA scoring integration tests + baseline/delta API checks
docs/ docs/
├── plans/ # Phase implementation and verification guides ├── plans/ # Phase implementation and verification guides
@@ -504,7 +529,7 @@ On startup the application:
3. Pre-loads all thresholds into Redis 3. Pre-loads all thresholds into Redis
4. Provisions Kafka topics and Elasticsearch indices 4. Provisions Kafka topics and Elasticsearch indices
5. Declares the RabbitMQ exchange and queue topology 5. Declares the RabbitMQ exchange and queue topology
6. Starts all background consumers (outbox relay, ES indexer, sepsis engine with SIRS + qSOFA, warning evaluator, NEWS2 scoring, trend analyzer, notification workers, data lake writer, reconciliation scheduler, sepsis bundle monitor) 6. Starts all background consumers (outbox relay, ES indexer, sepsis engine with SIRS + qSOFA, warning evaluator, NEWS2 scoring, GCS scoring, SOFA scoring, trend analyzer, notification workers, data lake writer, reconciliation scheduler, sepsis bundle monitor)
7. Starts Prometheus metric collectors (unacknowledged alerts, outbox pending, Kafka consumer lag) 7. Starts Prometheus metric collectors (unacknowledged alerts, outbox pending, Kafka consumer lag)
Swagger UI is available at `http://localhost:5270/swagger` in Development (API binds to `0.0.0.0:5270` per `launchSettings.json`). Swagger UI is available at `http://localhost:5270/swagger` in Development (API binds to `0.0.0.0:5270` per `launchSettings.json`).
@@ -569,6 +594,8 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
| `MedicationValidationTests` | 15 | FluentValidation 400 on empty drug name, zero dose, future `administeredAt` | | `MedicationValidationTests` | 15 | FluentValidation 400 on empty drug name, zero dose, future `administeredAt` |
| `EncountersListTests` | — | Ward encounter list — status/department filters, summary fields | | `EncountersListTests` | — | Ward encounter list — status/department filters, summary fields |
| `QsofaCurrentTests` | — | `GET /qsofa/current` — criteria count and breakdown from Redis | | `QsofaCurrentTests` | — | `GET /qsofa/current` — criteria count and breakdown from Redis |
| `GcsScoringTests` | 25 | GCS component scoring, classification, alerts, CNS integration with SOFA |
| `SofaScoringTests` | 26 | SOFA organ scores, baseline eligibility, delta alerts, carry-forward, vasopressors |
### Verification Scripts ### Verification Scripts
@@ -585,6 +612,22 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-phase15-verification.sh # Medication administration + correlation annotation pipeline ./scripts/run-phase15-verification.sh # Medication administration + correlation annotation pipeline
``` ```
Phase 25 — GCS scoring (requires running API + Docker Compose; set an active encounter UUID):
```bash
export ENCOUNTER_ID=$(docker compose exec -T postgres psql -U postgres -d vigilcare -t -A \
-c "SELECT id FROM encounters WHERE status = 'ACTIVE' LIMIT 1;")
./scripts/run-phase25-verification.sh
```
Phase 26 — SOFA scoring (same prerequisites; script polls for async Kafka scoring):
```bash
export ENCOUNTER_ID=$(docker compose exec -T postgres psql -U postgres -d vigilcare -t -A \
-c "SELECT id FROM encounters WHERE status = 'ACTIVE' LIMIT 1;")
./scripts/run-phase26-verification.sh
```
Phase 13 unit/integration tests only: Phase 13 unit/integration tests only:
```bash ```bash
@@ -627,7 +670,7 @@ See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manua
## Prometheus Metrics ## Prometheus Metrics
`GET /metrics` exposes fifteen 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 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 |
|---|---|---|---| |---|---|---|---|
@@ -639,6 +682,9 @@ See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manua
| `sepsis_bundle_compliance_total` | Counter | `status` | `SepsisBundleService` — on bundle completion (`COMPLIANT`, `NON_COMPLIANT`) | | `sepsis_bundle_compliance_total` | Counter | `status` | `SepsisBundleService` — on bundle completion (`COMPLIANT`, `NON_COMPLIANT`) |
| `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 |
| `gcs_scores_total` | Counter | `classification` | `GcsDetector` — on each persisted score (`MILD`, `MODERATE`, `SEVERE`) |
| `sofa_scores_total` | Counter | `has_delta_alert` | `SofaDetector` — on each persisted score (`true` / `false`) |
| `sofa_scoring_duration_seconds` | Histogram | — | `SofaDetector` — full SOFA compose + persist |
| `trend_alerts_total` | Counter | `observation_code` | `TrendDetector` — on each `RAPID_DETERIORATION` alert created | | `trend_alerts_total` | Counter | `observation_code` | `TrendDetector` — on each `RAPID_DETERIORATION` alert created |
| `trend_analysis_duration_seconds` | Histogram | — | `TrendDetector` — per-observation trend evaluation | | `trend_analysis_duration_seconds` | Histogram | — | `TrendDetector` — per-observation trend evaluation |
| `alert_suppressions_total` | Counter | `alert_type` | `AlertSuppressionService` — on each suppression window set after acknowledge | | `alert_suppressions_total` | Counter | `alert_type` | `AlertSuppressionService` — on each suppression window set after acknowledge |
@@ -915,6 +961,27 @@ The `population` query uses Elasticsearch's numeric range aggregation engine —
Scores are computed asynchronously by `News2ScoringService` after observations are ingested — allow a few seconds for the Kafka consumer to process all seven parameters before querying. Scores are computed asynchronously by `News2ScoringService` after observations are ingested — allow a few seconds for the Kafka consumer to process all seven parameters before querying.
### GCS (Glasgow Coma Scale)
| Method | Path | Description |
|---|---|---|
| GET | `/encounters/{id}/gcs` | Latest GCS score for an encounter (404 if none computed) |
**Response** includes `eyeScore`, `verbalScore`, `motorScore`, `totalScore` (315), `classification` (`MILD`, `MODERATE`, `SEVERE`), and `calculatedAt`.
All three components (`GCS_EYE`, `GCS_VERBAL`, `GCS_MOTOR`) must be recorded before a score is computed. Scores are asynchronous via `GcsScoringService`. A completed GCS score publishes `gcs.scored` to Kafka (via outbox) for SOFA CNS re-scoring.
### SOFA (Sequential Organ Failure Assessment)
| Method | Path | Description |
|---|---|---|
| GET | `/encounters/{id}/sofa` | Latest SOFA score for an encounter (404 if none computed) |
| GET | `/encounters/{id}/sofa/history` | Cursor-paginated score history |
**Response** includes `totalScore`, six component scores (`respiratoryScore` … `renalScore`), `isBaseline`, `deltaFromBaseline`, optional `staleness` metadata, and `calculatedAt`.
SOFA scores are computed asynchronously by `SofaScoringService` from SOFA-related observation codes (`PAO2_MMHG`, `FIO2_PCT`, `PLATELET_K_UL`, `BILIRUBIN_MG_DL`, `CREATININE_MG_DL`, `URINE_OUTPUT_ML_H`, vitals, `SPO2`, vasopressors) and from `gcs.scored` events (CNS organ system). Baseline is established when ≥ 4 of 6 organ systems have available data. Delta ≥ 2 from baseline creates a `SOFA_SEPSIS` alert; delta = 1 creates `SOFA_WARNING`. Poll `/sofa/history` to confirm baseline — the latest score may have `isBaseline: false` after subsequent observations.
### Sepsis Bundles ### Sepsis Bundles
| Method | Path | Description | | Method | Path | Description |
@@ -1057,6 +1124,43 @@ calculatedAt DateTimeOffset
Indexes: `(encounter_id, calculated_at DESC)`, `(patient_id, calculated_at DESC)` Indexes: `(encounter_id, calculated_at DESC)`, `(patient_id, calculated_at DESC)`
### GcsScore
```
id Guid PK
encounterId Guid FK → Encounter
patientId Guid FK → Patient
eyeScore int 14
verbalScore int 15
motorScore int 16
totalScore int 315
classification string MILD | MODERATE | SEVERE
calculatedAt DateTimeOffset
```
Indexes: `(encounter_id, calculated_at DESC)`
### SofaScore
```
id Guid PK
encounterId Guid FK → Encounter
patientId Guid FK → Patient
totalScore int sum of six components (024)
respiratoryScore int 04
coagulationScore int 04
liverScore int 04
cardiovascularScore int 04
cnsScore int 04
renalScore int 04
isBaseline bool true for admission baseline row
deltaFromBaseline int? current total minus baseline total
stalenessFlags jsonb? stale/missing components, SpO2 fallback flag
calculatedAt DateTimeOffset
```
Indexes: `(encounter_id, calculated_at DESC)`, partial `(encounter_id) WHERE is_baseline = true`
### Order ### Order
``` ```
@@ -1229,13 +1333,14 @@ 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`, `trend-analyzer`, `data-lake-writer` | | `observation.recorded` | `encounterId` | `es-indexer`, `sepsis-engine`, `warning-evaluator`, `news2-scoring`, `gcs-scoring`, `sofa-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` |
| `gcs.scored` | `encounterId` | `sofa-scoring` |
| `sepsis.bundle.created` | `encounterId` | `es-indexer` | | `sepsis.bundle.created` | `encounterId` | `es-indexer` |
| `sepsis.bundle.updated` | `encounterId` | `es-indexer` | | `sepsis.bundle.updated` | `encounterId` | `es-indexer` |
All topics use 6 partitions. `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner` to guarantee correct partition count. All topics use 6 partitions. `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics (including `gcs.scored`) are provisioned explicitly by `KafkaTopicProvisioner` on API startup.
**`alert.generated` payload (minimum fields for downstream consumers):** **`alert.generated` payload (minimum fields for downstream consumers):**
@@ -1413,7 +1518,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
## Implemented Phases ## Implemented Phases
Nineteen phases from the project roadmap are implemented and verified. Integration tests (`dotnet test` — 116 test methods) and per-phase verification scripts cover Phases 815. Phases 1719 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`). Nineteen core phases from the project roadmap are implemented and verified, plus **Phase 25 (GCS)** and **Phase 26 (SOFA)**. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 815, 25, and 26. Phases 1719 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
| Phase | Feature | Status | | Phase | Feature | Status |
|---|---|---| |---|---|---|
@@ -1436,7 +1541,11 @@ Nineteen phases from the project roadmap are implemented and verified. Integrati
| 17 | Ward dashboard shell — Vue 3 + Vite + Pinia + Tailwind; virtual ward table (NEWS2-sorted, department filter); patient detail (vitals, scores, alerts, orders, sepsis bundle); alert center (global acknowledge/resolve); API polling; CORS-backed `GET /encounters` ward list | Done | | 17 | Ward dashboard shell — Vue 3 + Vite + Pinia + Tailwind; virtual ward table (NEWS2-sorted, department filter); patient detail (vitals, scores, alerts, orders, sepsis bundle); alert center (global acknowledge/resolve); API polling; CORS-backed `GET /encounters` ward list | Done |
| 18 | Clinical review mode — Chart.js vital sign trends (5 charts), NEWS2 history chart, local replay controls, alert reasoning panel, medication context on alerts; `fetchNews2History` / `fetchMedications`; Vitest composable and component tests; `docs/dashboard-guide.md` | Done | | 18 | Clinical review mode — Chart.js vital sign trends (5 charts), NEWS2 history chart, local replay controls, alert reasoning panel, medication context on alerts; `fetchNews2History` / `fetchMedications`; Vitest composable and component tests; `docs/dashboard-guide.md` | Done |
| 19 | Clinician feedback mode — six rating buttons per alert, optional notes, Feedback Summary with aggregate stats, JSON/CSV export, localStorage persistence; `docs/clinical-testing-guide.md` for doctor/nurse evaluation sessions | Done | | 19 | Clinician feedback mode — six rating buttons per alert, optional notes, Feedback Summary with aggregate stats, JSON/CSV export, localStorage persistence; `docs/clinical-testing-guide.md` for doctor/nurse evaluation sessions | Done |
| 25 | Glasgow Coma Scale — `GcsCalculator`, `GcsDetector`, `GcsScoringService`; `gcs_scores` table; `GCS_CRITICAL` / `GCS_WARNING` alerts; `gcs.scored` outbox topic; NEWS2 consciousness GCS-first; qSOFA altered mentation sync; `GcsController`; Prometheus `gcs_scores_total`; `GcsScoringTests`; `run-phase25-verification.sh` | Done |
| 26 | SOFA scoring — `SofaCalculator`, `SofaDetector`, `SofaLabCache`, `SofaVasopressorResolver`, `SofaScoringService`; `sofa_scores` table with baseline + delta; `SOFA_SEPSIS` / `SOFA_WARNING` alerts; six new observation codes; `SofaController`; Kafka topic `gcs.scored` provisioned for CNS re-score; stale-encounter guard for Kafka replay; Prometheus `sofa_scores_total`; `SofaScoringTests`; `run-phase26-verification.sh` | Done |
**Ward dashboard:** backend APIs (`GET /encounters` ward list, `GET /qsofa/current`, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `vigilcare-dashboard` Vitest suite (41 tests: replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table). **Ward dashboard:** backend APIs (`GET /encounters` ward list, `GET /qsofa/current`, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `vigilcare-dashboard` Vitest suite (41 tests: replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table).
**Scoring pipeline (Phases 2526):** GCS components → `gcs_scores` + `gcs.scored` → SOFA CNS organ system; SOFA lab/vital observations → `sofa_scores` with baseline tracking → delta sepsis alerts when organ dysfunction worsens.
**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,197 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Integration")]
public class GcsScoringTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private Guid _encounterId;
private Guid _patientId;
public GcsScoringTests(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-GCS-001", FirstName = "GCS", LastName = "Test",
DateOfBirth = new DateOnly(1960, 1, 1), Gender = "M",
CreatedAt = DateTimeOffset.UtcNow
};
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = Department.Icu,
AttendingPhysician = "Dr. GCS", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
await db.SaveChangesAsync();
_patientId = patient.Id;
_encounterId = encounter.Id;
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
var cache = redis.GetDatabase();
foreach (var key in GcsCalculator.AllComponentKeys(_encounterId))
await cache.KeyDeleteAsync(key);
}
public Task DisposeAsync() => Task.CompletedTask;
private async Task<GcsResult> ScoreAsync(string code, decimal value)
{
using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<GcsDetector>();
return await detector.ProcessObservationAsync(_encounterId, _patientId, code, value);
}
[Fact]
public async Task IngestThreeComponents_ComputesTotal()
{
await ScoreAsync("GCS_EYE", 4m);
await ScoreAsync("GCS_VERBAL", 5m);
var result = await ScoreAsync("GCS_MOTOR", 6m);
result.Outcome.Should().Be(GcsOutcome.ScoreComputed);
result.TotalScore.Should().Be(15);
result.Classification.Should().Be("MILD");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var score = await db.GcsScores.SingleAsync();
score.TotalScore.Should().Be(15);
}
[Fact]
public async Task GcsTotal8_CreatesCriticalAlert()
{
await ScoreAsync("GCS_EYE", 2m);
await ScoreAsync("GCS_VERBAL", 3m);
var result = await ScoreAsync("GCS_MOTOR", 3m);
result.TotalScore.Should().Be(8);
result.AlertCreated.Should().BeTrue();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts.SingleAsync();
alert.AlertType.Should().Be(AlertType.GcsCritical);
alert.Severity.Should().Be(AlertSeverity.Critical);
alert.AlertType.IsSuppressible().Should().BeFalse();
}
[Fact]
public async Task GcsTotal12_CreatesWarningAlert()
{
await ScoreAsync("GCS_EYE", 3m);
await ScoreAsync("GCS_VERBAL", 4m);
var result = await ScoreAsync("GCS_MOTOR", 5m);
result.TotalScore.Should().Be(12);
result.AlertCreated.Should().BeTrue();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts.SingleAsync();
alert.AlertType.Should().Be(AlertType.GcsWarning);
alert.Severity.Should().Be(AlertSeverity.Warning);
alert.AlertType.IsSuppressible().Should().BeTrue();
}
[Fact]
public async Task GcsTotal15_NoAlert()
{
await ScoreAsync("GCS_EYE", 4m);
await ScoreAsync("GCS_VERBAL", 5m);
await ScoreAsync("GCS_MOTOR", 6m);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
}
[Fact]
public async Task PartialComponents_NoScore()
{
await ScoreAsync("GCS_EYE", 4m);
await ScoreAsync("GCS_VERBAL", 5m);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.GcsScores.CountAsync()).Should().Be(0);
}
[Fact]
public async Task GcsTriggersNews2Rescore()
{
using var scope = _fixture.Services.CreateScope();
var news2 = scope.ServiceProvider.GetRequiredService<News2Detector>();
await news2.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 16m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 98m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 120m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 36.8m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "SUPPLEMENTAL_O2", 0m);
await ScoreAsync("GCS_EYE", 2m);
await ScoreAsync("GCS_VERBAL", 3m);
await ScoreAsync("GCS_MOTOR", 4m);
var result = await news2.ProcessObservationAsync(_encounterId, _patientId, "GCS_MOTOR", 4m);
result.Outcome.Should().Be(News2Outcome.ScoreComputed);
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var score = await db.News2Scores.OrderByDescending(s => s.CalculatedAt).FirstAsync();
score.ConsciousnessScore.Should().Be(3);
}
[Fact]
public async Task GcsTriggersQsofaReeval()
{
using var scope = _fixture.Services.CreateScope();
var qsofa = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
await qsofa.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m);
await ScoreAsync("GCS_EYE", 2m);
await ScoreAsync("GCS_VERBAL", 3m);
await ScoreAsync("GCS_MOTOR", 4m);
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
var avpuKey = QsofaCalculator.CriterionKey(_encounterId, "AVPU");
var exists = await redis.GetDatabase().KeyExistsAsync(avpuKey);
exists.Should().BeTrue("GCS total 9 should set altered mentation criterion");
}
[Fact]
public async Task AvpuFallback_WhenNoGcs()
{
using var scope = _fixture.Services.CreateScope();
var news2 = scope.ServiceProvider.GetRequiredService<News2Detector>();
await news2.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 16m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 98m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 120m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m);
await news2.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 36.8m);
var result = await news2.ProcessObservationAsync(
_encounterId, _patientId, "SUPPLEMENTAL_O2", 0m);
result.Outcome.Should().Be(News2Outcome.ScoreComputed);
result.TotalScore.Should().Be(0);
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var score = await db.News2Scores.SingleAsync();
score.ConsciousnessScore.Should().Be(0);
}
}
@@ -18,6 +18,8 @@ public static class DbResetHelper
DELETE FROM outbox_events; DELETE FROM outbox_events;
DELETE FROM orders; DELETE FROM orders;
DELETE FROM clinical_alerts; DELETE FROM clinical_alerts;
DELETE FROM gcs_scores;
DELETE FROM sofa_scores;
DELETE FROM news2_scores; DELETE FROM news2_scores;
DELETE FROM observations; DELETE FROM observations;
DELETE FROM encounters; DELETE FROM encounters;
@@ -0,0 +1,207 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Integration")]
public class SofaScoringTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private Guid _encounterId;
private Guid _patientId;
public SofaScoringTests(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-SOFA-001", FirstName = "SOFA", LastName = "Test",
DateOfBirth = new DateOnly(1960, 1, 1), Gender = "M",
CreatedAt = DateTimeOffset.UtcNow
};
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = Department.Icu,
AttendingPhysician = "Dr. SOFA", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
await db.SaveChangesAsync();
_patientId = patient.Id;
_encounterId = encounter.Id;
}
public Task DisposeAsync() => Task.CompletedTask;
private async Task<SofaScoringResult> ScoreObsAsync(string code, decimal value)
{
using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
return await detector.ProcessObservationAsync(
_encounterId, _patientId, code, value, DateTimeOffset.UtcNow);
}
[Fact]
public async Task RespiratoryScore_PaO2FiO2Ratio()
{
await ScoreObsAsync("FIO2_PCT", 40m);
var result = await ScoreObsAsync("PAO2_MMHG", 80m);
result.Score!.Respiratory.Should().Be(2);
}
[Fact]
public async Task RespiratoryScore_SpO2Fallback()
{
await ScoreObsAsync("FIO2_PCT", 40m);
await ScoreObsAsync("SPO2", 94m);
var result = await ScoreObsAsync("PLATELET_K_UL", 180m);
result.Score!.Respiratory.Should().Be(1);
}
[Fact]
public async Task CoagulationScore_LowPlatelets()
{
var result = await ScoreObsAsync("PLATELET_K_UL", 45m);
result.Score!.Coagulation.Should().Be(3);
}
[Fact]
public async Task LiverScore_ElevatedBilirubin()
{
var result = await ScoreObsAsync("BILIRUBIN_MG_DL", 3.5m);
result.Score!.Liver.Should().Be(2);
}
[Fact]
public async Task CardiovascularScore_LowMAP()
{
await ScoreObsAsync("SYSTOLIC_BP", 85m);
var result = await ScoreObsAsync("DIASTOLIC_BP", 50m);
result.Score!.Cardiovascular.Should().Be(1);
}
[Fact]
public async Task CardiovascularScore_Vasopressor()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var resolver = scope.ServiceProvider.GetRequiredService<SofaVasopressorResolver>();
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
var med = new MedicationAdministration
{
Id = Guid.NewGuid(),
EncounterId = _encounterId,
DrugName = "NOREPINEPHRINE",
Dose = 0.05m,
DoseUnit = "mcg/kg/min",
Route = "IV",
AdministeredAt = DateTimeOffset.UtcNow,
AdministeredBy = "RN Test"
};
db.MedicationAdministrations.Add(med);
await db.SaveChangesAsync();
await resolver.CacheFromAdministrationAsync(med, CancellationToken.None);
var result = await detector.ProcessObservationAsync(
_encounterId, _patientId, "SYSTOLIC_BP", 120m, DateTimeOffset.UtcNow);
result.Score!.Cardiovascular.Should().BeGreaterThanOrEqualTo(2);
}
[Fact]
public async Task CnsScore_FromGcs()
{
using var scope = _fixture.Services.CreateScope();
var gcs = scope.ServiceProvider.GetRequiredService<GcsDetector>();
await gcs.ProcessObservationAsync(_encounterId, _patientId, "GCS_EYE", 2m);
await gcs.ProcessObservationAsync(_encounterId, _patientId, "GCS_VERBAL", 3m);
await gcs.ProcessObservationAsync(_encounterId, _patientId, "GCS_MOTOR", 4m);
var sofa = scope.ServiceProvider.GetRequiredService<SofaDetector>();
var result = await sofa.ProcessGcsScoredAsync(_encounterId, _patientId);
result.Score!.Cns.Should().Be(3);
}
[Fact]
public async Task RenalScore_ElevatedCreatinine()
{
var result = await ScoreObsAsync("CREATININE_MG_DL", 4.0m);
result.Score!.Renal.Should().Be(3);
}
[Fact]
public async Task BaselineEstablished_OnFirstCompleteScore()
{
await SeedBaselineInputsAsync();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var baseline = await db.SofaScores.FirstAsync(s => s.IsBaseline);
baseline.IsBaseline.Should().BeTrue();
}
[Fact]
public async Task DeltaTwo_CreatesSofaSepsisAlert()
{
await SeedBaselineInputsAsync(totalOffset: 0);
await ScoreObsAsync("PLATELET_K_UL", 20m);
await ScoreObsAsync("CREATININE_MG_DL", 4.5m);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts.SingleAsync(a => a.AlertType == AlertType.SofaSepsis);
alert.Severity.Should().Be(AlertSeverity.Critical);
}
[Fact]
public async Task DeltaOne_CreatesWarning()
{
await SeedBaselineInputsAsync(totalOffset: 0);
await ScoreObsAsync("PLATELET_K_UL", 120m);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts.SingleAsync(a => a.AlertType == AlertType.SofaWarning);
alert.Severity.Should().Be(AlertSeverity.Warning);
}
[Fact]
public async Task NoDelta_NoAlert()
{
await SeedBaselineInputsAsync();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.ClinicalAlerts.CountAsync()).Should().Be(0);
}
[Fact]
public async Task CarryForward_WithinWindow()
{
using var scope = _fixture.Services.CreateScope();
var cache = scope.ServiceProvider.GetRequiredService<SofaLabCache>();
await cache.StoreAsync(_encounterId, "PLATELET_K_UL", 180m,
DateTimeOffset.UtcNow.AddHours(-8));
var result = await ScoreObsAsync("FIO2_PCT", 40m);
result.Score!.Coagulation.Should().Be(0);
}
private async Task SeedBaselineInputsAsync(int totalOffset = 0)
{
await ScoreObsAsync("PAO2_MMHG", 100m);
await ScoreObsAsync("FIO2_PCT", 40m);
await ScoreObsAsync("PLATELET_K_UL", 180m - totalOffset);
await ScoreObsAsync("BILIRUBIN_MG_DL", 1.0m);
await ScoreObsAsync("SYSTOLIC_BP", 120m);
await ScoreObsAsync("DIASTOLIC_BP", 80m);
await ScoreObsAsync("CREATININE_MG_DL", 1.0m);
await ScoreObsAsync("URINE_OUTPUT_ML_H", 50m);
}
}
@@ -0,0 +1,84 @@
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Options;
public class GcsScoringService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger<GcsScoringService> _logger;
public GcsScoringService(
IServiceProvider services,
IOptions<KafkaOptions> kafkaOptions,
ILogger<GcsScoringService> logger)
{
_services = services;
_kafkaOptions = kafkaOptions.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "gcs-scoring",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
_logger.LogInformation("GcsScoringService started — consumer group: gcs-scoring");
try
{
while (!stoppingToken.IsCancellationRequested)
{
ConsumeResult<string, string>? result = null;
try
{
result = consumer.Consume(stoppingToken);
var evt = JsonSerializer.Deserialize<News2ObservationEvent>(
result.Message.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
using var scope = _services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<GcsDetector>();
var outcome = await detector.ProcessObservationAsync(
evt.EncounterId,
evt.PatientId,
evt.ObservationCode,
evt.Value,
stoppingToken);
if (outcome.Outcome == GcsOutcome.ScoreComputed)
_logger.LogInformation(
"GCS scored via consumer — encounter={Id} total={Total} class={Class}",
evt.EncounterId, outcome.TotalScore, outcome.Classification);
consumer.Commit(result);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"GcsScoringService failed on topic={Topic} offset={Offset} — not committing",
result?.Topic, result?.Offset.Value);
await Task.Delay(2000, stoppingToken);
}
}
}
finally
{
consumer.Close();
}
}
}
@@ -27,7 +27,8 @@ public class KafkaTopicProvisioner : IHostedService
_options.Topics.AlertAcknowledged, _options.Topics.AlertAcknowledged,
_options.Topics.EncounterStatusChanged, _options.Topics.EncounterStatusChanged,
_options.Topics.SepsisBundleCreated, _options.Topics.SepsisBundleCreated,
_options.Topics.SepsisBundleUpdated _options.Topics.SepsisBundleUpdated,
_options.Topics.GcsScored
}; };
var specs = topicNames.Select(name => new TopicSpecification var specs = topicNames.Select(name => new TopicSpecification
@@ -44,7 +45,9 @@ public class KafkaTopicProvisioner : IHostedService
} }
catch (CreateTopicsException ex) catch (CreateTopicsException ex)
{ {
var errors = ex.Results.Where(r => r.Error.Code != ErrorCode.TopicAlreadyExists).ToList(); var errors = ex.Results
.Where(r => r.Error.Code is not (ErrorCode.NoError or ErrorCode.TopicAlreadyExists))
.ToList();
if (errors.Count > 0) if (errors.Count > 0)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Failed to create Kafka topics: {string.Join(", ", errors.Select(e => e.Error.Reason))}"); $"Failed to create Kafka topics: {string.Join(", ", errors.Select(e => e.Error.Reason))}");
@@ -0,0 +1,116 @@
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Options;
public class SofaScoringService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger<SofaScoringService> _logger;
public SofaScoringService(
IServiceProvider services,
IOptions<KafkaOptions> kafkaOptions,
ILogger<SofaScoringService> logger)
{
_services = services;
_kafkaOptions = kafkaOptions.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "sofa-scoring",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(new[]
{
_kafkaOptions.Topics.ObservationRecorded,
_kafkaOptions.Topics.GcsScored
});
_logger.LogInformation("SofaScoringService started — consumer group: sofa-scoring");
try
{
while (!stoppingToken.IsCancellationRequested)
{
ConsumeResult<string, string>? result = null;
try
{
result = consumer.Consume(stoppingToken);
using var scope = _services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
if (result.Topic == _kafkaOptions.Topics.GcsScored)
{
var gcsEvt = JsonSerializer.Deserialize<GcsScoredEvent>(
result.Message.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var outcome = await detector.ProcessGcsScoredAsync(
gcsEvt.EncounterId, gcsEvt.PatientId, stoppingToken);
if (outcome.Outcome == SofaOutcome.EncounterNotFound)
_logger.LogWarning(
"Skipping stale gcs.scored event — encounter={Id} offset={Offset}",
gcsEvt.EncounterId, result.Offset.Value);
else if (outcome.Outcome == SofaOutcome.ScoreComputed)
_logger.LogInformation(
"SOFA re-scored via gcs.scored — encounter={Id} total={Total}",
gcsEvt.EncounterId, outcome.Score!.Total);
}
else
{
var evt = JsonSerializer.Deserialize<News2ObservationEvent>(
result.Message.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var outcome = await detector.ProcessObservationAsync(
evt.EncounterId,
evt.PatientId,
evt.ObservationCode,
evt.Value,
DateTimeOffset.UtcNow,
stoppingToken);
if (outcome.Outcome == SofaOutcome.EncounterNotFound)
_logger.LogWarning(
"Skipping stale observation.recorded event — encounter={Id} code={Code} offset={Offset}",
evt.EncounterId, evt.ObservationCode, result.Offset.Value);
else if (outcome.Outcome == SofaOutcome.ScoreComputed)
_logger.LogInformation(
"SOFA scored via consumer — encounter={Id} total={Total}",
evt.EncounterId, outcome.Score!.Total);
}
consumer.Commit(result);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"SofaScoringService failed on topic={Topic} offset={Offset} — not committing",
result?.Topic, result?.Offset.Value);
await Task.Delay(2000, stoppingToken);
}
}
}
finally
{
consumer.Close();
}
}
}
public record GcsScoredEvent(Guid EncounterId, Guid PatientId, int TotalScore);
@@ -6,4 +6,5 @@ public class KafkaTopicOptions
public string EncounterStatusChanged { get; set; } = "encounter.status.changed"; public string EncounterStatusChanged { get; set; } = "encounter.status.changed";
public string SepsisBundleCreated { get; set; } = "sepsis.bundle.created"; public string SepsisBundleCreated { get; set; } = "sepsis.bundle.created";
public string SepsisBundleUpdated { get; set; } = "sepsis.bundle.updated"; public string SepsisBundleUpdated { get; set; } = "sepsis.bundle.updated";
public string GcsScored { get; set; } = "gcs.scored";
} }
@@ -0,0 +1,7 @@
public class SofaOptions
{
public int LabStalenessHours { get; set; } = 24;
public int LabWarningHours { get; set; } = 12;
public bool UseSpO2FiO2Fallback { get; set; } = true;
public int VasopressorWindowHours { get; set; } = 1;
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/encounters/{encounterId:guid}/gcs")]
[Produces("application/json")]
public class GcsController : ControllerBase
{
private readonly IGcsService _gcs;
public GcsController(IGcsService gcs) => _gcs = gcs;
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<GcsScoreResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(Guid encounterId)
{
var score = await _gcs.GetCurrentAsync(encounterId);
if (score is null)
return NotFound(ApiResponse<object>.Fail(
404, "No GCS score computed for this encounter.", "NO_GCS_SCORE"));
return Ok(ApiResponse<GcsScoreResponse>.Ok(new GcsScoreResponse(
score.EyeScore, score.VerbalScore, score.MotorScore,
score.TotalScore, score.Classification, score.CalculatedAt)));
}
}
@@ -0,0 +1,64 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/encounters/{encounterId:guid}/sofa")]
[Produces("application/json")]
public class SofaController : ControllerBase
{
private readonly ISofaService _sofa;
public SofaController(ISofaService sofa) => _sofa = sofa;
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<SofaScoreResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(Guid encounterId)
{
var score = await _sofa.GetCurrentAsync(encounterId);
if (score is null)
return NotFound(ApiResponse<object>.Fail(
404, "No SOFA score computed for this encounter.", "NO_SOFA_SCORE"));
return Ok(ApiResponse<SofaScoreResponse>.Ok(MapResponse(score)));
}
[HttpGet("history")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<IActionResult> History(
Guid encounterId,
[FromQuery] int limit = 20,
[FromQuery] string? cursor = null)
{
var page = await _sofa.GetHistoryAsync(encounterId, limit, cursor);
return Ok(ApiResponse<object>.Ok(new
{
items = page.Items.Select(MapResponse),
nextCursor = page.NextCursor,
hasMore = page.HasMore
}));
}
private static SofaScoreResponse MapResponse(SofaScore score)
{
SofaStalenessInfo? staleness = null;
if (!string.IsNullOrEmpty(score.StalenessFlags))
{
var flags = JsonSerializer.Deserialize<SofaStalenessFlags>(
score.StalenessFlags,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (flags is not null)
{
staleness = new SofaStalenessInfo(
flags.StaleComponents, flags.MissingComponents, flags.UsedSpO2Fallback);
}
}
return new SofaScoreResponse(
score.TotalScore,
score.RespiratoryScore, score.CoagulationScore, score.LiverScore,
score.CardiovascularScore, score.CnsScore, score.RenalScore,
score.IsBaseline, score.DeltaFromBaseline,
staleness, score.CalculatedAt);
}
}
@@ -16,6 +16,8 @@ public class AppDbContext : DbContext
public DbSet<SepsisBundle> SepsisBundles => Set<SepsisBundle>(); public DbSet<SepsisBundle> SepsisBundles => Set<SepsisBundle>();
public DbSet<SepsisBundleElement> SepsisBundleElements => Set<SepsisBundleElement>(); public DbSet<SepsisBundleElement> SepsisBundleElements => Set<SepsisBundleElement>();
public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>(); public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>();
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -12,8 +12,19 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
t.HasCheckConstraint("chk_clinical_alerts_status", t.HasCheckConstraint("chk_clinical_alerts_status",
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
t.HasCheckConstraint("chk_clinical_alerts_alert_type", t.HasCheckConstraint("chk_clinical_alerts_alert_type",
"alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', " + "alert_type IN (" +
"'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')"); "'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', 'QSOFA_WARNING', " +
"'GCS_CRITICAL', 'GCS_WARNING')");
}); });
builder.HasKey(a => a.Id); builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class GcsScoreConfiguration : IEntityTypeConfiguration<GcsScore>
{
public void Configure(EntityTypeBuilder<GcsScore> builder)
{
builder.ToTable("gcs_scores", t =>
{
t.HasCheckConstraint("chk_gcs_scores_classification",
"classification IN ('MILD', 'MODERATE', 'SEVERE')");
});
builder.HasKey(g => g.Id);
builder.Property(g => g.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(g => g.EncounterId).HasColumnName("encounter_id").IsRequired();
builder.Property(g => g.PatientId).HasColumnName("patient_id").IsRequired();
builder.Property(g => g.EyeScore).HasColumnName("eye_score").IsRequired();
builder.Property(g => g.VerbalScore).HasColumnName("verbal_score").IsRequired();
builder.Property(g => g.MotorScore).HasColumnName("motor_score").IsRequired();
builder.Property(g => g.TotalScore).HasColumnName("total_score").IsRequired();
builder.Property(g => g.Classification).HasColumnName("classification").HasMaxLength(16).IsRequired();
builder.Property(g => g.CalculatedAt).HasColumnName("calculated_at").IsRequired();
builder.Property(g => g.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(g => g.Encounter)
.WithMany()
.HasForeignKey(g => g.EncounterId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => new { g.EncounterId, g.CalculatedAt });
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class SofaScoreConfiguration : IEntityTypeConfiguration<SofaScore>
{
public void Configure(EntityTypeBuilder<SofaScore> builder)
{
builder.ToTable("sofa_scores");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(s => s.EncounterId).HasColumnName("encounter_id").IsRequired();
builder.Property(s => s.PatientId).HasColumnName("patient_id").IsRequired();
builder.Property(s => s.TotalScore).HasColumnName("total_score").IsRequired();
builder.Property(s => s.RespiratoryScore).HasColumnName("respiratory_score").IsRequired();
builder.Property(s => s.CoagulationScore).HasColumnName("coagulation_score").IsRequired();
builder.Property(s => s.LiverScore).HasColumnName("liver_score").IsRequired();
builder.Property(s => s.CardiovascularScore).HasColumnName("cardiovascular_score").IsRequired();
builder.Property(s => s.CnsScore).HasColumnName("cns_score").IsRequired();
builder.Property(s => s.RenalScore).HasColumnName("renal_score").IsRequired();
builder.Property(s => s.IsBaseline).HasColumnName("is_baseline").IsRequired();
builder.Property(s => s.DeltaFromBaseline).HasColumnName("delta_from_baseline");
builder.Property(s => s.StalenessFlags).HasColumnName("staleness_flags").HasColumnType("jsonb");
builder.Property(s => s.CalculatedAt).HasColumnName("calculated_at").IsRequired();
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(s => s.Encounter)
.WithMany()
.HasForeignKey(s => s.EncounterId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(s => new { s.EncounterId, s.CalculatedAt });
builder.HasIndex(s => s.EncounterId)
.HasFilter("is_baseline = true")
.HasDatabaseName("idx_sofa_scores_baseline");
}
}
@@ -127,6 +127,70 @@ public static class DataSeeder
CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m, CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}, },
// GCS components — registered for ingestion; thresholds are null (alerting is on computed total in GcsDetector)
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_EYE",
DisplayName = "GCS Eye Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_VERBAL",
DisplayName = "GCS Verbal Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_MOTOR",
DisplayName = "GCS Motor Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "PAO2_MMHG",
DisplayName = "Partial Pressure O2 (Arterial)", Unit = "mmHg",
CriticalLow = 60m, WarningLow = 80m, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "FIO2_PCT",
DisplayName = "Fraction of Inspired O2", Unit = "%",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "PLATELET_K_UL",
DisplayName = "Platelet Count", Unit = "k/µL",
CriticalLow = 20m, WarningLow = 50m, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "BILIRUBIN_MG_DL",
DisplayName = "Total Bilirubin", Unit = "mg/dL",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 6.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "CREATININE_MG_DL",
DisplayName = "Serum Creatinine", Unit = "mg/dL",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 3.5m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "URINE_OUTPUT_ML_H",
DisplayName = "Urine Output", Unit = "mL/h",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
}; };
db.AlertThresholds.AddRange(thresholds); db.AlertThresholds.AddRange(thresholds);
@@ -0,0 +1,15 @@
public class GcsScore
{
public Guid Id { get; set; }
public Guid EncounterId { get; set; }
public Guid PatientId { get; set; }
public int EyeScore { get; set; }
public int VerbalScore { get; set; }
public int MotorScore { get; set; }
public int TotalScore { get; set; }
public string Classification { get; set; } = null!;
public DateTimeOffset CalculatedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Encounter Encounter { get; set; } = null!;
}
@@ -0,0 +1,20 @@
public class SofaScore
{
public Guid Id { get; set; }
public Guid EncounterId { get; set; }
public Guid PatientId { get; set; }
public int TotalScore { get; set; }
public int RespiratoryScore { get; set; }
public int CoagulationScore { get; set; }
public int LiverScore { get; set; }
public int CardiovascularScore { get; set; }
public int CnsScore { get; set; }
public int RenalScore { get; set; }
public bool IsBaseline { get; set; }
public int? DeltaFromBaseline { get; set; }
public string? StalenessFlags { get; set; }
public DateTimeOffset CalculatedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Encounter Encounter { get; set; } = null!;
}
@@ -31,6 +31,21 @@ public enum AlertType
RapidDeterioration, RapidDeterioration,
QsofaWarning, QsofaWarning,
GcsCritical,
GcsWarning,
CriticalPao2MmHg,
WarningPao2MmHg,
CriticalPlateletKUl,
WarningPlateletKUl,
CriticalBilirubinMgDl,
WarningBilirubinMgDl,
CriticalCreatinineMgDl,
WarningCreatinineMgDl,
SofaSepsis,
SofaWarning,
} }
public static class AlertTypeExtensions public static class AlertTypeExtensions
@@ -63,6 +78,18 @@ public static class AlertTypeExtensions
AlertType.News2Emergency => "NEWS2_EMERGENCY", AlertType.News2Emergency => "NEWS2_EMERGENCY",
AlertType.RapidDeterioration => "RAPID_DETERIORATION", AlertType.RapidDeterioration => "RAPID_DETERIORATION",
AlertType.QsofaWarning => "QSOFA_WARNING", AlertType.QsofaWarning => "QSOFA_WARNING",
AlertType.GcsCritical => "GCS_CRITICAL",
AlertType.GcsWarning => "GCS_WARNING",
AlertType.CriticalPao2MmHg => "CRITICAL_PAO2_MMHG",
AlertType.WarningPao2MmHg => "WARNING_PAO2_MMHG",
AlertType.CriticalPlateletKUl => "CRITICAL_PLATELET_K_UL",
AlertType.WarningPlateletKUl => "WARNING_PLATELET_K_UL",
AlertType.CriticalBilirubinMgDl => "CRITICAL_BILIRUBIN_MG_DL",
AlertType.WarningBilirubinMgDl => "WARNING_BILIRUBIN_MG_DL",
AlertType.CriticalCreatinineMgDl => "CRITICAL_CREATININE_MG_DL",
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
AlertType.SofaSepsis => "SOFA_SEPSIS",
AlertType.SofaWarning => "SOFA_WARNING",
_ => throw new ArgumentOutOfRangeException(nameof(t)) _ => throw new ArgumentOutOfRangeException(nameof(t))
}; };
@@ -94,6 +121,18 @@ public static class AlertTypeExtensions
"NEWS2_EMERGENCY" => AlertType.News2Emergency, "NEWS2_EMERGENCY" => AlertType.News2Emergency,
"RAPID_DETERIORATION" => AlertType.RapidDeterioration, "RAPID_DETERIORATION" => AlertType.RapidDeterioration,
"QSOFA_WARNING" => AlertType.QsofaWarning, "QSOFA_WARNING" => AlertType.QsofaWarning,
"GCS_CRITICAL" => AlertType.GcsCritical,
"GCS_WARNING" => AlertType.GcsWarning,
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
"WARNING_PAO2_MMHG" => AlertType.WarningPao2MmHg,
"CRITICAL_PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
"WARNING_PLATELET_K_UL" => AlertType.WarningPlateletKUl,
"CRITICAL_BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
"WARNING_BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
"CRITICAL_CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
"WARNING_CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
"SOFA_SEPSIS" => AlertType.SofaSepsis,
"SOFA_WARNING" => AlertType.SofaWarning,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'") _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
}; };
@@ -111,6 +150,10 @@ public static class AlertTypeExtensions
"LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL, "LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
"AVPU" => AlertType.CriticalAvpu, "AVPU" => AlertType.CriticalAvpu,
"GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl, "GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
"PAO2_MMHG" => AlertType.CriticalPao2MmHg,
"PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
"BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
"CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
_ => throw new ArgumentOutOfRangeException( _ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'") nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
}; };
@@ -127,6 +170,10 @@ public static class AlertTypeExtensions
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp, "DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL, "LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl, "GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
"PAO2_MMHG" => AlertType.WarningPao2MmHg,
"PLATELET_K_UL" => AlertType.WarningPlateletKUl,
"BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
"CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
_ => 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}'")
}; };
@@ -139,7 +186,9 @@ public static class AlertTypeExtensions
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
or AlertType.CriticalGlucoseMgDl => false, or AlertType.CriticalGlucoseMgDl => false,
AlertType.RapidDeterioration => false, // trajectory alerts are never suppressed AlertType.RapidDeterioration => false,
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
AlertType.SofaSepsis => false,
_ => true // all Warning* types, News2Warning, and QsofaWarning _ => true // all Warning* types, News2Warning, and QsofaWarning
}; };
@@ -155,6 +204,10 @@ public static class AlertTypeExtensions
AlertType.WarningDiastolicBp => "DIASTOLIC_BP", AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L", AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL", AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
AlertType.WarningPao2MmHg => "PAO2_MMHG",
AlertType.WarningPlateletKUl => "PLATELET_K_UL",
AlertType.WarningBilirubinMgDl => "BILIRUBIN_MG_DL",
AlertType.WarningCreatinineMgDl => "CREATININE_MG_DL",
_ => null _ => null
}; };
} }
@@ -0,0 +1,6 @@
public enum GcsOutcome
{
NotGcsCode,
IncompleteComponents,
ScoreComputed
}
@@ -0,0 +1,6 @@
public enum SofaOutcome
{
NotSofaTrigger,
EncounterNotFound,
ScoreComputed
}
@@ -0,0 +1,6 @@
public enum SofaValueStatus
{
Current,
Stale,
Expired
}
+60
View File
@@ -0,0 +1,60 @@
using StackExchange.Redis;
public static class GcsCalculator
{
public static readonly IReadOnlyList<string> ComponentCodes = new[]
{
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR"
};
public static readonly IReadOnlySet<string> ComponentCodeSet =
new HashSet<string>(ComponentCodes);
public static bool IsGcsCode(string observationCode) =>
ComponentCodeSet.Contains(observationCode);
public static RedisKey[] AllComponentKeys(Guid encounterId) =>
ComponentCodes
.Select(code => (RedisKey)$"gcs:{encounterId}:{code}")
.ToArray();
public static string ComponentKey(Guid encounterId, string code) =>
$"gcs:{encounterId}:{code}";
// Compute total from three components. Returns null if any component missing.
public static int? ComputeTotal(decimal? eye, decimal? verbal, decimal? motor)
{
if (eye is null || verbal is null || motor is null)
return null;
return (int)(eye.Value + verbal.Value + motor.Value);
}
// GCS severity classification
public static string ClassifyGcs(int total) => total switch
{
<= 8 => "SEVERE", // Coma
<= 12 => "MODERATE",
_ => "MILD" // 13-15
};
// Map GCS total to NEWS2 consciousness score (replaces AVPU mapping)
public static int ToNews2ConsciousnessScore(int gcsTotal) => gcsTotal switch
{
15 => 0, // Fully alert — equivalent to AVPU=Alert
_ => 3 // Any deficit — equivalent to AVPU=Voice/Pain/Unresponsive
};
// Map GCS total to qSOFA altered mentation criterion
public static bool MeetsQsofaAlteredMentation(int gcsTotal) =>
gcsTotal < 15;
// Map GCS total to SOFA CNS score (used in Phase 26)
public static int ToSofaCnsScore(int gcsTotal) => gcsTotal switch
{
15 => 0,
>= 13 => 1, // 13-14
>= 10 => 2, // 10-12
>= 6 => 3, // 6-9
_ => 4 // < 6
};
}
+235
View File
@@ -0,0 +1,235 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class GcsDetector
{
private const int GcsTtlSeconds = 14400; // 4 hours — same as NEWS2
private readonly IConnectionMultiplexer _redis;
private readonly IServiceProvider _services;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<GcsDetector> _logger;
public GcsDetector(
IConnectionMultiplexer redis,
IServiceProvider services,
ClinicalMetrics metrics,
ILogger<GcsDetector> logger)
{
_redis = redis;
_services = services;
_metrics = metrics;
_logger = logger;
}
public async Task<GcsResult> ProcessObservationAsync(
Guid encounterId,
Guid patientId,
string observationCode,
decimal value,
CancellationToken ct = default)
{
if (!GcsCalculator.IsGcsCode(observationCode))
return GcsResult.NotGcsCode;
var cache = _redis.GetDatabase();
await cache.StringSetAsync(
GcsCalculator.ComponentKey(encounterId, observationCode),
value.ToString(CultureInfo.InvariantCulture),
TimeSpan.FromSeconds(GcsTtlSeconds));
var allKeys = GcsCalculator.AllComponentKeys(encounterId);
var allValues = await cache.StringGetAsync(allKeys);
if (allValues.Any(v => !v.HasValue))
{
var present = allValues.Count(v => v.HasValue);
_logger.LogDebug(
"GCS incomplete for encounter {Id}: {Present}/3 components present",
encounterId, present);
return GcsResult.IncompleteComponents(present);
}
var eye = decimal.Parse(allValues[0]!, CultureInfo.InvariantCulture);
var verbal = decimal.Parse(allValues[1]!, CultureInfo.InvariantCulture);
var motor = decimal.Parse(allValues[2]!, CultureInfo.InvariantCulture);
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
var classification = GcsCalculator.ClassifyGcs(total);
var calculatedAt = DateTimeOffset.UtcNow;
await PersistScoreAsync(
encounterId, patientId, (int)eye, (int)verbal, (int)motor,
total, classification, calculatedAt, ct);
var alertCreated = false;
if (total <= 8)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.GcsCritical, AlertSeverity.Critical,
eye, verbal, motor, total, classification, ct);
}
else if (total <= 12)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.GcsWarning, AlertSeverity.Warning,
eye, verbal, motor, total, classification, ct);
}
await PublishScoredEventAsync(
encounterId, patientId, eye, verbal, motor, total, classification, calculatedAt, ct);
// Re-evaluate qSOFA altered mentation from the computed GCS total (Step 6)
using (var scope = _services.CreateScope())
{
var qsofa = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
await qsofa.SyncAlteredMentationAsync(encounterId, patientId, ct);
}
_metrics.GcsScoresTotal.WithLabels(classification).Inc();
_logger.LogInformation(
"GCS score {Total} ({Classification}) for encounter {Id} — E={Eye} V={Verbal} M={Motor}",
total, classification, encounterId, eye, verbal, motor);
return new GcsResult(GcsOutcome.ScoreComputed, total, classification, alertCreated, 3);
}
private async Task PersistScoreAsync(
Guid encounterId, Guid patientId,
int eye, int verbal, int motor,
int total, string classification,
DateTimeOffset calculatedAt,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.GcsScores.Add(new GcsScore
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
EyeScore = eye,
VerbalScore = verbal,
MotorScore = motor,
TotalScore = total,
Classification = classification,
CalculatedAt = calculatedAt,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
decimal eye, decimal verbal, decimal motor,
int total, string classification,
CancellationToken ct)
{
if (alertType == AlertType.GcsWarning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
{
_logger.LogDebug("GCS_WARNING suppressed for encounter {Id}", encounterId);
return false;
}
}
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 details =
$"GCS total {total} ({classification}): E={eye}, V={verbal}, M={motor}.";
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},
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = {alertType.ToDbString()}
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 = alertType.ToDbString(),
severity = severity.ToDbString(),
details,
triggeredAt,
gcsTotal = total,
gcsClassification = classification,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_metrics.ClinicalAlertsTotal
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
return true;
}
private async Task PublishScoredEventAsync(
Guid encounterId, Guid patientId,
decimal eye, decimal verbal, decimal motor,
int total, string classification,
DateTimeOffset calculatedAt,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = "gcs.scored",
Payload = JsonSerializer.Serialize(new
{
encounterId,
patientId,
eyeScore = (int)eye,
verbalScore = (int)verbal,
motorScore = (int)motor,
totalScore = total,
classification,
calculatedAt,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,999 @@
// <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("20260620153118_AddGcsScores")]
partial class AddGcsScores
{
/// <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', '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', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_WARNING')");
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("GcsScore", 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<string>("Classification")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("classification");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<int>("EyeScore")
.HasColumnType("integer")
.HasColumnName("eye_score");
b.Property<int>("MotorScore")
.HasColumnType("integer")
.HasColumnName("motor_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.Property<int>("VerbalScore")
.HasColumnType("integer")
.HasColumnName("verbal_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.ToTable("gcs_scores", null, t =>
{
t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
});
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("AdministeredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("administered_at");
b.Property<string>("AdministeredBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("administered_by");
b.Property<decimal>("Dose")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)")
.HasColumnName("dose");
b.Property<string>("DoseUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("dose_unit");
b.Property<string>("DrugName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("drug_name");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("Route")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("route");
b.HasKey("Id");
b.HasIndex("EncounterId", "AdministeredAt");
b.HasIndex("EncounterId", "DrugName");
b.ToTable("medication_administrations", (string)null);
});
modelBuilder.Entity("News2Score", b =>
{
b.Property<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("SepsisBundle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("ComplianceStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("compliance_status")
.HasDefaultValueSql("'IN_PROGRESS'");
b.Property<DateTimeOffset>("DeadlineAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deadline_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<DateTimeOffset>("RecognizedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recognized_at");
b.Property<Guid>("TriggeringAlertId")
.HasColumnType("uuid")
.HasColumnName("triggering_alert_id");
b.Property<string>("TriggeringAlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("triggering_alert_type");
b.HasKey("Id");
b.HasIndex("ComplianceStatus");
b.HasIndex("TriggeringAlertId");
b.HasIndex("EncounterId", "RecognizedAt");
b.ToTable("sepsis_bundles", null, t =>
{
t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')");
});
});
modelBuilder.Entity("SepsisBundleElement", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("ElementType")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)")
.HasColumnName("element_type");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid")
.HasColumnName("order_id");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("BundleId", "ElementType")
.IsUnique();
b.ToTable("sepsis_bundle_elements", null, t =>
{
t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')");
t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("GcsScore", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("News2Score", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ClinicalAlert", "TriggeringAlert")
.WithMany()
.HasForeignKey("TriggeringAlertId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
b.Navigation("TriggeringAlert");
});
modelBuilder.Entity("SepsisBundleElement", b =>
{
b.HasOne("SepsisBundle", "Bundle")
.WithMany("Elements")
.HasForeignKey("BundleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Bundle");
b.Navigation("Order");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.Navigation("Elements");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddGcsScores : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "gcs_scores",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
eye_score = table.Column<int>(type: "integer", nullable: false),
verbal_score = table.Column<int>(type: "integer", nullable: false),
motor_score = table.Column<int>(type: "integer", nullable: false),
total_score = table.Column<int>(type: "integer", nullable: false),
classification = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
calculated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_gcs_scores", x => x.id);
table.CheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
table.ForeignKey(
name: "FK_gcs_scores_encounters_encounter_id",
column: x => x.encounter_id,
principalTable: "encounters",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_gcs_scores_encounter_id_calculated_at",
table: "gcs_scores",
columns: new[] { "encounter_id", "calculated_at" });
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', 'QSOFA_WARNING',
'GCS_CRITICAL', 'GCS_WARNING'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gcs_scores");
}
}
}
@@ -0,0 +1,999 @@
// <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("20260620161423_AddSofaObservationAlertTypes")]
partial class AddSofaObservationAlertTypes
{
/// <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', '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', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_WARNING')");
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("GcsScore", 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<string>("Classification")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("classification");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<int>("EyeScore")
.HasColumnType("integer")
.HasColumnName("eye_score");
b.Property<int>("MotorScore")
.HasColumnType("integer")
.HasColumnName("motor_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.Property<int>("VerbalScore")
.HasColumnType("integer")
.HasColumnName("verbal_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.ToTable("gcs_scores", null, t =>
{
t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
});
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("AdministeredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("administered_at");
b.Property<string>("AdministeredBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("administered_by");
b.Property<decimal>("Dose")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)")
.HasColumnName("dose");
b.Property<string>("DoseUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("dose_unit");
b.Property<string>("DrugName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("drug_name");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("Route")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("route");
b.HasKey("Id");
b.HasIndex("EncounterId", "AdministeredAt");
b.HasIndex("EncounterId", "DrugName");
b.ToTable("medication_administrations", (string)null);
});
modelBuilder.Entity("News2Score", b =>
{
b.Property<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("SepsisBundle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("ComplianceStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("compliance_status")
.HasDefaultValueSql("'IN_PROGRESS'");
b.Property<DateTimeOffset>("DeadlineAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deadline_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<DateTimeOffset>("RecognizedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recognized_at");
b.Property<Guid>("TriggeringAlertId")
.HasColumnType("uuid")
.HasColumnName("triggering_alert_id");
b.Property<string>("TriggeringAlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("triggering_alert_type");
b.HasKey("Id");
b.HasIndex("ComplianceStatus");
b.HasIndex("TriggeringAlertId");
b.HasIndex("EncounterId", "RecognizedAt");
b.ToTable("sepsis_bundles", null, t =>
{
t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')");
});
});
modelBuilder.Entity("SepsisBundleElement", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("ElementType")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)")
.HasColumnName("element_type");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid")
.HasColumnName("order_id");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("BundleId", "ElementType")
.IsUnique();
b.ToTable("sepsis_bundle_elements", null, t =>
{
t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')");
t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("GcsScore", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("News2Score", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ClinicalAlert", "TriggeringAlert")
.WithMany()
.HasForeignKey("TriggeringAlertId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
b.Navigation("TriggeringAlert");
});
modelBuilder.Entity("SepsisBundleElement", b =>
{
b.HasOne("SepsisBundle", "Bundle")
.WithMany("Elements")
.HasForeignKey("BundleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Bundle");
b.Navigation("Order");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.Navigation("Elements");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSofaObservationAlertTypes : 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',
'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL',
'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_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',
'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL',
'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL',
'NEWS2_WARNING', 'NEWS2_EMERGENCY',
'RAPID_DETERIORATION', 'QSOFA_WARNING',
'GCS_CRITICAL', 'GCS_WARNING'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSofaScores : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sofa_scores",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
total_score = table.Column<int>(type: "integer", nullable: false),
respiratory_score = table.Column<int>(type: "integer", nullable: false),
coagulation_score = table.Column<int>(type: "integer", nullable: false),
liver_score = table.Column<int>(type: "integer", nullable: false),
cardiovascular_score = table.Column<int>(type: "integer", nullable: false),
cns_score = table.Column<int>(type: "integer", nullable: false),
renal_score = table.Column<int>(type: "integer", nullable: false),
is_baseline = table.Column<bool>(type: "boolean", nullable: false),
delta_from_baseline = table.Column<int>(type: "integer", nullable: true),
staleness_flags = table.Column<string>(type: "jsonb", nullable: true),
calculated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sofa_scores", x => x.id);
table.ForeignKey(
name: "FK_sofa_scores_encounters_encounter_id",
column: x => x.encounter_id,
principalTable: "encounters",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "idx_sofa_scores_baseline",
table: "sofa_scores",
column: "encounter_id",
filter: "is_baseline = true");
migrationBuilder.CreateIndex(
name: "IX_sofa_scores_encounter_id_calculated_at",
table: "sofa_scores",
columns: new[] { "encounter_id", "calculated_at" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sofa_scores");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSofaAlertTypes : 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',
'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL',
'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_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',
'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL',
'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL',
'NEWS2_WARNING', 'NEWS2_EMERGENCY',
'RAPID_DETERIORATION', 'QSOFA_WARNING',
'GCS_CRITICAL', 'GCS_WARNING',
'SOFA_SEPSIS', 'SOFA_WARNING'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -156,7 +156,7 @@ namespace VigilCareClinicalAPI.Migrations
b.ToTable("clinical_alerts", null, t => 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_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', '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', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_WARNING')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
@@ -250,6 +250,62 @@ namespace VigilCareClinicalAPI.Migrations
}); });
}); });
modelBuilder.Entity("GcsScore", 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<string>("Classification")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("classification");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<int>("EyeScore")
.HasColumnType("integer")
.HasColumnName("eye_score");
b.Property<int>("MotorScore")
.HasColumnType("integer")
.HasColumnName("motor_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.Property<int>("VerbalScore")
.HasColumnType("integer")
.HasColumnName("verbal_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.ToTable("gcs_scores", null, t =>
{
t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
});
});
modelBuilder.Entity("MedicationAdministration", b => modelBuilder.Entity("MedicationAdministration", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -785,6 +841,81 @@ namespace VigilCareClinicalAPI.Migrations
}); });
}); });
modelBuilder.Entity("SofaScore", 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>("CardiovascularScore")
.HasColumnType("integer")
.HasColumnName("cardiovascular_score");
b.Property<int>("CnsScore")
.HasColumnType("integer")
.HasColumnName("cns_score");
b.Property<int>("CoagulationScore")
.HasColumnType("integer")
.HasColumnName("coagulation_score");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int?>("DeltaFromBaseline")
.HasColumnType("integer")
.HasColumnName("delta_from_baseline");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<bool>("IsBaseline")
.HasColumnType("boolean")
.HasColumnName("is_baseline");
b.Property<int>("LiverScore")
.HasColumnType("integer")
.HasColumnName("liver_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("RenalScore")
.HasColumnType("integer")
.HasColumnName("renal_score");
b.Property<int>("RespiratoryScore")
.HasColumnType("integer")
.HasColumnName("respiratory_score");
b.Property<string>("StalenessFlags")
.HasColumnType("jsonb")
.HasColumnName("staleness_flags");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.HasKey("Id");
b.HasIndex("EncounterId")
.HasDatabaseName("idx_sofa_scores_baseline")
.HasFilter("is_baseline = true");
b.HasIndex("EncounterId", "CalculatedAt");
b.ToTable("sofa_scores", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b => modelBuilder.Entity("ClinicalAlert", b =>
{ {
b.HasOne("Encounter", "Encounter") b.HasOne("Encounter", "Encounter")
@@ -807,6 +938,17 @@ namespace VigilCareClinicalAPI.Migrations
b.Navigation("Patient"); b.Navigation("Patient");
}); });
modelBuilder.Entity("GcsScore", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("MedicationAdministration", b => modelBuilder.Entity("MedicationAdministration", b =>
{ {
b.HasOne("Encounter", "Encounter") b.HasOne("Encounter", "Encounter")
@@ -905,6 +1047,17 @@ namespace VigilCareClinicalAPI.Migrations
b.Navigation("Order"); b.Navigation("Order");
}); });
modelBuilder.Entity("SofaScore", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b => modelBuilder.Entity("Encounter", b =>
{ {
b.Navigation("Alerts"); b.Navigation("Alerts");
@@ -0,0 +1,12 @@
public record GcsResult(
GcsOutcome Outcome,
int? TotalScore = null,
string? Classification = null,
bool AlertCreated = false,
int PresentComponents = 0)
{
public static readonly GcsResult NotGcsCode = new(GcsOutcome.NotGcsCode);
public static GcsResult IncompleteComponents(int presentCount) =>
new(GcsOutcome.IncompleteComponents, PresentComponents: presentCount);
}
@@ -0,0 +1,7 @@
public record GcsScoreResponse(
int EyeScore,
int VerbalScore,
int MotorScore,
int TotalScore,
string Classification,
DateTimeOffset CalculatedAt);
@@ -0,0 +1 @@
public record SofaCachedValue(decimal Value, DateTimeOffset RecordedAt);
@@ -0,0 +1,3 @@
public record SofaResult(
int Total, int Respiratory, int Coagulation, int Liver,
int Cardiovascular, int Cns, int Renal);
@@ -0,0 +1,7 @@
public record SofaScoreResponse(
int TotalScore,
int RespiratoryScore, int CoagulationScore, int LiverScore,
int CardiovascularScore, int CnsScore, int RenalScore,
bool IsBaseline, int? DeltaFromBaseline,
SofaStalenessInfo? Staleness,
DateTimeOffset CalculatedAt);
@@ -0,0 +1,10 @@
public record SofaScoringResult(
SofaOutcome Outcome,
SofaResult? Score = null,
bool IsBaseline = false,
int? DeltaFromBaseline = null,
bool AlertCreated = false)
{
public static readonly SofaScoringResult NotSofaTrigger = new(SofaOutcome.NotSofaTrigger);
public static readonly SofaScoringResult EncounterNotFound = new(SofaOutcome.EncounterNotFound);
}
@@ -0,0 +1,4 @@
public record SofaStalenessFlags(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -0,0 +1,4 @@
public record SofaStalenessInfo(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using System.Text.Json;
public class SofaVasopressorResolver
{
private static readonly HashSet<string> VasopressorDrugs = new(StringComparer.OrdinalIgnoreCase)
{
"DOPAMINE", "DOBUTAMINE", "EPINEPHRINE", "NOREPINEPHRINE",
"VASOPRESSIN", "PHENYLEPHRINE"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private readonly AppDbContext _db;
private readonly IConnectionMultiplexer _redis;
private readonly SofaOptions _options;
public SofaVasopressorResolver(
AppDbContext db,
IConnectionMultiplexer redis,
IOptions<SofaOptions> options)
{
_db = db;
_redis = redis;
_options = options.Value;
}
public static string VasopressorCacheKey(Guid encounterId) =>
$"sofa:{encounterId}:vasopressor";
public async Task CacheFromAdministrationAsync(MedicationAdministration med, CancellationToken ct)
{
if (!VasopressorDrugs.Contains(med.DrugName)) return;
var info = new VasopressorInfo(med.DrugName, NormalizeDose(med.DrugName, med.Dose, med.DoseUnit));
var json = JsonSerializer.Serialize(new
{
info.DrugName,
info.DoseUgKgMin,
med.AdministeredAt
}, JsonOptions);
await _redis.GetDatabase().StringSetAsync(
VasopressorCacheKey(med.EncounterId),
json,
TimeSpan.FromHours(_options.VasopressorWindowHours));
}
public async Task<VasopressorInfo?> GetActiveVasopressorAsync(
Guid encounterId, CancellationToken ct)
{
var cached = await _redis.GetDatabase()
.StringGetAsync(VasopressorCacheKey(encounterId));
if (cached.HasValue)
{
using var doc = JsonDocument.Parse((string)cached!);
var root = doc.RootElement;
return new VasopressorInfo(
root.GetProperty("drugName").GetString()!,
root.GetProperty("doseUgKgMin").GetDecimal());
}
var since = DateTimeOffset.UtcNow.AddHours(-_options.VasopressorWindowHours);
var med = await _db.MedicationAdministrations
.AsNoTracking()
.Where(m => m.EncounterId == encounterId
&& VasopressorDrugs.Contains(m.DrugName)
&& m.AdministeredAt >= since)
.OrderByDescending(m => m.AdministeredAt)
.FirstOrDefaultAsync(ct);
if (med is null) return null;
return new VasopressorInfo(med.DrugName, NormalizeDose(med.DrugName, med.Dose, med.DoseUnit));
}
private static decimal NormalizeDose(string drug, decimal dose, string unit) =>
unit.ToLowerInvariant() switch
{
"mcg/kg/min" or "µg/kg/min" => dose,
"mcg/min" or "µg/min" => dose / 70m,
"mg/hr" => dose * 1000m / 60m / 70m,
_ => dose
};
}
@@ -0,0 +1 @@
public record VasopressorInfo(string DrugName, decimal DoseUgKgMin);
+11 -16
View File
@@ -17,10 +17,7 @@ public static class News2Calculator
$"news2:{encounterId}:{code}"; $"news2:{encounterId}:{code}";
public static bool IsNews2Code(string observationCode) => public static bool IsNews2Code(string observationCode) =>
ParameterCodes.Contains(observationCode); ParameterCodes.Contains(observationCode) || GcsCalculator.IsGcsCode(observationCode);
// --- Individual parameter scoring ---
// Each method returns 0-3 per the official NEWS2 scoring table.
public static int ScoreRespRate(decimal value) => value switch public static int ScoreRespRate(decimal value) => value switch
{ {
@@ -28,16 +25,15 @@ public static class News2Calculator
<= 11 => 1, <= 11 => 1,
<= 20 => 0, <= 20 => 0,
<= 24 => 2, <= 24 => 2,
_ => 3 // >= 25 _ => 3
}; };
// Scale 1 (standard). Scale 2 (hypercapnic respiratory failure) is not implemented.
public static int ScoreSpo2(decimal value) => value switch public static int ScoreSpo2(decimal value) => value switch
{ {
<= 91 => 3, <= 91 => 3,
<= 93 => 2, <= 93 => 2,
<= 95 => 1, <= 95 => 1,
_ => 0 // >= 96 _ => 0
}; };
public static int ScoreSystolicBp(decimal value) => value switch public static int ScoreSystolicBp(decimal value) => value switch
@@ -46,7 +42,7 @@ public static class News2Calculator
<= 100 => 2, <= 100 => 2,
<= 110 => 1, <= 110 => 1,
<= 219 => 0, <= 219 => 0,
_ => 3 // >= 220 _ => 3
}; };
public static int ScoreHeartRate(decimal value) => value switch public static int ScoreHeartRate(decimal value) => value switch
@@ -56,30 +52,30 @@ public static class News2Calculator
<= 90 => 0, <= 90 => 0,
<= 110 => 1, <= 110 => 1,
<= 130 => 2, <= 130 => 2,
_ => 3 // >= 131 _ => 3
}; };
// AVPU: Alert=0, Voice/Pain/Unresponsive=3 (any non-Alert scores 3)
public static int ScoreConsciousness(decimal value) => value switch public static int ScoreConsciousness(decimal value) => value switch
{ {
0 => 0, // Alert 0 => 0,
_ => 3 // Voice (1), Pain (2), Unresponsive (3) _ => 3
}; };
public static int ScoreConsciousnessFromGcs(int gcsTotal) =>
GcsCalculator.ToNews2ConsciousnessScore(gcsTotal);
public static int ScoreTemperature(decimal value) => value switch public static int ScoreTemperature(decimal value) => value switch
{ {
<= 35.0m => 3, <= 35.0m => 3,
<= 36.0m => 1, <= 36.0m => 1,
<= 38.0m => 0, <= 38.0m => 0,
<= 39.0m => 1, <= 39.0m => 1,
_ => 2 // >= 39.1 _ => 2
}; };
// 0 = room air, 1 = on supplemental oxygen
public static int ScoreSupplementalO2(decimal value) => public static int ScoreSupplementalO2(decimal value) =>
value >= 1 ? 2 : 0; value >= 1 ? 2 : 0;
// Dispatch to the correct scoring function by observation code.
public static int ScoreParameter(string observationCode, decimal value) => public static int ScoreParameter(string observationCode, decimal value) =>
observationCode switch observationCode switch
{ {
@@ -93,7 +89,6 @@ public static class News2Calculator
_ => throw new ArgumentOutOfRangeException(nameof(observationCode)) _ => throw new ArgumentOutOfRangeException(nameof(observationCode))
}; };
// Determine risk level from total score and single-param-3 flag.
public static string DetermineRiskLevel(int totalScore, bool hasSingleParamThree) => public static string DetermineRiskLevel(int totalScore, bool hasSingleParamThree) =>
totalScore switch totalScore switch
{ {
+49 -13
View File
@@ -40,10 +40,11 @@ public class News2Detector
return News2Result.NotNews2Code; return News2Result.NotNews2Code;
using var timer = _metrics.News2ScoringDuration.NewTimer(); using var timer = _metrics.News2ScoringDuration.NewTimer();
var cache = _redis.GetDatabase(); var cache = _redis.GetDatabase();
// Compute the individual score and store in Redis // GCS components are cached by GcsDetector — trigger re-score only
if (!GcsCalculator.IsGcsCode(observationCode))
{
var individualScore = News2Calculator.ScoreParameter(observationCode, value); var individualScore = News2Calculator.ScoreParameter(observationCode, value);
var paramData = JsonSerializer.Serialize(new var paramData = JsonSerializer.Serialize(new
{ {
@@ -55,15 +56,35 @@ public class News2Detector
News2Calculator.ParameterKey(encounterId, observationCode), News2Calculator.ParameterKey(encounterId, observationCode),
paramData, paramData,
TimeSpan.FromSeconds(News2TtlSeconds)); TimeSpan.FromSeconds(News2TtlSeconds));
}
// Fetch all 7 parameter keys in one MGET round-trip return await TryComputeScoreAsync(encounterId, patientId, ct);
}
private async Task<News2Result> TryComputeScoreAsync(
Guid encounterId, Guid patientId, CancellationToken ct)
{
var cache = _redis.GetDatabase();
var allKeys = News2Calculator.AllParameterKeys(encounterId); var allKeys = News2Calculator.AllParameterKeys(encounterId);
var allValues = await cache.StringGetAsync(allKeys); var allValues = await cache.StringGetAsync(allKeys);
// Check completeness — all 7 must be present
var scores = new int?[7]; var scores = new int?[7];
for (int i = 0; i < 7; i++) for (int i = 0; i < 7; i++)
{ {
if (i == 4) // consciousness — GCS-first, AVPU-fallback
{
scores[4] = await ResolveConsciousnessScoreAsync(encounterId);
if (scores[4] is null)
{
var present = allValues.Count(v => v.HasValue) + 0;
_logger.LogDebug(
"NEWS2 incomplete for encounter {Id}: consciousness missing ({Present}/7 present)",
encounterId, present);
return News2Result.IncompleteParameters(present);
}
continue;
}
if (!allValues[i].HasValue) if (!allValues[i].HasValue)
{ {
_logger.LogDebug( _logger.LogDebug(
@@ -77,23 +98,15 @@ public class News2Detector
scores[i] = cached?.Score; scores[i] = cached?.Score;
} }
// All 7 present — compute aggregate
var paramScores = scores.Select(s => s!.Value).ToArray(); var paramScores = scores.Select(s => s!.Value).ToArray();
var totalScore = paramScores.Sum(); var totalScore = paramScores.Sum();
var hasSingleParamThree = paramScores.Any(s => s == 3); var hasSingleParamThree = paramScores.Any(s => s == 3);
var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree); var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree);
// Persist the score to PostgreSQL await PersistScoreAsync(
var scoreId = await PersistScoreAsync(
encounterId, patientId, totalScore, riskLevel, encounterId, patientId, totalScore, riskLevel,
paramScores, hasSingleParamThree, ct); paramScores, hasSingleParamThree, ct);
_logger.LogInformation(
"NEWS2 score {Score} ({Risk}) for encounter {Id} — components: {Components}",
totalScore, riskLevel, encounterId,
string.Join(",", News2Calculator.ParameterCodes.Zip(paramScores, (c, s) => $"{c}={s}")));
// Create alert if warranted
var alertCreated = false; var alertCreated = false;
if (riskLevel == "HIGH") if (riskLevel == "HIGH")
{ {
@@ -112,6 +125,29 @@ public class News2Detector
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree); News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
} }
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (gcsValues.All(v => v.HasValue))
{
var eye = decimal.Parse(gcsValues[0]!);
var verbal = decimal.Parse(gcsValues[1]!);
var motor = decimal.Parse(gcsValues[2]!);
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
return News2Calculator.ScoreConsciousnessFromGcs(total);
}
var avpuVal = await cache.StringGetAsync(
News2Calculator.ParameterKey(encounterId, "AVPU"));
if (!avpuVal.HasValue)
return null;
var cached = JsonSerializer.Deserialize<News2CachedParam>(avpuVal!, CachedParamJsonOptions);
return cached?.Score;
}
private async Task<Guid> PersistScoreAsync( private async Task<Guid> PersistScoreAsync(
Guid encounterId, Guid patientId, Guid encounterId, Guid patientId,
int totalScore, string riskLevel, int totalScore, string riskLevel,
@@ -55,6 +55,16 @@ public sealed class ClinicalMetrics
"Sepsis bundle compliance outcomes.", "Sepsis bundle compliance outcomes.",
labelNames: new[] { "status" }); labelNames: new[] { "status" });
public readonly Counter GcsScoresTotal = Metrics.CreateCounter(
"gcs_scores_total",
"GCS scores computed, labeled by classification.",
labelNames: new[] { "classification" });
public readonly Counter SofaScoresTotal = Metrics.CreateCounter(
"sofa_scores_total",
"SOFA scores computed, labeled by whether a delta alert was created.",
labelNames: new[] { "has_delta_alert" });
// --- Histograms --- // --- Histograms ---
// Measures the full ingest transaction: Redis cache lookup + alert evaluation + // Measures the full ingest transaction: Redis cache lookup + alert evaluation +
@@ -84,6 +94,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 SofaScoringDuration = Metrics.CreateHistogram(
"sofa_scoring_duration_seconds",
"SOFA scoring computation time",
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
+10 -1
View File
@@ -77,6 +77,8 @@ try
builder.Services.Configure<DashboardOptions>( builder.Services.Configure<DashboardOptions>(
builder.Configuration.GetSection(DashboardOptions.Section)); builder.Configuration.GetSection(DashboardOptions.Section));
builder.Services.Configure<SofaOptions>(builder.Configuration.GetSection("Sofa"));
var dashboardOptions = builder.Configuration var dashboardOptions = builder.Configuration
.GetSection(DashboardOptions.Section) .GetSection(DashboardOptions.Section)
.Get<DashboardOptions>() ?? new DashboardOptions(); .Get<DashboardOptions>() ?? new DashboardOptions();
@@ -115,6 +117,12 @@ try
builder.Services.AddSingleton<IAlertSuppressionService, AlertSuppressionService>(); builder.Services.AddSingleton<IAlertSuppressionService, AlertSuppressionService>();
builder.Services.AddScoped<IMedicationService, MedicationService>(); builder.Services.AddScoped<IMedicationService, MedicationService>();
builder.Services.AddScoped<MedicationCorrelationHelper>(); builder.Services.AddScoped<MedicationCorrelationHelper>();
builder.Services.AddScoped<GcsDetector>();
builder.Services.AddScoped<IGcsService, GcsService>();
builder.Services.AddScoped<SofaLabCache>();
builder.Services.AddScoped<SofaVasopressorResolver>();
builder.Services.AddScoped<SofaDetector>();
builder.Services.AddScoped<ISofaService, SofaService>();
builder.Services.AddHostedService<ThresholdCacheLoader>(); builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>(); builder.Services.AddHostedService<KafkaTopicProvisioner>();
@@ -136,7 +144,8 @@ try
builder.Services.AddHostedService<News2ScoringService>(); builder.Services.AddHostedService<News2ScoringService>();
builder.Services.AddHostedService<TrendAnalyzerService>(); builder.Services.AddHostedService<TrendAnalyzerService>();
builder.Services.AddHostedService<SepsisBundleMonitorService>(); builder.Services.AddHostedService<SepsisBundleMonitorService>();
builder.Services.AddHostedService<GcsScoringService>();
builder.Services.AddHostedService<SofaScoringService>();
builder.Services.AddControllers() builder.Services.AddControllers()
.AddJsonOptions(opts => .AddJsonOptions(opts =>
@@ -10,10 +10,6 @@ public static class QsofaCalculator
public static readonly IReadOnlySet<string> QsofaCodeSet = public static readonly IReadOnlySet<string> QsofaCodeSet =
new HashSet<string>(QsofaCodes); new HashSet<string>(QsofaCodes);
// qSOFA criteria (Sepsis-3 consensus):
// - Respiratory rate ≥ 22 breaths/min
// - Systolic blood pressure ≤ 100 mmHg
// - Altered mentation: AVPU score ≥ 1 (any non-Alert state)
public static bool MeetsCriterion(string observationCode, decimal value) => public static bool MeetsCriterion(string observationCode, decimal value) =>
observationCode switch observationCode switch
{ {
@@ -23,6 +19,9 @@ public static class QsofaCalculator
_ => false _ => false
}; };
public static bool MeetsGcsAlteredMentation(int gcsTotal) =>
GcsCalculator.MeetsQsofaAlteredMentation(gcsTotal);
public static string CriterionKey(Guid encounterId, string code) => public static string CriterionKey(Guid encounterId, string code) =>
$"qsofa:{encounterId}:{code}"; $"qsofa:{encounterId}:{code}";
@@ -68,6 +68,44 @@ public class QsofaDetector
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen; return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
} }
public async Task<QsofaResult> SyncAlteredMentationAsync(
Guid encounterId,
Guid patientId,
CancellationToken ct = default)
{
var cache = _redis.GetDatabase();
var avpuKey = QsofaCalculator.CriterionKey(encounterId, "AVPU");
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (gcsValues.All(v => v.HasValue))
{
var eye = decimal.Parse(gcsValues[0]!);
var verbal = decimal.Parse(gcsValues[1]!);
var motor = decimal.Parse(gcsValues[2]!);
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
if (QsofaCalculator.MeetsGcsAlteredMentation(total))
{
await cache.StringSetAsync(
avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds));
}
else
{
await cache.KeyDeleteAsync(avpuKey);
}
}
var allKeys = QsofaCalculator.AllCriterionKeys(encounterId);
var values = await cache.StringGetAsync(allKeys);
var activeCount = QsofaCalculator.CountActiveCriteria(values);
if (activeCount < 2)
return QsofaResult.InsufficientCriteria(activeCount);
var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, values, ct);
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
}
private async Task<bool> TryCreateAlertAsync( private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid encounterId,
Guid patientId, Guid patientId,
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
public class GcsService : IGcsService
{
private readonly AppDbContext _db;
public GcsService(AppDbContext db) => _db = db;
public async Task<GcsScore?> GetCurrentAsync(Guid encounterId)
{
return await _db.GcsScores
.AsNoTracking()
.Where(s => s.EncounterId == encounterId)
.OrderByDescending(s => s.CalculatedAt)
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,4 @@
public interface IGcsService
{
Task<GcsScore?> GetCurrentAsync(Guid encounterId);
}
@@ -0,0 +1,7 @@
public interface ISofaService
{
Task<SofaScore?> GetCurrentAsync(Guid encounterId);
Task<SofaScore?> GetBaselineAsync(Guid encounterId);
Task<CursorPage<SofaScore>> GetHistoryAsync(
Guid encounterId, int limit, string? cursorToken);
}
@@ -0,0 +1,6 @@
public static class MapCalculator
{
// Returns MAP in mmHg from systolic and diastolic BP.
public static decimal Calculate(decimal systolicBp, decimal diastolicBp) =>
Math.Round(diastolicBp + (systolicBp - diastolicBp) / 3m, 1);
}
@@ -17,6 +17,15 @@ public static class PlausibilityValidator
["LACTATE_MMOL_L"] = (0.1m, 30), ["LACTATE_MMOL_L"] = (0.1m, 30),
["AVPU"] = (0, 3), ["AVPU"] = (0, 3),
["SUPPLEMENTAL_O2"] = (0, 1), ["SUPPLEMENTAL_O2"] = (0, 1),
["GCS_EYE"] = (1, 4),
["GCS_VERBAL"] = (1, 5),
["GCS_MOTOR"] = (1, 6),
["PAO2_MMHG"] = (20, 600),
["FIO2_PCT"] = (21, 100),
["PLATELET_K_UL"] = (1, 1500),
["BILIRUBIN_MG_DL"] = (0.1m, 50),
["CREATININE_MG_DL"] = (0.1m, 20),
["URINE_OUTPUT_ML_H"] = (0, 500),
}; };
public static bool IsPlausible(string observationCode, decimal value, out string? reason) public static bool IsPlausible(string observationCode, decimal value, out string? reason)
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
public class SofaService : ISofaService
{
private readonly AppDbContext _db;
public SofaService(AppDbContext db) => _db = db;
public async Task<SofaScore?> GetCurrentAsync(Guid encounterId)
{
return await _db.SofaScores
.AsNoTracking()
.Where(s => s.EncounterId == encounterId)
.OrderByDescending(s => s.CalculatedAt)
.FirstOrDefaultAsync();
}
public async Task<SofaScore?> GetBaselineAsync(Guid encounterId)
{
return await _db.SofaScores
.AsNoTracking()
.FirstOrDefaultAsync(s => s.EncounterId == encounterId && s.IsBaseline);
}
public async Task<CursorPage<SofaScore>> GetHistoryAsync(
Guid encounterId, int limit, string? cursorToken)
{
limit = Math.Clamp(limit, 1, 100);
var query = _db.SofaScores
.AsNoTracking()
.Where(s => s.EncounterId == encounterId);
var items = await query
.OrderByDescending(s => s.CalculatedAt)
.ThenByDescending(s => s.Id)
.Take(limit + 1)
.ToListAsync();
var hasMore = items.Count > limit;
if (hasMore) items.RemoveAt(limit);
return new CursorPage<SofaScore>(items, null, hasMore);
}
}
+160
View File
@@ -0,0 +1,160 @@
public static class SofaCalculator
{
public static readonly IReadOnlyList<string> SofaObservationCodes = new[]
{
"PAO2_MMHG", "FIO2_PCT", "PLATELET_K_UL", "BILIRUBIN_MG_DL",
"CREATININE_MG_DL", "URINE_OUTPUT_ML_H",
"SYSTOLIC_BP", "DIASTOLIC_BP", "SPO2", "SUPPLEMENTAL_O2"
};
public static readonly IReadOnlySet<string> SofaCodeSet =
new HashSet<string>(SofaObservationCodes);
public static bool IsSofaCode(string observationCode) =>
SofaCodeSet.Contains(observationCode);
// GCS components and gcs.scored also trigger SOFA re-score (CNS organ system)
public static bool TriggersRescore(string observationCode) =>
IsSofaCode(observationCode) || GcsCalculator.IsGcsCode(observationCode);
public static string CacheKey(Guid encounterId, string code) =>
$"sofa:{encounterId}:{code}";
// --- 1. Respiratory: PaO2/FiO2 ratio ---
public static int ScoreRespiratory(decimal? pao2, decimal? fio2, bool onMechanicalVent)
{
if (pao2 is null || fio2 is null || fio2 == 0) return 0;
var ratio = pao2.Value / (fio2.Value / 100m);
return ratio switch
{
>= 400 when onMechanicalVent => 0,
>= 400 => 0,
>= 300 => 1,
>= 200 => 2,
>= 100 when onMechanicalVent => 3,
>= 100 => 2,
_ when onMechanicalVent => 4,
_ => 3
};
}
// SpO2/FiO2 proxy when PaO2 unavailable (Rice et al. 2007)
public static int ScoreRespiratoryFromSpo2(decimal? spo2, decimal? fio2, bool onMechanicalVent)
{
if (spo2 is null || fio2 is null || fio2 == 0) return 0;
var sfRatio = spo2.Value / (fio2.Value / 100m);
return sfRatio switch
{
>= 315 => 0,
>= 235 => 1,
>= 150 => 2,
>= 67 when onMechanicalVent => 3,
>= 67 => 2,
_ when onMechanicalVent => 4,
_ => 3
};
}
// --- 2. Coagulation: Platelet count (k/µL) ---
public static int ScoreCoagulation(decimal? platelets)
{
if (platelets is null) return 0;
return platelets.Value switch
{
>= 150 => 0,
>= 100 => 1,
>= 50 => 2,
>= 20 => 3,
_ => 4
};
}
// --- 3. Liver: Bilirubin (mg/dL) ---
public static int ScoreLiver(decimal? bilirubin)
{
if (bilirubin is null) return 0;
return bilirubin.Value switch
{
< 1.2m => 0,
< 2.0m => 1,
< 6.0m => 2,
< 12.0m => 3,
_ => 4
};
}
// --- 4. Cardiovascular: MAP and vasopressor dose ---
public static int ScoreCardiovascular(decimal? map, VasopressorInfo? vasopressor)
{
if (vasopressor is not null)
{
return vasopressor.DrugName.ToUpperInvariant() switch
{
"DOPAMINE" when vasopressor.DoseUgKgMin > 15m => 4,
"EPINEPHRINE" when vasopressor.DoseUgKgMin > 0.1m => 4,
"NOREPINEPHRINE" when vasopressor.DoseUgKgMin > 0.1m => 4,
"DOPAMINE" when vasopressor.DoseUgKgMin > 5m => 3,
"EPINEPHRINE" => 3,
"NOREPINEPHRINE" => 3,
"DOPAMINE" => 2,
"DOBUTAMINE" => 2,
_ => 1
};
}
if (map is null) return 0;
return map.Value < 70m ? 1 : 0;
}
// --- 5. CNS: Glasgow Coma Scale (from Phase 25) ---
public static int ScoreCns(int? gcsTotal) =>
GcsCalculator.ToSofaCnsScore(gcsTotal ?? 15);
// --- 6. Renal: Creatinine (mg/dL) or urine output (mL/day) ---
public static int ScoreRenal(decimal? creatinine, decimal? urineOutputMlPerDay)
{
var creatScore = creatinine switch
{
null => 0,
< 1.2m => 0,
< 2.0m => 1,
< 3.5m => 2,
< 5.0m => 3,
_ => 4
};
var urineScore = urineOutputMlPerDay switch
{
null => 0,
< 200m => 4,
< 500m => 3,
_ => 0
};
return Math.Max(creatScore, urineScore);
}
public static SofaResult ComputeTotal(
int respiratory, int coagulation, int liver,
int cardiovascular, int cns, int renal) =>
new(
Total: respiratory + coagulation + liver + cardiovascular + cns + renal,
Respiratory: respiratory,
Coagulation: coagulation,
Liver: liver,
Cardiovascular: cardiovascular,
Cns: cns,
Renal: renal);
// Count organ systems with component data for baseline eligibility
public static int CountPopulatedOrganSystems(
bool hasRespiratory,
bool hasCoagulation,
bool hasLiver,
bool hasCardiovascular,
bool hasCns,
bool hasRenal) =>
new[] { hasRespiratory, hasCoagulation, hasLiver,
hasCardiovascular, hasCns, hasRenal }
.Count(hasData => hasData);
}
+340
View File
@@ -0,0 +1,340 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Prometheus;
using StackExchange.Redis;
public class SofaDetector
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IConnectionMultiplexer _redis;
private readonly IServiceProvider _services;
private readonly SofaLabCache _labCache;
private readonly SofaVasopressorResolver _vasopressors;
private readonly SofaOptions _options;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<SofaDetector> _logger;
public SofaDetector(
IConnectionMultiplexer redis,
IServiceProvider services,
SofaLabCache labCache,
SofaVasopressorResolver vasopressors,
IOptions<SofaOptions> options,
ClinicalMetrics metrics,
ILogger<SofaDetector> logger)
{
_redis = redis;
_services = services;
_labCache = labCache;
_vasopressors = vasopressors;
_options = options.Value;
_metrics = metrics;
_logger = logger;
}
public async Task<SofaScoringResult> ProcessObservationAsync(
Guid encounterId,
Guid patientId,
string observationCode,
decimal value,
DateTimeOffset recordedAt,
CancellationToken ct = default)
{
if (!SofaCalculator.TriggersRescore(observationCode))
return SofaScoringResult.NotSofaTrigger;
if (SofaCalculator.IsSofaCode(observationCode))
{
await _labCache.StoreAsync(encounterId, observationCode, value, recordedAt);
}
return await TryComputeScoreAsync(encounterId, patientId, ct);
}
public Task<SofaScoringResult> ProcessGcsScoredAsync(
Guid encounterId, Guid patientId, CancellationToken ct = default) =>
TryComputeScoreAsync(encounterId, patientId, ct);
private async Task<SofaScoringResult> TryComputeScoreAsync(
Guid encounterId, Guid patientId, CancellationToken ct)
{
using var timer = _metrics.SofaScoringDuration.NewTimer();
using (var scope = _services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (!await db.Encounters.AnyAsync(e => e.Id == encounterId, ct))
{
_logger.LogWarning(
"Skipping SOFA score for unknown encounter {EncounterId}", encounterId);
return SofaScoringResult.EncounterNotFound;
}
}
var cached = await _labCache.GetAllAsync(encounterId);
var staleComponents = new List<string>();
var missingComponents = new List<string>();
decimal? GetValue(string code)
{
if (!cached.TryGetValue(code, out var entry))
{
missingComponents.Add(code);
return null;
}
var status = _labCache.Classify(entry);
if (status == SofaValueStatus.Expired)
{
missingComponents.Add(code);
return null;
}
if (status == SofaValueStatus.Stale)
staleComponents.Add(code);
return entry.Value;
}
var pao2 = GetValue("PAO2_MMHG");
var fio2 = GetValue("FIO2_PCT");
var spo2 = GetValue("SPO2");
var supplementalO2 = GetValue("SUPPLEMENTAL_O2");
var onMechanicalVent = supplementalO2 is >= 1m;
var usedSpO2Fallback = false;
int respiratory;
if (pao2 is not null && fio2 is not null)
{
respiratory = SofaCalculator.ScoreRespiratory(pao2, fio2, onMechanicalVent);
}
else if (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null)
{
usedSpO2Fallback = true;
respiratory = SofaCalculator.ScoreRespiratoryFromSpo2(spo2, fio2, onMechanicalVent);
}
else
{
respiratory = 0;
if (pao2 is null) missingComponents.Add("PAO2_MMHG");
if (fio2 is null) missingComponents.Add("FIO2_PCT");
}
var coagulation = SofaCalculator.ScoreCoagulation(GetValue("PLATELET_K_UL"));
var liver = SofaCalculator.ScoreLiver(GetValue("BILIRUBIN_MG_DL"));
decimal? map = null;
var sbp = GetValue("SYSTOLIC_BP");
var dbp = GetValue("DIASTOLIC_BP");
if (sbp is not null && dbp is not null)
map = MapCalculator.Calculate(sbp.Value, dbp.Value);
var vasopressor = await _vasopressors.GetActiveVasopressorAsync(encounterId, ct);
var cardiovascular = SofaCalculator.ScoreCardiovascular(map, vasopressor);
var gcsTotal = await LoadGcsTotalAsync(encounterId);
var cns = SofaCalculator.ScoreCns(gcsTotal);
var creatinine = GetValue("CREATININE_MG_DL");
var urineMlH = GetValue("URINE_OUTPUT_ML_H");
decimal? urineMlDay = urineMlH is not null ? urineMlH * 24m : null;
var renal = SofaCalculator.ScoreRenal(creatinine, urineMlDay);
var result = SofaCalculator.ComputeTotal(
respiratory, coagulation, liver, cardiovascular, cns, renal);
var stalenessFlags = JsonSerializer.Serialize(new SofaStalenessFlags(
staleComponents.Distinct().ToList(),
missingComponents.Distinct().ToList(),
usedSpO2Fallback), JsonOptions);
var calculatedAt = DateTimeOffset.UtcNow;
var (isBaseline, delta) = await ResolveBaselineAndDeltaAsync(
encounterId, patientId, result, calculatedAt,
SofaCalculator.CountPopulatedOrganSystems(
hasRespiratory: (pao2 is not null && fio2 is not null)
|| (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null),
hasCoagulation: cached.ContainsKey("PLATELET_K_UL")
&& _labCache.Classify(cached["PLATELET_K_UL"]) != SofaValueStatus.Expired,
hasLiver: cached.ContainsKey("BILIRUBIN_MG_DL")
&& _labCache.Classify(cached["BILIRUBIN_MG_DL"]) != SofaValueStatus.Expired,
hasCardiovascular: (sbp is not null && dbp is not null) || vasopressor is not null,
hasCns: gcsTotal is not null,
hasRenal: (cached.ContainsKey("CREATININE_MG_DL")
&& _labCache.Classify(cached["CREATININE_MG_DL"]) != SofaValueStatus.Expired)
|| (cached.ContainsKey("URINE_OUTPUT_ML_H")
&& _labCache.Classify(cached["URINE_OUTPUT_ML_H"]) != SofaValueStatus.Expired)),
ct);
await PersistScoreAsync(
encounterId, patientId, result, isBaseline, delta,
stalenessFlags, calculatedAt, ct);
var alertCreated = false;
if (delta is >= 2)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.SofaSepsis, AlertSeverity.Critical,
result, isBaseline ? null : delta, stalenessFlags, ct);
}
else if (delta == 1)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.SofaWarning, AlertSeverity.Warning,
result, delta, stalenessFlags, ct);
}
_metrics.SofaScoresTotal
.WithLabels(alertCreated ? "true" : "false")
.Inc();
_logger.LogInformation(
"SOFA score {Total} for encounter {Id} — baseline={Baseline} delta={Delta}",
result.Total, encounterId, isBaseline, delta);
return new SofaScoringResult(
SofaOutcome.ScoreComputed, result, isBaseline, delta, alertCreated);
}
private async Task<int?> LoadGcsTotalAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (!gcsValues.All(v => v.HasValue)) return null;
var eye = decimal.Parse(gcsValues[0]!, CultureInfo.InvariantCulture);
var verbal = decimal.Parse(gcsValues[1]!, CultureInfo.InvariantCulture);
var motor = decimal.Parse(gcsValues[2]!, CultureInfo.InvariantCulture);
return GcsCalculator.ComputeTotal(eye, verbal, motor);
}
private async Task<(bool IsBaseline, int? Delta)> ResolveBaselineAndDeltaAsync(
Guid encounterId, Guid patientId, SofaResult result,
DateTimeOffset calculatedAt, int populatedOrganSystems, CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var existingBaseline = await db.SofaScores
.AsNoTracking()
.FirstOrDefaultAsync(s => s.EncounterId == encounterId && s.IsBaseline, ct);
if (existingBaseline is null)
{
if (populatedOrganSystems >= 4)
return (true, null);
return (false, null);
}
var delta = result.Total - existingBaseline.TotalScore;
return (false, delta);
}
private async Task PersistScoreAsync(
Guid encounterId, Guid patientId, SofaResult result,
bool isBaseline, int? delta, string stalenessFlags,
DateTimeOffset calculatedAt, CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.SofaScores.Add(new SofaScore
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
TotalScore = result.Total,
RespiratoryScore = result.Respiratory,
CoagulationScore = result.Coagulation,
LiverScore = result.Liver,
CardiovascularScore = result.Cardiovascular,
CnsScore = result.Cns,
RenalScore = result.Renal,
IsBaseline = isBaseline,
DeltaFromBaseline = delta,
StalenessFlags = stalenessFlags,
CalculatedAt = calculatedAt,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
SofaResult result, int? delta, string stalenessFlags,
CancellationToken ct)
{
if (alertType == AlertType.SofaWarning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
return false;
}
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 details =
$"SOFA score {result.Total} (delta +{delta}). " +
$"Components: Resp={result.Respiratory}, Coag={result.Coagulation}, " +
$"Liver={result.Liver}, CV={result.Cardiovascular}, CNS={result.Cns}, Renal={result.Renal}. " +
$"Staleness: {stalenessFlags}";
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},
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = {alertType.ToDbString()}
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 = alertType.ToDbString(),
severity = severity.ToDbString(),
details,
triggeredAt,
sofaTotal = result.Total,
sofaDelta = delta,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_metrics.ClinicalAlertsTotal
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
return true;
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
public class SofaLabCache
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IConnectionMultiplexer _redis;
private readonly SofaOptions _options;
public SofaLabCache(IConnectionMultiplexer redis, IOptions<SofaOptions> options)
{
_redis = redis;
_options = options.Value;
}
public async Task StoreAsync(
Guid encounterId, string code, decimal value, DateTimeOffset recordedAt)
{
var json = JsonSerializer.Serialize(new SofaCachedValue(value, recordedAt), JsonOptions);
var key = SofaCalculator.CacheKey(encounterId, code);
await _redis.GetDatabase().StringSetAsync(
key, json, TimeSpan.FromHours(_options.LabStalenessHours));
}
public async Task<SofaCachedValue?> GetAsync(Guid encounterId, string code)
{
var cached = await _redis.GetDatabase()
.StringGetAsync(SofaCalculator.CacheKey(encounterId, code));
if (!cached.HasValue) return null;
return JsonSerializer.Deserialize<SofaCachedValue>(cached!, JsonOptions);
}
public SofaValueStatus Classify(SofaCachedValue? value)
{
if (value is null) return SofaValueStatus.Expired;
var age = DateTimeOffset.UtcNow - value.RecordedAt;
if (age.TotalHours > _options.LabStalenessHours) return SofaValueStatus.Expired;
if (age.TotalHours > _options.LabWarningHours) return SofaValueStatus.Stale;
return SofaValueStatus.Current;
}
public async Task<Dictionary<string, SofaCachedValue>> GetAllAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
var keys = SofaCalculator.SofaObservationCodes
.Select(c => (RedisKey)SofaCalculator.CacheKey(encounterId, c))
.ToArray();
var values = await cache.StringGetAsync(keys);
var result = new Dictionary<string, SofaCachedValue>();
for (var i = 0; i < SofaCalculator.SofaObservationCodes.Count; i++)
{
if (!values[i].HasValue) continue;
var parsed = JsonSerializer.Deserialize<SofaCachedValue>(values[i]!, JsonOptions);
if (parsed is not null)
result[SofaCalculator.SofaObservationCodes[i]] = parsed;
}
return result;
}
}
+8 -1
View File
@@ -44,7 +44,8 @@
"Topics": { "Topics": {
"ObservationRecorded": "observation.recorded", "ObservationRecorded": "observation.recorded",
"AlertGenerated": "alert.generated", "AlertGenerated": "alert.generated",
"EncounterStatusChanged": "encounter.status.changed" "EncounterStatusChanged": "encounter.status.changed",
"GcsScored": "gcs.scored"
}, },
"NumPartitions": 6, "NumPartitions": 6,
"OutboxBatchSize": 100, "OutboxBatchSize": 100,
@@ -156,5 +157,11 @@
"albuterol": ["HEART_RATE", "SPO2"] "albuterol": ["HEART_RATE", "SPO2"]
} }
},
"Sofa": {
"LabStalenessHours": 24,
"LabWarningHours": 12,
"UseSpO2FiO2Fallback": true,
"VasopressorWindowHours": 1
} }
} }
+159
View File
@@ -0,0 +1,159 @@
# VigilCare Clinical — Product Assessment
An honest assessment of where VigilCare stands as a health informatics product after all planned phases (2029) are implemented.
---
## What the System Does Well
### Clinical Decision Support
- **Multi-tier alerting pipeline:** Instant critical thresholds (synchronous, in-transaction) → async composite scoring (NEWS2, SOFA) → velocity-based trend detection. Three independent safety nets with different detection characteristics.
- **Sepsis-3 aligned:** Full SOFA scoring across six organ systems with baseline tracking and delta detection. qSOFA as bedside screening that recommends lab workup — not an outdated SIRS-based declaration.
- **Glasgow Coma Scale:** Three-component entry (Eye/Verbal/Motor) feeding into SOFA, NEWS2, and standalone neuro monitoring. Clinically meaningful — not a toy AVPU approximation.
- **Treatment bundle automation:** Hour-1 sepsis bundle (blood cultures, lactate, antibiotics, IV fluids) with compliance tracking and overdue detection.
- **Alert lifecycle:** Acknowledgment, resolution, suppression windows, 5-minute DLQ escalation, and clinician feedback collection for product research.
### Architecture
- **Event-driven:** Kafka for observation fan-out to independent consumer groups (NEWS2, SOFA, GCS, trend, qSOFA, Elasticsearch, data lake). RabbitMQ for paging with DLQ escalation. Outbox pattern for transactional event publishing.
- **Idempotency at every layer:** Observation idempotency keys, alert `INSERT WHERE NOT EXISTS`, sync batch deduplication by `batchReference`, per-item `clientRef` correlation. Handles retries, duplicates, and at-least-once delivery.
- **Observability:** Prometheus metrics on every engine (scoring durations, detection counts, consumer lag). Grafana dashboards. Structured logging with Serilog + Seq. Per-request correlation IDs.
- **Data pipeline:** Elasticsearch for CQRS read projections and analytics. MinIO data lake with date-partitioned Parquet files. Redis for real-time state (scoring windows, threshold cache, trend history).
### Climate Resilience
- **Ward gateway architecture:** Local ward server runs critical/warning threshold alerts independently of central. Nurses get immediate alerts for dangerous values even during network outages.
- **Graceful degradation:** ONLINE → DEGRADED → OFFLINE states. Local paging and escalation. Buffered observations with idempotent sync on reconnection.
- **Zero data loss:** All observations and alert actions buffered locally, replayed to central with original timestamps. Scoring engines catch up with accurate clinical timeline.
- **Fleet visibility:** Operations dashboard showing gateway status, buffer depth, sync history. Grafana panels for offline count and sync lag.
### Simulator and Validation
- **Realistic clinical scenarios:** 11 scenarios covering sepsis progression, neurological decline, respiratory failure, cardiac arrest, DKA, hemorrhage, hypothermia, medication false alarms, and partial SOFA scoring.
- **End-to-end pipeline validation:** Scenarios replay through the full ingest → Kafka → scoring → alert → bundle → dashboard path.
- **Chaos experiments:** Six documented resilience tests with baseline/broken/fixed artifacts.
---
## What's Missing for a Real Product
### Clinical Gaps
**EHR Integration**
No HL7 FHIR or ADT/ORU message interfaces. Real hospital systems (Epic, Cerner, MEDITECH) communicate via FHIR R4, HL7v2 ADT (patient registration), and ORU (lab results). VigilCare accepts data via REST API only — no hospital system speaks this natively. An integration engine (Mirth Connect, Rhapsody) or FHIR facade would be needed to receive real patient data.
**Order Entry System**
The sepsis bundle recommends orders (blood cultures, antibiotics) but cannot place them in the hospital's pharmacy, lab, or CPOE system. In a real deployment, bundle activation would need to create orders in the EHR or at minimum generate structured order suggestions that a physician can accept with one click.
**Patient Context Engine**
Thresholds are universal — a heart rate of 110 bpm triggers the same alert for a 25-year-old athlete as an 80-year-old on beta-blockers. A real CDSS would adjust thresholds based on age, diagnosis, medications, and clinical context. This is a significant source of alert fatigue in current hospital systems.
**Allergy and Contraindication Checking**
The sepsis bundle recommends "broad-spectrum antibiotics" without checking the patient's allergy list. A real system would need drug-allergy cross-referencing and contraindication logic before suggesting specific medications.
**Diagnosis and Problem List Integration**
SOFA-based sepsis detection requires "suspected infection" to be clinically meaningful. Currently this is implicit — the system detects organ dysfunction but has no way to correlate it with infection markers or clinical documentation. A problem list or active diagnosis feed from the EHR would close this gap.
**Medication Reconciliation**
The system reads vasopressor administrations for SOFA cardiovascular scoring, but doesn't perform full medication reconciliation. Drug interactions, dose adjustments for renal function, and therapeutic monitoring are all absent.
### Regulatory and Compliance Gaps
**Audit Logging for Clinical Decisions**
No record of who changed a threshold, who overrode an alert suppression, or why a clinician dismissed a warning. Regulatory frameworks (FDA 21 CFR Part 11, EU MDR) require traceable decision audit trails for clinical decision support software.
**Role-Based Access Control (RBAC)**
No nurse vs. physician vs. admin permissions. The current auth stub doesn't differentiate clinical roles. A real deployment needs role-based views (nurses see patient vitals and alerts; physicians see order recommendations; admins manage thresholds and configurations).
**HIPAA / Data Protection Controls**
No PHI encryption at rest (database-level or column-level). No access logging for patient record views. No consent management. No data retention policies or automated purging. These are table-stakes for any US healthcare deployment and equivalent requirements exist in most jurisdictions (GDPR, PIPEDA, etc.).
**Clinical Validation Study**
The alerting system's sensitivity, specificity, positive predictive value, and negative predictive value have not been measured against patient outcomes. Before any clinical deployment, a retrospective validation study using real de-identified patient data would be needed to demonstrate that the system catches what it should and doesn't generate excessive false positives.
**Software as Medical Device (SaMD) Classification**
Clinical decision support software that provides diagnosis or treatment recommendations may be classified as a medical device by the FDA (Class II) or under EU MDR. This requires a quality management system (ISO 13485), risk management (ISO 14971), and clinical evaluation documentation.
### Operational Gaps
**Alert Fatigue Management**
Beyond basic suppression windows (30-minute TTL after acknowledgment), there's no adaptive threshold tuning, clinician workload awareness, or alert bundling. Alert fatigue is the #1 complaint from clinicians about existing CDSS products — solving it meaningfully requires machine learning on dismissal patterns and context-aware priority adjustment.
**Multi-Tenant Architecture**
One deployment per hospital. No tenant isolation, per-facility configuration, or centralized management for health systems with multiple facilities. A SaaS deployment model would require tenant-aware data partitioning and configuration management.
**Mobile and Pager Integration**
Pages are logged to console output — not actually sent via hospital paging systems (SMTP, Vocera, PagerDuty, cellular). A real deployment needs integration with the hospital's existing communication infrastructure.
**Downtime Procedures and Failover**
The ward gateway handles network partitions, but there's no documentation or tooling for planned maintenance windows, database migrations with zero downtime, or gateway firmware updates across a fleet.
**Interoperability Testing**
No IHE (Integrating the Healthcare Enterprise) profile conformance testing. IHE profiles define standard communication patterns between healthcare systems — conformance is expected by hospital IT departments evaluating new products.
---
## Competitive Positioning
### What VigilCare demonstrates vs. established products
| Capability | VigilCare | Typical Hospital CDSS (Epic/Cerner built-in) | Specialized CDSS (Sepsis Watch, InSight) |
|---|---|---|---|
| Real-time threshold alerting | Yes | Yes | Yes |
| Composite scoring (NEWS2) | Yes | Partial (often manual) | Yes |
| SOFA-based sepsis (Sepsis-3) | Yes | Rare (most still use SIRS) | Yes |
| GCS tracking | Yes (3-component) | Yes (often single total) | Varies |
| Trend / velocity detection | Yes | No (static thresholds) | Some |
| Ward-level resilience | Yes (unique) | No (assumes connectivity) | No |
| Clinician feedback loop | Yes | No | Rare |
| Open architecture (Kafka, FHIR-ready) | Yes | Proprietary | Proprietary |
| EHR integration | No | Native | Via integration engine |
| Regulatory approval | No | Yes (FDA cleared) | Yes (FDA cleared) |
### Differentiators
1. **Ward gateway / climate resilience** — genuinely novel for a CDSS. Most systems fail silently during network outages.
2. **Sepsis-3 alignment** — many hospital systems still use SIRS-based alerting despite the 2016 consensus update.
3. **Event-driven architecture** — observable, extensible, independently deployable scoring engines vs. monolithic rule engines.
4. **Clinician feedback collection** — built-in product research instrument for measuring alert value.
### Where established products are far ahead
1. **EHR integration** — years of HL7/FHIR development and hospital-specific customization.
2. **Regulatory clearance** — FDA 510(k) or De Novo classification is a 1224 month process with significant cost.
3. **Clinical validation** — published studies with real patient data demonstrating improved outcomes.
4. **Scale** — thousands of concurrent patients across hundreds of facilities.
---
## Highest-Value Next Steps (If Pursuing Product Viability)
Ordered by impact on moving from "portfolio project" to "could pilot in a clinic":
| Priority | Initiative | Why |
|---|---|---|
| 1 | **FHIR R4 facade** | Makes the system interoperable with real hospital systems. Without this, no hospital can feed it data. |
| 2 | **RBAC + audit logging** | Table-stakes for compliance. No hospital IT department will evaluate a system without access control and decision audit trails. |
| 3 | **PHI encryption + access logging** | HIPAA minimum requirements. Column-level encryption for PII, access logs for every patient record view. |
| 4 | **Retrospective clinical validation** | Run the alerting engine against a de-identified patient dataset (MIMIC-IV is publicly available). Publish sensitivity/specificity. This is what clinicians and hospital buyers look at first. |
| 5 | **Alert fatigue reduction** | Context-aware threshold adjustment, alert bundling, dismissal pattern analysis. This is the unsolved problem in health informatics — progress here is a genuine differentiator. |
| 6 | **Mobile notification integration** | SMTP/SMS/Vocera integration for real paging. The current log-only approach is a demo limitation. |
| 7 | **SaMD regulatory pathway** | ISO 13485 QMS, ISO 14971 risk management, FDA pre-submission. Only relevant if pursuing commercial deployment. |
---
## Summary
After phases 2029, VigilCare is a **serious clinical decision support system** that demonstrates real health informatics thinking — not a toy project with medical labels on a generic dashboard. The architecture is sound, the clinical logic is current (Sepsis-3, GCS, NEWS2), and the ward gateway resilience is genuinely novel.
The gaps are real but well-defined: EHR integration, regulatory compliance, and clinical validation are the barriers between "impressive portfolio piece" and "deployable product." Each is a known, solvable problem — not a fundamental design flaw.
For a portfolio and interview context, the system demonstrates:
- Understanding of clinical workflows and patient safety priorities
- Event-driven architecture under real constraints (timing, idempotency, network failure)
- The difference between screening and diagnosis (qSOFA vs. SOFA)
- Graceful degradation as a first-class design concern
- Observability and chaos engineering methodology
These are the qualities that distinguish a health informatics engineer from a developer who happens to work on medical software.
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
echo "=== Phase 25 verification ==="
echo "1. Integration tests"
dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" \
--filter "FullyQualifiedName~GcsScoring" \
--no-restore
echo "2. Manual API checks (requires running stack)"
BASE_URL="${BASE_URL:-http://localhost:5270}"
ENCOUNTER_ID="${ENCOUNTER_ID:?Set ENCOUNTER_ID to an active encounter UUID}"
post_obs() {
local code="$1" value="$2"
curl -sf -X POST "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/observations" \
-H "Content-Type: application/json" \
-d "{\"observations\":[{\"observationCode\":\"${code}\",\"value\":${value},\"unit\":\"score\",\"source\":\"Manual\",\"recordedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}]}"
}
post_obs GCS_EYE 4
post_obs GCS_VERBAL 5
post_obs GCS_MOTOR 6
curl -sf "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/gcs" | jq -e '.data.totalScore == 15'
post_obs GCS_EYE 1
post_obs GCS_VERBAL 2
post_obs GCS_MOTOR 3
echo "Verify GCS_CRITICAL alert, NEWS2 consciousness=3, and qSOFA altered mentation in DB/UI"
echo "Phase 25 verification complete."
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
echo "=== Phase 26 verification ==="
echo "1. Integration tests"
dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" \
--filter "FullyQualifiedName~SofaScoring" \
--no-restore
echo "2. Manual API checks (requires running stack)"
BASE_URL="${BASE_URL:-http://localhost:5270}"
ENCOUNTER_ID="${ENCOUNTER_ID:?Set ENCOUNTER_ID to an active encounter UUID}"
post_obs() {
local code="$1" value="$2"
curl -sf -X POST "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/observations" \
-H "Content-Type: application/json" \
-d "{\"observations\":[{\"observationCode\":\"${code}\",\"value\":${value},\"unit\":\"score\",\"source\":\"Manual\",\"recordedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}]}"
}
wait_for_sofa_baseline() {
for _ in $(seq 1 30); do
if curl -sf "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/sofa/history?limit=20" \
| jq -e '[.data.items[] | select(.isBaseline == true)] | length > 0' >/dev/null; then
return 0
fi
sleep 1
done
echo "Timed out waiting for SOFA baseline"
return 1
}
wait_for_sofa_delta() {
local min_delta="$1"
for _ in $(seq 1 30); do
if curl -sf "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/sofa" \
| jq -e ".data.deltaFromBaseline >= ${min_delta}" >/dev/null; then
return 0
fi
sleep 1
done
echo "Timed out waiting for SOFA delta >= ${min_delta}"
return 1
}
post_obs PAO2_MMHG 100
post_obs FIO2_PCT 40
post_obs PLATELET_K_UL 180
post_obs BILIRUBIN_MG_DL 1.0
post_obs SYSTOLIC_BP 120
post_obs DIASTOLIC_BP 80
post_obs CREATININE_MG_DL 1.0
post_obs URINE_OUTPUT_ML_H 50
wait_for_sofa_baseline
curl -sf "${BASE_URL}/api/v1/encounters/${ENCOUNTER_ID}/sofa" | jq -e '.data.totalScore >= 0'
post_obs PLATELET_K_UL 20
post_obs CREATININE_MG_DL 4.5
wait_for_sofa_delta 2
echo "Verify SOFA_SEPSIS alert and delta >= 2 in DB/UI"
echo "Phase 26 verification complete."