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
+59 -23
View File
@@ -40,30 +40,51 @@ public class News2Detector
return News2Result.NotNews2Code;
using var timer = _metrics.News2ScoringDuration.NewTimer();
var cache = _redis.GetDatabase();
// Compute the individual score and store in Redis
var individualScore = News2Calculator.ScoreParameter(observationCode, value);
var paramData = JsonSerializer.Serialize(new
// GCS components are cached by GcsDetector — trigger re-score only
if (!GcsCalculator.IsGcsCode(observationCode))
{
value,
score = individualScore,
recordedAt = DateTimeOffset.UtcNow
}, CachedParamJsonOptions);
await cache.StringSetAsync(
News2Calculator.ParameterKey(encounterId, observationCode),
paramData,
TimeSpan.FromSeconds(News2TtlSeconds));
var individualScore = News2Calculator.ScoreParameter(observationCode, value);
var paramData = JsonSerializer.Serialize(new
{
value,
score = individualScore,
recordedAt = DateTimeOffset.UtcNow
}, CachedParamJsonOptions);
await cache.StringSetAsync(
News2Calculator.ParameterKey(encounterId, observationCode),
paramData,
TimeSpan.FromSeconds(News2TtlSeconds));
}
// Fetch all 7 parameter keys in one MGET round-trip
return await TryComputeScoreAsync(encounterId, patientId, ct);
}
private async Task<News2Result> TryComputeScoreAsync(
Guid encounterId, Guid patientId, CancellationToken ct)
{
var cache = _redis.GetDatabase();
var allKeys = News2Calculator.AllParameterKeys(encounterId);
var allValues = await cache.StringGetAsync(allKeys);
// Check completeness — all 7 must be present
var scores = new int?[7];
for (int i = 0; i < 7; i++)
{
if (i == 4) // consciousness — GCS-first, AVPU-fallback
{
scores[4] = await ResolveConsciousnessScoreAsync(encounterId);
if (scores[4] is null)
{
var present = allValues.Count(v => v.HasValue) + 0;
_logger.LogDebug(
"NEWS2 incomplete for encounter {Id}: consciousness missing ({Present}/7 present)",
encounterId, present);
return News2Result.IncompleteParameters(present);
}
continue;
}
if (!allValues[i].HasValue)
{
_logger.LogDebug(
@@ -77,23 +98,15 @@ public class News2Detector
scores[i] = cached?.Score;
}
// All 7 present — compute aggregate
var paramScores = scores.Select(s => s!.Value).ToArray();
var totalScore = paramScores.Sum();
var hasSingleParamThree = paramScores.Any(s => s == 3);
var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree);
// Persist the score to PostgreSQL
var scoreId = await PersistScoreAsync(
await PersistScoreAsync(
encounterId, patientId, totalScore, riskLevel,
paramScores, hasSingleParamThree, ct);
_logger.LogInformation(
"NEWS2 score {Score} ({Risk}) for encounter {Id} — components: {Components}",
totalScore, riskLevel, encounterId,
string.Join(",", News2Calculator.ParameterCodes.Zip(paramScores, (c, s) => $"{c}={s}")));
// Create alert if warranted
var alertCreated = false;
if (riskLevel == "HIGH")
{
@@ -112,6 +125,29 @@ public class News2Detector
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
}
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (gcsValues.All(v => v.HasValue))
{
var eye = decimal.Parse(gcsValues[0]!);
var verbal = decimal.Parse(gcsValues[1]!);
var motor = decimal.Parse(gcsValues[2]!);
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
return News2Calculator.ScoreConsciousnessFromGcs(total);
}
var avpuVal = await cache.StringGetAsync(
News2Calculator.ParameterKey(encounterId, "AVPU"));
if (!avpuVal.HasValue)
return null;
var cached = JsonSerializer.Deserialize<News2CachedParam>(avpuVal!, CachedParamJsonOptions);
return cached?.Score;
}
private async Task<Guid> PersistScoreAsync(
Guid encounterId, Guid patientId,
int totalScore, string riskLevel,