246 lines
12 KiB
C#
246 lines
12 KiB
C#
using System.Text.Json;
|
||
using System.Text.Json.Nodes;
|
||
using Server.WatchBody.Core.Dal.Src.Domain;
|
||
using Server.WatchBody.Core.Dal.Src.Repository;
|
||
|
||
namespace Server.WatchBody.Core.Programs;
|
||
|
||
/// <summary>
|
||
/// Детерминированные правила прогрессии (Ф-12.2) и делоада (Ф-12.4):
|
||
/// результат — предложение draft-версии (author=rules) через
|
||
/// ProgramStore; активация — только человеком (🛡 vision 2.4).
|
||
/// Ограничения атлета — через персональную библиотеку: is_active=false
|
||
/// не попадает в draft, пара-замена подставляется при наличии
|
||
/// (module_reqs разд. 2.4). 🛡 Активная версия не меняется; в факт
|
||
/// ничего не пишет.
|
||
/// </summary>
|
||
public class ProgressionRules(
|
||
IWorkoutRepository workouts,
|
||
IExerciseRepository exercises,
|
||
ProgramStore programs,
|
||
ProgressionConfig config)
|
||
{
|
||
private static readonly string[] DayNames =
|
||
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
||
|
||
/// <summary>Микроправка весов после тренировки (spec 02): правила 1–5
|
||
/// по порядку, первое сработавшее; draft одна на активную версию.</summary>
|
||
public ProgramVersion? EvaluateAfterWorkout(int athleteId, Guid workoutId)
|
||
{
|
||
var active = programs.GetActive(athleteId);
|
||
if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null;
|
||
if (HasRulesDraft(athleteId, active.Version)) return null;
|
||
var workout = workouts.Get(athleteId, workoutId);
|
||
if (workout == null) return null;
|
||
|
||
var byName = NameIndex(athleteId);
|
||
var history = workouts
|
||
.Select(new WorkoutFilter { AthleteId = athleteId })
|
||
.OrderByDescending(w => w.StartedAt)
|
||
.ToList();
|
||
|
||
var body = JsonNode.Parse(active.Body) as JsonObject
|
||
?? throw new InvalidOperationException("body активной версии не является объектом");
|
||
var rationale = new List<string>();
|
||
|
||
foreach (var week in Weeks(body))
|
||
foreach (var session in Sessions(week))
|
||
{
|
||
if (session["exercises"] is not JsonArray exerciseNodes) continue;
|
||
foreach (var exerciseNode in exerciseNodes.OfType<JsonObject>().ToList())
|
||
{
|
||
var planId = exerciseNode["exercise_id"]?.GetValue<string>();
|
||
if (planId == null || !byName.TryGetValue(planId, out var ex))
|
||
{
|
||
rationale.Add($"{planId}: нет в библиотеке атлета — решение не генерируется");
|
||
continue;
|
||
}
|
||
if (!ex.IsActive)
|
||
{
|
||
ApplyExclusion(exerciseNodes, exerciseNode, ex, byName, rationale);
|
||
continue;
|
||
}
|
||
var decision = Decide(workout, ex, history, Sets(exerciseNode));
|
||
if (decision == null) continue;
|
||
ApplyDecision(exerciseNode, decision.Value, rationale);
|
||
}
|
||
// сессия только из исключённых упражнений → из плана целиком
|
||
if (session["exercises"] is JsonArray remaining && remaining.Count == 0 &&
|
||
session.Parent is JsonArray sessionList)
|
||
sessionList.Remove(session);
|
||
}
|
||
|
||
if (rationale.Count == 0) return null;
|
||
var draft = programs.CreateDraft(athleteId,
|
||
body.ToJsonString(), ProgramVersionAuthors.Rules,
|
||
"Микроправка после тренировки: " + string.Join("; ", rationale));
|
||
return draft;
|
||
}
|
||
|
||
/// <summary>Делоад (spec 03): DeloadMaxWeeks без делаода → draft
|
||
/// (веса ×0.6, подходы −1, session_kind=deload в week 1).</summary>
|
||
public ProgramVersion? CheckDeload(int athleteId, DateOnly today)
|
||
{
|
||
var active = programs.GetActive(athleteId);
|
||
if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null;
|
||
if (HasRulesDraft(athleteId, active.Version)) return null;
|
||
|
||
var all = workouts.Select(new WorkoutFilter { AthleteId = athleteId })
|
||
.OrderByDescending(w => w.StartedAt).ToList();
|
||
if (all.Count == 0) return null;
|
||
// атлет давно не тренируется — это домен break, не делоад
|
||
if (today > DateOnly.FromDateTime(all[0].StartedAt).AddDays(config.DeloadMaxWeeks * 7))
|
||
return null;
|
||
|
||
var lastDeload = all.FirstOrDefault(w => w.SessionKind == SessionKinds.Deload);
|
||
var anchor = lastDeload != null
|
||
? DateOnly.FromDateTime(lastDeload.StartedAt)
|
||
: DateOnly.FromDateTime(active.CreatedAt);
|
||
if (today < anchor.AddDays(config.DeloadMaxWeeks * 7)) return null;
|
||
|
||
var body = JsonNode.Parse(active.Body) as JsonObject
|
||
?? throw new InvalidOperationException("body активной версии не является объектом");
|
||
var firstWeek = Weeks(body).FirstOrDefault();
|
||
if (firstWeek == null) return null;
|
||
foreach (var session in Sessions(firstWeek))
|
||
{
|
||
session["session_kind"] = SessionKinds.Deload;
|
||
if (session["exercises"] is not JsonArray exerciseNodes) continue;
|
||
foreach (var exerciseNode in exerciseNodes.OfType<JsonObject>())
|
||
foreach (var set in Sets(exerciseNode))
|
||
if (WeightOf(set) is decimal weight)
|
||
set["weight_kg"] = Halves(weight * 0.6m);
|
||
TrimSets(session, remove: 1);
|
||
}
|
||
|
||
return programs.CreateDraft(athleteId, body.ToJsonString(),
|
||
ProgramVersionAuthors.Rules,
|
||
$"Делоад: {config.DeloadMaxWeeks} недель без лёгкой недели — объём −40–50 %");
|
||
}
|
||
|
||
// ---------- микроправка: решение по одному упражнению ----------
|
||
|
||
private (decimal? newWeight, int? newReps, string why)? Decide(
|
||
Workout workout, Exercise ex, List<Workout> history, List<JsonObject> planSets)
|
||
{
|
||
if (planSets.Count == 0) return null;
|
||
var planWeight = WeightOf(planSets[0]);
|
||
var planReps = planSets[0]["reps"]?.GetValue<int?>();
|
||
if (planWeight == null || planReps == null) return null; // калибровка
|
||
|
||
var setsOf = (Workout w) => w.Sets
|
||
.Where(s => s.ExerciseId == ex.Id && s.Source != WorkoutSetSources.Edit)
|
||
.ToList();
|
||
var current = setsOf(workout);
|
||
if (current.Count == 0) return null;
|
||
|
||
var step = ex.IsBase ? config.BaseStepKg : config.IsoStepKg;
|
||
|
||
// правило 1: 2 тренировки подряд все подходы «по плану» при RPE ≤ 7
|
||
var withExercise = history.Where(w => setsOf(w).Count > 0).Take(2).ToList();
|
||
if (withExercise.Count == 2 && withExercise.All(ClosedEasy))
|
||
return (Halves(planWeight.Value + step), null,
|
||
$"+{step} (2 тренировки подряд закрыты при RPE≤7)");
|
||
|
||
// правило 2: RPE ≥ 9 в 2+ сетах
|
||
if (current.Count(s => s.Rpe >= 9) >= 2)
|
||
return (Halves(planWeight.Value * (1 - config.BackoffPercent / 100m)), null,
|
||
$"−{config.BackoffPercent}% (RPE≥9 в {current.Count(s => s.Rpe >= 9)} сетах)");
|
||
|
||
// правило 3: верхняя граница диапазона повторений (15–20)
|
||
if (current.All(s => s.InPlan) && planReps >= 15)
|
||
return (Halves(planWeight.Value + step), 12,
|
||
$"+{step}, повторения к 12 (верх диапазона достигнут)");
|
||
|
||
// правило 4: ≥ 2 подходов не дотянуты по повторениям
|
||
var shortfalls = current.Count(s => s.Reps < planReps);
|
||
if (shortfalls >= 2)
|
||
return (Halves(planWeight.Value * (1 - config.BackoffPercent / 100m)), null,
|
||
$"−{config.BackoffPercent}% ({shortfalls} подходов не дотянуты)");
|
||
|
||
return null; // правило 5: без изменения
|
||
}
|
||
|
||
private static bool ClosedEasy(Workout w) =>
|
||
w.Sets.Where(s => s.Source != WorkoutSetSources.Edit).All(
|
||
s => s.InPlan && s.Rpe != null && s.Rpe <= 7);
|
||
|
||
private static void ApplyDecision(
|
||
JsonObject exerciseNode, (decimal? newWeight, int? newReps, string why) decision,
|
||
List<string> rationale)
|
||
{
|
||
var name = exerciseNode["exercise_id"]?.GetValue<string>();
|
||
var from = WeightOf(Sets(exerciseNode).FirstOrDefault());
|
||
foreach (var set in Sets(exerciseNode))
|
||
{
|
||
if (decision.newWeight != null)
|
||
set["weight_kg"] = decision.newWeight;
|
||
if (decision.newReps != null)
|
||
set["reps"] = decision.newReps;
|
||
}
|
||
rationale.Add($"{name}: {from} → {decision.newWeight ?? from} ({decision.why})");
|
||
}
|
||
|
||
/// <summary>Ограничения (spec 02 §4): неактивное упражнение → замена
|
||
/// из пары (активная, та же группа) или удаление из сессии.</summary>
|
||
private void ApplyExclusion(
|
||
JsonArray siblings, JsonObject exerciseNode, Exercise ex,
|
||
Dictionary<string, Exercise> byName, List<string> rationale)
|
||
{
|
||
var replacement = ex.ReplacementIds
|
||
.Select(id => byName.Values.FirstOrDefault(e => e.Id == id))
|
||
.FirstOrDefault(r => r != null && r.IsActive);
|
||
if (replacement != null)
|
||
{
|
||
exerciseNode["exercise_id"] = replacement.Name;
|
||
rationale.Add($"{ex.Name}: исключено (is_active=false) → замена {replacement.Name}");
|
||
return;
|
||
}
|
||
siblings.Remove(exerciseNode);
|
||
rationale.Add($"{ex.Name}: исключено (is_active=false), замены нет");
|
||
}
|
||
|
||
// ---------- делоад/общие помощники ----------
|
||
|
||
private static void TrimSets(JsonObject session, int remove)
|
||
{
|
||
if (session["exercises"] is not JsonArray exerciseNodes) return;
|
||
foreach (var exerciseNode in exerciseNodes.OfType<JsonObject>())
|
||
{
|
||
if (exerciseNode["sets"] is not JsonArray sets) continue;
|
||
while (sets.Count > 1 && remove > 0) { sets.RemoveAt(sets.Count - 1); remove--; }
|
||
}
|
||
}
|
||
|
||
// материализуем: ApplyExclusion удаляет узлы во время обхода
|
||
private static List<JsonObject> Weeks(JsonObject body) =>
|
||
body["weeks"] is JsonArray weeks ? weeks.OfType<JsonObject>().ToList() : [];
|
||
|
||
private static List<JsonObject> Sessions(JsonObject week) =>
|
||
week?["sessions"] is JsonArray sessions ? sessions.OfType<JsonObject>().ToList() : [];
|
||
|
||
private static List<JsonObject> Sets(JsonObject exerciseNode) =>
|
||
exerciseNode["sets"] is JsonArray sets ? sets.OfType<JsonObject>().ToList() : [];
|
||
|
||
private static decimal? WeightOf(JsonObject? set)
|
||
{
|
||
if (set?["weight_kg"] is not JsonValue value) return null;
|
||
return value.TryGetValue(out decimal weight) ? weight : null;
|
||
}
|
||
|
||
private static decimal Halves(decimal value) => Math.Round(value * 2m, MidpointRounding.AwayFromZero) / 2m;
|
||
|
||
private Dictionary<string, Exercise> NameIndex(int athleteId) =>
|
||
exercises.Select(new ExerciseFilter { AthleteId = athleteId })
|
||
.GroupBy(e => e.Name, StringComparer.Ordinal)
|
||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||
|
||
/// <summary>Rules-draft, основанный на активной версии или новее
|
||
/// (spec 02 §5.3) — пока он есть, новых предложений не создаём.</summary>
|
||
private bool HasRulesDraft(int athleteId, int activeVersion) =>
|
||
programs.ListVersions(athleteId).Any(v =>
|
||
v.Author == ProgramVersionAuthors.Rules &&
|
||
v.Status == ProgramVersionStatuses.Draft &&
|
||
(v.BasedOnVersion ?? 0) >= activeVersion);
|
||
}
|