feature: NEWS2 Composite Scoring Engine
This commit is contained in:
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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)}.";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user