feature:
Full SOFA Score: Data Layer + Scoring Engine Glasgow Coma Scale: Data Layer + Scoring Engine
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user