176 lines
6.4 KiB
C#
176 lines
6.4 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Prometheus;
|
|
using StackExchange.Redis;
|
|
|
|
public class TrendDetector
|
|
{
|
|
private readonly IConnectionMultiplexer _redis;
|
|
private readonly IServiceProvider _services;
|
|
private readonly TrendDetectionOptions _options;
|
|
private readonly ClinicalMetrics _metrics;
|
|
private readonly ILogger<TrendDetector> _logger;
|
|
|
|
public TrendDetector(
|
|
ILogger<TrendDetector> logger,
|
|
ClinicalMetrics metrics,
|
|
IServiceProvider services,
|
|
IConnectionMultiplexer redis,
|
|
IOptions<TrendDetectionOptions> options)
|
|
{
|
|
_logger = logger;
|
|
_metrics = metrics;
|
|
_services = services;
|
|
_redis = redis;
|
|
_options = options.Value;
|
|
}
|
|
|
|
public async Task<TrendResult> ProcessObservationAsync(
|
|
Guid encounterId,
|
|
Guid patientId,
|
|
string observationCode,
|
|
decimal value,
|
|
DateTimeOffset recordedAt,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (!TrendCalculator.IsTrendCode(observationCode))
|
|
return new TrendResult(TrendOutcome.NotTrendCode);
|
|
|
|
using var timer = _metrics.TrendAnalysisDuration.NewTimer();
|
|
|
|
var cache = _redis.GetDatabase();
|
|
var key = TrendCalculator.HistoryKey(encounterId, observationCode);
|
|
|
|
// Append new entry to history list
|
|
var entry = new TrendHistoryEntry(value, recordedAt);
|
|
var historyJson = await cache.StringGetAsync(key);
|
|
var history = historyJson.HasValue
|
|
? JsonSerializer.Deserialize<List<TrendHistoryEntry>>(historyJson!) ?? new()
|
|
: new List<TrendHistoryEntry>();
|
|
|
|
history.Add(entry);
|
|
|
|
// Trim to max entries and evict entries outside window
|
|
var cutoff = recordedAt.AddMinutes(-_options.WindowMinutes);
|
|
history = history
|
|
.Where(e => e.RecordedAt >= cutoff)
|
|
.TakeLast(_options.MaxHistoryEntries)
|
|
.ToList();
|
|
|
|
await cache.StringSetAsync(
|
|
key,
|
|
JsonSerializer.Serialize(history),
|
|
TimeSpan.FromSeconds(_options.HistoryTtlSeconds));
|
|
|
|
if (history.Count < 2)
|
|
return new TrendResult(TrendOutcome.InsufficientHistory, observationCode);
|
|
|
|
var rate = TrendCalculator.ComputeRatePerMinute(history, _options.WindowMinutes);
|
|
if (rate is null)
|
|
return new TrendResult(TrendOutcome.InsufficientHistory, observationCode);
|
|
|
|
if (!_options.RateThresholdsPerMinute.TryGetValue(observationCode, out var threshold))
|
|
return new TrendResult(TrendOutcome.Stable, observationCode, rate);
|
|
|
|
if (!TrendCalculator.ExceedsThreshold(observationCode, rate.Value, threshold))
|
|
return new TrendResult(TrendOutcome.Stable, observationCode, rate);
|
|
|
|
using (var scope = _services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
if (!await db.Encounters.AnyAsync(e => e.Id == encounterId, ct))
|
|
{
|
|
_logger.LogWarning(
|
|
"Skipping trend alert for unknown encounter {EncounterId}", encounterId);
|
|
return new TrendResult(TrendOutcome.EncounterNotFound, observationCode, rate);
|
|
}
|
|
}
|
|
|
|
var trendContext = TrendContextBuilder.BuildContext(observationCode, history, value);
|
|
var details = TrendCalculator.DescribeTrend(observationCode, rate.Value, value);
|
|
|
|
var contributor = new List<ScoreContributor>
|
|
{
|
|
new()
|
|
{
|
|
Parameter = TrendCalculator.DisplayName(observationCode),
|
|
Points = 0,
|
|
RawValue = value.ToString(),
|
|
NormalRange = null
|
|
}
|
|
};
|
|
var created = await TryCreateAlertAsync(
|
|
encounterId, patientId, observationCode, details, rate.Value,
|
|
contributor, trendContext, ct);
|
|
|
|
return new TrendResult(
|
|
created ? TrendOutcome.RapidDeterioration : TrendOutcome.AlertAlreadyOpen,
|
|
observationCode, rate, created, trendContext);
|
|
}
|
|
|
|
private async Task<bool> TryCreateAlertAsync(
|
|
Guid encounterId, Guid patientId,
|
|
string observationCode, string details, decimal ratePerMinute,
|
|
List<ScoreContributor> contributors, TrendContext? trendContext,
|
|
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 fullDetails = $"{details} — velocity {ratePerMinute:F2}/min over {_options.WindowMinutes}min window.";
|
|
|
|
MedicationContext? medication = null;
|
|
var correlation = scope.ServiceProvider.GetRequiredService<MedicationCorrelationHelper>();
|
|
medication = await correlation.TryGetContextAsync(encounterId, observationCode, ct);
|
|
|
|
var explanation = AlertExplanationBuilder.Build(
|
|
"Rapid deterioration", null, contributors, trendContext, medication);
|
|
|
|
var inserted = await ClinicalAlertFactory.TryInsertAsync(
|
|
db, alertId, encounterId, patientId,
|
|
AlertType.RapidDeterioration, AlertSeverity.Warning,
|
|
fullDetails, explanation, observationCode, triggeredAt, ct);
|
|
|
|
if (!inserted)
|
|
{
|
|
await tx.RollbackAsync(ct);
|
|
return false;
|
|
}
|
|
|
|
db.OutboxEvents.Add(new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "alert.generated",
|
|
Payload = ClinicalAlertFactory.SerializeOutboxPayload(new
|
|
{
|
|
alertId,
|
|
encounterId,
|
|
patientId,
|
|
alertType = "RAPID_DETERIORATION",
|
|
severity = "Warning",
|
|
details = fullDetails,
|
|
triggeredAt,
|
|
observationCode,
|
|
ratePerMinute,
|
|
explanation,
|
|
partitionKey = encounterId.ToString()
|
|
}),
|
|
PartitionKey = encounterId.ToString(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
await tx.CommitAsync(ct);
|
|
|
|
_metrics.TrendAlertsTotal.WithLabels(observationCode).Inc();
|
|
_metrics.ClinicalAlertsTotal
|
|
.WithLabels("RAPID_DETERIORATION", "Warning").Inc();
|
|
|
|
return true;
|
|
}
|
|
} |