Files
adas-core/adas-core.Application/Services/GroupedObservationService.cs
T

1417 lines
71 KiB
C#

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;
/// <summary>
/// Provides a concrete implementation of <see cref="IGroupedObservationService"/> for managing and exposing grouped observation data.
/// </summary>
/// <!-- aidoc:v1 sig=d252f21 -->
public class GroupedObservationService : IGroupedObservationService
{
private readonly CacheSettings? _cacheSettings;
private readonly ICacheService _cacheService;
private readonly IConfigObservationService _configObservationService;
private readonly ILogger<GroupedObservationService> _logger;
private readonly IObservationRepository _observationRepository;
/// <summary>
/// Initializes a new instance of the <see cref="GroupedObservationService"/>, which coordinates the retrieval, configuration, caching, and logging of grouped observation data, by capturing the supplied dependencies.
/// </summary>
/// <param name="observationRepository">The <see cref="IObservationRepository"/> used to access underlying observation records.</param>
/// <param name="configObservationService">The <see cref="IConfigObservationService"/> used to resolve observation configuration.</param>
/// <param name="logger">The <see cref="ILogger{GroupedObservationService}"/> used to record diagnostic and operational messages.</param>
/// <param name="cacheService">The <see cref="ICacheService"/> used to read from and write to the application cache.</param>
/// <param name="apiSettings">The <see cref="IOptions{ApiSettings}"/> providing configurable API options.</param>
/// <param name="cacheSettings">The <see cref="IOptions{CacheSettings}"/> whose <see cref="IOptions{TOptions}.Value"/> is stored as the resolved cache configuration.</param>
/// <!-- aidoc:v1 sig=3ffcb4f body=0e26b9d -->
public GroupedObservationService(
IObservationRepository observationRepository,
IConfigObservationService configObservationService,
ILogger<GroupedObservationService> logger,
ICacheService cacheService,
IOptions<ApiSettings> apiSettings,
IOptions<CacheSettings> cacheSettings)
{
_observationRepository = observationRepository;
_configObservationService = configObservationService;
_logger = logger;
_cacheService = cacheService;
_cacheSettings = cacheSettings.Value;
}
// PUBLIC API (Use cases)
/// <summary>
/// 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 <paramref name="cacheIsChecked"/> is <c>false</c>; otherwise the observations are recomputed and returned without touching the cache.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being aggregated.</param>
/// <param name="groupedField">The grouping field definition that drives the aggregation, regularity, shift times, and which result types (first, last, min, max, etc.) are computed.</param>
/// <param name="timeZoneId">The system time zone id used to convert observation times from UTC. Defaults to "Romance Standard Time".</param>
/// <param name="cacheIsChecked">When <c>true</c>, the cache is bypassed and observations are always recomputed; when <c>false</c>, results are obtained through the cache with the configured TTL.</param>
/// <param name="ct">Token used to cancel the asynchronous operation.</param>
/// <returns>A <see cref="Task{GroupedObservation}"/> containing the patient id, group, name, and the list of computed <see cref="GroupedObservationObs"/> entries.</returns>
/// <!-- aidoc:v1 sig=17d649b body=5ba5a2e -->
public async Task<GroupedObservation> 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<List<GroupedObservationObs>> 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<GroupedObservationObs>();
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
};
}
/// <summary>
/// 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.
/// </summary>
/// <param name="patientId">The identifier of the patient whose grouped observation is being generated.</param>
/// <param name="groupedField">The grouped field configuration whose result type and name drive the recalculation strategy and cache key.</param>
/// <param name="wsgLastGroupedObservationObs">The last known grouped observations provided as context for the grouped observation.</param>
/// <param name="obs">The new patient observation to be incorporated into the grouped observation.</param>
/// <param name="timeZoneId">The time zone identifier used when a full recalculation is required. Defaults to "Romance Standard Time".</param>
/// <returns>A task that resolves to the generated <see cref="GroupedObservation"/> for the patient, either from an incrementally updated cache entry or from a full recalculation.</returns>
/// <!-- aidoc:v1 sig=63421dc body=6b8c46b -->
public async Task<GroupedObservation> GenerateGroupedObservation(
ObjectId patientId,
GroupedField groupedField,
List<GroupedObservationObs> 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<List<GroupedObservationObs>>(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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being queried.</param>
/// <param name="num">The maximum number of recent observations to return. Defaults to 2.</param>
/// <param name="filterObservations">An optional list of observation identifiers used to restrict the result set. When null, no filtering is applied.</param>
/// <returns>A task that represents the asynchronous operation, containing a list of <see cref="PatientObservation"/> entries representing the patient's most recent observations.</returns>
/// <!-- aidoc:v1 sig=6f6283b body=92c1221 -->
public async Task<List<PatientObservation>> FindLastObservations(ObjectId patientId, int num = 2,
List<string>? filterObservations = null)
{
var result = await _observationRepository.AggregatedPatientLastObservations(patientId, num, filterObservations);
return result;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="ws">The grouped subscriber context that supplies the group, names, regularity, max window, and cache key used to derive the next observations.</param>
/// <returns>A <see cref="GroupedObservation"/> containing the resulting observations adjusted to the configured time window based on <see cref="GroupedObservationEnum.Regularity"/> (Minute, Second, or default hour-based).</returns>
/// <!-- aidoc:v1 sig=b957e8f body=3a63489 -->
public async Task<GroupedObservation> 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<List<GroupedObservationObs>>(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
/// <summary>
/// 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
/// <c>isFilled</c> and the appropriate result-type fields (sum, average, count, half-hour, etc.)
/// are initialized to zero, except for <c>LastFilled</c> and <c>LastAll</c> result types which are
/// skipped, and the output is ordered by time before being returned.
/// </summary>
/// <param name="result">The existing grouped observation documents to be completed with missing intervals.</param>
/// <param name="groupedField">The grouping configuration that defines the names, maximum number of intervals, regularity and result types to use when generating the placeholders.</param>
/// <returns>A list of <see cref="BsonDocument"/> entries ordered by time, containing the original data and any added zero-valued filler entries for missing intervals.</returns>
/// <!-- aidoc:v1 sig=b15a145 body=b74a0fe -->
public List<BsonDocument> FillHours(List<BsonDocument> 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<BsonDocument> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="result">The collection of BsonDocument results to be processed.</param>
/// <param name="groupedField">The grouped field containing either a list of names to iterate over or a single name used as fallback when the list is empty.</param>
/// <param name="patientId">The identifier of the patient whose observations are being calculated.</param>
/// <returns>A task that returns a list of BsonDocument containing the calculated last filled observations.</returns>
/// <!-- aidoc:v1 sig=685bef7 body=8a6adf3 -->
public async Task<List<BsonDocument>> CalculateLastFilledObservations(List<BsonDocument> result,
GroupedField groupedField, ObjectId patientId)
{
List<BsonDocument> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="result">The list of grouped BsonDocument observations to enrich with missing entries.</param>
/// <param name="groupedField">Defines the maximum number of entries, the regularity (day/hour/minute/second), and the field name used to look up the last observation.</param>
/// <param name="patientId">The patient identifier used to query the last observation before the earliest expected date.</param>
/// <param name="name">The name assigned to the generated BsonDocument <c>_id</c> entries.</param>
/// <returns>The result list with missing time-slot entries filled using the last-filled value, ordered by time.</returns>
/// <!-- aidoc:v1 sig=3627386 body=09800ac -->
private async Task<List<BsonDocument>> LastFilledCalc(List<BsonDocument> 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;
}
/// <summary>
/// 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 <c>isFilled</c> 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.
/// </summary>
/// <param name="shiftGroupObservations">The list of shift observations to be aggregated.</param>
/// <param name="groupedField">The grouped field configuration whose Result property determines whether a Sum aggregation is applied.</param>
/// <returns>A list of <see cref="BsonDocument"/> containing the aggregated shift observations, or the original list when no Sum operation is required.</returns>
/// <!-- aidoc:v1 sig=37e3bfd body=e1601ec -->
public List<BsonDocument> CalculateShiftObservations(List<BsonDocument> shiftGroupObservations,
GroupedField groupedField)
{
var shiftGrouped = new List<BsonDocument>();
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?
/// <summary>
/// Calculates half-hour observations for the provided list of <see cref="BsonDocument"/> 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 <see cref="BsonDocument"/>
/// when no grouped values are available.
/// </summary>
/// <param name="result">The ordered list of <see cref="BsonDocument"/> instances whose "all" arrays will be
/// grouped into half-hour intervals and enriched with a "halfhour" field in place.</param>
/// <returns>The same <see cref="List{BsonDocument}"/> instance with the "halfhour" field populated
/// according to the half-hour grouping rules.</returns>
/// <!-- aidoc:v1 sig=9daf918 body=efd69f0 -->
public List<BsonDocument> CalculateHalfHourObservations(List<BsonDocument> 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<BsonValue?> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="result">The list of aggregated BsonDocuments to enrich; each document is modified in place to include the day and shift elements.</param>
/// <param name="groupedField">The grouping configuration providing the ordered list of shift start times used to determine the assigned shift.</param>
/// <returns>The same <paramref name="result"/> list with day and shift elements added to each successfully processed document.</returns>
/// <!-- aidoc:v1 sig=f6df652 body=c517b3e -->
public List<BsonDocument> GenerateShiftObservations(List<BsonDocument> 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
/// <summary>
/// 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).
/// </summary>
/// <param name="group">The list of grouped observations to search and update.</param>
/// <param name="obs">The patient observation to match against or insert into the group.</param>
/// <param name="groupedField">The grouped field providing the regularity, result aggregation types, maximum retention count, and shift configuration.</param>
/// <returns>The updated list of grouped observations, limited to entries whose time is within the allowed date range.</returns>
/// <!-- aidoc:v1 sig=cca5083 body=147be01 -->
private List<GroupedObservationObs> LocateCurrentObsInGroup(List<GroupedObservationObs> 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
/// <summary>
/// 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).
/// </summary>
/// <param name="currentObsDate">The observation date whose time of day is evaluated against the configured shifts.</param>
/// <param name="groupedFieldStartTimeShift">The list of shift start time strings used to build the shift intervals; must contain at least one entry to produce a result.</param>
/// <returns>The zero-based index of the matching shift, -1 if no shift matches, or null if <paramref name="groupedFieldStartTimeShift"/> is empty.</returns>
/// <!-- aidoc:v1 sig=d71f06d body=ef631e3 -->
private int? GetShift(DateTime currentObsDate, List<string> 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;
}
/// <summary>
/// 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).
/// </summary>
/// <param name="groupedFieldStartTimeShift">A list of time span string representations to be parsed and adjusted.</param>
/// <returns>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.</returns>
/// <!-- aidoc:v1 sig=1b7da10 body=741054c -->
private List<TimeSpan> GetTimeSpan(List<string> groupedFieldStartTimeShift)
{
List<TimeSpan> timeSpans = [];
timeSpans.AddRange(groupedFieldStartTimeShift.Select(TimeSpan.Parse));
List<TimeSpan> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="groupedField">The grouped field to be evaluated.</param>
/// <param name="result">The grouped observation result to be assessed.</param>
/// <param name="name">The name associated with the observation being checked.</param>
/// <param name="value">The value to be compared against the configured observation rules.</param>
/// <param name="min">The optional minimum bound used in the status evaluation.</param>
/// <param name="max">The optional maximum bound used in the status evaluation.</param>
/// <returns>A task that resolves to the <see cref="StatusEnum.Type"/> representing the status of the grouped observation.</returns>
/// <!-- aidoc:v1 sig=8c95413 body=a79f912 -->
private async Task<StatusEnum.Type> GroupedObservationStatus(GroupedField groupedField,
GroupedObservationEnum.Result result, string name, object value, double? min, double? max)
{
return await _configObservationService.GroupedObservationStatus(groupedField, result, name, value, min, max);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="obs">The grouped observation containing the timestamp of the last recorded observation.</param>
/// <param name="gf">The grouped field whose regularity (second, minute, hour, or day) defines the time unit used in the calculation.</param>
/// <returns>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.</returns>
/// <!-- aidoc:v1 sig=dcb9cc9 body=13579ae -->
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);
}
/// <summary>
/// Creates a new <see cref="GroupedObservationObs"/> instance based on the provided source observation, advancing its time by the specified number of seconds and marking it as filled.
/// </summary>
/// <param name="sourceObs">The source observation whose values and time are used to build the next observation.</param>
/// <param name="gf">The grouped field providing the list of result property names to copy and the regularity used to determine shift handling.</param>
/// <param name="seconsToAdd">The number of seconds to add to the source observation's time for the new observation.</param>
/// <returns>A new <see cref="GroupedObservationObs"/> populated with values derived from <paramref name="sourceObs"/>; when the regularity is <see cref="GroupedObservationEnum.Regularity.Shift"/>, the shift date and shift are also assigned.</returns>
/// <!-- aidoc-review:v1 severity=high kind=wrong_returns
/// "The <returns> description states the new observation is 'populated with values derived from sourceObs', but the code sets each property in gf.Result to a defaultValue of (0, sourceObs.Time) and never copies the actual values from sourceObs. The retrieved sourceObs value is only used to mutate sourceObs (resetting its inner Time), not to populate lastObs." -->
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;
}
}