324 lines
12 KiB
C#
324 lines
12 KiB
C#
using System.Text.Json;
|
|
using VigilCare.Simulation;
|
|
|
|
public record MimicGenerateOptions(
|
|
int? MaxHours = null,
|
|
bool IncludeMedications = true,
|
|
bool IncludeLabs = true);
|
|
|
|
public class MimicScenarioBuilder
|
|
{
|
|
private readonly MimicDataLoader _loader;
|
|
|
|
private static readonly string[] ObservationPriority =
|
|
[
|
|
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP", "TEMP_C", "SPO2",
|
|
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR",
|
|
"FIO2_PCT", "PAO2_MMHG",
|
|
"CREATININE_MG_DL", "BILIRUBIN_MG_DL", "PLATELET_K_UL",
|
|
"WBC_K_UL", "POTASSIUM_MEQ_L", "LACTATE_MMOL_L", "GLUCOSE_MG_DL",
|
|
"URINE_OUTPUT_ML_H"
|
|
];
|
|
|
|
public MimicScenarioBuilder(MimicDataLoader loader)
|
|
{
|
|
_loader = loader;
|
|
}
|
|
|
|
public (ScenarioFile Scenario, List<string> Warnings) Build(
|
|
MimicIcuStay stay, MimicAdmission admission, MimicPatient patient,
|
|
MimicGenerateOptions options)
|
|
{
|
|
var warnings = new List<string>();
|
|
var scenarioStart = stay.InTime;
|
|
var scenarioEnd = stay.OutTime;
|
|
|
|
if (options.MaxHours.HasValue)
|
|
{
|
|
var maxEnd = scenarioStart.AddHours(options.MaxHours.Value);
|
|
if (maxEnd < scenarioEnd)
|
|
scenarioEnd = maxEnd;
|
|
}
|
|
|
|
var scenarioPatient = BuildPatient(patient, admission);
|
|
var encounter = BuildEncounter(stay, admission);
|
|
var events = BuildEvents(stay, admission, scenarioStart, scenarioEnd, options, warnings);
|
|
var meta = BuildMeta(stay, admission, patient, scenarioStart, scenarioEnd, events);
|
|
|
|
var scenario = new ScenarioFile(meta, scenarioPatient, encounter, events, ExpectedOutcomes: null);
|
|
return (scenario, warnings);
|
|
}
|
|
|
|
private static ScenarioPatient BuildPatient(MimicPatient patient, MimicAdmission admission)
|
|
{
|
|
var birthYear = DateTime.UtcNow.Year - patient.AnchorAge;
|
|
var dob = new DateTime(birthYear, 7, 1);
|
|
|
|
return new ScenarioPatient(
|
|
FirstName: $"MIMIC-{patient.SubjectId}",
|
|
LastName: $"S{admission.HadmId}",
|
|
DateOfBirth: dob.ToString("yyyy-MM-dd"),
|
|
Gender: patient.Gender == "F" ? "Female" : "Male");
|
|
}
|
|
|
|
private static ScenarioEncounter BuildEncounter(MimicIcuStay stay, MimicAdmission admission)
|
|
{
|
|
return new ScenarioEncounter(
|
|
Department: MimicCareUnitMap.ToVigilCareDepartment(stay.FirstCareUnit),
|
|
EncounterType: MimicCareUnitMap.ToVigilCareEncounterType(admission.AdmissionType),
|
|
AttendingPhysician: "MIMIC-Physician",
|
|
RoomBed: $"ICU-{stay.StayId % 100:D2}",
|
|
AdmissionReason: $"MIMIC-IV admission ({admission.AdmissionType}, from {admission.AdmissionLocation ?? "unknown"})");
|
|
}
|
|
|
|
private List<ScenarioEvent> BuildEvents(
|
|
MimicIcuStay stay, MimicAdmission admission,
|
|
DateTime scenarioStart, DateTime scenarioEnd,
|
|
MimicGenerateOptions options, List<string> warnings)
|
|
{
|
|
var rawObs = CollectObservations(stay, admission, scenarioStart, scenarioEnd, options);
|
|
var deduplicated = DeduplicateBloodPressure(rawObs);
|
|
var events = new List<ScenarioEvent>();
|
|
|
|
foreach (var obs in deduplicated)
|
|
{
|
|
var offsetMinutes = Math.Round((obs.ChartTime - scenarioStart).TotalMinutes);
|
|
if (offsetMinutes < 0) offsetMinutes = 0;
|
|
|
|
var data = JsonSerializer.SerializeToElement(new
|
|
{
|
|
code = obs.Code,
|
|
value = obs.Value,
|
|
unit = obs.Unit,
|
|
source = obs.Source
|
|
}, SerializerOptions);
|
|
|
|
events.Add(new ScenarioEvent(offsetMinutes, "observation", data, null));
|
|
}
|
|
|
|
if (options.IncludeMedications)
|
|
{
|
|
var meds = CollectMedications(admission, scenarioStart, scenarioEnd, warnings);
|
|
events.AddRange(meds);
|
|
}
|
|
|
|
events = events.OrderBy(e => e.OffsetMinutes).ToList();
|
|
events = EnforceClusterLimit(events, warnings);
|
|
return events;
|
|
}
|
|
|
|
private List<RawObservation> CollectObservations(
|
|
MimicIcuStay stay, MimicAdmission admission,
|
|
DateTime scenarioStart, DateTime scenarioEnd,
|
|
MimicGenerateOptions options)
|
|
{
|
|
var observations = new List<RawObservation>();
|
|
|
|
foreach (var ce in _loader.StreamChartEvents(stay.StayId))
|
|
{
|
|
if (ce.ChartTime < scenarioStart || ce.ChartTime > scenarioEnd) continue;
|
|
if (!MimicItemMap.TryMapChartEvent(ce.ItemId, out var mapping)) continue;
|
|
if (ce.ValueNum is null) continue;
|
|
|
|
var value = MimicItemMap.ConvertValue(ce.ValueNum.Value, mapping);
|
|
observations.Add(new RawObservation(
|
|
ce.ChartTime, mapping.Code, value, mapping.Unit, mapping.Source, mapping.Priority));
|
|
}
|
|
|
|
if (options.IncludeLabs)
|
|
{
|
|
var labCodes = new HashSet<(DateTime time, string code)>(
|
|
observations.Select(o => (o.ChartTime, o.Code)));
|
|
|
|
foreach (var le in _loader.StreamLabEvents(admission.HadmId, scenarioStart, scenarioEnd))
|
|
{
|
|
if (!MimicItemMap.TryMapLabEvent(le.ItemId, out var mapping)) continue;
|
|
if (le.ValueNum is null) continue;
|
|
|
|
if (labCodes.Contains((le.ChartTime, mapping.Code)))
|
|
continue;
|
|
|
|
observations.Add(new RawObservation(
|
|
le.ChartTime, mapping.Code, le.ValueNum.Value, mapping.Unit, "Lab", 0));
|
|
}
|
|
}
|
|
|
|
return observations.OrderBy(o => o.ChartTime).ToList();
|
|
}
|
|
|
|
private static List<RawObservation> DeduplicateBloodPressure(List<RawObservation> observations)
|
|
{
|
|
var bpGroups = observations
|
|
.Where(o => o.Code is "SYSTOLIC_BP" or "DIASTOLIC_BP")
|
|
.GroupBy(o => (Time: RoundToMinute(o.ChartTime), o.Code));
|
|
|
|
var removals = new HashSet<RawObservation>();
|
|
foreach (var group in bpGroups)
|
|
{
|
|
var items = group.ToList();
|
|
if (items.Count <= 1) continue;
|
|
|
|
var hasPrimary = items.Any(i => i.Priority == 0);
|
|
if (hasPrimary)
|
|
{
|
|
foreach (var fallback in items.Where(i => i.Priority > 0))
|
|
removals.Add(fallback);
|
|
}
|
|
}
|
|
|
|
return removals.Count > 0
|
|
? observations.Where(o => !removals.Contains(o)).ToList()
|
|
: observations;
|
|
}
|
|
|
|
private List<ScenarioEvent> CollectMedications(
|
|
MimicAdmission admission, DateTime scenarioStart, DateTime scenarioEnd,
|
|
List<string> warnings)
|
|
{
|
|
var events = new List<ScenarioEvent>();
|
|
var count = 0;
|
|
var skipped = 0;
|
|
|
|
foreach (var rx in _loader.StreamPrescriptions(admission.HadmId, scenarioStart, scenarioEnd))
|
|
{
|
|
var dose = ParseDose(rx.DoseValRx);
|
|
if (dose is null || string.IsNullOrWhiteSpace(rx.DoseUnitRx)
|
|
|| string.IsNullOrWhiteSpace(rx.Route))
|
|
{
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
var offsetMinutes = Math.Round((rx.StartTime - scenarioStart).TotalMinutes);
|
|
if (offsetMinutes < 0) offsetMinutes = 0;
|
|
|
|
var data = JsonSerializer.SerializeToElement(new
|
|
{
|
|
drugName = rx.Drug,
|
|
dose = dose.Value,
|
|
doseUnit = rx.DoseUnitRx,
|
|
route = rx.Route,
|
|
administeredBy = "MIMIC-RN"
|
|
}, SerializerOptions);
|
|
|
|
events.Add(new ScenarioEvent(offsetMinutes, "medication", data, null));
|
|
count++;
|
|
}
|
|
|
|
if (skipped > 0)
|
|
warnings.Add($"Skipped {skipped} prescriptions with missing dose/unit/route data");
|
|
|
|
return events;
|
|
}
|
|
|
|
private static List<ScenarioEvent> EnforceClusterLimit(
|
|
List<ScenarioEvent> events, List<string> warnings)
|
|
{
|
|
var result = new List<ScenarioEvent>();
|
|
var clusters = events.GroupBy(e => e.OffsetMinutes).OrderBy(g => g.Key).ToList();
|
|
var spillover = new List<(double offset, ScenarioEvent evt)>();
|
|
|
|
foreach (var cluster in clusters)
|
|
{
|
|
var obsInCluster = cluster.Where(e => e.Type == "observation").ToList();
|
|
var otherInCluster = cluster.Where(e => e.Type != "observation").ToList();
|
|
|
|
// Add any spillover from previous clusters at this offset
|
|
var spilled = spillover.Where(s => s.offset == cluster.Key).Select(s => s.evt).ToList();
|
|
spillover.RemoveAll(s => s.offset == cluster.Key);
|
|
obsInCluster.AddRange(spilled);
|
|
|
|
if (obsInCluster.Count > 10)
|
|
{
|
|
var sorted = obsInCluster
|
|
.OrderBy(e => GetObservationPriority(e))
|
|
.ToList();
|
|
|
|
var keep = sorted.Take(10).ToList();
|
|
var overflow = sorted.Skip(10).ToList();
|
|
|
|
warnings.Add(
|
|
$"Offset {cluster.Key}: split {obsInCluster.Count} observations " +
|
|
$"(moved {overflow.Count} to offset {cluster.Key + 1})");
|
|
|
|
foreach (var evt in overflow)
|
|
spillover.Add((cluster.Key + 1,
|
|
new ScenarioEvent(cluster.Key + 1, evt.Type, evt.Data, evt.Note)));
|
|
|
|
obsInCluster = keep;
|
|
}
|
|
|
|
result.AddRange(obsInCluster);
|
|
result.AddRange(otherInCluster);
|
|
}
|
|
|
|
// Handle any remaining spillover
|
|
foreach (var (offset, evt) in spillover.OrderBy(s => s.offset))
|
|
result.Add(evt);
|
|
|
|
return result.OrderBy(e => e.OffsetMinutes).ToList();
|
|
}
|
|
|
|
private static int GetObservationPriority(ScenarioEvent evt)
|
|
{
|
|
var code = evt.Data.TryGetProperty("code", out var codeProp)
|
|
? codeProp.GetString() : null;
|
|
if (code is null) return 999;
|
|
var idx = Array.IndexOf(ObservationPriority, code);
|
|
return idx >= 0 ? idx : 999;
|
|
}
|
|
|
|
private static ScenarioMeta BuildMeta(
|
|
MimicIcuStay stay, MimicAdmission admission, MimicPatient patient,
|
|
DateTime scenarioStart, DateTime scenarioEnd,
|
|
List<ScenarioEvent> events)
|
|
{
|
|
var durationMinutes = (int)(scenarioEnd - scenarioStart).TotalMinutes;
|
|
var obsCount = events.Count(e => e.Type == "observation");
|
|
var medCount = events.Count(e => e.Type == "medication");
|
|
|
|
var description =
|
|
$"Real de-identified MIMIC-IV data. " +
|
|
$"Subject {patient.SubjectId}, stay {stay.StayId}. " +
|
|
$"{(patient.Gender == "F" ? "Female" : "Male")}, age ~{patient.AnchorAge}. " +
|
|
$"ICU LOS: {stay.Los:F1} days. Care unit: {stay.FirstCareUnit}. " +
|
|
$"{obsCount} observations, {medCount} medications. " +
|
|
(admission.HospitalExpireFlag == 1
|
|
? "Patient expired during hospitalization."
|
|
: $"Discharged to {admission.DischargeLocation ?? "unknown"}.");
|
|
|
|
return new ScenarioMeta(
|
|
Id: $"mimic-s{stay.StayId}",
|
|
Name: $"MIMIC-IV — {stay.FirstCareUnit} ({patient.Gender}, ~{patient.AnchorAge}y)",
|
|
Description: description,
|
|
DurationMinutes: durationMinutes,
|
|
Tags: MimicCareUnitMap.GetTags(stay.FirstCareUnit, admission.HospitalExpireFlag));
|
|
}
|
|
|
|
private static decimal? ParseDose(string? doseValRx)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(doseValRx)) return null;
|
|
|
|
var val = doseValRx.Trim();
|
|
var dashIdx = val.IndexOf('-');
|
|
if (dashIdx > 0) val = val[..dashIdx];
|
|
|
|
return decimal.TryParse(val, System.Globalization.NumberStyles.Any,
|
|
System.Globalization.CultureInfo.InvariantCulture, out var d) && d > 0
|
|
? d
|
|
: null;
|
|
}
|
|
|
|
private static DateTime RoundToMinute(DateTime dt)
|
|
=> new(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0);
|
|
|
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
private record RawObservation(
|
|
DateTime ChartTime, string Code, decimal Value,
|
|
string Unit, string Source, int Priority);
|
|
}
|