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
+60
View File
@@ -0,0 +1,60 @@
using StackExchange.Redis;
public static class GcsCalculator
{
public static readonly IReadOnlyList<string> ComponentCodes = new[]
{
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR"
};
public static readonly IReadOnlySet<string> ComponentCodeSet =
new HashSet<string>(ComponentCodes);
public static bool IsGcsCode(string observationCode) =>
ComponentCodeSet.Contains(observationCode);
public static RedisKey[] AllComponentKeys(Guid encounterId) =>
ComponentCodes
.Select(code => (RedisKey)$"gcs:{encounterId}:{code}")
.ToArray();
public static string ComponentKey(Guid encounterId, string code) =>
$"gcs:{encounterId}:{code}";
// Compute total from three components. Returns null if any component missing.
public static int? ComputeTotal(decimal? eye, decimal? verbal, decimal? motor)
{
if (eye is null || verbal is null || motor is null)
return null;
return (int)(eye.Value + verbal.Value + motor.Value);
}
// GCS severity classification
public static string ClassifyGcs(int total) => total switch
{
<= 8 => "SEVERE", // Coma
<= 12 => "MODERATE",
_ => "MILD" // 13-15
};
// Map GCS total to NEWS2 consciousness score (replaces AVPU mapping)
public static int ToNews2ConsciousnessScore(int gcsTotal) => gcsTotal switch
{
15 => 0, // Fully alert — equivalent to AVPU=Alert
_ => 3 // Any deficit — equivalent to AVPU=Voice/Pain/Unresponsive
};
// Map GCS total to qSOFA altered mentation criterion
public static bool MeetsQsofaAlteredMentation(int gcsTotal) =>
gcsTotal < 15;
// Map GCS total to SOFA CNS score (used in Phase 26)
public static int ToSofaCnsScore(int gcsTotal) => gcsTotal switch
{
15 => 0,
>= 13 => 1, // 13-14
>= 10 => 2, // 10-12
>= 6 => 3, // 6-9
_ => 4 // < 6
};
}
+235
View File
@@ -0,0 +1,235 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class GcsDetector
{
private const int GcsTtlSeconds = 14400; // 4 hours — same as NEWS2
private readonly IConnectionMultiplexer _redis;
private readonly IServiceProvider _services;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<GcsDetector> _logger;
public GcsDetector(
IConnectionMultiplexer redis,
IServiceProvider services,
ClinicalMetrics metrics,
ILogger<GcsDetector> logger)
{
_redis = redis;
_services = services;
_metrics = metrics;
_logger = logger;
}
public async Task<GcsResult> ProcessObservationAsync(
Guid encounterId,
Guid patientId,
string observationCode,
decimal value,
CancellationToken ct = default)
{
if (!GcsCalculator.IsGcsCode(observationCode))
return GcsResult.NotGcsCode;
var cache = _redis.GetDatabase();
await cache.StringSetAsync(
GcsCalculator.ComponentKey(encounterId, observationCode),
value.ToString(CultureInfo.InvariantCulture),
TimeSpan.FromSeconds(GcsTtlSeconds));
var allKeys = GcsCalculator.AllComponentKeys(encounterId);
var allValues = await cache.StringGetAsync(allKeys);
if (allValues.Any(v => !v.HasValue))
{
var present = allValues.Count(v => v.HasValue);
_logger.LogDebug(
"GCS incomplete for encounter {Id}: {Present}/3 components present",
encounterId, present);
return GcsResult.IncompleteComponents(present);
}
var eye = decimal.Parse(allValues[0]!, CultureInfo.InvariantCulture);
var verbal = decimal.Parse(allValues[1]!, CultureInfo.InvariantCulture);
var motor = decimal.Parse(allValues[2]!, CultureInfo.InvariantCulture);
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
var classification = GcsCalculator.ClassifyGcs(total);
var calculatedAt = DateTimeOffset.UtcNow;
await PersistScoreAsync(
encounterId, patientId, (int)eye, (int)verbal, (int)motor,
total, classification, calculatedAt, ct);
var alertCreated = false;
if (total <= 8)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.GcsCritical, AlertSeverity.Critical,
eye, verbal, motor, total, classification, ct);
}
else if (total <= 12)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.GcsWarning, AlertSeverity.Warning,
eye, verbal, motor, total, classification, ct);
}
await PublishScoredEventAsync(
encounterId, patientId, eye, verbal, motor, total, classification, calculatedAt, ct);
// Re-evaluate qSOFA altered mentation from the computed GCS total (Step 6)
using (var scope = _services.CreateScope())
{
var qsofa = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
await qsofa.SyncAlteredMentationAsync(encounterId, patientId, ct);
}
_metrics.GcsScoresTotal.WithLabels(classification).Inc();
_logger.LogInformation(
"GCS score {Total} ({Classification}) for encounter {Id} — E={Eye} V={Verbal} M={Motor}",
total, classification, encounterId, eye, verbal, motor);
return new GcsResult(GcsOutcome.ScoreComputed, total, classification, alertCreated, 3);
}
private async Task PersistScoreAsync(
Guid encounterId, Guid patientId,
int eye, int verbal, int motor,
int total, string classification,
DateTimeOffset calculatedAt,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.GcsScores.Add(new GcsScore
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
EyeScore = eye,
VerbalScore = verbal,
MotorScore = motor,
TotalScore = total,
Classification = classification,
CalculatedAt = calculatedAt,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
decimal eye, decimal verbal, decimal motor,
int total, string classification,
CancellationToken ct)
{
if (alertType == AlertType.GcsWarning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
{
_logger.LogDebug("GCS_WARNING suppressed for encounter {Id}", encounterId);
return false;
}
}
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 =
$"GCS total {total} ({classification}): E={eye}, V={verbal}, M={motor}.";
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,
gcsTotal = total,
gcsClassification = classification,
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();
return true;
}
private async Task PublishScoredEventAsync(
Guid encounterId, Guid patientId,
decimal eye, decimal verbal, decimal motor,
int total, string classification,
DateTimeOffset calculatedAt,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = "gcs.scored",
Payload = JsonSerializer.Serialize(new
{
encounterId,
patientId,
eyeScore = (int)eye,
verbalScore = (int)verbal,
motorScore = (int)motor,
totalScore = total,
classification,
calculatedAt,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
}