Full SOFA Score: Data Layer + Scoring Engine

Glasgow Coma Scale: Data Layer + Scoring Engine
This commit is contained in:
voltsrage
2026-06-21 01:09:50 +08:00
parent 78c043e4d3
commit 93ea473d2b
62 changed files with 7133 additions and 72 deletions
+160
View File
@@ -0,0 +1,160 @@
public static class SofaCalculator
{
public static readonly IReadOnlyList<string> SofaObservationCodes = new[]
{
"PAO2_MMHG", "FIO2_PCT", "PLATELET_K_UL", "BILIRUBIN_MG_DL",
"CREATININE_MG_DL", "URINE_OUTPUT_ML_H",
"SYSTOLIC_BP", "DIASTOLIC_BP", "SPO2", "SUPPLEMENTAL_O2"
};
public static readonly IReadOnlySet<string> SofaCodeSet =
new HashSet<string>(SofaObservationCodes);
public static bool IsSofaCode(string observationCode) =>
SofaCodeSet.Contains(observationCode);
// GCS components and gcs.scored also trigger SOFA re-score (CNS organ system)
public static bool TriggersRescore(string observationCode) =>
IsSofaCode(observationCode) || GcsCalculator.IsGcsCode(observationCode);
public static string CacheKey(Guid encounterId, string code) =>
$"sofa:{encounterId}:{code}";
// --- 1. Respiratory: PaO2/FiO2 ratio ---
public static int ScoreRespiratory(decimal? pao2, decimal? fio2, bool onMechanicalVent)
{
if (pao2 is null || fio2 is null || fio2 == 0) return 0;
var ratio = pao2.Value / (fio2.Value / 100m);
return ratio switch
{
>= 400 when onMechanicalVent => 0,
>= 400 => 0,
>= 300 => 1,
>= 200 => 2,
>= 100 when onMechanicalVent => 3,
>= 100 => 2,
_ when onMechanicalVent => 4,
_ => 3
};
}
// SpO2/FiO2 proxy when PaO2 unavailable (Rice et al. 2007)
public static int ScoreRespiratoryFromSpo2(decimal? spo2, decimal? fio2, bool onMechanicalVent)
{
if (spo2 is null || fio2 is null || fio2 == 0) return 0;
var sfRatio = spo2.Value / (fio2.Value / 100m);
return sfRatio switch
{
>= 315 => 0,
>= 235 => 1,
>= 150 => 2,
>= 67 when onMechanicalVent => 3,
>= 67 => 2,
_ when onMechanicalVent => 4,
_ => 3
};
}
// --- 2. Coagulation: Platelet count (k/µL) ---
public static int ScoreCoagulation(decimal? platelets)
{
if (platelets is null) return 0;
return platelets.Value switch
{
>= 150 => 0,
>= 100 => 1,
>= 50 => 2,
>= 20 => 3,
_ => 4
};
}
// --- 3. Liver: Bilirubin (mg/dL) ---
public static int ScoreLiver(decimal? bilirubin)
{
if (bilirubin is null) return 0;
return bilirubin.Value switch
{
< 1.2m => 0,
< 2.0m => 1,
< 6.0m => 2,
< 12.0m => 3,
_ => 4
};
}
// --- 4. Cardiovascular: MAP and vasopressor dose ---
public static int ScoreCardiovascular(decimal? map, VasopressorInfo? vasopressor)
{
if (vasopressor is not null)
{
return vasopressor.DrugName.ToUpperInvariant() switch
{
"DOPAMINE" when vasopressor.DoseUgKgMin > 15m => 4,
"EPINEPHRINE" when vasopressor.DoseUgKgMin > 0.1m => 4,
"NOREPINEPHRINE" when vasopressor.DoseUgKgMin > 0.1m => 4,
"DOPAMINE" when vasopressor.DoseUgKgMin > 5m => 3,
"EPINEPHRINE" => 3,
"NOREPINEPHRINE" => 3,
"DOPAMINE" => 2,
"DOBUTAMINE" => 2,
_ => 1
};
}
if (map is null) return 0;
return map.Value < 70m ? 1 : 0;
}
// --- 5. CNS: Glasgow Coma Scale (from Phase 25) ---
public static int ScoreCns(int? gcsTotal) =>
GcsCalculator.ToSofaCnsScore(gcsTotal ?? 15);
// --- 6. Renal: Creatinine (mg/dL) or urine output (mL/day) ---
public static int ScoreRenal(decimal? creatinine, decimal? urineOutputMlPerDay)
{
var creatScore = creatinine switch
{
null => 0,
< 1.2m => 0,
< 2.0m => 1,
< 3.5m => 2,
< 5.0m => 3,
_ => 4
};
var urineScore = urineOutputMlPerDay switch
{
null => 0,
< 200m => 4,
< 500m => 3,
_ => 0
};
return Math.Max(creatScore, urineScore);
}
public static SofaResult ComputeTotal(
int respiratory, int coagulation, int liver,
int cardiovascular, int cns, int renal) =>
new(
Total: respiratory + coagulation + liver + cardiovascular + cns + renal,
Respiratory: respiratory,
Coagulation: coagulation,
Liver: liver,
Cardiovascular: cardiovascular,
Cns: cns,
Renal: renal);
// Count organ systems with component data for baseline eligibility
public static int CountPopulatedOrganSystems(
bool hasRespiratory,
bool hasCoagulation,
bool hasLiver,
bool hasCardiovascular,
bool hasCns,
bool hasRenal) =>
new[] { hasRespiratory, hasCoagulation, hasLiver,
hasCardiovascular, hasCns, hasRenal }
.Count(hasData => hasData);
}
+340
View File
@@ -0,0 +1,340 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Prometheus;
using StackExchange.Redis;
public class SofaDetector
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IConnectionMultiplexer _redis;
private readonly IServiceProvider _services;
private readonly SofaLabCache _labCache;
private readonly SofaVasopressorResolver _vasopressors;
private readonly SofaOptions _options;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<SofaDetector> _logger;
public SofaDetector(
IConnectionMultiplexer redis,
IServiceProvider services,
SofaLabCache labCache,
SofaVasopressorResolver vasopressors,
IOptions<SofaOptions> options,
ClinicalMetrics metrics,
ILogger<SofaDetector> logger)
{
_redis = redis;
_services = services;
_labCache = labCache;
_vasopressors = vasopressors;
_options = options.Value;
_metrics = metrics;
_logger = logger;
}
public async Task<SofaScoringResult> ProcessObservationAsync(
Guid encounterId,
Guid patientId,
string observationCode,
decimal value,
DateTimeOffset recordedAt,
CancellationToken ct = default)
{
if (!SofaCalculator.TriggersRescore(observationCode))
return SofaScoringResult.NotSofaTrigger;
if (SofaCalculator.IsSofaCode(observationCode))
{
await _labCache.StoreAsync(encounterId, observationCode, value, recordedAt);
}
return await TryComputeScoreAsync(encounterId, patientId, ct);
}
public Task<SofaScoringResult> ProcessGcsScoredAsync(
Guid encounterId, Guid patientId, CancellationToken ct = default) =>
TryComputeScoreAsync(encounterId, patientId, ct);
private async Task<SofaScoringResult> TryComputeScoreAsync(
Guid encounterId, Guid patientId, CancellationToken ct)
{
using var timer = _metrics.SofaScoringDuration.NewTimer();
using (var scope = _services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (!await db.Encounters.AnyAsync(e => e.Id == encounterId, ct))
{
_logger.LogWarning(
"Skipping SOFA score for unknown encounter {EncounterId}", encounterId);
return SofaScoringResult.EncounterNotFound;
}
}
var cached = await _labCache.GetAllAsync(encounterId);
var staleComponents = new List<string>();
var missingComponents = new List<string>();
decimal? GetValue(string code)
{
if (!cached.TryGetValue(code, out var entry))
{
missingComponents.Add(code);
return null;
}
var status = _labCache.Classify(entry);
if (status == SofaValueStatus.Expired)
{
missingComponents.Add(code);
return null;
}
if (status == SofaValueStatus.Stale)
staleComponents.Add(code);
return entry.Value;
}
var pao2 = GetValue("PAO2_MMHG");
var fio2 = GetValue("FIO2_PCT");
var spo2 = GetValue("SPO2");
var supplementalO2 = GetValue("SUPPLEMENTAL_O2");
var onMechanicalVent = supplementalO2 is >= 1m;
var usedSpO2Fallback = false;
int respiratory;
if (pao2 is not null && fio2 is not null)
{
respiratory = SofaCalculator.ScoreRespiratory(pao2, fio2, onMechanicalVent);
}
else if (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null)
{
usedSpO2Fallback = true;
respiratory = SofaCalculator.ScoreRespiratoryFromSpo2(spo2, fio2, onMechanicalVent);
}
else
{
respiratory = 0;
if (pao2 is null) missingComponents.Add("PAO2_MMHG");
if (fio2 is null) missingComponents.Add("FIO2_PCT");
}
var coagulation = SofaCalculator.ScoreCoagulation(GetValue("PLATELET_K_UL"));
var liver = SofaCalculator.ScoreLiver(GetValue("BILIRUBIN_MG_DL"));
decimal? map = null;
var sbp = GetValue("SYSTOLIC_BP");
var dbp = GetValue("DIASTOLIC_BP");
if (sbp is not null && dbp is not null)
map = MapCalculator.Calculate(sbp.Value, dbp.Value);
var vasopressor = await _vasopressors.GetActiveVasopressorAsync(encounterId, ct);
var cardiovascular = SofaCalculator.ScoreCardiovascular(map, vasopressor);
var gcsTotal = await LoadGcsTotalAsync(encounterId);
var cns = SofaCalculator.ScoreCns(gcsTotal);
var creatinine = GetValue("CREATININE_MG_DL");
var urineMlH = GetValue("URINE_OUTPUT_ML_H");
decimal? urineMlDay = urineMlH is not null ? urineMlH * 24m : null;
var renal = SofaCalculator.ScoreRenal(creatinine, urineMlDay);
var result = SofaCalculator.ComputeTotal(
respiratory, coagulation, liver, cardiovascular, cns, renal);
var stalenessFlags = JsonSerializer.Serialize(new SofaStalenessFlags(
staleComponents.Distinct().ToList(),
missingComponents.Distinct().ToList(),
usedSpO2Fallback), JsonOptions);
var calculatedAt = DateTimeOffset.UtcNow;
var (isBaseline, delta) = await ResolveBaselineAndDeltaAsync(
encounterId, patientId, result, calculatedAt,
SofaCalculator.CountPopulatedOrganSystems(
hasRespiratory: (pao2 is not null && fio2 is not null)
|| (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null),
hasCoagulation: cached.ContainsKey("PLATELET_K_UL")
&& _labCache.Classify(cached["PLATELET_K_UL"]) != SofaValueStatus.Expired,
hasLiver: cached.ContainsKey("BILIRUBIN_MG_DL")
&& _labCache.Classify(cached["BILIRUBIN_MG_DL"]) != SofaValueStatus.Expired,
hasCardiovascular: (sbp is not null && dbp is not null) || vasopressor is not null,
hasCns: gcsTotal is not null,
hasRenal: (cached.ContainsKey("CREATININE_MG_DL")
&& _labCache.Classify(cached["CREATININE_MG_DL"]) != SofaValueStatus.Expired)
|| (cached.ContainsKey("URINE_OUTPUT_ML_H")
&& _labCache.Classify(cached["URINE_OUTPUT_ML_H"]) != SofaValueStatus.Expired)),
ct);
await PersistScoreAsync(
encounterId, patientId, result, isBaseline, delta,
stalenessFlags, calculatedAt, ct);
var alertCreated = false;
if (delta is >= 2)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.SofaSepsis, AlertSeverity.Critical,
result, isBaseline ? null : delta, stalenessFlags, ct);
}
else if (delta == 1)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.SofaWarning, AlertSeverity.Warning,
result, delta, stalenessFlags, ct);
}
_metrics.SofaScoresTotal
.WithLabels(alertCreated ? "true" : "false")
.Inc();
_logger.LogInformation(
"SOFA score {Total} for encounter {Id} — baseline={Baseline} delta={Delta}",
result.Total, encounterId, isBaseline, delta);
return new SofaScoringResult(
SofaOutcome.ScoreComputed, result, isBaseline, delta, alertCreated);
}
private async Task<int?> LoadGcsTotalAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (!gcsValues.All(v => v.HasValue)) return null;
var eye = decimal.Parse(gcsValues[0]!, CultureInfo.InvariantCulture);
var verbal = decimal.Parse(gcsValues[1]!, CultureInfo.InvariantCulture);
var motor = decimal.Parse(gcsValues[2]!, CultureInfo.InvariantCulture);
return GcsCalculator.ComputeTotal(eye, verbal, motor);
}
private async Task<(bool IsBaseline, int? Delta)> ResolveBaselineAndDeltaAsync(
Guid encounterId, Guid patientId, SofaResult result,
DateTimeOffset calculatedAt, int populatedOrganSystems, CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var existingBaseline = await db.SofaScores
.AsNoTracking()
.FirstOrDefaultAsync(s => s.EncounterId == encounterId && s.IsBaseline, ct);
if (existingBaseline is null)
{
if (populatedOrganSystems >= 4)
return (true, null);
return (false, null);
}
var delta = result.Total - existingBaseline.TotalScore;
return (false, delta);
}
private async Task PersistScoreAsync(
Guid encounterId, Guid patientId, SofaResult result,
bool isBaseline, int? delta, string stalenessFlags,
DateTimeOffset calculatedAt, CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.SofaScores.Add(new SofaScore
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
TotalScore = result.Total,
RespiratoryScore = result.Respiratory,
CoagulationScore = result.Coagulation,
LiverScore = result.Liver,
CardiovascularScore = result.Cardiovascular,
CnsScore = result.Cns,
RenalScore = result.Renal,
IsBaseline = isBaseline,
DeltaFromBaseline = delta,
StalenessFlags = stalenessFlags,
CalculatedAt = calculatedAt,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
SofaResult result, int? delta, string stalenessFlags,
CancellationToken ct)
{
if (alertType == AlertType.SofaWarning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
return false;
}
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await using var tx = await db.Database.BeginTransactionAsync(ct);
var alertId = Guid.NewGuid();
var triggeredAt = DateTimeOffset.UtcNow;
var details =
$"SOFA score {result.Total} (delta +{delta}). " +
$"Components: Resp={result.Respiratory}, Coag={result.Coagulation}, " +
$"Liver={result.Liver}, CV={result.Cardiovascular}, CNS={result.Cns}, Renal={result.Renal}. " +
$"Staleness: {stalenessFlags}";
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = {alertType.ToDbString()}
AND status IN ('OPEN', 'ESCALATED')
)
""", ct);
if (affected == 0)
{
await tx.RollbackAsync(ct);
return false;
}
db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
{
alertId,
encounterId,
patientId,
alertType = alertType.ToDbString(),
severity = severity.ToDbString(),
details,
triggeredAt,
sofaTotal = result.Total,
sofaDelta = delta,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_metrics.ClinicalAlertsTotal
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
return true;
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
public class SofaLabCache
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IConnectionMultiplexer _redis;
private readonly SofaOptions _options;
public SofaLabCache(IConnectionMultiplexer redis, IOptions<SofaOptions> options)
{
_redis = redis;
_options = options.Value;
}
public async Task StoreAsync(
Guid encounterId, string code, decimal value, DateTimeOffset recordedAt)
{
var json = JsonSerializer.Serialize(new SofaCachedValue(value, recordedAt), JsonOptions);
var key = SofaCalculator.CacheKey(encounterId, code);
await _redis.GetDatabase().StringSetAsync(
key, json, TimeSpan.FromHours(_options.LabStalenessHours));
}
public async Task<SofaCachedValue?> GetAsync(Guid encounterId, string code)
{
var cached = await _redis.GetDatabase()
.StringGetAsync(SofaCalculator.CacheKey(encounterId, code));
if (!cached.HasValue) return null;
return JsonSerializer.Deserialize<SofaCachedValue>(cached!, JsonOptions);
}
public SofaValueStatus Classify(SofaCachedValue? value)
{
if (value is null) return SofaValueStatus.Expired;
var age = DateTimeOffset.UtcNow - value.RecordedAt;
if (age.TotalHours > _options.LabStalenessHours) return SofaValueStatus.Expired;
if (age.TotalHours > _options.LabWarningHours) return SofaValueStatus.Stale;
return SofaValueStatus.Current;
}
public async Task<Dictionary<string, SofaCachedValue>> GetAllAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
var keys = SofaCalculator.SofaObservationCodes
.Select(c => (RedisKey)SofaCalculator.CacheKey(encounterId, c))
.ToArray();
var values = await cache.StringGetAsync(keys);
var result = new Dictionary<string, SofaCachedValue>();
for (var i = 0; i < SofaCalculator.SofaObservationCodes.Count; i++)
{
if (!values[i].HasValue) continue;
var parsed = JsonSerializer.Deserialize<SofaCachedValue>(values[i]!, JsonOptions);
if (parsed is not null)
result[SofaCalculator.SofaObservationCodes[i]] = parsed;
}
return result;
}
}