feature:
Full SOFA Score: Data Layer + Scoring Engine Glasgow Coma Scale: Data Layer + Scoring Engine
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
public static class SofaCalculator
|
||||
{
|
||||
public static readonly IReadOnlyList<string> SofaObservationCodes = new[]
|
||||
{
|
||||
"PAO2_MMHG", "FIO2_PCT", "PLATELET_K_UL", "BILIRUBIN_MG_DL",
|
||||
"CREATININE_MG_DL", "URINE_OUTPUT_ML_H",
|
||||
"SYSTOLIC_BP", "DIASTOLIC_BP", "SPO2", "SUPPLEMENTAL_O2"
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> SofaCodeSet =
|
||||
new HashSet<string>(SofaObservationCodes);
|
||||
|
||||
public static bool IsSofaCode(string observationCode) =>
|
||||
SofaCodeSet.Contains(observationCode);
|
||||
|
||||
// GCS components and gcs.scored also trigger SOFA re-score (CNS organ system)
|
||||
public static bool TriggersRescore(string observationCode) =>
|
||||
IsSofaCode(observationCode) || GcsCalculator.IsGcsCode(observationCode);
|
||||
|
||||
public static string CacheKey(Guid encounterId, string code) =>
|
||||
$"sofa:{encounterId}:{code}";
|
||||
|
||||
// --- 1. Respiratory: PaO2/FiO2 ratio ---
|
||||
public static int ScoreRespiratory(decimal? pao2, decimal? fio2, bool onMechanicalVent)
|
||||
{
|
||||
if (pao2 is null || fio2 is null || fio2 == 0) return 0;
|
||||
var ratio = pao2.Value / (fio2.Value / 100m);
|
||||
return ratio switch
|
||||
{
|
||||
>= 400 when onMechanicalVent => 0,
|
||||
>= 400 => 0,
|
||||
>= 300 => 1,
|
||||
>= 200 => 2,
|
||||
>= 100 when onMechanicalVent => 3,
|
||||
>= 100 => 2,
|
||||
_ when onMechanicalVent => 4,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
// SpO2/FiO2 proxy when PaO2 unavailable (Rice et al. 2007)
|
||||
public static int ScoreRespiratoryFromSpo2(decimal? spo2, decimal? fio2, bool onMechanicalVent)
|
||||
{
|
||||
if (spo2 is null || fio2 is null || fio2 == 0) return 0;
|
||||
var sfRatio = spo2.Value / (fio2.Value / 100m);
|
||||
return sfRatio switch
|
||||
{
|
||||
>= 315 => 0,
|
||||
>= 235 => 1,
|
||||
>= 150 => 2,
|
||||
>= 67 when onMechanicalVent => 3,
|
||||
>= 67 => 2,
|
||||
_ when onMechanicalVent => 4,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
// --- 2. Coagulation: Platelet count (k/µL) ---
|
||||
public static int ScoreCoagulation(decimal? platelets)
|
||||
{
|
||||
if (platelets is null) return 0;
|
||||
return platelets.Value switch
|
||||
{
|
||||
>= 150 => 0,
|
||||
>= 100 => 1,
|
||||
>= 50 => 2,
|
||||
>= 20 => 3,
|
||||
_ => 4
|
||||
};
|
||||
}
|
||||
|
||||
// --- 3. Liver: Bilirubin (mg/dL) ---
|
||||
public static int ScoreLiver(decimal? bilirubin)
|
||||
{
|
||||
if (bilirubin is null) return 0;
|
||||
return bilirubin.Value switch
|
||||
{
|
||||
< 1.2m => 0,
|
||||
< 2.0m => 1,
|
||||
< 6.0m => 2,
|
||||
< 12.0m => 3,
|
||||
_ => 4
|
||||
};
|
||||
}
|
||||
|
||||
// --- 4. Cardiovascular: MAP and vasopressor dose ---
|
||||
public static int ScoreCardiovascular(decimal? map, VasopressorInfo? vasopressor)
|
||||
{
|
||||
if (vasopressor is not null)
|
||||
{
|
||||
return vasopressor.DrugName.ToUpperInvariant() switch
|
||||
{
|
||||
"DOPAMINE" when vasopressor.DoseUgKgMin > 15m => 4,
|
||||
"EPINEPHRINE" when vasopressor.DoseUgKgMin > 0.1m => 4,
|
||||
"NOREPINEPHRINE" when vasopressor.DoseUgKgMin > 0.1m => 4,
|
||||
"DOPAMINE" when vasopressor.DoseUgKgMin > 5m => 3,
|
||||
"EPINEPHRINE" => 3,
|
||||
"NOREPINEPHRINE" => 3,
|
||||
"DOPAMINE" => 2,
|
||||
"DOBUTAMINE" => 2,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
|
||||
if (map is null) return 0;
|
||||
return map.Value < 70m ? 1 : 0;
|
||||
}
|
||||
|
||||
// --- 5. CNS: Glasgow Coma Scale (from Phase 25) ---
|
||||
public static int ScoreCns(int? gcsTotal) =>
|
||||
GcsCalculator.ToSofaCnsScore(gcsTotal ?? 15);
|
||||
|
||||
// --- 6. Renal: Creatinine (mg/dL) or urine output (mL/day) ---
|
||||
public static int ScoreRenal(decimal? creatinine, decimal? urineOutputMlPerDay)
|
||||
{
|
||||
var creatScore = creatinine switch
|
||||
{
|
||||
null => 0,
|
||||
< 1.2m => 0,
|
||||
< 2.0m => 1,
|
||||
< 3.5m => 2,
|
||||
< 5.0m => 3,
|
||||
_ => 4
|
||||
};
|
||||
|
||||
var urineScore = urineOutputMlPerDay switch
|
||||
{
|
||||
null => 0,
|
||||
< 200m => 4,
|
||||
< 500m => 3,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
return Math.Max(creatScore, urineScore);
|
||||
}
|
||||
|
||||
public static SofaResult ComputeTotal(
|
||||
int respiratory, int coagulation, int liver,
|
||||
int cardiovascular, int cns, int renal) =>
|
||||
new(
|
||||
Total: respiratory + coagulation + liver + cardiovascular + cns + renal,
|
||||
Respiratory: respiratory,
|
||||
Coagulation: coagulation,
|
||||
Liver: liver,
|
||||
Cardiovascular: cardiovascular,
|
||||
Cns: cns,
|
||||
Renal: renal);
|
||||
|
||||
// Count organ systems with component data for baseline eligibility
|
||||
public static int CountPopulatedOrganSystems(
|
||||
bool hasRespiratory,
|
||||
bool hasCoagulation,
|
||||
bool hasLiver,
|
||||
bool hasCardiovascular,
|
||||
bool hasCns,
|
||||
bool hasRenal) =>
|
||||
new[] { hasRespiratory, hasCoagulation, hasLiver,
|
||||
hasCardiovascular, hasCns, hasRenal }
|
||||
.Count(hasData => hasData);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Prometheus;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class SofaDetector
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly SofaLabCache _labCache;
|
||||
private readonly SofaVasopressorResolver _vasopressors;
|
||||
private readonly SofaOptions _options;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
private readonly ILogger<SofaDetector> _logger;
|
||||
|
||||
public SofaDetector(
|
||||
IConnectionMultiplexer redis,
|
||||
IServiceProvider services,
|
||||
SofaLabCache labCache,
|
||||
SofaVasopressorResolver vasopressors,
|
||||
IOptions<SofaOptions> options,
|
||||
ClinicalMetrics metrics,
|
||||
ILogger<SofaDetector> logger)
|
||||
{
|
||||
_redis = redis;
|
||||
_services = services;
|
||||
_labCache = labCache;
|
||||
_vasopressors = vasopressors;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SofaScoringResult> ProcessObservationAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
string observationCode,
|
||||
decimal value,
|
||||
DateTimeOffset recordedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!SofaCalculator.TriggersRescore(observationCode))
|
||||
return SofaScoringResult.NotSofaTrigger;
|
||||
|
||||
if (SofaCalculator.IsSofaCode(observationCode))
|
||||
{
|
||||
await _labCache.StoreAsync(encounterId, observationCode, value, recordedAt);
|
||||
}
|
||||
|
||||
return await TryComputeScoreAsync(encounterId, patientId, ct);
|
||||
}
|
||||
|
||||
public Task<SofaScoringResult> ProcessGcsScoredAsync(
|
||||
Guid encounterId, Guid patientId, CancellationToken ct = default) =>
|
||||
TryComputeScoreAsync(encounterId, patientId, ct);
|
||||
|
||||
private async Task<SofaScoringResult> TryComputeScoreAsync(
|
||||
Guid encounterId, Guid patientId, CancellationToken ct)
|
||||
{
|
||||
using var timer = _metrics.SofaScoringDuration.NewTimer();
|
||||
|
||||
using (var scope = _services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
if (!await db.Encounters.AnyAsync(e => e.Id == encounterId, ct))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping SOFA score for unknown encounter {EncounterId}", encounterId);
|
||||
return SofaScoringResult.EncounterNotFound;
|
||||
}
|
||||
}
|
||||
|
||||
var cached = await _labCache.GetAllAsync(encounterId);
|
||||
var staleComponents = new List<string>();
|
||||
var missingComponents = new List<string>();
|
||||
|
||||
decimal? GetValue(string code)
|
||||
{
|
||||
if (!cached.TryGetValue(code, out var entry))
|
||||
{
|
||||
missingComponents.Add(code);
|
||||
return null;
|
||||
}
|
||||
var status = _labCache.Classify(entry);
|
||||
if (status == SofaValueStatus.Expired)
|
||||
{
|
||||
missingComponents.Add(code);
|
||||
return null;
|
||||
}
|
||||
if (status == SofaValueStatus.Stale)
|
||||
staleComponents.Add(code);
|
||||
return entry.Value;
|
||||
}
|
||||
|
||||
var pao2 = GetValue("PAO2_MMHG");
|
||||
var fio2 = GetValue("FIO2_PCT");
|
||||
var spo2 = GetValue("SPO2");
|
||||
var supplementalO2 = GetValue("SUPPLEMENTAL_O2");
|
||||
var onMechanicalVent = supplementalO2 is >= 1m;
|
||||
|
||||
var usedSpO2Fallback = false;
|
||||
int respiratory;
|
||||
if (pao2 is not null && fio2 is not null)
|
||||
{
|
||||
respiratory = SofaCalculator.ScoreRespiratory(pao2, fio2, onMechanicalVent);
|
||||
}
|
||||
else if (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null)
|
||||
{
|
||||
usedSpO2Fallback = true;
|
||||
respiratory = SofaCalculator.ScoreRespiratoryFromSpo2(spo2, fio2, onMechanicalVent);
|
||||
}
|
||||
else
|
||||
{
|
||||
respiratory = 0;
|
||||
if (pao2 is null) missingComponents.Add("PAO2_MMHG");
|
||||
if (fio2 is null) missingComponents.Add("FIO2_PCT");
|
||||
}
|
||||
|
||||
var coagulation = SofaCalculator.ScoreCoagulation(GetValue("PLATELET_K_UL"));
|
||||
var liver = SofaCalculator.ScoreLiver(GetValue("BILIRUBIN_MG_DL"));
|
||||
|
||||
decimal? map = null;
|
||||
var sbp = GetValue("SYSTOLIC_BP");
|
||||
var dbp = GetValue("DIASTOLIC_BP");
|
||||
if (sbp is not null && dbp is not null)
|
||||
map = MapCalculator.Calculate(sbp.Value, dbp.Value);
|
||||
|
||||
var vasopressor = await _vasopressors.GetActiveVasopressorAsync(encounterId, ct);
|
||||
var cardiovascular = SofaCalculator.ScoreCardiovascular(map, vasopressor);
|
||||
|
||||
var gcsTotal = await LoadGcsTotalAsync(encounterId);
|
||||
var cns = SofaCalculator.ScoreCns(gcsTotal);
|
||||
|
||||
var creatinine = GetValue("CREATININE_MG_DL");
|
||||
var urineMlH = GetValue("URINE_OUTPUT_ML_H");
|
||||
decimal? urineMlDay = urineMlH is not null ? urineMlH * 24m : null;
|
||||
var renal = SofaCalculator.ScoreRenal(creatinine, urineMlDay);
|
||||
|
||||
var result = SofaCalculator.ComputeTotal(
|
||||
respiratory, coagulation, liver, cardiovascular, cns, renal);
|
||||
|
||||
var stalenessFlags = JsonSerializer.Serialize(new SofaStalenessFlags(
|
||||
staleComponents.Distinct().ToList(),
|
||||
missingComponents.Distinct().ToList(),
|
||||
usedSpO2Fallback), JsonOptions);
|
||||
|
||||
var calculatedAt = DateTimeOffset.UtcNow;
|
||||
var (isBaseline, delta) = await ResolveBaselineAndDeltaAsync(
|
||||
encounterId, patientId, result, calculatedAt,
|
||||
SofaCalculator.CountPopulatedOrganSystems(
|
||||
hasRespiratory: (pao2 is not null && fio2 is not null)
|
||||
|| (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null),
|
||||
hasCoagulation: cached.ContainsKey("PLATELET_K_UL")
|
||||
&& _labCache.Classify(cached["PLATELET_K_UL"]) != SofaValueStatus.Expired,
|
||||
hasLiver: cached.ContainsKey("BILIRUBIN_MG_DL")
|
||||
&& _labCache.Classify(cached["BILIRUBIN_MG_DL"]) != SofaValueStatus.Expired,
|
||||
hasCardiovascular: (sbp is not null && dbp is not null) || vasopressor is not null,
|
||||
hasCns: gcsTotal is not null,
|
||||
hasRenal: (cached.ContainsKey("CREATININE_MG_DL")
|
||||
&& _labCache.Classify(cached["CREATININE_MG_DL"]) != SofaValueStatus.Expired)
|
||||
|| (cached.ContainsKey("URINE_OUTPUT_ML_H")
|
||||
&& _labCache.Classify(cached["URINE_OUTPUT_ML_H"]) != SofaValueStatus.Expired)),
|
||||
ct);
|
||||
|
||||
await PersistScoreAsync(
|
||||
encounterId, patientId, result, isBaseline, delta,
|
||||
stalenessFlags, calculatedAt, ct);
|
||||
|
||||
var alertCreated = false;
|
||||
if (delta is >= 2)
|
||||
{
|
||||
alertCreated = await TryCreateAlertAsync(
|
||||
encounterId, patientId, AlertType.SofaSepsis, AlertSeverity.Critical,
|
||||
result, isBaseline ? null : delta, stalenessFlags, ct);
|
||||
}
|
||||
else if (delta == 1)
|
||||
{
|
||||
alertCreated = await TryCreateAlertAsync(
|
||||
encounterId, patientId, AlertType.SofaWarning, AlertSeverity.Warning,
|
||||
result, delta, stalenessFlags, ct);
|
||||
}
|
||||
|
||||
_metrics.SofaScoresTotal
|
||||
.WithLabels(alertCreated ? "true" : "false")
|
||||
.Inc();
|
||||
|
||||
_logger.LogInformation(
|
||||
"SOFA score {Total} for encounter {Id} — baseline={Baseline} delta={Delta}",
|
||||
result.Total, encounterId, isBaseline, delta);
|
||||
|
||||
return new SofaScoringResult(
|
||||
SofaOutcome.ScoreComputed, result, isBaseline, delta, alertCreated);
|
||||
}
|
||||
|
||||
private async Task<int?> LoadGcsTotalAsync(Guid encounterId)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
|
||||
if (!gcsValues.All(v => v.HasValue)) return null;
|
||||
|
||||
var eye = decimal.Parse(gcsValues[0]!, CultureInfo.InvariantCulture);
|
||||
var verbal = decimal.Parse(gcsValues[1]!, CultureInfo.InvariantCulture);
|
||||
var motor = decimal.Parse(gcsValues[2]!, CultureInfo.InvariantCulture);
|
||||
return GcsCalculator.ComputeTotal(eye, verbal, motor);
|
||||
}
|
||||
|
||||
private async Task<(bool IsBaseline, int? Delta)> ResolveBaselineAndDeltaAsync(
|
||||
Guid encounterId, Guid patientId, SofaResult result,
|
||||
DateTimeOffset calculatedAt, int populatedOrganSystems, CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var existingBaseline = await db.SofaScores
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.EncounterId == encounterId && s.IsBaseline, ct);
|
||||
|
||||
if (existingBaseline is null)
|
||||
{
|
||||
if (populatedOrganSystems >= 4)
|
||||
return (true, null);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var delta = result.Total - existingBaseline.TotalScore;
|
||||
return (false, delta);
|
||||
}
|
||||
|
||||
private async Task PersistScoreAsync(
|
||||
Guid encounterId, Guid patientId, SofaResult result,
|
||||
bool isBaseline, int? delta, string stalenessFlags,
|
||||
DateTimeOffset calculatedAt, CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
db.SofaScores.Add(new SofaScore
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
TotalScore = result.Total,
|
||||
RespiratoryScore = result.Respiratory,
|
||||
CoagulationScore = result.Coagulation,
|
||||
LiverScore = result.Liver,
|
||||
CardiovascularScore = result.Cardiovascular,
|
||||
CnsScore = result.Cns,
|
||||
RenalScore = result.Renal,
|
||||
IsBaseline = isBaseline,
|
||||
DeltaFromBaseline = delta,
|
||||
StalenessFlags = stalenessFlags,
|
||||
CalculatedAt = calculatedAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<bool> TryCreateAlertAsync(
|
||||
Guid encounterId, Guid patientId,
|
||||
AlertType alertType, AlertSeverity severity,
|
||||
SofaResult result, int? delta, string stalenessFlags,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (alertType == AlertType.SofaWarning)
|
||||
{
|
||||
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
|
||||
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
|
||||
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 =
|
||||
$"SOFA score {result.Total} (delta +{delta}). " +
|
||||
$"Components: Resp={result.Respiratory}, Coag={result.Coagulation}, " +
|
||||
$"Liver={result.Liver}, CV={result.Cardiovascular}, CNS={result.Cns}, Renal={result.Renal}. " +
|
||||
$"Staleness: {stalenessFlags}";
|
||||
|
||||
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,
|
||||
sofaTotal = result.Total,
|
||||
sofaDelta = delta,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class SofaLabCache
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly SofaOptions _options;
|
||||
|
||||
public SofaLabCache(IConnectionMultiplexer redis, IOptions<SofaOptions> options)
|
||||
{
|
||||
_redis = redis;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task StoreAsync(
|
||||
Guid encounterId, string code, decimal value, DateTimeOffset recordedAt)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new SofaCachedValue(value, recordedAt), JsonOptions);
|
||||
var key = SofaCalculator.CacheKey(encounterId, code);
|
||||
await _redis.GetDatabase().StringSetAsync(
|
||||
key, json, TimeSpan.FromHours(_options.LabStalenessHours));
|
||||
}
|
||||
|
||||
public async Task<SofaCachedValue?> GetAsync(Guid encounterId, string code)
|
||||
{
|
||||
var cached = await _redis.GetDatabase()
|
||||
.StringGetAsync(SofaCalculator.CacheKey(encounterId, code));
|
||||
if (!cached.HasValue) return null;
|
||||
return JsonSerializer.Deserialize<SofaCachedValue>(cached!, JsonOptions);
|
||||
}
|
||||
|
||||
public SofaValueStatus Classify(SofaCachedValue? value)
|
||||
{
|
||||
if (value is null) return SofaValueStatus.Expired;
|
||||
var age = DateTimeOffset.UtcNow - value.RecordedAt;
|
||||
if (age.TotalHours > _options.LabStalenessHours) return SofaValueStatus.Expired;
|
||||
if (age.TotalHours > _options.LabWarningHours) return SofaValueStatus.Stale;
|
||||
return SofaValueStatus.Current;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, SofaCachedValue>> GetAllAsync(Guid encounterId)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
var keys = SofaCalculator.SofaObservationCodes
|
||||
.Select(c => (RedisKey)SofaCalculator.CacheKey(encounterId, c))
|
||||
.ToArray();
|
||||
var values = await cache.StringGetAsync(keys);
|
||||
|
||||
var result = new Dictionary<string, SofaCachedValue>();
|
||||
for (var i = 0; i < SofaCalculator.SofaObservationCodes.Count; i++)
|
||||
{
|
||||
if (!values[i].HasValue) continue;
|
||||
var parsed = JsonSerializer.Deserialize<SofaCachedValue>(values[i]!, JsonOptions);
|
||||
if (parsed is not null)
|
||||
result[SofaCalculator.SofaObservationCodes[i]] = parsed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user