using System.Globalization; using adas_core.Application.Repositories.Interfaces; using adas_core.Application.Services.Interfaces; using adas_core.Application.Subscriptions; using adas_core.Domain.Enums; using adas_core.Domain.Models; using adas_core.Domain.Models.AppSettings; using adas_core.Domain.Models.GroupedObservations; using adas_core.Domain.Utils; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MongoDB.Bson; using static adas_core.Domain.Models.GroupedObservation; namespace adas_core.Application.Services; /// /// Provides a concrete implementation of for managing and exposing grouped observation data. /// public class GroupedObservationService : IGroupedObservationService { private readonly CacheSettings? _cacheSettings; private readonly ICacheService _cacheService; private readonly IConfigObservationService _configObservationService; private readonly ILogger _logger; private readonly IObservationRepository _observationRepository; public GroupedObservationService( IObservationRepository observationRepository, IConfigObservationService configObservationService, ILogger logger, ICacheService cacheService, IOptions apiSettings, IOptions cacheSettings) { _observationRepository = observationRepository; _configObservationService = configObservationService; _logger = logger; _cacheService = cacheService; _cacheSettings = cacheSettings.Value; } // PUBLIC API (Use cases) /// /// Generates a complete set of grouped observations for a patient based on the provided grouping configuration, applying timezone conversion, shift/half-hour rules, and per-result computations (first, last, min, max, sum, average, count, last filled). Results are returned from cache when is false; otherwise the observations are recomputed and returned without touching the cache. /// /// The unique identifier of the patient whose observations are being aggregated. /// The grouping field definition that drives the aggregation, regularity, shift times, and which result types (first, last, min, max, etc.) are computed. /// The system time zone id used to convert observation times from UTC. Defaults to "Romance Standard Time". /// When true, the cache is bypassed and observations are always recomputed; when false, results are obtained through the cache with the configured TTL. /// Token used to cancel the asynchronous operation. /// A containing the patient id, group, name, and the list of computed entries. public async Task GenerateGroupedObservation( ObjectId patientId, GroupedField groupedField, string timeZoneId = "Romance Standard Time", bool cacheIsChecked = false, CancellationToken ct = default) { // Loader que calcula TODA la lista de observaciones agrupadas async Task> ComputeObservationsAsync() { var result = await _observationRepository.AggregatedPatientGroupedObservations(patientId, groupedField); var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); result = FillHours(result, groupedField); if (groupedField.StartTimeShift.Count > 0 && groupedField.Regularity == GroupedObservationEnum.Regularity.Shift) { var shiftGroupObservations = GenerateShiftObservations(result, groupedField); result = CalculateShiftObservations(shiftGroupObservations, groupedField); } if (groupedField.Result.Contains(GroupedObservationEnum.Result.HalfHour)) { result = CalculateHalfHourObservations(result); result = FillHours(result, groupedField); } if (groupedField.Result.Contains(GroupedObservationEnum.Result.LastFilled)) result = await CalculateLastFilledObservations(result, groupedField, patientId); var list = new List(); foreach (var it in result.ToList()) { var id = it.TryGetElement("_id", out var _) ? it.GetElement("_id").Value.AsBsonDocument : null; var name = id?.Get("name")?.AsString; if (name is null or "_id") { var idValue = id?.Get("value") as BsonDocument; name = idValue?.Get("name")?.AsString; } var uTime = it.Get("time")?.ToUniversalTime(); if (!uTime.HasValue) { _logger.LogError("Error getting time to universal time. Bson document: {it}", it); continue; } var obsTime = TimeZoneInfo.ConvertTimeFromUtc(uTime.Value, tz); obsTime = DateTime.SpecifyKind(obsTime, DateTimeKind.Local); var obs = new GroupedObservationObs { Time = obsTime, Name = name ?? string.Empty }; if (it.TryGetValue("shift", out var shiftValue) && it.TryGetValue("day", out _)) { obs.Shift = shiftValue.ToInt32(); obs.ShiftDate = obs.Time; } if (id != null && id.Contains("min")) obs.MinAlert = id.Get("min")?.ToDouble(); if (id != null && id.Contains("max")) obs.MaxAlert = id.Get("max")?.ToDouble(); if (id != null && it.Contains("isFilled")) obs.IsFilled = (bool)it.Get("isFilled"); if (groupedField.Result.Contains(GroupedObservationEnum.Result.First)) { var g = !it.Contains("first") ? null : it.Get("first")?.AsBsonDocument; if (g != null) { var value = g.Get("value")?.ToObject(); var min = g.Get("min")?.ToDouble(); var max = g.Get("max")?.ToDouble(); var uTim = g.Get("time")?.ToUniversalTime(); if (!uTim.HasValue) { _logger.LogError("Error getting utc time in 'first': {it}", it); goto addObs; } var time = TimeZoneInfo.ConvertTimeFromUtc(uTim.Value, tz); time = DateTime.SpecifyKind(time, DateTimeKind.Local); if (string.IsNullOrEmpty(name) || value == null) { _logger.LogError("Invalid 'first' grouped obs (name/value) patient:{patientId}", patientId); goto addObs; } obs.First = new GroupedObservationObsValue( value, time, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.First, name, value, min, max) ); } } if (groupedField.Result.Contains(GroupedObservationEnum.Result.Last)) { var g = !it.Contains("last") ? null : it.Get("last")?.AsBsonDocument; if (g != null) { var value = g.Get("value")?.ToObject(); var min = g.Get("min")?.ToDouble(); var max = g.Get("max")?.ToDouble(); var uTim = g.Get("time")?.ToUniversalTime(); if (!uTim.HasValue) { _logger.LogError("Error getting utc time in 'last': {it}", it); goto addObs; } var time = TimeZoneInfo.ConvertTimeFromUtc(uTim.Value, tz); time = DateTime.SpecifyKind(time, DateTimeKind.Local); if (string.IsNullOrEmpty(name) || value == null) { _logger.LogError("Invalid 'last' grouped obs (name/value) patient:{patientId}", patientId); goto addObs; } obs.Last = new GroupedObservationObsValue( value, time, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.Last, name, value, min, max) ); } } if (groupedField.Result.Contains(GroupedObservationEnum.Result.Min)) { var g = !it.Contains("min") ? null : it.Get("min")?.AsBsonDocument; if (g != null) { var value = g.Get("value")?.ToObject(); var min = g.Get("min")?.ToDouble(); var max = g.Get("max")?.ToDouble(); var uTim = g.Get("time")?.ToUniversalTime(); if (!uTim.HasValue) { _logger.LogError("Error getting utc time in 'min': {it}", it); goto addObs; } var time = TimeZoneInfo.ConvertTimeFromUtc(uTim.Value, tz); time = DateTime.SpecifyKind(time, DateTimeKind.Local); if (string.IsNullOrEmpty(name) || value == null) { _logger.LogError("Invalid 'min' grouped obs (name/value) patient:{patientId}", patientId); goto addObs; } obs.Min = new GroupedObservationObsValue( value, time, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.Min, name, value, min, max) ); } } if (groupedField.Result.Contains(GroupedObservationEnum.Result.Max)) { var g = !it.Contains("max") ? null : it.Get("max")?.AsBsonDocument; if (g != null) { var value = g.Get("value")?.ToObject(); var min = g.Get("min")?.ToDouble(); var max = g.Get("max")?.ToDouble(); var uTim = g.Get("time")?.ToUniversalTime(); if (!uTim.HasValue) { _logger.LogError("Error getting utc time in 'max': {it}", it); goto addObs; } var time = TimeZoneInfo.ConvertTimeFromUtc(uTim.Value, tz); time = DateTime.SpecifyKind(time, DateTimeKind.Local); if (string.IsNullOrEmpty(name) || value == null) { _logger.LogError("Invalid 'max' grouped obs (name/value) patient:{patientId}", patientId); goto addObs; } obs.Max = new GroupedObservationObsValue( value, time, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.Max, name, value, min, max) ); } } if (groupedField.Result.Contains(GroupedObservationEnum.Result.Sum)) { var value = !it.Contains("sum") ? null : it.Get("sum")?.ToObject(); if (value != null && !string.IsNullOrEmpty(name)) { obs.Sum = new GroupedObservationObsValue( value, null, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.Sum, name, value, null, null) ); } } if (groupedField.Result.Contains(GroupedObservationEnum.Result.Average)) { var value = !it.Contains("average") ? null : it.Get("average")?.ToObject(); if (value != null && !string.IsNullOrEmpty(name)) { obs.Average = new GroupedObservationObsValue( value, null, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.Average, name, value, null, null) ); } } if (groupedField.Result.Contains(GroupedObservationEnum.Result.Count)) { var value = !it.Contains("count") ? null : it.Get("count")?.AsNullableInt32; if (value != null && !string.IsNullOrEmpty(name)) { obs.Count = new GroupedObservationObsValue( value, null, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.Count, name, value, null, null) ); } } if (groupedField.Result.Contains(GroupedObservationEnum.Result.LastFilled)) { var g = !it.Contains("lastfilled") ? null : it.Get("lastfilled")?.AsBsonDocument; if (g != null) { var value = g.Get("value")?.ToObject(); var min = g.Get("min")?.ToDouble(); var max = g.Get("max")?.ToDouble(); var uTim = g.Get("time")?.ToUniversalTime(); if (!uTim.HasValue) { _logger.LogError("Error getting utc time in 'lastfilled': {it}", it); goto addObs; } var time = TimeZoneInfo.ConvertTimeFromUtc(uTim.Value, tz); time = DateTime.SpecifyKind(time, DateTimeKind.Local); if (value != null && !string.IsNullOrEmpty(name)) { obs.LastFilled = new GroupedObservationObsValue( value, time, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.LastFilled, name, value, min, max) ); } } } if (groupedField.Result.Contains(GroupedObservationEnum.Result.HalfHour)) { var g = !it.Contains("halfhour") ? null : it.Get("halfhour")?.AsBsonDocument; if (g != null) { var value = g.Get("value")?.ToObject(); var min = g.Get("min")?.ToDouble(); var max = g.Get("max")?.ToDouble(); var uTim = g.Get("time")?.ToUniversalTime(); if (!uTim.HasValue) { _logger.LogError("Error getting utc time in 'halfhour': {it}", it); goto addObs; } var time = TimeZoneInfo.ConvertTimeFromUtc(uTim.Value, tz); time = DateTime.SpecifyKind(time, DateTimeKind.Local); if (value != null && !string.IsNullOrEmpty(name)) { obs.HalfHour = new GroupedObservationObsValue( value, time, await GroupedObservationStatus(groupedField, GroupedObservationEnum.Result.Last, name, value, min, max) ); } } } addObs: _logger.LogTrace("PatientId: {patientId}. Add to grouped observation: {obs}", patientId, obs); list.Add(obs); } return list; } // caché explícita por clave + TTL --- if (!cacheIsChecked) { var (key, ttl) = CacheKeys.GroupedObsKeyWithTtl(_cacheSettings, patientId, groupedField.Name ?? string.Empty); var observations = await _cacheService.GetOrSetObjectAsync( key, ComputeObservationsAsync, ttl, ct); return new GroupedObservation { PatientId = patientId, Group = groupedField.Group, Name = groupedField.Name, Observations = observations }; } // Re-cálculo forzado (sin tocar caché) var computed = await ComputeObservationsAsync(); return new GroupedObservation { PatientId = patientId, Group = groupedField.Group, Name = groupedField.Name, Observations = computed }; } /// /// Generates a grouped observation for a patient, using an incremental update from the cache when the result type allows it, /// and otherwise performing a full recalculation (for Average, HalfHour, and Sum results) or when no cached entry exists. /// /// The identifier of the patient whose grouped observation is being generated. /// The grouped field configuration whose result type and name drive the recalculation strategy and cache key. /// The last known grouped observations provided as context for the grouped observation. /// The new patient observation to be incorporated into the grouped observation. /// The time zone identifier used when a full recalculation is required. Defaults to "Romance Standard Time". /// A task that resolves to the generated for the patient, either from an incrementally updated cache entry or from a full recalculation. public async Task GenerateGroupedObservation( ObjectId patientId, GroupedField groupedField, List wsgLastGroupedObservationObs, PatientObservation obs, string timeZoneId = "Romance Standard Time") { // Estos resultados requieren recálculo completo (no incremental) if (groupedField.Result.Contains(GroupedObservationEnum.Result.Average) || groupedField.Result.Contains(GroupedObservationEnum.Result.HalfHour) || groupedField.Result.Contains(GroupedObservationEnum.Result.Sum)) { return await GenerateGroupedObservation(patientId, groupedField, timeZoneId, true); } // Clave consistente con CacheDispatcher / backends var cacheKey = $"GroupedObs:{patientId}:{groupedField.Name}"; // Intentar leer caché actual var cached = await _cacheService.GetObjectAsync>(cacheKey, true); if (cached != null) { _logger.LogInformation("Return cached grouped obs (incremental)"); // Actualizar lista en memoria var updated = LocateCurrentObsInGroup(cached, obs, groupedField); // Persistir nueva lista (SET incremental) await _cacheService.SetObjectAsync(cacheKey, updated, true); return new GroupedObservation { PatientId = patientId, Group = groupedField.Group, Name = groupedField.Name, Observations = updated }; } // Si MISS, recálculo completo y salida return await GenerateGroupedObservation(patientId, groupedField, timeZoneId, true); } /// /// Retrieves the most recent observations for a specified patient, optionally filtered by observation types. /// Delegates the aggregation to the underlying observation repository and returns the resulting collection. /// /// The unique identifier of the patient whose observations are being queried. /// The maximum number of recent observations to return. Defaults to 2. /// An optional list of observation identifiers used to restrict the result set. When null, no filtering is applied. /// A task that represents the asynchronous operation, containing a list of entries representing the patient's most recent observations. public async Task> FindLastObservations(ObjectId patientId, int num = 2, List? filterObservations = null) { var result = await _observationRepository.AggregatedPatientLastObservations(patientId, num, filterObservations); return result; } /// /// Builds the next batch of empty observations for a grouped subscriber, either by extending the cached observation list with newly inferred time slots or by falling back to a full recalculation when the cache is empty. /// /// The grouped subscriber context that supplies the group, names, regularity, max window, and cache key used to derive the next observations. /// A containing the resulting observations adjusted to the configured time window based on (Minute, Second, or default hour-based). public async Task CreateNextEmptyObs(WsSubscriberGrouped ws) { var gf = new GroupedField { Max = ws.Max, Since = ws.Since, StartTimeShift = ws.StartTimeShift, Regularity = ws.Regularity, Result = ws.Result, Names = ws.Names, Name = ws.Names.First(), Group = ws.Group.FirstOrDefault().Value }; // Mantenemos el uso del HashCode var currentObsList = await _cacheService.GetObjectAsync>(ws.HashCode, false); if (currentObsList is { Count: > 0 }) { var orderCurrentObsList = currentObsList.OrderBy(c => c.Time).ToList(); foreach (var name in gf.Names) { var obs = orderCurrentObsList.LastOrDefault(c => c.Name == name) ?? orderCurrentObsList.LastOrDefault(); if (obs != null) { var (timesLeft, multiplierFactor) = TimesLeftToCreateEmptyObs(obs, gf); for (var i = 1; i <= timesLeft; i++) orderCurrentObsList.Add(CreateNextObs(obs, gf, i * multiplierFactor)); } } // Ajuste de ventana temporal a mostrar según Regularity DateTime dateLimit; var requireMinutes = false; var requireSeconds = false; switch (gf.Regularity) { case GroupedObservationEnum.Regularity.Minute: dateLimit = new DateTime( DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, DateTime.Now.Hour, DateTime.Now.Minute, 0 ).AddMinutes(gf.Max * -1); requireMinutes = true; break; case GroupedObservationEnum.Regularity.Second: dateLimit = new DateTime( DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, DateTime.Now.Hour, DateTime.Now.Minute, DateTime.Now.Second ).AddSeconds(gf.Max * -1); requireMinutes = true; requireSeconds = true; break; default: dateLimit = new DateTime( DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, DateTime.Now.Hour, 0, 0 ).AddHours(gf.Max * -1); break; } var cacheResult = orderCurrentObsList .Where(c => new DateTime( c.Time.Year, c.Time.Month, c.Time.Day, c.Time.Hour, requireMinutes ? c.Time.Minute : 0, requireSeconds ? c.Time.Second : 0 ) >= dateLimit) .ToList(); // Persistimos la lista incrementada en la misma clave para no romper el flujo actual. await _cacheService.SetObjectAsync(ws.HashCode, cacheResult, false); return new GroupedObservation { PatientId = ws.PatientId, Group = gf.Group, Name = gf.Name, Observations = cacheResult }; } // Si no hay caché o hay MISS → recálculo completo (sin usar caché en esta ruta). return await GenerateGroupedObservation(ws.PatientId, gf, ws.TimeZoneId, true); } // PRIVATE: High-level composition steps /// /// Fills missing time slots in a grouped observation result set by inserting zero-valued placeholder /// entries for each expected interval based on the configured regularity (day, hour, shift, minute, /// or second) and the maximum number of intervals. When the result already contains enough entries /// for every name, the original list is returned unchanged. Placeholders are marked with /// isFilled and the appropriate result-type fields (sum, average, count, half-hour, etc.) /// are initialized to zero, except for LastFilled and LastAll result types which are /// skipped, and the output is ordered by time before being returned. /// /// The existing grouped observation documents to be completed with missing intervals. /// The grouping configuration that defines the names, maximum number of intervals, regularity and result types to use when generating the placeholders. /// A list of entries ordered by time, containing the original data and any added zero-valued filler entries for missing intervals. public List FillHours(List result, GroupedField groupedField) { var now = DateTime.UtcNow; //var resultNumber = groupedField.Max; var names = groupedField.Names.Count > 1 ? groupedField.Names : [groupedField.Name ?? string.Empty]; if (result.Count >= groupedField.Max * names.Count) return result; //We have data from each hour/day/minute, dont need to calc. List fillResult = []; //var first = result.FirstOrDefault(); foreach (var name in names) { var filteredDocuments = result.Where(doc => doc["_id"]["name"] == name).ToList(); if (filteredDocuments.Count >= groupedField.Max) { filteredDocuments = filteredDocuments.OrderBy(e => e.Get("time")?.ToLocalTime()).ToList(); fillResult.AddRange(filteredDocuments); continue; } var expectedDate = DateTime.MinValue; var inverseCounter = 0; for (var i = groupedField.Max; i != 0; i--) { var expectedMatch = true; var value = filteredDocuments.ElementAtOrDefault(inverseCounter); DateTime? time = null; if (value == null) expectedMatch = false; else //Estamos recogiendo la hora en UTC time = value.Get("time")?.ToUniversalTime(); switch (groupedField.Regularity) { case GroupedObservationEnum.Regularity.Day: expectedDate = now.AddDays(-i); if (!time.HasValue || expectedDate.Day != time.Value.Day) expectedMatch = false; break; case GroupedObservationEnum.Regularity.Hour: case GroupedObservationEnum.Regularity.Shift: expectedDate = now.AddHours(-i); if (!time.HasValue || expectedDate.Hour != time.Value.Hour) expectedMatch = false; break; case GroupedObservationEnum.Regularity.Minute: expectedDate = now.AddMinutes(-i); if (!time.HasValue || expectedDate.Minute != time.Value.Minute) expectedMatch = false; break; case GroupedObservationEnum.Regularity.Second: expectedDate = now.AddSeconds(-i); if (!time.HasValue || expectedDate.Second != time.Value.Second) expectedMatch = false; break; } if (!expectedMatch) { var newEntry = new BsonDocument { { "_id", new BsonDocument { { "year", expectedDate.Year }, { "month", expectedDate.Month }, { "day", expectedDate.Day }, { "hour", expectedDate.Hour }, { "minute", expectedDate.Minute }, { "name", groupedField.Name } } }, { "time", new BsonDateTime(expectedDate) }, //TODO loop del result entero //{ Result.HalfHour.ToString().ToLower(), new BsonDocument { { "value", 0 }, { "min",0 }, { "max",0 }, { "time", new BsonDateTime(expectedDate) } } } // { groupedField.regularity.ToString().ToLower(), new BsonDocument { { "value", 0 }, { "min",0 }, { "max",0 }, { "time", new BsonDateTime(expectedDate) } } } { "isFilled", true } //Marcamos como hora añadida }; foreach (var r in groupedField.Result) switch (r) { case GroupedObservationEnum.Result.LastFilled: case GroupedObservationEnum.Result.LastAll: continue; case GroupedObservationEnum.Result.Sum: case GroupedObservationEnum.Result.Average: case GroupedObservationEnum.Result.Count: newEntry.Add(new BsonElement(r.ToString().ToLower(), 0)); continue; case GroupedObservationEnum.Result.HalfHour: newEntry.Add("all", new BsonArray { new BsonDocument { { "value", 0 }, { "min", 0 }, { "max", 0 }, { "time", new BsonDateTime(expectedDate) } } }); continue; default: newEntry.Add(r.ToString().ToLower(), new BsonDocument { { "value", 0 }, { "min", 0 }, { "max", 0 }, { "time", new BsonDateTime(expectedDate) } }); continue; } if (groupedField.Result.Count != 1 || !groupedField.Result.Contains(GroupedObservationEnum.Result.LastFilled)) filteredDocuments.Add(newEntry); filteredDocuments = filteredDocuments.OrderBy(e => e.Get("time")?.ToLocalTime()).ToList(); } inverseCounter++; } if (groupedField.Max <= filteredDocuments.Count) { var value = filteredDocuments.ElementAtOrDefault(inverseCounter); if (value == null) { var newEntry = new BsonDocument { { "_id", new BsonDocument { { "year", DateTime.UtcNow.Year }, { "month", DateTime.UtcNow.Month }, { "day", DateTime.UtcNow.Day }, { "hour", DateTime.UtcNow.Hour }, { "minute", DateTime.UtcNow.Minute }, { "name", groupedField.Name } } }, { "time", new BsonDateTime(DateTime.UtcNow) }, //TODO loop del result entero { "isFilled", true } //Marcamos como hora añadida }; foreach (var r in groupedField.Result) switch (r) { case GroupedObservationEnum.Result.LastFilled: case GroupedObservationEnum.Result.LastAll: continue; case GroupedObservationEnum.Result.Sum: case GroupedObservationEnum.Result.Average: case GroupedObservationEnum.Result.Count: newEntry.Add(new BsonElement(r.ToString().ToLower(), 0)); continue; case GroupedObservationEnum.Result.HalfHour: newEntry.Add("all", new BsonArray { new BsonDocument { { "value", 0 }, { "min", 0 }, { "max", 0 }, { "time", new BsonDateTime(expectedDate) } } }); continue; default: newEntry.Add(r.ToString().ToLower(), new BsonDocument { { "value", 0 }, { "min", 0 }, { "max", 0 }, { "time", new BsonDateTime(expectedDate) } }); continue; } if (groupedField.Result.Count != 1 || !groupedField.Result.Contains(GroupedObservationEnum.Result.LastFilled)) filteredDocuments.Add(newEntry); filteredDocuments = filteredDocuments.OrderBy(e => e.Get("time")?.ToLocalTime()).ToList(); } fillResult.AddRange(filteredDocuments); } } fillResult = fillResult.OrderBy(e => e.Get("time")?.ToLocalTime()).ToList(); return fillResult; } /// /// Calculates the last filled observations for a patient. When the grouped field contains multiple names, the calculation is performed for each name and the results are aggregated; otherwise, the calculation is performed once using the single grouped field name. /// /// The collection of BsonDocument results to be processed. /// The grouped field containing either a list of names to iterate over or a single name used as fallback when the list is empty. /// The identifier of the patient whose observations are being calculated. /// A task that returns a list of BsonDocument containing the calculated last filled observations. public async Task> CalculateLastFilledObservations(List result, GroupedField groupedField, ObjectId patientId) { List resultToReturn = []; if (groupedField.Names.Any()) foreach (var name in groupedField.Names) { var resultFiltedByName = result.FindAll(b => b.Get("_id")?.AsBsonDocument.Get("name") == name); var resultCalculated = await LastFilledCalc(resultFiltedByName, groupedField, patientId, name); resultToReturn.AddRange(resultCalculated); } else return await LastFilledCalc(result, groupedField, patientId, groupedField.Name); return resultToReturn; } /// /// Fills missing time-slot entries in the grouped observation result with the last known value (last-filled strategy) based on the field's regularity (day, hour, minute, or second). Returns early when the result already contains the expected number of entries, and falls back to the last observation before the earliest expected date, the previous result entry, or zero when no prior value is available. /// /// The list of grouped BsonDocument observations to enrich with missing entries. /// Defines the maximum number of entries, the regularity (day/hour/minute/second), and the field name used to look up the last observation. /// The patient identifier used to query the last observation before the earliest expected date. /// The name assigned to the generated BsonDocument _id entries. /// The result list with missing time-slot entries filled using the last-filled value, ordered by time. private async Task> LastFilledCalc(List result, GroupedField groupedField, ObjectId patientId, string? name) { var now = DateTime.UtcNow; if (result.Count == groupedField.Max) return result; //We have data from each hour/day/minute, dont need to calc. var first = result.FirstOrDefault(); object? lastObservationValue = null; var expectedFirstDate = groupedField.Regularity switch { GroupedObservationEnum.Regularity.Day => now.AddDays(-groupedField.Max), GroupedObservationEnum.Regularity.Hour => now.AddHours(-groupedField.Max), GroupedObservationEnum.Regularity.Minute => now.AddMinutes(-groupedField.Max), GroupedObservationEnum.Regularity.Second => now.AddSeconds(-groupedField.Max), _ => DateTime.MinValue }; //first > fecha minima tenemos que buscar el ultimo valor if (first == null || first.Get("time")?.ToUniversalTime().CompareTo(expectedFirstDate) > 0) { var lastObservation = await _observationRepository.FindLastObservationBeforeDate(patientId, groupedField.Name, expectedFirstDate); if (lastObservation != null) lastObservationValue = lastObservation.Value; } var expectedDate = DateTime.MinValue; var inverseCounter = 0; for (var i = groupedField.Max; i != 0; i--) { var expectedMatch = true; var value = result.ElementAtOrDefault(inverseCounter); DateTime? time = null; if (value == null) expectedMatch = false; else time = value.Get("time")?.ToUniversalTime(); switch (groupedField.Regularity) { case GroupedObservationEnum.Regularity.Day: expectedDate = now.AddDays(-i); if (!time.HasValue || expectedDate.Day != time.Value.Day) expectedMatch = false; break; case GroupedObservationEnum.Regularity.Hour: expectedDate = now.AddHours(-i); if (!time.HasValue || expectedDate.Hour != time.Value.Hour) expectedMatch = false; break; case GroupedObservationEnum.Regularity.Minute: expectedDate = now.AddMinutes(-i); if (!time.HasValue || expectedDate.Minute != time.Value.Minute) expectedMatch = false; break; case GroupedObservationEnum.Regularity.Second: expectedDate = now.AddSeconds(-i); if (!time.HasValue || expectedDate.Second != time.Value.Second) expectedMatch = false; break; } if (!expectedMatch) { var lastResult = result.ElementAtOrDefault(inverseCounter - 1); object? lastFilledValue; if (lastResult == null) { lastFilledValue = lastObservationValue; } else { var lastfilled = GroupedObservationEnum.Result.LastFilled.ToString().ToLower(); lastFilledValue = lastResult.Get(lastfilled)?.AsBsonDocument.Get("value"); } lastFilledValue ??= 0; var newEntry = new BsonDocument { { "_id", new BsonDocument { { "year", expectedDate.Year }, { "month", expectedDate.Month }, { "day", expectedDate.Day }, { "hour", expectedDate.Hour }, { "minute", expectedDate.Minute }, { "name", name } } }, { "time", new BsonDateTime(expectedDate) }, { GroupedObservationEnum.Result.LastFilled.ToString().ToLower(), new BsonDocument { { "value", lastFilledValue.ToString() }, { "min", 0 }, { "max", 0 }, { "time", new BsonDateTime(expectedDate) } } } }; result.Add(newEntry); result = result.OrderBy(e => e.Get("time")?.ToLocalTime()).ToList(); } inverseCounter++; } return result; } /// /// Calculates aggregated shift observations by grouping them by shift and day. When the grouped field result contains a Sum operation, the method sums the values within each group and propagates the isFilled flag, marking a group as not filled if any of its observations is not filled. If no Sum operation is present, the original observations are returned unchanged. /// /// The list of shift observations to be aggregated. /// The grouped field configuration whose Result property determines whether a Sum aggregation is applied. /// A list of containing the aggregated shift observations, or the original list when no Sum operation is required. public List CalculateShiftObservations(List shiftGroupObservations, GroupedField groupedField) { var shiftGrouped = new List(); if (groupedField.Result.Contains(GroupedObservationEnum.Result.Sum)) { var shiftGroups = shiftGroupObservations.GroupBy(shift => new { V1 = shift.GetElement("shift").ToString(), V = shift.GetElement("day").ToString() }) .Select(s => s.ToList()) .ToList(); shiftGroups.ForEach(shiftGroup => { var shiftSum = shiftGroup.Select(shift => shift.Get("sum")?.ToInt32()).Sum(); var shiftToAdd = shiftGroup.FirstOrDefault(); shiftToAdd?.SetElement(new BsonElement("sum", shiftSum)); if (shiftGroup.Select(shift => shift.Get("isFilled")).Any(s => s == false)) shiftToAdd?.SetElement(new BsonElement("isFilled", false)); if (shiftToAdd != null) shiftGrouped.Add(shiftToAdd); }); return shiftGrouped; } return shiftGroupObservations; } //MIGUEL: EJEMPLO SI LAS 12 TIENEN 4 VALORES 12:15 => 5, 12:20 => 10, 12:45 => 20 Y LAS 13 NO TIENE VALOR. 12 DEBERÍA 5 Y 13 DEBERÍA SER // ACTUALMENTE A LAS 12 MOSTRARIA EL ULTIMO DE LA MEDIA HORA Y A LAS 13? /// /// Calculates half-hour observations for the provided list of entries by grouping /// the "all" sub-document values into 30-minute time buckets and assigning the most recent sample of each /// bucket to a "halfhour" field on the corresponding document. Handles edge cases such as the last document /// in the list, absence of a next document, empty groups, a next document without readings (or with a /// single "0" value), and missing "halfhour" fields, falling back to an empty /// when no grouped values are available. /// /// The ordered list of instances whose "all" arrays will be /// grouped into half-hour intervals and enriched with a "halfhour" field in place. /// The same instance with the "halfhour" field populated /// according to the half-hour grouping rules. public List CalculateHalfHourObservations(List result) { //HalfHour añadir que se rellenen las horas que no existen for (var i = 0; i < result.Count; i++) { var document = result[i]; BsonDocument? nextDocument = null; if (i + 1 < result.Count) nextDocument = result[i + 1]; document.TryGetValue("all", out var docvalues); List groups = []; if (docvalues != null) groups = docvalues.AsBsonArray.ToList().GroupBy(x => { var stamp = x.AsBsonDocument.Get("time")?.ToLocalTime(); if (!stamp.HasValue) return null; stamp = stamp.Value.AddMinutes(-(stamp.Value.Minute % 30)); stamp = stamp.Value.AddMilliseconds(-stamp.Value.Millisecond - 1000 * stamp.Value.Second); return stamp; }).Select(g => g.OrderByDescending(c => c.AsBsonDocument.Get("time")?.ToLocalTime()).FirstOrDefault()) .ToList(); if (groups.Count > 0) { if (nextDocument == null) { document.Set("halfhour", groups.Last()); break; } if (groups.Count > 1) { var nextDocumentValues = nextDocument.Get("all")?.AsBsonArray.ToList(); if (nextDocumentValues == null || nextDocumentValues.Count == 0 || (nextDocumentValues.Count == 1 && nextDocument.Get("all")?.AsBsonArray.ToList()[0].AsBsonDocument .Get("value")?.ToString() == "0")) //El siguiente registro no tiene tomas { nextDocument.Set("halfhour", groups.LastOrDefault()); document.Set("halfhour", groups.FirstOrDefault()); } else { document.Set("halfhour", groups.LastOrDefault()); } } else { if (document.Get("halfhour")?.AsBsonDocument == null) document.Set("halfhour", groups.LastOrDefault()); } } else { if (!document.TryGetValue("halfhour", out _)) document.Set("halfhour", new BsonDocument()); } } return result; } /// /// Enriches each BsonDocument in the result with a shift identifier and the corresponding day, by matching the document's observation time against the configured shift start times. Falls back to the previous day and the last shift when no matching shift is found, and skips documents with missing or invalid date components or an empty shift configuration. /// /// The list of aggregated BsonDocuments to enrich; each document is modified in place to include the day and shift elements. /// The grouping configuration providing the ordered list of shift start times used to determine the assigned shift. /// The same list with day and shift elements added to each successfully processed document. public List GenerateShiftObservations(List result, GroupedField groupedField) { result.ForEach(item => { DateTime? obsDateTime = null; var id = item.Get("_id")?.AsBsonDocument; if (id == null) return; var timeStr = id.Get("hour")?.ToString()?.PadLeft(2, '0') ?? "00"; if (id.TryGetValue("minute", out _)) { timeStr += ":" + id.Get("minute")?.ToString()?.PadLeft(2, '0'); if (DateTime.TryParseExact(timeStr, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var r1)) obsDateTime = r1; } else { if (DateTime.TryParseExact(timeStr, "HH", CultureInfo.InvariantCulture, DateTimeStyles.None, out var r2)) obsDateTime = r2; } obsDateTime = obsDateTime?.ToLocalTime(); int shift; int day; //Return all dates in shift array before obsDateTime, and take last, that will be the closest shift date. var expectedHour = obsDateTime?.Hour.ToString().Length < 2 ? $"0{obsDateTime.Value.Hour}" : obsDateTime?.Hour.ToString(); var expectedMinutes = obsDateTime?.Minute.ToString().Length < 2 ? $"0{obsDateTime.Value.Minute}" : obsDateTime?.Minute.ToString(); var shiftSelected = groupedField.StartTimeShift.LastOrDefault (i => DateTime.ParseExact(i, "HH:mm", CultureInfo.InvariantCulture).Ticks <= DateTime .ParseExact($"{expectedHour}:{expectedMinutes}", "HH:mm", CultureInfo.InvariantCulture) .Ticks); var havemin = id.TryGetValue("minute", out var minuteV); var year = id.Get("year")?.ToInt32(); var month = id.Get("month")?.ToInt32(); var dayy = id.Get("day")?.ToInt32(); var hour = id.Get("hour")?.ToInt32(); if (!year.HasValue || !month.HasValue || !dayy.HasValue || !hour.HasValue) { _logger.LogError("Error getting dateTime from Bson: {id}", id); return; } var dateUtc = new DateTime(year.Value, month.Value, dayy.Value, hour.Value, havemin ? minuteV.ToInt32() : 0, 0); if (!groupedField.StartTimeShift.Any()) { _logger.LogError( "Error generating shift observations. StartTimeShift List in null or empty. {groupedField}", groupedField); return; } if (shiftSelected == null) { day = dateUtc.ToLocalTime().AddDays(-1).Day; shift = groupedField.StartTimeShift.Count - 1; } else { day = dateUtc.ToLocalTime().Day; shift = groupedField.StartTimeShift.IndexOf(shiftSelected); } item.Add(new BsonElement("day", day)); item.Add(new BsonElement("shift", shift)); }); return result; } // PRIVATE: Incremental item adjustment /// /// Locates the current observation within the given group, updating its aggregated values (Max, Min, Last, LastFilled) when a matching time-slot entry exists, or appending a new entry otherwise, and finally filters the group to retain only observations within the configured retention window based on the grouped field's regularity (Minute, Second, or Hourly). /// /// The list of grouped observations to search and update. /// The patient observation to match against or insert into the group. /// The grouped field providing the regularity, result aggregation types, maximum retention count, and shift configuration. /// The updated list of grouped observations, limited to entries whose time is within the allowed date range. private List LocateCurrentObsInGroup(List group, PatientObservation obs, GroupedField groupedField) { // Check if we need to modify the current value or add new one var specifyMinutes = groupedField.Regularity is GroupedObservationEnum.Regularity.Minute or GroupedObservationEnum.Regularity.Second; var specifySeconds = groupedField.Regularity == GroupedObservationEnum.Regularity.Second; var obsTime = new DateTime(obs.Time.Year, obs.Time.Month, obs.Time.Day, obs.Time.Hour, specifyMinutes ? obs.Time.Minute : 0, specifySeconds ? obs.Time.Second : 0); var obsIndexInThisHour = group.FindIndex(c => new DateTime(c.Time.Year, c.Time.Month, c.Time.Day, c.Time.Hour, specifyMinutes ? c.Time.Minute : 0, specifySeconds ? c.Time.Second : 0) == obsTime && c.Name == obs.Name); if (obsIndexInThisHour >= 0) { // Obs to modify found var valueIsParsed = double.TryParse(obs.Value.ToString(), out var valueParsed); if (!valueIsParsed) return group; group[obsIndexInThisHour].IsFilled = false; group[obsIndexInThisHour].Time = obsTime; foreach (var result in groupedField.Result) switch (result) { case GroupedObservationEnum.Result.Max: var valueMaxIsParsed = double.TryParse(group[obsIndexInThisHour].Max?.Value.ToString(), out var valueMaxParsed); if (!valueMaxIsParsed) continue; //actual value is higher, is relevant if (group[obsIndexInThisHour].Max?.Value != null && valueParsed > valueMaxParsed) { group[obsIndexInThisHour].Max!.Value = obs.Value; group[obsIndexInThisHour].Max!.Time = obs.Time; group[obsIndexInThisHour].Max!.Type = obs.Status; group[obsIndexInThisHour].Max!.Type = obs.Status; } break; case GroupedObservationEnum.Result.Min: var valueMinIsParsed = double.TryParse(group[obsIndexInThisHour].Max?.Value.ToString(), out var valueMinParsed); if (!valueMinIsParsed) continue; if (group[obsIndexInThisHour].Max?.Value != null && valueParsed < valueMinParsed) { group[obsIndexInThisHour].Min!.Value = obs.Value; group[obsIndexInThisHour].Min!.Time = obs.Time; group[obsIndexInThisHour].Min!.Type = obs.Status; } break; case GroupedObservationEnum.Result.Last: if (group[obsIndexInThisHour].Last?.Time < obs.Time) { group[obsIndexInThisHour].Last!.Value = obs.Value; group[obsIndexInThisHour].Last!.Time = obs.Time; group[obsIndexInThisHour].Last!.Type = obs.Status; } break; case GroupedObservationEnum.Result.LastFilled: if (group[obsIndexInThisHour].LastFilled?.Time < obs.Time) { group[obsIndexInThisHour].LastFilled!.Value = obs.Value; group[obsIndexInThisHour].LastFilled!.Time = obs.Time; group[obsIndexInThisHour].LastFilled!.Type = obs.Status; } break; } } else { // Delete obs autofilled and in time of the new obs var obsIndexInThisHourToRemove = group.FindIndex(c => new DateTime(c.Time.Year, c.Time.Month, c.Time.Day, c.Time.Hour, specifyMinutes ? c.Time.Minute : 0, specifySeconds ? c.Time.Second : 0) == obsTime && c.IsFilled); if (obsIndexInThisHourToRemove >= 0) group.RemoveAt(obsIndexInThisHourToRemove); // Obs to modify not found add it var ngo = new GroupedObservationObs { Name = obs.Name ?? string.Empty, MinAlert = obs.Min, MaxAlert = obs.Max, Time = obsTime, IsFilled = false, ShiftDate = obs.Time, Shift = GetShift(obs.Time, groupedField.StartTimeShift) }; foreach (var result in groupedField.Result) switch (result) { case GroupedObservationEnum.Result.Max: ngo.Max = new GroupedObservationObsValue(obs.Value, obs.Time, obs.Status); break; case GroupedObservationEnum.Result.Min: ngo.Min = new GroupedObservationObsValue(obs.Value, obs.Time, obs.Status); break; case GroupedObservationEnum.Result.Last: ngo.Last = new GroupedObservationObsValue(obs.Value, obs.Time, obs.Status); break; case GroupedObservationEnum.Result.LastFilled: ngo.LastFilled = new GroupedObservationObsValue(obs.Value, obs.Time, obs.Status); break; } group.Add(ngo); } // Check if we have more obs than we require to show DateTime dateLimit; switch (groupedField.Regularity) { case GroupedObservationEnum.Regularity.Minute: dateLimit = new DateTime(obs.Time.Year, obs.Time.Month, obs.Time.Day, obs.Time.Hour, obs.Time.Minute, 0) .AddMinutes(groupedField.Max * -1); break; case GroupedObservationEnum.Regularity.Second: dateLimit = new DateTime(obs.Time.Year, obs.Time.Month, obs.Time.Day, obs.Time.Hour, obs.Time.Minute, obs.Time.Second).AddSeconds(groupedField.Max * -1); break; default: dateLimit = new DateTime(obs.Time.Year, obs.Time.Month, obs.Time.Day, obs.Time.Hour, 0, 0).AddHours( groupedField.Max * -1); break; } return group.Where(c => new DateTime(c.Time.Year, c.Time.Month, c.Time.Day, c.Time.Hour, specifyMinutes ? c.Time.Minute : 0, specifySeconds ? c.Time.Second : 0) >= dateLimit).ToList(); } // PRIVATE: Low-level utilities /// /// Determines the index of the shift that contains the time portion of the given observation date, based on the provided list of shift start times. Returns null when the shift list is empty, and -1 when the observation time does not fall within any defined shift interval (using a wrap-around comparison where the last interval extends back to the first). /// /// The observation date whose time of day is evaluated against the configured shifts. /// The list of shift start time strings used to build the shift intervals; must contain at least one entry to produce a result. /// The zero-based index of the matching shift, -1 if no shift matches, or null if is empty. private int? GetShift(DateTime currentObsDate, List groupedFieldStartTimeShift) { if (groupedFieldStartTimeShift.Count == 0) return null; var listTime = GetTimeSpan(groupedFieldStartTimeShift); var dateTimeSpan = currentObsDate.TimeOfDay; for (var i = 0; i < listTime.Count; i++) { var nextIndex = (i + 1) % listTime.Count; var nextTimeSpan = listTime[nextIndex]; var currentTimeSpan = listTime[i]; if (dateTimeSpan >= currentTimeSpan && dateTimeSpan < nextTimeSpan) return i; } return -1; } /// /// Parses a list of time string representations into TimeSpan values, then adjusts each value by adding one minute, capping the result at the next time span in the sequence (wrapping around for the last element). /// /// A list of time span string representations to be parsed and adjusted. /// A list of TimeSpan values where each entry is the original time plus one minute, or the subsequent time span if adding one minute would exceed it. private List GetTimeSpan(List groupedFieldStartTimeShift) { List timeSpans = []; timeSpans.AddRange(groupedFieldStartTimeShift.Select(TimeSpan.Parse)); List newTimeSpans = []; for (var i = 0; i < timeSpans.Count; i++) { var nextIndex = (i + 1) % timeSpans.Count; var nextTimeSpan = timeSpans[nextIndex]; var currentTimeSpan = timeSpans[i]; var addedTimeSpan = currentTimeSpan.Add(TimeSpan.FromMinutes(1)); newTimeSpans.Add(addedTimeSpan < nextTimeSpan ? addedTimeSpan : nextTimeSpan); } return newTimeSpans; } /// /// Retrieves the status of a grouped observation by delegating to the configuration observation service, evaluating the provided grouped field and result against the specified name, value, and optional numeric range bounds. /// /// The grouped field to be evaluated. /// The grouped observation result to be assessed. /// The name associated with the observation being checked. /// The value to be compared against the configured observation rules. /// The optional minimum bound used in the status evaluation. /// The optional maximum bound used in the status evaluation. /// A task that resolves to the representing the status of the grouped observation. private async Task GroupedObservationStatus(GroupedField groupedField, GroupedObservationEnum.Result result, string name, object value, double? min, double? max) { return await _configObservationService.GroupedObservationStatus(groupedField, result, name, value, min, max); } /// /// Calculates the number of empty observations that need to be created based on the time elapsed since the last observation and the regularity of the grouped field. The regularity unit (second, minute, hour, or day) determines both the time conversion and the corresponding multiplier factor. /// /// The grouped observation containing the timestamp of the last recorded observation. /// The grouped field whose regularity (second, minute, hour, or day) defines the time unit used in the calculation. /// A tuple containing the number of times left to create an empty observation (the ceiling of the elapsed time in the selected unit) and the multiplier factor associated with the regularity unit. private (int timesLeft, int multiplierFactor) TimesLeftToCreateEmptyObs(GroupedObservationObs obs, GroupedField gf) { var currentDateTime = DateTime.Now; var timeElapsed = currentDateTime - obs.Time; double timeToCurrent; var multipliesFactor = 1; // Calcula el tiempo transcurrido en la unidad deseada switch (gf.Regularity) { case GroupedObservationEnum.Regularity.Second: timeToCurrent = timeElapsed.TotalSeconds; break; case GroupedObservationEnum.Regularity.Minute: timeToCurrent = timeElapsed.TotalMinutes; multipliesFactor = 60; break; case GroupedObservationEnum.Regularity.Day: timeToCurrent = timeElapsed.TotalDays; multipliesFactor = 86400; break; default: timeToCurrent = timeElapsed.TotalHours; multipliesFactor = 3600; break; } var timesLeft = (int)Math.Ceiling(timeToCurrent); return (timesLeft, multipliesFactor); } /// /// Creates a new instance based on the provided source observation, advancing its time by the specified number of seconds and marking it as filled. /// /// The source observation whose values and time are used to build the next observation. /// The grouped field providing the list of result property names to copy and the regularity used to determine shift handling. /// The number of seconds to add to the source observation's time for the new observation. /// A new populated with values derived from ; when the regularity is , the shift date and shift are also assigned. private GroupedObservationObs CreateNextObs(GroupedObservationObs sourceObs, GroupedField gf, int seconsToAdd) { var lastObs = new GroupedObservationObs { Time = sourceObs.Time.AddSeconds(seconsToAdd), Name = sourceObs.Name, IsFilled = true }; //Log.Debug("Result list:", string.Join(',', Result)); //Se rellena el tiempo con la hora de la obs anterior por que no hay dato de la actual GroupedObservationObsValue defaultValue = new(0, sourceObs.Time); foreach (var r in gf.Result) { //Log.Debug("Processing {r}", r.ToString()); //var props = typeof(GroupedObservationObs).GetProperties(); //Log.Debug("GroupedObservationObs Poperties list: {list}", string.Join(',', props.ToList())); var propInfo = typeof(GroupedObservationObs).GetProperty(r.ToString()); if (propInfo == null) { _logger.LogError("Error Creating Next Obs. Getting property: {R} not found. SourceObs:{Obs}", r.ToString(), sourceObs); continue; } propInfo.SetValue(lastObs, defaultValue); var gObsValue = propInfo.GetValue(sourceObs); if (gObsValue == null) { _logger.LogWarning("Error Creating Next Obs.Getting property value: {R} not found. SourceObs:{Obs}", r.ToString(), sourceObs); continue; } var prevValue = (GroupedObservationObsValue)gObsValue; prevValue.Time = sourceObs.Time; propInfo.SetValue(sourceObs, prevValue); } if (gf.Regularity == GroupedObservationEnum.Regularity.Shift) { lastObs.ShiftDate = lastObs.Time; lastObs.Shift = GetShift(lastObs.ShiftDate, gf.StartTimeShift); } return lastObs; } }