VEU.WathBody/3-app/2-cli/Server.WatchBody.Core/Programs/ProgressionRules.cs

246 lines
12 KiB
C#
Raw 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 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);
}