feature: MIMIC-IV Replay Scenario Generator

This commit is contained in:
voltsrage
2026-06-25 13:35:09 +08:00
parent 069881991a
commit a8964381a2
29 changed files with 916019 additions and 8 deletions
@@ -0,0 +1,37 @@
public static class MimicCareUnitMap
{
public static string ToVigilCareDepartment(string mimicCareUnit)
{
// All MIMIC ICU stays map to ICU in VigilCare
return "Icu";
}
public static string ToVigilCareEncounterType(string admissionType)
{
if (admissionType.Contains("EMER", StringComparison.OrdinalIgnoreCase))
return "Emergency";
return "Inpatient";
}
public static List<string> GetTags(string mimicCareUnit, int hospitalExpireFlag)
{
var tags = new List<string> { "mimic-iv", "real-data", "icu" };
var unit = mimicCareUnit.ToUpperInvariant();
if (unit.Contains("CARDIAC") || unit.Contains("CVICU") || unit.Contains("CCU") || unit.Contains("CORONARY"))
tags.Add("cardiac");
if (unit.Contains("NEURO"))
tags.Add("neuro");
if (unit.Contains("SURG") || unit.Contains("TSICU"))
tags.Add("surgical");
if (unit.Contains("TRAUMA"))
tags.Add("trauma");
if (unit.Contains("MICU") || unit.Contains("MEDICAL"))
tags.Add("medical");
if (hospitalExpireFlag == 1)
tags.Add("expired");
return tags;
}
}
@@ -0,0 +1,70 @@
public static class MimicCsvReader
{
public static IEnumerable<T> Read<T>(
string filePath,
Func<string[], Dictionary<string, int>, T?> parser,
Func<string[], Dictionary<string, int>, bool>? filter = null)
{
using var reader = new StreamReader(filePath);
var headerLine = reader.ReadLine();
if (headerLine is null) yield break;
var headers = BuildHeaderIndex(headerLine);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var fields = line.Split(',');
if (filter is not null && !filter(fields, headers))
continue;
var record = parser(fields, headers);
if (record is not null)
yield return record;
}
}
public static List<T> ReadAll<T>(
string filePath,
Func<string[], Dictionary<string, int>, T?> parser)
{
return Read(filePath, parser).ToList();
}
private static Dictionary<string, int> BuildHeaderIndex(string headerLine)
{
var headers = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var columns = headerLine.Split(',');
for (var i = 0; i < columns.Length; i++)
headers[columns[i].Trim()] = i;
return headers;
}
public static string Col(string[] fields, Dictionary<string, int> headers, string name)
{
return headers.TryGetValue(name, out var idx) && idx < fields.Length
? fields[idx].Trim()
: string.Empty;
}
public static int? ColInt(string[] fields, Dictionary<string, int> headers, string name)
{
var val = Col(fields, headers, name);
return int.TryParse(val, out var result) ? result : null;
}
public static decimal? ColDecimal(string[] fields, Dictionary<string, int> headers, string name)
{
var val = Col(fields, headers, name);
return decimal.TryParse(val, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var result) ? result : null;
}
public static DateTime? ColDateTime(string[] fields, Dictionary<string, int> headers, string name)
{
var val = Col(fields, headers, name);
return DateTime.TryParse(val, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var result) ? result : null;
}
}
@@ -0,0 +1,191 @@
public record MimicPatient(int SubjectId, string Gender, int AnchorAge, int AnchorYear, string? Dod);
public record MimicAdmission(
int SubjectId, int HadmId,
DateTime AdmitTime, DateTime DischTime, DateTime? DeathTime,
string AdmissionType, string? AdmissionLocation, string? DischargeLocation,
int HospitalExpireFlag);
public record MimicIcuStay(
int SubjectId, int HadmId, int StayId,
string FirstCareUnit, string LastCareUnit,
DateTime InTime, DateTime OutTime, decimal Los);
public record MimicChartEvent(
int StayId, DateTime ChartTime, int ItemId,
string? TextValue, decimal? ValueNum);
public record MimicLabEvent(
int HadmId, DateTime ChartTime, int ItemId,
decimal? ValueNum, string? ValueUom);
public record MimicPrescription(
int HadmId, DateTime StartTime,
string Drug, string? DoseValRx, string? DoseUnitRx, string? Route);
public class MimicDataLoader
{
private readonly string _dataDir;
public MimicDataLoader(string dataDir)
{
if (!Directory.Exists(dataDir))
throw new DirectoryNotFoundException($"MIMIC data directory not found: {dataDir}");
_dataDir = dataDir;
}
private string Path(string fileName) => System.IO.Path.Combine(_dataDir, fileName);
public List<MimicPatient> LoadPatients()
{
return MimicCsvReader.ReadAll(Path("patients.csv"), (f, h) =>
{
var id = MimicCsvReader.ColInt(f, h, "subject_id");
var age = MimicCsvReader.ColInt(f, h, "anchor_age");
var year = MimicCsvReader.ColInt(f, h, "anchor_year");
if (id is null || age is null || year is null) return null;
return new MimicPatient(
id.Value,
MimicCsvReader.Col(f, h, "gender"),
age.Value,
year.Value,
MimicCsvReader.Col(f, h, "dod") is { Length: > 0 } dod ? dod : null);
});
}
public List<MimicAdmission> LoadAdmissions()
{
return MimicCsvReader.ReadAll(Path("admissions.csv"), (f, h) =>
{
var subjectId = MimicCsvReader.ColInt(f, h, "subject_id");
var hadmId = MimicCsvReader.ColInt(f, h, "hadm_id");
var admitTime = MimicCsvReader.ColDateTime(f, h, "admittime");
var dischTime = MimicCsvReader.ColDateTime(f, h, "dischtime");
if (subjectId is null || hadmId is null || admitTime is null || dischTime is null)
return null;
return new MimicAdmission(
subjectId.Value, hadmId.Value,
admitTime.Value, dischTime.Value,
MimicCsvReader.ColDateTime(f, h, "deathtime"),
MimicCsvReader.Col(f, h, "admission_type"),
MimicCsvReader.Col(f, h, "admission_location") is { Length: > 0 } loc ? loc : null,
MimicCsvReader.Col(f, h, "discharge_location") is { Length: > 0 } dloc ? dloc : null,
MimicCsvReader.ColInt(f, h, "hospital_expire_flag") ?? 0);
});
}
public List<MimicIcuStay> LoadIcuStays()
{
return MimicCsvReader.ReadAll(Path("icustays.csv"), (f, h) =>
{
var subjectId = MimicCsvReader.ColInt(f, h, "subject_id");
var hadmId = MimicCsvReader.ColInt(f, h, "hadm_id");
var stayId = MimicCsvReader.ColInt(f, h, "stay_id");
var inTime = MimicCsvReader.ColDateTime(f, h, "intime");
var outTime = MimicCsvReader.ColDateTime(f, h, "outtime");
if (subjectId is null || hadmId is null || stayId is null
|| inTime is null || outTime is null)
return null;
return new MimicIcuStay(
subjectId.Value, hadmId.Value, stayId.Value,
MimicCsvReader.Col(f, h, "first_careunit"),
MimicCsvReader.Col(f, h, "last_careunit"),
inTime.Value, outTime.Value,
MimicCsvReader.ColDecimal(f, h, "los") ?? 0m);
});
}
public IEnumerable<MimicChartEvent> StreamChartEvents(int stayId)
{
var stayIdStr = stayId.ToString();
return MimicCsvReader.Read(
Path("chartevents.csv"),
parser: (f, h) =>
{
var itemId = MimicCsvReader.ColInt(f, h, "itemid");
if (itemId is null || !MimicItemMap.AllChartItemIds.Contains(itemId.Value))
return null;
var chartTime = MimicCsvReader.ColDateTime(f, h, "charttime");
if (chartTime is null) return null;
var valueNum = MimicCsvReader.ColDecimal(f, h, "valuenum");
var textValue = MimicCsvReader.Col(f, h, "value");
if (MimicItemMap.IsGcsItem(itemId.Value))
{
var gcsVal = MimicItemMap.ResolveGcsValue(itemId.Value, textValue, valueNum);
if (gcsVal is null) return null;
return new MimicChartEvent(stayId, chartTime.Value, itemId.Value, textValue, gcsVal);
}
if (valueNum is null) return null;
return new MimicChartEvent(stayId, chartTime.Value, itemId.Value, textValue, valueNum);
},
filter: (f, h) =>
{
var sid = MimicCsvReader.Col(f, h, "stay_id");
return sid == stayIdStr;
});
}
public IEnumerable<MimicLabEvent> StreamLabEvents(int hadmId, DateTime? after = null, DateTime? before = null)
{
var hadmIdStr = hadmId.ToString();
return MimicCsvReader.Read(
Path("labevents.csv"),
parser: (f, h) =>
{
var itemId = MimicCsvReader.ColInt(f, h, "itemid");
if (itemId is null || !MimicItemMap.AllLabItemIds.Contains(itemId.Value))
return null;
var chartTime = MimicCsvReader.ColDateTime(f, h, "charttime");
if (chartTime is null) return null;
if (after.HasValue && chartTime.Value < after.Value) return null;
if (before.HasValue && chartTime.Value > before.Value) return null;
var valueNum = MimicCsvReader.ColDecimal(f, h, "valuenum");
if (valueNum is null) return null;
return new MimicLabEvent(
hadmId, chartTime.Value, itemId.Value, valueNum,
MimicCsvReader.Col(f, h, "valueuom") is { Length: > 0 } uom ? uom : null);
},
filter: (f, h) =>
{
var hid = MimicCsvReader.Col(f, h, "hadm_id");
return hid == hadmIdStr;
});
}
public IEnumerable<MimicPrescription> StreamPrescriptions(
int hadmId, DateTime? after = null, DateTime? before = null)
{
var hadmIdStr = hadmId.ToString();
return MimicCsvReader.Read(
Path("prescriptions.csv"),
parser: (f, h) =>
{
var startTime = MimicCsvReader.ColDateTime(f, h, "starttime");
if (startTime is null) return null;
if (after.HasValue && startTime.Value < after.Value) return null;
if (before.HasValue && startTime.Value > before.Value) return null;
var drug = MimicCsvReader.Col(f, h, "drug");
if (string.IsNullOrWhiteSpace(drug)) return null;
return new MimicPrescription(
hadmId, startTime.Value, drug,
MimicCsvReader.Col(f, h, "dose_val_rx") is { Length: > 0 } d ? d : null,
MimicCsvReader.Col(f, h, "dose_unit_rx") is { Length: > 0 } u ? u : null,
MimicCsvReader.Col(f, h, "route") is { Length: > 0 } r ? r : null);
},
filter: (f, h) =>
{
var hid = MimicCsvReader.Col(f, h, "hadm_id");
return hid == hadmIdStr;
});
}
}
@@ -0,0 +1,136 @@
using System.CommandLine;
using System.Text.Json;
using Spectre.Console;
public static class MimicGenerateCommand
{
public static Command Create()
{
var dataDirArg = new Argument<DirectoryInfo>("mimic-dir",
"Path to directory containing MIMIC-IV CSV files");
var stayIdOpt = new Option<int>("--stay-id", "ICU stay ID to generate scenario for")
{ IsRequired = true };
var maxHoursOpt = new Option<int?>("--max-hours", "Limit scenario duration in hours");
var noMedsOpt = new Option<bool>("--no-medications", () => false,
"Exclude medication events");
var noLabsOpt = new Option<bool>("--no-labs", () => false,
"Exclude lab observations");
var outputOpt = new Option<string?>("--output", "Output file path (default: auto-named)");
var validateOpt = new Option<bool>("--validate", () => false,
"Run scenario validation after generation");
var command = new Command("mimic-generate",
"Generate a VigilCare scenario JSON from a MIMIC-IV ICU stay")
{
dataDirArg, stayIdOpt, maxHoursOpt, noMedsOpt, noLabsOpt, outputOpt, validateOpt
};
command.SetHandler(context =>
{
var dataDir = context.ParseResult.GetValueForArgument(dataDirArg);
var stayId = context.ParseResult.GetValueForOption(stayIdOpt);
var maxHours = context.ParseResult.GetValueForOption(maxHoursOpt);
var noMeds = context.ParseResult.GetValueForOption(noMedsOpt);
var noLabs = context.ParseResult.GetValueForOption(noLabsOpt);
var outputPath = context.ParseResult.GetValueForOption(outputOpt);
var validate = context.ParseResult.GetValueForOption(validateOpt);
var loader = new MimicDataLoader(dataDir.FullName);
AnsiConsole.MarkupLine($"[bold]Loading MIMIC-IV data for stay {stayId}...[/]");
var stays = loader.LoadIcuStays();
var stay = stays.FirstOrDefault(s => s.StayId == stayId);
if (stay is null)
{
AnsiConsole.MarkupLine($"[red]ICU stay {stayId} not found.[/]");
var available = stays.Select(s => s.StayId).OrderBy(x => x).ToList();
AnsiConsole.MarkupLine($"Available stay IDs: {string.Join(", ", available.Take(20))}...");
return;
}
var admissions = loader.LoadAdmissions();
var admission = admissions.FirstOrDefault(a => a.HadmId == stay.HadmId);
if (admission is null)
{
AnsiConsole.MarkupLine($"[red]Admission {stay.HadmId} not found.[/]");
return;
}
var patients = loader.LoadPatients();
var patient = patients.FirstOrDefault(p => p.SubjectId == stay.SubjectId);
if (patient is null)
{
AnsiConsole.MarkupLine($"[red]Patient {stay.SubjectId} not found.[/]");
return;
}
AnsiConsole.MarkupLine(
$" Patient: [cyan]{patient.SubjectId}[/] ({patient.Gender}, ~{patient.AnchorAge}y)");
AnsiConsole.MarkupLine(
$" Stay: [cyan]{stay.StayId}[/] in {stay.FirstCareUnit}");
AnsiConsole.MarkupLine(
$" LOS: {stay.Los:F1} days ({stay.InTime:g} → {stay.OutTime:g})");
AnsiConsole.MarkupLine(
$" Outcome: {(admission.HospitalExpireFlag == 1 ? "[red]Expired[/]" : "Survived")}");
var options = new MimicGenerateOptions(
MaxHours: maxHours,
IncludeMedications: !noMeds,
IncludeLabs: !noLabs);
AnsiConsole.MarkupLine("\n[bold]Generating scenario...[/]");
var builder = new MimicScenarioBuilder(loader);
var (scenario, warnings) = builder.Build(stay, admission, patient, options);
foreach (var warning in warnings)
AnsiConsole.MarkupLine($" [yellow]WARNING:[/] {Markup.Escape(warning)}");
var obsCount = scenario.Events.Count(e => e.Type == "observation");
var medCount = scenario.Events.Count(e => e.Type == "medication");
AnsiConsole.MarkupLine(
$" Events: [green]{obsCount}[/] observations, [green]{medCount}[/] medications");
AnsiConsole.MarkupLine(
$" Duration: {scenario.Scenario.DurationMinutes} minutes " +
$"({scenario.Scenario.DurationMinutes / 60.0:F1} hours)");
if (validate)
{
var errors = ScenarioValidator.Validate(scenario);
if (errors.Count == 0)
{
AnsiConsole.MarkupLine(" [green]Validation: PASSED[/]");
}
else
{
AnsiConsole.MarkupLine($" [red]Validation: {errors.Count} error(s)[/]");
foreach (var err in errors)
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(err)}[/]");
}
}
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var json = JsonSerializer.Serialize(scenario, jsonOptions);
var filePath = outputPath
?? Path.Combine("Scenarios", "List", $"mimic-s{stayId}.json");
var dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(filePath, json);
AnsiConsole.MarkupLine($"\n[bold green]Scenario written to:[/] {filePath}");
AnsiConsole.MarkupLine($" Replay with: [dim]dotnet run -- replay {filePath}[/]");
});
return command;
}
}
+110
View File
@@ -0,0 +1,110 @@
public static class MimicItemMap
{
public record ChartItemMapping(
string Code, string Unit, string Source,
int Priority = 0,
bool ConvertFahrenheit = false);
public record LabItemMapping(string Code, string Unit);
private static readonly Dictionary<int, ChartItemMapping> ChartMappings = new()
{
[220045] = new("HEART_RATE", "bpm", "Device"),
[220210] = new("RESP_RATE", "/min", "Device"),
[220179] = new("SYSTOLIC_BP", "mmHg", "Device"),
[220180] = new("DIASTOLIC_BP", "mmHg", "Device"),
[220050] = new("SYSTOLIC_BP", "mmHg", "Device", Priority: 1),
[220051] = new("DIASTOLIC_BP", "mmHg", "Device", Priority: 1),
[223762] = new("TEMP_C", "°C", "Manual"),
[223761] = new("TEMP_C", "°C", "Manual", ConvertFahrenheit: true),
[220277] = new("SPO2", "%", "Device"),
[223835] = new("FIO2_PCT", "%", "Device"),
[220739] = new("GCS_EYE", "score", "Manual"),
[223900] = new("GCS_VERBAL", "score", "Manual"),
[223901] = new("GCS_MOTOR", "score", "Manual"),
[220615] = new("CREATININE_MG_DL", "mg/dL", "Lab"),
[225690] = new("BILIRUBIN_MG_DL", "mg/dL", "Lab"),
[225678] = new("PLATELET_K_UL", "k/µL", "Lab"),
[220224] = new("PAO2_MMHG", "mmHg", "Lab"),
};
private static readonly Dictionary<int, LabItemMapping> LabMappings = new()
{
[50912] = new("CREATININE_MG_DL", "mg/dL"),
[51704] = new("PLATELET_K_UL", "k/µL"),
[50885] = new("BILIRUBIN_MG_DL", "mg/dL"),
[50813] = new("LACTATE_MMOL_L", "mmol/L"),
[51301] = new("WBC_K_UL", "k/µL"),
[50971] = new("POTASSIUM_MEQ_L", "mEq/L"),
[50931] = new("GLUCOSE_MG_DL", "mg/dL"),
[50821] = new("PAO2_MMHG", "mmHg"),
};
// GCS text → numeric fallback (in case valuenum is missing)
private static readonly Dictionary<string, int> GcsEyeText = new(StringComparer.OrdinalIgnoreCase)
{
["None"] = 1, ["No Response"] = 1,
["To Pain"] = 2,
["To Speech"] = 3,
["Spontaneously"] = 4,
};
private static readonly Dictionary<string, int> GcsVerbalText = new(StringComparer.OrdinalIgnoreCase)
{
["No Response"] = 1, ["No Response-ETT"] = 1,
["Incomprehensible sounds"] = 2, ["Incomprehensible Sounds"] = 2,
["Inappropriate Words"] = 3,
["Confused"] = 4,
["Oriented"] = 5,
};
private static readonly Dictionary<string, int> GcsMotorText = new(StringComparer.OrdinalIgnoreCase)
{
["No response"] = 1, ["No Response"] = 1,
["Abnormal extension"] = 2, ["Abnormal Extension"] = 2,
["Abnormal Flexion"] = 3,
["Flex-withdraws"] = 3, ["Flex-Withdraws"] = 3,
["Localizes Pain"] = 4,
["Obeys Commands"] = 6,
};
public static readonly HashSet<int> AllChartItemIds = new(ChartMappings.Keys);
public static readonly HashSet<int> AllLabItemIds = new(LabMappings.Keys);
public static bool TryMapChartEvent(int itemId, out ChartItemMapping mapping)
=> ChartMappings.TryGetValue(itemId, out mapping!);
public static bool TryMapLabEvent(int itemId, out LabItemMapping mapping)
=> LabMappings.TryGetValue(itemId, out mapping!);
public static decimal ConvertValue(decimal rawValue, ChartItemMapping mapping)
{
if (mapping.ConvertFahrenheit)
return Math.Round((rawValue - 32m) * 5m / 9m, 1);
return rawValue;
}
public static decimal? ResolveGcsValue(int itemId, string? textValue, decimal? numericValue)
{
if (numericValue.HasValue && numericValue.Value > 0)
return numericValue.Value;
if (string.IsNullOrWhiteSpace(textValue))
return null;
var lookup = itemId switch
{
220739 => GcsEyeText,
223900 => GcsVerbalText,
223901 => GcsMotorText,
_ => null
};
if (lookup is not null && lookup.TryGetValue(textValue, out var score))
return score;
return null;
}
public static bool IsGcsItem(int itemId) => itemId is 220739 or 223900 or 223901;
}
@@ -0,0 +1,75 @@
using System.CommandLine;
using Spectre.Console;
public static class MimicListCommand
{
public static Command Create()
{
var dataDirArg = new Argument<DirectoryInfo>("mimic-dir",
"Path to directory containing MIMIC-IV CSV files");
var subjectIdOpt = new Option<int?>("--subject-id", "Filter to a specific patient");
var stayIdOpt = new Option<int?>("--stay-id", "Filter to a specific ICU stay");
var command = new Command("mimic-list",
"List available MIMIC-IV patients and ICU stays")
{
dataDirArg, subjectIdOpt, stayIdOpt
};
command.SetHandler(context =>
{
var dataDir = context.ParseResult.GetValueForArgument(dataDirArg);
var subjectId = context.ParseResult.GetValueForOption(subjectIdOpt);
var stayId = context.ParseResult.GetValueForOption(stayIdOpt);
var loader = new MimicDataLoader(dataDir.FullName);
var patients = loader.LoadPatients().ToDictionary(p => p.SubjectId);
var admissions = loader.LoadAdmissions().ToDictionary(a => a.HadmId);
var stays = loader.LoadIcuStays();
if (subjectId.HasValue)
stays = stays.Where(s => s.SubjectId == subjectId.Value).ToList();
if (stayId.HasValue)
stays = stays.Where(s => s.StayId == stayId.Value).ToList();
var table = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]MIMIC-IV ICU Stays[/]");
table.AddColumn("StayId");
table.AddColumn("SubjectId");
table.AddColumn("HadmId");
table.AddColumn("Gender");
table.AddColumn("Age");
table.AddColumn("Care Unit");
table.AddColumn("Admission");
table.AddColumn("LOS (d)");
table.AddColumn("Expired");
foreach (var stay in stays.OrderBy(s => s.SubjectId).ThenBy(s => s.InTime))
{
var pt = patients.GetValueOrDefault(stay.SubjectId);
var adm = admissions.GetValueOrDefault(stay.HadmId);
table.AddRow(
stay.StayId.ToString(),
stay.SubjectId.ToString(),
stay.HadmId.ToString(),
pt?.Gender ?? "?",
pt?.AnchorAge.ToString() ?? "?",
Markup.Escape(stay.FirstCareUnit),
adm?.AdmissionType ?? "?",
stay.Los.ToString("F1"),
adm?.HospitalExpireFlag == 1 ? "[red]Yes[/]" : "No");
}
AnsiConsole.Write(table);
var patientCount = stays.Select(s => s.SubjectId).Distinct().Count();
AnsiConsole.MarkupLine(
$"\nFound [bold]{stays.Count}[/] ICU stays across [bold]{patientCount}[/] patients.");
});
return command;
}
}
@@ -0,0 +1,322 @@
using System.Text.Json;
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);
}