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
@@ -0,0 +1,84 @@
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Options;
public class GcsScoringService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger<GcsScoringService> _logger;
public GcsScoringService(
IServiceProvider services,
IOptions<KafkaOptions> kafkaOptions,
ILogger<GcsScoringService> logger)
{
_services = services;
_kafkaOptions = kafkaOptions.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "gcs-scoring",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
_logger.LogInformation("GcsScoringService started — consumer group: gcs-scoring");
try
{
while (!stoppingToken.IsCancellationRequested)
{
ConsumeResult<string, string>? result = null;
try
{
result = consumer.Consume(stoppingToken);
var evt = JsonSerializer.Deserialize<News2ObservationEvent>(
result.Message.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
using var scope = _services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<GcsDetector>();
var outcome = await detector.ProcessObservationAsync(
evt.EncounterId,
evt.PatientId,
evt.ObservationCode,
evt.Value,
stoppingToken);
if (outcome.Outcome == GcsOutcome.ScoreComputed)
_logger.LogInformation(
"GCS scored via consumer — encounter={Id} total={Total} class={Class}",
evt.EncounterId, outcome.TotalScore, outcome.Classification);
consumer.Commit(result);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"GcsScoringService failed on topic={Topic} offset={Offset} — not committing",
result?.Topic, result?.Offset.Value);
await Task.Delay(2000, stoppingToken);
}
}
}
finally
{
consumer.Close();
}
}
}
@@ -27,7 +27,8 @@ public class KafkaTopicProvisioner : IHostedService
_options.Topics.AlertAcknowledged,
_options.Topics.EncounterStatusChanged,
_options.Topics.SepsisBundleCreated,
_options.Topics.SepsisBundleUpdated
_options.Topics.SepsisBundleUpdated,
_options.Topics.GcsScored
};
var specs = topicNames.Select(name => new TopicSpecification
@@ -44,7 +45,9 @@ public class KafkaTopicProvisioner : IHostedService
}
catch (CreateTopicsException ex)
{
var errors = ex.Results.Where(r => r.Error.Code != ErrorCode.TopicAlreadyExists).ToList();
var errors = ex.Results
.Where(r => r.Error.Code is not (ErrorCode.NoError or ErrorCode.TopicAlreadyExists))
.ToList();
if (errors.Count > 0)
throw new InvalidOperationException(
$"Failed to create Kafka topics: {string.Join(", ", errors.Select(e => e.Error.Reason))}");
@@ -0,0 +1,116 @@
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Options;
public class SofaScoringService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger<SofaScoringService> _logger;
public SofaScoringService(
IServiceProvider services,
IOptions<KafkaOptions> kafkaOptions,
ILogger<SofaScoringService> logger)
{
_services = services;
_kafkaOptions = kafkaOptions.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "sofa-scoring",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(new[]
{
_kafkaOptions.Topics.ObservationRecorded,
_kafkaOptions.Topics.GcsScored
});
_logger.LogInformation("SofaScoringService started — consumer group: sofa-scoring");
try
{
while (!stoppingToken.IsCancellationRequested)
{
ConsumeResult<string, string>? result = null;
try
{
result = consumer.Consume(stoppingToken);
using var scope = _services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
if (result.Topic == _kafkaOptions.Topics.GcsScored)
{
var gcsEvt = JsonSerializer.Deserialize<GcsScoredEvent>(
result.Message.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var outcome = await detector.ProcessGcsScoredAsync(
gcsEvt.EncounterId, gcsEvt.PatientId, stoppingToken);
if (outcome.Outcome == SofaOutcome.EncounterNotFound)
_logger.LogWarning(
"Skipping stale gcs.scored event — encounter={Id} offset={Offset}",
gcsEvt.EncounterId, result.Offset.Value);
else if (outcome.Outcome == SofaOutcome.ScoreComputed)
_logger.LogInformation(
"SOFA re-scored via gcs.scored — encounter={Id} total={Total}",
gcsEvt.EncounterId, outcome.Score!.Total);
}
else
{
var evt = JsonSerializer.Deserialize<News2ObservationEvent>(
result.Message.Value,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var outcome = await detector.ProcessObservationAsync(
evt.EncounterId,
evt.PatientId,
evt.ObservationCode,
evt.Value,
DateTimeOffset.UtcNow,
stoppingToken);
if (outcome.Outcome == SofaOutcome.EncounterNotFound)
_logger.LogWarning(
"Skipping stale observation.recorded event — encounter={Id} code={Code} offset={Offset}",
evt.EncounterId, evt.ObservationCode, result.Offset.Value);
else if (outcome.Outcome == SofaOutcome.ScoreComputed)
_logger.LogInformation(
"SOFA scored via consumer — encounter={Id} total={Total}",
evt.EncounterId, outcome.Score!.Total);
}
consumer.Commit(result);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"SofaScoringService failed on topic={Topic} offset={Offset} — not committing",
result?.Topic, result?.Offset.Value);
await Task.Delay(2000, stoppingToken);
}
}
}
finally
{
consumer.Close();
}
}
}
public record GcsScoredEvent(Guid EncounterId, Guid PatientId, int TotalScore);
@@ -6,4 +6,5 @@ public class KafkaTopicOptions
public string EncounterStatusChanged { get; set; } = "encounter.status.changed";
public string SepsisBundleCreated { get; set; } = "sepsis.bundle.created";
public string SepsisBundleUpdated { get; set; } = "sepsis.bundle.updated";
public string GcsScored { get; set; } = "gcs.scored";
}
@@ -0,0 +1,7 @@
public class SofaOptions
{
public int LabStalenessHours { get; set; } = 24;
public int LabWarningHours { get; set; } = 12;
public bool UseSpO2FiO2Fallback { get; set; } = true;
public int VasopressorWindowHours { get; set; } = 1;
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/encounters/{encounterId:guid}/gcs")]
[Produces("application/json")]
public class GcsController : ControllerBase
{
private readonly IGcsService _gcs;
public GcsController(IGcsService gcs) => _gcs = gcs;
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<GcsScoreResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(Guid encounterId)
{
var score = await _gcs.GetCurrentAsync(encounterId);
if (score is null)
return NotFound(ApiResponse<object>.Fail(
404, "No GCS score computed for this encounter.", "NO_GCS_SCORE"));
return Ok(ApiResponse<GcsScoreResponse>.Ok(new GcsScoreResponse(
score.EyeScore, score.VerbalScore, score.MotorScore,
score.TotalScore, score.Classification, score.CalculatedAt)));
}
}
@@ -0,0 +1,64 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/encounters/{encounterId:guid}/sofa")]
[Produces("application/json")]
public class SofaController : ControllerBase
{
private readonly ISofaService _sofa;
public SofaController(ISofaService sofa) => _sofa = sofa;
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<SofaScoreResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Current(Guid encounterId)
{
var score = await _sofa.GetCurrentAsync(encounterId);
if (score is null)
return NotFound(ApiResponse<object>.Fail(
404, "No SOFA score computed for this encounter.", "NO_SOFA_SCORE"));
return Ok(ApiResponse<SofaScoreResponse>.Ok(MapResponse(score)));
}
[HttpGet("history")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<IActionResult> History(
Guid encounterId,
[FromQuery] int limit = 20,
[FromQuery] string? cursor = null)
{
var page = await _sofa.GetHistoryAsync(encounterId, limit, cursor);
return Ok(ApiResponse<object>.Ok(new
{
items = page.Items.Select(MapResponse),
nextCursor = page.NextCursor,
hasMore = page.HasMore
}));
}
private static SofaScoreResponse MapResponse(SofaScore score)
{
SofaStalenessInfo? staleness = null;
if (!string.IsNullOrEmpty(score.StalenessFlags))
{
var flags = JsonSerializer.Deserialize<SofaStalenessFlags>(
score.StalenessFlags,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (flags is not null)
{
staleness = new SofaStalenessInfo(
flags.StaleComponents, flags.MissingComponents, flags.UsedSpO2Fallback);
}
}
return new SofaScoreResponse(
score.TotalScore,
score.RespiratoryScore, score.CoagulationScore, score.LiverScore,
score.CardiovascularScore, score.CnsScore, score.RenalScore,
score.IsBaseline, score.DeltaFromBaseline,
staleness, score.CalculatedAt);
}
}
@@ -16,6 +16,8 @@ public class AppDbContext : DbContext
public DbSet<SepsisBundle> SepsisBundles => Set<SepsisBundle>();
public DbSet<SepsisBundleElement> SepsisBundleElements => Set<SepsisBundleElement>();
public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>();
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -12,8 +12,19 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
t.HasCheckConstraint("chk_clinical_alerts_status",
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
t.HasCheckConstraint("chk_clinical_alerts_alert_type",
"alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', " +
"'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
"alert_type IN (" +
"'SEPSIS_WARNING', " +
"'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', " +
"'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " +
"'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " +
"'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', " +
"'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', " +
"'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " +
"'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " +
"'WARNING_GLUCOSE_MG_DL', " +
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
"'RAPID_DETERIORATION', 'QSOFA_WARNING', " +
"'GCS_CRITICAL', 'GCS_WARNING')");
});
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class GcsScoreConfiguration : IEntityTypeConfiguration<GcsScore>
{
public void Configure(EntityTypeBuilder<GcsScore> builder)
{
builder.ToTable("gcs_scores", t =>
{
t.HasCheckConstraint("chk_gcs_scores_classification",
"classification IN ('MILD', 'MODERATE', 'SEVERE')");
});
builder.HasKey(g => g.Id);
builder.Property(g => g.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(g => g.EncounterId).HasColumnName("encounter_id").IsRequired();
builder.Property(g => g.PatientId).HasColumnName("patient_id").IsRequired();
builder.Property(g => g.EyeScore).HasColumnName("eye_score").IsRequired();
builder.Property(g => g.VerbalScore).HasColumnName("verbal_score").IsRequired();
builder.Property(g => g.MotorScore).HasColumnName("motor_score").IsRequired();
builder.Property(g => g.TotalScore).HasColumnName("total_score").IsRequired();
builder.Property(g => g.Classification).HasColumnName("classification").HasMaxLength(16).IsRequired();
builder.Property(g => g.CalculatedAt).HasColumnName("calculated_at").IsRequired();
builder.Property(g => g.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(g => g.Encounter)
.WithMany()
.HasForeignKey(g => g.EncounterId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => new { g.EncounterId, g.CalculatedAt });
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class SofaScoreConfiguration : IEntityTypeConfiguration<SofaScore>
{
public void Configure(EntityTypeBuilder<SofaScore> builder)
{
builder.ToTable("sofa_scores");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(s => s.EncounterId).HasColumnName("encounter_id").IsRequired();
builder.Property(s => s.PatientId).HasColumnName("patient_id").IsRequired();
builder.Property(s => s.TotalScore).HasColumnName("total_score").IsRequired();
builder.Property(s => s.RespiratoryScore).HasColumnName("respiratory_score").IsRequired();
builder.Property(s => s.CoagulationScore).HasColumnName("coagulation_score").IsRequired();
builder.Property(s => s.LiverScore).HasColumnName("liver_score").IsRequired();
builder.Property(s => s.CardiovascularScore).HasColumnName("cardiovascular_score").IsRequired();
builder.Property(s => s.CnsScore).HasColumnName("cns_score").IsRequired();
builder.Property(s => s.RenalScore).HasColumnName("renal_score").IsRequired();
builder.Property(s => s.IsBaseline).HasColumnName("is_baseline").IsRequired();
builder.Property(s => s.DeltaFromBaseline).HasColumnName("delta_from_baseline");
builder.Property(s => s.StalenessFlags).HasColumnName("staleness_flags").HasColumnType("jsonb");
builder.Property(s => s.CalculatedAt).HasColumnName("calculated_at").IsRequired();
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(s => s.Encounter)
.WithMany()
.HasForeignKey(s => s.EncounterId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(s => new { s.EncounterId, s.CalculatedAt });
builder.HasIndex(s => s.EncounterId)
.HasFilter("is_baseline = true")
.HasDatabaseName("idx_sofa_scores_baseline");
}
}
@@ -127,6 +127,70 @@ public static class DataSeeder
CriticalLow = 40m, WarningLow = 70m, WarningHigh = 180m, CriticalHigh = 400m,
CreatedAt = DateTimeOffset.UtcNow
},
// GCS components — registered for ingestion; thresholds are null (alerting is on computed total in GcsDetector)
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_EYE",
DisplayName = "GCS Eye Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_VERBAL",
DisplayName = "GCS Verbal Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "GCS_MOTOR",
DisplayName = "GCS Motor Response", Unit = "score",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "PAO2_MMHG",
DisplayName = "Partial Pressure O2 (Arterial)", Unit = "mmHg",
CriticalLow = 60m, WarningLow = 80m, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "FIO2_PCT",
DisplayName = "Fraction of Inspired O2", Unit = "%",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "PLATELET_K_UL",
DisplayName = "Platelet Count", Unit = "k/µL",
CriticalLow = 20m, WarningLow = 50m, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "BILIRUBIN_MG_DL",
DisplayName = "Total Bilirubin", Unit = "mg/dL",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 6.0m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "CREATININE_MG_DL",
DisplayName = "Serum Creatinine", Unit = "mg/dL",
CriticalLow = null, WarningLow = null, WarningHigh = 2.0m, CriticalHigh = 3.5m,
CreatedAt = DateTimeOffset.UtcNow
},
new AlertThreshold
{
Id = Guid.NewGuid(), ObservationCode = "URINE_OUTPUT_ML_H",
DisplayName = "Urine Output", Unit = "mL/h",
CriticalLow = null, WarningLow = null, WarningHigh = null, CriticalHigh = null,
CreatedAt = DateTimeOffset.UtcNow
},
};
db.AlertThresholds.AddRange(thresholds);
@@ -0,0 +1,15 @@
public class GcsScore
{
public Guid Id { get; set; }
public Guid EncounterId { get; set; }
public Guid PatientId { get; set; }
public int EyeScore { get; set; }
public int VerbalScore { get; set; }
public int MotorScore { get; set; }
public int TotalScore { get; set; }
public string Classification { get; set; } = null!;
public DateTimeOffset CalculatedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Encounter Encounter { get; set; } = null!;
}
@@ -0,0 +1,20 @@
public class SofaScore
{
public Guid Id { get; set; }
public Guid EncounterId { get; set; }
public Guid PatientId { get; set; }
public int TotalScore { get; set; }
public int RespiratoryScore { get; set; }
public int CoagulationScore { get; set; }
public int LiverScore { get; set; }
public int CardiovascularScore { get; set; }
public int CnsScore { get; set; }
public int RenalScore { get; set; }
public bool IsBaseline { get; set; }
public int? DeltaFromBaseline { get; set; }
public string? StalenessFlags { get; set; }
public DateTimeOffset CalculatedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Encounter Encounter { get; set; } = null!;
}
@@ -31,6 +31,21 @@ public enum AlertType
RapidDeterioration,
QsofaWarning,
GcsCritical,
GcsWarning,
CriticalPao2MmHg,
WarningPao2MmHg,
CriticalPlateletKUl,
WarningPlateletKUl,
CriticalBilirubinMgDl,
WarningBilirubinMgDl,
CriticalCreatinineMgDl,
WarningCreatinineMgDl,
SofaSepsis,
SofaWarning,
}
public static class AlertTypeExtensions
@@ -63,6 +78,18 @@ public static class AlertTypeExtensions
AlertType.News2Emergency => "NEWS2_EMERGENCY",
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
AlertType.QsofaWarning => "QSOFA_WARNING",
AlertType.GcsCritical => "GCS_CRITICAL",
AlertType.GcsWarning => "GCS_WARNING",
AlertType.CriticalPao2MmHg => "CRITICAL_PAO2_MMHG",
AlertType.WarningPao2MmHg => "WARNING_PAO2_MMHG",
AlertType.CriticalPlateletKUl => "CRITICAL_PLATELET_K_UL",
AlertType.WarningPlateletKUl => "WARNING_PLATELET_K_UL",
AlertType.CriticalBilirubinMgDl => "CRITICAL_BILIRUBIN_MG_DL",
AlertType.WarningBilirubinMgDl => "WARNING_BILIRUBIN_MG_DL",
AlertType.CriticalCreatinineMgDl => "CRITICAL_CREATININE_MG_DL",
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
AlertType.SofaSepsis => "SOFA_SEPSIS",
AlertType.SofaWarning => "SOFA_WARNING",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
@@ -94,6 +121,18 @@ public static class AlertTypeExtensions
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
"QSOFA_WARNING" => AlertType.QsofaWarning,
"GCS_CRITICAL" => AlertType.GcsCritical,
"GCS_WARNING" => AlertType.GcsWarning,
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
"WARNING_PAO2_MMHG" => AlertType.WarningPao2MmHg,
"CRITICAL_PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
"WARNING_PLATELET_K_UL" => AlertType.WarningPlateletKUl,
"CRITICAL_BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
"WARNING_BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
"CRITICAL_CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
"WARNING_CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
"SOFA_SEPSIS" => AlertType.SofaSepsis,
"SOFA_WARNING" => AlertType.SofaWarning,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
};
@@ -111,6 +150,10 @@ public static class AlertTypeExtensions
"LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
"AVPU" => AlertType.CriticalAvpu,
"GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
"PAO2_MMHG" => AlertType.CriticalPao2MmHg,
"PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
"BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
"CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
_ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
};
@@ -127,6 +170,10 @@ public static class AlertTypeExtensions
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
"PAO2_MMHG" => AlertType.WarningPao2MmHg,
"PLATELET_K_UL" => AlertType.WarningPlateletKUl,
"BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
"CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
_ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
};
@@ -139,7 +186,9 @@ public static class AlertTypeExtensions
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
or AlertType.CriticalGlucoseMgDl => false,
AlertType.RapidDeterioration => false, // trajectory alerts are never suppressed
AlertType.RapidDeterioration => false,
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
AlertType.SofaSepsis => false,
_ => true // all Warning* types, News2Warning, and QsofaWarning
};
@@ -155,6 +204,10 @@ public static class AlertTypeExtensions
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
AlertType.WarningPao2MmHg => "PAO2_MMHG",
AlertType.WarningPlateletKUl => "PLATELET_K_UL",
AlertType.WarningBilirubinMgDl => "BILIRUBIN_MG_DL",
AlertType.WarningCreatinineMgDl => "CREATININE_MG_DL",
_ => null
};
}
@@ -0,0 +1,6 @@
public enum GcsOutcome
{
NotGcsCode,
IncompleteComponents,
ScoreComputed
}
@@ -0,0 +1,6 @@
public enum SofaOutcome
{
NotSofaTrigger,
EncounterNotFound,
ScoreComputed
}
@@ -0,0 +1,6 @@
public enum SofaValueStatus
{
Current,
Stale,
Expired
}
+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);
}
}
@@ -0,0 +1,999 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260620153118_AddGcsScores")]
partial class AddGcsScores
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<int?>("SuppressionWindowMinutes")
.HasColumnType("integer")
.HasColumnName("suppression_window_minutes");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_WARNING')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("admitted_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("AttendingPhysician")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("attending_physician");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at");
b.Property<string>("EncounterType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("encounter_type");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("RoomBed")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'SCHEDULED'");
b.HasKey("Id");
b.HasIndex("PatientId", "AdmittedAt");
b.HasIndex("Status", "AdmittedAt")
.HasFilter("status = 'ACTIVE'");
b.ToTable("encounters", null, t =>
{
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
});
});
modelBuilder.Entity("GcsScore", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CalculatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("calculated_at");
b.Property<string>("Classification")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("classification");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<int>("EyeScore")
.HasColumnType("integer")
.HasColumnName("eye_score");
b.Property<int>("MotorScore")
.HasColumnType("integer")
.HasColumnName("motor_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.Property<int>("VerbalScore")
.HasColumnType("integer")
.HasColumnName("verbal_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.ToTable("gcs_scores", null, t =>
{
t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
});
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("AdministeredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("administered_at");
b.Property<string>("AdministeredBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("administered_by");
b.Property<decimal>("Dose")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)")
.HasColumnName("dose");
b.Property<string>("DoseUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("dose_unit");
b.Property<string>("DrugName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("drug_name");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("Route")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("route");
b.HasKey("Id");
b.HasIndex("EncounterId", "AdministeredAt");
b.HasIndex("EncounterId", "DrugName");
b.ToTable("medication_administrations", (string)null);
});
modelBuilder.Entity("News2Score", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CalculatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("calculated_at");
b.Property<int>("ConsciousnessScore")
.HasColumnType("integer")
.HasColumnName("consciousness_score");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<bool>("HasSingleParamThree")
.HasColumnType("boolean")
.HasColumnName("has_single_param_three");
b.Property<int>("HeartRateScore")
.HasColumnType("integer")
.HasColumnName("heart_rate_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("RespRateScore")
.HasColumnType("integer")
.HasColumnName("resp_rate_score");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("risk_level");
b.Property<int>("Spo2Score")
.HasColumnType("integer")
.HasColumnName("spo2_score");
b.Property<int>("SupplementalO2Score")
.HasColumnType("integer")
.HasColumnName("supplemental_o2_score");
b.Property<int>("SystolicBpScore")
.HasColumnType("integer")
.HasColumnName("systolic_bp_score");
b.Property<int>("TemperatureScore")
.HasColumnType("integer")
.HasColumnName("temperature_score");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.HasIndex("PatientId", "CalculatedAt");
b.ToTable("news2_scores", null, t =>
{
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
});
});
modelBuilder.Entity("Observation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("IdempotencyKey")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("source")
.HasDefaultValueSql("'MANUAL'");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("IdempotencyKey")
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
b.ToTable("observations", null, t =>
{
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
});
});
modelBuilder.Entity("Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("order_type");
b.Property<DateTimeOffset>("OrderedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("ordered_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("OrderedBy")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("ordered_by");
b.Property<string>("ResultSummary")
.HasColumnType("text")
.HasColumnName("result_summary");
b.Property<DateTimeOffset?>("ResultedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resulted_at");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("EncounterId", "OrderedAt");
b.HasIndex("Status", "OrderedAt")
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
b.ToTable("orders", null, t =>
{
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
});
});
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("topic");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasFilter("processed_at IS NULL");
b.ToTable("outbox_events", (string)null);
});
modelBuilder.Entity("Patient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Allergies")
.HasColumnType("text")
.HasColumnName("allergies");
b.Property<string>("BloodType")
.HasMaxLength(5)
.HasColumnType("character varying(5)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("emergency_contact_name");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("emergency_contact_phone");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("gender");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("active")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("Mrn")
.IsUnique();
b.ToTable("patients", (string)null);
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CheckType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("check_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.HasKey("Id");
b.HasIndex("EncounterId");
b.HasIndex("PatientId");
b.HasIndex("CheckType", "EncounterId")
.HasFilter("resolved_at IS NULL");
b.ToTable("reconciliation_alerts", null, t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("ComplianceStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("compliance_status")
.HasDefaultValueSql("'IN_PROGRESS'");
b.Property<DateTimeOffset>("DeadlineAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deadline_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<DateTimeOffset>("RecognizedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recognized_at");
b.Property<Guid>("TriggeringAlertId")
.HasColumnType("uuid")
.HasColumnName("triggering_alert_id");
b.Property<string>("TriggeringAlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("triggering_alert_type");
b.HasKey("Id");
b.HasIndex("ComplianceStatus");
b.HasIndex("TriggeringAlertId");
b.HasIndex("EncounterId", "RecognizedAt");
b.ToTable("sepsis_bundles", null, t =>
{
t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')");
});
});
modelBuilder.Entity("SepsisBundleElement", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("ElementType")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)")
.HasColumnName("element_type");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid")
.HasColumnName("order_id");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("BundleId", "ElementType")
.IsUnique();
b.ToTable("sepsis_bundle_elements", null, t =>
{
t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')");
t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("GcsScore", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("News2Score", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ClinicalAlert", "TriggeringAlert")
.WithMany()
.HasForeignKey("TriggeringAlertId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
b.Navigation("TriggeringAlert");
});
modelBuilder.Entity("SepsisBundleElement", b =>
{
b.HasOne("SepsisBundle", "Bundle")
.WithMany("Elements")
.HasForeignKey("BundleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Bundle");
b.Navigation("Order");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.Navigation("Elements");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddGcsScores : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "gcs_scores",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
eye_score = table.Column<int>(type: "integer", nullable: false),
verbal_score = table.Column<int>(type: "integer", nullable: false),
motor_score = table.Column<int>(type: "integer", nullable: false),
total_score = table.Column<int>(type: "integer", nullable: false),
classification = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
calculated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_gcs_scores", x => x.id);
table.CheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
table.ForeignKey(
name: "FK_gcs_scores_encounters_encounter_id",
column: x => x.encounter_id,
principalTable: "encounters",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_gcs_scores_encounter_id_calculated_at",
table: "gcs_scores",
columns: new[] { "encounter_id", "calculated_at" });
migrationBuilder.Sql("""
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
CHECK (alert_type IN (
'SEPSIS_WARNING',
'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L',
'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL',
'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L',
'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL',
'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L',
'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL',
'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L',
'WARNING_GLUCOSE_MG_DL',
'NEWS2_WARNING', 'NEWS2_EMERGENCY',
'RAPID_DETERIORATION', 'QSOFA_WARNING',
'GCS_CRITICAL', 'GCS_WARNING'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gcs_scores");
}
}
}
@@ -0,0 +1,999 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260620161423_AddSofaObservationAlertTypes")]
partial class AddSofaObservationAlertTypes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<int?>("SuppressionWindowMinutes")
.HasColumnType("integer")
.HasColumnName("suppression_window_minutes");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_WARNING')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("admitted_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("AttendingPhysician")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("attending_physician");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at");
b.Property<string>("EncounterType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("encounter_type");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("RoomBed")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'SCHEDULED'");
b.HasKey("Id");
b.HasIndex("PatientId", "AdmittedAt");
b.HasIndex("Status", "AdmittedAt")
.HasFilter("status = 'ACTIVE'");
b.ToTable("encounters", null, t =>
{
t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')");
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
});
});
modelBuilder.Entity("GcsScore", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CalculatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("calculated_at");
b.Property<string>("Classification")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("classification");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<int>("EyeScore")
.HasColumnType("integer")
.HasColumnName("eye_score");
b.Property<int>("MotorScore")
.HasColumnType("integer")
.HasColumnName("motor_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.Property<int>("VerbalScore")
.HasColumnType("integer")
.HasColumnName("verbal_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.ToTable("gcs_scores", null, t =>
{
t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
});
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("AdministeredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("administered_at");
b.Property<string>("AdministeredBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("administered_by");
b.Property<decimal>("Dose")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)")
.HasColumnName("dose");
b.Property<string>("DoseUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("dose_unit");
b.Property<string>("DrugName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("drug_name");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("Route")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("route");
b.HasKey("Id");
b.HasIndex("EncounterId", "AdministeredAt");
b.HasIndex("EncounterId", "DrugName");
b.ToTable("medication_administrations", (string)null);
});
modelBuilder.Entity("News2Score", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CalculatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("calculated_at");
b.Property<int>("ConsciousnessScore")
.HasColumnType("integer")
.HasColumnName("consciousness_score");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<bool>("HasSingleParamThree")
.HasColumnType("boolean")
.HasColumnName("has_single_param_three");
b.Property<int>("HeartRateScore")
.HasColumnType("integer")
.HasColumnName("heart_rate_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("RespRateScore")
.HasColumnType("integer")
.HasColumnName("resp_rate_score");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("risk_level");
b.Property<int>("Spo2Score")
.HasColumnType("integer")
.HasColumnName("spo2_score");
b.Property<int>("SupplementalO2Score")
.HasColumnType("integer")
.HasColumnName("supplemental_o2_score");
b.Property<int>("SystolicBpScore")
.HasColumnType("integer")
.HasColumnName("systolic_bp_score");
b.Property<int>("TemperatureScore")
.HasColumnType("integer")
.HasColumnName("temperature_score");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.HasIndex("PatientId", "CalculatedAt");
b.ToTable("news2_scores", null, t =>
{
t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')");
});
});
modelBuilder.Entity("Observation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("IdempotencyKey")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("source")
.HasDefaultValueSql("'MANUAL'");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("IdempotencyKey")
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
b.ToTable("observations", null, t =>
{
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
});
});
modelBuilder.Entity("Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("order_type");
b.Property<DateTimeOffset>("OrderedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("ordered_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("OrderedBy")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("ordered_by");
b.Property<string>("ResultSummary")
.HasColumnType("text")
.HasColumnName("result_summary");
b.Property<DateTimeOffset?>("ResultedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resulted_at");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("EncounterId", "OrderedAt");
b.HasIndex("Status", "OrderedAt")
.HasFilter("status IN ('PENDING', 'IN_PROGRESS')");
b.ToTable("orders", null, t =>
{
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')");
});
});
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("topic");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasFilter("processed_at IS NULL");
b.ToTable("outbox_events", (string)null);
});
modelBuilder.Entity("Patient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Allergies")
.HasColumnType("text")
.HasColumnName("allergies");
b.Property<string>("BloodType")
.HasMaxLength(5)
.HasColumnType("character varying(5)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("emergency_contact_name");
b.Property<string>("EmergencyContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("emergency_contact_phone");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("gender");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("active")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("Mrn")
.IsUnique();
b.ToTable("patients", (string)null);
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CheckType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("check_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.HasKey("Id");
b.HasIndex("EncounterId");
b.HasIndex("PatientId");
b.HasIndex("CheckType", "EncounterId")
.HasFilter("resolved_at IS NULL");
b.ToTable("reconciliation_alerts", null, t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("ComplianceStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("compliance_status")
.HasDefaultValueSql("'IN_PROGRESS'");
b.Property<DateTimeOffset>("DeadlineAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deadline_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<DateTimeOffset>("RecognizedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recognized_at");
b.Property<Guid>("TriggeringAlertId")
.HasColumnType("uuid")
.HasColumnName("triggering_alert_id");
b.Property<string>("TriggeringAlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("triggering_alert_type");
b.HasKey("Id");
b.HasIndex("ComplianceStatus");
b.HasIndex("TriggeringAlertId");
b.HasIndex("EncounterId", "RecognizedAt");
b.ToTable("sepsis_bundles", null, t =>
{
t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')");
});
});
modelBuilder.Entity("SepsisBundleElement", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("ElementType")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)")
.HasColumnName("element_type");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid")
.HasColumnName("order_id");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'PENDING'");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("BundleId", "ElementType")
.IsUnique();
b.ToTable("sepsis_bundle_elements", null, t =>
{
t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')");
t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("GcsScore", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("News2Score", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Encounter");
b.Navigation("Patient");
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ClinicalAlert", "TriggeringAlert")
.WithMany()
.HasForeignKey("TriggeringAlertId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
b.Navigation("TriggeringAlert");
});
modelBuilder.Entity("SepsisBundleElement", b =>
{
b.HasOne("SepsisBundle", "Bundle")
.WithMany("Elements")
.HasForeignKey("BundleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Bundle");
b.Navigation("Order");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
modelBuilder.Entity("SepsisBundle", b =>
{
b.Navigation("Elements");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSofaObservationAlertTypes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
CHECK (alert_type IN (
'SEPSIS_WARNING',
'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L',
'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL',
'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L',
'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL',
'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL',
'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL',
'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L',
'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL',
'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L',
'WARNING_GLUCOSE_MG_DL',
'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL',
'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL',
'NEWS2_WARNING', 'NEWS2_EMERGENCY',
'RAPID_DETERIORATION', 'QSOFA_WARNING',
'GCS_CRITICAL', 'GCS_WARNING'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSofaScores : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sofa_scores",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
total_score = table.Column<int>(type: "integer", nullable: false),
respiratory_score = table.Column<int>(type: "integer", nullable: false),
coagulation_score = table.Column<int>(type: "integer", nullable: false),
liver_score = table.Column<int>(type: "integer", nullable: false),
cardiovascular_score = table.Column<int>(type: "integer", nullable: false),
cns_score = table.Column<int>(type: "integer", nullable: false),
renal_score = table.Column<int>(type: "integer", nullable: false),
is_baseline = table.Column<bool>(type: "boolean", nullable: false),
delta_from_baseline = table.Column<int>(type: "integer", nullable: true),
staleness_flags = table.Column<string>(type: "jsonb", nullable: true),
calculated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sofa_scores", x => x.id);
table.ForeignKey(
name: "FK_sofa_scores_encounters_encounter_id",
column: x => x.encounter_id,
principalTable: "encounters",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "idx_sofa_scores_baseline",
table: "sofa_scores",
column: "encounter_id",
filter: "is_baseline = true");
migrationBuilder.CreateIndex(
name: "IX_sofa_scores_encounter_id_calculated_at",
table: "sofa_scores",
columns: new[] { "encounter_id", "calculated_at" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sofa_scores");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSofaAlertTypes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
CHECK (alert_type IN (
'SEPSIS_WARNING',
'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L',
'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL',
'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L',
'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL',
'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL',
'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL',
'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L',
'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL',
'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L',
'WARNING_GLUCOSE_MG_DL',
'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL',
'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL',
'NEWS2_WARNING', 'NEWS2_EMERGENCY',
'RAPID_DETERIORATION', 'QSOFA_WARNING',
'GCS_CRITICAL', 'GCS_WARNING',
'SOFA_SEPSIS', 'SOFA_WARNING'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -156,7 +156,7 @@ namespace VigilCareClinicalAPI.Migrations
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_WARNING')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
@@ -250,6 +250,62 @@ namespace VigilCareClinicalAPI.Migrations
});
});
modelBuilder.Entity("GcsScore", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CalculatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("calculated_at");
b.Property<string>("Classification")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("classification");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<int>("EyeScore")
.HasColumnType("integer")
.HasColumnName("eye_score");
b.Property<int>("MotorScore")
.HasColumnType("integer")
.HasColumnName("motor_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.Property<int>("VerbalScore")
.HasColumnType("integer")
.HasColumnName("verbal_score");
b.HasKey("Id");
b.HasIndex("EncounterId", "CalculatedAt");
b.ToTable("gcs_scores", null, t =>
{
t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')");
});
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.Property<Guid>("Id")
@@ -785,6 +841,81 @@ namespace VigilCareClinicalAPI.Migrations
});
});
modelBuilder.Entity("SofaScore", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CalculatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("calculated_at");
b.Property<int>("CardiovascularScore")
.HasColumnType("integer")
.HasColumnName("cardiovascular_score");
b.Property<int>("CnsScore")
.HasColumnType("integer")
.HasColumnName("cns_score");
b.Property<int>("CoagulationScore")
.HasColumnType("integer")
.HasColumnName("coagulation_score");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int?>("DeltaFromBaseline")
.HasColumnType("integer")
.HasColumnName("delta_from_baseline");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<bool>("IsBaseline")
.HasColumnType("boolean")
.HasColumnName("is_baseline");
b.Property<int>("LiverScore")
.HasColumnType("integer")
.HasColumnName("liver_score");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<int>("RenalScore")
.HasColumnType("integer")
.HasColumnName("renal_score");
b.Property<int>("RespiratoryScore")
.HasColumnType("integer")
.HasColumnName("respiratory_score");
b.Property<string>("StalenessFlags")
.HasColumnType("jsonb")
.HasColumnName("staleness_flags");
b.Property<int>("TotalScore")
.HasColumnType("integer")
.HasColumnName("total_score");
b.HasKey("Id");
b.HasIndex("EncounterId")
.HasDatabaseName("idx_sofa_scores_baseline")
.HasFilter("is_baseline = true");
b.HasIndex("EncounterId", "CalculatedAt");
b.ToTable("sofa_scores", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
@@ -807,6 +938,17 @@ namespace VigilCareClinicalAPI.Migrations
b.Navigation("Patient");
});
modelBuilder.Entity("GcsScore", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("MedicationAdministration", b =>
{
b.HasOne("Encounter", "Encounter")
@@ -905,6 +1047,17 @@ namespace VigilCareClinicalAPI.Migrations
b.Navigation("Order");
});
modelBuilder.Entity("SofaScore", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany()
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
@@ -0,0 +1,12 @@
public record GcsResult(
GcsOutcome Outcome,
int? TotalScore = null,
string? Classification = null,
bool AlertCreated = false,
int PresentComponents = 0)
{
public static readonly GcsResult NotGcsCode = new(GcsOutcome.NotGcsCode);
public static GcsResult IncompleteComponents(int presentCount) =>
new(GcsOutcome.IncompleteComponents, PresentComponents: presentCount);
}
@@ -0,0 +1,7 @@
public record GcsScoreResponse(
int EyeScore,
int VerbalScore,
int MotorScore,
int TotalScore,
string Classification,
DateTimeOffset CalculatedAt);
@@ -0,0 +1 @@
public record SofaCachedValue(decimal Value, DateTimeOffset RecordedAt);
@@ -0,0 +1,3 @@
public record SofaResult(
int Total, int Respiratory, int Coagulation, int Liver,
int Cardiovascular, int Cns, int Renal);
@@ -0,0 +1,7 @@
public record SofaScoreResponse(
int TotalScore,
int RespiratoryScore, int CoagulationScore, int LiverScore,
int CardiovascularScore, int CnsScore, int RenalScore,
bool IsBaseline, int? DeltaFromBaseline,
SofaStalenessInfo? Staleness,
DateTimeOffset CalculatedAt);
@@ -0,0 +1,10 @@
public record SofaScoringResult(
SofaOutcome Outcome,
SofaResult? Score = null,
bool IsBaseline = false,
int? DeltaFromBaseline = null,
bool AlertCreated = false)
{
public static readonly SofaScoringResult NotSofaTrigger = new(SofaOutcome.NotSofaTrigger);
public static readonly SofaScoringResult EncounterNotFound = new(SofaOutcome.EncounterNotFound);
}
@@ -0,0 +1,4 @@
public record SofaStalenessFlags(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -0,0 +1,4 @@
public record SofaStalenessInfo(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using System.Text.Json;
public class SofaVasopressorResolver
{
private static readonly HashSet<string> VasopressorDrugs = new(StringComparer.OrdinalIgnoreCase)
{
"DOPAMINE", "DOBUTAMINE", "EPINEPHRINE", "NOREPINEPHRINE",
"VASOPRESSIN", "PHENYLEPHRINE"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private readonly AppDbContext _db;
private readonly IConnectionMultiplexer _redis;
private readonly SofaOptions _options;
public SofaVasopressorResolver(
AppDbContext db,
IConnectionMultiplexer redis,
IOptions<SofaOptions> options)
{
_db = db;
_redis = redis;
_options = options.Value;
}
public static string VasopressorCacheKey(Guid encounterId) =>
$"sofa:{encounterId}:vasopressor";
public async Task CacheFromAdministrationAsync(MedicationAdministration med, CancellationToken ct)
{
if (!VasopressorDrugs.Contains(med.DrugName)) return;
var info = new VasopressorInfo(med.DrugName, NormalizeDose(med.DrugName, med.Dose, med.DoseUnit));
var json = JsonSerializer.Serialize(new
{
info.DrugName,
info.DoseUgKgMin,
med.AdministeredAt
}, JsonOptions);
await _redis.GetDatabase().StringSetAsync(
VasopressorCacheKey(med.EncounterId),
json,
TimeSpan.FromHours(_options.VasopressorWindowHours));
}
public async Task<VasopressorInfo?> GetActiveVasopressorAsync(
Guid encounterId, CancellationToken ct)
{
var cached = await _redis.GetDatabase()
.StringGetAsync(VasopressorCacheKey(encounterId));
if (cached.HasValue)
{
using var doc = JsonDocument.Parse((string)cached!);
var root = doc.RootElement;
return new VasopressorInfo(
root.GetProperty("drugName").GetString()!,
root.GetProperty("doseUgKgMin").GetDecimal());
}
var since = DateTimeOffset.UtcNow.AddHours(-_options.VasopressorWindowHours);
var med = await _db.MedicationAdministrations
.AsNoTracking()
.Where(m => m.EncounterId == encounterId
&& VasopressorDrugs.Contains(m.DrugName)
&& m.AdministeredAt >= since)
.OrderByDescending(m => m.AdministeredAt)
.FirstOrDefaultAsync(ct);
if (med is null) return null;
return new VasopressorInfo(med.DrugName, NormalizeDose(med.DrugName, med.Dose, med.DoseUnit));
}
private static decimal NormalizeDose(string drug, decimal dose, string unit) =>
unit.ToLowerInvariant() switch
{
"mcg/kg/min" or "µg/kg/min" => dose,
"mcg/min" or "µg/min" => dose / 70m,
"mg/hr" => dose * 1000m / 60m / 70m,
_ => dose
};
}
@@ -0,0 +1 @@
public record VasopressorInfo(string DrugName, decimal DoseUgKgMin);
+18 -23
View File
@@ -9,18 +9,15 @@ public static class News2Calculator
};
public static RedisKey[] AllParameterKeys(Guid encounterId) =>
ParameterCodes
.Select(code => (RedisKey)$"news2:{encounterId}:{code}")
.ToArray();
ParameterCodes
.Select(code => (RedisKey)$"news2:{encounterId}:{code}")
.ToArray();
public static string ParameterKey(Guid encounterId, string code) =>
$"news2:{encounterId}:{code}";
public static bool IsNews2Code(string observationCode) =>
ParameterCodes.Contains(observationCode);
// --- Individual parameter scoring ---
// Each method returns 0-3 per the official NEWS2 scoring table.
ParameterCodes.Contains(observationCode) || GcsCalculator.IsGcsCode(observationCode);
public static int ScoreRespRate(decimal value) => value switch
{
@@ -28,16 +25,15 @@ public static class News2Calculator
<= 11 => 1,
<= 20 => 0,
<= 24 => 2,
_ => 3 // >= 25
_ => 3
};
// Scale 1 (standard). Scale 2 (hypercapnic respiratory failure) is not implemented.
public static int ScoreSpo2(decimal value) => value switch
{
<= 91 => 3,
<= 93 => 2,
<= 95 => 1,
_ => 0 // >= 96
_ => 0
};
public static int ScoreSystolicBp(decimal value) => value switch
@@ -46,7 +42,7 @@ public static class News2Calculator
<= 100 => 2,
<= 110 => 1,
<= 219 => 0,
_ => 3 // >= 220
_ => 3
};
public static int ScoreHeartRate(decimal value) => value switch
@@ -56,30 +52,30 @@ public static class News2Calculator
<= 90 => 0,
<= 110 => 1,
<= 130 => 2,
_ => 3 // >= 131
_ => 3
};
// AVPU: Alert=0, Voice/Pain/Unresponsive=3 (any non-Alert scores 3)
public static int ScoreConsciousness(decimal value) => value switch
{
0 => 0, // Alert
_ => 3 // Voice (1), Pain (2), Unresponsive (3)
0 => 0,
_ => 3
};
public static int ScoreConsciousnessFromGcs(int gcsTotal) =>
GcsCalculator.ToNews2ConsciousnessScore(gcsTotal);
public static int ScoreTemperature(decimal value) => value switch
{
<= 35.0m => 3,
<= 36.0m => 1,
<= 38.0m => 0,
<= 39.0m => 1,
_ => 2 // >= 39.1
_ => 2
};
// 0 = room air, 1 = on supplemental oxygen
public static int ScoreSupplementalO2(decimal value) =>
value >= 1 ? 2 : 0;
// Dispatch to the correct scoring function by observation code.
public static int ScoreParameter(string observationCode, decimal value) =>
observationCode switch
{
@@ -93,13 +89,12 @@ public static class News2Calculator
_ => throw new ArgumentOutOfRangeException(nameof(observationCode))
};
// Determine risk level from total score and single-param-3 flag.
public static string DetermineRiskLevel(int totalScore, bool hasSingleParamThree) =>
totalScore switch
{
>= 7 => "HIGH",
>= 5 => "MEDIUM",
_ when hasSingleParamThree => "LOW_MEDIUM",
_ => "LOW"
>= 7 => "HIGH",
>= 5 => "MEDIUM",
_ when hasSingleParamThree => "LOW_MEDIUM",
_ => "LOW"
};
}
+59 -23
View File
@@ -40,30 +40,51 @@ public class News2Detector
return News2Result.NotNews2Code;
using var timer = _metrics.News2ScoringDuration.NewTimer();
var cache = _redis.GetDatabase();
// Compute the individual score and store in Redis
var individualScore = News2Calculator.ScoreParameter(observationCode, value);
var paramData = JsonSerializer.Serialize(new
// GCS components are cached by GcsDetector — trigger re-score only
if (!GcsCalculator.IsGcsCode(observationCode))
{
value,
score = individualScore,
recordedAt = DateTimeOffset.UtcNow
}, CachedParamJsonOptions);
await cache.StringSetAsync(
News2Calculator.ParameterKey(encounterId, observationCode),
paramData,
TimeSpan.FromSeconds(News2TtlSeconds));
var individualScore = News2Calculator.ScoreParameter(observationCode, value);
var paramData = JsonSerializer.Serialize(new
{
value,
score = individualScore,
recordedAt = DateTimeOffset.UtcNow
}, CachedParamJsonOptions);
await cache.StringSetAsync(
News2Calculator.ParameterKey(encounterId, observationCode),
paramData,
TimeSpan.FromSeconds(News2TtlSeconds));
}
// Fetch all 7 parameter keys in one MGET round-trip
return await TryComputeScoreAsync(encounterId, patientId, ct);
}
private async Task<News2Result> TryComputeScoreAsync(
Guid encounterId, Guid patientId, CancellationToken ct)
{
var cache = _redis.GetDatabase();
var allKeys = News2Calculator.AllParameterKeys(encounterId);
var allValues = await cache.StringGetAsync(allKeys);
// Check completeness — all 7 must be present
var scores = new int?[7];
for (int i = 0; i < 7; i++)
{
if (i == 4) // consciousness — GCS-first, AVPU-fallback
{
scores[4] = await ResolveConsciousnessScoreAsync(encounterId);
if (scores[4] is null)
{
var present = allValues.Count(v => v.HasValue) + 0;
_logger.LogDebug(
"NEWS2 incomplete for encounter {Id}: consciousness missing ({Present}/7 present)",
encounterId, present);
return News2Result.IncompleteParameters(present);
}
continue;
}
if (!allValues[i].HasValue)
{
_logger.LogDebug(
@@ -77,23 +98,15 @@ public class News2Detector
scores[i] = cached?.Score;
}
// All 7 present — compute aggregate
var paramScores = scores.Select(s => s!.Value).ToArray();
var totalScore = paramScores.Sum();
var hasSingleParamThree = paramScores.Any(s => s == 3);
var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree);
// Persist the score to PostgreSQL
var scoreId = await PersistScoreAsync(
await PersistScoreAsync(
encounterId, patientId, totalScore, riskLevel,
paramScores, hasSingleParamThree, ct);
_logger.LogInformation(
"NEWS2 score {Score} ({Risk}) for encounter {Id} — components: {Components}",
totalScore, riskLevel, encounterId,
string.Join(",", News2Calculator.ParameterCodes.Zip(paramScores, (c, s) => $"{c}={s}")));
// Create alert if warranted
var alertCreated = false;
if (riskLevel == "HIGH")
{
@@ -112,6 +125,29 @@ public class News2Detector
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
}
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (gcsValues.All(v => v.HasValue))
{
var eye = decimal.Parse(gcsValues[0]!);
var verbal = decimal.Parse(gcsValues[1]!);
var motor = decimal.Parse(gcsValues[2]!);
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
return News2Calculator.ScoreConsciousnessFromGcs(total);
}
var avpuVal = await cache.StringGetAsync(
News2Calculator.ParameterKey(encounterId, "AVPU"));
if (!avpuVal.HasValue)
return null;
var cached = JsonSerializer.Deserialize<News2CachedParam>(avpuVal!, CachedParamJsonOptions);
return cached?.Score;
}
private async Task<Guid> PersistScoreAsync(
Guid encounterId, Guid patientId,
int totalScore, string riskLevel,
@@ -55,6 +55,16 @@ public sealed class ClinicalMetrics
"Sepsis bundle compliance outcomes.",
labelNames: new[] { "status" });
public readonly Counter GcsScoresTotal = Metrics.CreateCounter(
"gcs_scores_total",
"GCS scores computed, labeled by classification.",
labelNames: new[] { "classification" });
public readonly Counter SofaScoresTotal = Metrics.CreateCounter(
"sofa_scores_total",
"SOFA scores computed, labeled by whether a delta alert was created.",
labelNames: new[] { "has_delta_alert" });
// --- Histograms ---
// Measures the full ingest transaction: Redis cache lookup + alert evaluation +
@@ -84,6 +94,14 @@ public sealed class ClinicalMetrics
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
});
public readonly Histogram SofaScoringDuration = Metrics.CreateHistogram(
"sofa_scoring_duration_seconds",
"SOFA scoring computation time",
new HistogramConfiguration
{
Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 }
});
// --- Gauges (set by background collectors, not incremented inline) ---
// The most clinically significant panel. A non-zero value means a patient's
+10 -1
View File
@@ -77,6 +77,8 @@ try
builder.Services.Configure<DashboardOptions>(
builder.Configuration.GetSection(DashboardOptions.Section));
builder.Services.Configure<SofaOptions>(builder.Configuration.GetSection("Sofa"));
var dashboardOptions = builder.Configuration
.GetSection(DashboardOptions.Section)
.Get<DashboardOptions>() ?? new DashboardOptions();
@@ -115,6 +117,12 @@ try
builder.Services.AddSingleton<IAlertSuppressionService, AlertSuppressionService>();
builder.Services.AddScoped<IMedicationService, MedicationService>();
builder.Services.AddScoped<MedicationCorrelationHelper>();
builder.Services.AddScoped<GcsDetector>();
builder.Services.AddScoped<IGcsService, GcsService>();
builder.Services.AddScoped<SofaLabCache>();
builder.Services.AddScoped<SofaVasopressorResolver>();
builder.Services.AddScoped<SofaDetector>();
builder.Services.AddScoped<ISofaService, SofaService>();
builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>();
@@ -136,7 +144,8 @@ try
builder.Services.AddHostedService<News2ScoringService>();
builder.Services.AddHostedService<TrendAnalyzerService>();
builder.Services.AddHostedService<SepsisBundleMonitorService>();
builder.Services.AddHostedService<GcsScoringService>();
builder.Services.AddHostedService<SofaScoringService>();
builder.Services.AddControllers()
.AddJsonOptions(opts =>
@@ -10,19 +10,18 @@ public static class QsofaCalculator
public static readonly IReadOnlySet<string> QsofaCodeSet =
new HashSet<string>(QsofaCodes);
// qSOFA criteria (Sepsis-3 consensus):
// - Respiratory rate ≥ 22 breaths/min
// - Systolic blood pressure ≤ 100 mmHg
// - Altered mentation: AVPU score ≥ 1 (any non-Alert state)
public static bool MeetsCriterion(string observationCode, decimal value) =>
observationCode switch
{
"RESP_RATE" => value >= 22m,
"SYSTOLIC_BP" => value <= 100m,
"AVPU" => value >= 1m,
_ => false
_ => false
};
public static bool MeetsGcsAlteredMentation(int gcsTotal) =>
GcsCalculator.MeetsQsofaAlteredMentation(gcsTotal);
public static string CriterionKey(Guid encounterId, string code) =>
$"qsofa:{encounterId}:{code}";
@@ -68,6 +68,44 @@ public class QsofaDetector
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
}
public async Task<QsofaResult> SyncAlteredMentationAsync(
Guid encounterId,
Guid patientId,
CancellationToken ct = default)
{
var cache = _redis.GetDatabase();
var avpuKey = QsofaCalculator.CriterionKey(encounterId, "AVPU");
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (gcsValues.All(v => v.HasValue))
{
var eye = decimal.Parse(gcsValues[0]!);
var verbal = decimal.Parse(gcsValues[1]!);
var motor = decimal.Parse(gcsValues[2]!);
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
if (QsofaCalculator.MeetsGcsAlteredMentation(total))
{
await cache.StringSetAsync(
avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds));
}
else
{
await cache.KeyDeleteAsync(avpuKey);
}
}
var allKeys = QsofaCalculator.AllCriterionKeys(encounterId);
var values = await cache.StringGetAsync(allKeys);
var activeCount = QsofaCalculator.CountActiveCriteria(values);
if (activeCount < 2)
return QsofaResult.InsufficientCriteria(activeCount);
var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, values, ct);
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId,
Guid patientId,
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
public class GcsService : IGcsService
{
private readonly AppDbContext _db;
public GcsService(AppDbContext db) => _db = db;
public async Task<GcsScore?> GetCurrentAsync(Guid encounterId)
{
return await _db.GcsScores
.AsNoTracking()
.Where(s => s.EncounterId == encounterId)
.OrderByDescending(s => s.CalculatedAt)
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,4 @@
public interface IGcsService
{
Task<GcsScore?> GetCurrentAsync(Guid encounterId);
}
@@ -0,0 +1,7 @@
public interface ISofaService
{
Task<SofaScore?> GetCurrentAsync(Guid encounterId);
Task<SofaScore?> GetBaselineAsync(Guid encounterId);
Task<CursorPage<SofaScore>> GetHistoryAsync(
Guid encounterId, int limit, string? cursorToken);
}
@@ -0,0 +1,6 @@
public static class MapCalculator
{
// Returns MAP in mmHg from systolic and diastolic BP.
public static decimal Calculate(decimal systolicBp, decimal diastolicBp) =>
Math.Round(diastolicBp + (systolicBp - diastolicBp) / 3m, 1);
}
@@ -17,6 +17,15 @@ public static class PlausibilityValidator
["LACTATE_MMOL_L"] = (0.1m, 30),
["AVPU"] = (0, 3),
["SUPPLEMENTAL_O2"] = (0, 1),
["GCS_EYE"] = (1, 4),
["GCS_VERBAL"] = (1, 5),
["GCS_MOTOR"] = (1, 6),
["PAO2_MMHG"] = (20, 600),
["FIO2_PCT"] = (21, 100),
["PLATELET_K_UL"] = (1, 1500),
["BILIRUBIN_MG_DL"] = (0.1m, 50),
["CREATININE_MG_DL"] = (0.1m, 20),
["URINE_OUTPUT_ML_H"] = (0, 500),
};
public static bool IsPlausible(string observationCode, decimal value, out string? reason)
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
public class SofaService : ISofaService
{
private readonly AppDbContext _db;
public SofaService(AppDbContext db) => _db = db;
public async Task<SofaScore?> GetCurrentAsync(Guid encounterId)
{
return await _db.SofaScores
.AsNoTracking()
.Where(s => s.EncounterId == encounterId)
.OrderByDescending(s => s.CalculatedAt)
.FirstOrDefaultAsync();
}
public async Task<SofaScore?> GetBaselineAsync(Guid encounterId)
{
return await _db.SofaScores
.AsNoTracking()
.FirstOrDefaultAsync(s => s.EncounterId == encounterId && s.IsBaseline);
}
public async Task<CursorPage<SofaScore>> GetHistoryAsync(
Guid encounterId, int limit, string? cursorToken)
{
limit = Math.Clamp(limit, 1, 100);
var query = _db.SofaScores
.AsNoTracking()
.Where(s => s.EncounterId == encounterId);
var items = await query
.OrderByDescending(s => s.CalculatedAt)
.ThenByDescending(s => s.Id)
.Take(limit + 1)
.ToListAsync();
var hasMore = items.Count > limit;
if (hasMore) items.RemoveAt(limit);
return new CursorPage<SofaScore>(items, null, hasMore);
}
}
+160
View File
@@ -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);
}
+340
View File
@@ -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;
}
}
+65
View File
@@ -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;
}
}
+8 -1
View File
@@ -44,7 +44,8 @@
"Topics": {
"ObservationRecorded": "observation.recorded",
"AlertGenerated": "alert.generated",
"EncounterStatusChanged": "encounter.status.changed"
"EncounterStatusChanged": "encounter.status.changed",
"GcsScored": "gcs.scored"
},
"NumPartitions": 6,
"OutboxBatchSize": 100,
@@ -156,5 +157,11 @@
"albuterol": ["HEART_RATE", "SPO2"]
}
},
"Sofa": {
"LabStalenessHours": 24,
"LabWarningHours": 12,
"UseSpO2FiO2Fallback": true,
"VasopressorWindowHours": 1
}
}