282 lines
13 KiB
C#
282 lines
13 KiB
C#
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);
|
||
}
|