feature: NEWS2 Composite Scoring Engine

This commit is contained in:
voltsrage
2026-06-18 17:39:31 +08:00
parent c3fbc20ddc
commit e6f7989298
31 changed files with 3118 additions and 45 deletions
@@ -0,0 +1,105 @@
using StackExchange.Redis;
public static class News2Calculator
{
// The 7 NEWS2 parameter codes. Order is stable for MGET.
public static readonly IReadOnlyList<string> ParameterCodes = new[]
{
"RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE", "AVPU", "TEMP_C", "SUPPLEMENTAL_O2"
};
public static RedisKey[] AllParameterKeys(Guid encounterId) =>
ParameterCodes
.Select(code => (RedisKey)$"news2:{encounterId}:{code}")
.ToArray();
public static string ParameterKey(Guid encounterId, string code) =>
$"news2:{encounterId}:{code}";
public static bool IsNews2Code(string observationCode) =>
ParameterCodes.Contains(observationCode);
// --- Individual parameter scoring ---
// Each method returns 0-3 per the official NEWS2 scoring table.
public static int ScoreRespRate(decimal value) => value switch
{
<= 8 => 3,
<= 11 => 1,
<= 20 => 0,
<= 24 => 2,
_ => 3 // >= 25
};
// Scale 1 (standard). Scale 2 (hypercapnic respiratory failure) is not implemented.
public static int ScoreSpo2(decimal value) => value switch
{
<= 91 => 3,
<= 93 => 2,
<= 95 => 1,
_ => 0 // >= 96
};
public static int ScoreSystolicBp(decimal value) => value switch
{
<= 90 => 3,
<= 100 => 2,
<= 110 => 1,
<= 219 => 0,
_ => 3 // >= 220
};
public static int ScoreHeartRate(decimal value) => value switch
{
<= 40 => 3,
<= 50 => 1,
<= 90 => 0,
<= 110 => 1,
<= 130 => 2,
_ => 3 // >= 131
};
// AVPU: Alert=0, Voice/Pain/Unresponsive=3 (any non-Alert scores 3)
public static int ScoreConsciousness(decimal value) => value switch
{
0 => 0, // Alert
_ => 3 // Voice (1), Pain (2), Unresponsive (3)
};
public static int ScoreTemperature(decimal value) => value switch
{
<= 35.0m => 3,
<= 36.0m => 1,
<= 38.0m => 0,
<= 39.0m => 1,
_ => 2 // >= 39.1
};
// 0 = room air, 1 = on supplemental oxygen
public static int ScoreSupplementalO2(decimal value) =>
value >= 1 ? 2 : 0;
// Dispatch to the correct scoring function by observation code.
public static int ScoreParameter(string observationCode, decimal value) =>
observationCode switch
{
"RESP_RATE" => ScoreRespRate(value),
"SPO2" => ScoreSpo2(value),
"SYSTOLIC_BP" => ScoreSystolicBp(value),
"HEART_RATE" => ScoreHeartRate(value),
"AVPU" => ScoreConsciousness(value),
"TEMP_C" => ScoreTemperature(value),
"SUPPLEMENTAL_O2" => ScoreSupplementalO2(value),
_ => throw new ArgumentOutOfRangeException(nameof(observationCode))
};
// Determine risk level from total score and single-param-3 flag.
public static string DetermineRiskLevel(int totalScore, bool hasSingleParamThree) =>
totalScore switch
{
>= 7 => "HIGH",
>= 5 => "MEDIUM",
_ when hasSingleParamThree => "LOW_MEDIUM",
_ => "LOW"
};
}
+224
View File
@@ -0,0 +1,224 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using StackExchange.Redis;
public class News2Detector
{
private const int News2TtlSeconds = 14400; // 4 hours
private static readonly JsonSerializerOptions CachedParamJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IConnectionMultiplexer _redis;
private readonly IServiceProvider _services;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<News2Detector> _logger;
public News2Detector(
IConnectionMultiplexer redis,
IServiceProvider services,
ClinicalMetrics metrics,
ILogger<News2Detector> logger)
{
_redis = redis;
_services = services;
_metrics = metrics;
_logger = logger;
}
public async Task<News2Result> ProcessObservationAsync(
Guid encounterId,
Guid patientId,
string observationCode,
decimal value,
CancellationToken ct = default)
{
if (!News2Calculator.IsNews2Code(observationCode))
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
{
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
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 (!allValues[i].HasValue)
{
_logger.LogDebug(
"NEWS2 incomplete for encounter {Id}: {Code} missing ({Present}/7 present)",
encounterId, News2Calculator.ParameterCodes[i],
allValues.Count(v => v.HasValue));
return News2Result.IncompleteParameters(allValues.Count(v => v.HasValue));
}
var cached = JsonSerializer.Deserialize<News2CachedParam>(allValues[i]!, CachedParamJsonOptions);
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(
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")
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.News2Emergency, AlertSeverity.Critical,
totalScore, riskLevel, paramScores, ct);
}
else if (riskLevel == "MEDIUM" || riskLevel == "LOW_MEDIUM")
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.News2Warning, AlertSeverity.Warning,
totalScore, riskLevel, paramScores, ct);
}
return new News2Result(
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
}
private async Task<Guid> PersistScoreAsync(
Guid encounterId, Guid patientId,
int totalScore, string riskLevel,
int[] paramScores, bool hasSingleParamThree,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var score = new News2Score
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
TotalScore = totalScore,
RiskLevel = riskLevel,
RespRateScore = paramScores[0],
Spo2Score = paramScores[1],
SystolicBpScore = paramScores[2],
HeartRateScore = paramScores[3],
ConsciousnessScore = paramScores[4],
TemperatureScore = paramScores[5],
SupplementalO2Score = paramScores[6],
HasSingleParamThree = hasSingleParamThree,
CalculatedAt = DateTimeOffset.UtcNow
};
db.News2Scores.Add(score);
await db.SaveChangesAsync(ct);
_metrics.News2ScoresTotal.WithLabels(riskLevel).Inc();
return score.Id;
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
int totalScore, string riskLevel, int[] paramScores,
CancellationToken ct)
{
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 = BuildDetails(totalScore, riskLevel, paramScores);
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(),
triggeredAt,
news2Score = totalScore,
news2RiskLevel = riskLevel,
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();
_logger.LogWarning(
"NEWS2 alert {AlertType} created for encounter {EncounterId} — score={Score} risk={Risk}",
alertType.ToDbString(), encounterId, totalScore, riskLevel);
return true;
}
private static string BuildDetails(int totalScore, string riskLevel, int[] paramScores)
{
var components = News2Calculator.ParameterCodes
.Zip(paramScores, (code, score) => $"{code}={score}")
.ToArray();
return $"NEWS2 score {totalScore} ({riskLevel}): {string.Join(", ", components)}.";
}
}