Files
adas-core/adas-core.Application/Customizations/HRYC/CalculatedObservations.cs
T
2026-06-26 10:29:23 +02:00

1109 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using adas_core.Application.Services.Interfaces;
using adas_core.Domain.Enums;
using adas_core.Domain.Models;
using adas_core.Domain.Models.AppSettings;
using adas_core.Domain.Models.MongoModels;
using adas_core.Domain.Models.Pumps;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
namespace adas_core.Application.Customizations.HRYC;
/// <summary>
/// Implements the HRYC-specific clinical calculations that derive secondary
/// <see cref="PatientObservation"/> values (NEWS alarms, IROX, Resp_Rate_Calculated, Hydric_Balance_Calculated,
/// Weight_Diff, Diuresis_Weight, Delta_Pressure, Daily_Balance_Calculated, Allergies, DVE, Drainage_Height,
/// Resp_Type, and Hour_Balance) from incoming raw observations and active configurations loaded from
/// <see cref="ApiSettings"/>.
/// </summary>
public class CalculatedObservations : ICalculatedObservations
{
private readonly IOptions<ApiSettings> _apiSettings;
private readonly List<string> _codesForInvasiveVentilation = [];
private readonly IConfigObservationService _configObservationService;
//private readonly Lazy<IBoxService> _boxService;
private readonly List<string> _highFrequencyVentilation = [];
private readonly Lazy<ILightBeaconService> _lightBeaconService;
private readonly ILogger<CalculatedObservations> _logger;
private readonly List<string> _nonInvasiveVentilation = [];
private readonly Lazy<IObservationService> _observationService;
private readonly Lazy<IPatientService> _patientService;
/// <summary>
/// Initializes a new instance of the <see cref="CalculatedObservations"/> class,
/// resolving its dependencies (observation, patient, light-beacon, and config-observation services,
/// plus the logger) from the supplied <see cref="IServiceProvider"/> and loading the configured
/// code catalogues (high-frequency ventilation, non-invasive ventilation, and invasive ventilation)
/// from <see cref="ApiSettings"/>.
/// </summary>
/// <param name="serviceProvider">The application's service provider used to resolve the required dependencies and configuration.</param>
public CalculatedObservations(IServiceProvider serviceProvider)
{
_observationService = serviceProvider.GetRequiredService<Lazy<IObservationService>>(); //observationService;
_patientService = serviceProvider.GetRequiredService<Lazy<IPatientService>>();
_apiSettings = serviceProvider.GetRequiredService<IOptions<ApiSettings>>(); //apiSettings;
//_boxService = serviceProvider.GetRequiredService<Lazy<IBoxService>>();
_logger = serviceProvider.GetRequiredService<ILogger<CalculatedObservations>>();
_lightBeaconService = serviceProvider.GetRequiredService<Lazy<ILightBeaconService>>();
_configObservationService =
serviceProvider.GetRequiredService<IConfigObservationService>(); //configObservationService;
var highFrenquencyVentilation = _apiSettings.Value.HighFrequencyVentilation ?? null;
highFrenquencyVentilation?.ForEach(x => _highFrequencyVentilation.Add(x.Trim()));
var nonInvasiveVentilation = _apiSettings.Value.NonInvasiveVentilation ?? null;
nonInvasiveVentilation?.ForEach(x => _nonInvasiveVentilation.Add(x.Trim()));
var codesForInvasiveVentilation = //TODO comprobar si se refiere a InvasiveVentilation
_apiSettings.Value.InvasiveVentilation ?? null;
codesForInvasiveVentilation?.ForEach(x => _codesForInvasiveVentilation.Add(x.Trim()));
}
/// <summary>
/// Dispatches the supplied raw observation to the appropriate HRYC calculation based on its
/// <see cref="BasePatientObservation.Name"/>. The set of supported calculations includes
/// <c>Resp_Mode</c> (ventilation type), <c>Diuresis</c> / <c>Weight_Current</c>
/// (weight difference and diuresis-per-kilogram), <c>AllergiesObs</c>, <c>DrainagesObs</c>,
/// <c>PEEP</c> / <c>Pleateu_Pressure</c> (driving pressure), <c>Daily_Balance</c>,
/// <c>Hydric_Balance</c> / <c>Hour_Balance</c> (time shifting), <c>FR</c> / <c>Vent_Rate</c>
/// (respiratory rate), <c>SpO2</c> / <c>FiO2</c> (IROX), and <c>NEWS</c> (alarms).
/// </summary>
/// <typeparam name="T">The concrete observation type, deriving from <see cref="BasePatientObservation"/>.</typeparam>
/// <param name="obs">The observation to map or transform in-place.</param>
/// <param name="onlyByName">
/// Reserved for future use. When <see langword="true"/>, restricts the mapping strategy to
/// name-based lookups only.
/// </param>
/// <returns>
/// A <see cref="Task{T}"/> that resolves to the (possibly transformed) observation,
/// or <see langword="null"/> when the input observation has no name.
/// </returns>
public async Task<T?> Map<T>(T obs, bool onlyByName) where T : BasePatientObservation
{
_logger.LogTrace("Mapping {name} observation {obs}", obs.Name, obs);
switch (obs.Name)
{
case "Resp_Mode":
_logger.LogTrace("Mapping Resp mode observation {obs}", obs);
await CalculateVentilationMode(obs);
break;
case "Diuresis":
case "Weight_Current":
if (obs.Name == "Weight_Current") await CalculateWeight_DiffObservation(obs);
await CalculateDiureis_WeightObservation(obs);
break;
case "AllergiesObs":
await CalculateAllergiesObservation(obs);
break;
case "DrainagesObs":
await CalculateDrainagesObservation(obs);
break;
case "PEEP":
case "Pleateu_Pressure":
await CalculateDelta_PressureObservation(obs);
break;
case "Daily_Balance":
await CalculateDaily_BalanceObservation(obs);
break;
case "Hydric_Balance":
await CalculateHydricBalanceCalculated(obs);
obs = (T)await CalculateHydricBalance(obs);
break;
case "Hour_Balance":
obs = (T)await CalculateHourBalance(obs);
break;
case "FR":
await CalculateRespRate(obs);
await CalculateIrox(obs);
break;
case "Vent_Rate":
await CalculateRespRate(obs);
break;
case "SpO2":
case "FiO2":
await CalculateIrox(obs);
break;
case "NEWS":
await CalculateNews(obs);
break;
}
return obs;
}
/// <summary>
/// Placeholder mapping for a <see cref="PatientTreatment"/>. The HRYC customization does not
/// currently derive observations from treatments.
/// </summary>
/// <param name="treatment">The treatment to map.</param>
/// <returns>Never returns a result.</returns>
/// <exception cref="NotImplementedException">Always thrown because treatment mapping is not implemented in the HRYC customization.</exception>
public Task<PatientTreatment> Map(PatientTreatment treatment)
{
throw new NotImplementedException();
}
/// <summary>
/// Identity mapping for a <see cref="PumpObservation"/>: returns the supplied instance unchanged,
/// wrapped in a completed task. Provided to satisfy the customization contract; pump observations
/// do not require HRYC-specific calculation.
/// </summary>
/// <param name="pumpObservation">The pump observation to map.</param>
/// <returns>A task containing the same <see cref="PumpObservation"/> instance that was passed in.</returns>
public async Task<PumpObservation> Map(PumpObservation pumpObservation)
{
return await Task.FromResult(pumpObservation);
}
/// <summary>
/// No-op implementation of the medicine-observation calculation hook. The HRYC customization does
/// not derive Medication or ERMedication observations from the active medicine list.
/// </summary>
/// <param name="activeMedicines">The list of active medicines (ignored).</param>
/// <param name="patientId">The unique identifier of the patient (ignored).</param>
/// <returns>A completed task.</returns>
public Task CalculateMedicineObservation(List<Medicine> activeMedicines, ObjectId patientId)
{
return Task.CompletedTask;
}
/// <summary>
/// No-op implementation of the active-bolus calculation hook. The HRYC customization does not
/// derive an OpiateBoluses observation.
/// </summary>
/// <param name="patientId">The unique identifier of the patient (ignored).</param>
/// <returns>A completed task.</returns>
public Task CalculateActiveBolus(ObjectId patientId)
{
return Task.CompletedTask;
}
/// <summary>
/// Returns an empty enumerable of active treatments. The HRYC customization does not currently
/// maintain a per-patient active-treatment cache.
/// </summary>
/// <param name="id">The unique identifier of the patient (ignored).</param>
/// <returns>A completed task containing an empty <see cref="IEnumerable{PatientTreatment}"/>.</returns>
public Task<IEnumerable<PatientTreatment?>> GetActiveTreatmentsByPatient(ObjectId id)
{
return Task.FromResult(new List<PatientTreatment?>().AsEnumerable());
}
/// <summary>
/// Identity mapping for a <see cref="PatientDiagnosis"/>: returns the supplied instance unchanged,
/// wrapped in a completed task. Provided to satisfy the customization contract.
/// </summary>
/// <param name="diagnosis">The <see cref="PatientDiagnosis"/> to map.</param>
/// <returns>A task containing the same <see cref="PatientDiagnosis"/> instance that was passed in.</returns>
public Task<PatientDiagnosis> Map(PatientDiagnosis diagnosis)
{
return Task.FromResult(diagnosis);
}
/// <summary>
/// Resolves time-inconsistencies for a new patient observation when it arrives with the same
/// (down-to-the-second) timestamp as the latest stored observation of the same name. The new
/// observation's <c>Time</c> is shifted forward by one second to avoid duplicate-key collisions.
/// </summary>
/// <param name="newObservation">The new patient observation to evaluate for time inconsistencies.</param>
/// <returns>
/// A task that resolves to the (possibly time-shifted) <see cref="PatientObservation"/>.
/// Returns the input unchanged when its <c>Name</c> is null or empty, logging the error.
/// </returns>
public async Task<PatientObservation?> FixTimeInconsistencyWithLast(PatientObservation newObservation)
{
if (string.IsNullOrEmpty(newObservation.Name))
{
_logger.LogError(
"Error FixTimeInconsistencyWithLast newObservation name ia null or empty. Observation: {newObservation}",
newObservation);
return newObservation;
}
var lastObservations = await _observationService.Value.FindLastObservations(newObservation.PatientId, 1,
[newObservation.Name]);
var lastObservation = lastObservations.FirstOrDefault();
if (lastObservation != null && DateTime.Compare(
new DateTime(lastObservation.Time.Year, lastObservation.Time.Month,
lastObservation.Time.Day, lastObservation.Time.Hour,
lastObservation.Time.Minute, lastObservation.Time.Second),
new DateTime(newObservation.Time.Year, newObservation.Time.Month,
newObservation.Time.Day, newObservation.Time.Hour,
newObservation.Time.Minute, newObservation.Time.Second)
) >= 0)
newObservation.Time = lastObservation.Time.AddSeconds(1);
return newObservation;
}
/// <summary>
/// Pre-maps a batch of patient observations, optimising the order in which they are persisted
/// when both <c>Vent_Rate</c> and <c>FR</c> are present. If both observations are present with
/// non-zero values, <c>Vent_Rate</c> is inserted first (and removed from the returned list) so
/// that <see cref="CalculateRespRate"/> can derive <c>Resp_Rate_Calculated</c> correctly.
/// </summary>
/// <param name="listToInsert">The list of patient observations to pre-map. Modified in-place.</param>
/// <returns>
/// A task that resolves to the resulting list of observations (with <c>Vent_Rate</c> removed
/// when it was inserted synchronously, or the original list otherwise).
/// </returns>
public async Task<List<PatientObservation>> PreMapList(List<PatientObservation> listToInsert)
{
var ventRate = listToInsert.FirstOrDefault(obs => obs.Name == "MDC_VENT_RESP_RATE");
var respRate = listToInsert.FirstOrDefault(obs => obs.Name == "MDC_RESP_RATE");
// Check if obs for Vent_Rate and FR is on the list to insert
if (ventRate == null || respRate == null) return listToInsert;
var ventRateValue = double.TryParse(ventRate.Value.ToString(), out var v1) ? v1 : 0;
// ventRate is on the list to insert but its value is 0
if (ventRateValue == 0)
{
_logger.LogDebug("Find ventRate on PreMap request not processed because its value is 0");
return listToInsert;
}
var respRateValue = double.TryParse(ventRate.Value.ToString(), out var v2) ? v2 : 0;
// respRate is on the list to insert but its value is 0
if (respRateValue == 0)
{
_logger.LogDebug("Find respRate on PreMap request not processed because its value is 0");
return listToInsert;
}
// Exists both obs and have value != 0
// In that case we need to calculate Resp_Rate_Calculated just with the value of Vent_Rate
try
{
_logger.LogDebug(
"Find on PreMap Vent_Rate and FR in the same request process Vent_Rate first to calculate Resp_Rate_Calculated");
await _observationService.Value.InsertObservation(ventRate);
listToInsert.Remove(ventRate);
}
catch (Exception e)
{
_logger.LogError("Error while PreMap on insert obs: {ventRate} exception: {e}", ventRate, e);
}
return listToInsert;
}
/// <summary>
/// Identity mapping for a source alarm observation paired with a <see cref="PatientObservationAlarm"/>:
/// returns the supplied observation unchanged, wrapped in a completed task. Provided to satisfy the
/// customization contract.
/// </summary>
/// <param name="obs">The patient observation that triggered the alarm.</param>
/// <param name="alarmToInsert">The alarm metadata to be inserted alongside the observation.</param>
/// <returns>A task containing the same <see cref="PatientObservation"/> instance that was passed in.</returns>
public Task<PatientObservation> MapSourceAlarm(PatientObservation obs, PatientObservationAlarm alarmToInsert)
{
return Task.FromResult(obs);
}
/// <summary>
/// Placeholder alarm dispatch hook used by the customization contract. The HRYC customization
/// dispatches NEWS alarms through its private <c>SendAlarm(BasePatientObservation, AlarmEnum.Name)</c>
/// overload; calling this public overload always throws.
/// </summary>
/// <param name="obs">The patient observation that triggered the alarm.</param>
/// <param name="name">The alarm display name.</param>
/// <param name="code">The optional alarm code from <see cref="AlarmEnum.Name"/>.</param>
/// <returns>Never returns a result.</returns>
/// <exception cref="NotImplementedException">Always thrown because this overload is not used in the HRYC customization.</exception>
public Task SendAlarm(PatientObservation obs, string name, AlarmEnum.Name? code)
{
throw new NotImplementedException();
}
/// <summary>
/// Translates a NEWS (National Early Warning Score) value into an alarm level: a value below 5
/// (or an unparsable value) is reported as <c>NewsOff</c>, between 5 and 7 as <c>NewsWarning</c>,
/// and 7 or above as <c>NewsAlert</c>. Returns early when the patient cannot be located.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation, expected to be a <see cref="PatientObservation"/> whose <c>Name</c> is <c>NEWS</c>.</param>
/// <returns>A task that represents the asynchronous alarm evaluation.</returns>
private async Task CalculateNews(BasePatientObservation obs)
{
var pobs = (PatientObservation)obs;
var parsed = int.TryParse(pobs.Value.ToString(), out var valueParsed);
var patient = await _patientService.Value.FindById(obs.PatientId);
if (patient == null) return;
//var beaconConfig = await _boxService.Value.GetBeaconConfig(new PatientLocation(patient.PointOfCare, patient.Bed));
//power off beacon
//recuperar la beacon del paciente y encenderla apagarla si el valor es > 5&6 o 7 alert
if ((parsed && valueParsed < 5) || !parsed)
//await _balizaService.Value.PowerOffLed(beaconCnf);
//await _observationService.Value.SendObsBroadcast(obs);
await SendAlarm(obs, AlarmEnum.Name.NewsOff);
else
switch (valueParsed)
{
case >= 5 and < 7:
_logger.LogDebug("beacon yellow alert by NEWS for patient: {patientid}", obs.PatientId);
await SendAlarm(obs, AlarmEnum.Name.NewsWarning);
//await _balizaService.Value.SendBeaconColor(patient, LightBeaconColor.YELLOW);
break;
case >= 7:
_logger.LogDebug("beacon red alert by NEWS for patient: {patientid}", obs.PatientId);
await SendAlarm(obs, AlarmEnum.Name.NewsAlert);
//await _balizaService.Value.SendBeaconColor(patient, LightBeaconColor.RED);
break;
}
//if (obsAlarm == null) return;
}
/// <summary>
/// Builds a synthetic <c>Alarm_&lt;Name&gt;</c> observation, looks up its configuration, and when
/// the configured alarm and its beacon are enabled, dispatches a light-beacon colour change for
/// the patient's point-of-care. The alarm observation is always persisted. Errors are logged and
/// swallowed.
/// </summary>
/// <param name="obs">The base patient observation that triggered the alarm (used to derive <c>PatientId</c> and <c>Time</c>).</param>
/// <param name="name">The <see cref="AlarmEnum.Name"/> describing the alarm kind (for example, <c>NewsOff</c>, <c>NewsWarning</c>, <c>NewsAlert</c>).</param>
/// <returns>A task that represents the asynchronous alarm dispatch.</returns>
private async Task SendAlarm(BasePatientObservation obs, AlarmEnum.Name name)
{
var pobs = (PatientObservation)obs;
PatientObservation nObs = new()
{
CodingSystem = "ADAS_ALARM",
Code = name.ToString(),
Name = $"Alarm_{name}",
PatientId = pobs.PatientId,
Time = pobs.Time
};
try
{
//ConfigObservations
var configObs = await _configObservationService.Get(new PatientObservation
{
Name = nObs.Name,
PatientId = obs.PatientId
}
);
if (configObs == null)
{
_logger.LogError("Config observation is null on send alarm NEWS for {nObs}", nObs);
return;
}
if (configObs is { Alarm.Enabled: true })
{
nObs.Alarm = configObs.Alarm;
if (configObs.Alarm.Beacon is { Enabled: true })
{
var patient = await _patientService.Value.FindById(obs.PatientId);
if (patient != null)
{
_logger.LogDebug(
"PatientId: {nObsPatientId}. Send Beacon alarmName {configObsAlarmBeaconBeaconColorValue}",
nObs.PatientId, configObs.Alarm.Beacon.BeaconColor);
nObs.Value = configObs.Alarm.Beacon.BeaconColor.ToString();
SendBeaconCode(configObs.Alarm.Beacon.BeaconColor, patient);
}
}
}
_logger.LogDebug(
"Insert obs alarm for news at: {DateTimeNow} patientId: {nObsPatientId} color value: {nObsValue}",
DateTime.Now, nObs.PatientId, nObs.Value);
await _observationService.Value.InsertObservation(nObs);
}
catch (Exception ex)
{
_logger.LogError("Exception sending alarm: {exMessage}", ex.Message);
}
}
/// <summary>
/// Sends a colour command to the light beacon associated with the patient's point-of-care.
/// Maps <see cref="AlarmEnum.BeaconColor"/> values to <see cref="LightBeaconColor"/> commands
/// (blue, yellow, red, or off) and logs an error when the patient has no point-of-care identifier.
/// </summary>
/// <param name="color">The beacon colour to apply.</param>
/// <param name="patient">The patient whose associated beacon should be updated.</param>
private void SendBeaconCode(AlarmEnum.BeaconColor color, Patient patient)
{
if (!patient.PointOfCareId.HasValue)
{
_logger.LogError("Error sending beacon color on calculateObservations poc id on patient is null {Patient}",
patient.ToString());
return;
}
switch (color)
{
case AlarmEnum.BeaconColor.Blue:
_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value, LightBeaconColor.Blue);
break;
case AlarmEnum.BeaconColor.Yellow:
_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value, LightBeaconColor.Yellow);
break;
case AlarmEnum.BeaconColor.Red:
_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value, LightBeaconColor.Red);
break;
case AlarmEnum.BeaconColor.None:
_lightBeaconService.Value.SendColor(patient.PointOfCareId.Value, LightBeaconColor.Off);
break;
}
}
/// <summary>
/// Computes the IROX index (<c>SpO2 / FiO2 / FR</c>) when <c>SpO2</c>, <c>FiO2</c>, and <c>FR</c>
/// are all present in the latest observations and were recorded within the last 10 minutes.
/// Persists the resulting <c>IROX</c> observation. Skips silently when any input is missing,
/// zero, or unparsable. Errors are logged and swallowed.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation (<c>SpO2</c>, <c>FiO2</c>, or <c>FR</c>).</param>
/// <returns>A task that represents the asynchronous calculation operation.</returns>
private async Task CalculateIrox(BasePatientObservation obs)
{
try
{
var pob = (PatientObservation)obs;
var obsToCalc = new List<PatientObservation> { pob };
switch (pob.Name)
{
case "SpO2":
var fio2Frvalues = await _observationService.Value.FindLastObservations(obs.PatientId, 1,
["FiO2", "FR"]);
if (fio2Frvalues.Count > 0) obsToCalc.AddRange(fio2Frvalues);
break;
case "FiO2":
var spo2Frvalues = await _observationService.Value.FindLastObservations(obs.PatientId, 1,
["SpO2", "FR"]);
if (spo2Frvalues.Count > 0) obsToCalc.AddRange(spo2Frvalues);
break;
case "FR":
var fio2Spo2Values = await _observationService.Value.FindLastObservations(obs.PatientId, 1,
["FiO2", "SpO2"]);
if (fio2Spo2Values.Count > 0) obsToCalc.AddRange(fio2Spo2Values);
break;
}
if (!obsToCalc.All(o => o.Time.CompareTo(DateTime.UtcNow.AddMinutes(10)) <= 0)) return;
var fio2 = obsToCalc.FirstOrDefault(o => o.Name == "FiO2");
var fr = obsToCalc.FirstOrDefault(o => o.Name == "FR");
var spo2 = obsToCalc.FirstOrDefault(o => o.Name == "SpO2");
if (fio2 == null || fr == null || spo2 == null) return;
if (!double.TryParse(spo2.Value.ToString(), out var nSpo2) ||
!double.TryParse(fio2.Value.ToString(), out var nFio2) ||
!double.TryParse(fr.Value.ToString(), out var nFr))
{
_logger.LogWarning("Error parsing values. fio2: {fio2}, fr: {fr}, spo2: {spo2}", fio2, fr, spo2);
return;
}
var spo2Value = nSpo2;
var fio2Value = nFio2;
var frValue = nFr;
if (spo2Value == 0 || fio2Value == 0 || frValue == 0) return;
var value = nSpo2 / nFio2 / nFr;
var irox = new PatientObservation
{
Name = "IROX",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Time,
Value = value
};
await _observationService.Value.InsertObservation(irox);
}
catch (Exception e)
{
_logger.LogError("Exception calculating IROX, error: {eMessage}", e.Message);
}
}
/// <summary>
/// Persists a <c>Resp_Rate_Calculated</c> observation when the supplied observation's value is
/// non-zero. For <c>FR</c>, the insert is skipped if a recent (within
/// <c>CalculateRespRateVentExpires</c> minutes) non-zero <c>Vent_Rate</c> observation exists;
/// the inserted observation's <c>Time</c> is set to the original <c>Time</c> for <c>FR</c>, or
/// shifted one second forward for any other name.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation (<c>FR</c> or <c>Vent_Rate</c>).</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateRespRate(BasePatientObservation obs)
{
var pobs = (PatientObservation)obs;
_logger.LogDebug("CalculateRespRate {obsPatientid} wait {obsName} {obsTime} {pobsValue}", obs.PatientId,
obs.Name, obs.Time, pobs.Value);
if (Convert.ToDouble(pobs.Value) == 0) return;
if (Convert.ToDouble(pobs.Value) == 0) return;
var insert = true;
if (obs.Name == "FR")
{
// Insertamos si no existe una obs Vent_Rate o si es existe tiene más de X" y su valor es > 0
var lastVentRate =
(await _observationService.Value.FindLastObservations(obs.PatientId, 1, ["Vent_Rate"]))
.FirstOrDefault();
if (lastVentRate == null || (double.TryParse(lastVentRate.Value.ToString(), out var v) ? v : 0) == 0)
{
_logger.LogDebug("CalculateRespRate {id} lastVentRate: null", obs.PatientId);
}
else
{
var diff = DateTime.UtcNow.Subtract(lastVentRate.Time.ToUniversalTime());
insert =
lastVentRate.Time.ToUniversalTime().AddMinutes(_apiSettings.Value.CalculateRespRateVentExpires) <
DateTime.UtcNow;
_logger.LogDebug(
"CalculateRespRate {patientid} lastVentRate: {value} - {time} passed {diff} {insert}",
obs.PatientId, lastVentRate.Value, lastVentRate.Time, diff, insert ? "Inserted" : "Not inserted");
}
}
if (insert)
{
_logger.LogDebug(
"CalculateRespRate {patientid} Insert Resp_Rate_Calculated with {name} - {time} value {value}",
obs.PatientId, obs.Name, obs.Time, pobs.Value);
var calculatedRespRate = new PatientObservation
{
Id = new ObjectId(),
Name = "Resp_Rate_Calculated",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Name == "FR" ? obs.Time : obs.Time.AddSeconds(1),
Value = pobs.Value,
ParentData = new ParentDataClass { Name = obs.Name }
};
await _observationService.Value.InsertObservation(calculatedRespRate);
}
else
{
_logger.LogDebug(
"CalculateRespRate {patientid} Insert Resp_Rate_Calculated with {name} - {time} value {value}",
obs.PatientId, obs.Name, obs.Time, pobs.Value);
}
_logger.LogDebug("CalculateRespRate {patientid} release {name} {time} {value}", obs.PatientId,
obs.Name, obs.Time, pobs.Value);
}
/// <summary>
/// Shifts the timestamp of a <c>Hydric_Balance</c> observation forward by one second when another
/// <c>Hydric_Balance</c> observation was already recorded within the same hour, so the most
/// recent entry can be identified unambiguously.
/// </summary>
/// <param name="obs">The base patient observation expected to be a <see cref="PatientObservation"/> with <c>Name</c> <c>Hydric_Balance</c>.</param>
/// <returns>A task that resolves to the (possibly time-shifted) base patient observation.</returns>
private async Task<BasePatientObservation> CalculateHydricBalance(BasePatientObservation obs)
{
var pobs = (PatientObservation)obs;
var lastHydricBalanceFromHour =
await _observationService.Value.FindLastBeforeDate(pobs.PatientId, pobs.Time.AddMinutes(59),
"Hydric_Balance");
if (lastHydricBalanceFromHour != null &&
DateTime.Compare(new DateTime(lastHydricBalanceFromHour.Time.Year, lastHydricBalanceFromHour.Time.Month,
lastHydricBalanceFromHour.Time.Day, lastHydricBalanceFromHour.Time.Hour, 0, 0)
, new DateTime(pobs.Time.Year, pobs.Time.Month, pobs.Time.Day, pobs.Time.Hour, 0, 0)) == 0)
pobs.Time = lastHydricBalanceFromHour.Time.AddSeconds(1);
return obs;
}
/// <summary>
/// Shifts the timestamp of a <c>Hour_Balance</c> observation forward by one second when another
/// <c>Hour_Balance</c> observation was already recorded within the same hour.
/// </summary>
/// <param name="obs">The base patient observation expected to be a <see cref="PatientObservation"/> with <c>Name</c> <c>Hour_Balance</c>.</param>
/// <returns>A task that resolves to the (possibly time-shifted) base patient observation.</returns>
private async Task<BasePatientObservation> CalculateHourBalance(BasePatientObservation obs)
{
var pobs = (PatientObservation)obs;
var lastHourBalanceFromHour =
await _observationService.Value.FindLastBeforeDate(pobs.PatientId, pobs.Time.AddMinutes(59),
"Hour_Balance");
if (lastHourBalanceFromHour != null &&
DateTime.Compare(new DateTime(lastHourBalanceFromHour.Time.Year, lastHourBalanceFromHour.Time.Month,
lastHourBalanceFromHour.Time.Day, lastHourBalanceFromHour.Time.Hour, 0, 0)
, new DateTime(pobs.Time.Year, pobs.Time.Month, pobs.Time.Day, pobs.Time.Hour, 0, 0)) == 0)
pobs.Time = lastHourBalanceFromHour.Time.AddSeconds(1);
return obs;
}
/// <summary>
/// Persists a <c>Hydric_Balance_Calculated</c> observation mirroring the supplied
/// <c>Hydric_Balance</c> value, after enforcing three guards: the observation's hour must not be
/// in the future, the latest <c>Hydric_Balance_Calculated</c> for the current hour prevents
/// replaying older hours, and a newer stored <c>Hydric_Balance_Calculated</c> prevents
/// overwriting it. The stored time is shifted one second forward when an existing calculated
/// observation in the same hour is detected.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation, expected to be a <see cref="PatientObservation"/> with <c>Name</c> <c>Hydric_Balance</c>.</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateHydricBalanceCalculated(BasePatientObservation obs)
{
//Hydric_Balance_Calculated
//No guardamos como calculadas nunca horas futuras. En el obx siempre vienen con la hora y los minutos a 00
var pobs = (PatientObservation)obs;
var nowUniversalTime = DateTime.Now.ToUniversalTime();
//La hora de la observación es mayor que la actual. No generamos la calculada
if (DateTime.Compare(new DateTime(obs.Time.Year, obs.Time.Month, obs.Time.Day, obs.Time.Hour, 0, 0),
new DateTime(nowUniversalTime.Year, nowUniversalTime.Month, nowUniversalTime.Day, nowUniversalTime.Hour,
0, 0)) > 0
)
{
_logger.LogDebug(
"HYDRIC BALANCE: La hora de la observación es mayor que la actual. No generamos la calculada {obs}",
obs);
return;
}
//Para la calculada. Si la última que tenemos es de la hora actual y lo que viene es de una hora anterior no entra como calculada.
var lastHydricBalanceList = await _observationService.Value.FindLastObservations(obs.PatientId, 1,
["Hydric_Balance_Calculated"]);
var lastHydricBalance = lastHydricBalanceList.FirstOrDefault();
if (lastHydricBalance != null && lastHydricBalance.Time.Hour == DateTime.UtcNow.Hour &&
obs.Time.Hour != DateTime.UtcNow.Hour)
{
_logger.LogDebug(
"HYDRIC BALANCE: ultima que tenemos es de la hora actual y lo que viene es de una hora anterior no entra como calculada {obs}",
obs);
return;
}
//Si el último hydric balance es de una hora posterior a la observación que acaba de llegar no entra como calculada.
if (lastHydricBalance != null &&
DateTime.Compare(new DateTime(lastHydricBalance.Time.Year, lastHydricBalance.Time.Month,
lastHydricBalance.Time.Day, lastHydricBalance.Time.Hour, 0, 0)
, new DateTime(pobs.Time.Year, pobs.Time.Month, pobs.Time.Day, pobs.Time.Hour, 0, 0)) == 1
)
{
_logger.LogDebug(
"HYDRIC BALANCE: Si el ultimo hydric balance es de una hora posterior a la observación que acaba de llegar no entra como calculada. {obs}",
obs);
return;
}
var calculatedHydricBalance = new PatientObservation
{
Name = "Hydric_Balance_Calculated",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Time.AddSeconds(1), //To discriminate updates.
Value = pobs.Value,
Units = obs.Units
};
if (lastHydricBalance != null && DateTime.Compare(
new DateTime(lastHydricBalance.Time.Year, lastHydricBalance.Time.Month, lastHydricBalance.Time.Day,
lastHydricBalance.Time.Hour, 0, 0)
, new DateTime(pobs.Time.Year, pobs.Time.Month, pobs.Time.Day, pobs.Time.Hour, 0, 0)) == 0)
calculatedHydricBalance.Time = lastHydricBalance.Time.AddSeconds(1);
await _observationService.Value.InsertObservation(calculatedHydricBalance);
}
/// <summary>
/// Maps a <c>Resp_Mode</c> observation to a <c>Resp_Type</c> value: codes listed in
/// <c>InvasiveVentilation</c> yield <c>Invasive</c>, otherwise the observation's value is matched
/// against the configured high-frequency / non-invasive catalogues (yielding
/// <c>HighFrequencyVentilation</c>, <c>NonInvasive</c>, or <c>Invasive</c> as fallback). Values
/// equal to <c>"EnESPERA"</c> are skipped.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation, expected to be a <see cref="PatientObservation"/> with <c>Name</c> <c>Resp_Mode</c>.</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateVentilationMode(BasePatientObservation obs)
{
_logger.LogDebug("CalculateVentilationMode {obs}", obs);
var pobs = (PatientObservation)obs;
var respTypeObs = new PatientObservation
{
Name = "Resp_Type",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Time
};
//https://epigram.teamwork.com/#/tasks/34765750
if (obs.Code != null && _codesForInvasiveVentilation.Contains(obs.Code))
{
//añadir que sea solo cuando el valor es invasiva
respTypeObs.Value = nameof(RespirationType.Invasive);
respTypeObs.Time = obs.Time.AddSeconds(1);
await _observationService.Value.InsertIfChanged("Resp_Type", respTypeObs);
}
else
{
var strValue = pobs.Value.ToString();
if (strValue == null)
return;
//si es en espera tendrá que insertar que es en espera
if (!"EnESPERA".Equals(strValue))
{
respTypeObs.Value = _highFrequencyVentilation.Contains(strValue)
? nameof(RespirationType.HighFrequencyVentilation)
: _nonInvasiveVentilation.Contains(strValue)
? respTypeObs.Value = nameof(RespirationType.NonInvasive)
: nameof(RespirationType.Invasive);
await _observationService.Value.InsertIfChanged("Resp_Type", respTypeObs);
}
}
}
/// <summary>
/// Computes the <c>Weight_Diff</c> observation (new weight previous weight, rounded to two
/// decimals) by comparing the supplied <c>Weight_Current</c> observation against the most
/// recently stored one for the same patient. Skipped when either value cannot be parsed.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation, expected to be a <see cref="PatientObservation"/> with <c>Name</c> <c>Weight_Current</c>.</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateWeight_DiffObservation(BasePatientObservation obs)
{
var toSearchList = new List<string>();
toSearchList.AddRange(new List<string> { "Weight_Current" });
var values = await _observationService.Value.FindLastObservations(obs.PatientId, 1, toSearchList);
var pWeight = values.FirstOrDefault(o => o.Name == "Weight_Current");
var newWeightObs = (PatientObservation)obs;
if (pWeight?.Value != null && double.TryParse(pWeight.Value.ToString(), out var weight) &&
double.TryParse(newWeightObs.Value.ToString(), out var newWeight))
{
var weightDiffValue = Math.Round(newWeight - weight, 2);
var weightDiff = new PatientObservation
{
Name = "Weight_Diff",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Time,
Value = weightDiffValue,
Units = obs.Units
};
await _observationService.Value.InsertObservation(weightDiff);
}
}
/// <summary>
/// Computes the <c>Diuresis_Weight</c> observation (diuresis / current weight in ml/kg) when
/// either a <c>Diuresis</c> or a <c>Weight_Current</c> observation is received. When triggered
/// by <c>Weight_Current</c>, the operation is skipped if the latest <c>Diuresis</c> observation
/// has expired. Skipped silently when the weight is zero or any value is unparsable.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation (<c>Diuresis</c> or <c>Weight_Current</c>).</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateDiureis_WeightObservation(BasePatientObservation obs)
{
var toSearchList = new List<string>();
PatientObservation? diuresisObs;
PatientObservation? weightObs;
toSearchList.AddRange(new List<string> { "Diuresis", "Weight_Current" });
var values = await _observationService.Value.FindLastObservations(obs.PatientId, 1, toSearchList);
if (obs.Name == "Diuresis")
{
diuresisObs = (PatientObservation)obs;
weightObs = values.FirstOrDefault(o => o.Name == "Weight_Current");
}
else
{
weightObs = (PatientObservation)obs;
diuresisObs = values.FirstOrDefault(o => o.Name == "Diuresis");
if (diuresisObs == null || CheckExpired(diuresisObs)) return;
}
if (weightObs?.Value != null)
if (double.TryParse(weightObs.Value.ToString(), out var nWeight) && nWeight != 0 &&
double.TryParse(diuresisObs.Value.ToString(), out var diuresis))
{
var diruesisWeightValue = Math.Round(diuresis / nWeight, 2);
var diruesisWeight = new PatientObservation
{
Name = "Diuresis_Weight",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Time,
Value = diruesisWeightValue,
Units = "ml/kg"
};
await _observationService.Value.InsertObservation(diruesisWeight);
}
}
/// <summary>
/// Aggregates a <c>AllergiesObs</c> observation into a single <c>Allergies</c> string per type,
/// special-casing drug allergies ("FÁRMACOS") into a single grouped entry and setting the
/// <c>Status</c> to <c>Alert</c> when drug allergies are present. <c>InvalidCastException</c>s
/// are logged and swallowed.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation, expected to be a <see cref="PatientObservation"/> with <c>Name</c> <c>AllergiesObs</c> and a collection-valued <c>Value</c> of <see cref="PatientAllergiesValue"/>.</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateAllergiesObservation(BasePatientObservation obs)
{
try
{
var pobs = (PatientObservation)obs;
var allergiesObs = new PatientObservation
{
Name = "Allergies",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Time,
MessageTime = pobs.MessageTime
};
var patientAllergiesValues =
new List<PatientAllergiesValue>((IEnumerable<PatientAllergiesValue>)pobs.Value);
var allergiesValues = patientAllergiesValues.GroupBy(o => o.Type)
.Select(x =>
new PatientAllergiesValue
{
Type = x.Key,
Value = string.Join(", ", x.Select(v => v.Value)),
Notes = string.Join(", ", x.Select(n => n.Notes))
}).ToList();
var farmacosType = false;
var farmacosValues = new List<string>();
var values = new List<string>();
foreach (var allergies in allergiesValues)
{
if (allergies.Value == null)
continue;
var type = allergies.Type?.Replace("Alergia a ", "").Replace("Alergia ", "").ToUpper();
if (type != null)
switch (type)
{
case "FÁRMACOS":
farmacosType = true;
farmacosValues.Add(allergies.Value.ToUpper());
break;
default:
values.Add(type);
break;
}
}
if (farmacosType)
{
values.Add($"FÁRMACOS ({string.Join(", ", farmacosValues)})");
allergiesObs.Status = StatusEnum.Type.Alert;
}
allergiesObs.Value = string.Join(", ", values);
await _observationService.Value.InsertObservation(allergiesObs);
}
catch (InvalidCastException)
{
_logger.LogError("CalculateAllergiesObservation {obs}", obs);
}
}
/// <summary>
/// For a <c>DrainagesObs</c> observation of type <c>"Drenaje ventricular"</c>, persists the
/// derived <c>DVE</c> (volume) and <c>Drainage_Height</c> observations using the underlying
/// <see cref="PatientDrainagesValue"/> properties.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation, expected to be a <see cref="PatientObservation"/> with <c>Name</c> <c>DrainagesObs</c> and a <see cref="PatientDrainagesValue"/>-typed <c>Value</c>.</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateDrainagesObservation(BasePatientObservation obs)
{
var pobs = (PatientObservation)obs;
var patientDraingesValue = (PatientDrainagesValue)pobs.Value;
if (patientDraingesValue.Type == "Drenaje ventricular")
{
var dve = new PatientObservation
{
Name = "DVE",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Time,
Units = obs.Units
};
var drainageHeight = new PatientObservation
{
Name = "Drainage_Height",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = obs.Time
};
if (patientDraingesValue.Volume != null)
{
dve.Value = patientDraingesValue.Volume;
await _observationService.Value.InsertObservation(dve);
}
if (patientDraingesValue.Height != null)
{
drainageHeight.Value = patientDraingesValue.Height;
await _observationService.Value.InsertObservation(drainageHeight);
}
}
}
/// <summary>
/// Computes the <c>Delta_Pressure</c> observation (driving pressure = plateau PEEP) when both
/// <c>PEEP</c> and <c>Pleateu_Pressure</c> observations are available. The stored <c>Time</c> is
/// the most recent of the two source observations, so the result is anchored to the latest input.
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation (<c>PEEP</c> or <c>Pleateu_Pressure</c>).</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateDelta_PressureObservation(BasePatientObservation obs)
{
var toSearchList = new List<string>();
PatientObservation? peep;
PatientObservation? pleateuPressure;
toSearchList.AddRange(new List<string> { "PEEP", "Pleateu_Pressure" });
var values = await _observationService.Value.FindLastObservations(obs.PatientId, 1, toSearchList);
var time = obs.Time;
double pleateuPressureValue = 0;
if (obs.Name == "PEEP")
{
peep = (PatientObservation)obs;
pleateuPressure = values.FirstOrDefault(o => o.Name == "Pleateu_Pressure");
if (pleateuPressure?.Time != null && time.CompareTo(pleateuPressure.Time) > 0) time = pleateuPressure.Time;
}
else
{
pleateuPressure = (PatientObservation)obs;
peep = values.FirstOrDefault(o => o.Name == "PEEP");
if (peep?.Time != null && time.CompareTo(peep.Time) > 0) time = peep.Time;
}
if (peep?.Value != null && pleateuPressure?.Value != null)
{
var peepSuccess = double.TryParse(peep.Value.ToString(), out var peepValue);
if (!peepSuccess) peepValue = 0;
if (!peepSuccess) pleateuPressureValue = 0;
var deltaPressure = Math.Round(pleateuPressureValue - peepValue, 2);
var deltaPressureObs = new PatientObservation
{
Name = "Delta_Pressure",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = time,
Value = deltaPressure
};
await _observationService.Value.InsertObservation(deltaPressureObs);
}
}
/// <summary>
/// Persists a <c>Daily_Balance_Calculated</c> observation mirroring the supplied
/// <c>Daily_Balance</c> value, but only when the local hour of the observation is 8 (the daily
/// balance cut-off used by the HRYC customization).
/// </summary>
/// <param name="obs">The base patient observation that triggered the calculation, expected to be a <see cref="PatientObservation"/> with <c>Name</c> <c>Daily_Balance</c>.</param>
/// <returns>A task that represents the asynchronous insert operation.</returns>
private async Task CalculateDaily_BalanceObservation(BasePatientObservation obs)
{
if (obs.Time.ToLocalTime().Hour == 8)
{
var dailyBalanceCalculated = new PatientObservation
{
Name = "Daily_Balance_Calculated",
CodingSystem = "ADAS",
PatientId = obs.PatientId,
Time = DateTime.Now,
Units = obs.Units,
Value = ((PatientObservation)obs).Value
};
await _observationService.Value.InsertObservation(dailyBalanceCalculated);
}
}
/// <summary>
/// Determines whether a patient observation has expired by comparing the current time against
/// <c>Time + Expires</c> seconds, when <c>Expires</c> is set.
/// </summary>
/// <param name="obs">The patient observation to evaluate.</param>
/// <returns>
/// <see langword="true"/> when the observation has an <c>Expires</c> value and the current time
/// is past the expiration instant; otherwise, <see langword="false"/>.
/// </returns>
private static bool CheckExpired(PatientObservation obs)
{
if (obs.Expires == null) return false;
var timeExpire = obs.Time.ToLocalTime().AddSeconds(Convert.ToDouble(obs.Expires));
return DateTime.Now.CompareTo(timeExpire) > 0;
}
}