Healthcare Guide Β· Published by MBIC Β· 2025
Power BI automation for healthcare organizations with HIPAA compliance β from Epic and Cerner integration to de-identification, PHI security, and a phased implementation roadmap.
Prefer the PDF? Download the originalHealthcare organizations face unique challenges when automating business intelligence workflows: strict regulatory requirements, complex data integrations, and high stakes for data accuracy. This guide provides a roadmap for implementing Power BI automation in healthcare while maintaining HIPAA compliance.
Key topics: HIPAA compliance requirements for BI automation; Epic and Cerner integration patterns; patient data de-identification strategies; real-time clinical dashboards; automated population health reporting; audit logging for regulatory compliance; and security architecture for PHI protection.
Business impact: benchmark-based directional estimates for healthcare organizations implementing BI automation:
Target audience: healthcare CIOs and IT directors, privacy officers and compliance teams, clinical analytics leaders, health informatics professionals, and population health managers.
Figures in this guide are benchmark-based directional estimates, and the implementation examples β including the reference implementations in Section 12 β are realistic reference implementations drawn from common patterns, not descriptions of specific client engagements. This guide is not legal or compliance advice; consult your privacy officer and counsel for decisions about HIPAA obligations in your environment.
Typical healthcare organization:
Common pain points:
High-value use cases:
Protected Health Information (PHI) includes:
Key HIPAA rules:
Required BAAs:
Microsoft offers HIPAA BAA for:
- Power BI Premium per User
- Power BI Premium Capacity
- Power BI Embedded
NOT available for:
- Power BI Pro (without Premium)
- Free tier
To enable:
1. Contact Microsoft licensing
2. Execute BAA
3. Configure Power BI tenant for HIPAA
4. Enable audit logging
5. Implement encryption
// Power BI measure - only show aggregated data
// Don't show individual patient details unless job role requires it
PatientCount =
VAR UserRole = USERNAME()
VAR IsAuthorizedForDetails =
CONTAINS(
AuthorizedUsers,
AuthorizedUsers[Email], UserRole,
AuthorizedUsers[CanViewDetails], TRUE()
)
RETURN
IF(
IsAuthorizedForDetails,
COUNTROWS(Patients), // Show actual count
IF(
COUNTROWS(Patients) < 10,
BLANK(), // Don't show if < 10 (re-identification risk)
COUNTROWS(Patients) // Show count if >= 10
)
)
Epic provides several APIs for integration:
ββββββββββββββββββββ
β Epic FHIR API β
β (R4 or DSTU2) β
ββββββββββββββββββββ
β HTTPS
β OAuth 2.0
βΌ
ββββββββββββββββββββ
β Azure Function β
β (FHIR Parser) β
ββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββ
β Azure SQL DB β
β (Staging) β
ββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββ
β Power BI β
β (Analytics) β
ββββββββββββββββββββ
// Epic uses OAuth 2.0 backend service authentication
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
public class EpicAuthenticator
{
private readonly string _clientId;
private readonly string _privateKey;
private readonly string _epicBaseUrl;
public async Task<string> GetAccessToken()
{
// Create JWT assertion
var header = new JwtHeader(
new SigningCredentials(
GetRsaSecurityKey(),
SecurityAlgorithms.RsaSha384
)
);
var payload = new JwtPayload
{
{ "iss", _clientId },
{ "sub", _clientId },
{ "aud", $"{_epicBaseUrl}/oauth2/token" },
{ "jti", Guid.NewGuid().ToString() },
{ "exp", DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds() }
};
var jwt = new JwtSecurityToken(header, payload);
var tokenHandler = new JwtSecurityTokenHandler();
var assertion = tokenHandler.WriteToken(jwt);
// Exchange JWT for access token
using (var client = new HttpClient())
{
var request = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
new KeyValuePair<string, string>("client_assertion", assertion)
});
var response = await client.PostAsync(
$"{_epicBaseUrl}/oauth2/token",
request
);
var content = await response.Content.ReadAsStringAsync();
var tokenResponse = JsonConvert.DeserializeObject<TokenResponse>(content);
return tokenResponse.AccessToken;
}
}
}
// Retrieve patient data via FHIR
public async Task<List<Patient>> GetPatientsForPopulationHealth(
DateTime startDate,
DateTime endDate)
{
var token = await _epicAuth.GetAccessToken();
using (var fhirClient = new FhirClient(_epicBaseUrl))
{
fhirClient.OnBeforeRequest += (sender, e) =>
{
e.RawRequest.Headers.Add("Authorization", $"Bearer {token}");
};
// Search for patients with specific conditions
var searchParams = new SearchParams()
.Where("birthdate=ge" + startDate.ToString("yyyy-MM-dd"))
.Where("birthdate=le" + endDate.ToString("yyyy-MM-dd"))
.Include("Patient:general-practitioner")
.LimitTo(100);
var bundle = await fhirClient.SearchAsync<Patient>(searchParams);
var patients = new List<Patient>();
while (bundle != null)
{
foreach (var entry in bundle.Entry)
{
if (entry.Resource is Patient patient)
{
patients.Add(patient);
}
}
// Get next page if available
bundle = await fhirClient.ContinueAsync(bundle);
}
return patients;
}
}
# Real-time bed capacity automation using Epic ADT events
# Epic sends HL7 ADT messages to Azure Service Bus
# Azure Function processes messages and updates Power BI
# Azure Function Trigger
When Service Bus message received
Queue: epic-adt-events
# Parse HL7 message
Parse HL7
Message: @{triggerBody()}
# Extract key data
Set variables:
PatientMRN: @{hl7.PID.3}
EventType: @{hl7.MSH.9} # ADT^A01 (admit), ADT^A03 (discharge), etc.
BedLocation: @{hl7.PV1.3}
AdmitDateTime: @{hl7.PV1.44}
# Update staging database
Execute SQL
Query:
"""
MERGE INTO PatientCensus AS target
USING (SELECT
@PatientMRN AS MRN,
@BedLocation AS Location,
@EventType AS EventType,
@AdmitDateTime AS EventTime
) AS source
ON target.MRN = source.MRN
WHEN MATCHED AND source.EventType = 'ADT^A03'
THEN DELETE
WHEN MATCHED
THEN UPDATE SET
Location = source.Location,
LastUpdated = GETUTCDATE()
WHEN NOT MATCHED AND source.EventType IN ('ADT^A01', 'ADT^A02')
THEN INSERT (MRN, Location, AdmitTime, LastUpdated)
VALUES (source.MRN, source.Location, source.EventTime, GETUTCDATE());
"""
# Trigger Power BI refresh
Refresh dataset (Power BI)
Dataset: Real-Time Census Dashboard
# Check bed capacity thresholds
Get rows (Power BI)
Dataset: Real-Time Census
Table: BedCapacity
# Alert if capacity > 90%
Condition: Capacity > 90%
THEN
Post message (Teams)
Channel: Nursing Operations
Message:
"β High Census Alert
Current Capacity: @{capacity}%
Available Beds: @{availableBeds}
Pending Admissions: @{pendingAdmits}
Action needed: Activate surge protocol"
Available APIs:
// Similar to Epic but with Cerner-specific considerations
public class CernerDataExtractor
{
private readonly string _cernerBaseUrl;
private readonly string _clientId;
private readonly string _clientSecret;
public async Task<List<Observation>> GetLabResults(
string patientId,
DateTime startDate)
{
var token = await GetCernerAccessToken();
using (var fhirClient = new FhirClient(_cernerBaseUrl))
{
fhirClient.OnBeforeRequest += (sender, e) =>
{
e.RawRequest.Headers.Add("Authorization", $"Bearer {token}");
};
var searchParams = new SearchParams()
.Where($"patient={patientId}")
.Where($"category=laboratory")
.Where($"date=ge{startDate:yyyy-MM-dd}")
.OrderBy("-date");
var bundle = await fhirClient.SearchAsync<Observation>(searchParams);
var observations = new List<Observation>();
foreach (var entry in bundle.Entry)
{
if (entry.Resource is Observation obs)
{
observations.Add(obs);
}
}
return observations;
}
}
private async Task<string> GetCernerAccessToken()
{
using (var client = new HttpClient())
{
var authString = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}")
);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", authString);
var request = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("scope", "system/Observation.read system/Patient.read")
});
var response = await client.PostAsync(
$"{_cernerBaseUrl}/authorization/oauth2/token",
request
);
var content = await response.Content.ReadAsStringAsync();
var token = JsonConvert.DeserializeObject<CernerTokenResponse>(content);
return token.AccessToken;
}
}
}
-- CCL script to extract quality measure data
-- Run via Cerner's Discern Explorer or scheduled job
SELECT
p.person_id,
p.name_full_formatted,
p.birth_dt_tm,
e.encntr_id,
e.reg_dt_tm,
e.disch_dt_tm,
cv_encntr.display AS encounter_type,
-- Diagnosis codes
d.diagnosis_id,
cv_diag.display AS diagnosis,
d.diag_code AS icd10_code,
-- Procedures
pr.procedure_id,
cv_proc.display AS procedure_name,
pr.proc_code AS cpt_code,
-- Medications
m.order_id,
cv_med.display AS medication_name,
m.ordered_dt_tm,
-- Lab results
o.observation_id,
cv_obs.display AS lab_name,
o.result_val,
o.result_units_cd
FROM
person p,
encounter e,
diagnosis d,
procedures pr,
med_order m,
clinical_event o
WHERE
p.person_id = e.person_id
AND e.encntr_id = d.encntr_id (+)
AND e.encntr_id = pr.encntr_id (+)
AND e.encntr_id = m.encntr_id (+)
AND e.encntr_id = o.encntr_id (+)
AND e.disch_dt_tm BETWEEN
CNVTDATETIME("01-JAN-2024")
AND CNVTDATETIME("31-DEC-2024")
-- Filter for quality measure criteria
AND d.diag_code IN (
"I21.0", "I21.1", "I21.2", "I21.3" -- AMI codes
)
ORDER BY
e.reg_dt_tm DESC
-- Output to file for Power BI import
GO TO [output_file_path]
# Scheduled extraction from Cerner
Trigger: Recurrence
Frequency: Daily
Time: 02:00 # Off-hours to minimize impact
# Execute CCL script via Cerner API
HTTP: Execute CCL
Method: POST
URI: https://cerner-server/api/ccl/execute
Headers:
Authorization: Bearer @{cernerToken}
Body: {
"script_name": "QUALITY_MEASURES_EXTRACT",
"parameters": {
"start_date": "@{formatDateTime(addDays(utcnow(), -1), 'dd-MMM-yyyy')}",
"end_date": "@{formatDateTime(utcnow(), 'dd-MMM-yyyy')}"
},
"output_format": "CSV"
}
# Wait for completion
Do until complete (max 30 minutes)
Delay: 1 minute
HTTP: Check job status
Condition: status = "completed"
# Download results
HTTP: Download output file
URI: @{jobOutputUrl}
# Upload to Azure Blob Storage
Create blob
Container: cerner-extracts
Blob name: quality_measures_@{formatDateTime(utcnow(), 'yyyyMMdd')}.csv
Content: @{body('HTTP_Download')}
# Trigger Power BI refresh
Refresh dataset (Power BI)
Dataset: Quality Measures Dashboard
# Validate data quality
Get rows (Power BI)
Dataset: Quality Measures
Table: FactQuality
# Check record count
Condition: Record count < expected threshold
THEN
Send alert: Data quality issue - low record count
Create incident ticket
Common HL7 message types in healthcare:
using NHapi.Base.Parser;
using NHapi.Model.V24.Message;
public class HL7MessageProcessor
{
private readonly PipeParser _parser = new PipeParser();
public PatientAdmission ProcessADT(string hl7Message)
{
var message = _parser.Parse(hl7Message);
if (message is ADT_A01 adt) // Admit message
{
var pid = adt.PID; // Patient Identification
var pv1 = adt.PV1; // Patient Visit
return new PatientAdmission
{
MRN = pid.GetPatientIdentifierList(0).ID.Value,
PatientName = $"{pid.GetPatientName(0).FamilyName.Surname.Value}, {pid.GetPatientName(0).GivenName.Value}",
DateOfBirth = ParseHL7Date(pid.DateTimeOfBirth.TimeOfAnEvent.Value),
AdmitDateTime = ParseHL7DateTime(pv1.AdmitDateTime.TimeOfAnEvent.Value),
Location = pv1.AssignedPatientLocation.PointOfCare.Value,
AttendingPhysician = $"Dr. {pv1.GetAttendingDoctor(0).FamilyName.Surname.Value}",
AdmitDiagnosis = pv1.AdmitDiagnosis(0).Identifier.Value
};
}
throw new ArgumentException("Not an ADT^A01 message");
}
public LabResult ProcessORU(string hl7Message)
{
var message = _parser.Parse(hl7Message);
if (message is ORU_R01 oru) // Lab results
{
var results = new List<LabObservation>();
foreach (var orderObservation in oru.GetRESPONSE().GetORDER_OBSERVATION())
{
foreach (var observation in orderObservation.GetOBSERVATION())
{
var obx = observation.OBX;
results.Add(new LabObservation
{
TestCode = obx.ObservationIdentifier.Identifier.Value,
TestName = obx.ObservationIdentifier.Text.Value,
Result = obx.GetObservationValue(0).Data.ToString(),
Units = obx.Units.Identifier.Value,
ReferenceRange = obx.ReferencesRange.Value,
AbnormalFlag = obx.AbnormalFlags(0).Value,
ObservationDateTime = ParseHL7DateTime(obx.DateTimeOfTheObservation.TimeOfAnEvent.Value)
});
}
}
return new LabResult
{
PatientMRN = oru.GetRESPONSE().PATIENT.PID.GetPatientIdentifierList(0).ID.Value,
OrderNumber = oru.GetRESPONSE().GetORDER_OBSERVATION(0).ORC.PlacerOrderNumber.EntityIdentifier.Value,
Observations = results
};
}
throw new ArgumentException("Not an ORU^R01 message");
}
private DateTime ParseHL7Date(string hl7Date)
{
// HL7 date format: YYYYMMDD
return DateTime.ParseExact(hl7Date, "yyyyMMdd", null);
}
private DateTime ParseHL7DateTime(string hl7DateTime)
{
// HL7 datetime format: YYYYMMDDHHmmss
return DateTime.ParseExact(hl7DateTime, "yyyyMMddHHmmss", null);
}
}
// Transform FHIR resources to Power BI-friendly format
public class FHIRToPowerBIMapper
{
public DataTable MapPatients(List<Patient> fhirPatients)
{
var dt = new DataTable("Patients");
dt.Columns.Add("PatientID", typeof(string));
dt.Columns.Add("MRN", typeof(string));
dt.Columns.Add("FirstName", typeof(string));
dt.Columns.Add("LastName", typeof(string));
dt.Columns.Add("DateOfBirth", typeof(DateTime));
dt.Columns.Add("Gender", typeof(string));
dt.Columns.Add("Address", typeof(string));
dt.Columns.Add("City", typeof(string));
dt.Columns.Add("State", typeof(string));
dt.Columns.Add("ZipCode", typeof(string));
dt.Columns.Add("Phone", typeof(string));
dt.Columns.Add("PrimaryCareProvider", typeof(string));
foreach (var patient in fhirPatients)
{
var row = dt.NewRow();
row["PatientID"] = patient.Id;
row["MRN"] = patient.Identifier
.FirstOrDefault(i => i.System == "urn:oid:MRN")?.Value;
row["FirstName"] = patient.Name.FirstOrDefault()?.Given.FirstOrDefault();
row["LastName"] = patient.Name.FirstOrDefault()?.Family;
row["DateOfBirth"] = patient.BirthDate != null
? DateTime.Parse(patient.BirthDate)
: (object)DBNull.Value;
row["Gender"] = patient.Gender?.ToString();
var address = patient.Address.FirstOrDefault();
if (address != null)
{
row["Address"] = string.Join(", ", address.Line);
row["City"] = address.City;
row["State"] = address.State;
row["ZipCode"] = address.PostalCode;
}
row["Phone"] = patient.Telecom
.FirstOrDefault(t => t.System == ContactPoint.ContactPointSystem.Phone)?.Value;
row["PrimaryCareProvider"] = patient.GeneralPractitioner
.FirstOrDefault()?.Display;
dt.Rows.Add(row);
}
return dt;
}
public DataTable MapObservations(List<Observation> fhirObservations)
{
var dt = new DataTable("Observations");
dt.Columns.Add("ObservationID", typeof(string));
dt.Columns.Add("PatientID", typeof(string));
dt.Columns.Add("Code", typeof(string));
dt.Columns.Add("CodeSystem", typeof(string));
dt.Columns.Add("DisplayName", typeof(string));
dt.Columns.Add("ValueQuantity", typeof(decimal));
dt.Columns.Add("ValueUnit", typeof(string));
dt.Columns.Add("ValueString", typeof(string));
dt.Columns.Add("Category", typeof(string));
dt.Columns.Add("EffectiveDateTime", typeof(DateTime));
dt.Columns.Add("Status", typeof(string));
foreach (var obs in fhirObservations)
{
var row = dt.NewRow();
row["ObservationID"] = obs.Id;
row["PatientID"] = obs.Subject?.Reference;
row["Code"] = obs.Code?.Coding?.FirstOrDefault()?.Code;
row["CodeSystem"] = obs.Code?.Coding?.FirstOrDefault()?.System;
row["DisplayName"] = obs.Code?.Coding?.FirstOrDefault()?.Display;
if (obs.Value is Quantity qty)
{
row["ValueQuantity"] = qty.Value;
row["ValueUnit"] = qty.Unit;
}
else if (obs.Value is FhirString str)
{
row["ValueString"] = str.Value;
}
row["Category"] = obs.Category?.FirstOrDefault()?.Coding?.FirstOrDefault()?.Code;
row["EffectiveDateTime"] = (obs.Effective as FhirDateTime)?.ToDateTime();
row["Status"] = obs.Status?.ToString();
dt.Rows.Add(row);
}
return dt;
}
}
Must remove 18 identifiers:
public class DeIdentifier
{
public Patient DeIdentifyPatient(Patient patient)
{
var deidentified = patient.DeepCopy();
// Remove direct identifiers
deidentified.Name = null; // Names
deidentified.Telecom = null; // Phone, email
deidentified.Address = null; // Address (except state)
// Keep state for geographic analysis
if (patient.Address?.FirstOrDefault()?.State != null)
{
deidentified.Address = new List<Address>
{
new Address { State = patient.Address.First().State }
};
}
// Remove dates except year
if (patient.BirthDate != null)
{
var birthYear = DateTime.Parse(patient.BirthDate).Year;
// For patients 90+, set to 90
var age = DateTime.Now.Year - birthYear;
if (age >= 90)
{
deidentified.BirthDate = DateTime.Now.AddYears(-90).ToString("yyyy");
}
else
{
deidentified.BirthDate = birthYear.ToString();
}
}
// Replace MRN with pseudonym
foreach (var identifier in deidentified.Identifier)
{
identifier.Value = HashIdentifier(identifier.Value);
}
// Remove photos
deidentified.Photo = null;
return deidentified;
}
private string HashIdentifier(string value)
{
using (var sha = SHA256.Create())
{
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + _salt));
return "HASH_" + BitConverter.ToString(hash).Replace("-", "").Substring(0, 16);
}
}
}
// Ensure k-anonymity (each record indistinguishable from k-1 others)
public class KAnonymizer
{
public DataTable ApplyKAnonymity(DataTable data, int k = 5)
{
// Group by quasi-identifiers (age, gender, zip)
var groups = data.AsEnumerable()
.GroupBy(r => new
{
AgeGroup = GetAgeGroup((int)r["Age"]),
Gender = r["Gender"].ToString(),
ZipPrefix = r["Zip"].ToString().Substring(0, 3)
});
var result = data.Clone();
foreach (var group in groups)
{
// Only include groups with at least k members
if (group.Count() >= k)
{
foreach (var row in group)
{
var newRow = result.NewRow();
newRow.ItemArray = row.ItemArray;
// Generalize quasi-identifiers
newRow["Age"] = GetAgeGroup((int)row["Age"]);
newRow["Zip"] = row["Zip"].ToString().Substring(0, 3) + "**";
result.Rows.Add(newRow);
}
}
// Groups < k are suppressed (not included)
}
return result;
}
private string GetAgeGroup(int age)
{
if (age < 18) return "<18";
if (age < 30) return "18-29";
if (age < 40) return "30-39";
if (age < 50) return "40-49";
if (age < 60) return "50-59";
if (age < 70) return "60-69";
if (age < 80) return "70-79";
if (age < 90) return "80-89";
return "90+";
}
}
# Before using patient data for analytics
When dataset refresh triggered
Dataset: Patient Analytics
# Extract data from EMR
HTTP: Get patient data (FHIR)
[Retrieve patient records]
# De-identify before loading to Power BI
HTTP: Call de-identification service
Method: POST
URI: https://deidentification-func.azurewebsites.net/api/deidentify
Body: {
"data": @{patientData},
"method": "safe_harbor",
"k_anonymity": 5,
"retain_fields": ["state", "age_group", "diagnosis_category"]
}
Parse JSON: De-identified data
# Load to staging database
Bulk insert (SQL)
Table: StagingPatientData
Data: @{deidentifiedData}
# Refresh Power BI
Refresh dataset
Dataset: Patient Analytics
# Log de-identification for audit
Add row to audit log
Action: De-identification
Records Processed: @{recordCount}
Method: Safe Harbor + K-Anonymity
Timestamp: @{utcnow()}
# Monitor for sepsis criteria, alert care team immediately
# Triggered by lab results (HL7 ORU message)
When Service Bus message received
Topic: lab-results
Parse HL7: Lab result message
# Extract relevant values
Set variables:
PatientMRN: @{pid.3}
WBC: @{obx.value[where code='WBC']}
Temperature: @{obx.value[where code='TEMP']}
Lactate: @{obx.value[where code='LACT']}
SystolicBP: @{obx.value[where code='SBP']}
# Query recent vital signs and labs
Get rows (SQL)
Query:
"""
SELECT TOP 1 *
FROM PatientVitals
WHERE MRN = '@{PatientMRN}'
ORDER BY RecordedTime DESC
"""
# Apply sepsis screening criteria (qSOFA)
Set variable: qSOFA_Score = 0
# Respiratory rate >= 22
Condition: RespiratoryRate >= 22
THEN: Increment qSOFA_Score
# Altered mental status
Condition: GCS < 15
THEN: Increment qSOFA_Score
# Systolic BP <= 100
Condition: SystolicBP <= 100
THEN: Increment qSOFA_Score
# Check SIRS criteria
Set variable: SIRS_Score = 0
Condition: Temperature < 36 OR Temperature > 38
THEN: Increment SIRS_Score
Condition: HeartRate > 90
THEN: Increment SIRS_Score
Condition: RespiratoryRate > 20
THEN: Increment SIRS_Score
Condition: WBC < 4 OR WBC > 12
THEN: Increment SIRS_Score
# Alert if criteria met
Condition: qSOFA_Score >= 2 OR (SIRS_Score >= 2 AND Lactate > 2)
THEN
# Get patient details
HTTP: Get patient info (Epic FHIR)
Patient ID: @{PatientMRN}
# Get care team
HTTP: Get care team
Patient ID: @{PatientMRN}
# Create sepsis alert
HTTP: Create alert (EMR)
Priority: STAT
Type: Sepsis Alert
Patient: @{PatientMRN}
# Notify care team via Teams
Post adaptive card (Teams)
Recipients: @{careTeam}
Card:
Title: "π SEPSIS ALERT"
Subtitle: "Patient: @{patientName} | Room: @{roomNumber}"
Body:
Facts:
- qSOFA Score: @{qSOFA_Score}
- SIRS Score: @{SIRS_Score}
- Lactate: @{Lactate} mmol/L
- BP: @{SystolicBP}/@{DiastolicBP}
- Temp: @{Temperature}Β°C
- WBC: @{WBC} K/Β΅L
Alert Time: @{utcnow()}
Actions:
- Acknowledge [Button]
- Start Sepsis Bundle [Button]
- View Chart [Link to EMR]
# Page physician if not acknowledged in 5 minutes
Delay: 5 minutes
Condition: Alert not acknowledged
THEN
HTTP: Send page
To: @{attendingPhysician}
Message: "SEPSIS ALERT - @{patientName} - @{roomNumber}"
# Log in compliance database
Add row (SQL)
Table: SepsisAlerts
Columns:
MRN: @{PatientMRN}
AlertTime: @{utcnow()}
qSOFA: @{qSOFA_Score}
SIRS: @{SIRS_Score}
NotifiedTeam: @{careTeam}
AcknowledgedBy: [null - updated when acknowledged]
BundleStarted: [null - updated when started]
// Check for drug interactions when new medication ordered
public async Task<InteractionResult> CheckMedicationInteractions(
string patientId,
string newMedicationCode)
{
// Get patient's current medications (from FHIR)
var currentMeds = await _fhirClient.SearchAsync<MedicationRequest>(
new SearchParams()
.Where($"patient={patientId}")
.Where("status=active")
);
var interactions = new List<DrugInteraction>();
foreach (var med in currentMeds.Entry.Select(e => e.Resource as MedicationRequest))
{
// Call drug interaction database API
var interaction = await _drugDbClient.CheckInteraction(
med.MedicationCodeableConcept.Coding.First().Code,
newMedicationCode
);
if (interaction.Severity == "High" || interaction.Severity == "Moderate")
{
interactions.Add(interaction);
}
}
return new InteractionResult
{
HasInteractions = interactions.Any(),
Interactions = interactions,
RequiresPharmacistReview = interactions.Any(i => i.Severity == "High")
};
}
# Automatically identify patients for diabetes registry
Trigger: Recurrence
Frequency: Weekly
Day: Monday
Time: 06:00
# Query patients with diabetes diagnosis
HTTP: Search patients (FHIR)
Query:
"""
Condition?code=http://snomed.info/sct|44054006,73211009,8801005
&clinical-status=active
&_include=Condition:patient
"""
Parse JSON: Diabetes patients
# For each patient, get recent HbA1c
Apply to each patient:
HTTP: Get observations (FHIR)
Patient: @{currentPatient.id}
Code: http://loinc.org|4548-4 (HbA1c)
Date: ge@{addMonths(utcnow(), -6)}
Parse JSON: HbA1c results
# Identify gaps in care
Set variable: hasRecentHbA1c = @{length(HbA1cResults) > 0}
Condition: hasRecentHbA1c = false
THEN
# Patient needs HbA1c
Add to array: patientsNeedingHbA1c
# Check if controlled
Condition: Latest HbA1c > 7.0
THEN
Add to array: uncontrolledDiabetes
# Check eye exam
HTTP: Get eye exam procedures
Patient: @{currentPatient.id}
Code: diabetic eye exam
Date: ge@{addYears(utcnow(), -1)}
Condition: No eye exam in last year
THEN
Add to array: needsEyeExam
# Generate care gap report
Create Excel file
Name: Diabetes_Care_Gaps_@{formatDateTime(utcnow(), 'yyyy-MM-dd')}.xlsx
Sheets:
- Summary
Total Patients: @{totalPatients}
Controlled (<7%): @{controlledCount}
Uncontrolled (>=7%): @{uncontrolledCount}
Missing HbA1c: @{length(patientsNeedingHbA1c)}
Missing Eye Exam: @{length(needsEyeExam)}
- Action List
Columns: PatientName, MRN, LastHbA1c, LastEyeExam, PCP, CareGaps
# Update Power BI dataset
Bulk insert (SQL)
Table: DiabetesRegistry
Data: @{diabetesPatients}
Refresh dataset (Power BI)
Dataset: Population Health Dashboard
# Send to care coordinators
Apply to each PCP:
# Filter patients for this PCP
Set variable: pcpPatients
Value: @{filter(needsEyeExam, 'PCP' = currentPCP)}
# Send personalized report
Send email
To: @{currentPCP.Email}
Subject: "Diabetes Care Gaps - Action Required (@{length(pcpPatients)} patients)"
Body:
"""
The following patients in your panel need outreach:
@{join(map(pcpPatients, 'PatientName + " - " + CareGap'), '\n')}
Full report attached.
"""
Attachments: [Filtered Excel for this PCP]
// Calculate patient risk scores for population health
public class RiskStratificationEngine
{
public PatientRiskScore CalculateRiskScore(Patient patient, List<Condition> conditions, List<Observation> vitals, List<MedicationRequest> medications)
{
var score = 0;
var riskFactors = new List<string>();
// Age factor
var age = CalculateAge(patient.BirthDate);
if (age >= 65) { score += 10; riskFactors.Add("Age 65+"); }
else if (age >= 50) { score += 5; }
// Chronic conditions
var chronicConditions = new Dictionary<string, int>
{
{ "73211009", 15 }, // Diabetes
{ "38341003", 15 }, // Hypertension
{ "49601007", 20 }, // CHF
{ "13645005", 25 }, // COPD
{ "53741008", 20 } // CAD
};
foreach (var condition in conditions)
{
var code = condition.Code.Coding.FirstOrDefault()?.Code;
if (chronicConditions.ContainsKey(code))
{
score += chronicConditions[code];
riskFactors.Add(condition.Code.Text);
}
}
// Recent hospitalizations
var recentAdmits = conditions
.Where(c => c.OnsetDateTime > DateTime.Now.AddMonths(-6))
.Count();
score += recentAdmits * 15;
if (recentAdmits > 0)
riskFactors.Add($"{recentAdmits} recent hospitalizations");
// Medication count (polypharmacy)
var activeMeds = medications.Count(m => m.Status == MedicationRequest.MedicationrequestStatus.Active);
if (activeMeds >= 10) { score += 10; riskFactors.Add("Polypharmacy (10+ meds)"); }
else if (activeMeds >= 5) { score += 5; }
// Recent vitals - uncontrolled
var recentBP = vitals.FirstOrDefault(v =>
v.Code.Coding.Any(c => c.Code == "85354-9") && // Systolic BP
v.EffectiveDateTime > DateTime.Now.AddMonths(-3)
);
if (recentBP != null)
{
var systolic = (recentBP.Value as Quantity)?.Value;
if (systolic >= 140)
{
score += 10;
riskFactors.Add("Uncontrolled BP");
}
}
// Determine risk category
string riskLevel;
if (score >= 60) riskLevel = "Very High";
else if (score >= 40) riskLevel = "High";
else if (score >= 20) riskLevel = "Moderate";
else riskLevel = "Low";
return new PatientRiskScore
{
PatientId = patient.Id,
Score = score,
RiskLevel = riskLevel,
RiskFactors = riskFactors,
CalculatedDate = DateTime.Now,
RecommendedInterventions = GetInterventions(riskLevel, riskFactors)
};
}
private List<string> GetInterventions(string riskLevel, List<string> riskFactors)
{
var interventions = new List<string>();
if (riskLevel == "Very High" || riskLevel == "High")
{
interventions.Add("Assign to care management program");
interventions.Add("Monthly care coordinator calls");
interventions.Add("Medication reconciliation");
}
if (riskFactors.Contains("Uncontrolled BP"))
{
interventions.Add("Schedule HTN follow-up");
interventions.Add("Consider medication adjustment");
}
if (riskFactors.Any(f => f.Contains("hospitalization")))
{
interventions.Add("Post-discharge follow-up within 7 days");
interventions.Add("Medication adherence counseling");
}
return interventions;
}
}
# Automated tracking of CMS quality measures
Trigger: Recurrence
Frequency: Daily
Time: 08:00
# Query eligible patients for each measure
# Example: AMI-7a (Fibrinolytic Therapy Received Within 30 Minutes)
HTTP: Get AMI patients (FHIR)
Query:
"""
Encounter?diagnosis=I21
&period=ge@{startOfMonth()}&period=le@{endOfMonth()}
&_include=Encounter:patient
&_include=Encounter:diagnosis
"""
Parse JSON: AMI encounters
Apply to each encounter:
# Get medication administration times
HTTP: Get medications
Encounter: @{currentEncounter.id}
Code: fibrinolytic
# Calculate door-to-needle time
Set variable: arrivalTime = @{currentEncounter.period.start}
Set variable: medicationTime = @{firstMedication.effectiveDateTime}
Set variable: doorToNeedleMinutes = @{minutes(arrivalTime, medicationTime)}
# Check if meets measure
Condition: doorToNeedleMinutes <= 30
THEN
Add to: measuresMetCount
ELSE
Add to: measuresNotMetCount
# Document why (for improvement)
Add to exceptions log
# Calculate measure performance
Compose: AMI-7a Performance
Numerator: @{measuresMetCount}
Denominator: @{totalEligiblePatients}
Rate: @{divide(measuresMetCount, totalEligiblePatients)}
Target: 90%
Status: @{IF(rate >= 0.90, 'Met', 'Not Met')}
# Update Power BI
Add rows (SQL)
Table: QualityMeasures
Data: @{measurePerformance}
Refresh dataset (Power BI)
Dataset: Quality Dashboard
# Alert if below threshold
Condition: Rate < target
THEN
Post message (Teams)
Channel: Quality Improvement
Message:
"β Quality Measure Alert
Measure: AMI-7a (Fibrinolytic <30 min)
Performance: @{format(rate, 'P')}
Target: 90%
Gap: @{gapCount} patients
Action required: Review cases and identify barriers"
# Monitor 30-day readmissions
When patient discharged (ADT^A03 event)
# Record discharge
Add row (SQL)
Table: Discharges
MRN: @{patientMRN}
DischargeDate: @{dischargeDateTime}
Diagnosis: @{dischargeDiagnosis}
DischargeLocation: @{dischargeLocation}
# Schedule check in 35 days (catch 30-day readmits)
Delay until: @{addDays(dischargeDateTime, 35)}
# Query for readmissions
Get rows (SQL)
Query:
"""
SELECT * FROM Admissions
WHERE MRN = '@{patientMRN}'
AND AdmitDate BETWEEN
DATEADD(day, 1, '@{dischargeDateTime}')
AND DATEADD(day, 30, '@{dischargeDateTime}')
"""
Condition: Readmission found
THEN
# Flag as readmission
Update row (SQL)
Table: Discharges
Set: Readmitted = true,
ReadmitDate = @{readmitDate},
DaysBetween = @{daysBetween}
# Categorize (planned vs unplanned)
HTTP: Categorize readmission
Original Diagnosis: @{dischargeDiagnosis}
Readmit Diagnosis: @{readmitDiagnosis}
# If unplanned, trigger review
Condition: Type = 'Unplanned'
THEN
Create task (case review)
Assigned to: Quality team
Patient: @{patientMRN}
Type: Readmission Review
Due: 7 days
# Update metrics
Refresh dataset (Power BI)
Dataset: Readmission Tracking
ββββββββββββββββββββββββββββββββββ
β Layer 1: Network Security β
β - Azure Private Link β
β - VNet integration β
β - NSG rules β
ββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββ
β Layer 2: Identity & Access β
β - Azure AD authentication β
β - Service principal with certificate β
β - Row-level security (RLS) β
β - Least privilege access β
ββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββ
β Layer 3: Data Protection β
β - TLS 1.2+ in transit β
β - Encryption at rest (AES-256) β
β - Customer-managed keys (optional) β
β - Data masking β
ββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββ
β Layer 4: Application Security β
β - Input validation β
β - Output encoding β
β - API rate limiting β
β - Token management β
ββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββ
β Layer 5: Monitoring & Response β
β - Azure Monitor β
β - Security alerts β
β - Audit logging β
β - Incident response β
ββββββββββββββββββββββββββββββββββ
| Role | View Aggregate Data | View Individual Records | Export Data | Modify Data | Admin Functions |
|---|---|---|---|---|---|
| Executive | β | β | β | β | β |
| Quality Analyst | β | β (de-identified) | β (de-identified) | β | β |
| Care Manager | β | β (assigned patients) | β | β | β |
| Physician | β | β (their patients) | β | β | β |
| Privacy Officer | β | β (audit purposes) | β (audit) | β | β |
| IT Admin | β | β | β | β | β (system only) |
| Automation Service | β | β (with RLS) | β (de-identified) | β | β |
// RLS rule: CareManagerRole
[AssignedCareManager] = USERPRINCIPALNAME()
// RLS rule: PhysicianRole
[AttendingPhysician] = USERPRINCIPALNAME()
OR [PrimaryCareProvider] = USERPRINCIPALNAME()
// RLS rule: QualityAnalystRole
// Return de-identified view
[AccessLevel] = "Aggregate"
// RLS rule: ExecutiveRole
[DataView] = "Summary"
AND [PatientCount] >= 10 // Prevent re-identification
Must log:
[FunctionName("AccessPHI")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
[CosmosDB(
databaseName: "HIPAACompliance",
collectionName: "AuditLog",
ConnectionStringSetting = "CosmosDBConnection"
)] IAsyncCollector<AuditEntry> auditLog,
ILogger log)
{
var userId = req.Headers["X-User-Id"];
var patientId = req.Query["patientId"];
var action = req.Query["action"];
// Log BEFORE granting access
var auditEntry = new AuditEntry
{
EventId = Guid.NewGuid().ToString(),
Timestamp = DateTime.UtcNow,
UserId = userId,
UserRole = await GetUserRole(userId),
PatientId = HashPII(patientId), // Hash for privacy
Action = action,
IPAddress = req.HttpContext.Connection.RemoteIpAddress.ToString(),
UserAgent = req.Headers["User-Agent"].ToString(),
Justification = req.Headers["X-Access-Justification"],
Result = "Pending"
};
await auditLog.AddAsync(auditEntry);
// Perform authorization check
var authorized = await CheckAuthorization(userId, patientId, action);
// Update audit result
auditEntry.Result = authorized ? "Authorized" : "Denied";
auditEntry.CompletedTimestamp = DateTime.UtcNow;
await auditLog.AddAsync(auditEntry);
if (!authorized)
{
log.LogWarning($"Unauthorized PHI access attempt: {userId} -> {patientId}");
return new UnauthorizedResult();
}
// Grant access
var data = await GetPHI(patientId);
return new OkObjectResult(data);
}
public class AuditEntry
{
public string EventId { get; set; }
public DateTime Timestamp { get; set; }
public string UserId { get; set; }
public string UserRole { get; set; }
public string PatientId { get; set; } // Hashed
public string Action { get; set; }
public string IPAddress { get; set; }
public string UserAgent { get; set; }
public string Justification { get; set; }
public string Result { get; set; }
public DateTime? CompletedTimestamp { get; set; }
public int DurationMs => CompletedTimestamp.HasValue
? (int)(CompletedTimestamp.Value - Timestamp).TotalMilliseconds
: 0;
}
# HIPAA requires 6 years minimum retention
# Weekly archival to cold storage
Trigger: Recurrence
Frequency: Weekly
Day: Sunday
Time: 02:00
# Get logs older than 90 days (move to cold storage)
HTTP: Query Cosmos DB
Query:
"""
SELECT * FROM AuditLog
WHERE timestamp < '@{addDays(utcnow(), -90)}'
AND archived = false
"""
Apply to each log entry:
# Copy to Azure Blob (Archive tier)
Create blob
Container: hipaa-audit-archive
Path: /@{year}/@{month}/@{day}/@{logId}.json
Content: @{currentLog}
Access Tier: Archive
# Mark as archived
Update document (Cosmos DB)
Id: @{logId}
Archived: true
ArchiveLocation: @{blobPath}
# After 6 years, eligible for deletion (retain longer if needed)
# Separate job handles final deletion after retention period
Organization profile:
Challenge:
Solution implemented:
Epic FHIR API
β
Azure Service Bus (ADT events)
β
Azure Functions (Processing)
β
Azure SQL Database
β
Power BI (Real-time dashboards)
β
Power Automate (Alerts & workflows)
Indicative results (directional benchmarks):
Key success factors:
Organization profile:
Challenge:
Solution:
# Weekly population health refresh
Trigger: Weekly (Monday 6 AM)
# Extract from Cerner FHIR
- Get active patients (50,000)
- Get conditions (chronic diseases)
- Get recent labs (HbA1c, lipids, etc.)
- Get procedures (eye exams, foot exams)
# Process through business rules
- Identify care gaps
- Calculate risk scores
- Flag for outreach
# Update Power BI
- Population health dashboard
- Provider-specific panels
- Quality measure tracking
# Distribute reports
- Email care coordinators
- Alert for high-risk patients
- Track outreach activities
Indicative results (directional benchmarks):
Ongoing:
Typical healthcare implementation (benchmark-based directional estimates):
| Component | Cost Range |
|---|---|
| Professional services (implementation) | $125K β $200K |
| Power BI Premium licenses | $5K β $20K/year |
| Azure infrastructure | $2K β $8K/year |
| Training | $10K β $15K |
| Total Year 1 | $142K β $243K |
| Ongoing (annual) | $15K β $35K |
ROI typically 300β600% over 3 years (benchmark-based directional estimate).
Healthcare BI automation delivers measurable clinical and financial outcomes while maintaining strict HIPAA compliance. Success requires:
Getting started: Contact MBIC for healthcare-specific automation β HIPAA compliance assessment, EMR integration planning, security architecture review, and implementation services. Email: hello@mbic.us Β· Website: mbic.us
Prefer the PDF? Download the original
Get a free AI & Automation Opportunity Audit β mbic.us/ai-audit.html β or book 15 minutes β mbic.us/contact.html.