paper-dynasty-card-creation/pitchers/models.py
Cal Corum 2bf3a6cee7 Fix SLG formula drift in extracted rating models
The extracted batting and pitching models used malformed SLG equations that double-counted and omitted outcomes, skewing slash lines. Align formulas with canonical weighting and add regression tests to prevent recurrence.

Co-Authored-By: Claude GPT-5.3-Codex <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-26 07:47:15 -06:00

301 lines
12 KiB
Python

import math
import pydantic
from creation_helpers import mround, sanitize_chance_output
from typing import Literal
from exceptions import logger
class PitchingCardRatingsModel(pydantic.BaseModel):
pitchingcard_id: int
pit_hand: Literal['R', 'L']
vs_hand: Literal['R', 'L']
all_hits: float = 0.0
all_other_ob: float = 0.0
all_outs: float = 0.0
rem_singles: float = 0.0
rem_xbh: float = 0.0
rem_hr: float = 0.0
rem_doubles: float = 0.0
hard_rate: float
med_rate: float
soft_rate: float
# pull_rate: float
# center_rate: float
# slap_rate: float
homerun: float = 0.0
bp_homerun: float = 0.0
triple: float = 0.0
double_three: float = 0.0
double_two: float = 0.0
double_cf: float = 0.0
single_two: float = 0.0
single_one: float = 0.0
single_center: float = 0.0
bp_single: float = 0.0
hbp: float = 0.0
walk: float = 0.0
strikeout: float = 0.0
rem_flyballs: float = 0.0
flyout_lf_b: float = 0.0
flyout_cf_b: float = 0.0
flyout_rf_b: float = 0.0
rem_groundballs: float = 0.0
groundout_a: float = 0.0
groundout_b: float = 0.0
xcheck_p: float = float(1.0)
xcheck_c: float = float(3.0)
xcheck_1b: float = float(2.0)
xcheck_2b: float = float(6.0)
xcheck_3b: float = float(3.0)
xcheck_ss: float = float(7.0)
xcheck_lf: float = float(2.0)
xcheck_cf: float = float(3.0)
xcheck_rf: float = float(2.0)
avg: float = 0.0
obp: float = 0.0
slg: float = 0.0
def total_chances(self):
return mround(sum([
self.homerun, self.bp_homerun, self.triple, self.double_three, self.double_two, self.double_cf,
self.single_two, self.single_one, self.single_center, self.bp_single, self.hbp, self.walk, self.strikeout,
self.flyout_lf_b, self.flyout_cf_b, self.flyout_rf_b, self.groundout_a, self.groundout_b, self.xcheck_p,
self.xcheck_c, self.xcheck_1b, self.xcheck_2b, self.xcheck_3b, self.xcheck_ss, self.xcheck_lf,
self.xcheck_cf, self.xcheck_rf
]))
def total_hits(self):
return mround(sum([
self.homerun, self.bp_homerun, self.triple, self.double_three, self.double_two, self.double_cf,
self.single_two, self.single_one, self.single_center, self.bp_single
]))
def total_ob(self):
return mround(sum([
self.homerun, self.bp_homerun, self.triple, self.double_three, self.double_two, self.double_cf,
self.single_two, self.single_one, self.single_center, self.bp_single, self.hbp, self.walk
]))
def total_outs(self):
return mround(sum([
self.strikeout, self.flyout_lf_b, self.flyout_cf_b, self.flyout_rf_b, self.groundout_a, self.groundout_b,
self.xcheck_p, self.xcheck_c, self.xcheck_1b, self.xcheck_2b, self.xcheck_3b, self.xcheck_ss,
self.xcheck_lf, self.xcheck_cf, self.xcheck_rf
]))
def calculate_rate_stats(self):
self.avg = mround(self.total_hits() / 108, prec=5, base=0.00001)
self.obp = mround((self.total_hits() + self.hbp + self.walk) / 108, prec=5, base=0.00001)
self.slg = mround((
self.homerun * 4 + self.bp_homerun * 2 + self.triple * 3 + self.double_three * 2 +
self.double_two * 2 + self.double_cf * 2 + self.single_two + self.single_one +
self.single_center + self.bp_single / 2) / 108, prec=5, base=0.00001)
def custom_to_dict(self):
self.calculate_rate_stats()
return {
'pitchingcard_id': self.pitchingcard_id,
'vs_hand': self.vs_hand,
'homerun': self.homerun,
'bp_homerun': self.bp_homerun,
'triple': self.triple,
'double_three': self.double_three,
'double_two': self.double_two,
'double_cf': self.double_cf,
'single_two': self.single_two,
'single_one': self.single_one,
'single_center': self.single_center,
'bp_single': self.bp_single,
'hbp': self.hbp,
'walk': self.walk,
'strikeout': self.strikeout,
'flyout_lf_b': self.flyout_lf_b,
'flyout_cf_b': self.flyout_cf_b,
'flyout_rf_b': self.flyout_rf_b,
'groundout_a': self.groundout_a,
'groundout_b': self.groundout_b,
'xcheck_p': self.xcheck_p,
'xcheck_c': self.xcheck_c,
'xcheck_1b': self.xcheck_1b,
'xcheck_2b': self.xcheck_2b,
'xcheck_3b': self.xcheck_3b,
'xcheck_ss': self.xcheck_ss,
'xcheck_lf': self.xcheck_lf,
'xcheck_cf': self.xcheck_cf,
'xcheck_rf': self.xcheck_rf,
'avg': self.avg,
'obp': self.obp,
'slg': self.slg
}
def calculate_singles(self, szn_hits, szn_singles):
if szn_hits == 0:
return
tot = sanitize_chance_output(self.all_hits * (szn_singles / szn_hits))
logger.debug(f'total singles: {tot}')
self.rem_singles = tot
self.bp_single = 5.0 if self.rem_singles >= 5 else 0.0
self.rem_singles -= self.bp_single
self.single_two = sanitize_chance_output(self.rem_singles / 2) if self.hard_rate >= 0.2 else 0.0
self.rem_singles -= self.single_two
self.single_one = sanitize_chance_output(self.rem_singles) if self.soft_rate >= .2 else 0.0
self.rem_singles -= self.single_one
self.single_center = sanitize_chance_output(self.rem_singles)
self.rem_singles -= self.single_center
self.rem_xbh = self.all_hits - self.single_center - self.single_one - self.single_two - self.bp_single
logger.info(f'remaining singles: {self.rem_singles} / total xbh: {self.rem_xbh}')
def calculate_xbh(self, szn_doubles, szn_triples, szn_homeruns, hr_per_fb_rate):
szn_xbh = szn_doubles + szn_triples + szn_homeruns
if szn_xbh == 0:
return
hr_rate = mround(szn_homeruns / szn_xbh)
tr_rate = mround(szn_triples / szn_xbh)
do_rate = mround(szn_doubles / szn_xbh)
logger.info(f'hr%: {hr_rate:.2f} / tr%: {tr_rate:.2f} / do%: {do_rate:.2f}')
raw_do_chances = sanitize_chance_output(self.rem_xbh * do_rate)
logger.info(f'raw do chances: {raw_do_chances}')
self.double_two = raw_do_chances if self.soft_rate > .2 else 0.0
self.double_cf = mround(raw_do_chances - self.double_two)
self.rem_xbh -= mround(self.double_two + self.double_cf + self.double_three)
logger.info(f'Double**: {self.double_two} / Double(cf): {self.double_cf} / rem xbh: {self.rem_xbh}')
self.triple = sanitize_chance_output(self.rem_xbh * tr_rate)
self.rem_xbh = mround(self.rem_xbh - self.triple)
logger.info(f'Triple: {self.triple} / rem xbh: {self.rem_xbh}')
raw_hr_chances = self.rem_xbh
logger.info(f'raw hr chances: {raw_hr_chances}')
if hr_per_fb_rate < .08:
self.bp_homerun = sanitize_chance_output(raw_hr_chances, min_chances=1.0, rounding=1.0)
elif hr_per_fb_rate > .28:
self.homerun = raw_hr_chances
elif hr_per_fb_rate > .18:
self.bp_homerun = sanitize_chance_output(raw_hr_chances * 0.4, min_chances=1.0, rounding=1.0)
self.homerun = self.rem_xbh - self.bp_homerun
else:
self.bp_homerun = sanitize_chance_output(raw_hr_chances * .75, min_chances=1.0, rounding=1.0)
self.homerun = mround(self.rem_xbh - self.bp_homerun)
logger.info(f'BP HR: {self.bp_homerun} / ND HR: {self.homerun}')
self.rem_xbh -= (self.bp_homerun + self.homerun)
logger.info(f'excess xbh: {self.rem_xbh}')
if self.rem_xbh > 0:
if self.triple > 1:
logger.info(f'Passing {self.rem_xbh} xbh to triple')
self.triple += self.rem_xbh
self.rem_xbh = 0.0
elif self.double_cf > 1:
logger.info(f'Passing {self.rem_xbh} xbh to double(cf)')
self.double_cf += self.rem_xbh
self.rem_xbh = 0.0
elif self.double_two > 1:
logger.info(f'Passing {self.rem_xbh} xbh to double**')
self.double_two += self.rem_xbh
self.rem_xbh = 0.0
elif self.single_two > 1:
logger.info(f'Passing {self.rem_xbh} xbh to single**')
self.single_two += self.rem_xbh
self.rem_xbh = 0.0
elif self.single_center > 1:
logger.info(f'Passing {self.rem_xbh} xbh to single(cf)')
self.single_center += self.rem_xbh
self.rem_xbh = 0.0
elif self.single_one > 1:
logger.info(f'Passing {self.rem_xbh} xbh to single*')
self.single_one += self.rem_xbh
self.rem_xbh = 0.0
else:
logger.info(f'Passing {self.rem_xbh} xbh to other_ob')
self.all_other_ob += self.rem_xbh
def calculate_other_ob(self, szn_walks, szn_hbp):
if szn_walks + szn_hbp == 0:
return
this_hbp = sanitize_chance_output(self.all_other_ob * szn_hbp / (szn_walks + szn_hbp), rounding=1.0)
logger.info(f'hbp value candidate: {this_hbp} / all_other_ob: {self.all_other_ob}')
self.hbp = max(min(this_hbp, self.all_other_ob), 0)
self.walk = mround(self.all_other_ob - self.hbp)
logger.info(f'self.hbp: {self.hbp} / self.walk: {self.walk}')
def calculate_strikouts(self, szn_strikeouts, szn_ab, szn_hits):
denom = max(szn_ab - szn_hits, 1)
raw_so = sanitize_chance_output(self.all_outs * (szn_strikeouts * 1.2) / denom)
sum_bb_so = self.walk + raw_so
excess = sum_bb_so - mround(math.floor(sum_bb_so))
logger.info(f'raw_so: {raw_so} / sum_bb_so: {sum_bb_so} / excess: {excess}')
self.strikeout = max(raw_so - excess - .05, 0.0)
if self.strikeout < 0:
logger.error(f'Strikeouts are less than zero :confusedpsyduck:')
def calculate_other_outs(self, fb_pct, gb_pct, oppo_pct):
rem_outs = 108 - self.total_chances()
all_fo = sanitize_chance_output(rem_outs * fb_pct)
if self.pit_hand == 'L':
self.flyout_lf_b = sanitize_chance_output(all_fo * oppo_pct)
else:
self.flyout_rf_b = sanitize_chance_output(all_fo * oppo_pct)
self.flyout_cf_b = all_fo - self.flyout_lf_b - self.flyout_rf_b
rem_outs -= (self.flyout_lf_b + self.flyout_cf_b + self.flyout_rf_b)
all_gb = rem_outs
self.groundout_a = sanitize_chance_output(all_gb * self.soft_rate)
self.groundout_b = sanitize_chance_output(all_gb - self.groundout_a)
rem_chances = 108 - self.total_chances()
logger.info(f'Remaining outs: {rem_chances}')
if self.strikeout > 1:
logger.info(f'Passing {rem_chances} outs to strikeouts')
self.strikeout += rem_chances
elif self.flyout_cf_b > 1:
logger.info(f'Passing {rem_chances} outs to fly(cf)')
self.flyout_cf_b += rem_chances
elif self.flyout_rf_b > 1:
logger.info(f'Passing {rem_chances} outs to fly(rf)')
self.flyout_rf_b += rem_chances
elif self.flyout_lf_b > 1:
logger.info(f'Passing {rem_chances} outs to fly(lf)')
self.flyout_lf_b += rem_chances
elif self.groundout_a > 1:
logger.info(f'Passing {rem_chances} outs to gbA')
self.groundout_a += rem_chances
elif self.single_one > 1:
logger.info(f'Passing {rem_chances} outs to single*')
self.single_one += rem_chances
elif self.single_center > 1:
logger.info(f'Passing {rem_chances} outs to single(cf)')
self.single_center += rem_chances
elif self.single_two > 1:
logger.info(f'Passing {rem_chances} outs to single**')
self.single_two += rem_chances
elif self.double_two > 1:
logger.info(f'Passing {rem_chances} outs to double**')
self.double_two += rem_chances
elif self.double_cf > 1:
logger.info(f'Passing {rem_chances} outs to double(cf)')
self.double_cf += rem_chances
elif self.triple > 1:
logger.info(f'Passing {rem_chances} outs to triple')
self.triple += rem_chances
elif self.homerun > 1:
logger.info(f'Passing {rem_chances} outs to homerun')
self.homerun += rem_chances
else:
raise ValueError(f'Could not complete card')