feature: MIMIC-IV Replay Scenario Generator
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
# Guide 19: FHIR R4 Integration Facade
|
||||
|
||||
## What is FHIR?
|
||||
|
||||
**FHIR** (Fast Healthcare Interoperability Resources, pronounced "fire") is a standard for exchanging healthcare data between systems. If your hospital has an EHR (Electronic Health Record) like Epic or Cerner, a lab system, a pharmacy system, and a monitoring platform, they all need to share patient data. FHIR defines a common language for this — standard data formats (called **resources**) and standard ways to send them (RESTful HTTP endpoints).
|
||||
|
||||
Key concepts:
|
||||
|
||||
- **Resource**: A structured JSON object representing a healthcare concept. Examples: `Patient`, `Encounter`, `Observation`, `MedicationAdministration`. Each resource type has a defined set of fields and data types.
|
||||
- **FHIR R4**: The fourth major release of the FHIR standard (Release 4). R4 is the current normative version used by most healthcare systems.
|
||||
- **Identifier**: A system+value pair that identifies a resource in an external system. For example, `system: "http://hospital.example/mrn"`, `value: "MRN-001"`. A patient might have different identifiers in different systems — FHIR uses these to match records across systems.
|
||||
- **Coding**: A code from a standard terminology. `system: "http://loinc.org"`, `code: "8867-4"` means "Heart rate" in the LOINC vocabulary. `system: "http://snomed.info/sct"`, `code: "364075005"` also means "Heart rate" in SNOMED CT.
|
||||
- **Bundle**: A collection of resources sent together. A "transaction Bundle" is like a database transaction — all entries succeed or all fail.
|
||||
- **CapabilityStatement**: A resource that describes what a FHIR server can do — which resource types it supports and which operations (create, read, search).
|
||||
- **OperationOutcome**: A FHIR-standard error response. Instead of returning `{"error": "..."}`, FHIR servers return a structured `OperationOutcome` resource with severity, issue type, and diagnostic details.
|
||||
|
||||
**What is a "facade"?** This project doesn't implement a full FHIR server. It implements a **facade** — a thin translation layer that accepts FHIR-formatted requests, maps them to the internal data model, and returns FHIR-formatted responses. The internal database schema is not FHIR-native; the facade translates between the two worlds.
|
||||
|
||||
---
|
||||
|
||||
## Why FHIR in This Project?
|
||||
|
||||
Hospital integration engines (like Mirth Connect or Rhapsody) speak FHIR. They send ADT (Admit/Discharge/Transfer) messages with Patient and Encounter resources, vital signs as Observation resources, and medication records as MedicationAdministration resources. By implementing FHIR endpoints, VigilCareClinical can receive data from any FHIR-capable system without custom integration code for each one.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Hospital Systems VigilCareClinical
|
||||
┌──────────────┐ ┌──────────────────────────────┐
|
||||
│ EHR (Epic) │ │ FHIR Ingest Controller │
|
||||
│ │ POST /fhir/R4/ │ │ │
|
||||
│ Mirth Connect│ Patient │ ▼ │
|
||||
│ (integration │ ────────────────► │ PatientFhirMapper │
|
||||
│ engine) │ │ │ FHIR Patient → internal │
|
||||
│ │ POST /fhir/R4/ │ ▼ │
|
||||
│ Lab System │ Observation │ PatientService │
|
||||
│ │ ────────────────► │ │ save to PostgreSQL │
|
||||
│ Pharmacy │ │ ▼ │
|
||||
│ │ POST /fhir/R4/ │ FhirMapper (reverse) │
|
||||
│ │ Bundle │ │ internal → FHIR response│
|
||||
│ │ ────────────────► │ ▼ │
|
||||
│ │ │ Return FHIR JSON │
|
||||
└──────────────┘ └──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Resources and Interactions
|
||||
|
||||
The `CapabilityStatement` (available at `GET /fhir/R4/metadata`, no auth required) declares:
|
||||
|
||||
| Resource | Create | Read | Search |
|
||||
|----------|--------|------|--------|
|
||||
| Patient | ✓ | ✓ | ✓ (by identifier) |
|
||||
| Encounter | ✓ | ✓ | ✓ (by patient, status) |
|
||||
| Observation | ✓ | | |
|
||||
| MedicationAdministration | ✓ | | |
|
||||
| Bundle (transaction) | ✓ | | |
|
||||
|
||||
All FHIR endpoints use `application/fhir+json` as the content type (not `application/json`), following the FHIR specification.
|
||||
|
||||
---
|
||||
|
||||
## LOINC Code Mapping
|
||||
|
||||
FHIR Observations use standardized codes (LOINC, SNOMED CT) to identify what's being measured. The internal data model uses simpler codes like `"HEART_RATE"`. The `LoincCodeMapper` translates between them:
|
||||
|
||||
```csharp
|
||||
public static class LoincCodeMapper
|
||||
{
|
||||
private static readonly Dictionary<string, LoincMapping> _map = new()
|
||||
{
|
||||
["8867-4"] = new("HEART_RATE", "/min"),
|
||||
["8310-5"] = new("TEMP_C", "Cel", AllowFahrenheit: true),
|
||||
["2823-3"] = new("POTASSIUM_MEQ_L", "mmol/L"),
|
||||
["2708-6"] = new("SPO2", "%"),
|
||||
["9279-1"] = new("RESP_RATE", "/min"),
|
||||
["6690-2"] = new("WBC_K_UL", "10*3/uL"),
|
||||
["8480-6"] = new("SYSTOLIC_BP", "mm[Hg]"),
|
||||
["8462-4"] = new("DIASTOLIC_BP", "mm[Hg]"),
|
||||
["2524-7"] = new("LACTATE_MMOL_L", "mmol/L"),
|
||||
["777-3"] = new("PLATELET_K_UL", "10*3/uL"),
|
||||
["1975-2"] = new("BILIRUBIN_MG_DL", "mg/dL"),
|
||||
["2160-0"] = new("CREATININE_MG_DL", "mg/dL"),
|
||||
["2703-7"] = new("PAO2_MMHG", "mm[Hg]"),
|
||||
["80288-7"] = new("GCS_EYE", "{score}"),
|
||||
["80289-5"] = new("GCS_VERBAL", "{score}"),
|
||||
["80290-3"] = new("GCS_MOTOR", "{score}"),
|
||||
// ... 19 mappings total
|
||||
};
|
||||
|
||||
// SNOMED CT fallbacks for systems that don't use LOINC
|
||||
private static readonly Dictionary<string, LoincMapping> _snomedMap = new()
|
||||
{
|
||||
["364075005"] = new("HEART_RATE", "/min"),
|
||||
["431314004"] = new("SPO2", "%"),
|
||||
["86290005"] = new("RESP_RATE", "/min"),
|
||||
};
|
||||
|
||||
public static bool TryMap(string system, string code, out LoincMapping mapping)
|
||||
{
|
||||
if (system.Contains("loinc") && _map.TryGetValue(code, out mapping!))
|
||||
return true;
|
||||
if (system.Contains("snomed") && _snomedMap.TryGetValue(code, out mapping!))
|
||||
return true;
|
||||
mapping = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a FHIR Observation arrives with code `system: "http://loinc.org"`, `code: "8867-4"`, the mapper translates it to internal code `"HEART_RATE"` with expected unit `"/min"`.
|
||||
|
||||
### Unit Conversion
|
||||
|
||||
Some hospitals send temperatures in Fahrenheit. The `FhirUnitConverter` handles this:
|
||||
|
||||
```csharp
|
||||
public static (decimal Value, string Unit) Normalize(
|
||||
string internalCode, decimal value, string? fhirUnit, bool allowFahrenheit)
|
||||
{
|
||||
if (internalCode == "TEMP_C" && allowFahrenheit &&
|
||||
(fhirUnit == "[degF]" || fhirUnit == "degF"))
|
||||
{
|
||||
var celsius = (value - 32m) * 5m / 9m;
|
||||
return (Math.Round(celsius, 2), "Cel");
|
||||
}
|
||||
return (value, fhirUnit ?? "1");
|
||||
}
|
||||
```
|
||||
|
||||
The internal system always stores temperature in Celsius. If a FHIR Observation arrives in Fahrenheit, it's converted transparently.
|
||||
|
||||
---
|
||||
|
||||
## The Observation Mapper
|
||||
|
||||
The `ObservationFhirMapper` handles the most complex mapping because FHIR Observations can have multiple formats:
|
||||
|
||||
```csharp
|
||||
public async Task<IReadOnlyList<MappedObservation>> ToIngestRequestsAsync(
|
||||
Hl7.Fhir.Model.Observation fhir)
|
||||
{
|
||||
var encounterId = await _refs.ResolveEncounterReferenceAsync(fhir.Encounter);
|
||||
var source = MapSource(fhir); // vital-signs → Device, laboratory → Lab
|
||||
var recordedAt = fhir.Effective.ToUtcDateTimeOffset() ?? DateTimeOffset.UtcNow;
|
||||
var idempotencyKey = fhir.Identifier?.FirstOrDefault()?.Value ?? fhir.Id;
|
||||
|
||||
// FHIR Observations can be single-value or multi-component
|
||||
if (fhir.Component?.Count > 0)
|
||||
{
|
||||
// Multi-component (e.g., blood pressure with systolic + diastolic)
|
||||
foreach (var component in fhir.Component)
|
||||
results.AddRange(MapSingleCoding(component.Code, component.Value, ...));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single value (e.g., heart rate)
|
||||
results.AddRange(MapSingleCoding(fhir.Code, fhir.Value, ...));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A FHIR blood pressure Observation has two **components** (systolic and diastolic). The mapper produces two internal observations from one FHIR resource.
|
||||
|
||||
---
|
||||
|
||||
## Transaction Bundle Processing
|
||||
|
||||
A transaction Bundle groups multiple resources into one atomic operation — like a database transaction. The `FhirBundleProcessor` handles this:
|
||||
|
||||
```csharp
|
||||
public async Task<Bundle> ProcessTransactionAsync(Bundle transaction)
|
||||
{
|
||||
var response = new Bundle { Type = Bundle.BundleType.TransactionResponse };
|
||||
|
||||
// Sort entries: Patient first, then Encounter, then everything else
|
||||
var entries = transaction.Entry
|
||||
.OrderBy(e => Priority(e.Resource))
|
||||
.ToList();
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var location = resource switch
|
||||
{
|
||||
Patient p => await ProcessPatientAsync(p),
|
||||
Encounter e => await ProcessEncounterAsync(e),
|
||||
Observation o => await ProcessObservationAsync(o),
|
||||
MedicationAdministration m => await ProcessMedAsync(m),
|
||||
_ => throw new FhirMappingException("Unsupported resource type")
|
||||
};
|
||||
|
||||
response.Entry.Add(new Bundle.EntryComponent
|
||||
{
|
||||
Response = new Bundle.ResponseComponent
|
||||
{ Status = "201 Created", Location = location }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await tx.RollbackAsync(); // all-or-nothing
|
||||
response.Entry.Add(/* error entry */);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
**Why sort by priority?** An Encounter references a Patient, and an Observation references an Encounter. If the Bundle contains all three, the Patient must be created first (so the Encounter can reference it), then the Encounter (so the Observation can reference it). The `Priority()` function ensures this ordering.
|
||||
|
||||
**All-or-nothing**: If any entry fails, the entire transaction rolls back — no partially-created data.
|
||||
|
||||
---
|
||||
|
||||
## FHIR Error Responses: OperationOutcome
|
||||
|
||||
FHIR has its own error format. Instead of the project's standard `ApiResponse<T>` envelope, FHIR endpoints return `OperationOutcome` resources:
|
||||
|
||||
```csharp
|
||||
public static class FhirOperationOutcomeBuilder
|
||||
{
|
||||
public static OperationOutcome FromException(Exception ex) => ex switch
|
||||
{
|
||||
FhirMappingException fme => Create(fme.HttpStatus, fme.FhirIssueCode, fme.Message),
|
||||
NotFoundException => Create(404, "not-found", ex.Message),
|
||||
ValidationException => Create(422, "invalid", ex.Message),
|
||||
ConflictException => Create(409, "conflict", ex.Message),
|
||||
_ => Create(500, "exception", "An unexpected error occurred.")
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The `FhirExceptionFilter` catches exceptions on `/fhir/*` paths and converts them to `OperationOutcome` responses with `Content-Type: application/fhir+json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"resourceType": "OperationOutcome",
|
||||
"issue": [{
|
||||
"severity": "warning",
|
||||
"code": "not-found",
|
||||
"diagnostics": "Patient not found."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## External Identifier Resolution
|
||||
|
||||
When a FHIR Encounter references a Patient as `"subject": {"reference": "Patient/MRN-001"}`, the system needs to find the internal UUID for that patient. The `ExternalResourceIdentifier` table maps between external identifiers (hospital MRNs, visit numbers) and internal UUIDs:
|
||||
|
||||
| resource_type | system | value | internal_id |
|
||||
|--------------|--------|-------|-------------|
|
||||
| Patient | `http://hospital.example/mrn` | MRN-001 | `3fa85f64-...` |
|
||||
| Encounter | `http://hospital.example/visit` | VISIT-100 | `7e4b2a1f-...` |
|
||||
|
||||
This mapping is created when a resource is first ingested and used for all subsequent references. It's what makes the FHIR endpoints **idempotent** — sending the same Patient twice (same identifier) updates the existing record instead of creating a duplicate.
|
||||
|
||||
---
|
||||
|
||||
## Authentication for FHIR Endpoints
|
||||
|
||||
FHIR endpoints accept two authentication methods (see Guide 15):
|
||||
|
||||
1. **JWT token** — for admin users accessing via the dashboard or Swagger
|
||||
2. **API key** — for integration engines like Mirth Connect
|
||||
|
||||
```
|
||||
POST /fhir/R4/Observation
|
||||
X-Api-Key: dev-integration-key-change-in-production
|
||||
Content-Type: application/fhir+json
|
||||
```
|
||||
|
||||
The FHIR API key creates an identity with the `Integration` role, which has permissions for `fhir:ingest`, `fhir:read`, `patients:write`, `encounters:write`, `observations:ingest`, and `medications:write`.
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- **FHIR is the healthcare data standard** — it defines how to represent patients, encounters, observations, and medications as JSON resources with standard coding systems
|
||||
- **A facade translates, not stores** — the internal database uses its own schema; the FHIR layer maps between FHIR resources and internal entities
|
||||
- **LOINC and SNOMED CT codes are translated to internal codes** — `"8867-4"` (LOINC) becomes `"HEART_RATE"` internally; SNOMED fallbacks handle systems that don't use LOINC
|
||||
- **Transaction Bundles are atomic** — Patient → Encounter → Observation ordering is enforced, and any failure rolls back the entire bundle
|
||||
- **OperationOutcome is the FHIR error format** — FHIR endpoints return structured error resources instead of the project's standard API envelope
|
||||
- **External identifiers enable idempotency** — the same resource sent twice (same identifier) updates rather than duplicates
|
||||
- **Unit conversion happens transparently** — Fahrenheit temperatures are converted to Celsius during mapping so the internal system works with a single unit
|
||||
Reference in New Issue
Block a user