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
@@ -0,0 +1,12 @@
public record GcsResult(
GcsOutcome Outcome,
int? TotalScore = null,
string? Classification = null,
bool AlertCreated = false,
int PresentComponents = 0)
{
public static readonly GcsResult NotGcsCode = new(GcsOutcome.NotGcsCode);
public static GcsResult IncompleteComponents(int presentCount) =>
new(GcsOutcome.IncompleteComponents, PresentComponents: presentCount);
}
@@ -0,0 +1,7 @@
public record GcsScoreResponse(
int EyeScore,
int VerbalScore,
int MotorScore,
int TotalScore,
string Classification,
DateTimeOffset CalculatedAt);
@@ -0,0 +1 @@
public record SofaCachedValue(decimal Value, DateTimeOffset RecordedAt);
@@ -0,0 +1,3 @@
public record SofaResult(
int Total, int Respiratory, int Coagulation, int Liver,
int Cardiovascular, int Cns, int Renal);
@@ -0,0 +1,7 @@
public record SofaScoreResponse(
int TotalScore,
int RespiratoryScore, int CoagulationScore, int LiverScore,
int CardiovascularScore, int CnsScore, int RenalScore,
bool IsBaseline, int? DeltaFromBaseline,
SofaStalenessInfo? Staleness,
DateTimeOffset CalculatedAt);
@@ -0,0 +1,10 @@
public record SofaScoringResult(
SofaOutcome Outcome,
SofaResult? Score = null,
bool IsBaseline = false,
int? DeltaFromBaseline = null,
bool AlertCreated = false)
{
public static readonly SofaScoringResult NotSofaTrigger = new(SofaOutcome.NotSofaTrigger);
public static readonly SofaScoringResult EncounterNotFound = new(SofaOutcome.EncounterNotFound);
}
@@ -0,0 +1,4 @@
public record SofaStalenessFlags(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -0,0 +1,4 @@
public record SofaStalenessInfo(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using System.Text.Json;
public class SofaVasopressorResolver
{
private static readonly HashSet<string> VasopressorDrugs = new(StringComparer.OrdinalIgnoreCase)
{
"DOPAMINE", "DOBUTAMINE", "EPINEPHRINE", "NOREPINEPHRINE",
"VASOPRESSIN", "PHENYLEPHRINE"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private readonly AppDbContext _db;
private readonly IConnectionMultiplexer _redis;
private readonly SofaOptions _options;
public SofaVasopressorResolver(
AppDbContext db,
IConnectionMultiplexer redis,
IOptions<SofaOptions> options)
{
_db = db;
_redis = redis;
_options = options.Value;
}
public static string VasopressorCacheKey(Guid encounterId) =>
$"sofa:{encounterId}:vasopressor";
public async Task CacheFromAdministrationAsync(MedicationAdministration med, CancellationToken ct)
{
if (!VasopressorDrugs.Contains(med.DrugName)) return;
var info = new VasopressorInfo(med.DrugName, NormalizeDose(med.DrugName, med.Dose, med.DoseUnit));
var json = JsonSerializer.Serialize(new
{
info.DrugName,
info.DoseUgKgMin,
med.AdministeredAt
}, JsonOptions);
await _redis.GetDatabase().StringSetAsync(
VasopressorCacheKey(med.EncounterId),
json,
TimeSpan.FromHours(_options.VasopressorWindowHours));
}
public async Task<VasopressorInfo?> GetActiveVasopressorAsync(
Guid encounterId, CancellationToken ct)
{
var cached = await _redis.GetDatabase()
.StringGetAsync(VasopressorCacheKey(encounterId));
if (cached.HasValue)
{
using var doc = JsonDocument.Parse((string)cached!);
var root = doc.RootElement;
return new VasopressorInfo(
root.GetProperty("drugName").GetString()!,
root.GetProperty("doseUgKgMin").GetDecimal());
}
var since = DateTimeOffset.UtcNow.AddHours(-_options.VasopressorWindowHours);
var med = await _db.MedicationAdministrations
.AsNoTracking()
.Where(m => m.EncounterId == encounterId
&& VasopressorDrugs.Contains(m.DrugName)
&& m.AdministeredAt >= since)
.OrderByDescending(m => m.AdministeredAt)
.FirstOrDefaultAsync(ct);
if (med is null) return null;
return new VasopressorInfo(med.DrugName, NormalizeDose(med.DrugName, med.Dose, med.DoseUnit));
}
private static decimal NormalizeDose(string drug, decimal dose, string unit) =>
unit.ToLowerInvariant() switch
{
"mcg/kg/min" or "µg/kg/min" => dose,
"mcg/min" or "µg/min" => dose / 70m,
"mg/hr" => dose * 1000m / 60m / 70m,
_ => dose
};
}
@@ -0,0 +1 @@
public record VasopressorInfo(string DrugName, decimal DoseUgKgMin);