feature: MIMIC-IV Replay Scenario Generator
This commit is contained in:
@@ -38,7 +38,12 @@ public class VigilCareApiClient
|
||||
public async Task<PatientResponse> RegisterPatientAsync(RegisterPatientRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Register patient failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
|
||||
}
|
||||
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
|
||||
return envelope!.Data!;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -6,5 +6,7 @@ rootCommand.AddCommand(ReplayCommand.Create());
|
||||
rootCommand.AddCommand(ReplayAllCommand.Create());
|
||||
rootCommand.AddCommand(ValidateCommand.Create());
|
||||
rootCommand.AddCommand(DryRunCommand.Create());
|
||||
rootCommand.AddCommand(MimicListCommand.Create());
|
||||
rootCommand.AddCommand(MimicGenerateCommand.Create());
|
||||
|
||||
return await rootCommand.InvokeAsync(args);
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user