using System.Text.Json; using Server.WatchBody.Core.Dal.Ref.Domain; using Server.WatchBody.Core.Dal.Ref.Repository; using Server.WatchBody.Core.Dal.Src.Domain; using Server.WatchBody.Core.Dal.Src.Repository; using Server.WatchBody.Core.Programs; namespace Server.WatchBody.Core.Metrics; /// /// Детерминированный расчёт метрик одной тренировки (Ф-12.1, документ /// метрик п. 2.5, правила 1 и 6) → src.workout_metrics (upsert). /// Все выборки по athleteId (🛡 изоляция); в план ничего не пишет — /// это m05 (🛡3); сбой расчёта не должен ломать sync (🛡4, см. m02). /// public class MetricsEngine( IWorkoutRepository workouts, IExerciseRepository exercises, IExerciseGroupRepository groups, IDailyLogRepository dailyLogs, IWorkoutMetricsRepository metrics, ProgramStore programs, MetricsConfig safety) { private static readonly string[] DayNames = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; public WorkoutMetrics ComputeForWorkout(int athleteId, Guid workoutId) { var workout = workouts.Get(athleteId, workoutId) ?? throw new InvalidOperationException( $"тренировка {workoutId} не найдена (athlete {athleteId})"); var sets = workout.Sets.ToList(); var exById = exercises.Select(new ExerciseFilter { AthleteId = athleteId }) .ToDictionary(e => e.Id); var groupCodeById = groups.Select().ToDictionary(g => g.Id, g => g.Code); var date = DateOnly.FromDateTime(workout.StartedAt); var logs = dailyLogs.Select(new DailyLogFilter { AthleteId = athleteId, DateFrom = date.AddDays(-safety.HrBaselineDays - safety.LightDayCount), DateTo = date, }) .GroupBy(l => l.Date).ToDictionary(g => g.Key, g => g.First()); var result = new WorkoutMetrics { WorkoutId = workoutId, AthleteId = athleteId, Adherence = Adherence(workout, sets, date), Tonnage = Tonnage(sets), TonnageByGroup = JsonSerializer.Serialize(TonnageByGroup(sets, exById, groupCodeById)), E1pmByExercise = JsonSerializer.Serialize(E1pmByExercise(sets)), HrMetrics = HrMetrics(workout), RedFlags = JsonSerializer.Serialize(RedFlags(workout, sets, date, logs, exById)), }; metrics.Upsert(athleteId, result); return result; } /// % подходов «по плану» от планового числа подходов сессии дня; /// план не сопоставлен → доля in_plan среди фактических подходов (spec 3.1). private decimal Adherence(Workout workout, List sets, DateOnly date) { var inPlan = sets.Count(s => s.InPlan); var planned = PlannedSets(workout, date); if (planned != null) return planned.Value == 0 ? 0 : Percent(inPlan, planned.Value); return sets.Count == 0 ? 0 : Percent(inPlan, sets.Count); } /// Плановое число подходов сессии дня недели из активной версии; /// null — плана нет (нет версии / сессии дня / body не парсится). private int? PlannedSets(Workout workout, DateOnly date) { var active = programs.GetActive(workout.AthleteId); if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null; try { using var doc = JsonDocument.Parse(active.Body); var day = DayNames[((int)date.DayOfWeek + 6) % 7]; foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray()) foreach (var session in week.GetProperty("sessions").EnumerateArray()) { if (!session.TryGetProperty("day", out var d) || !string.Equals(d.GetString(), day, StringComparison.OrdinalIgnoreCase)) continue; var count = 0; foreach (var exercise in session.GetProperty("exercises").EnumerateArray()) count += exercise.GetProperty("sets").GetArrayLength(); return count; } return null; } catch (Exception) { return null; // body не парсится — метрики считаем без плана } } private static decimal Tonnage(List sets) => sets.Where(s => s.WeightKg > 0 && s.Reps > 0).Sum(s => s.WeightKg * s.Reps); private static Dictionary TonnageByGroup( List sets, Dictionary exById, Dictionary groupCodeById) { var result = new Dictionary(); foreach (var set in sets) { if (set.WeightKg <= 0 || set.Reps <= 0) continue; if (set.ExerciseId == null || !exById.TryGetValue(set.ExerciseId.Value, out var ex)) continue; var code = groupCodeById.GetValueOrDefault(ex.GroupId, $"group_{ex.GroupId}"); result[code] = result.GetValueOrDefault(code) + set.WeightKg * set.Reps; } return result; } /// Эпли (ОВ-7): e1pm = w × (1 + reps/30) по лучшему подходу /// упражнения (max вес, при равном — max reps), spec 3.4. private static Dictionary E1pmByExercise(List sets) { var best = new Dictionary(); foreach (var set in sets) { if (set.ExerciseId == null || set.WeightKg <= 0 || set.Reps <= 0) continue; if (!best.TryGetValue(set.ExerciseId.Value, out var b) || set.WeightKg > b.WeightKg || (set.WeightKg == b.WeightKg && set.Reps > b.Reps)) best[set.ExerciseId.Value] = set; } return best.ToDictionary( p => p.Key.ToString(), p => Math.Round(p.Value.WeightKg * (1m + p.Value.Reps / 30m), 1, MidpointRounding.AwayFromZero)); } /// Из фактов .fit (m06), что есть; фактов нет — телефонные /// avg_hr/hr_recovery_s (spec 3.5). private static string HrMetrics(Workout workout) { var result = new Dictionary(); if (!string.IsNullOrWhiteSpace(workout.FactsJson)) { try { using var doc = JsonDocument.Parse(workout.FactsJson); foreach (var key in new[] { "avg_hr", "peak_hr", "rest_between_s" }) if (doc.RootElement.TryGetProperty(key, out var v) && v.TryGetDecimal(out var d)) result[key] = d; } catch (JsonException) { // битые факты — метрики по телефонным полям } } if (result.Count == 0) { if (workout.AvgHr != null) result["avg_hr"] = workout.AvgHr.Value; if (workout.HrRecoveryS != null) result["hr_recovery_s"] = workout.HrRecoveryS.Value; } return JsonSerializer.Serialize(result); } private List RedFlags( Workout workout, List sets, DateOnly date, Dictionary logs, Dictionary exById) { var flags = new List(); var day = logs.GetValueOrDefault(date); if (day != null) { if ((day.BpSys != null && day.BpSys > safety.BpCancelSys) || (day.BpDias != null && day.BpDias > safety.BpCancelDias)) flags.Add(Flag("bp_over_150_90", date)); else if ((day.BpSys != null && day.BpSys >= safety.BpElevatedSys) || (day.BpDias != null && day.BpDias >= safety.BpElevatedDias)) flags.Add(Flag("bp_elevated", date)); } var hrRun = HrRestingRun(date, logs); if (hrRun.Count >= safety.HrRestingDays) flags.Add(new Dictionary { ["flag"] = "hr_resting_up", ["dates"] = hrRun }); var wbRun = ConditionRun(date, logs, l => l.Wellbeing != null, l => l.Wellbeing <= safety.WellbeingLow, null); if (wbRun.Count >= safety.WellbeingDays) flags.Add(new Dictionary { ["flag"] = "wellbeing_low", ["dates"] = wbRun }); if (hrRun.Count >= safety.LightDayCount || wbRun.Count >= safety.LightDayCount) flags.Add(Flag("cancel_recommendation", date)); if (KneePainRepeat(workout, sets, exById)) flags.Add(Flag("knee_pain_repeat", date)); return flags; } private static Dictionary Flag(string name, DateOnly date) => new() { ["flag"] = name, ["date"] = date }; /// Серия «пульс покоя ≥ базовый + HrRestingRise» назад от дня /// тренировки, включая его (spec 03 §2 #3). Начало серии — среди /// последних LightDayCount записей; базовый = среднее за окно до начала /// серии, значений меньше HrBaselineMin → серия не считается. private List HrRestingRun(DateOnly date, Dictionary logs) { List best = []; for (var back = 0; back < safety.LightDayCount; back++) { var start = date.AddDays(-back); var baseline = HrBaseline(start, logs); if (baseline == null) continue; var run = ConditionRun(date, logs, l => l.HrResting != null, l => l.HrResting >= baseline + safety.HrRestingRise, start); if (run.Count > best.Count) best = run; } return best; } /// Базовый пульс покоя: среднее hr_resting за окно до дня start; /// значений меньше HrBaselineMin → null. private int? HrBaseline(DateOnly start, Dictionary logs) { var window = new List(); for (var day = start.AddDays(-safety.HrBaselineDays); day < start; day = day.AddDays(1)) if (logs.TryGetValue(day, out var log) && log.HrResting != null) window.Add(log.HrResting.Value); return window.Count < safety.HrBaselineMin ? null : (int)Math.Round(window.Average(), MidpointRounding.AwayFromZero); } /// Даты серии условия назад от date до start (start null — без /// нижней границы): запись с условием продлевает серию, запись без /// условия рвёт, день без записи не рвёт и не удлиняет (spec 03). private List ConditionRun( DateOnly date, Dictionary logs, Func hasValue, Func matches, DateOnly? start) { var run = new List(); for (var day = date; ; day = day.AddDays(-1)) { if (start != null && day < start) break; if (day < date.AddDays(-safety.HrBaselineDays - safety.LightDayCount)) break; if (!logs.TryGetValue(day, out var log) || !hasValue(log)) continue; if (!matches(log)) break; run.Add(day); if (run.Count >= safety.LightDayCount) break; } return run; } /// Боль в колене ≥ KneePainMin в KneeRepeatSessions занятиях /// подряд (текущая + предыдущие по started_at), группа «ноги» (spec 03 §2 #6). private bool KneePainRepeat(Workout workout, List sets, Dictionary exById) { if (!HasLegKneePain(sets, exById)) return false; var need = safety.KneeRepeatSessions - 1; if (need <= 0) return true; var previous = workouts.Select(new WorkoutFilter { AthleteId = workout.AthleteId, DateTo = DateOnly.FromDateTime(workout.StartedAt).AddDays(-1), }) .OrderByDescending(w => w.StartedAt) .Take(need) .ToList(); return previous.Count == need && previous.All(w => HasLegKneePain(w.Sets.ToList(), exById)); } private bool HasLegKneePain(List sets, Dictionary exById) => sets.Any(s => s.KneePain != null && s.KneePain >= safety.KneePainMin && s.ExerciseId != null && exById.TryGetValue(s.ExerciseId.Value, out var ex) && ex.GroupId == ExerciseGroups.Legs); private static decimal Percent(int numerator, int denominator) => Math.Round(numerator * 100m / denominator, 1, MidpointRounding.AwayFromZero); }