paper-dynasty-database/app/services/formula_engine.py
Cal Corum 40e988ac9d feat: formula engine for evolution value computation (WP-09)
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>
2026-03-12 19:34:40 -05:00

106 lines
2.9 KiB
Python
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.

"""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 (04) 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