VEU.WathBody/3-app/2-cli/Server.WatchBody.Core/Metrics/MetricsEngine.cs

282 lines
13 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using 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;
/// <summary>
/// Детерминированный расчёт метрик одной тренировки (Ф-12.1, документ
/// метрик п. 2.5, правила 1 и 6) → src.workout_metrics (upsert).
/// Все выборки по athleteId (🛡 изоляция); в план ничего не пишет —
/// это m05 (🛡3); сбой расчёта не должен ломать sync (🛡4, см. m02).
/// </summary>
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;
}
/// <summary>% подходов «по плану» от планового числа подходов сессии дня;
/// план не сопоставлен → доля in_plan среди фактических подходов (spec 3.1).</summary>
private decimal Adherence(Workout workout, List<WorkoutSet> 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);
}
/// <summary>Плановое число подходов сессии дня недели из активной версии;
/// null — плана нет (нет версии / сессии дня / body не парсится).</summary>
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<WorkoutSet> sets) =>
sets.Where(s => s.WeightKg > 0 && s.Reps > 0).Sum(s => s.WeightKg * s.Reps);
private static Dictionary<string, decimal> TonnageByGroup(
List<WorkoutSet> sets, Dictionary<Guid, Exercise> exById, Dictionary<int, string> groupCodeById)
{
var result = new Dictionary<string, decimal>();
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;
}
/// <summary>Эпли (ОВ-7): e1pm = w × (1 + reps/30) по лучшему подходу
/// упражнения (max вес, при равном — max reps), spec 3.4.</summary>
private static Dictionary<string, decimal> E1pmByExercise(List<WorkoutSet> sets)
{
var best = new Dictionary<Guid, WorkoutSet>();
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));
}
/// <summary>Из фактов .fit (m06), что есть; фактов нет — телефонные
/// avg_hr/hr_recovery_s (spec 3.5).</summary>
private static string HrMetrics(Workout workout)
{
var result = new Dictionary<string, decimal>();
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<object> RedFlags(
Workout workout, List<WorkoutSet> sets, DateOnly date,
Dictionary<DateOnly, DailyLog> logs, Dictionary<Guid, Exercise> exById)
{
var flags = new List<object>();
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<string, object> { ["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<string, object> { ["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<string, object> Flag(string name, DateOnly date) =>
new() { ["flag"] = name, ["date"] = date };
/// <summary>Серия «пульс покоя ≥ базовый + HrRestingRise» назад от дня
/// тренировки, включая его (spec 03 §2 #3). Начало серии — среди
/// последних LightDayCount записей; базовый = среднее за окно до начала
/// серии, значений меньше HrBaselineMin → серия не считается.</summary>
private List<DateOnly> HrRestingRun(DateOnly date, Dictionary<DateOnly, DailyLog> logs)
{
List<DateOnly> 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;
}
/// <summary>Базовый пульс покоя: среднее hr_resting за окно до дня start;
/// значений меньше HrBaselineMin → null.</summary>
private int? HrBaseline(DateOnly start, Dictionary<DateOnly, DailyLog> logs)
{
var window = new List<int>();
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);
}
/// <summary>Даты серии условия назад от date до start (start null — без
/// нижней границы): запись с условием продлевает серию, запись без
/// условия рвёт, день без записи не рвёт и не удлиняет (spec 03).</summary>
private List<DateOnly> ConditionRun(
DateOnly date, Dictionary<DateOnly, DailyLog> logs,
Func<DailyLog, bool> hasValue, Func<DailyLog, bool> matches, DateOnly? start)
{
var run = new List<DateOnly>();
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;
}
/// <summary>Боль в колене ≥ KneePainMin в KneeRepeatSessions занятиях
/// подряд (текущая + предыдущие по started_at), группа «ноги» (spec 03 §2 #6).</summary>
private bool KneePainRepeat(Workout workout, List<WorkoutSet> sets, Dictionary<Guid, Exercise> 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<WorkoutSet> sets, Dictionary<Guid, Exercise> 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);
}