Files
2026-06-25 00:25:31 +08:00

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;
}
}