348 lines
13 KiB
C#
348 lines
13 KiB
C#
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;
|
|
}
|
|
|
|
AlertCreationGuard.EnsureAllowed(alertType);
|
|
|
|
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);
|
|
|
|
if (alertType == AlertType.SofaSepsis)
|
|
{
|
|
var handler = _services.GetRequiredService<SepsisAlertHandler>();
|
|
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, alertType, ct);
|
|
}
|
|
|
|
_metrics.ClinicalAlertsTotal
|
|
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
|
|
|
|
return true;
|
|
}
|
|
} |