using System.Timers; using adas_core.Domain.Enums; using adas_core.Domain.Models; using adas_core.Domain.Models.GroupedObservations; using adas_core.Domain.Utils; using MongoDB.Bson; using Serilog; using static adas_core.Domain.Models.GroupedObservation; using Timer = System.Timers.Timer; namespace adas_core.Application.Subscriptions; /** * This class is used to group the subscribers by the same group */ /// /// Represents a grouped WebSocket subscriber that aggregates and manages multiple related subscription registrations. /// public class WsSubscriberGrouped { private readonly EventHandler _sendEvent; public WsSubscriberGrouped(ObjectId patientId, string wsId, string? timeZoneId, GroupedField gf, GroupedObservation lastGroupedObservationObs, EventHandler sendEvent) { _sendEvent = sendEvent; if (WsSubscriber.ToList().FirstOrDefault(s => s == wsId) == null) WsSubscriber.Add(wsId); TimeZoneId = timeZoneId ?? "Romance Standard Time"; PatientId = patientId; Names = CollectionsUtils.IfEmptyOrNull(gf.Names, [gf.Name ?? string.Empty]); Max = gf.Max; Since = gf.Since; StartTimeShift = gf.StartTimeShift; Regularity = gf.Regularity; Result = gf.Result; Group.Add(wsId, gf.Group ?? string.Empty); UpdateLastGo(lastGroupedObservationObs); HashCode = CryptoAdas.CreateMd5GroupedObs(gf, patientId); Timer = new Timer { Interval = SetUpTimerInterval() }; Timer.Elapsed += Timer_Elapsed; if (Regularity != GroupedObservationEnum.Regularity.Times) Timer.Start(); } public List WsSubscriber { get; set; } = []; public ObjectId PatientId { get; set; } public List Names { get; set; } public int Max { get; set; } public GroupedObservationEnum.Regularity? Regularity { get; set; } public List Result { get; set; } public GroupedObservationEnum.Since Since { get; set; } public List StartTimeShift { get; set; } public string HashCode { get; set; } public string TimeZoneId { get; set; } public List LastGroupedObservationObs { get; set; } = []; public Dictionary Group { get; set; } = new(); public Timer Timer { get; set; } /// /// Refreshes the cached last observations by matching each known name against the supplied grouped observation, falling back to the most recent observation when no match is found. /// /// The source grouped observation whose observations are searched to populate the last-observation cache. public void UpdateLastGo(GroupedObservation lastGroupedObservationObs) { LastGroupedObservationObs.Clear(); foreach (var name in Names) { var lastObs = lastGroupedObservationObs.Observations.LastOrDefault(c => c.Name == name); if (lastObs != null) { LastGroupedObservationObs.Add(lastObs); } else { lastObs = lastGroupedObservationObs.Observations.LastOrDefault(); if (lastObs != null) LastGroupedObservationObs.Add(lastObs); } } } private void Timer_Elapsed(object? sender, ElapsedEventArgs e) { var currentDateTime = DateTime.Now; Timer.Stop(); try { bool hasCurrentObs; switch (Regularity) { case GroupedObservationEnum.Regularity.Second: // Filtra observaciones con el mismo segundo actual hasCurrentObs = LastGroupedObservationObs.Any(obs => obs.Time.Second == currentDateTime.Second && obs.Time.Minute == currentDateTime.Minute && obs.Time.Hour == currentDateTime.Hour && obs.Time.Date == currentDateTime.Date); break; case GroupedObservationEnum.Regularity.Minute: // Filtra observaciones con el mismo minuto actual hasCurrentObs = LastGroupedObservationObs.Any(obs => obs.Time.Minute == currentDateTime.Minute && obs.Time.Hour == currentDateTime.Hour && obs.Time.Date == currentDateTime.Date); break; case GroupedObservationEnum.Regularity.Day: // Filtra observaciones con la misma fecha actual (día completo) hasCurrentObs = LastGroupedObservationObs.Any(obs => obs.Time.Date == currentDateTime.Date); break; default: // Filtra observaciones con la misma hora actual hasCurrentObs = LastGroupedObservationObs.Any(obs => obs.Time.Hour == currentDateTime.Hour && obs.Time.Date == currentDateTime.Date); break; } // Si no hay al menos una observación que coincide, invoca el evento if (!hasCurrentObs) _sendEvent(this, HashCode); } catch (Exception ex) { Log.Warning( $"Exception on WsSubsciptor for patient: {PatientId} message: {ex.Message} exception: {ex}"); } TimerReestart(); } /// /// Restarts the timer by recalculating its interval through SetUpTimerInterval and then starting it. /// private void TimerReestart() { Timer.Interval = SetUpTimerInterval(); Timer.Start(); } /// /// Calculates the timer interval in milliseconds until the next scheduled occurrence based on the configured , supporting Second, Minute, Day, and a default (Hour) case, and adds a per-regularity grace time. /// /// The number of milliseconds to wait until the next interval, including the grace time. private int SetUpTimerInterval() { var currentDateTime = DateTime.Now; int graceTime; DateTime nextIntervalTime; switch (Regularity) { case GroupedObservationEnum.Regularity.Second: nextIntervalTime = new DateTime( currentDateTime.Year, currentDateTime.Month, currentDateTime.Day, currentDateTime.Hour, currentDateTime.Minute, currentDateTime.Second ).AddSeconds(1); graceTime = 500; break; case GroupedObservationEnum.Regularity.Minute: nextIntervalTime = new DateTime( currentDateTime.Year, currentDateTime.Month, currentDateTime.Day, currentDateTime.Hour, currentDateTime.Minute, 0 ).AddMinutes(1); graceTime = 30000; break; case GroupedObservationEnum.Regularity.Day: nextIntervalTime = new DateTime( currentDateTime.Year, currentDateTime.Month, currentDateTime.Day, 0, 0, 0 ).AddDays(1); graceTime = 60000; break; default: nextIntervalTime = new DateTime( currentDateTime.Year, currentDateTime.Month, currentDateTime.Day, currentDateTime.Hour, 0, 0 ).AddHours(1); graceTime = 30000; break; } var timeSpanToNextInterval = nextIntervalTime - currentDateTime; Log.Information( "Obs {Obs}, currentDateTime: {CurrentDateTime}, nextIntervalTime: {NextIntervalTime}, timeSpanToNextInterval: {TimeSpanToNextInterval}, timeSpanToNextInterval.TotalMilliseconds+graceTime: {Tt}", Group, currentDateTime, nextIntervalTime, timeSpanToNextInterval, (int)timeSpanToNextInterval.TotalMilliseconds + graceTime); return (int)timeSpanToNextInterval.TotalMilliseconds + graceTime; } } /// /// Provides static extension methods to enhance WebSocket subscriber functionality. /// public static class WsSubscriberExtension { /// /// Determines whether the specified and patient identifier match the current instance by comparing regularity, since, start time shift, maximum, result sequence, patient identifier, and names (treating a null .Name as an empty string). /// /// The current instance being compared. /// The to compare against the source. /// The patient identifier to match against the source patient identifier. /// true if all compared properties are equal; otherwise, false. public static bool Compare(this WsSubscriberGrouped source, GroupedField r, ObjectId patientId) { return r.Regularity == source.Regularity && r.Since == source.Since && CompareStringShiftList(r.StartTimeShift, source.StartTimeShift) && r.Max == source.Max && r.Result.SequenceEqual(source.Result) && patientId.ToString() == source.PatientId.ToString() && CompareNamesStringList(r.Names, [r.Name ?? string.Empty], source.Names); } /// /// Compares two string shift lists for equality, returning true when both lists are null or when both contain the same elements in the same order, and false when only one of the lists is null. /// /// The target shift list to compare. /// The source shift list to compare against. /// true if both lists are null or if their sequence of strings is equal; otherwise, false. private static bool CompareStringShiftList(List? shift, List? sourceShift) { if (shift != null && sourceShift != null) return sourceShift.SequenceEqual(shift); if (shift == null && sourceShift == null) return true; return false; } /// /// Compares the list against two candidate name lists and returns whether either one matches. /// Falls back to the second list () when the first () is empty or null, or does not match. /// /// The primary candidate list of names to compare against . /// The fallback candidate list of names to compare against when cannot be used. /// The source list of names being compared. /// true if is a sequence match for either or ; otherwise, false. private static bool CompareNamesStringList(List names, List name, List sourceNames) { if (!CollectionsUtils.IsEmptyOrNull(names) && sourceNames.SequenceEqual(names)) return true; return !CollectionsUtils.IsEmptyOrNull(name) && sourceNames.SequenceEqual(name); } /// /// Check if the incoming observation affect to the group and is new info or is irrelevant /// /// public static bool IsNewObservationRelevantForGroup(this WsSubscriberGrouped group, PatientObservation obs) { double.TryParse(obs.Value.ToString(), out var pobsValue); switch (group.Regularity) { case GroupedObservationEnum.Regularity.Shift: case GroupedObservationEnum.Regularity.Hour: var obsInThisHour = group.LastGroupedObservationObs.Where(c => new DateTime(c.Time.Year, c.Time.Month, c.Time.Day, c.Time.Hour, 0, 0) == new DateTime(obs.Time.Year, obs.Time.Month, obs.Time.Day, obs.Time.Hour, 0, 0) && !c.IsFilled); //Obs is in grouped time, don't generate group for observation outside of range //Change to don't cut is obs time is a future var obsInTime = DateTime.UtcNow.AddHours(group.Max) > obs.Time && DateTime.UtcNow.AddHours(group.Max * -1) < obs.Time; //future data is relevant because maybe nothing is inserted in the same hour after it and we cant lose it /* if (DateTime.UtcNow.AddHours(group.Max) > obs.time) { } */ if (!obsInTime) return false; var groupedObservationObsEnumerable = obsInThisHour.ToList(); if (!groupedObservationObsEnumerable.Any() && obsInTime) return true; //If obs in this hour check if is relevant by Result if (groupedObservationObsEnumerable.Any()) foreach (var result in group.Result) switch (result) { case GroupedObservationEnum.Result.Max: var value = groupedObservationObsEnumerable.Max(s => s.Max?.Value); var parsed = double.TryParse(value?.ToString(), out var valueParsed); //actual value is higher, is relevant if (parsed && pobsValue > valueParsed) return true; break; case GroupedObservationEnum.Result.Min: var minValue = groupedObservationObsEnumerable.Min(s => s.Min?.Value); var isParsed = double.TryParse(minValue?.ToString(), out var minValueParsed); //actual value is higher, is relevant if (isParsed && pobsValue < minValueParsed) return true; break; case GroupedObservationEnum.Result.Last: var lastObsInTime = groupedObservationObsEnumerable.OrderBy(t => t.Last?.Time).First(); //is the new latest in the hour, relevant if (lastObsInTime.Time < obs.Time) return true; break; case GroupedObservationEnum.Result.LastFilled: var lastFilledObsInTime = groupedObservationObsEnumerable .OrderBy(t => t.LastFilled?.Time).First(); //is the new latest in the hour, relevant if (lastFilledObsInTime.Time < obs.Time) return true; break; //always is relevant because SUM everything in the hour //TODO halfHour should check if any obs already in the halfHour case GroupedObservationEnum.Result.Sum: case GroupedObservationEnum.Result.Average: case GroupedObservationEnum.Result.HalfHour: return true; } return false; default: return true; } } }