defmodule TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.RotationQuality do @moduledoc """ Scores AVC's Rotation Quality metric across the full 240-minute rotation. Every player's Adjusted EPM is multiplied by the shared fatigue-adjusted minutes curve, then by their season-availability factor (`TradeMachine.GMGradeV2.Availability` — `1.0` unless the user set expected games). The contributions are summed and divided by 240 before conversion against the frozen `rotation_quality` benchmark. The denominator stays 240 when a player is discounted. That is the point: a rotation slot that is empty for half the season really does lower the team's season-long per-minute strength, and renormalizing to the available minutes would divide the discount straight back out. """ alias TradeMachine.GMGradeV2.Benchmark alias TradeMachine.GMGradeV2.ChallengeSnapshot alias TradeMachine.GMGradeV2.Fatigue alias TradeMachine.GMGradeV2.Percentile alias TradeMachine.GMGradeV2.Pillars.CompetitiveStrength.MetricInputs alias TradeMachine.GMGradeV2.Result.MetricResult alias TradeMachine.GMGradeV2.RotationValidation @doc "Returns a full-precision Rotation Quality metric result." @spec score(map(), ChallengeSnapshot.t()) :: MetricResult.t() def score( %{rotation: rotation} = scenario_state, %ChallengeSnapshot{benchmark: %Benchmark{} = benchmark} = snapshot ) do entries = MetricInputs.rotation_entries(rotation) impacts = ChallengeSnapshot.section(snapshot, :players) names = MetricInputs.roster_names(Map.get(scenario_state, :roster, [])) contributions = rotation_contributions(entries, impacts, names) raw_value = average(contributions) benchmark_values = MetricInputs.benchmark_values(benchmark, :rotation_quality) assigned_minutes = Enum.sum(Enum.map(contributions, & &1.assigned_minutes)) %MetricResult{ key: :rotation_quality, version: benchmark.version, score: Percentile.score(raw_value, benchmark_values), inputs: %{ raw: raw_value, assigned_minutes: assigned_minutes, effective_minutes: Enum.sum(Enum.map(contributions, & &1.effective_minutes)), denominator_minutes: RotationValidation.total_minutes(), benchmark: benchmark_values }, supporting: %{contributions: contributions}, weights: %{ minutes_1_36: 1.0, minutes_37_40: 0.75, minutes_41_44: 0.5, minutes_45_48: 0.25 }, fallbacks: MetricInputs.impact_fallbacks(contributions, impacts), explanation: "Rotation Quality averages fatigue-adjusted impact across " <> "#{length(contributions)} rotation players and #{assigned_minutes} assigned minutes." } end @doc "Calculates Rotation Quality before percentile conversion." @spec raw(term(), map()) :: float() def raw(rotation, impacts) when is_map(impacts) do rotation |> MetricInputs.rotation_entries() |> rotation_contributions(impacts, %{}) |> average() end defp rotation_contributions(entries, impacts, names) do Enum.map(entries, fn entry -> player_id = MetricInputs.player_id(entry) assigned = MetricInputs.minutes(entry) evidence = MetricInputs.player_impact!(impacts, player_id) {availability, annotation} = MetricInputs.availability(entry, evidence) adjusted_epm = MetricInputs.impact_number!(impacts, player_id, :adjusted_epm) effective = Fatigue.effective_minutes(assigned) Map.merge( %{ player_id: player_id, name: MetricInputs.player_name(names, player_id), assigned_minutes: assigned, effective_minutes: effective, adjusted_epm: adjusted_epm, contribution: Fatigue.weighted_contribution(adjusted_epm, assigned) * availability, replacement?: false }, annotation ) end) end defp average(contributions) do total = Enum.reduce(contributions, 0.0, &(&2 + &1.contribution)) total / RotationValidation.total_minutes() end end