Add custom card profiles, S3 upload with timestamp cache-busting, and CLI enhancements

- Add Sippie Swartzel custom batter profile (0.820 OPS, SS/RF, no HR power)
- Update Kalin Young profile (0.891 OPS, All-Star rarity)
- Update Admiral Ball Traits profile with innings field
- Fix S3 cache-busting to include Unix timestamp for same-day updates
- Add pd_cards/core/upload.py and scouting.py modules
- Add custom card submission scripts and documentation
- Add uv.lock for dependency tracking
This commit is contained in:
Cal Corum 2026-01-25 21:57:35 -06:00
parent 9a121d370f
commit 1de8b1db2f
12 changed files with 5546 additions and 141 deletions

View File

@ -10,7 +10,7 @@ from exceptions import logger
# Configuration
CARDSET_NAME = '2005 Live'
CARDSET_NAME = "2005 Live"
START_ID = None # Integer to only start pulling cards at player_id START_ID
TEST_COUNT = 9999 # integer to stop after TEST_COUNT calls
HTML_CARDS = False # boolean to only check and not generate cards
@ -18,14 +18,16 @@ SKIP_ARMS = False
SKIP_BATS = False
# AWS Configuration
AWS_BUCKET_NAME = 'paper-dynasty' # Change to your bucket name
AWS_REGION = 'us-east-1' # Change to your region
S3_BASE_URL = f'https://{AWS_BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com'
UPLOAD_TO_S3 = True # Set to False to skip S3 upload (testing) - STEP 6: Upload validated cards
AWS_BUCKET_NAME = "paper-dynasty" # Change to your bucket name
AWS_REGION = "us-east-1" # Change to your region
S3_BASE_URL = f"https://{AWS_BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com"
UPLOAD_TO_S3 = (
True # Set to False to skip S3 upload (testing) - STEP 6: Upload validated cards
)
UPDATE_PLAYER_URLS = True # Set to False to skip player URL updates (testing) - STEP 6: Update player URLs
# Initialize S3 client
s3_client = boto3.client('s3', region_name=AWS_REGION) if UPLOAD_TO_S3 else None
s3_client = boto3.client("s3", region_name=AWS_REGION) if UPLOAD_TO_S3 else None
async def fetch_card_image(session, card_url: str, timeout: int = 6) -> bytes:
@ -42,17 +44,25 @@ async def fetch_card_image(session, card_url: str, timeout: int = 6) -> bytes:
"""
import aiohttp
async with session.get(card_url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp:
async with session.get(
card_url, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status == 200:
logger.info(f'Fetched card image from {card_url}')
logger.info(f"Fetched card image from {card_url}")
return await resp.read()
else:
error_text = await resp.text()
logger.error(f'Failed to fetch card: {error_text}')
raise ValueError(f'Card fetch error: {error_text}')
logger.error(f"Failed to fetch card: {error_text}")
raise ValueError(f"Card fetch error: {error_text}")
def upload_card_to_s3(image_data: bytes, player_id: int, card_type: str, release_date: str, cardset_id: int) -> str:
def upload_card_to_s3(
image_data: bytes,
player_id: int,
card_type: str,
release_date: str,
cardset_id: int,
) -> str:
"""
Upload card image to S3 and return the S3 URL with cache-busting param.
@ -67,60 +77,65 @@ def upload_card_to_s3(image_data: bytes, player_id: int, card_type: str, release
Full S3 URL with ?d= parameter
"""
# Format cardset_id with 3 digits and leading zeros
cardset_str = f'{cardset_id:03d}'
s3_key = f'cards/cardset-{cardset_str}/player-{player_id}/{card_type}card.png'
cardset_str = f"{cardset_id:03d}"
s3_key = f"cards/cardset-{cardset_str}/player-{player_id}/{card_type}card.png"
try:
s3_client.put_object(
Bucket=AWS_BUCKET_NAME,
Key=s3_key,
Body=image_data,
ContentType='image/png',
CacheControl='public, max-age=300', # 5 minute cache
ContentType="image/png",
CacheControl="public, max-age=300", # 5 minute cache
Metadata={
'player-id': str(player_id),
'card-type': card_type,
'upload-date': datetime.datetime.now().isoformat()
}
"player-id": str(player_id),
"card-type": card_type,
"upload-date": datetime.datetime.now().isoformat(),
},
)
# Return URL with cache-busting parameter
s3_url = f'{S3_BASE_URL}/{s3_key}?d={release_date}'
logger.info(f'Uploaded {card_type} card for player {player_id} to S3: {s3_url}')
s3_url = f"{S3_BASE_URL}/{s3_key}?d={release_date}"
logger.info(f"Uploaded {card_type} card for player {player_id} to S3: {s3_url}")
return s3_url
except Exception as e:
logger.error(f'Failed to upload {card_type} card for player {player_id}: {e}')
logger.error(f"Failed to upload {card_type} card for player {player_id}: {e}")
raise
async def main(args):
import aiohttp
print(f'Searching for cardset: {CARDSET_NAME}')
c_query = await db_get('cardsets', params=[('name', CARDSET_NAME)])
print(f"Searching for cardset: {CARDSET_NAME}")
c_query = await db_get("cardsets", params=[("name", CARDSET_NAME)])
if not c_query or c_query['count'] == 0:
print(f'I do not see a cardset named {CARDSET_NAME}')
if not c_query or c_query["count"] == 0:
print(f"I do not see a cardset named {CARDSET_NAME}")
return
cardset = c_query['cardsets'][0]
cardset = c_query["cardsets"][0]
del c_query
p_query = await db_get(
'players',
params=[('inc_dex', False), ('cardset_id', cardset['id']), ('short_output', True)]
"players",
params=[
("inc_dex", False),
("cardset_id", cardset["id"]),
("short_output", True),
],
)
if not p_query or p_query['count'] == 0:
raise ValueError('No players returned from Paper Dynasty API')
all_players = p_query['players']
if not p_query or p_query["count"] == 0:
raise ValueError("No players returned from Paper Dynasty API")
all_players = p_query["players"]
del p_query
# Generate release date for cache busting
# Generate release date for cache busting (include timestamp for same-day updates)
now = datetime.datetime.now()
release_date = f'{now.year}-{now.month}-{now.day}'
timestamp = int(now.timestamp())
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
# PD API base URL for card generation
PD_API_URL = 'https://pd.manticorum.com/api'
PD_API_URL = "https://pd.manticorum.com/api"
errors = []
successes = []
@ -130,61 +145,72 @@ async def main(args):
count = -1
start_time = datetime.datetime.now()
print(f'\nRelease date for cards: {release_date}')
print(f'S3 Upload: {"ENABLED" if UPLOAD_TO_S3 else "DISABLED"}')
print(f'URL Update: {"ENABLED" if UPDATE_PLAYER_URLS else "DISABLED"}\n')
print(f"\nRelease date for cards: {release_date}")
print(f"S3 Upload: {'ENABLED' if UPLOAD_TO_S3 else 'DISABLED'}")
print(f"URL Update: {'ENABLED' if UPDATE_PLAYER_URLS else 'DISABLED'}\n")
# Create persistent aiohttp session for all card fetches
async with aiohttp.ClientSession() as session:
for x in all_players:
if 'pitching' in x['image'] and SKIP_ARMS:
if "pitching" in x["image"] and SKIP_ARMS:
pass
elif 'batting' in x['image'] and SKIP_BATS:
elif "batting" in x["image"] and SKIP_BATS:
pass
elif START_ID is not None and START_ID > x['player_id']:
elif START_ID is not None and START_ID > x["player_id"]:
pass
elif 'sombaseball' in x['image']:
errors.append((x, f'Bad card url: {x["image"]}'))
elif "sombaseball" in x["image"]:
errors.append((x, f"Bad card url: {x['image']}"))
else:
count += 1
if count % 20 == 0:
print(f'Card #{count + 1} being pulled is {x["p_name"]}...')
print(f"Card #{count + 1} being pulled is {x['p_name']}...")
elif TEST_COUNT is not None and TEST_COUNT < count:
print(f'Done test run')
print(f"Done test run")
break
# Determine card type from existing image URL
card_type = 'pitching' if 'pitching' in x['image'] else 'batting'
card_type = "pitching" if "pitching" in x["image"] else "batting"
# Generate card URL from PD API (forces fresh generation from database)
pd_card_url = f'{PD_API_URL}/v2/players/{x["player_id"]}/{card_type}card?d={release_date}'
pd_card_url = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type}card?d={release_date}"
if HTML_CARDS:
card_url = f'{pd_card_url}&html=true'
card_url = f"{pd_card_url}&html=true"
timeout = 2
else:
card_url = pd_card_url
timeout = 6
try:
# Upload to S3 if enabled
if UPLOAD_TO_S3 and not HTML_CARDS:
# Fetch card image bytes directly
image_bytes = await fetch_card_image(session, card_url, timeout=timeout)
s3_url = upload_card_to_s3(image_bytes, x['player_id'], card_type, release_date, cardset['id'])
uploads.append((x['player_id'], card_type, s3_url))
image_bytes = await fetch_card_image(
session, card_url, timeout=timeout
)
s3_url = upload_card_to_s3(
image_bytes,
x["player_id"],
card_type,
release_date,
cardset["id"],
)
uploads.append((x["player_id"], card_type, s3_url))
# Update player record with new S3 URL
if UPDATE_PLAYER_URLS:
await db_patch('players', object_id=x['player_id'], params=[
('image', s3_url)
])
url_updates.append((x['player_id'], card_type, s3_url))
logger.info(f'Updated player {x["player_id"]} image URL to S3')
await db_patch(
"players",
object_id=x["player_id"],
params=[("image", s3_url)],
)
url_updates.append((x["player_id"], card_type, s3_url))
logger.info(
f"Updated player {x['player_id']} image URL to S3"
)
else:
# Just validate card exists (old behavior)
logger.info(f'calling the card url')
logger.info(f"calling the card url")
resp = await url_get(card_url, timeout=timeout)
except ConnectionError as e:
@ -197,40 +223,56 @@ async def main(args):
errors.append((x, e))
except Exception as e:
logger.error(f'S3 upload/update failed for player {x["player_id"]}: {e}')
errors.append((x, f'S3 error: {e}'))
logger.error(
f"S3 upload/update failed for player {x['player_id']}: {e}"
)
errors.append((x, f"S3 error: {e}"))
continue
# Handle image2 (dual-position players)
if x['image2'] is not None:
if x["image2"] is not None:
# Determine second card type
card_type2 = 'pitching' if 'pitching' in x['image2'] else 'batting'
card_type2 = "pitching" if "pitching" in x["image2"] else "batting"
# Generate card URL from PD API (forces fresh generation from database)
pd_card_url2 = f'{PD_API_URL}/v2/players/{x["player_id"]}/{card_type2}card?d={release_date}'
pd_card_url2 = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type2}card?d={release_date}"
if HTML_CARDS:
card_url2 = f'{pd_card_url2}&html=true'
card_url2 = f"{pd_card_url2}&html=true"
else:
card_url2 = pd_card_url2
if 'sombaseball' in x['image2']:
errors.append((x, f'Bad card url: {x["image2"]}'))
if "sombaseball" in x["image2"]:
errors.append((x, f"Bad card url: {x['image2']}"))
else:
try:
if UPLOAD_TO_S3 and not HTML_CARDS:
# Fetch second card image bytes directly from PD API
image_bytes2 = await fetch_card_image(session, card_url2, timeout=6)
s3_url2 = upload_card_to_s3(image_bytes2, x['player_id'], card_type2, release_date, cardset['id'])
uploads.append((x['player_id'], card_type2, s3_url2))
image_bytes2 = await fetch_card_image(
session, card_url2, timeout=6
)
s3_url2 = upload_card_to_s3(
image_bytes2,
x["player_id"],
card_type2,
release_date,
cardset["id"],
)
uploads.append((x["player_id"], card_type2, s3_url2))
# Update player record with new S3 URL for image2
if UPDATE_PLAYER_URLS:
await db_patch('players', object_id=x['player_id'], params=[
('image2', s3_url2)
])
url_updates.append((x['player_id'], card_type2, s3_url2))
logger.info(f'Updated player {x["player_id"]} image2 URL to S3')
await db_patch(
"players",
object_id=x["player_id"],
params=[("image2", s3_url2)],
)
url_updates.append(
(x["player_id"], card_type2, s3_url2)
)
logger.info(
f"Updated player {x['player_id']} image2 URL to S3"
)
else:
# Just validate card exists (old behavior)
resp = await url_get(card_url2, timeout=6)
@ -247,42 +289,44 @@ async def main(args):
errors.append((x, e))
except Exception as e:
logger.error(f'S3 upload/update failed for player {x["player_id"]} image2: {e}')
errors.append((x, f'S3 error (image2): {e}'))
logger.error(
f"S3 upload/update failed for player {x['player_id']} image2: {e}"
)
errors.append((x, f"S3 error (image2): {e}"))
else:
successes.append(x)
# Print summary
print(f'\n{"="*60}')
print(f'SUMMARY')
print(f'{"="*60}')
print(f"\n{'=' * 60}")
print(f"SUMMARY")
print(f"{'=' * 60}")
if len(errors) > 0:
logger.error(f'All Errors:')
logger.error(f"All Errors:")
for x in errors:
logger.error(f'ID {x[0]["player_id"]} {x[0]["p_name"]} - Error: {x[1]}')
logger.error(f"ID {x[0]['player_id']} {x[0]['p_name']} - Error: {x[1]}")
if len(successes) > 0:
logger.debug(f'All Successes:')
logger.debug(f"All Successes:")
for x in successes:
logger.info(f'ID {x["player_id"]} {x["p_name"]}')
logger.info(f"ID {x['player_id']} {x['p_name']}")
p_run_time = datetime.datetime.now() - start_time
print(f'\nErrors: {len(errors)}')
print(f'Successes: {len(successes)}')
print(f"\nErrors: {len(errors)}")
print(f"Successes: {len(successes)}")
if UPLOAD_TO_S3:
print(f'S3 Uploads: {len(uploads)}')
print(f"S3 Uploads: {len(uploads)}")
if len(uploads) > 0:
print(f' First upload: {uploads[0][2]}')
print(f" First upload: {uploads[0][2]}")
if UPDATE_PLAYER_URLS:
print(f'URL Updates: {len(url_updates)}')
print(f"URL Updates: {len(url_updates)}")
print(f'\nTotal runtime: {p_run_time.total_seconds():.2f} seconds')
print(f'{"="*60}')
print(f"\nTotal runtime: {p_run_time.total_seconds():.2f} seconds")
print(f"{'=' * 60}")
if __name__ == '__main__':
if __name__ == "__main__":
asyncio.run(main(sys.argv[1:]))

View File

@ -0,0 +1,203 @@
"""
Submit Kalin Young updates to Paper Dynasty Database
Updates:
- BattingCardRatings: +1.55 walks/singles, -3.10 strikeouts (both splits)
- CardPositions: RF Range 2/Error 6, LF Range 4/Error 6
- Target OPS: 0.880 (from 0.837)
Does NOT upload to AWS - user will review first.
"""
import asyncio
from db_calls import db_get, db_put, db_patch
from datetime import datetime
# Player info
PLAYER_ID = 13009
BATTINGCARD_ID = 6072
CARDSET_ID = 29
# New ratings (from kalin_young_preview.py)
NEW_RATINGS = {
'L': { # vs LHP
'battingcard_id': BATTINGCARD_ID,
'vs_hand': 'L',
'homerun': 2.05,
'bp_homerun': 2.00,
'triple': 0.00,
'double_three': 0.00,
'double_two': 6.00,
'double_pull': 2.35,
'single_two': 5.05,
'single_one': 5.10,
'single_center': 5.80, # +1.55
'bp_single': 5.00,
'walk': 16.05, # +1.55
'hbp': 1.00,
'strikeout': 15.40, # -3.10
'lineout': 15.00,
'popout': 0.00,
'flyout_a': 0.00,
'flyout_bq': 0.75,
'flyout_lf_b': 3.65,
'flyout_rf_b': 3.95,
'groundout_a': 3.95,
'groundout_b': 10.00,
'groundout_c': 4.90,
'pull_rate': 0.33,
'center_rate': 0.37,
'slap_rate': 0.30,
},
'R': { # vs RHP
'battingcard_id': BATTINGCARD_ID,
'vs_hand': 'R',
'homerun': 2.10,
'bp_homerun': 1.00,
'triple': 1.25,
'double_three': 0.00,
'double_two': 6.35,
'double_pull': 1.80,
'single_two': 5.40,
'single_one': 4.95,
'single_center': 6.05, # +1.55
'bp_single': 5.00,
'walk': 14.55, # +1.55
'hbp': 2.00,
'strikeout': 17.90, # -3.10
'lineout': 12.00,
'popout': 1.00,
'flyout_a': 0.00,
'flyout_bq': 0.50,
'flyout_lf_b': 4.10,
'flyout_rf_b': 4.00,
'groundout_a': 4.00,
'groundout_b': 5.00,
'groundout_c': 9.05,
'pull_rate': 0.25,
'center_rate': 0.40,
'slap_rate': 0.35,
}
}
# New defensive positions
NEW_POSITIONS = [
{
'player_id': PLAYER_ID,
'position': 'RF',
'range': 2, # was 3
'error': 6, # was 7
'arm': 0,
'fielding_pct': None
},
{
'player_id': PLAYER_ID,
'position': 'LF',
'range': 4,
'error': 6, # was 7
'arm': 0,
'fielding_pct': None
}
]
def calc_ops(r):
"""Calculate OPS from ratings dict."""
avg = (r['homerun'] + r['bp_homerun']/2 + r['triple'] + r['double_three'] +
r['double_two'] + r['double_pull'] + r['single_two'] + r['single_one'] +
r['single_center'] + r['bp_single']/2) / 108
obp = avg + (r['hbp'] + r['walk']) / 108
slg = (r['homerun']*4 + r['bp_homerun']*2 + r['triple']*3 +
(r['double_three'] + r['double_two'] + r['double_pull'])*2 +
r['single_two'] + r['single_one'] + r['single_center'] + r['bp_single']/2) / 108
return obp + slg
def verify_total(r):
"""Verify ratings sum to 108."""
return sum([
r['homerun'], r['bp_homerun'], r['triple'],
r['double_three'], r['double_two'], r['double_pull'],
r['single_two'], r['single_one'], r['single_center'], r['bp_single'],
r['walk'], r['hbp'], r['strikeout'],
r['lineout'], r['popout'],
r['flyout_a'], r['flyout_bq'], r['flyout_lf_b'], r['flyout_rf_b'],
r['groundout_a'], r['groundout_b'], r['groundout_c']
])
async def main():
"""Update Kalin Young's batting card ratings and defensive positions."""
print("\n" + "="*70)
print("UPDATING KALIN YOUNG IN DATABASE")
print("="*70)
print(f"\nPlayer ID: {PLAYER_ID}")
print(f"BattingCard ID: {BATTINGCARD_ID}")
# Verify totals
print("\nVerifying ratings totals...")
for hand, ratings in NEW_RATINGS.items():
total = verify_total(ratings)
ops = calc_ops(ratings)
print(f" vs {hand}HP: Total={total:.2f}, OPS={ops:.3f}")
if abs(total - 108.0) > 0.01:
print(f" ⚠ WARNING: Total is not 108!")
return
# Calculate combined OPS
ops_vl = calc_ops(NEW_RATINGS['L'])
ops_vr = calc_ops(NEW_RATINGS['R'])
total_ops = (ops_vl + ops_vr + min(ops_vl, ops_vr)) / 3
print(f" Combined OPS: {total_ops:.3f}")
# Step 1: Update BattingCardRatings
print("\nUpdating BattingCardRatings...")
ratings_list = [NEW_RATINGS['L'], NEW_RATINGS['R']]
ratings_payload = {'ratings': ratings_list}
try:
result = await db_put('battingcardratings', payload=ratings_payload, timeout=10)
print(f" ✓ Updated ratings: {result}")
except Exception as e:
print(f" ✗ Error updating ratings: {e}")
return
# Step 2: Update CardPositions
print("\nUpdating CardPositions...")
positions_payload = {'positions': NEW_POSITIONS}
try:
result = await db_put('cardpositions', payload=positions_payload, timeout=10)
print(f" ✓ Updated positions: {result}")
except Exception as e:
print(f" ✗ Error updating positions: {e}")
return
# Summary
print("\n" + "="*70)
print("✓ KALIN YOUNG UPDATED SUCCESSFULLY!")
print("="*70)
print(f"\nPlayer ID: {PLAYER_ID}")
print(f"Cardset: 2005 Custom (ID: {CARDSET_ID})")
print(f"\nRating Changes:")
print(f" Walk vL: 14.50 → 16.05 (+1.55)")
print(f" Walk vR: 13.00 → 14.55 (+1.55)")
print(f" Single (Center) vL: 4.25 → 5.80 (+1.55)")
print(f" Single (Center) vR: 4.50 → 6.05 (+1.55)")
print(f" Strikeout vL: 18.50 → 15.40 (-3.10)")
print(f" Strikeout vR: 21.00 → 17.90 (-3.10)")
print(f"\nOPS: 0.837 → {total_ops:.3f}")
print(f"\nDefensive Updates:")
print(f" RF: Range 3→2, Error 7→6, Arm 0")
print(f" LF: Range 4, Error 7→6, Arm 0")
print(f"\n⚠️ AWS upload skipped - review card first:")
now = datetime.now()
release_date = f"{now.year}-{now.month}-{now.day}"
print(f" PNG: https://pd.manticorum.com/api/v2/players/{PLAYER_ID}/battingcard?d={release_date}")
print(f" HTML: https://pd.manticorum.com/api/v2/players/{PLAYER_ID}/battingcard?d={release_date}&html=true")
print("")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,318 @@
"""
Submit Tony Smehrik to Paper Dynasty Database
Custom pitcher with:
- Hand: Left
- Position: SP/RP (starter_rating: 5, relief_rating: 5)
- Combined OPS: 0.585
- OPS vs L: 0.465 (dominant same-side)
- OPS vs R: 0.645 (weaker opposite-side)
- K-rate vs L: ~20% (average)
- K-rate vs R: ~8% (very low)
- FB% vs L: ~44% (average)
- FB% vs R: ~56% (high)
- Team: Custom Ballplayers
- Cardset ID: 29
NO AWS UPLOAD - preview via PD API first
"""
import asyncio
from datetime import datetime
from db_calls import db_get, db_post, db_patch, db_put
from custom_cards.tony_smehrik_preview import calculate_pitcher_rating, DEFAULT_XCHECKS
# Configuration
CARDSET_ID = 29
CARDSET_NAME = "2005 Custom"
SEASON = 2005
PLAYER_DESCRIPTION = "2005 Custom"
# Player info
PLAYER_NAME_FIRST = "Tony"
PLAYER_NAME_LAST = "Smehrik"
HAND = "L"
STARTER_RATING = 5
RELIEF_RATING = 5
CLOSER_RATING = None
async def main():
"""Create Tony Smehrik pitcher card in database."""
print("\n" + "="*70)
print("SUBMITTING TONY SMEHRIK TO DATABASE")
print("="*70)
# Step 1: Calculate ratings (same as preview)
print("\nCalculating ratings...")
pit_hand = 'L'
vl = calculate_pitcher_rating(
vs_hand='L',
pit_hand=pit_hand,
avg=0.185,
obp=0.240,
slg=0.225,
bb_pct=0.055,
k_pct=0.20,
hr_per_hit=0.04,
triple_per_hit=0.02,
double_per_hit=0.22,
fb_pct=0.35,
gb_pct=0.45,
hard_pct=0.30,
med_pct=0.50,
soft_pct=0.20,
oppo_pct=0.26,
hr_fb_pct=0.06,
)
vr = calculate_pitcher_rating(
vs_hand='R',
pit_hand=pit_hand,
avg=0.245,
obp=0.305,
slg=0.340,
bb_pct=0.075,
k_pct=0.08,
hr_per_hit=0.07,
triple_per_hit=0.02,
double_per_hit=0.24,
fb_pct=0.45,
gb_pct=0.35,
hard_pct=0.34,
med_pct=0.46,
soft_pct=0.20,
oppo_pct=0.26,
hr_fb_pct=0.09,
)
# Apply same manual adjustments as preview
# HR adjustments: half of all OB chances go to HR
vl_total_hits = (vl['homerun'] + vl['bp_homerun'] + vl['triple'] +
vl['double_three'] + vl['double_two'] + vl['double_cf'] +
vl['single_two'] + vl['single_one'] + vl['single_center'] + vl['bp_single'])
vl_total_ob = vl_total_hits + vl['walk'] + vl['hbp']
vl_hr_total = vl_total_ob / 2
vl['bp_homerun'] = round(vl_hr_total / 2)
vl['homerun'] = round((vl_hr_total - vl['bp_homerun']) * 20) / 20
vl['hbp'] = 1.0
vr_total_hits = (vr['homerun'] + vr['bp_homerun'] + vr['triple'] +
vr['double_three'] + vr['double_two'] + vr['double_cf'] +
vr['single_two'] + vr['single_one'] + vr['single_center'] + vr['bp_single'])
vr_total_ob = vr_total_hits + vr['walk'] + vr['hbp']
vr_hr_total = vr_total_ob / 2
vr['bp_homerun'] = round(vr_hr_total / 2)
vr['homerun'] = round((vr_hr_total - vr['bp_homerun']) * 20) / 20
vr['hbp'] = 1.0
# Reduce singles to compensate for HR increase
vl_new_hr_total = vl['homerun'] + vl['bp_homerun']
vl_hr_increase = vl_new_hr_total
vl_singles_to_reduce = vl_hr_increase
reduce_from_one = min(vl['single_one'], vl_singles_to_reduce)
vl['single_one'] -= reduce_from_one
vl_singles_to_reduce -= reduce_from_one
reduce_from_two = min(vl['single_two'], vl_singles_to_reduce)
vl['single_two'] -= reduce_from_two
vl_singles_to_reduce -= reduce_from_two
reduce_from_bp = min(vl['bp_single'], vl_singles_to_reduce)
vl['bp_single'] -= reduce_from_bp
vl_singles_to_reduce -= reduce_from_bp
if vl_singles_to_reduce > 0:
vl['double_cf'] = max(0, vl['double_cf'] - vl_singles_to_reduce)
vr_new_hr_total = vr['homerun'] + vr['bp_homerun']
vr_old_hr_total = 0.80 + 1.00
vr_hr_increase = vr_new_hr_total - vr_old_hr_total
vr_singles_to_reduce = vr_hr_increase
reduce_from_one = min(vr['single_one'], vr_singles_to_reduce)
vr['single_one'] -= reduce_from_one
vr_singles_to_reduce -= reduce_from_one
reduce_from_two = min(vr['single_two'], vr_singles_to_reduce)
vr['single_two'] -= reduce_from_two
vr_singles_to_reduce -= reduce_from_two
reduce_from_bp = min(vr['bp_single'], vr_singles_to_reduce)
vr['bp_single'] -= reduce_from_bp
vr_singles_to_reduce -= reduce_from_bp
if vr_singles_to_reduce > 0:
vr['double_cf'] = max(0, vr['double_cf'] - vr_singles_to_reduce)
# Force BP-SI values
vl['bp_single'] = 5.0
vr['bp_single'] = 3.0
# Adjust strikeouts to hit 108
for rating in [vl, vr]:
total = sum([
rating['homerun'], rating['bp_homerun'], rating['triple'],
rating['double_three'], rating['double_two'], rating['double_cf'],
rating['single_two'], rating['single_one'], rating['single_center'], rating['bp_single'],
rating['hbp'], rating['walk'], rating['strikeout'],
rating['flyout_lf_b'], rating['flyout_cf_b'], rating['flyout_rf_b'],
rating['groundout_a'], rating['groundout_b'],
rating['xcheck_p'], rating['xcheck_c'], rating['xcheck_1b'], rating['xcheck_2b'],
rating['xcheck_3b'], rating['xcheck_ss'], rating['xcheck_lf'], rating['xcheck_cf'], rating['xcheck_rf']
])
diff = 108 - total
rating['strikeout'] = round((rating['strikeout'] + diff) * 20) / 20
# Calculate OPS for verification
ops_vl = vl['obp'] + vl['slg']
ops_vr = vr['obp'] + vr['slg']
combined_ops = (ops_vr + ops_vl + max(ops_vl, ops_vr)) / 3
print(f" OPS vs L: {ops_vl:.3f}")
print(f" OPS vs R: {ops_vr:.3f}")
print(f" Combined OPS: {combined_ops:.3f}")
# Step 2: Verify cardset exists
print(f"\nVerifying cardset '{CARDSET_NAME}' (ID: {CARDSET_ID})...")
c_query = await db_get('cardsets', params=[('id', CARDSET_ID)])
if c_query['count'] == 0:
print(f" ERROR: Cardset ID {CARDSET_ID} not found!")
return
print(f" ✓ Cardset verified")
# Step 3: Create Player record
print("\nCreating Player record...")
now = datetime.now()
release_date = f"{now.year}-{now.month}-{now.day}"
bbref_id = f"custom_{PLAYER_NAME_LAST.lower()}{PLAYER_NAME_FIRST[0].lower()}01"
player_payload = {
'p_name': f'{PLAYER_NAME_FIRST} {PLAYER_NAME_LAST}',
'cost': '100', # Default cost
'image': 'change-me',
'mlbclub': 'Custom Ballplayers',
'franchise': 'Custom Ballplayers',
'cardset_id': CARDSET_ID,
'set_num': 99999,
'rarity_id': 3, # Starter rarity
'pos_1': 'SP',
'pos_2': 'RP',
'description': PLAYER_DESCRIPTION,
'bbref_id': bbref_id,
'fangr_id': 0,
'mlbplayer_id': None,
'is_custom': True,
}
player = await db_post('players', payload=player_payload)
player_id = player['player_id']
print(f" ✓ Created Player ID: {player_id}")
# Update player with API image URL for preview
api_image_url = f"https://pd.manticorum.com/api/v2/players/{player_id}/pitchingcard?d={release_date}"
await db_patch('players', object_id=player_id, params=[('image', api_image_url)])
print(f" ✓ Updated Player with API image URL")
# Step 4: Create PitchingCard
print("\nCreating PitchingCard...")
pitching_card_payload = {
'cards': [{
'player_id': player_id,
'key_bbref': bbref_id,
'key_fangraphs': 0,
'key_mlbam': 0,
'key_retro': '',
'name_first': PLAYER_NAME_FIRST,
'name_last': PLAYER_NAME_LAST,
'hand': HAND,
'starter_rating': STARTER_RATING,
'relief_rating': RELIEF_RATING,
'closer_rating': CLOSER_RATING,
}]
}
await db_put('pitchingcards', payload=pitching_card_payload, timeout=10)
# Get the created card ID
pc_query = await db_get('pitchingcards', params=[('player_id', player_id)])
pitchingcard_id = pc_query['cards'][0]['id']
print(f" ✓ Created PitchingCard ID: {pitchingcard_id}")
# Step 5: Create PitchingCardRatings
print("\nCreating PitchingCardRatings...")
# Build ratings payload (remove display-only fields)
def build_rating_payload(rating, pitchingcard_id):
return {
'pitchingcard_id': pitchingcard_id,
'vs_hand': rating['vs_hand'],
'homerun': rating['homerun'],
'bp_homerun': rating['bp_homerun'],
'triple': rating['triple'],
'double_three': rating['double_three'],
'double_two': rating['double_two'],
'double_cf': rating['double_cf'],
'single_two': rating['single_two'],
'single_one': rating['single_one'],
'single_center': rating['single_center'],
'bp_single': rating['bp_single'],
'hbp': rating['hbp'],
'walk': rating['walk'],
'strikeout': rating['strikeout'],
'flyout_lf_b': rating['flyout_lf_b'],
'flyout_cf_b': rating['flyout_cf_b'],
'flyout_rf_b': rating['flyout_rf_b'],
'groundout_a': rating['groundout_a'],
'groundout_b': rating['groundout_b'],
'xcheck_p': rating['xcheck_p'],
'xcheck_c': rating['xcheck_c'],
'xcheck_1b': rating['xcheck_1b'],
'xcheck_2b': rating['xcheck_2b'],
'xcheck_3b': rating['xcheck_3b'],
'xcheck_ss': rating['xcheck_ss'],
'xcheck_lf': rating['xcheck_lf'],
'xcheck_cf': rating['xcheck_cf'],
'xcheck_rf': rating['xcheck_rf'],
}
ratings_payload = {
'ratings': [
build_rating_payload(vl, pitchingcard_id),
build_rating_payload(vr, pitchingcard_id),
]
}
await db_put('pitchingcardratings', payload=ratings_payload, timeout=10)
print(f" ✓ Created ratings for vs L and vs R")
# Step 6: Create CardPositions
print("\nCreating CardPositions...")
positions_payload = {
'positions': [
{
'player_id': player_id,
'position': 'P',
}
]
}
await db_put('cardpositions', payload=positions_payload, timeout=10)
print(f" ✓ Created position record: P")
# Summary
print("\n" + "="*70)
print("✓ TONY SMEHRIK CREATED SUCCESSFULLY!")
print("="*70)
print(f"\nPlayer ID: {player_id}")
print(f"PitchingCard ID: {pitchingcard_id}")
print(f"Cardset: {CARDSET_NAME} (ID: {CARDSET_ID})")
print(f"Hand: {HAND}")
print(f"Starter Rating: {STARTER_RATING} | Relief Rating: {RELIEF_RATING}")
print(f"\nOffensive Profile (OPS Against):")
print(f" vs LHB: .185/.240/.225 ({ops_vl:.3f} OPS)")
print(f" vs RHB: .245/.305/.340 ({ops_vr:.3f} OPS)")
print(f" Combined: {combined_ops:.3f}")
print(f"\nPreview card (NO S3 upload yet):")
print(f" PNG: {api_image_url}")
print(f" HTML: {api_image_url}&html=true")
print("")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,488 @@
"""
Tony Smehrik - Custom Pitcher Card Preview
FIRST PASS - Target specs:
- Name: Tony Smehrik
- Hand: Left
- Position: RP, SP (starter_rating: 5, relief_rating: 5, closer_rating: None)
- Target Combined OPS: 0.585
- Target OPS vs L: 0.495 (dominant vs lefties - same-side advantage)
- K-rate vs L: average (~20%)
- K-rate vs R: very low (~12%)
- Flyball rate vs L: average (~35%)
- Flyball rate vs R: high (~45%)
- Team: Custom Ballplayers
- Cardset ID: 29
Uses CORRECT pitcher schema with:
- double_cf (not double_pull)
- flyout_lf_b, flyout_cf_b, flyout_rf_b (no flyout_a, flyout_bq)
- groundout_a, groundout_b only (no groundout_c)
- xcheck fields (P, C, 1B, 2B, 3B, SS, LF, CF, RF) = 29 chances total
"""
import asyncio
from dataclasses import dataclass
from creation_helpers import mround, sanitize_chance_output
# Default x-check values (from calcs_pitcher.py)
DEFAULT_XCHECKS = {
'xcheck_p': 1.0,
'xcheck_c': 3.0,
'xcheck_1b': 2.0,
'xcheck_2b': 6.0,
'xcheck_3b': 3.0,
'xcheck_ss': 7.0,
'xcheck_lf': 2.0,
'xcheck_cf': 3.0,
'xcheck_rf': 2.0,
}
TOTAL_XCHECK = sum(DEFAULT_XCHECKS.values()) # 29.0
def calculate_pitcher_rating(
vs_hand: str,
pit_hand: str,
avg: float,
obp: float,
slg: float,
bb_pct: float,
k_pct: float,
hr_per_hit: float,
triple_per_hit: float,
double_per_hit: float,
fb_pct: float,
gb_pct: float,
hard_pct: float,
med_pct: float,
soft_pct: float,
oppo_pct: float,
hr_fb_pct: float = 0.10,
) -> dict:
"""
Calculate pitcher card ratings using the correct schema.
Total chances = 108:
- Hits (HR, BP-HR, 3B, 2B, 1B variants)
- On-base (BB, HBP)
- Outs (K, flyouts, groundouts)
- X-checks (29 chances that redirect to pitcher's card)
"""
# Calculate base chances (108 total, minus 29 for x-checks = 79 "real" chances)
# But wait - the x-checks ARE part of the 108. They're outs that redirect.
# So: hits + ob + outs = 108, where outs includes K, flyouts, groundouts, AND xchecks
# From calcs_pitcher.py, total_chances includes xchecks in the out count
# Let's calculate hits and OB first, then outs fill the rest
# Hits allowed (adjusted for the 1.2x offense modifier used in game)
# Note: calcs_pitcher subtracts 0.05 from AVG for BP results
all_hits = sanitize_chance_output((avg - 0.05) * 108)
# OB (walks + HBP)
all_other_ob = sanitize_chance_output(min(bb_pct * 108, 0.8 * 108)) # Cap at ~86 chances
# Outs (everything else)
all_outs = mround(108 - all_hits - all_other_ob, base=0.5)
# ===== HITS DISTRIBUTION =====
# Singles
single_pct = 1.0 - hr_per_hit - triple_per_hit - double_per_hit
total_singles = sanitize_chance_output(all_hits * single_pct)
bp_single = 5.0 if total_singles >= 5 else 0.0
rem_singles = total_singles - bp_single
# Distribute singles based on contact quality
single_two = sanitize_chance_output(rem_singles / 2) if hard_pct >= 0.2 else 0.0
rem_singles -= single_two
single_one = sanitize_chance_output(rem_singles) if soft_pct >= 0.2 else 0.0
rem_singles -= single_one
single_center = sanitize_chance_output(rem_singles)
# XBH
rem_xbh = all_hits - bp_single - single_two - single_one - single_center
# Doubles
xbh_total = hr_per_hit + triple_per_hit + double_per_hit
if xbh_total > 0:
do_rate = double_per_hit / xbh_total
tr_rate = triple_per_hit / xbh_total
hr_rate = hr_per_hit / xbh_total
else:
do_rate = tr_rate = hr_rate = 0.0
raw_doubles = sanitize_chance_output(rem_xbh * do_rate)
double_two = raw_doubles if soft_pct > 0.2 else 0.0
double_cf = mround(raw_doubles - double_two)
double_three = 0.0 # Reserved for special cases
rem_xbh -= (double_two + double_cf + double_three)
# Triples
triple = sanitize_chance_output(rem_xbh * tr_rate)
rem_xbh = mround(rem_xbh - triple)
# Home runs
raw_hr = rem_xbh
if hr_fb_pct < 0.08:
bp_homerun = sanitize_chance_output(raw_hr, min_chances=1.0, rounding=1.0)
homerun = 0.0
elif hr_fb_pct > 0.28:
homerun = raw_hr
bp_homerun = 0.0
elif hr_fb_pct > 0.18:
bp_homerun = sanitize_chance_output(raw_hr * 0.4, min_chances=1.0, rounding=1.0)
homerun = mround(raw_hr - bp_homerun)
else:
bp_homerun = sanitize_chance_output(raw_hr * 0.75, min_chances=1.0, rounding=1.0)
homerun = mround(raw_hr - bp_homerun)
# ===== ON-BASE DISTRIBUTION =====
# Assume 90% walks, 10% HBP
hbp = mround(all_other_ob * 0.10)
walk = mround(all_other_ob - hbp)
# ===== OUTS DISTRIBUTION =====
# Strikeouts (adjusted for K%)
# K rate is K/PA, but we need K/(AB-H) for out distribution
# Simplified: use K% directly scaled to outs
raw_so = sanitize_chance_output(all_outs * k_pct * 1.2) # 1.2x modifier from calcs_pitcher
# Cap strikeouts to leave room for other outs
current_total = (homerun + bp_homerun + triple + double_three + double_two + double_cf +
single_two + single_one + single_center + bp_single + hbp + walk)
max_so = 108 - current_total - TOTAL_XCHECK - 5 # Leave at least 5 for flyouts/groundouts
strikeout = min(raw_so, max_so)
# Remaining outs (after K and x-checks)
rem_outs = 108 - current_total - strikeout - TOTAL_XCHECK
# Flyouts vs groundouts based on FB%/GB%
total_batted = fb_pct + gb_pct
if total_batted > 0:
fb_share = fb_pct / total_batted
gb_share = gb_pct / total_batted
else:
fb_share = gb_share = 0.5
all_flyouts = sanitize_chance_output(rem_outs * fb_share)
all_groundouts = rem_outs - all_flyouts
# Distribute flyouts by field (pitcher hand affects distribution)
if pit_hand == 'L':
# Lefty: more fly balls to LF (opposite field for RHB)
flyout_lf_b = sanitize_chance_output(all_flyouts * oppo_pct)
flyout_rf_b = sanitize_chance_output(all_flyouts * 0.25)
else:
# Righty: more fly balls to RF (opposite field for LHB)
flyout_rf_b = sanitize_chance_output(all_flyouts * oppo_pct)
flyout_lf_b = sanitize_chance_output(all_flyouts * 0.25)
flyout_cf_b = all_flyouts - flyout_lf_b - flyout_rf_b
# Distribute groundouts (A = DP potential, B = routine)
groundout_a = sanitize_chance_output(all_groundouts * soft_pct)
groundout_b = sanitize_chance_output(all_groundouts - groundout_a)
# Build the rating dict
rating = {
'pitchingcard_id': 0, # Will be set on submission
'vs_hand': vs_hand,
'homerun': mround(homerun),
'bp_homerun': mround(bp_homerun),
'triple': mround(triple),
'double_three': mround(double_three),
'double_two': mround(double_two),
'double_cf': mround(double_cf),
'single_two': mround(single_two),
'single_one': mround(single_one),
'single_center': mround(single_center),
'bp_single': mround(bp_single),
'hbp': mround(hbp),
'walk': mround(walk),
'strikeout': mround(strikeout),
'flyout_lf_b': mround(flyout_lf_b),
'flyout_cf_b': mround(flyout_cf_b),
'flyout_rf_b': mround(flyout_rf_b),
'groundout_a': mround(groundout_a),
'groundout_b': mround(groundout_b),
**DEFAULT_XCHECKS,
# Calculated stats for display
'avg': avg,
'obp': obp,
'slg': slg,
}
# Verify total and adjust if needed
total = sum([
rating['homerun'], rating['bp_homerun'], rating['triple'],
rating['double_three'], rating['double_two'], rating['double_cf'],
rating['single_two'], rating['single_one'], rating['single_center'], rating['bp_single'],
rating['hbp'], rating['walk'], rating['strikeout'],
rating['flyout_lf_b'], rating['flyout_cf_b'], rating['flyout_rf_b'],
rating['groundout_a'], rating['groundout_b'],
rating['xcheck_p'], rating['xcheck_c'], rating['xcheck_1b'], rating['xcheck_2b'],
rating['xcheck_3b'], rating['xcheck_ss'], rating['xcheck_lf'], rating['xcheck_cf'], rating['xcheck_rf']
])
# Adjust strikeouts to hit exactly 108
diff = 108 - total
if abs(diff) > 0.01:
rating['strikeout'] = mround(rating['strikeout'] + diff)
return rating
def preview_pitcher():
"""Preview Tony Smehrik's ratings without submitting to database."""
print("\n" + "="*70)
print("TONY SMEHRIK - CUSTOM PITCHER PREVIEW")
print("="*70)
# Target: Combined OPS 0.585, OPS vs L 0.495
# Combined OPS formula for pitchers: (OPS_vR + OPS_vL + max(OPS_vL, OPS_vR)) / 3
# If OPS_vL = 0.495 and OPS_vR > OPS_vL:
# 0.585 = (OPS_vR + 0.495 + OPS_vR) / 3
# 1.755 = 2*OPS_vR + 0.495
# OPS_vR = (1.755 - 0.495) / 2 = 0.630
pit_hand = 'L'
# vs LHB (Same-side - Dominant)
# Target OPS: 0.465 (lowered from 0.495)
# OBP ~0.240, SLG ~0.225
print("\nCalculating vs LHB (same-side advantage)...")
vl = calculate_pitcher_rating(
vs_hand='L',
pit_hand=pit_hand,
avg=0.185, # Lowered from 0.195
obp=0.240, # Lowered from 0.252
slg=0.225, # Lowered from 0.243
bb_pct=0.055, # Slightly lower walks
k_pct=0.20, # Average K-rate
hr_per_hit=0.04,
triple_per_hit=0.02,
double_per_hit=0.22,
fb_pct=0.35, # Average flyball
gb_pct=0.45,
hard_pct=0.30,
med_pct=0.50,
soft_pct=0.20,
oppo_pct=0.26,
hr_fb_pct=0.06,
)
# vs RHB (Opposite-side - Weaker)
# Target OPS: 0.645 (raised from 0.630)
# OBP ~0.305, SLG ~0.340
print("Calculating vs RHB (opposite-side)...")
vr = calculate_pitcher_rating(
vs_hand='R',
pit_hand=pit_hand,
avg=0.245, # Raised from 0.238
obp=0.305, # Raised from 0.298
slg=0.340, # Raised from 0.332
bb_pct=0.075, # Slightly more walks
k_pct=0.08, # Very low K-rate (~8%)
hr_per_hit=0.07,
triple_per_hit=0.02,
double_per_hit=0.24,
fb_pct=0.45, # High flyball
gb_pct=0.35,
hard_pct=0.34,
med_pct=0.46,
soft_pct=0.20,
oppo_pct=0.26,
hr_fb_pct=0.09,
)
# ===== MANUAL ADJUSTMENTS =====
# Set half of ALL on-base chances (hits + BB + HBP) to HR
# Split 1:1 between HR and BP-HR
# BP-HR and HBP must be whole numbers
# vs LHB: Total OB = Hits + BB + HBP
vl_total_hits = (vl['homerun'] + vl['bp_homerun'] + vl['triple'] +
vl['double_three'] + vl['double_two'] + vl['double_cf'] +
vl['single_two'] + vl['single_one'] + vl['single_center'] + vl['bp_single'])
vl_total_ob = vl_total_hits + vl['walk'] + vl['hbp']
vl_hr_total = vl_total_ob / 2 # Half of all OB → HR
# Split 1:1, BP-HR must be whole number
vl['bp_homerun'] = round(vl_hr_total / 2) # Whole number
vl['homerun'] = round((vl_hr_total - vl['bp_homerun']) * 20) / 20
# Ensure HBP is whole number
vl['hbp'] = 1.0
# vs RHB: Total OB = Hits + BB + HBP
vr_total_hits = (vr['homerun'] + vr['bp_homerun'] + vr['triple'] +
vr['double_three'] + vr['double_two'] + vr['double_cf'] +
vr['single_two'] + vr['single_one'] + vr['single_center'] + vr['bp_single'])
vr_total_ob = vr_total_hits + vr['walk'] + vr['hbp']
vr_hr_total = vr_total_ob / 2 # Half of all OB → HR
# Split 1:1, BP-HR must be whole number
vr['bp_homerun'] = round(vr_hr_total / 2) # Whole number
vr['homerun'] = round((vr_hr_total - vr['bp_homerun']) * 20) / 20
# Ensure HBP is whole number
vr['hbp'] = 1.0
# The HR chances come FROM the existing hit pool, so we need to reduce other hits
# to keep total hits the same. Reduce singles proportionally.
vl_new_hr_total = vl['homerun'] + vl['bp_homerun']
vl_old_hr_total = 0 # Started with 0 HR
vl_hr_increase = vl_new_hr_total - vl_old_hr_total
# Reduce singles to compensate - take from single_one, single_two, bp_single
vl_singles_to_reduce = vl_hr_increase
# Take from single_one first, then single_two, then bp_single
reduce_from_one = min(vl['single_one'], vl_singles_to_reduce)
vl['single_one'] -= reduce_from_one
vl_singles_to_reduce -= reduce_from_one
reduce_from_two = min(vl['single_two'], vl_singles_to_reduce)
vl['single_two'] -= reduce_from_two
vl_singles_to_reduce -= reduce_from_two
reduce_from_bp = min(vl['bp_single'], vl_singles_to_reduce)
vl['bp_single'] -= reduce_from_bp
vl_singles_to_reduce -= reduce_from_bp
# If still need to reduce, take from doubles
if vl_singles_to_reduce > 0:
vl['double_cf'] = max(0, vl['double_cf'] - vl_singles_to_reduce)
vr_new_hr_total = vr['homerun'] + vr['bp_homerun']
vr_old_hr_total = 0.80 + 1.00 # Had some HR already
vr_hr_increase = vr_new_hr_total - vr_old_hr_total
# Reduce singles to compensate
vr_singles_to_reduce = vr_hr_increase
reduce_from_one = min(vr['single_one'], vr_singles_to_reduce)
vr['single_one'] -= reduce_from_one
vr_singles_to_reduce -= reduce_from_one
reduce_from_two = min(vr['single_two'], vr_singles_to_reduce)
vr['single_two'] -= reduce_from_two
vr_singles_to_reduce -= reduce_from_two
reduce_from_bp = min(vr['bp_single'], vr_singles_to_reduce)
vr['bp_single'] -= reduce_from_bp
vr_singles_to_reduce -= reduce_from_bp
if vr_singles_to_reduce > 0:
vr['double_cf'] = max(0, vr['double_cf'] - vr_singles_to_reduce)
# Force BP-SI to specific values
vl['bp_single'] = 5.0
vr['bp_single'] = 3.0
# Recalculate totals and adjust strikeouts to hit 108
for rating in [vl, vr]:
total = sum([
rating['homerun'], rating['bp_homerun'], rating['triple'],
rating['double_three'], rating['double_two'], rating['double_cf'],
rating['single_two'], rating['single_one'], rating['single_center'], rating['bp_single'],
rating['hbp'], rating['walk'], rating['strikeout'],
rating['flyout_lf_b'], rating['flyout_cf_b'], rating['flyout_rf_b'],
rating['groundout_a'], rating['groundout_b'],
rating['xcheck_p'], rating['xcheck_c'], rating['xcheck_1b'], rating['xcheck_2b'],
rating['xcheck_3b'], rating['xcheck_ss'], rating['xcheck_lf'], rating['xcheck_cf'], rating['xcheck_rf']
])
diff = 108 - total
rating['strikeout'] = round((rating['strikeout'] + diff) * 20) / 20
print(f"\nApplied HR adjustments + BP-SI override (vL: 5.0, vR: 3.0):")
print(f" vs LHB: Total OB was {vl_total_ob:.2f} → Half ({vl_hr_total:.2f}) to HR (HR:{vl['homerun']:.2f} + BP-HR:{vl['bp_homerun']:.0f})")
print(f" vs RHB: Total OB was {vr_total_ob:.2f} → Half ({vr_hr_total:.2f}) to HR (HR:{vr['homerun']:.2f} + BP-HR:{vr['bp_homerun']:.0f})")
# Calculate combined OPS
ops_vl = vl['obp'] + vl['slg']
ops_vr = vr['obp'] + vr['slg']
combined_ops = (ops_vr + ops_vl + max(ops_vl, ops_vr)) / 3
# Display results
print("\n" + "-"*70)
print("TONY SMEHRIK (L) - SP/RP")
print("-"*70)
print(f"\nTarget OPS: 0.585 combined, 0.495 vs L")
print(f"Actual OPS: {combined_ops:.3f} combined, {ops_vl:.3f} vs L, {ops_vr:.3f} vs R")
for rating in [vl, vr]:
vs_hand = rating['vs_hand']
print(f"\n{'='*35}")
print(f"VS {vs_hand}HB:")
print(f"{'='*35}")
ops = rating['obp'] + rating['slg']
print(f" AVG: {rating['avg']:.3f} OBP: {rating['obp']:.3f} SLG: {rating['slg']:.3f} OPS: {ops:.3f}")
# Show hit distribution
total_hits = (rating['homerun'] + rating['bp_homerun'] + rating['triple'] +
rating['double_three'] + rating['double_two'] + rating['double_cf'] +
rating['single_two'] + rating['single_one'] + rating['single_center'] + rating['bp_single'])
doubles = rating['double_cf'] + rating['double_two'] + rating['double_three']
singles = total_hits - rating['homerun'] - rating['bp_homerun'] - rating['triple'] - doubles
print(f"\n HITS ALLOWED: {total_hits:.2f}")
print(f" HR: {rating['homerun']:.2f} + BP-HR: {rating['bp_homerun']:.2f}")
print(f" 3B: {rating['triple']:.2f}")
print(f" 2B: {doubles:.2f} (double**: {rating['double_two']:.2f}, double(cf): {rating['double_cf']:.2f})")
print(f" 1B: {singles:.2f} (1B**: {rating['single_two']:.2f}, 1B*: {rating['single_one']:.2f}, 1B(cf): {rating['single_center']:.2f}, BP-1B: {rating['bp_single']:.2f})")
# Show walks/strikeouts
print(f"\n PLATE DISCIPLINE:")
print(f" BB: {rating['walk']:.2f} HBP: {rating['hbp']:.2f}")
print(f" K: {rating['strikeout']:.2f}")
# Show batted ball outs
flyouts = rating['flyout_lf_b'] + rating['flyout_cf_b'] + rating['flyout_rf_b']
groundouts = rating['groundout_a'] + rating['groundout_b']
xchecks = sum([rating[f'xcheck_{pos}'] for pos in ['p', 'c', '1b', '2b', '3b', 'ss', 'lf', 'cf', 'rf']])
total_outs = rating['strikeout'] + flyouts + groundouts + xchecks
print(f"\n OUTS: {total_outs:.2f}")
print(f" Strikeouts: {rating['strikeout']:.2f}")
print(f" Flyouts: {flyouts:.2f} (LF-B: {rating['flyout_lf_b']:.2f}, CF-B: {rating['flyout_cf_b']:.2f}, RF-B: {rating['flyout_rf_b']:.2f})")
print(f" Groundouts: {groundouts:.2f} (A: {rating['groundout_a']:.2f}, B: {rating['groundout_b']:.2f})")
print(f" X-Checks: {xchecks:.2f} (P:{rating['xcheck_p']:.0f} C:{rating['xcheck_c']:.0f} 1B:{rating['xcheck_1b']:.0f} 2B:{rating['xcheck_2b']:.0f} 3B:{rating['xcheck_3b']:.0f} SS:{rating['xcheck_ss']:.0f} LF:{rating['xcheck_lf']:.0f} CF:{rating['xcheck_cf']:.0f} RF:{rating['xcheck_rf']:.0f})")
batted_outs = flyouts + groundouts
if batted_outs > 0:
fb_rate = flyouts / batted_outs
gb_rate = groundouts / batted_outs
print(f" FB%: {fb_rate*100:.1f}% GB%: {gb_rate*100:.1f}%")
# Verify total = 108
total = sum([
rating['homerun'], rating['bp_homerun'], rating['triple'],
rating['double_three'], rating['double_two'], rating['double_cf'],
rating['single_two'], rating['single_one'], rating['single_center'], rating['bp_single'],
rating['hbp'], rating['walk'], rating['strikeout'],
rating['flyout_lf_b'], rating['flyout_cf_b'], rating['flyout_rf_b'],
rating['groundout_a'], rating['groundout_b'],
rating['xcheck_p'], rating['xcheck_c'], rating['xcheck_1b'], rating['xcheck_2b'],
rating['xcheck_3b'], rating['xcheck_ss'], rating['xcheck_lf'], rating['xcheck_cf'], rating['xcheck_rf']
])
print(f"\n Total chances: {total:.2f} (should be 108.0)")
# Summary
print("\n" + "="*70)
print("SUMMARY")
print("="*70)
print(f"\nName: Tony Smehrik")
print(f"Hand: L")
print(f"Positions: SP, RP")
print(f"Starter Rating: 5 | Relief Rating: 5 | Closer Rating: None")
print(f"\nCombined OPS Against: {combined_ops:.3f} (target: 0.585)")
print(f"OPS vs LHB: {ops_vl:.3f} (target: 0.495)")
print(f"OPS vs RHB: {ops_vr:.3f} (target: ~0.630)")
print(f"\nK-rate vs L: ~20% (average)")
print(f"K-rate vs R: ~12% (very low)")
print(f"\nFB% vs L: ~35% (average)")
print(f"FB% vs R: ~45% (high)")
print("\n" + "-"*70)
print("Ready to submit? Run: python -m custom_cards.submit_tony_smehrik")
print("-"*70 + "\n")
return vl, vr
if __name__ == "__main__":
preview_pitcher()

172
docs/CUSTOM_CARD_TIERS.md Normal file
View File

@ -0,0 +1,172 @@
# Custom Card Tier System
Custom cards are rewards earned by Paper Dynasty players. Players earn their initial card and subsequent upgrades through gameplay achievements.
## Progression Path
Cards progress through tiers in this order:
```
Low Starter → Mid Starter → High Starter →
Low All-Star → Mid All-Star → High All-Star →
Low MVP → Mid MVP → High MVP →
Low HoF → Mid HoF → High HoF →
(Legendary tiers follow .x20/.x50/.x80 pattern)
```
## Target OPS by Tier
### Batters
| Tier | Code | Target OPS | Notes |
|------|------|------------|-------|
| Low Starter | L-STR | 0.820 | Entry-level custom card |
| Mid Starter | M-STR | 0.850 | First upgrade |
| High Starter | H-STR | 0.880 | |
| Low All-Star | L-AS | 0.920 | |
| Mid All-Star | M-AS | 0.950 | |
| High All-Star | H-AS | 0.980 | |
| Low MVP | L-MVP | 1.025 | |
| Mid MVP | M-MVP | 1.075 | |
| High MVP | H-MVP | 1.150 | |
| Low Hall of Fame | L-HOF | 1.220 | |
| Mid Hall of Fame | M-HOF | 1.250 | |
| High Hall of Fame | H-HOF | 1.280 | |
| Legendary+ | LEG-1 | 1.320 | Further upgrades follow pattern |
| Legendary++ | LEG-2 | 1.350 | |
| Legendary+++ | LEG-3 | 1.380 | |
**Pattern for Legendary+:** Each subsequent tier adds +0.030 OPS following the .x20/.x50/.x80 pattern.
### Pitchers (OPS Allowed)
Pitchers use inverted logic - lower OPS allowed = better pitcher. **Starting pitchers and relievers have separate progressions** - relievers are held to a higher standard (lower OPS) at each tier.
#### Starting Pitchers
| Tier | Code | Target OPS | Notes |
|------|------|------------|-------|
| Low Starter | L-STR | 0.580 | Entry-level custom card |
| Mid Starter | M-STR | 0.560 | |
| High Starter | H-STR | 0.540 | |
| Low All-Star | L-AS | 0.520 | |
| Mid All-Star | M-AS | 0.500 | |
| High All-Star | H-AS | 0.485 | |
| Low MVP | L-MVP | 0.460 | |
| Mid MVP | M-MVP | 0.435 | |
| High MVP | H-MVP | 0.410 | |
| Low Hall of Fame | L-HOF | 0.390 | |
| Mid Hall of Fame | M-HOF | 0.370 | |
| High Hall of Fame | H-HOF | 0.350 | |
**Beyond H-HOF:** Decrease OPS by 0.015 per upgrade (0.335 → 0.320 → 0.305...)
#### Relief Pitchers
| Tier | Code | Target OPS | Notes |
|------|------|------------|-------|
| Low Starter | L-STR | 0.540 | Entry-level custom card |
| Mid Starter | M-STR | 0.515 | |
| High Starter | H-STR | 0.490 | |
| Low All-Star | L-AS | 0.465 | |
| Mid All-Star | M-AS | 0.440 | |
| High All-Star | H-AS | 0.415 | |
| Low MVP | L-MVP | 0.380 | |
| Mid MVP | M-MVP | 0.360 | |
| High MVP | H-MVP | 0.340 | |
| Low Hall of Fame | L-HOF | 0.320 | |
| Mid Hall of Fame | M-HOF | 0.300 | |
| High Hall of Fame | H-HOF | 0.280 | |
**Beyond H-HOF:** Decrease OPS by 0.020 per upgrade (0.260 → 0.240 → 0.220...)
## YAML Profile Schema
Each custom card profile should include tier tracking:
```yaml
name: Player Name
player_type: batter # or pitcher
pitcher_role: starter # starter or reliever (pitchers only)
hand: R
# Tier tracking
tier: H-STR # Current tier code
tier_history: # Upgrade history
- tier: L-STR
date: 2025-01-15
reason: Initial card creation
- tier: M-STR
date: 2025-03-20
reason: Season 5 championship reward
- tier: H-STR
date: 2025-06-10
reason: MVP award
target_ops: 0.880 # Should match tier target (use SP or RP table for pitchers)
cardset_id: 29
player_id: 12345
batting_card_id: 6789 # or pitching_card_id for pitchers
# ... rest of profile
```
## Upgrade Process
When upgrading a card:
1. **Identify current tier** from profile
2. **Determine next tier** from progression path
3. **Calculate OPS delta** needed (next_target - current_target)
4. **Distribute delta** across ratings:
- Increase positive outcomes (hits, walks)
- Decrease negative outcomes (strikeouts, outs)
- Maintain player's characteristic style (spray charts, power/contact balance)
5. **Update profile**:
- Set new `tier` code
- Update `target_ops` to new tier target
- Add entry to `tier_history`
- Update `ratings` with new values
6. **Submit to database** via `pd-cards custom submit`
## CLI Commands
```bash
# Preview current tier status
pd-cards custom preview kalin_young
# Upgrade a player to next tier
pd-cards custom upgrade kalin_young --reason "Season 6 reward"
# Upgrade to specific tier
pd-cards custom upgrade kalin_young --to-tier M-AS --reason "Special event"
# List all players by tier
pd-cards custom list --by-tier
```
## OPS Calculation Reference
For batters, Combined OPS uses the formula:
```
combined_ops = (ops_vL + ops_vR + min(ops_vL, ops_vR)) / 3
```
For pitchers (OPS allowed):
```
combined_ops = (ops_vL + ops_vR + max(ops_vL, ops_vR)) / 3
```
This weights the weaker split more heavily for batters (penalizes platoon weaknesses) and the stronger split for pitchers (rewards same-side dominance).
## Maintaining Player Identity
When upgrading, preserve the player's characteristic profile:
- **Power hitters**: Increase HR/XBH proportionally
- **Contact hitters**: Increase singles, reduce strikeouts
- **Patient hitters**: Increase walks
- **Speedsters**: Improve baserunning stats alongside OPS
- **Defensive specialists**: May upgrade defense alongside OPS
Avoid creating "generic good players" - each upgrade should feel like a natural evolution of the player's established style.

View File

@ -0,0 +1,657 @@
# pd-cards CLI Reference
Complete command reference for the Paper Dynasty card creation CLI.
## Installation
```bash
# Install in development mode
uv pip install -e .
# Verify installation
pd-cards version
```
## Command Structure
```
pd-cards <subcommand> <command> [OPTIONS]
```
**Subcommands:**
- `custom` - Custom character card management
- `live-series` - Live season card updates
- `retrosheet` - Historical Retrosheet data processing
- `scouting` - Scouting report generation
- `upload` - Card image upload to S3
---
## custom - Custom Character Cards
Manage fictional player cards using YAML profiles stored in `pd_cards/custom/profiles/`.
### custom list
List all available character profiles.
```bash
pd-cards custom list
```
**Output:** Table showing name, type (batter/pitcher), target OPS, positions, and player ID.
---
### custom preview
Preview a character's calculated ratings without submitting.
```bash
pd-cards custom preview <character>
```
**Arguments:**
- `character` - Profile name (e.g., `kalin_young`, `tony_smehrik`)
**Output:** Displays:
- Player info (name, hand, target OPS, IDs)
- Defensive positions with ratings
- Batting/pitching ratings vs L and R
- Calculated AVG/OBP/SLG/OPS for each platoon split
- Verification that ratings total 108
**Example:**
```bash
pd-cards custom preview kalin_young
```
---
### custom submit
Submit a custom character to the database.
```bash
pd-cards custom submit <character> [OPTIONS]
```
**Arguments:**
- `character` - Profile name
**Options:**
| Option | Short | Description |
|--------|-------|-------------|
| `--dry-run` | `-n` | Preview changes without saving |
| `--skip-s3` | | Skip S3 upload after submit |
**Requirements:** Profile must have valid `player_id` and `batting_card_id`/`pitching_card_id` set.
**Example:**
```bash
pd-cards custom submit kalin_young --dry-run # Validate first
pd-cards custom submit kalin_young # Actually submit
```
---
### custom new
Create a new character profile template.
```bash
pd-cards custom new --name <name> [OPTIONS]
```
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--name` | `-n` | (required) | Character name |
| `--type` | `-t` | `batter` | Player type (`batter` or `pitcher`) |
| `--hand` | `-h` | `R` | Batting/throwing hand (`L`, `R`, or `S` for switch) |
| `--target-ops` | | `0.750` | Target OPS (or OPS allowed for pitchers) |
**Output:** Creates YAML file at `pd_cards/custom/profiles/<name>.yaml`
**Examples:**
```bash
pd-cards custom new --name "Mike Power" --type batter --hand R --target-ops 0.850
pd-cards custom new --name "Sandy Lefty" --type pitcher --hand L --target-ops 0.620
```
---
## live-series - Live Season Updates
Generate cards from current season FanGraphs/Baseball Reference data.
### live-series update
Update live series cards from statistical data.
```bash
pd-cards live-series update --cardset <name> [OPTIONS]
```
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--cardset` | `-c` | (required) | Target cardset name |
| `--season` | `-s` | (from cardset) | Season year |
| `--games` | `-g` | `162` | Number of games played (1-162) |
| `--description` | `-d` | (year) | Player description |
| `--pull-fielding/--no-pull-fielding` | | `True` | Pull fielding stats from Baseball Reference |
| `--post-batters/--skip-batters` | | `True` | Post batting cards and ratings |
| `--post-pitchers/--skip-pitchers` | | `True` | Post pitching cards and ratings |
| `--post-fielders/--skip-fielders` | | `True` | Post card positions |
| `--post-players/--skip-players` | | `True` | Post player updates |
| `--live/--not-live` | | `True` | Look up current MLB clubs from statsapi |
| `--ignore-limits` | | `False` | Ignore minimum PA/TBF requirements |
| `--dry-run` | `-n` | `False` | Preview without saving to database |
**Data Input:** Reads from `data-input/{cardset} Cardset/`
**Examples:**
```bash
pd-cards live-series update --cardset "2025 Season" --games 81 --dry-run
pd-cards live-series update --cardset "2025 Season" --games 162
pd-cards live-series update --cardset "2025 Season" --games 40 --ignore-limits
```
---
### live-series status
Show status of live series cardsets.
```bash
pd-cards live-series status [OPTIONS]
```
**Options:**
| Option | Short | Description |
|--------|-------|-------------|
| `--cardset` | `-c` | Filter by cardset name |
**Output:** Table showing cardset ID, name, season, and player count.
**Examples:**
```bash
pd-cards live-series status
pd-cards live-series status --cardset "2025"
```
---
## retrosheet - Historical Data Processing
Generate cards from Retrosheet play-by-play historical data.
### retrosheet process
Process Retrosheet data and create player cards.
```bash
pd-cards retrosheet process <year> --cardset-id <id> [OPTIONS]
```
**Arguments:**
- `year` - Season year to process (e.g., 2005)
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--cardset-id` | `-c` | (required) | Target cardset ID |
| `--description` | `-d` | `Live` | Player description (`Live` or `<Month> PotM`) |
| `--start` | | `{year}0301` | Start date YYYYMMDD |
| `--end` | | `{year}1002` | End date YYYYMMDD |
| `--events` | `-e` | `retrosheets_events_{year}.csv` | Retrosheet events CSV filename |
| `--input` | `-i` | `data-input/{year} Live Cardset/` | Data input directory |
| `--season-pct` | | `1.0` | Season percentage (0.0-1.0) |
| `--min-pa-vl` | | `20` (Live) / `1` (PotM) | Minimum PA vs LHP |
| `--min-pa-vr` | | `40` (Live) / `1` (PotM) | Minimum PA vs RHP |
| `--post/--no-post` | | `True` | Post data to database |
| `--dry-run` | `-n` | `False` | Preview without saving |
**Examples:**
```bash
pd-cards retrosheet process 2005 --cardset-id 27 --description Live --dry-run
pd-cards retrosheet process 2005 --cardset-id 27 --description Live
pd-cards retrosheet process 2005 --cardset-id 28 --description "April PotM" --start 20050401 --end 20050430
```
---
### retrosheet arms
Generate outfield arm ratings from Retrosheet play-by-play data.
```bash
pd-cards retrosheet arms <year> --events <file> [OPTIONS]
```
**Arguments:**
- `year` - Season year
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--events` | `-e` | (required) | Retrosheet events CSV file |
| `--output` | `-o` | `data-output/retrosheet_arm_ratings_{year}.csv` | Output CSV file |
| `--season-pct` | | `1.0` | Season percentage for min sample |
**Output:** CSV with player_id, position, arm_rating, balls_fielded, assist_rate, z_score
**Example:**
```bash
pd-cards retrosheet arms 2005 --events data-input/retrosheet/retrosheets_events_2005.csv
```
---
### retrosheet validate
Validate positions for a cardset.
```bash
pd-cards retrosheet validate <cardset_id> [OPTIONS]
```
**Arguments:**
- `cardset_id` - Cardset ID to validate
**Options:**
| Option | Default | Description |
|--------|---------|-------------|
| `--api` | `https://pd.manticorum.com/api` | API URL |
**Checks:**
- Anomalous DH counts (should be <5 for full-season cards)
- Missing outfield positions (LF, CF, RF)
**Example:**
```bash
pd-cards retrosheet validate 27
pd-cards retrosheet validate 27 --api https://pddev.manticorum.com/api
```
---
### retrosheet defense
Fetch defensive statistics from Baseball Reference.
```bash
pd-cards retrosheet defense <year> [OPTIONS]
```
**Arguments:**
- `year` - Season year to fetch
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--output` | `-o` | `data-input/{year} Live Cardset/` | Output directory |
**Output:** Creates `defense_{position}.csv` files for: c, 1b, 2b, 3b, ss, lf, cf, rf, of, p
**Note:** Includes 8-second delay between requests for rate limiting.
**Example:**
```bash
pd-cards retrosheet defense 2005 --output "data-input/2005 Live Cardset/"
```
---
## scouting - Scouting Reports
Generate scouting reports and ratings comparisons.
### scouting batters
Generate batting scouting reports.
```bash
pd-cards scouting batters [OPTIONS]
```
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--cardset-id` | `-c` | (all) | Cardset ID(s) to include (repeatable) |
| `--output` | `-o` | `scouting` | Output directory |
**Output:** Creates `batting-basic.csv` and `batting-ratings.csv`
**Examples:**
```bash
pd-cards scouting batters --cardset-id 27
pd-cards scouting batters --cardset-id 27 --cardset-id 29
pd-cards scouting batters --output reports/
```
---
### scouting pitchers
Generate pitching scouting reports.
```bash
pd-cards scouting pitchers [OPTIONS]
```
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--cardset-id` | `-c` | (all) | Cardset ID(s) to include (repeatable) |
| `--output` | `-o` | `scouting` | Output directory |
**Output:** Creates `pitching-basic.csv` and `pitching-ratings.csv`
**Examples:**
```bash
pd-cards scouting pitchers --cardset-id 27
pd-cards scouting pitchers --cardset-id 27 --cardset-id 29
```
---
### scouting all
Generate all scouting reports (batters and pitchers).
```bash
pd-cards scouting all [OPTIONS]
```
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--cardset-id` | `-c` | (all) | Cardset ID(s) to include (repeatable) |
| `--output` | `-o` | `scouting` | Output directory |
**Output:** Creates all four scouting CSV files.
**Example:**
```bash
pd-cards scouting all --cardset-id 27
```
---
## upload - S3 Card Upload
Upload card images to AWS S3.
### upload s3
Upload card images to AWS S3 bucket.
```bash
pd-cards upload s3 --cardset <name> [OPTIONS]
```
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--cardset` | `-c` | (required) | Cardset name to upload |
| `--start-id` | | | Player ID to start from (for resuming) |
| `--limit` | `-l` | | Limit number of cards to process |
| `--html` | | `False` | Upload HTML preview cards instead of PNG |
| `--skip-batters` | | `False` | Skip batting cards |
| `--skip-pitchers` | | `False` | Skip pitching cards |
| `--upload/--no-upload` | | `True` | Upload to S3 |
| `--update-urls/--no-update-urls` | | `True` | Update player URLs in database |
| `--dry-run` | `-n` | `False` | Preview without uploading |
**Prerequisites:** AWS CLI configured with credentials (`~/.aws/credentials`)
**S3 URL Structure:** `cards/cardset-{id:03d}/player-{player_id}/{batting|pitching}card.png?d={date}`
**Examples:**
```bash
pd-cards upload s3 --cardset "2005 Live" --dry-run
pd-cards upload s3 --cardset "2005 Live" --limit 10
pd-cards upload s3 --cardset "2005 Live" --start-id 5000
pd-cards upload s3 --cardset "2005 Live" --skip-pitchers
```
---
### upload refresh
Refresh (re-generate and re-upload) card images.
```bash
pd-cards upload refresh --cardset <name> [OPTIONS]
```
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--cardset` | `-c` | (required) | Cardset name |
| `--limit` | `-l` | | Limit number of cards |
| `--dry-run` | `-n` | `False` | Preview without refreshing |
**Example:**
```bash
pd-cards upload refresh --cardset "2005 Live" --limit 10
```
---
### upload check
Check and validate card images without uploading.
```bash
pd-cards upload check --cardset <name> [OPTIONS]
```
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--cardset` | `-c` | (required) | Cardset name |
| `--limit` | `-l` | | Limit number of cards to check |
| `--output` | `-o` | `data-output` | Output directory |
**Example:**
```bash
pd-cards upload check --cardset "2005 Live" --limit 10
```
---
## YAML Profile Schema
**See also:** [`docs/CUSTOM_CARD_TIERS.md`](CUSTOM_CARD_TIERS.md) for tier progression system.
### Batter Profile
```yaml
name: Player Name
player_type: batter
hand: R # L, R, or S (switch)
# Tier tracking (see CUSTOM_CARD_TIERS.md for values)
tier: M-STR # Current tier code
target_ops: 0.850 # Must match tier target
tier_history:
- tier: L-STR
date: 2025-01-15
reason: Initial card creation
- tier: M-STR
date: 2025-03-20
reason: Season 5 championship reward
cardset_id: 29
player_id: null # Set after creation
batting_card_id: null
positions:
RF:
range: 3 # 1-5 (lower = better)
error: 7 # 1-20 (higher = better)
arm: 0 # -6 to +5 (negative = strong)
LF:
range: 4
error: 6
arm: 2
baserunning:
steal_jump: 0.166667 # Fraction for steal success
steal_high: 15 # High end of steal range
steal_low: 8 # Low end of steal range
steal_auto: 0 # Auto-steal threshold
running: 12 # Base running rating
hit_and_run: A # A/B/C/D
bunting: B # A/B/C/D
ratings:
vs_L: # All values must sum to 108
homerun: 2.0
bp_homerun: 1.0 # Ballpark home run
triple: 0.5
double_three: 0.0 # Double to 3-zone
double_two: 4.0 # Double to 2-zone
double_pull: 2.0 # Pulled double
single_two: 5.0
single_one: 5.0
single_center: 5.0
bp_single: 3.0 # Ballpark single
walk: 10.0
hbp: 1.0
strikeout: 20.0
lineout: 12.0
popout: 2.0
flyout_a: 2.0
flyout_bq: 2.0
flyout_lf_b: 4.0
flyout_rf_b: 4.0
groundout_a: 5.0
groundout_b: 10.0
groundout_c: 8.5
pull_rate: 0.30
center_rate: 0.40
slap_rate: 0.30
vs_R:
# Same structure as vs_L
```
### Pitcher Profile
```yaml
name: Pitcher Name
player_type: pitcher
hand: L
target_ops: 0.620 # OPS allowed
cardset_id: 29
player_id: null
pitching_card_id: null
positions:
P: null
pitching:
starter_rating: 5 # 1-5 (higher = better starter)
relief_rating: 5 # 1-5 (higher = better reliever)
closer_rating: null # null or 1-5 for closers
ratings:
vs_L: # All values must sum to 108 (includes 29 for x-checks)
homerun: 1.0
bp_homerun: 1.0
triple: 0.5
double_three: 0.0
double_two: 3.0
double_cf: 2.0 # Pitchers use double_cf, not double_pull
single_two: 4.0
single_one: 3.0
single_center: 4.0
bp_single: 5.0
walk: 8.0
hbp: 1.0
strikeout: 20.0
flyout_lf_b: 4.0
flyout_cf_b: 5.0 # Pitchers have flyout_cf_b
flyout_rf_b: 4.0
groundout_a: 6.0
groundout_b: 7.5
# X-checks (fielder plays) - must sum to 29
xcheck_p: 1.0
xcheck_c: 3.0
xcheck_1b: 2.0
xcheck_2b: 6.0
xcheck_3b: 3.0
xcheck_ss: 7.0
xcheck_lf: 2.0
xcheck_cf: 3.0
xcheck_rf: 2.0
vs_R:
# Same structure as vs_L
```
---
## Common Workflows
### Creating a New Custom Character
```bash
# 1. Create template
pd-cards custom new --name "John Slugger" --type batter --hand R --target-ops 0.850
# 2. Edit the YAML file
# pd_cards/custom/profiles/john_slugger.yaml
# 3. Preview ratings
pd-cards custom preview john_slugger
# 4. Create player in database (get IDs)
# Use Paper Dynasty admin interface
# 5. Update YAML with player_id and batting_card_id
# 6. Submit
pd-cards custom submit john_slugger --dry-run
pd-cards custom submit john_slugger
```
### Processing a Historical Season
```bash
# 1. Fetch defensive stats
pd-cards retrosheet defense 2005 --output "data-input/2005 Live Cardset/"
# 2. Generate arm ratings
pd-cards retrosheet arms 2005 --events data-input/retrosheet/retrosheets_events_2005.csv
# 3. Process cards
pd-cards retrosheet process 2005 --cardset-id 27 --description Live --dry-run
pd-cards retrosheet process 2005 --cardset-id 27 --description Live
# 4. Validate positions
pd-cards retrosheet validate 27
# 5. Upload to S3
pd-cards upload s3 --cardset "2005 Live" --dry-run
pd-cards upload s3 --cardset "2005 Live"
# 6. Generate scouting reports
pd-cards scouting all --cardset-id 27
```
### Updating Live Season Cards
```bash
# Mid-season update (81 games)
pd-cards live-series update --cardset "2025 Season" --games 81 --dry-run
pd-cards live-series update --cardset "2025 Season" --games 81
# Full season update
pd-cards live-series update --cardset "2025 Season" --games 162
# Upload updated cards
pd-cards upload s3 --cardset "2025 Season"
```

670
pd_cards/core/scouting.py Normal file
View File

@ -0,0 +1,670 @@
"""
Scouting report generation core logic.
Business logic for generating batting and pitching scouting reports.
"""
import asyncio
import datetime
from functools import partial
import multiprocessing
from pathlib import Path
from typing import Literal, Optional, List
import pandas as pd
# These imports are resolved at runtime when called from CLI
# since the CLI adds the parent directory to sys.path
from db_calls import db_get
from exceptions import logger, log_exception
# =============================================================================
# Shared Utilities
# =============================================================================
def log_time(
which: Literal['start', 'end'],
message: str = '',
print_to_console: bool = True,
start_time: datetime.datetime = None
) -> Optional[datetime.datetime]:
"""Log timing information for operations."""
if print_to_console and len(message) == 0:
log_exception(KeyError, 'A message must be included when print_to_console equals True')
if which == 'start':
logger.info(f'starting timer - {message}')
if print_to_console:
print(message)
return datetime.datetime.now()
elif start_time is not None:
logger.info(f'ending timer - {message}: {(datetime.datetime.now() - start_time).total_seconds():.2f}s\n')
if print_to_console:
print(f'{message}\n')
return None
else:
log_exception(KeyError, 'start_time must be passed to log_time() when which equals \'end\'')
async def fetch_data(data: tuple) -> dict:
"""Fetch data from API endpoint."""
start_time = log_time('start', print_to_console=False)
this_query = await db_get(endpoint=data[0], params=data[1])
log_time('end', print_to_console=False, start_time=start_time)
return this_query
# =============================================================================
# Batting Scouting
# =============================================================================
def build_series(label: str, code: str, pos_code: str, all_positions: list) -> pd.Series:
"""Build a pandas Series from position data."""
logger.info(f'Building {label} series for {pos_code}')
return pd.Series(
dict([(x['player']['player_id'], x[code]) for x in all_positions if x['position'] == pos_code]),
name=f'{label} {pos_code}'
)
def build_ranges(all_positions: list, pos_code: str) -> pd.Series:
"""Build range rating series for a position."""
return build_series('Range', 'range', pos_code, all_positions)
def build_errors(all_positions: list, pos_code: str) -> pd.Series:
"""Build error rating series for a position."""
x = build_series('Error', 'error', pos_code, all_positions)
logger.info(f'error ratings:\n{x}')
return x
def build_of_arms(all_positions: list, pos_code: str) -> pd.Series:
"""Build outfield arm rating series."""
logger.info(f'Building OF series for {pos_code}')
return pd.Series(
dict([(x['player']['player_id'], x['arm']) for x in all_positions if x['position'] == pos_code]),
name='Arm OF'
)
def build_c_arms(all_positions: list, pos_code: str) -> pd.Series:
"""Build catcher arm rating series."""
x = build_series('Arm', 'arm', pos_code, all_positions)
logger.info(f'arm ratings:\n{x}')
return x
def build_c_pb(all_positions: list, pos_code: str) -> pd.Series:
"""Build catcher passed ball rating series."""
return build_series('PB', 'pb', pos_code, all_positions)
def build_c_throw(all_positions: list, pos_code: str) -> pd.Series:
"""Build catcher overthrow rating series."""
return build_series('Throw', 'overthrow', pos_code, all_positions)
async def get_batting_scouting_dfs(cardset_ids: List[int] = None) -> pd.DataFrame:
"""
Fetch and build batting scouting dataframes from API.
Args:
cardset_ids: List of cardset IDs to filter by (empty = all)
Returns:
DataFrame with batting ratings and defensive positions joined
"""
cardset_ids = cardset_ids or []
cardset_params = [('cardset_id', x) for x in cardset_ids]
ratings_params = [('team_id', 31), ('ts', 's37136685556r6135248705'), *cardset_params]
API_CALLS = [
('battingcardratings', [('vs_hand', 'vL'), *ratings_params]),
('battingcardratings', [('vs_hand', 'vR'), *ratings_params]),
('cardpositions', cardset_params)
]
start_time = log_time('start', message='Pulling all batting card ratings and positions')
tasks = [fetch_data(params) for params in API_CALLS]
api_data = await asyncio.gather(*tasks)
log_time('end', f'Pulled {api_data[0]["count"] + api_data[1]["count"]} batting card ratings and {api_data[2]["count"]} positions', start_time=start_time)
start_time = log_time('start', message='Building base dataframes')
vl_vals = api_data[0]['ratings']
for x in vl_vals:
x.update(x['battingcard'])
x['player_id'] = x['battingcard']['player']['player_id']
x['player_name'] = x['battingcard']['player']['p_name']
x['rarity'] = x['battingcard']['player']['rarity']['name']
x['cardset_id'] = x['battingcard']['player']['cardset']['id']
x['cardset_name'] = x['battingcard']['player']['cardset']['name']
del x['battingcard']
del x['player']
vr_vals = api_data[1]['ratings']
for x in vr_vals:
x['player_id'] = x['battingcard']['player']['player_id']
del x['battingcard']
vl = pd.DataFrame(vl_vals)
vr = pd.DataFrame(vr_vals)
log_time('end', 'Base dataframes are complete', start_time=start_time)
start_time = log_time('start', message='Building combined dataframe')
bat_df = pd.merge(vl, vr, on='player_id', suffixes=('_vl', '_vr')).set_index('player_id', drop=False)
log_time('end', 'Combined dataframe is complete', start_time=start_time)
POSITION_DATA = api_data[2]['positions']
series_list = []
POSITIONS = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
start_time = log_time('start', message='Building range series')
with multiprocessing.Pool(processes=min(8, multiprocessing.cpu_count())) as pool:
get_ranges = partial(build_ranges, POSITION_DATA)
ranges = pool.map(get_ranges, POSITIONS)
series_list.extend(ranges)
log_time('end', f'Processed {len(ranges)} position ranges', start_time=start_time)
start_time = log_time('start', message='Building error series')
with multiprocessing.Pool(processes=min(8, multiprocessing.cpu_count())) as pool:
get_errors = partial(build_errors, POSITION_DATA)
errors = pool.map(get_errors, POSITIONS)
series_list.extend(errors)
log_time('end', f'Processed {len(errors)} position errors', start_time=start_time)
start_time = log_time('start', message='Building OF arm series')
lf_arms = build_of_arms(POSITION_DATA, 'LF')
cf_arms = build_of_arms(POSITION_DATA, 'CF')
rf_arms = build_of_arms(POSITION_DATA, 'RF')
combined_series = lf_arms.combine(cf_arms, max, fill_value=0)
combined_series = combined_series.combine(rf_arms, max, fill_value=0)
series_list.extend([combined_series])
log_time('end', f'Processed {len(combined_series)} OF arms', start_time=start_time)
start_time = log_time('start', message='Building C arm series')
c_arms = build_c_arms(POSITION_DATA, 'C')
series_list.extend([c_arms])
log_time('end', f'Processed {len(c_arms)} catcher arms', start_time=start_time)
start_time = log_time('start', message='Building C PB series')
with multiprocessing.Pool(processes=min(8, multiprocessing.cpu_count())) as pool:
get_pb = partial(build_c_pb, POSITION_DATA)
passed_ball = pool.map(get_pb, ['C'])
series_list.extend(passed_ball)
log_time('end', f'Processed {len(passed_ball)} C PB series', start_time=start_time)
start_time = log_time('start', message='Building C OT series')
with multiprocessing.Pool(processes=min(8, multiprocessing.cpu_count())) as pool:
get_throw = partial(build_c_throw, POSITION_DATA)
overthrows = pool.map(get_throw, ['C'])
series_list.extend(overthrows)
log_time('end', f'Processed {len(overthrows)} C OT series', start_time=start_time)
logger.info(f'series_list: {series_list}')
return bat_df.join(series_list)
async def calc_batting_basic(batting_dfs: pd.DataFrame, output_dir: Path) -> None:
"""
Calculate basic batting scouting metrics and save to CSV.
Args:
batting_dfs: DataFrame with batting ratings
output_dir: Directory to save output CSV
"""
def get_raw_speed(df_data):
speed_raw = df_data['running'] / 20 + df_data['steal_jump']
if df_data['steal_auto']:
speed_raw += 0.5
return speed_raw
start_time = log_time('start', 'Beginning Speed calcs')
raw_series = batting_dfs.apply(get_raw_speed, axis=1)
rank_series = raw_series.rank(pct=True)
batting_dfs['Speed'] = round(rank_series * 100)
log_time('end', 'Done Speed calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Stealing calcs')
def get_raw_steal(df_data):
return (
((df_data['steal_high'] / 20) + (df_data['steal_low'] / 20)) * df_data['steal_jump']
)
raw_series = batting_dfs.apply(get_raw_steal, axis=1)
rank_series = raw_series.rank(pct=True)
batting_dfs['Steal'] = round(rank_series * 100)
log_time('end', 'Done Stealing calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Reaction calcs')
def get_raw_reaction(df_data):
raw_total = 0
for pos_range in [df_data['Range C'], df_data['Range 1B'], df_data['Range 2B'], df_data['Range 3B'],
df_data['Range SS'], df_data['Range LF'], df_data['Range CF'], df_data['Range RF']]:
if pd.notna(pos_range):
raw_total += 10 ** (5 - pos_range)
return raw_total
raw_series = batting_dfs.apply(get_raw_reaction, axis=1)
rank_series = raw_series.rank(pct=True)
batting_dfs['Reaction'] = round(rank_series * 100)
log_time('end', 'Done Reaction calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Arm calcs')
def get_raw_arm(df_data):
of_arm = None
of_pos = None
if pd.notna(df_data['Range RF']):
of_pos = 'RF'
elif pd.notna(df_data['Range CF']):
of_pos = 'CF'
elif pd.notna(df_data['Range LF']):
of_pos = 'LF'
if of_pos is not None:
if df_data['Arm OF'] < 0:
of_raw = df_data['Arm OF'] * -10
else:
of_raw = (5 - df_data['Arm OF'])
if of_pos == 'RF':
of_raw = of_raw * 1.5
of_raw += ((6 - df_data['Range RF']) * 4)
elif of_pos == 'CF':
of_raw += ((6 - df_data['Range CF']) * 3)
elif of_pos == 'LF':
of_raw = of_raw / 2
of_raw += ((6 - df_data['Range LF']) * 2)
of_arm = of_raw
if_arm = None
if pd.notna(df_data['Range 3B']) or pd.notna(df_data['Range 2B']) or pd.notna(df_data['Range 1B']) or \
pd.notna(df_data['Range SS']):
range_totals = 0
if pd.notna(df_data['Range 3B']):
range_totals += ((6 - df_data['Range 3B']) * 5)
if pd.notna(df_data['Range SS']):
range_totals += ((6 - df_data['Range SS']) * 4)
if pd.notna(df_data['Range 2B']):
range_totals += ((6 - df_data['Range 2B']) * 3)
if pd.notna(df_data['Range 1B']):
range_totals += (6 - df_data['Range 1B'])
if_arm = 100 - (50 - range_totals)
c_arm = None
if pd.notna(df_data['Arm C']):
if df_data['Arm C'] == -5:
c_arm = 100
else:
temp_arm = 20 + ((10 - df_data['Arm C']) * 3) + (20 - df_data['PB C']) + (20 - df_data['Throw C']) - \
df_data['Error C']
c_arm = min(100, temp_arm)
if c_arm is not None:
return c_arm
elif of_arm is not None:
return of_arm
elif if_arm is not None:
return if_arm
else:
return 1
raw_series = batting_dfs.apply(get_raw_arm, axis=1)
rank_series = raw_series.rank(pct=True)
batting_dfs['Arm'] = round(rank_series * 100)
log_time('end', 'Done Arm calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Fielding calcs')
def get_raw_fielding(df_data):
if_error, of_error, c_error = 0, 0, 0
denom = 0
if pd.notna(df_data['Error 3B']) or pd.notna(df_data['Error 2B']) or pd.notna(df_data['Error 1B']) or \
pd.notna(df_data['Error SS']):
raw_if = 100
if pd.notna(df_data['Error 3B']):
raw_if -= (df_data['Error 3B'] * 2)
if pd.notna(df_data['Error SS']):
raw_if -= (df_data['Error SS'] * .75)
if pd.notna(df_data['Error 2B']):
raw_if -= (df_data['Error 2B'] * 1.25)
if pd.notna(df_data['Error 1B']):
raw_if -= (df_data['Error 1B'] * 2)
if_error = max(1, raw_if)
denom += 1
if pd.notna(df_data['Error LF']) or pd.notna(df_data['Error CF']) or pd.notna(df_data['Error RF']):
raw_of = 100
if pd.notna(df_data['Error LF']):
raw_of -= (df_data['Error LF'] * 2)
if pd.notna(df_data['Error CF']):
raw_of -= (df_data['Error CF'] * .75)
if pd.notna(df_data['Error RF']):
raw_of -= (df_data['Error RF'] * 1.25)
of_error = max(1, raw_of)
denom += 1
if pd.notna(df_data['Error C']):
c_error = max(100 - (df_data['Error C'] * 5) - df_data['Throw C'] - df_data['PB C'], 1)
denom += 1
return sum([if_error, of_error, c_error]) / max(denom, 1)
raw_series = batting_dfs.apply(get_raw_fielding, axis=1)
rank_series = raw_series.rank(pct=True)
batting_dfs['Fielding'] = round(rank_series * 100)
log_time('end', 'Done Fielding calcs', start_time=start_time)
start_time = log_time('start', 'Beginning AVG vL calcs')
rank_series = batting_dfs['avg_vl'].rank(pct=True)
batting_dfs['Contact L'] = round(rank_series * 100)
log_time('end', 'Done AVG vL calcs', start_time=start_time)
start_time = log_time('start', 'Beginning AVG vR calcs')
rank_series = batting_dfs['avg_vr'].rank(pct=True)
batting_dfs['Contact R'] = round(rank_series * 100)
log_time('end', 'Done AVG vR calcs', start_time=start_time)
start_time = log_time('start', 'Beginning PWR vL calcs')
rank_series = batting_dfs['slg_vl'].rank(pct=True)
batting_dfs['Power L'] = round(rank_series * 100)
log_time('end', 'Done PWR vL calcs', start_time=start_time)
start_time = log_time('start', 'Beginning PWR vR calcs')
rank_series = batting_dfs['slg_vr'].rank(pct=True)
batting_dfs['Power R'] = round(rank_series * 100)
log_time('end', 'Done PWR vR calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Vision calcs')
def get_raw_vision(df_data):
return (
((((df_data['obp_vr'] * 0.67) + (df_data['obp_vl'] * 0.33)) -
((df_data['avg_vr'] * 0.67) + (df_data['avg_vl'] * 0.33))) * 5) -
(((df_data['strikeout_vl'] * 0.33) + (df_data['strikeout_vr'] * 0.67)) / 208)
)
raw_series = batting_dfs.apply(get_raw_vision, axis=1)
rank_series = raw_series.rank(pct=True)
batting_dfs['Vision'] = round(rank_series * 100)
log_time('end', 'Done Vision calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Rating calcs')
def get_raw_rating(df_data):
return (
((df_data['Reaction'] + df_data['Arm'] + df_data['Fielding']) * 2) +
(df_data['Speed'] + df_data['Steal']) +
((((df_data['Contact R'] + df_data['Power R']) * 0.67) +
((df_data['Contact L'] + df_data['Power L']) * 0.33) + df_data['Vision']) * 6)
)
raw_series = batting_dfs.apply(get_raw_rating, axis=1)
rank_series = raw_series.rank(pct=True)
batting_dfs['Rating'] = round(rank_series * 100)
log_time('end', 'Done Rating calcs', start_time=start_time)
start_time = log_time('start', 'Beginning write to file')
output = batting_dfs[[
'player_id', 'player_name', 'Rating', 'Contact R', 'Contact L', 'Power R', 'Power L', 'Vision', 'Speed',
'Steal', 'Reaction', 'Arm', 'Fielding', 'hand', 'cardset_name'
]]
csv_file = pd.DataFrame(output).to_csv(index=False)
output_file = output_dir / 'batting-basic.csv'
with open(output_file, 'w') as file:
file.write(csv_file)
log_time('end', 'Done writing to file', start_time=start_time)
async def calc_batting_ratings(batting_dfs: pd.DataFrame, output_dir: Path) -> None:
"""
Filter batting ratings and save to CSV.
Args:
batting_dfs: DataFrame with batting ratings
output_dir: Directory to save output CSV
"""
start_time = log_time('start', 'Beginning Ratings filtering')
output = batting_dfs
first = ['player_id', 'player_name', 'cardset_name', 'rarity', 'hand', 'variant']
exclude = first + ['id_vl', 'id_vr', 'vs_hand_vl', 'vs_hand_vr']
output = output[first + [col for col in output.columns if col not in exclude]]
log_time('end', 'Done filtering ratings', start_time=start_time)
start_time = log_time('start', 'Beginning write to file')
csv_file = pd.DataFrame(output).to_csv(index=False)
output_file = output_dir / 'batting-ratings.csv'
with open(output_file, 'w') as file:
file.write(csv_file)
log_time('end', 'Done writing to file', start_time=start_time)
# =============================================================================
# Pitching Scouting
# =============================================================================
async def get_pitching_scouting_dfs(cardset_ids: List[int] = None) -> pd.DataFrame:
"""
Fetch and build pitching scouting dataframes from API.
Args:
cardset_ids: List of cardset IDs to filter by (empty = all)
Returns:
DataFrame with pitching ratings and defensive positions joined
"""
cardset_ids = cardset_ids or []
cardset_params = [('cardset_id', x) for x in cardset_ids]
ratings_params = [('team_id', 31), ('ts', 's37136685556r6135248705'), *cardset_params]
API_CALLS = [
('pitchingcardratings', [('vs_hand', 'vL'), *ratings_params]),
('pitchingcardratings', [('vs_hand', 'vR'), *ratings_params]),
('cardpositions', [('position', 'P'), *cardset_params])
]
start_time = log_time('start', message='Pulling all pitching card ratings and positions')
tasks = [fetch_data(params) for params in API_CALLS]
api_data = await asyncio.gather(*tasks)
log_time('end', f'Pulled {api_data[0]["count"] + api_data[1]["count"]} pitching card ratings and {api_data[2]["count"]} positions', start_time=start_time)
start_time = log_time('start', message='Building base dataframes')
vl_vals = api_data[0]['ratings']
for x in vl_vals:
x.update(x['pitchingcard'])
x['player_id'] = x['pitchingcard']['player']['player_id']
x['player_name'] = x['pitchingcard']['player']['p_name']
x['rarity'] = x['pitchingcard']['player']['rarity']['name']
x['cardset_id'] = x['pitchingcard']['player']['cardset']['id']
x['cardset_name'] = x['pitchingcard']['player']['cardset']['name']
x['starter_rating'] = x['pitchingcard']['starter_rating']
x['relief_rating'] = x['pitchingcard']['relief_rating']
x['closer_rating'] = x['pitchingcard']['closer_rating']
del x['pitchingcard'], x['player']
vr_vals = api_data[1]['ratings']
for x in vr_vals:
x['player_id'] = x['pitchingcard']['player']['player_id']
del x['pitchingcard']
vl = pd.DataFrame(vl_vals)
vr = pd.DataFrame(vr_vals)
pit_df = pd.merge(vl, vr, on='player_id', suffixes=('_vl', '_vr')).set_index('player_id', drop=False)
log_time('end', 'Base dataframes are complete', start_time=start_time)
start_time = log_time('start', message='Building defense series')
positions = api_data[2]['positions']
series_list = [
pd.Series(
dict([(x['player']['player_id'], x['range']) for x in positions]),
name='Range P'
),
pd.Series(
dict([(x['player']['player_id'], x['error']) for x in positions]),
name='Error P'
)
]
log_time('end', f'Processed {len(positions)} defense series', start_time=start_time)
logger.info(f'series_list: {series_list}')
return pit_df.join(series_list)
async def calc_pitching_basic(pitching_dfs: pd.DataFrame, output_dir: Path) -> None:
"""
Calculate basic pitching scouting metrics and save to CSV.
Args:
pitching_dfs: DataFrame with pitching ratings
output_dir: Directory to save output CSV
"""
raw_data = pitching_dfs
def get_raw_leftcontrol(df_data):
return ((1 - (df_data['obp_vl'] - df_data['avg_vl'])) * 100) + (1 - (df_data['wild_pitch'] / 20))
start_time = log_time('start', 'Beginning Control L calcs')
raw_series = raw_data.apply(get_raw_leftcontrol, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['Control L'] = round(rank_series * 100)
log_time('end', 'Done Control L calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Control R calcs')
def get_raw_rightcontrol(df_data):
return ((1 - (df_data['obp_vr'] - df_data['avg_vr'])) * 100) + (1 - (df_data['wild_pitch'] / 20))
raw_series = raw_data.apply(get_raw_rightcontrol, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['Control R'] = round(rank_series * 100)
log_time('end', 'Done Control R calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Stuff L calcs')
def get_raw_leftstuff(df_data):
return 10 - (df_data['slg_vl'] + df_data['slg_vl'] + ((df_data['homerun_vl'] + df_data['bp_homerun_vl']) / 108))
raw_series = raw_data.apply(get_raw_leftstuff, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['Stuff L'] = round(rank_series * 100)
log_time('end', 'Done Stuff L calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Stuff R calcs')
def get_raw_rightstuff(df_data):
return 10 - (df_data['slg_vr'] + df_data['slg_vr'] + ((df_data['homerun_vr'] + df_data['bp_homerun_vr']) / 108))
raw_series = raw_data.apply(get_raw_rightstuff, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['Stuff R'] = round(rank_series * 100)
log_time('end', 'Done Stuff R calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Fielding calcs')
def get_raw_fielding(df_data):
return ((6 - df_data['Range P']) * 10) + (50 - df_data['Error P'])
raw_series = raw_data.apply(get_raw_fielding, axis=1)
rank_series = raw_series.rank(pct=True)
logger.info(f'max fld: {raw_series.max()} / min fld: {raw_series.min()}')
raw_data['Fielding'] = round(rank_series * 100)
log_time('end', 'Done Fielding calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Stamina calcs')
def get_raw_stamina(df_data):
spow = df_data['starter_rating'] if pd.isna(df_data['starter_rating']) else -1
rpow = df_data['relief_rating'] if pd.isna(df_data['relief_rating']) else -1
this_pow = spow if spow > rpow else rpow
return (((this_pow * (df_data['obp_vr'] * (2 / 3))) + (this_pow * (df_data['obp_vl'] / 3))) * 4.5) + this_pow
raw_series = raw_data.apply(get_raw_stamina, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['Stamina'] = round(rank_series * 100)
log_time('end', 'Done Stamina calcs', start_time=start_time)
start_time = log_time('start', 'Beginning H/9 calcs')
def get_raw_hit(df_data):
return 1 - (df_data['avg_vr'] * (2 / 3)) + (df_data['avg_vl'] / 3)
raw_series = raw_data.apply(get_raw_hit, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['H/9'] = round(rank_series * 100)
log_time('end', 'Done H/9 calcs', start_time=start_time)
start_time = log_time('start', 'Beginning K/9 calcs')
def get_raw_k(df_data):
return ((df_data['strikeout_vr'] / 108) * (2 / 3)) + ((df_data['strikeout_vl'] / 108) / 3)
raw_series = raw_data.apply(get_raw_k, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['K/9'] = round(rank_series * 100)
log_time('end', 'Done K/9 calcs', start_time=start_time)
start_time = log_time('start', 'Beginning BB/9 calcs')
def get_raw_bb(df_data):
return ((df_data['walk_vr'] / 108) * (2 / 3)) + ((df_data['walk_vl'] / 108) / 3)
raw_series = raw_data.apply(get_raw_bb, axis=1)
rank_series = raw_series.rank(pct=True, ascending=False)
raw_data['BB/9'] = round(rank_series * 100)
log_time('end', 'Done BB/9 calcs', start_time=start_time)
start_time = log_time('start', 'Beginning HR/9 calcs')
def get_raw_hr(df_data):
return 1 - (
(((df_data['homerun_vr'] + df_data['bp_homerun_vr']) / 108) * (2 / 3)) +
(((df_data['homerun_vl'] + df_data['bp_homerun_vl']) / 108) / 3)
)
raw_series = raw_data.apply(get_raw_hr, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['HR/9'] = round(rank_series * 100)
log_time('end', 'Done HR/9 calcs', start_time=start_time)
start_time = log_time('start', 'Beginning Rating calcs')
def get_raw_rating(df_data):
spow = df_data['starter_rating'] if pd.isna(df_data['starter_rating']) else -1
rpow = df_data['relief_rating'] if pd.isna(df_data['relief_rating']) else -1
if spow > rpow and spow >= 4:
return (
((df_data['H/9'] + df_data['K/9'] + df_data['BB/9'] + df_data['HR/9']) * 5) +
(df_data['Fielding']) + (df_data['Stamina'] * 5) +
(((df_data['Stuff L'] / 3) + (df_data['Stuff R'] * (2 / 3))) * 4) +
(((df_data['Control L'] / 3) + (df_data['Control R'] * (2 / 3))) * 2)
)
else:
return (
((df_data['H/9'] + df_data['K/9'] + df_data['BB/9'] + df_data['HR/9']) * 5) +
(df_data['Fielding']) + (df_data['Stamina'] * 5) +
(((df_data['Stuff L'] / 3) + (df_data['Stuff R'] * (2 / 3))) * 4) +
(((df_data['Control L'] / 3) + (df_data['Control R'] * (2 / 3))) * 2)
)
raw_series = raw_data.apply(get_raw_rating, axis=1)
rank_series = raw_series.rank(pct=True)
raw_data['Rating'] = round(rank_series * 100)
output = raw_data[[
'player_id', 'player_name', 'Rating', 'Control R', 'Control L', 'Stuff R', 'Stuff L', 'Stamina', 'Fielding',
'H/9', 'K/9', 'BB/9', 'HR/9', 'hand', 'cardset_name'
]]
log_time('end', 'Done Rating calcs', start_time=start_time)
start_time = log_time('start', 'Beginning write csv')
csv_file = pd.DataFrame(output).to_csv(index=False)
output_file = output_dir / 'pitching-basic.csv'
with open(output_file, 'w') as file:
file.write(csv_file)
log_time('end', 'Done writing to file', start_time=start_time)
async def calc_pitching_ratings(pitching_dfs: pd.DataFrame, output_dir: Path) -> None:
"""
Filter pitching ratings and save to CSV.
Args:
pitching_dfs: DataFrame with pitching ratings
output_dir: Directory to save output CSV
"""
start_time = log_time('start', 'Beginning Ratings filtering')
output = pitching_dfs
first = ['player_id', 'player_name', 'cardset_name', 'rarity', 'hand', 'variant']
exclude = first + ['id_vl', 'id_vr', 'vs_hand_vl', 'vs_hand_vr']
output = output[first + [col for col in output.columns if col not in exclude]]
log_time('end', 'Done filtering ratings', start_time=start_time)
start_time = log_time('start', 'Beginning write to file')
csv_file = pd.DataFrame(output).to_csv(index=False)
output_file = output_dir / 'pitching-ratings.csv'
with open(output_file, 'w') as file:
file.write(csv_file)
log_time('end', 'Done writing to file', start_time=start_time)

553
pd_cards/core/upload.py Normal file
View File

@ -0,0 +1,553 @@
"""
Card image upload and management core logic.
Business logic for uploading card images to AWS S3 and managing card URLs.
"""
import asyncio
import datetime
from typing import Optional, List, Tuple
import urllib.parse
# These imports are resolved at runtime when called from CLI
# since the CLI adds the parent directory to sys.path
from db_calls import db_get, db_patch, db_post, url_get
from exceptions import logger
# AWS Configuration
DEFAULT_AWS_BUCKET = "paper-dynasty"
DEFAULT_AWS_REGION = "us-east-1"
def get_s3_base_url(
bucket: str = DEFAULT_AWS_BUCKET, region: str = DEFAULT_AWS_REGION
) -> str:
"""Get the S3 base URL for a bucket."""
return f"https://{bucket}.s3.{region}.amazonaws.com"
async def fetch_card_image(session, card_url: str, timeout: int = 6) -> bytes:
"""
Fetch card image from URL and return raw bytes.
Args:
session: aiohttp ClientSession to use for the request
card_url: URL to fetch the card from
timeout: Request timeout in seconds
Returns:
Raw PNG image bytes
"""
import aiohttp
async with session.get(
card_url, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status == 200:
logger.info(f"Fetched card image from {card_url}")
return await resp.read()
else:
error_text = await resp.text()
logger.error(f"Failed to fetch card: {error_text}")
raise ValueError(f"Card fetch error: {error_text}")
def upload_card_to_s3(
s3_client,
image_data: bytes,
player_id: int,
card_type: str,
release_date: str,
cardset_id: int,
bucket: str = DEFAULT_AWS_BUCKET,
region: str = DEFAULT_AWS_REGION,
) -> str:
"""
Upload card image to S3 and return the S3 URL with cache-busting param.
Args:
s3_client: Boto3 S3 client
image_data: Raw PNG image bytes
player_id: Player ID
card_type: 'batting' or 'pitching'
release_date: Date string for cache busting (e.g., '2025-11-8')
cardset_id: Cardset ID (will be zero-padded to 3 digits)
bucket: S3 bucket name
region: AWS region
Returns:
Full S3 URL with ?d= parameter
"""
# Format cardset_id with 3 digits and leading zeros
cardset_str = f"{cardset_id:03d}"
s3_key = f"cards/cardset-{cardset_str}/player-{player_id}/{card_type}card.png"
s3_base_url = get_s3_base_url(bucket, region)
try:
s3_client.put_object(
Bucket=bucket,
Key=s3_key,
Body=image_data,
ContentType="image/png",
CacheControl="public, max-age=300", # 5 minute cache
Metadata={
"player-id": str(player_id),
"card-type": card_type,
"upload-date": datetime.datetime.now().isoformat(),
},
)
# Return URL with cache-busting parameter
s3_url = f"{s3_base_url}/{s3_key}?d={release_date}"
logger.info(f"Uploaded {card_type} card for player {player_id} to S3: {s3_url}")
return s3_url
except Exception as e:
logger.error(f"Failed to upload {card_type} card for player {player_id}: {e}")
raise
async def upload_cards_to_s3(
cardset_name: str,
start_id: Optional[int] = None,
limit: Optional[int] = None,
html_cards: bool = False,
skip_batters: bool = False,
skip_pitchers: bool = False,
upload: bool = True,
update_urls: bool = True,
bucket: str = DEFAULT_AWS_BUCKET,
region: str = DEFAULT_AWS_REGION,
on_progress: callable = None,
) -> dict:
"""
Upload card images to S3 for a cardset.
Args:
cardset_name: Name of the cardset to process
start_id: Player ID to start from (for resuming)
limit: Maximum number of cards to process
html_cards: Fetch HTML preview cards instead of PNG
skip_batters: Skip batting cards
skip_pitchers: Skip pitching cards
upload: Actually upload to S3
update_urls: Update player URLs in database
bucket: S3 bucket name
region: AWS region
on_progress: Callback function for progress updates
Returns:
Dict with counts of errors, successes, uploads, url_updates
"""
import aiohttp
import boto3
# Look up cardset
c_query = await db_get("cardsets", params=[("name", cardset_name)])
if not c_query or c_query["count"] == 0:
raise ValueError(f'Cardset "{cardset_name}" not found')
cardset = c_query["cardsets"][0]
# Get all players
p_query = await db_get(
"players",
params=[
("inc_dex", False),
("cardset_id", cardset["id"]),
("short_output", True),
],
)
if not p_query or p_query["count"] == 0:
raise ValueError("No players returned from Paper Dynasty API")
all_players = p_query["players"]
# Generate release date for cache busting (include timestamp for same-day updates)
now = datetime.datetime.now()
timestamp = int(now.timestamp())
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
# PD API base URL for card generation
PD_API_URL = "https://pd.manticorum.com/api"
# Initialize S3 client if uploading
s3_client = boto3.client("s3", region_name=region) if upload else None
errors = []
successes = []
uploads = []
url_updates = []
cxn_error = False
count = 0
max_count = limit or 9999
async with aiohttp.ClientSession() as session:
for x in all_players:
# Apply filters
if "pitching" in x["image"] and skip_pitchers:
continue
if "batting" in x["image"] and skip_batters:
continue
if start_id is not None and start_id > x["player_id"]:
continue
if "sombaseball" in x["image"]:
errors.append((x, f"Bad card url: {x['image']}"))
continue
if count >= max_count:
break
count += 1
if on_progress and count % 20 == 0:
on_progress(count, x["p_name"])
# Determine card type from existing image URL
card_type = "pitching" if "pitching" in x["image"] else "batting"
# Generate card URL from PD API (forces fresh generation from database)
pd_card_url = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type}card?d={release_date}"
if html_cards:
card_url = f"{pd_card_url}&html=true"
timeout = 2
else:
card_url = pd_card_url
timeout = 6
try:
if upload and not html_cards:
# Fetch card image bytes directly
image_bytes = await fetch_card_image(
session, card_url, timeout=timeout
)
s3_url = upload_card_to_s3(
s3_client,
image_bytes,
x["player_id"],
card_type,
release_date,
cardset["id"],
bucket,
region,
)
uploads.append((x["player_id"], card_type, s3_url))
# Update player record with new S3 URL
if update_urls:
await db_patch(
"players",
object_id=x["player_id"],
params=[("image", s3_url)],
)
url_updates.append((x["player_id"], card_type, s3_url))
logger.info(f"Updated player {x['player_id']} image URL to S3")
else:
# Just validate card exists
logger.info(f"Validating card URL: {card_url}")
await url_get(card_url, timeout=timeout)
except ConnectionError as e:
if cxn_error:
raise e
cxn_error = True
errors.append((x, e))
except ValueError as e:
errors.append((x, e))
except Exception as e:
logger.error(
f"S3 upload/update failed for player {x['player_id']}: {e}"
)
errors.append((x, f"S3 error: {e}"))
continue
# Handle image2 (dual-position players)
if x["image2"] is not None:
card_type2 = "pitching" if "pitching" in x["image2"] else "batting"
pd_card_url2 = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type2}card?d={release_date}"
if html_cards:
card_url2 = f"{pd_card_url2}&html=true"
else:
card_url2 = pd_card_url2
if "sombaseball" in x["image2"]:
errors.append((x, f"Bad card url: {x['image2']}"))
else:
try:
if upload and not html_cards:
image_bytes2 = await fetch_card_image(
session, card_url2, timeout=6
)
s3_url2 = upload_card_to_s3(
s3_client,
image_bytes2,
x["player_id"],
card_type2,
release_date,
cardset["id"],
bucket,
region,
)
uploads.append((x["player_id"], card_type2, s3_url2))
if update_urls:
await db_patch(
"players",
object_id=x["player_id"],
params=[("image2", s3_url2)],
)
url_updates.append(
(x["player_id"], card_type2, s3_url2)
)
logger.info(
f"Updated player {x['player_id']} image2 URL to S3"
)
else:
await url_get(card_url2, timeout=6)
successes.append(x)
except ConnectionError as e:
if cxn_error:
raise e
cxn_error = True
errors.append((x, e))
except ValueError as e:
errors.append((x, e))
except Exception as e:
logger.error(
f"S3 upload/update failed for player {x['player_id']} image2: {e}"
)
errors.append((x, f"S3 error (image2): {e}"))
else:
successes.append(x)
return {
"errors": errors,
"successes": successes,
"uploads": uploads,
"url_updates": url_updates,
"release_date": release_date,
"cardset": cardset,
}
async def refresh_card_images(
cardset_name: str,
limit: Optional[int] = None,
html_cards: bool = False,
on_progress: callable = None,
) -> dict:
"""
Refresh card images for a cardset by triggering regeneration.
Args:
cardset_name: Name of the cardset to process
limit: Maximum number of cards to process
html_cards: Fetch HTML preview cards instead of PNG
on_progress: Callback function for progress updates
Returns:
Dict with counts of errors, successes
"""
# Look up cardset
c_query = await db_get("cardsets", params=[("name", cardset_name)])
if not c_query or c_query["count"] == 0:
raise ValueError(f'Cardset "{cardset_name}" not found')
cardset = c_query["cardsets"][0]
CARD_BASE_URL = "https://pd.manticorum.com/api/v2/players"
# Get all players
p_query = await db_get(
"players",
params=[
("inc_dex", False),
("cardset_id", cardset["id"]),
("short_output", True),
],
)
if p_query["count"] == 0:
raise ValueError("No players returned from Paper Dynasty API")
all_players = p_query["players"]
errors = []
successes = []
cxn_error = False
count = 0
max_count = limit or 9999
start_time = datetime.datetime.now()
# First pass: Reset URLs for players with old sombaseball URLs
for x in all_players:
if "sombaseball" in x["image"]:
if on_progress:
on_progress(count, f"{x['p_name']} - fixing old URL")
release_dir = f"{start_time.year}-{start_time.month}-{start_time.day}"
if x["pos_1"] in ["SP", "RP", "CP", "P"]:
image_url = (
f"{CARD_BASE_URL}/{x['player_id']}/pitchingcard"
f"{urllib.parse.quote('?d=')}{release_dir}"
)
else:
image_url = (
f"{CARD_BASE_URL}/{x['player_id']}/battingcard"
f"{urllib.parse.quote('?d=')}{release_dir}"
)
await db_patch(
"players", object_id=x["player_id"], params=[("image", image_url)]
)
else:
count += 1
if on_progress and count % 20 == 0:
on_progress(count, f"{x['p_name']} - resetting")
if count >= max_count:
break
try:
await db_post(f"players/{x['player_id']}/image-reset")
except ConnectionError as e:
if cxn_error:
raise e
cxn_error = True
errors.append((x, e))
except ValueError as e:
errors.append((x, e))
# Second pass: Fetch images to trigger regeneration
count = 0
for x in all_players:
if count >= max_count:
break
if html_cards:
card_url = f"{x['image']}&html=true"
timeout = 2
else:
card_url = x["image"]
timeout = 6
try:
logger.info(f"Fetching card URL: {card_url}")
await url_get(card_url, timeout=timeout)
except ConnectionError as e:
if cxn_error:
raise e
cxn_error = True
errors.append((x, e))
except ValueError as e:
errors.append((x, e))
else:
# Handle image2
if x["image2"] is not None:
if html_cards:
card_url2 = f"{x['image2']}&html=true"
else:
card_url2 = x["image2"]
if "sombaseball" in x["image2"]:
errors.append((x, f"Bad card url: {x['image2']}"))
else:
try:
await url_get(card_url2, timeout=6)
successes.append(x)
except ConnectionError as e:
if cxn_error:
raise e
cxn_error = True
errors.append((x, e))
except ValueError as e:
errors.append((x, e))
else:
successes.append(x)
count += 1
return {"errors": errors, "successes": successes, "cardset": cardset}
async def check_card_images(
cardset_name: str, limit: Optional[int] = None, on_progress: callable = None
) -> dict:
"""
Check and validate card images without uploading.
Args:
cardset_name: Name of the cardset to check
limit: Maximum number of cards to check
on_progress: Callback function for progress updates
Returns:
Dict with counts of errors and successes
"""
# Look up cardset
c_query = await db_get("cardsets", params=[("name", cardset_name)])
if not c_query or c_query["count"] == 0:
raise ValueError(f'Cardset "{cardset_name}" not found')
cardset = c_query["cardsets"][0]
# Get all players
p_query = await db_get(
"players",
params=[
("inc_dex", False),
("cardset_id", cardset["id"]),
("short_output", True),
],
)
if not p_query or p_query["count"] == 0:
raise ValueError("No players returned from Paper Dynasty API")
all_players = p_query["players"]
# Generate release date for cache busting (include timestamp for same-day updates)
now = datetime.datetime.now()
timestamp = int(now.timestamp())
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
PD_API_URL = "https://pd.manticorum.com/api"
errors = []
successes = []
cxn_error = False
count = 0
max_count = limit or 9999
for x in all_players:
if count >= max_count:
break
if "sombaseball" in x["image"]:
errors.append((x, f"Bad card url: {x['image']}"))
continue
count += 1
if on_progress and count % 20 == 0:
on_progress(count, x["p_name"])
card_type = "pitching" if "pitching" in x["image"] else "batting"
card_url = (
f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type}card?d={release_date}"
)
try:
logger.info(f"Checking card URL: {card_url}")
await url_get(card_url, timeout=6)
successes.append(x)
except ConnectionError as e:
if cxn_error:
raise e
cxn_error = True
errors.append((x, e))
except ValueError as e:
errors.append((x, e))
return {
"errors": errors,
"successes": successes,
"cardset": cardset,
"release_date": release_date,
}

View File

@ -1,21 +1,23 @@
name: Adm Ball Traits
player_type: batter
hand: R
target_ops: 0.8
target_ops: 0.830 # This character is -0.2 from the standard thresholds in favor of defense and speed
cardset_id: 29
player_id: 13361
batting_card_id: 6244
positions:
SS:
range: 1
error: 10
innings: 500
3B:
range: 2
error: 8
SS:
range: 3
error: 10
innings: 250
baserunning:
steal_jump: 0.083333
steal_jump: 0.1388888
steal_high: 15
steal_low: 9
steal_low: 12
steal_auto: true
running: 13
hit_and_run: B
@ -27,13 +29,13 @@ ratings:
triple: 2.75
double_three: 0.0
double_two: 2.0
double_pull: 3.75
double_pull: 5.25
single_two: 6.5
single_one: 5.0
single_one: 4.0
single_center: 7.75
bp_single: 5.0
walk: 12.0
hbp: 4.0
hbp: 3.5
strikeout: 16.0
lineout: 4.0
popout: 2.0
@ -41,9 +43,9 @@ ratings:
flyout_bq: 2.0
flyout_lf_b: 4.0
flyout_rf_b: 4.0
groundout_a: 9.0
groundout_b: 11.0
groundout_c: 6.25
groundout_a: 6.0
groundout_b: 9.0
groundout_c: 11.25
pull_rate: 0.2
center_rate: 0.5
slap_rate: 0.3
@ -54,22 +56,22 @@ ratings:
double_three: 0.0
double_two: 4.5
double_pull: 4.5
single_two: 5.0
single_two: 6.25
single_one: 8.0
single_center: 5.5
single_center: 5.75
bp_single: 5.0
walk: 11.0
hbp: 1.0
strikeout: 14.5
strikeout: 13.0
lineout: 2.0
popout: 2.0
flyout_a: 0.0
flyout_bq: 3.0
flyout_lf_b: 5.0
flyout_rf_b: 8.0
groundout_a: 5.0
groundout_b: 12.0
groundout_c: 10.5
flyout_lf_b: 6.0
flyout_rf_b: 9.0
groundout_a: 3.0
groundout_b: 9.0
groundout_c: 13.5
pull_rate: 0.18
center_rate: 0.42
slap_rate: 0.4

View File

@ -5,7 +5,7 @@
name: Kalin Young
hand: R
target_ops: 0.880
target_ops: 0.920
cardset_id: 29
player_id: 13009
batting_card_id: 6072
@ -13,14 +13,12 @@ batting_card_id: 6072
positions:
RF:
range: 2
error: 6
arm: 0
fielding_pct: null
error: 4
arm: -1
LF:
range: 4
error: 6
arm: 0
fielding_pct: null
error: 4
arm: -1
baserunning:
steal_jump: 0.22222
@ -35,53 +33,53 @@ ratings:
# Updated ratings from submit_kalin_young.py
vs_L:
homerun: 2.05
bp_homerun: 2.00
bp_homerun: 3.00
triple: 0.00
double_three: 0.00
double_two: 6.00
double_pull: 2.35
single_two: 5.05
single_one: 5.10
single_center: 5.80 # +1.55 from original
double_two: 6.50
double_pull: 4.35
single_two: 5.55
single_one: 2.10
single_center: 4.80 # +1.55 from original
bp_single: 5.00
walk: 16.05 # +1.55 from original
walk: 16.55 # +1.55 from original
hbp: 1.00
strikeout: 15.40 # -3.10 from original
lineout: 15.00
strikeout: 16.40 # -3.10 from original
lineout: 8.00
popout: 0.00
flyout_a: 0.00
flyout_bq: 0.75
flyout_lf_b: 3.65
flyout_rf_b: 3.95
flyout_bq: 1.75
flyout_lf_b: 5.65
flyout_rf_b: 5.95
groundout_a: 3.95
groundout_b: 10.00
groundout_c: 4.90
groundout_b: 9.50
groundout_c: 5.90
pull_rate: 0.33
center_rate: 0.37
slap_rate: 0.30
vs_R:
homerun: 2.10
bp_homerun: 1.00
bp_homerun: 3.00
triple: 1.25
double_three: 0.00
double_two: 6.35
double_pull: 1.80
double_two: 5.35
double_pull: 3.80
single_two: 5.40
single_one: 4.95
single_one: 3.95
single_center: 6.05 # +1.55 from original
bp_single: 5.00
walk: 14.55 # +1.55 from original
hbp: 2.00
strikeout: 17.90 # -3.10 from original
strikeout: 16.90 # -3.10 from original
lineout: 12.00
popout: 1.00
flyout_a: 0.00
flyout_bq: 0.50
flyout_lf_b: 4.10
flyout_rf_b: 4.00
groundout_a: 4.00
groundout_b: 5.00
groundout_a: 3.50
groundout_b: 4.50
groundout_c: 9.05
pull_rate: 0.25
center_rate: 0.40

View File

@ -0,0 +1,93 @@
# Sippie Swartzel - Custom Player
# Target OPS: 0.850
# Hand: Left-handed batter
# Profile: Contact-oriented hitter with good plate discipline
name: Sippie Swartzel
hand: L
target_ops: 0.820
cardset_id: 29
player_id: 13475
batting_card_id: 6304
positions:
# Primary position - fill in based on character concept
SS:
range: 3
error: 14
RF:
range: 4
error: 5
arm: -1
baserunning:
steal_jump: 0.0833333 # Decent speed
steal_high: 20
steal_low: 9
steal_auto: 0
running: 12
hit_and_run: B
bunting: C
ratings:
# vs_L = performance vs left-handed pitchers (same-side)
# Typically weaker split for LHB
# Target: Lower OPS vs LHP
# Total must = 108
vs_L:
homerun: 0.00
bp_homerun: 0.00
triple: 3.00
double_three: 0.00
double_two: 5.00
double_pull: 1.50
single_two: 5.50
single_one: 5.50
single_center: 6.50
bp_single: 5.00
walk: 11.00
hbp: 1.00
strikeout: 19.00
lineout: 14.50
popout: 1.00
flyout_a: 0.00
flyout_bq: 0.50
flyout_lf_b: 5.00
flyout_rf_b: 5.00
groundout_a: 4.00
groundout_b: 9.00
groundout_c: 6.00
pull_rate: 0.28
center_rate: 0.38
slap_rate: 0.34
# vs_R = performance vs right-handed pitchers (opposite-side)
# Stronger split for LHB
# Target: Higher OPS vs RHP
# Total must = 108
vs_R:
homerun: 0.00
bp_homerun: 0.00
triple: 4.50
double_three: 0.00
double_two: 5.50
double_pull: 2.00
single_two: 6.00
single_one: 6.00
single_center: 7.75
bp_single: 5.00
walk: 12.75
hbp: 1.00
strikeout: 16.50
lineout: 12.50
popout: 0.50
flyout_a: 0.00
flyout_bq: 0.50
flyout_lf_b: 5.00
flyout_rf_b: 5.00
groundout_a: 4.00
groundout_b: 7.50
groundout_c: 6.00
pull_rate: 0.30
center_rate: 0.38
slap_rate: 0.32

2207
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff