Closes #74 Adds app/services/formula_engine.py with three pure formula functions (compute_batter_value, compute_sp_value, compute_rp_value), a dispatch helper (compute_value_for_track), and a tier classifier (tier_from_value). Tier boundaries and thresholds match the locked seed data from WP-03. Note: pitcher formulas use stats.k (not stats.so) to match the PlayerSeasonStats model field name introduced in WP-02. 19 unit tests in tests/test_formula_engine.py — all pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
106 lines
2.9 KiB
Python
106 lines
2.9 KiB
Python
"""Formula engine for evolution value computation (WP-09).
|
||
|
||
Three pure functions that compute a numeric evolution value from career stats,
|
||
plus helpers for formula dispatch and tier classification.
|
||
|
||
Stats attributes expected by each formula:
|
||
compute_batter_value: pa, hits, doubles, triples, hr
|
||
compute_sp_value: outs, k (k = pitcher strikeouts, from PlayerSeasonStats)
|
||
compute_rp_value: outs, k
|
||
"""
|
||
|
||
from typing import Protocol
|
||
|
||
|
||
class BatterStats(Protocol):
|
||
pa: int
|
||
hits: int
|
||
doubles: int
|
||
triples: int
|
||
hr: int
|
||
|
||
|
||
class PitcherStats(Protocol):
|
||
outs: int
|
||
k: int
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Core formula functions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def compute_batter_value(stats) -> float:
|
||
"""PA + (TB × 2) where TB = 1B + 2×2B + 3×3B + 4×HR."""
|
||
singles = stats.hits - stats.doubles - stats.triples - stats.hr
|
||
tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr
|
||
return float(stats.pa + tb * 2)
|
||
|
||
|
||
def compute_sp_value(stats) -> float:
|
||
"""IP + K where IP = outs / 3. Uses stats.k (pitcher strikeouts)."""
|
||
return stats.outs / 3 + stats.k
|
||
|
||
|
||
def compute_rp_value(stats) -> float:
|
||
"""IP + K (same formula as SP; thresholds differ). Uses stats.k."""
|
||
return stats.outs / 3 + stats.k
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Dispatch and tier helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_FORMULA_DISPATCH = {
|
||
"batter": compute_batter_value,
|
||
"sp": compute_sp_value,
|
||
"rp": compute_rp_value,
|
||
}
|
||
|
||
|
||
def compute_value_for_track(card_type: str, stats) -> float:
|
||
"""Dispatch to the correct formula function by card_type.
|
||
|
||
Args:
|
||
card_type: One of 'batter', 'sp', 'rp'.
|
||
stats: Object with the attributes required by the formula.
|
||
|
||
Raises:
|
||
ValueError: If card_type is not recognised.
|
||
"""
|
||
fn = _FORMULA_DISPATCH.get(card_type)
|
||
if fn is None:
|
||
raise ValueError(f"Unknown card_type: {card_type!r}")
|
||
return fn(stats)
|
||
|
||
|
||
def tier_from_value(value: float, track) -> int:
|
||
"""Return the evolution tier (0–4) for a computed value against a track.
|
||
|
||
Tier boundaries are inclusive on the lower end:
|
||
T0: value < t1
|
||
T1: t1 <= value < t2
|
||
T2: t2 <= value < t3
|
||
T3: t3 <= value < t4
|
||
T4: value >= t4
|
||
|
||
Args:
|
||
value: Computed formula value.
|
||
track: Object (or dict-like) with t1, t2, t3, t4 attributes/keys.
|
||
"""
|
||
# Support both attribute-style (Peewee model) and dict (seed fixture)
|
||
if isinstance(track, dict):
|
||
t1, t2, t3, t4 = track["t1"], track["t2"], track["t3"], track["t4"]
|
||
else:
|
||
t1, t2, t3, t4 = track.t1, track.t2, track.t3, track.t4
|
||
|
||
if value >= t4:
|
||
return 4
|
||
if value >= t3:
|
||
return 3
|
||
if value >= t2:
|
||
return 2
|
||
if value >= t1:
|
||
return 1
|
||
return 0
|