Compare commits
1 Commits
main
...
ai/paper-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d603ccd48 |
@ -1,14 +1,8 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import pandas as pd
|
||||
|
||||
# Add project root so we can import db_calls
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from db_calls import AUTH_TOKEN
|
||||
|
||||
AUTH_TOKEN = {"Authorization": "Bearer Tp3aO3jhYve5NJF1IqOmJTmk"}
|
||||
PROD_URL = "https://pd.manticorum.com/api"
|
||||
|
||||
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
# Paper Dynasty API
|
||||
PD_API_TOKEN=your-bearer-token-here
|
||||
@ -118,9 +118,6 @@ pd-cards scouting all && pd-cards scouting upload
|
||||
pd-cards upload s3 --cardset "2005 Live" --dry-run
|
||||
pd-cards upload s3 --cardset "2005 Live" --limit 10
|
||||
|
||||
# High-concurrency local rendering (start API server locally first)
|
||||
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
|
||||
|
||||
# Check cards without uploading
|
||||
pd-cards upload check --cardset "2005 Live" --limit 10
|
||||
|
||||
@ -266,7 +263,6 @@ Before running retrosheet_data.py, verify these configuration settings:
|
||||
- `UPDATE_PLAYER_URLS`: Enable/disable updating player records with S3 URLs (careful - modifies database)
|
||||
- `AWS_BUCKET_NAME`: S3 bucket name (default: 'paper-dynasty')
|
||||
- `AWS_REGION`: AWS region (default: 'us-east-1')
|
||||
- `PD_API_URL` (env var): Override the API base URL for card rendering (default: `https://pd.manticorum.com/api`). Set to `http://localhost:8000/api` for local rendering.
|
||||
|
||||
**S3 URL Structure**: `cards/cardset-{cardset_id:03d}/player-{player_id}/{batting|pitching}card.png?d={release_date}`
|
||||
- Uses zero-padded 3-digit cardset ID for consistent sorting
|
||||
|
||||
@ -573,7 +573,7 @@ def stealing_line(steal_data: dict):
|
||||
else:
|
||||
good_jump = "2-12"
|
||||
|
||||
return f"{'*' if sd[2] else ''}{good_jump}/- ({sd[1] if sd[1] else '-'}-{sd[0] if sd[0] else '-'})"
|
||||
return f'{"*" if sd[2] else ""}{good_jump}/- ({sd[1] if sd[1] else "-"}-{sd[0] if sd[0] else "-"})'
|
||||
|
||||
|
||||
def running(extra_base_pct: str):
|
||||
@ -583,7 +583,7 @@ def running(extra_base_pct: str):
|
||||
xb_pct = float(extra_base_pct.strip("%")) / 80
|
||||
except Exception as e:
|
||||
logger.error(f"calcs_batter running - {e}")
|
||||
return 8
|
||||
xb_pct = 20
|
||||
|
||||
return max(min(round(6 + (10 * xb_pct)), 17), 8)
|
||||
|
||||
@ -693,11 +693,11 @@ def get_batter_ratings(df_data) -> List[dict]:
|
||||
|
||||
logger.debug(
|
||||
f"all on base: {vl.hbp + vl.walk + vl.total_hits()} / all chances: {vl.total_chances()}"
|
||||
f"{'*******ERROR ABOVE*******' if vl.hbp + vl.walk + vl.total_hits() != vl.total_chances() else ''}"
|
||||
f'{"*******ERROR ABOVE*******" if vl.hbp + vl.walk + vl.total_hits() != vl.total_chances() else ""}'
|
||||
)
|
||||
logger.debug(
|
||||
f"all on base: {vr.hbp + vr.walk + vr.total_hits()} / all chances: {vr.total_chances()}"
|
||||
f"{'*******ERROR ABOVE*******' if vr.hbp + vr.walk + vr.total_hits() != vr.total_chances() else ''}"
|
||||
f'{"*******ERROR ABOVE*******" if vr.hbp + vr.walk + vr.total_hits() != vr.total_chances() else ""}'
|
||||
)
|
||||
|
||||
vl.calculate_strikeouts(df_data["SO_vL"], df_data["AB_vL"], df_data["H_vL"])
|
||||
|
||||
@ -3,7 +3,7 @@ import urllib.parse
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Dict
|
||||
from creation_helpers import (
|
||||
get_all_pybaseball_ids,
|
||||
sanitize_name,
|
||||
@ -158,8 +158,8 @@ async def create_new_players(
|
||||
{
|
||||
"p_name": f"{f_name} {l_name}",
|
||||
"cost": NEW_PLAYER_COST,
|
||||
"image": f"{card_base_url}/{df_data['player_id']}/battingcard"
|
||||
f"{urllib.parse.quote('?d=')}{release_dir}",
|
||||
"image": f'{card_base_url}/{df_data["player_id"]}/battingcard'
|
||||
f'{urllib.parse.quote("?d=")}{release_dir}',
|
||||
"mlbclub": CLUB_LIST[df_data["Tm_vL"]],
|
||||
"franchise": FRANCHISE_LIST[df_data["Tm_vL"]],
|
||||
"cardset_id": cardset["id"],
|
||||
@ -302,7 +302,7 @@ async def calculate_batting_ratings(offense_stats: pd.DataFrame, to_post: bool):
|
||||
|
||||
|
||||
async def post_player_updates(
|
||||
cardset: Dict[str, Any],
|
||||
cardset: Dict[str, any],
|
||||
card_base_url: str,
|
||||
release_dir: str,
|
||||
player_desc: str,
|
||||
@ -432,8 +432,8 @@ async def post_player_updates(
|
||||
[
|
||||
(
|
||||
"image",
|
||||
f"{card_base_url}/{df_data['player_id']}/battingcard"
|
||||
f"{urllib.parse.quote('?d=')}{release_dir}",
|
||||
f'{card_base_url}/{df_data["player_id"]}/battingcard'
|
||||
f'{urllib.parse.quote("?d=")}{release_dir}',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import functools
|
||||
import os
|
||||
import sys
|
||||
import boto3
|
||||
|
||||
@ -136,11 +135,10 @@ async def main(args):
|
||||
timestamp = int(now.timestamp())
|
||||
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
|
||||
|
||||
# PD API base URL for card generation (override with PD_API_URL env var for local rendering)
|
||||
PD_API_URL = os.environ.get("PD_API_URL", "https://pd.manticorum.com/api")
|
||||
# PD API base URL for card generation
|
||||
PD_API_URL = "https://pd.manticorum.com/api"
|
||||
|
||||
print(f"\nRelease date for cards: {release_date}")
|
||||
print(f"API URL: {PD_API_URL}")
|
||||
print(f"S3 Upload: {'ENABLED' if UPLOAD_TO_S3 else 'DISABLED'}")
|
||||
print(f"URL Update: {'ENABLED' if UPDATE_PLAYER_URLS else 'DISABLED'}")
|
||||
print(f"Concurrency: {CONCURRENCY} parallel tasks\n")
|
||||
@ -172,7 +170,7 @@ async def main(args):
|
||||
results_lock = asyncio.Lock()
|
||||
|
||||
start_time = datetime.datetime.now()
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
semaphore = asyncio.Semaphore(CONCURRENCY)
|
||||
|
||||
async def report_progress():
|
||||
|
||||
@ -10,7 +10,7 @@ import requests
|
||||
import time
|
||||
|
||||
from db_calls import db_get
|
||||
from db_calls_card_creation import PitcherData
|
||||
from db_calls_card_creation import *
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Card Creation Constants
|
||||
@ -533,7 +533,7 @@ def get_pitching_peripherals(season: int):
|
||||
row_data.append(player_id)
|
||||
if len(headers) == 0:
|
||||
col_names.append("key_bbref")
|
||||
except KeyError:
|
||||
except Exception:
|
||||
pass
|
||||
row_data.append(cell.text)
|
||||
if len(headers) == 0:
|
||||
@ -595,21 +595,21 @@ def legal_splits(tot_chances):
|
||||
|
||||
|
||||
def result_string(tba_data, row_num, split_min=None, split_max=None):
|
||||
bold1 = f"{'<b>' if tba_data['bold'] else ''}"
|
||||
bold2 = f"{'</b>' if tba_data['bold'] else ''}"
|
||||
row_string = f"{'<b> </b>' if int(row_num) < 10 else ''}{row_num}"
|
||||
bold1 = f'{"<b>" if tba_data["bold"] else ""}'
|
||||
bold2 = f'{"</b>" if tba_data["bold"] else ""}'
|
||||
row_string = f'{"<b> </b>" if int(row_num) < 10 else ""}{row_num}'
|
||||
if TESTING:
|
||||
print(
|
||||
f"adding {tba_data['string']} to row {row_num} / "
|
||||
f'adding {tba_data["string"]} to row {row_num} / '
|
||||
f"split_min: {split_min} / split_max: {split_max}"
|
||||
)
|
||||
|
||||
# No splits; standard result
|
||||
if not split_min:
|
||||
return f"{bold1}{row_string}-{tba_data['string']}{bold2}"
|
||||
return f'{bold1}{row_string}-{tba_data["string"]}{bold2}'
|
||||
|
||||
# With splits
|
||||
split_nums = f"{split_min if split_min != 20 else ''}{'-' if split_min != 20 else ''}{split_max}"
|
||||
split_nums = f'{split_min if split_min != 20 else ""}{"-" if split_min != 20 else ""}{split_max}'
|
||||
data_string = (
|
||||
tba_data["sm-string"] if "sm-string" in tba_data.keys() else tba_data["string"]
|
||||
)
|
||||
@ -618,10 +618,10 @@ def result_string(tba_data, row_num, split_min=None, split_max=None):
|
||||
spaces -= 3
|
||||
elif "SI**" in data_string:
|
||||
spaces += 1
|
||||
elif "DO**" in data_string:
|
||||
spaces -= 2
|
||||
elif "DO*" in data_string:
|
||||
spaces -= 1
|
||||
elif "DO*" in data_string:
|
||||
spaces -= 2
|
||||
elif "so" in data_string:
|
||||
spaces += 3
|
||||
elif "gb" in data_string:
|
||||
@ -638,39 +638,41 @@ def result_string(tba_data, row_num, split_min=None, split_max=None):
|
||||
row_output = "<b> </b>"
|
||||
if TESTING:
|
||||
print(f"row_output: {row_output}")
|
||||
return f"{bold1}{row_output}{data_string}{' ' * spaces}{split_nums}{bold2}"
|
||||
return f'{bold1}{row_output}{data_string}{" " * spaces}{split_nums}{bold2}'
|
||||
|
||||
|
||||
def result_data(
|
||||
tba_data, row_num, tba_data_bottom=None, top_split_max=None, fatigue=False
|
||||
):
|
||||
ret_data = {}
|
||||
top_bold1 = f"{'<b>' if tba_data['bold'] else ''}"
|
||||
top_bold2 = f"{'</b>' if tba_data['bold'] else ''}"
|
||||
top_bold1 = f'{"<b>" if tba_data["bold"] else ""}'
|
||||
top_bold2 = f'{"</b>" if tba_data["bold"] else ""}'
|
||||
bot_bold1 = None
|
||||
bot_bold2 = None
|
||||
if tba_data_bottom:
|
||||
bot_bold1 = f"{'<b>' if tba_data_bottom['bold'] else ''}"
|
||||
bot_bold2 = f"{'</b>' if tba_data_bottom['bold'] else ''}"
|
||||
bot_bold1 = f'{"<b>" if tba_data_bottom["bold"] else ""}'
|
||||
bot_bold2 = f'{"</b>" if tba_data_bottom["bold"] else ""}'
|
||||
|
||||
if tba_data_bottom is None:
|
||||
ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}"
|
||||
ret_data["splits"] = f"{top_bold1}{top_bold2}"
|
||||
ret_data["result"] = (
|
||||
f"{top_bold1}{tba_data['string']}{' •' if fatigue else ''}{top_bold2}"
|
||||
f"{top_bold1}"
|
||||
f'{tba_data["string"]}{" •" if fatigue else ""}'
|
||||
f"{top_bold2}"
|
||||
)
|
||||
else:
|
||||
ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}\n"
|
||||
ret_data["splits"] = (
|
||||
f"{top_bold1}1{'-' if top_split_max != 1 else ''}"
|
||||
f"{top_split_max if top_split_max != 1 else ''}{top_bold2}\n"
|
||||
f"{bot_bold1}{top_split_max + 1}{'-20' if top_split_max != 19 else ''}{bot_bold2}"
|
||||
f'{top_bold1}1{"-" if top_split_max != 1 else ""}'
|
||||
f'{top_split_max if top_split_max != 1 else ""}{top_bold2}\n'
|
||||
f'{bot_bold1}{top_split_max+1}{"-20" if top_split_max != 19 else ""}{bot_bold2}'
|
||||
)
|
||||
ret_data["result"] = (
|
||||
f"{top_bold1}{tba_data['sm-string'] if 'sm-string' in tba_data.keys() else tba_data['string']}"
|
||||
f'{top_bold1}{tba_data["sm-string"] if "sm-string" in tba_data.keys() else tba_data["string"]}'
|
||||
f"{top_bold2}\n"
|
||||
f"{bot_bold1}"
|
||||
f"{tba_data_bottom['sm-string'] if 'sm-string' in tba_data_bottom.keys() else tba_data_bottom['string']}"
|
||||
f'{tba_data_bottom["sm-string"] if "sm-string" in tba_data_bottom.keys() else tba_data_bottom["string"]}'
|
||||
f"{bot_bold2}"
|
||||
)
|
||||
|
||||
@ -686,9 +688,9 @@ def get_of(batter_hand, pitcher_hand, pull_side=True):
|
||||
|
||||
if batter_hand == "S":
|
||||
if pitcher_hand == "L":
|
||||
return "lf" if pull_side else "rf"
|
||||
return "rf" if pull_side else "rf"
|
||||
else:
|
||||
return "rf" if pull_side else "lf"
|
||||
return "lf" if pull_side else "lf"
|
||||
|
||||
|
||||
def get_col(col_num):
|
||||
@ -727,7 +729,7 @@ def get_position_string(all_pos: list, inc_p: bool):
|
||||
|
||||
for x in all_pos:
|
||||
if x.position == "OF":
|
||||
of_arm = f"{'+' if '-' not in x.arm else ''}{x.arm}"
|
||||
of_arm = f'{"+" if "-" not in x.arm else ""}{x.arm}'
|
||||
of_error = x.error
|
||||
of_innings = x.innings
|
||||
elif x.position == "CF":
|
||||
@ -742,7 +744,7 @@ def get_position_string(all_pos: list, inc_p: bool):
|
||||
elif x.position == "C":
|
||||
all_def.append(
|
||||
(
|
||||
f"c-{x.range}({'+' if int(x.arm) >= 0 else ''}{x.arm}) e{x.error} T-{x.overthrow}(pb-{x.pb})",
|
||||
f'c-{x.range}({"+" if int(x.arm) >= 0 else ""}{x.arm}) e{x.error} T-{x.overthrow}(pb-{x.pb})',
|
||||
x.innings,
|
||||
)
|
||||
)
|
||||
@ -1077,7 +1079,7 @@ def mlbteam_and_franchise(mlbam_playerid):
|
||||
p_data["franchise"] = normalize_franchise(data["currentTeam"]["name"])
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not set team for {mlbam_playerid}; received {data['currentTeam']['name']}"
|
||||
f'Could not set team for {mlbam_playerid}; received {data["currentTeam"]["name"]}'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
@ -1220,5 +1222,5 @@ def get_hand(df_data):
|
||||
else:
|
||||
return "R"
|
||||
except Exception:
|
||||
logger.error(f"Error in get_hand for {df_data['Name']}")
|
||||
logger.error(f'Error in get_hand for {df_data["Name"]}')
|
||||
return "R"
|
||||
|
||||
@ -6,7 +6,6 @@ baseball archetypes with iterative review and refinement.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import sys
|
||||
from typing import Literal
|
||||
from datetime import datetime
|
||||
@ -180,12 +179,7 @@ class CustomCardCreator:
|
||||
else:
|
||||
calc = PitcherRatingCalculator(archetype)
|
||||
ratings = calc.calculate_ratings(pitchingcard_id=0) # Temp ID
|
||||
card_data = {
|
||||
"ratings": ratings,
|
||||
"starter_rating": archetype.starter_rating,
|
||||
"relief_rating": archetype.relief_rating,
|
||||
"closer_rating": archetype.closer_rating,
|
||||
}
|
||||
card_data = {"ratings": ratings}
|
||||
|
||||
# Step 4: Review and tweak loop
|
||||
final_data = await self.review_and_tweak(
|
||||
@ -353,7 +347,7 @@ class CustomCardCreator:
|
||||
vs_hand = rating["vs_hand"]
|
||||
print(f"\nVS {vs_hand}{'HP' if player_type == 'batter' else 'HB'}:")
|
||||
print(
|
||||
f" AVG: {rating['avg']:.3f} OBP: {rating['obp']:.3f} SLG: {rating['slg']:.3f} OPS: {rating['obp'] + rating['slg']:.3f}"
|
||||
f" AVG: {rating['avg']:.3f} OBP: {rating['obp']:.3f} SLG: {rating['slg']:.3f} OPS: {rating['obp']+rating['slg']:.3f}"
|
||||
)
|
||||
|
||||
# Show hit distribution
|
||||
@ -370,7 +364,7 @@ class CustomCardCreator:
|
||||
+ rating["bp_single"]
|
||||
)
|
||||
print(
|
||||
f" Hits: {total_hits:.1f} (HR: {rating['homerun']:.1f} 3B: {rating['triple']:.1f} 2B: {rating['double_pull'] + rating['double_two'] + rating['double_three']:.1f} 1B: {total_hits - rating['homerun'] - rating['bp_homerun'] - rating['triple'] - rating['double_pull'] - rating['double_two'] - rating['double_three']:.1f})"
|
||||
f" Hits: {total_hits:.1f} (HR: {rating['homerun']:.1f} 3B: {rating['triple']:.1f} 2B: {rating['double_pull']+rating['double_two']+rating['double_three']:.1f} 1B: {total_hits - rating['homerun'] - rating['bp_homerun'] - rating['triple'] - rating['double_pull'] - rating['double_two'] - rating['double_three']:.1f})"
|
||||
)
|
||||
|
||||
# Show walk/strikeout
|
||||
@ -395,7 +389,7 @@ class CustomCardCreator:
|
||||
)
|
||||
)
|
||||
print(
|
||||
f" Outs: {outs:.1f} (K: {rating['strikeout']:.1f} LD: {rating['lineout']:.1f} FB: {rating['flyout_a'] + rating['flyout_bq'] + rating['flyout_lf_b'] + rating['flyout_rf_b']:.1f} GB: {rating['groundout_a'] + rating['groundout_b'] + rating['groundout_c']:.1f})"
|
||||
f" Outs: {outs:.1f} (K: {rating['strikeout']:.1f} LD: {rating['lineout']:.1f} FB: {rating['flyout_a']+rating['flyout_bq']+rating['flyout_lf_b']+rating['flyout_rf_b']:.1f} GB: {rating['groundout_a']+rating['groundout_b']+rating['groundout_c']:.1f})"
|
||||
)
|
||||
|
||||
# Calculate and display total OPS
|
||||
@ -426,68 +420,10 @@ class CustomCardCreator:
|
||||
print("-" * 70)
|
||||
print("\nAdjust key percentages (press Enter to keep current value):\n")
|
||||
|
||||
def prompt_float(label: str, current: float) -> float:
|
||||
val = input(f" {label} [{current:.3f}]: ").strip()
|
||||
if not val:
|
||||
return current
|
||||
try:
|
||||
return float(val)
|
||||
except ValueError:
|
||||
print(" Invalid value, keeping current.")
|
||||
return current
|
||||
|
||||
def prompt_int(label: str, current: int) -> int:
|
||||
val = input(f" {label} [{current}]: ").strip()
|
||||
if not val:
|
||||
return current
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
print(" Invalid value, keeping current.")
|
||||
return current
|
||||
|
||||
arch = copy.copy(archetype)
|
||||
|
||||
print("--- vs RHP/RHB ---")
|
||||
arch.avg_vs_r = prompt_float("AVG vs R", arch.avg_vs_r)
|
||||
arch.obp_vs_r = prompt_float("OBP vs R", arch.obp_vs_r)
|
||||
arch.slg_vs_r = prompt_float("SLG vs R", arch.slg_vs_r)
|
||||
arch.bb_pct_vs_r = prompt_float("BB% vs R", arch.bb_pct_vs_r)
|
||||
arch.k_pct_vs_r = prompt_float("K% vs R", arch.k_pct_vs_r)
|
||||
|
||||
print("\n--- vs LHP/LHB ---")
|
||||
arch.avg_vs_l = prompt_float("AVG vs L", arch.avg_vs_l)
|
||||
arch.obp_vs_l = prompt_float("OBP vs L", arch.obp_vs_l)
|
||||
arch.slg_vs_l = prompt_float("SLG vs L", arch.slg_vs_l)
|
||||
arch.bb_pct_vs_l = prompt_float("BB% vs L", arch.bb_pct_vs_l)
|
||||
arch.k_pct_vs_l = prompt_float("K% vs L", arch.k_pct_vs_l)
|
||||
|
||||
print("\n--- Power Profile ---")
|
||||
arch.hr_per_hit = prompt_float("HR/Hit", arch.hr_per_hit)
|
||||
arch.triple_per_hit = prompt_float("3B/Hit", arch.triple_per_hit)
|
||||
arch.double_per_hit = prompt_float("2B/Hit", arch.double_per_hit)
|
||||
|
||||
print("\n--- Batted Ball Profile ---")
|
||||
arch.gb_pct = prompt_float("GB%", arch.gb_pct)
|
||||
arch.fb_pct = prompt_float("FB%", arch.fb_pct)
|
||||
arch.ld_pct = prompt_float("LD%", arch.ld_pct)
|
||||
|
||||
if player_type == "batter":
|
||||
print("\n--- Baserunning ---")
|
||||
arch.speed_rating = prompt_int("Speed (1-10)", arch.speed_rating) # type: ignore[arg-type]
|
||||
arch.steal_jump = prompt_int("Jump (1-10)", arch.steal_jump) # type: ignore[arg-type]
|
||||
arch.xbt_pct = prompt_float("XBT%", arch.xbt_pct) # type: ignore[union-attr]
|
||||
|
||||
# Recalculate card ratings with the modified archetype
|
||||
if player_type == "batter":
|
||||
calc = BatterRatingCalculator(arch) # type: ignore[arg-type]
|
||||
ratings = calc.calculate_ratings(battingcard_id=0)
|
||||
baserunning = calc.calculate_baserunning()
|
||||
return {"ratings": ratings, "baserunning": baserunning}
|
||||
else:
|
||||
calc_p = PitcherRatingCalculator(arch) # type: ignore[arg-type]
|
||||
ratings = calc_p.calculate_ratings(pitchingcard_id=0)
|
||||
return {"ratings": ratings}
|
||||
# TODO: Implement percentage tweaking
|
||||
# For now, return unchanged
|
||||
print("(Feature coming soon - manual adjustments available in option 3)")
|
||||
return card_data
|
||||
|
||||
async def manual_adjustments(
|
||||
self, player_type: Literal["batter", "pitcher"], card_data: dict
|
||||
@ -498,99 +434,10 @@ class CustomCardCreator:
|
||||
print("-" * 70)
|
||||
print("\nDirectly edit D20 chances (must sum to 108):\n")
|
||||
|
||||
D20_FIELDS = [
|
||||
"homerun",
|
||||
"bp_homerun",
|
||||
"triple",
|
||||
"double_three",
|
||||
"double_two",
|
||||
"double_pull",
|
||||
"single_two",
|
||||
"single_one",
|
||||
"single_center",
|
||||
"bp_single",
|
||||
"hbp",
|
||||
"walk",
|
||||
"strikeout",
|
||||
"lineout",
|
||||
"popout",
|
||||
"flyout_a",
|
||||
"flyout_bq",
|
||||
"flyout_lf_b",
|
||||
"flyout_rf_b",
|
||||
"groundout_a",
|
||||
"groundout_b",
|
||||
"groundout_c",
|
||||
]
|
||||
|
||||
# Choose which split to edit
|
||||
print("Which split to edit?")
|
||||
for i, rating in enumerate(card_data["ratings"]):
|
||||
vs = rating["vs_hand"]
|
||||
print(f" {i + 1}. vs {vs}{'HP' if player_type == 'batter' else 'HB'}")
|
||||
|
||||
while True:
|
||||
choice = input("\nSelect split (1-2): ").strip()
|
||||
try:
|
||||
idx = int(choice) - 1
|
||||
if 0 <= idx < len(card_data["ratings"]):
|
||||
break
|
||||
else:
|
||||
print("Invalid choice.")
|
||||
except ValueError:
|
||||
print("Invalid input.")
|
||||
|
||||
result = copy.deepcopy(card_data)
|
||||
rating = result["ratings"][idx]
|
||||
|
||||
while True:
|
||||
vs = rating["vs_hand"]
|
||||
print(
|
||||
f"\n--- VS {vs}{'HP' if player_type == 'batter' else 'HB'} D20 Chances ---"
|
||||
)
|
||||
total = 0.0
|
||||
for i, field in enumerate(D20_FIELDS, 1):
|
||||
val = rating[field]
|
||||
print(f" {i:2d}. {field:<20s}: {val:.2f}")
|
||||
total += val
|
||||
print(f"\n Total: {total:.2f} (target: 108.00)")
|
||||
|
||||
user_input = input(
|
||||
"\nEnter field number and new value (e.g. '1 3.5'), or 'done': "
|
||||
).strip()
|
||||
if user_input.lower() in ("done", "q", ""):
|
||||
break
|
||||
|
||||
parts = user_input.split()
|
||||
if len(parts) != 2:
|
||||
print(" Enter a field number and a value separated by a space.")
|
||||
continue
|
||||
|
||||
try:
|
||||
field_idx = int(parts[0]) - 1
|
||||
new_val = float(parts[1])
|
||||
except ValueError:
|
||||
print(" Invalid input.")
|
||||
continue
|
||||
|
||||
if not (0 <= field_idx < len(D20_FIELDS)):
|
||||
print(f" Field number must be between 1 and {len(D20_FIELDS)}.")
|
||||
continue
|
||||
|
||||
if new_val < 0:
|
||||
print(" Value cannot be negative.")
|
||||
continue
|
||||
|
||||
rating[D20_FIELDS[field_idx]] = new_val
|
||||
|
||||
total = sum(rating[f] for f in D20_FIELDS)
|
||||
if abs(total - 108.0) > 0.01:
|
||||
print(
|
||||
f"\nWarning: Total is {total:.2f} (expected 108.00). "
|
||||
"Ratings saved but card probabilities may be incorrect."
|
||||
)
|
||||
|
||||
return result
|
||||
# TODO: Implement manual adjustments
|
||||
# For now, return unchanged
|
||||
print("(Feature coming soon)")
|
||||
return card_data
|
||||
|
||||
async def create_database_records(
|
||||
self,
|
||||
@ -733,9 +580,9 @@ class CustomCardCreator:
|
||||
"name_first": player_info["name_first"],
|
||||
"name_last": player_info["name_last"],
|
||||
"hand": player_info["hand"],
|
||||
"starter_rating": card_data["starter_rating"],
|
||||
"relief_rating": card_data["relief_rating"],
|
||||
"closer_rating": card_data["closer_rating"],
|
||||
"starter_rating": 5, # TODO: Get from archetype
|
||||
"relief_rating": 5, # TODO: Get from archetype
|
||||
"closer_rating": None, # TODO: Get from archetype
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
64
db_calls.py
64
db_calls.py
@ -1,18 +1,10 @@
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
import pybaseball as pb
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from typing import Literal, Optional
|
||||
from typing import Literal
|
||||
from exceptions import logger
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_token = os.environ.get("PD_API_TOKEN")
|
||||
if not _token:
|
||||
raise EnvironmentError("PD_API_TOKEN environment variable is required")
|
||||
AUTH_TOKEN = {"Authorization": f"Bearer {_token}"}
|
||||
AUTH_TOKEN = {"Authorization": "Bearer Tp3aO3jhYve5NJF1IqOmJTmk"}
|
||||
DB_URL = "https://pd.manticorum.com/api"
|
||||
master_debug = True
|
||||
alt_database = None
|
||||
@ -33,7 +25,7 @@ def param_char(other_params):
|
||||
def get_req_url(
|
||||
endpoint: str, api_ver: int = 2, object_id: int = None, params: list = None
|
||||
):
|
||||
req_url = f"{DB_URL}/v{api_ver}/{endpoint}{'/' if object_id is not None else ''}{object_id if object_id is not None else ''}"
|
||||
req_url = f'{DB_URL}/v{api_ver}/{endpoint}{"/" if object_id is not None else ""}{object_id if object_id is not None else ""}'
|
||||
|
||||
if params:
|
||||
other_params = False
|
||||
@ -47,11 +39,11 @@ def get_req_url(
|
||||
def log_return_value(log_string: str):
|
||||
if master_debug:
|
||||
logger.info(
|
||||
f"return: {log_string[:1200]}{' [ S N I P P E D ]' if len(log_string) > 1200 else ''}\n"
|
||||
f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n'
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"return: {log_string[:1200]}{' [ S N I P P E D ]' if len(log_string) > 1200 else ''}\n"
|
||||
f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n'
|
||||
)
|
||||
|
||||
|
||||
@ -61,15 +53,13 @@ async def db_get(
|
||||
object_id: int = None,
|
||||
params: list = None,
|
||||
none_okay: bool = True,
|
||||
timeout: int = 30,
|
||||
) -> Optional[dict]:
|
||||
timeout: int = 3,
|
||||
):
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params)
|
||||
log_string = f"get:\n{endpoint} id: {object_id} params: {params}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with session.get(req_url) as r:
|
||||
logger.info(f"session info: {r}")
|
||||
if r.status == 200:
|
||||
@ -86,13 +76,11 @@ async def db_get(
|
||||
raise ValueError(f"DB: {e}")
|
||||
|
||||
|
||||
async def url_get(url: str, timeout: int = 30) -> dict:
|
||||
async def url_get(url: str, timeout: int = 3):
|
||||
log_string = f"get:\n{url}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as r:
|
||||
if r.status == 200:
|
||||
log_string = "200 received"
|
||||
@ -105,15 +93,13 @@ async def url_get(url: str, timeout: int = 30) -> dict:
|
||||
|
||||
|
||||
async def db_patch(
|
||||
endpoint: str, object_id: int, params: list, api_ver: int = 2, timeout: int = 30
|
||||
) -> dict:
|
||||
endpoint: str, object_id: int, params: list, api_ver: int = 2, timeout: int = 3
|
||||
):
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params)
|
||||
log_string = f"patch:\n{endpoint} {params}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with session.patch(req_url) as r:
|
||||
if r.status == 200:
|
||||
js = await r.json()
|
||||
@ -126,15 +112,13 @@ async def db_patch(
|
||||
|
||||
|
||||
async def db_post(
|
||||
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 30
|
||||
) -> dict:
|
||||
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 3
|
||||
):
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver)
|
||||
log_string = f"post:\n{endpoint} payload: {payload}\ntype: {type(payload)}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with session.post(req_url, json=payload) as r:
|
||||
if r.status == 200:
|
||||
js = await r.json()
|
||||
@ -147,15 +131,13 @@ async def db_post(
|
||||
|
||||
|
||||
async def db_put(
|
||||
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 30
|
||||
) -> dict:
|
||||
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 3
|
||||
):
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver)
|
||||
log_string = f"put:\n{endpoint} payload: {payload}\ntype: {type(payload)}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with session.put(req_url, json=payload) as r:
|
||||
if r.status == 200:
|
||||
js = await r.json()
|
||||
@ -167,14 +149,12 @@ async def db_put(
|
||||
raise ValueError(f"DB: {e}")
|
||||
|
||||
|
||||
async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3) -> dict:
|
||||
async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3):
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id)
|
||||
log_string = f"delete:\n{endpoint} {object_id}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with session.delete(req_url) as r:
|
||||
if r.status == 200:
|
||||
js = await r.json()
|
||||
@ -203,4 +183,4 @@ def get_player_data(
|
||||
def player_desc(this_player) -> str:
|
||||
if this_player["p_name"] in this_player["description"]:
|
||||
return this_player["description"]
|
||||
return f"{this_player['description']} {this_player['p_name']}"
|
||||
return f'{this_player["description"]} {this_player["p_name"]}'
|
||||
|
||||
@ -404,35 +404,17 @@ pd-cards upload s3 --cardset <name> [OPTIONS]
|
||||
| `--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 |
|
||||
| `--concurrency` | `-j` | `8` | Number of parallel uploads |
|
||||
| `--api-url` | | `https://pd.manticorum.com/api` | API base URL for card rendering |
|
||||
|
||||
**Prerequisites:** AWS CLI configured with credentials (`~/.aws/credentials`)
|
||||
|
||||
**S3 URL Structure:** `cards/cardset-{id:03d}/player-{player_id}/{batting|pitching}card.png?d={date}`
|
||||
|
||||
**Local Rendering:** For high-concurrency local rendering, start the Paper Dynasty API server locally and point uploads at it:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start local API server (from database repo)
|
||||
cd /mnt/NV2/Development/paper-dynasty/database
|
||||
DATABASE_TYPE=postgresql POSTGRES_HOST=10.10.0.42 POSTGRES_DB=paperdynasty_dev \
|
||||
POSTGRES_USER=sba_admin POSTGRES_PASSWORD=<pw> POSTGRES_PORT=5432 \
|
||||
API_TOKEN=your-api-token-here \
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# Terminal 2: Upload with local rendering
|
||||
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
|
||||
```
|
||||
|
||||
**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
|
||||
pd-cards upload s3 --cardset "2005 Live" --concurrency 16
|
||||
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -1,468 +0,0 @@
|
||||
# Refractor Phase 2 — Design Validation Spec
|
||||
|
||||
## Purpose
|
||||
|
||||
This document captures the design validation test cases that must be verified before and during
|
||||
Phase 2 (rating boosts) of the Refractor card progression system. Phase 1 — tracking,
|
||||
milestone evaluation, and tier state persistence — is implemented. Phase 2 adds the rating boost
|
||||
application logic (`apply_evolution_boosts`), rarity upgrade at T4, and variant hash creation.
|
||||
|
||||
**When to reference this document:**
|
||||
|
||||
- Before beginning Phase 2 implementation: review all cases to understand the design constraints
|
||||
and edge cases the implementation must handle.
|
||||
- During implementation: use each test case as an acceptance gate before the corresponding
|
||||
feature is considered complete.
|
||||
- During code review: each case documents the "risk if failed" so reviewers can assess whether
|
||||
a proposed implementation correctly handles that scenario.
|
||||
- After Phase 2 ships: run the cases as a regression checklist before any future change to the
|
||||
boost logic, rarity assignment, or milestone evaluator.
|
||||
|
||||
## Background: Rating Model
|
||||
|
||||
Batter cards have 22 outcome columns summing to exactly 108 chances (derived from the D20
|
||||
probability system: 2d6 x 3 columns x 6 rows). Each Refractor tier (T1 through T4) awards a
|
||||
1.0-chance budget — a flat shift from out columns to positive-outcome columns. The total
|
||||
accumulated budget across all four tiers is 4.0 chances, equal to approximately 3.7% of the
|
||||
108-chance total (4 / 108 ≈ 0.037).
|
||||
|
||||
**Rarity naming cross-reference:** The PRD chapters (`prd-evolution/`) use the player-facing
|
||||
display names. The codebase and this spec use the internal names from `rarity_thresholds.py`.
|
||||
They map as follows:
|
||||
|
||||
| PRD / Display Name | Codebase Name | ID |
|
||||
|---|---|---|
|
||||
| Replacement | Common | 5 |
|
||||
| Reserve | Bronze | 4 |
|
||||
| Starter | Silver | 3 |
|
||||
| All-Star | Gold | 2 |
|
||||
| MVP | Diamond | 1 |
|
||||
| Hall of Fame | HoF | 99 |
|
||||
|
||||
All rarity references in this spec use the codebase names.
|
||||
|
||||
Rarity IDs in the codebase (from `rarity_thresholds.py`):
|
||||
|
||||
| Rarity Name | ID |
|
||||
|---|---|
|
||||
| Common | 5 |
|
||||
| Bronze | 4 |
|
||||
| Silver | 3 |
|
||||
| Gold | 2 |
|
||||
| Diamond | 1 |
|
||||
| Hall of Fame | 99 |
|
||||
|
||||
The special value `99` for Hall of Fame means a naive `rarity_id + 1` increment is incorrect;
|
||||
the upgrade logic must use an ordered rarity ladder, not arithmetic.
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
---
|
||||
|
||||
### T4-1: 108-sum preservation under profile-based boosts
|
||||
|
||||
**Status:** Pending — Phase 2
|
||||
|
||||
**Scenario:**
|
||||
|
||||
`apply_evolution_boosts(card_ratings, boost_tier, player_profile)` redistributes 1.0 chance per
|
||||
tier across outcome columns according to the player's detected profile (power hitter, contact
|
||||
hitter, patient hitter, starting pitcher, relief pitcher). Every combination of profile and tier
|
||||
must leave the 22-column sum exactly equal to 108 after the boost is applied. This must hold for
|
||||
all four tier applications, cumulative as well as individual.
|
||||
|
||||
The edge case: a batter card where `flyout_a = 0`. The power and contact hitter profiles draw
|
||||
reductions from out columns including `flyout_a`. If the preferred reduction column is at zero,
|
||||
the implementation must not produce a negative value and must not silently drop the remainder of
|
||||
the budget. The 0-floor cap is enforced per column (see `05-rating-boosts.md` section 5.1:
|
||||
"Truncated points are lost, not redistributed").
|
||||
|
||||
Verify:
|
||||
- After each of T1, T2, T3, T4 boost applications, `sum(all outcome columns) == 108` exactly.
|
||||
- A card with `flyout_a = 0` does not raise an error and does not produce a column below 0.
|
||||
- When truncation occurs (column already at 0), the lost budget is discarded, not moved
|
||||
elsewhere — the post-boost sum will be less than 108 + budget_added only in the case of
|
||||
truncation, but must never exceed 108.
|
||||
|
||||
**Expected Outcome:**
|
||||
|
||||
Sum remains 108 after every boost under non-truncation conditions. Under truncation conditions
|
||||
(a column hits 0), the final column sum must equal exactly `108 - truncated_amount` — where
|
||||
`truncated_amount` is the portion of the 1.0-chance budget that was dropped due to the 0-floor
|
||||
cap. This is a single combined assertion: `sum(columns) == 108 - truncated_amount`. Checking
|
||||
"sum <= 108" and "truncated amount was discarded" as two independent conditions is insufficient
|
||||
— a test can pass both checks while the sum is wrong for an unrelated reason (e.g., a positive
|
||||
column also lost value due to a bug). No column value falls below 0.
|
||||
|
||||
**Risk If Failed:**
|
||||
|
||||
A broken 108-sum produces invalid game probabilities. The D20 engine derives per-outcome
|
||||
probabilities from `column / 108`. If the sum drifts above or below 108, every outcome
|
||||
probability on that card is subtly wrong for every future game that uses it. This error silently
|
||||
corrupts game results without any visible failure.
|
||||
|
||||
**Files Involved:**
|
||||
|
||||
- `docs/prd-evolution/05-rating-boosts.md` — boost budget, profile definitions, cap behavior
|
||||
- Phase 2: `pd_cards/evo/boost_profiles.py` (to be created) — `apply_evolution_boosts`
|
||||
- `batters/creation.py` — `battingcardratings` model column set (22 columns)
|
||||
- `pitchers/creation.py` — `pitchingcardratings` model column set (18 columns + 9 x-checks)
|
||||
|
||||
---
|
||||
|
||||
### T4-2: D20 probability shift at T4
|
||||
|
||||
**Status:** Pending — Phase 2
|
||||
|
||||
**Scenario:**
|
||||
|
||||
Take a representative Bronze-rarity batter (e.g., a player with total OPS near 0.730,
|
||||
`homerun` ≈ 1.2, `single_one` ≈ 4.0, `walk` ≈ 3.0 in the base ratings). Apply all four
|
||||
tier boosts cumulatively, distributing the total 4.0-chance budget across positive-outcome
|
||||
columns (HR, singles, walk) with equal reductions from out columns. Calculate the resulting
|
||||
absolute and relative probability change per D20 roll outcome.
|
||||
|
||||
Design target: the full T4 evolution shifts approximately 3.7% of all outcomes from outs to
|
||||
positive results (4.0 / 108 = 0.037). The shift should be perceptible to a player reviewing
|
||||
their card stats but should not fundamentally alter the card's tier or role. A Bronze batter
|
||||
does not become a Gold batter through evolution — they become an evolved Bronze batter.
|
||||
|
||||
Worked example for validation reference:
|
||||
- Pre-evolution: `homerun = 1.2` → probability per D20 = 1.2 / 108 ≈ 1.11%
|
||||
- Post T4 with +0.5 to homerun per tier (4 tiers × 0.5 = +2.0 total): `homerun = 3.2`
|
||||
→ probability per D20 = 3.2 / 108 ≈ 2.96% — an increase of ~1.85 percentage points
|
||||
- Across all positive outcomes: total shift = 4.0 / 108 ≈ 3.7%
|
||||
|
||||
**Expected Outcome:**
|
||||
|
||||
The cumulative 4.0-chance shift produces a ~3.7% total movement from negative to positive
|
||||
outcomes. No single outcome column increases by more than 2.5 chances across the full T4
|
||||
journey under any profile. The card remains recognizably Bronze — it does not cross the Gold
|
||||
OPS threshold (0.900 for 2024/2025 thresholds; confirmed in `rarity_thresholds.py`
|
||||
`BATTER_THRESHOLDS_2024.gold` and `BATTER_THRESHOLDS_2025.gold`) unless it was already near
|
||||
the boundary. Note: 0.700 is the Bronze floor (`bronze` field), not the Gold threshold.
|
||||
|
||||
**Risk If Failed:**
|
||||
|
||||
If the shift is too large, evolution becomes a rarity bypass — players grind low-rarity cards
|
||||
to simulate an upgrade they cannot earn through pack pulls. If the shift is too small, the
|
||||
system feels unrewarding and players lose motivation to complete tiers. Either miscalibration
|
||||
undermines the core design intent.
|
||||
|
||||
**Files Involved:**
|
||||
|
||||
- `docs/prd-evolution/05-rating-boosts.md` — section 5.2 (boost budgets), section 5.3 (profiles)
|
||||
- `rarity_thresholds.py` — OPS boundary values used to assess whether evolution crosses a rarity
|
||||
threshold as a side effect (it should not for mid-range cards)
|
||||
- Phase 2: `pd_cards/evo/boost_profiles.py` — boost distribution logic
|
||||
|
||||
---
|
||||
|
||||
### T4-3: T4 rarity upgrade — pipeline collision risk
|
||||
|
||||
**Status:** Pending — Phase 2
|
||||
|
||||
**Scenario:**
|
||||
|
||||
The Refractor T4 rarity upgrade (`player.rarity_id` incremented by one ladder step) and the
|
||||
live-series `post_player_updates()` rarity assignment (OPS-threshold-based, in
|
||||
`batters/creation.py`) both write to the same `rarity_id` field on the player record. A
|
||||
collision occurs when both run against the same player:
|
||||
|
||||
1. Player completes Refractor T4. Evolution system upgrades rarity: Bronze (4) → Silver (3).
|
||||
`evolution_card_state.final_rarity_id = 3` is written as an audit record.
|
||||
2. Live-series update runs two weeks later. `post_player_updates()` recalculates OPS → maps to
|
||||
Bronze (4) → writes `rarity_id = 4` to the player record.
|
||||
3. The T4 rarity upgrade is silently overwritten. The player's card reverts to Bronze. The
|
||||
`evolution_card_state` record still shows `final_rarity_id = 3` but the live card is Bronze.
|
||||
|
||||
This is a conflict between two independent systems both writing to the same field without
|
||||
awareness of each other. The current live-series pipeline has no concept of evolution state.
|
||||
|
||||
Proposed resolution strategies (document and evaluate; do not implement during Phase 2 spec):
|
||||
- **Guard clause in `post_player_updates()`:** Before writing `rarity_id`, check
|
||||
`evolution_card_state.final_rarity_id` for the player. If an evolution upgrade is on record,
|
||||
apply `max(ops_rarity, final_rarity_id_ladder_position)` — never downgrade past the T4 result.
|
||||
- **Separate evolution rarity field:** Add `evolution_rarity_bump` (int, default 0) to the
|
||||
card model. The game engine resolves effective rarity as `base_rarity + bump`. Live-series
|
||||
updates only touch `base_rarity`; the bump is immutable once T4 is reached.
|
||||
- **Deferred rarity upgrade:** T4 does not write `rarity_id` immediately. Instead, it sets a
|
||||
flag on `evolution_card_state`. `post_player_updates()` checks the flag and applies the bump
|
||||
after its own rarity calculation, ensuring the evolution upgrade layers on top of the current
|
||||
OPS-derived rarity rather than competing with it.
|
||||
|
||||
**Expected Outcome:**
|
||||
|
||||
Phase 2 must implement one of these strategies (or an alternative that provides equivalent
|
||||
protection). The collision scenario must be explicitly tested: evolve a Bronze card to T4,
|
||||
run a live-series update that maps the same player to Bronze, confirm the displayed rarity is
|
||||
Silver or higher — not Bronze.
|
||||
|
||||
**Risk If Failed:**
|
||||
|
||||
Live-series updates silently revert T4 rarity upgrades. Players invest significant game time
|
||||
reaching T4, receive the visual rarity upgrade, then lose it after the next live-series run
|
||||
with no explanation. This is one of the highest-trust violations the system can produce — a
|
||||
reward that disappears invisibly.
|
||||
|
||||
**Files Involved:**
|
||||
|
||||
- `batters/creation.py` — `post_player_updates()` (lines ~304–480)
|
||||
- `pitchers/creation.py` — equivalent `post_player_updates()` for pitchers
|
||||
- `docs/prd-evolution/05-rating-boosts.md` — section 5.4 (rarity upgrade at T4), note on live
|
||||
series interaction
|
||||
- Phase 2: `pd_cards/evo/tier_completion.py` (to be created) — T4 completion handler
|
||||
- Database: `evolution_card_state` table, `final_rarity_id` column
|
||||
|
||||
---
|
||||
|
||||
### T4-4: T4 rarity cap for HoF cards
|
||||
|
||||
**Status:** Pending — Phase 2
|
||||
|
||||
**Scenario:**
|
||||
|
||||
A player card currently at Hall of Fame rarity (`rarity_id = 99`) completes Refractor T4. The
|
||||
design specifies: HoF cards receive the T4 rating boost deltas (1.0 chance shift) but do not
|
||||
receive a rarity upgrade. The rarity stays at 99.
|
||||
|
||||
The implementation must handle this without producing an invalid rarity value. The rarity ID
|
||||
sequence in `rarity_thresholds.py` is non-contiguous — the IDs are:
|
||||
|
||||
```
|
||||
5 (Common) → 4 (Bronze) → 3 (Silver) → 2 (Gold) → 1 (Diamond) → 99 (Hall of Fame)
|
||||
```
|
||||
|
||||
A naive `rarity_id + 1` would produce `100`, which is not a valid rarity. A lookup-table
|
||||
approach on the ordered ladder must be used instead. At `99` (HoF), the ladder returns `99`
|
||||
(no-op). Additionally, Diamond (1) cards that complete T4 should upgrade to HoF (99), not to
|
||||
`rarity_id = 0` or any other invalid value.
|
||||
|
||||
**Expected Outcome:**
|
||||
|
||||
- `rarity_id = 99` (HoF): T4 boost applied, rarity unchanged at 99.
|
||||
- `rarity_id = 1` (Diamond): T4 boost applied, rarity upgrades to 99 (HoF).
|
||||
- `rarity_id = 2` (Gold): T4 boost applied, rarity upgrades to 1 (Diamond).
|
||||
- `rarity_id = 3` (Silver): T4 boost applied, rarity upgrades to 2 (Gold).
|
||||
- `rarity_id = 4` (Bronze): T4 boost applied, rarity upgrades to 3 (Silver).
|
||||
- `rarity_id = 5` (Common): T4 boost applied, rarity upgrades to 4 (Bronze).
|
||||
- No card ever receives `rarity_id` outside the set {1, 2, 3, 4, 5, 99}.
|
||||
|
||||
**Risk If Failed:**
|
||||
|
||||
An invalid rarity ID (e.g., 0, 100, or None) propagates into the game engine and Discord bot
|
||||
display layer. Cards with invalid rarities may render incorrectly, break sort/filter operations
|
||||
in pack-opening UX, or cause exceptions in code paths that switch on rarity values.
|
||||
|
||||
**Files Involved:**
|
||||
|
||||
- `rarity_thresholds.py` — authoritative rarity ID definitions
|
||||
- `docs/prd-evolution/05-rating-boosts.md` — section 5.4 (HoF cap behavior)
|
||||
- Phase 2: `pd_cards/evo/tier_completion.py` — rarity ladder lookup, T4 completion handler
|
||||
- Database: `evolution_card_state.final_rarity_id`
|
||||
|
||||
---
|
||||
|
||||
### T4-5: RP T1 achievability in realistic timeframe
|
||||
|
||||
**Status:** Pending — Phase 2
|
||||
|
||||
**Scenario:**
|
||||
|
||||
The Relief Pitcher track formula is `IP + K` with a T1 threshold of 3. The design intent is
|
||||
"almost any active reliever hits this" in approximately 2 appearances (from `04-milestones.md`
|
||||
section 4.2). The scenario to validate: a reliever who throws 1.2 IP (4 outs) with 1 K in an
|
||||
appearance scores `1.33 + 1 = 2.33` — below T1. This reliever needs another appearance before
|
||||
reaching T1.
|
||||
|
||||
The validation question is whether this is a blocking problem. If typical active RP usage
|
||||
(5+ team game appearances) reliably produces T1 within a few sessions of play, the design is
|
||||
sound. If a reliever can appear 4–5 times and still not reach T1 due to short, low-strikeout
|
||||
outings (e.g., a pure groundball closer who throws 1.0 IP / 0 K per outing), the threshold
|
||||
may be too high for the RP role to feel rewarding.
|
||||
|
||||
Reference calibration data from Season 10 (via `evo_milestone_simulator.py`): ~94% of all
|
||||
relievers reached T1 under the IP+K formula with the threshold of 3. However, this is based on
|
||||
a full or near-full season of data. The question is whether early-season RP usage (first 3–5
|
||||
team games) produces T1 reliably.
|
||||
|
||||
Worked example for a pure-groundball closer:
|
||||
- 5 appearances × (1.0 IP + 0 K) = 5.0 — reaches T1 (threshold 3) after appearance 3
|
||||
- 5 appearances × (0.2 IP + 0 K) = 1.0 — does not reach T1 after 5 appearances
|
||||
|
||||
The second case (mop-up reliever with minimal usage) is expected to not reach T1 quickly, and
|
||||
the design accepts this. What is NOT acceptable: a dedicated closer or setup man with 2+ IP per
|
||||
session failing to reach T1 after 5+ appearances.
|
||||
|
||||
**Expected Outcome:**
|
||||
|
||||
A reliever averaging 1.0+ IP per appearance reaches T1 after 3 appearances. A reliever
|
||||
averaging 0.5+ IP per appearance reaches T1 after 5–6 appearances. A reliever with fewer than
|
||||
3 total appearances in a season is not expected to reach T1 — this is acceptable. The ~94%
|
||||
Season 10 T1 rate confirms the threshold is calibrated correctly for active relievers.
|
||||
|
||||
**Risk If Failed:**
|
||||
|
||||
If active relievers (regular bullpen roles) cannot reach T1 within 5–10 team games, the
|
||||
Refractor system is effectively dead for RP cards from launch. Players who pick up RP cards
|
||||
expecting progression will see no reward for multiple play sessions, creating a negative first
|
||||
impression of the entire system.
|
||||
|
||||
**Files Involved:**
|
||||
|
||||
- `docs/prd-evolution/04-milestones.md` — section 4.2 (RP track thresholds and design intent),
|
||||
section 4.3 (Season 10 calibration data)
|
||||
- `scripts/evo_milestone_simulator.py` — `formula_rp_ip_k`, `simulate_tiers` — re-run against
|
||||
current season data to validate T1 achievability in early-season usage windows
|
||||
- Database: `evolution_track` table — threshold values (admin-tunable, no code change required
|
||||
if recalibration is needed)
|
||||
|
||||
---
|
||||
|
||||
### T4-6: SP/RP T4 parity with batters
|
||||
|
||||
**Status:** Pending — Phase 2
|
||||
|
||||
**Scenario:**
|
||||
|
||||
The T4 thresholds are:
|
||||
|
||||
| Position | T4 Threshold | Formula |
|
||||
|---|---|---|
|
||||
| Batter | 896 | PA + (TB x 2) |
|
||||
| Starting Pitcher | 240 | IP + K |
|
||||
| Relief Pitcher | 70 | IP + K |
|
||||
|
||||
These were calibrated against Season 10 production data using `evo_milestone_simulator.py`.
|
||||
The calibration target was approximately 3% of active players reaching T4 over a full season
|
||||
across all position types. The validation here is that this parity holds: one position type
|
||||
does not trivially farm Superfractors while another cannot reach T2 without extraordinary
|
||||
performance.
|
||||
|
||||
The specific risk: SP T4 requires 240 IP+K across the full season. Top Season 10 SPs (Harang:
|
||||
163, deGrom: 143) were on pace for T4 at the time of measurement but had not crossed 240 yet.
|
||||
If the final-season data shows a spike (e.g., 10–15% of SPs reaching T4 vs. 3% of batters),
|
||||
the SP threshold needs adjustment. Conversely, if no reliever reaches T4 in a full season
|
||||
where 94% reach T1, the RP T4 threshold of 70 may be achievable only by top closers in
|
||||
extreme usage scenarios.
|
||||
|
||||
Validation requires re-running `evo_milestone_simulator.py --season <current>` with the final
|
||||
season data for all three position types and comparing T4 reach percentages. Accepted tolerance:
|
||||
T4 reach rate within 2x across position types (e.g., if batters are at 3%, SP and RP should be
|
||||
between 1.5% and 6%).
|
||||
|
||||
**Expected Outcome:**
|
||||
|
||||
All three position types produce T4 rates between 1% and 6% over a full season of active play.
|
||||
No position type produces T4 rates above 10% (trivially farmable) or below 0.5% (effectively
|
||||
unachievable). SP and RP T4 rates should be comparable because their thresholds were designed
|
||||
together with the same 3% target in mind.
|
||||
|
||||
**Risk If Failed:**
|
||||
|
||||
If SP is easy (T4 in half a season) while RP is hard (T4 only for elite closers), then SP card
|
||||
owners extract disproportionate value from the system. The Refractor system's balance premise
|
||||
— "same tier, same reward, regardless of position" — breaks down, undermining player confidence
|
||||
in the fairness of the progression.
|
||||
|
||||
**Files Involved:**
|
||||
|
||||
- `docs/prd-evolution/04-milestones.md` — section 4.3 (Season 10 calibration table)
|
||||
- `scripts/evo_milestone_simulator.py` — primary validation tool; run with `--all-formulas
|
||||
--pitchers-only` and `--batters-only` flags against final season data
|
||||
- Database: `evolution_track` table — thresholds are admin-tunable; recalibration does not
|
||||
require a code deployment
|
||||
|
||||
---
|
||||
|
||||
### T4-7: Cross-season stat accumulation — design confirmation
|
||||
|
||||
**Status:** Pending — Phase 2
|
||||
|
||||
**Scenario:**
|
||||
|
||||
The milestone evaluator (Phase 1, already implemented) queries `BattingSeasonStats` and
|
||||
`PitchingSeasonStats` and SUMs the formula metric across all rows for a given
|
||||
`(player_id, team_id)` pair, regardless of season number. This means a player's Refractor
|
||||
progress is cumulative across seasons: if a player reaches 400 batter points in Season 10 and
|
||||
another 400 in Season 11, their total is 800 — within range of T4 (threshold: 896).
|
||||
|
||||
This design must be confirmed as intentional before Phase 2 is implemented, because it has
|
||||
significant downstream implications:
|
||||
|
||||
1. **Progress does not reset between seasons.** A player who earns a card across multiple
|
||||
seasons continues progressing the same Refractor state. Season boundaries are invisible to
|
||||
the evaluator.
|
||||
2. **New teams start from zero.** If a player trades away a card and acquires a new copy of the
|
||||
same player, the new card's `evolution_card_state` row starts at T0. The stat accumulation
|
||||
query is scoped to `(player_id, team_id)`, so historical stats from the previous owner are
|
||||
not inherited.
|
||||
3. **Live-series stat updates do not retroactively change progress.** The evaluator reads
|
||||
finalized season stat rows. If a player's Season 10 stats are adjusted via a data correction,
|
||||
the evaluator will pick up the change on the next evaluation run — progress could shift
|
||||
backward if a data correction removes a game's stats.
|
||||
4. **The "full season" targets in the design docs (e.g., "T4 requires ~120 games") assume
|
||||
cumulative multi-season play, not a single season.** At ~7.5 batter points per game, T4 of
|
||||
896 requires approximately 120 in-game appearances. A player who plays 40 games per season
|
||||
across three seasons reaches T4 in their third season.
|
||||
|
||||
This is the confirmed intended design per `04-milestones.md`: "Cumulative within a season —
|
||||
progress never resets mid-season." The document does not explicitly state "cumulative across
|
||||
seasons," but the evaluator implementation (SUM across all rows, no season filter) makes this
|
||||
behavior implicit. This test case exists to surface that ambiguity and require an explicit
|
||||
design decision before Phase 2 ships.
|
||||
|
||||
**Expected Outcome:**
|
||||
|
||||
Before Phase 2 implementation begins, the design intent must be explicitly confirmed in writing
|
||||
(update `04-milestones.md` section 4.1 with a cross-season statement) or the evaluator query
|
||||
must be updated to add a season boundary. The options are:
|
||||
|
||||
- **Option A (current behavior — accumulate across seasons):** Document explicitly. The
|
||||
Refractor journey can span multiple seasons. Long-term card holders are rewarded for loyalty.
|
||||
- **Option B (reset per season):** Add a season filter to the evaluator query. Refractor
|
||||
progress resets at season start. T4 is achievable within a single full season. Cards earned
|
||||
mid-season have a natural catch-up disadvantage.
|
||||
|
||||
This spec takes no position on which option is correct. It records that the choice exists,
|
||||
that the current implementation defaults to Option A, and that Phase 2 must not be built on
|
||||
an unexamined assumption about which option is in effect.
|
||||
|
||||
**Risk If Failed:**
|
||||
|
||||
If Option A is unintentional and players discover their Refractor progress carries over across
|
||||
seasons before it is documented as a feature, they will optimize around it in ways the design
|
||||
did not anticipate (e.g., holding cards across seasons purely to farm Refractor tiers). If
|
||||
Option B is unintentional and progress resets each season without warning, players who invested
|
||||
heavily in T3 at season end will be angry when their progress disappears.
|
||||
|
||||
**Files Involved:**
|
||||
|
||||
- `docs/prd-evolution/04-milestones.md` — section 4.1 (design principles) — **requires update
|
||||
to state the cross-season policy explicitly**
|
||||
- Phase 1 (implemented): `pd_cards/evo/evaluator.py` — stat accumulation query; inspect the
|
||||
WHERE clause for any season filter
|
||||
- Database: `BattingSeasonStats`, `PitchingSeasonStats` — confirm schema includes `season`
|
||||
column and whether the evaluator query filters on it
|
||||
- Database: `evolution_card_state` — confirm there is no season-reset logic in the state
|
||||
management layer
|
||||
|
||||
---
|
||||
|
||||
## Summary Status
|
||||
|
||||
| ID | Title | Status |
|
||||
|---|---|---|
|
||||
| T4-1 | 108-sum preservation under profile-based boosts | Pending — Phase 2 |
|
||||
| T4-2 | D20 probability shift at T4 | Pending — Phase 2 |
|
||||
| T4-3 | T4 rarity upgrade — pipeline collision risk | Pending — Phase 2 |
|
||||
| T4-4 | T4 rarity cap for HoF cards | Pending — Phase 2 |
|
||||
| T4-5 | RP T1 achievability in realistic timeframe | Pending — Phase 2 |
|
||||
| T4-6 | SP/RP T4 parity with batters | Pending — Phase 2 |
|
||||
| T4-7 | Cross-season stat accumulation — design confirmation | Pending — Phase 2 |
|
||||
|
||||
All cases are unblocked pending Phase 2 implementation. T4-7 requires a design decision before
|
||||
any Phase 2 code is written. T4-3 requires a resolution strategy to be selected before the T4
|
||||
completion handler is implemented.
|
||||
@ -1,356 +0,0 @@
|
||||
# Phase 0 — Render Pipeline Optimization: Project Plan
|
||||
|
||||
**Version:** 1.1
|
||||
**Date:** 2026-03-13
|
||||
**PRD Reference:** `docs/prd-evolution/02-architecture.md` § Card Render Pipeline Optimization, `13-implementation.md` § Phase 0
|
||||
**Status:** Complete — deployed to dev (PR #94), client-side concurrent uploads merged via PR #28 (card-creation repo)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Phase 0 is independent of Card Evolution and benefits all existing card workflows immediately. The goal is to reduce per-card render time and full cardset uploads significantly by eliminating browser spawn overhead, CDN dependencies, and sequential processing.
|
||||
|
||||
**Bottlenecks addressed:**
|
||||
1. New Chromium process spawned per render request (~1.0-1.5s overhead)
|
||||
2. Google Fonts CDN fetched over network on every render (~0.3-0.5s) — no persistent cache since browser is destroyed after each render
|
||||
3. Upload pipeline is fully sequential — one card at a time, blocking S3 upload via synchronous boto3
|
||||
|
||||
**Results:**
|
||||
|
||||
| Metric | Before | Target | Actual |
|
||||
|--------|--------|--------|--------|
|
||||
| Per-card render (fresh) | ~2.0s (benchmark avg) | <1.0s | **~0.98s avg** (range 0.63-1.44s, **~51% reduction**) |
|
||||
| Per-card render (cached) | N/A | — | **~0.1s** |
|
||||
| External dependencies during render | Google Fonts CDN | None | **None** |
|
||||
| Chromium processes per 800-card run | 800 | 1 | **1** |
|
||||
| 800-card upload (sequential, estimated) | ~27 min | ~8-13 min | ~13 min (estimated at 0.98s/card) |
|
||||
| 800-card upload (concurrent 8x, estimated) | N/A | ~2-4 min | ~2-3 min (estimated) |
|
||||
|
||||
**Benchmark details (7 fresh renders on dev, 2026-03-13):**
|
||||
|
||||
| Player | Type | Time |
|
||||
|--------|------|------|
|
||||
| Michael Young (12726) | Batting | 0.96s |
|
||||
| Darin Erstad (12729) | Batting | 0.78s |
|
||||
| Wilson Valdez (12746) | Batting | 1.44s |
|
||||
| Player 12750 | Batting | 0.76s |
|
||||
| Jarrod Washburn (12880) | Pitching | 0.63s |
|
||||
| Ryan Drese (12879) | Pitching | 1.25s |
|
||||
| Player 12890 | Pitching | 1.07s |
|
||||
|
||||
**Average: 0.98s** — meets the <1s target. Occasional spikes to ~1.4s from Chromium GC pressure. Pitching cards tend to render slightly faster due to less template data.
|
||||
|
||||
**Optimization breakdown:**
|
||||
- Persistent browser (WP-02): eliminated ~1.0s spawn overhead
|
||||
- Variable font deduplication (WP-01 fix): eliminated ~163KB redundant base64 parsing, saved ~0.4s
|
||||
- Remaining ~0.98s is Playwright page creation, HTML parsing, and PNG screenshot — not reducible without GPU acceleration or a different rendering approach
|
||||
|
||||
---
|
||||
|
||||
## Work Packages (6 WPs)
|
||||
|
||||
### WP-00: Baseline Benchmarks
|
||||
|
||||
**Repo:** `database` + `card-creation`
|
||||
**Complexity:** XS
|
||||
**Dependencies:** None
|
||||
|
||||
Capture before-metrics so we can measure improvement.
|
||||
|
||||
#### Tasks
|
||||
1. Time 10 sequential card renders via the API (curl with timing)
|
||||
2. Time a small batch S3 upload (e.g., 20 cards) via `pd-cards upload`
|
||||
3. Record results in a benchmark log
|
||||
|
||||
#### Tests
|
||||
- [ ] Benchmark script or documented curl commands exist and are repeatable
|
||||
|
||||
#### Acceptance Criteria
|
||||
1. Baseline numbers recorded for per-card render time
|
||||
2. Baseline numbers recorded for batch upload time
|
||||
3. Methodology is repeatable for post-optimization comparison
|
||||
|
||||
---
|
||||
|
||||
### WP-01: Self-Hosted Fonts
|
||||
|
||||
**Repo:** `database`
|
||||
**Complexity:** S
|
||||
**Dependencies:** None (can run in parallel with WP-02)
|
||||
|
||||
Replace Google Fonts CDN with locally embedded WOFF2 fonts. Eliminates ~0.3-0.5s network round-trip per render and removes external dependency.
|
||||
|
||||
#### Current State
|
||||
- `storage/templates/player_card.html` lines 5-7: `<link>` tags to `fonts.googleapis.com`
|
||||
- `storage/templates/style.html`: References `"Open Sans"` and `"Source Sans 3"` font-families
|
||||
- Two fonts used: Open Sans (300, 400, 700) and Source Sans 3 (400, 700)
|
||||
|
||||
#### Implementation
|
||||
1. Download WOFF2 files for both fonts (5 files total: Open Sans 300/400/700, Source Sans 3 400/700)
|
||||
2. Base64-encode each WOFF2 file
|
||||
3. Add `@font-face` declarations with base64 data URIs to `style.html`
|
||||
4. Remove the three `<link>` tags from `player_card.html`
|
||||
5. Visual diff: render the same card before/after and verify identical output
|
||||
|
||||
#### Files
|
||||
- **Create:** `database/storage/fonts/` directory with raw WOFF2 files (source archive, not deployed)
|
||||
- **Modify:** `database/storage/templates/style.html` — add `@font-face` declarations
|
||||
- **Modify:** `database/storage/templates/player_card.html` — remove `<link>` tags (lines 5-7)
|
||||
|
||||
#### Tests
|
||||
- [ ] Unit: `style.html` contains no `fonts.googleapis.com` references
|
||||
- [ ] Unit: `player_card.html` contains no `<link>` to external font CDNs
|
||||
- [ ] Unit: `@font-face` declarations present for all 5 font variants
|
||||
- [ ] Visual: rendered card is pixel-identical to pre-change output (manual check)
|
||||
|
||||
#### Acceptance Criteria
|
||||
1. No external network requests during card render
|
||||
2. All 5 font weights render correctly
|
||||
3. Card appearance unchanged
|
||||
|
||||
---
|
||||
|
||||
### WP-02: Persistent Browser Instance
|
||||
|
||||
**Repo:** `database`
|
||||
**Complexity:** M
|
||||
**Dependencies:** None (can run in parallel with WP-01)
|
||||
|
||||
Replace per-request Chromium launch/teardown with a persistent browser that lives for the lifetime of the API process. Eliminates ~1.0-1.5s spawn overhead per render.
|
||||
|
||||
#### Current State
|
||||
- `app/routers_v2/players.py` lines 801-826: `async with async_playwright() as p:` block creates and destroys a browser per request
|
||||
- No browser reuse, no connection pooling
|
||||
|
||||
#### Implementation
|
||||
1. Add module-level `_browser` and `_playwright` globals to `players.py`
|
||||
2. Implement `get_browser()` — lazy-init with `is_connected()` auto-reconnect
|
||||
3. Implement `shutdown_browser()` — clean teardown for API shutdown
|
||||
4. Replace the `async with async_playwright()` block with page-per-request pattern:
|
||||
```python
|
||||
browser = await get_browser()
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
||||
try:
|
||||
await page.set_content(html_string)
|
||||
await page.screenshot(path=file_path, type="png", clip={...})
|
||||
finally:
|
||||
await page.close()
|
||||
```
|
||||
5. Ensure page is always closed in `finally` block to prevent memory leaks
|
||||
|
||||
#### Files
|
||||
- **Modify:** `database/app/routers_v2/players.py` — persistent browser, page-per-request
|
||||
|
||||
#### Tests
|
||||
- [ ] Unit: `get_browser()` returns a connected browser
|
||||
- [ ] Unit: `get_browser()` returns same instance on second call
|
||||
- [ ] Unit: `get_browser()` relaunches if browser disconnected
|
||||
- [ ] Integration: render 10 cards sequentially, no browser leaks (page count returns to 0 between renders)
|
||||
- [ ] Integration: concurrent renders (4 simultaneous requests) complete without errors
|
||||
- [ ] Integration: `shutdown_browser()` cleanly closes browser and playwright
|
||||
|
||||
#### Acceptance Criteria
|
||||
1. Only 1 Chromium process running regardless of render count
|
||||
2. Page count returns to 0 between renders (no leaks)
|
||||
3. Auto-reconnect works if browser crashes
|
||||
4. ~~Per-card render time drops to ~1.0-1.5s~~ **Actual: ~0.98s avg fresh render (from ~2.0s baseline) — target met**
|
||||
|
||||
---
|
||||
|
||||
### WP-03: FastAPI Lifespan Hooks
|
||||
|
||||
**Repo:** `database`
|
||||
**Complexity:** S
|
||||
**Dependencies:** WP-02
|
||||
|
||||
Wire `get_browser()` and `shutdown_browser()` into FastAPI's lifespan so the browser warms up on startup and cleans up on shutdown.
|
||||
|
||||
#### Current State
|
||||
- `app/main.py` line 54: plain `FastAPI(...)` constructor with no lifespan
|
||||
- Only middleware is the DB session handler (lines 97-105)
|
||||
|
||||
#### Implementation
|
||||
1. Add `@asynccontextmanager` lifespan function that calls `get_browser()` on startup and `shutdown_browser()` on shutdown
|
||||
2. Pass `lifespan=lifespan` to `FastAPI()` constructor
|
||||
3. Verify existing middleware is unaffected
|
||||
|
||||
#### Files
|
||||
- **Modify:** `database/app/main.py` — add lifespan hook, pass to FastAPI constructor
|
||||
- **Modify:** `database/app/routers_v2/players.py` — export `get_browser`/`shutdown_browser` (if not already importable)
|
||||
|
||||
#### Tests
|
||||
- [ ] Integration: browser is connected immediately after API startup (before any render request)
|
||||
- [ ] Integration: browser is closed after API shutdown (no orphan processes)
|
||||
- [ ] Integration: existing DB middleware still functions correctly
|
||||
- [ ] Integration: API health endpoint still responds
|
||||
|
||||
#### Acceptance Criteria
|
||||
1. Browser pre-warmed on startup — first render request has no cold-start penalty
|
||||
2. Clean shutdown — no orphan Chromium processes after API stop
|
||||
3. No regression in existing API behavior
|
||||
|
||||
---
|
||||
|
||||
### WP-04: Concurrent Upload Pipeline
|
||||
|
||||
**Repo:** `card-creation`
|
||||
**Complexity:** M
|
||||
**Dependencies:** WP-02 (persistent browser must be deployed for concurrent renders to work)
|
||||
|
||||
Replace the sequential upload loop with semaphore-bounded `asyncio.gather` for parallel card fetching, rendering, and S3 upload.
|
||||
|
||||
#### Current State
|
||||
- `pd_cards/core/upload.py` `upload_cards_to_s3()` (lines 109-333): sequential `for x in all_players:` loop
|
||||
- `fetch_card_image` timeout hardcoded to 6s (line 28)
|
||||
- `upload_card_to_s3()` uses synchronous `boto3.put_object` — blocks the event loop
|
||||
- Single `aiohttp.ClientSession` is reused (good)
|
||||
|
||||
#### Implementation
|
||||
1. Wrap per-card processing in an `async def process_card(player)` coroutine
|
||||
2. Add `asyncio.Semaphore(concurrency)` guard (default concurrency=8)
|
||||
3. Replace sequential loop with `asyncio.gather(*[process_card(p) for p in all_players], return_exceptions=True)`
|
||||
4. Offload synchronous `upload_card_to_s3()` to thread pool via `asyncio.get_event_loop().run_in_executor(None, upload_card_to_s3, ...)`
|
||||
5. Increase `fetch_card_image` timeout from 6s to 10s
|
||||
6. Add error handling: individual card failures logged but don't abort the batch
|
||||
7. Add progress reporting: log completion count every N cards (not every start)
|
||||
8. Add `--concurrency` CLI argument to `pd-cards upload` command
|
||||
|
||||
#### Files
|
||||
- **Modify:** `pd_cards/core/upload.py` — concurrent pipeline, timeout increase
|
||||
- **Modify:** `pd_cards/cli/upload.py` (or wherever CLI args are defined) — add `--concurrency` flag
|
||||
|
||||
#### Tests
|
||||
- [ ] Unit: semaphore limits concurrent tasks to specified count
|
||||
- [ ] Unit: individual card failure doesn't abort batch (return_exceptions=True)
|
||||
- [ ] Unit: progress logging fires at correct intervals
|
||||
- [ ] Integration: 20-card concurrent upload completes successfully
|
||||
- [ ] Integration: S3 URLs are correct after concurrent upload
|
||||
- [ ] Integration: `--concurrency 1` behaves like sequential (regression safety)
|
||||
|
||||
#### Acceptance Criteria
|
||||
1. Default concurrency of 8 parallel card processes
|
||||
2. Individual failures logged, don't abort batch
|
||||
3. `fetch_card_image` timeout is 10s
|
||||
4. 800-card upload estimated at ~3-4 minutes with 8x concurrency (with WP-01 + WP-02 deployed)
|
||||
5. `--concurrency` flag available on CLI
|
||||
|
||||
---
|
||||
|
||||
### WP-05: Legacy Upload Script Update
|
||||
|
||||
**Repo:** `card-creation`
|
||||
**Complexity:** S
|
||||
**Dependencies:** WP-04
|
||||
|
||||
Apply the same concurrency pattern to `check_cards_and_upload.py` for users who still use the legacy script.
|
||||
|
||||
#### Current State
|
||||
- `check_cards_and_upload.py` lines 150-293: identical sequential pattern to `pd_cards/core/upload.py`
|
||||
- Module-level boto3 client (line 27)
|
||||
|
||||
#### Implementation
|
||||
1. Refactor the sequential loop to use `asyncio.gather` + `Semaphore` (same pattern as WP-04)
|
||||
2. Offload synchronous S3 calls to thread pool
|
||||
3. Increase fetch timeout to 10s
|
||||
4. Add progress reporting
|
||||
|
||||
#### Files
|
||||
- **Modify:** `check_cards_and_upload.py`
|
||||
|
||||
#### Tests
|
||||
- [ ] Integration: legacy script uploads 10 cards concurrently without errors
|
||||
- [ ] Integration: S3 URLs match expected format
|
||||
|
||||
#### Acceptance Criteria
|
||||
1. Same concurrency behavior as WP-04
|
||||
2. No regression in existing functionality
|
||||
|
||||
---
|
||||
|
||||
## WP Summary
|
||||
|
||||
| WP | Title | Repo | Size | Dependencies | Tests |
|
||||
|----|-------|------|------|-------------|-------|
|
||||
| WP-00 | Baseline Benchmarks | both | XS | — | 1 |
|
||||
| WP-01 | Self-Hosted Fonts | database | S | — | 4 |
|
||||
| WP-02 | Persistent Browser Instance | database | M | — | 6 |
|
||||
| WP-03 | FastAPI Lifespan Hooks | database | S | WP-02 | 4 |
|
||||
| WP-04 | Concurrent Upload Pipeline | card-creation | M | WP-02 | 6 |
|
||||
| WP-05 | Legacy Upload Script Update | card-creation | S | WP-04 | 2 |
|
||||
|
||||
**Total: 6 WPs, ~23 tests**
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
WP-00 (benchmarks)
|
||||
|
|
||||
v
|
||||
WP-01 (fonts) ──────┐
|
||||
├──> WP-03 (lifespan) ──> Deploy to dev ──> WP-04 (concurrent upload)
|
||||
WP-02 (browser) ────┘ |
|
||||
v
|
||||
WP-05 (legacy script)
|
||||
|
|
||||
v
|
||||
Re-run benchmarks
|
||||
```
|
||||
|
||||
**Parallelization:**
|
||||
- WP-00, WP-01, WP-02 can all start immediately in parallel
|
||||
- WP-03 needs WP-02
|
||||
- WP-04 needs WP-02 deployed (persistent browser must be running server-side for concurrent fetches to work)
|
||||
- WP-05 needs WP-04 (reuse the pattern)
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Base64-embedded fonts bloat template HTML | Medium | Low | WOFF2 files are small (~20-40KB each). Total ~150KB base64 added to template. Acceptable since template is loaded once into Playwright, not transmitted to clients. |
|
||||
| Persistent browser memory leak | Medium | Medium | Always close pages in `finally` block. Monitor RSS after sustained renders. Add `is_connected()` check for crash recovery. |
|
||||
| Concurrent renders overload API server | Low | High | Semaphore bounds concurrency. Start at 8, tune based on server RAM (~100MB per page). 8 pages = ~800MB, well within 16GB. |
|
||||
| Synchronous boto3 blocks event loop under concurrency | Medium | Medium | Use `run_in_executor` to offload to thread pool. Consider `aioboto3` if thread pool proves insufficient. |
|
||||
| Visual regression from font change | Low | High | Visual diff test before/after. Render same card with both approaches and compare pixel output. |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — Phase 0 is straightforward infrastructure optimization with no design decisions pending.
|
||||
|
||||
---
|
||||
|
||||
## Follow-On: Local High-Concurrency Rendering (2026-03-14)
|
||||
|
||||
After Phase 0 was deployed, a follow-on improvement was implemented: **configurable API URL** for card rendering. This enables running the Paper Dynasty API server locally on the workstation and pointing upload scripts at `localhost` for dramatically higher concurrency.
|
||||
|
||||
### Changes
|
||||
- `pd_cards/core/upload.py` — `upload_cards_to_s3()`, `refresh_card_images()`, `check_card_images()` accept `api_url` parameter (defaults to production)
|
||||
- `pd_cards/commands/upload.py` — `--api-url` CLI option on `upload s3` command
|
||||
- `check_cards_and_upload.py` — `PD_API_URL` env var override (legacy script)
|
||||
|
||||
### Expected Performance
|
||||
|
||||
| Scenario | Per-card | 800 cards |
|
||||
|----------|----------|-----------|
|
||||
| Remote server, 8x concurrency (current) | ~0.98s render + network | ~2-3 min |
|
||||
| Local server, 32x concurrency | ~0.98s render, 32 parallel | ~30-45 sec |
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Phase 0 is a prerequisite for Phase 4 (Animated Cosmetics) which needs the persistent browser for efficient multi-frame APNG capture
|
||||
- The persistent browser also benefits Phase 2/3 variant rendering
|
||||
- GPU acceleration was evaluated and rejected — see PRD `02-architecture.md` § Optimization 4
|
||||
- Consider `aioboto3` as a future enhancement if `run_in_executor` thread pool becomes a bottleneck
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,243 +0,0 @@
|
||||
# Refractor Tier Visual Spec — Cherry-Pick Reference
|
||||
|
||||
Approved effects from `wip/refractor-card-art` mockup (`docs/refractor-tier-mockup.html`).
|
||||
This document is the handoff reference for applying these visuals to the production card renderer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tier Diamond Indicator
|
||||
|
||||
A 4-quadrant diamond icon centered at the intersection of the left/right column headers.
|
||||
Replaces all per-tier emoji badges.
|
||||
|
||||
### Positioning & Structure
|
||||
|
||||
- **Position**: `left: 600px; top: 78.5px` (centered between column header top/bottom borders)
|
||||
- **Size**: `19px × 19px` (tips fit within header row bounds)
|
||||
- **Rotation**: `transform: translate(-50%, -50%) rotate(45deg)`
|
||||
- **Layout**: CSS Grid `2×2`, `gap: 2px`
|
||||
- **Background (gap color)**: `rgba(0,0,0,0.75)` with `border-radius: 2px`
|
||||
- **Base shadow**: `0 0 0 1.5px rgba(0,0,0,0.7), 0 2px 5px rgba(0,0,0,0.5)`
|
||||
- **z-index**: 20
|
||||
|
||||
### Fill Order (Baseball Base Path)
|
||||
|
||||
Quadrants fill progressively following the base path: **1st → 2nd → 3rd → Home**.
|
||||
|
||||
| Tier | Quadrants Filled | Visual |
|
||||
|------|-----------------|--------|
|
||||
| T0 (Base) | 0 — no diamond shown | — |
|
||||
| T1 | 1st base (right) | ◇ with right quadrant filled |
|
||||
| T2 | 1st + 2nd (right + top) | ◇ with two quadrants |
|
||||
| T3 | 1st + 2nd + 3rd (right + top + left) | ◇ with three quadrants |
|
||||
| T4 | All 4 (full diamond) | ◆ fully filled |
|
||||
|
||||
### Grid-to-Visual Mapping (after 45° rotation)
|
||||
|
||||
The CSS grid positions map to visual positions as follows:
|
||||
|
||||
| Grid Slot | Visual Position | Base |
|
||||
|-----------|----------------|------|
|
||||
| div 1 (top-left) | TOP | 2nd base |
|
||||
| div 2 (top-right) | RIGHT | 1st base |
|
||||
| div 3 (bottom-left) | LEFT | 3rd base |
|
||||
| div 4 (bottom-right) | BOTTOM | Home plate |
|
||||
|
||||
**Render order in HTML**: `[2nd, 1st, 3rd, home]` (matches grid slot order above).
|
||||
|
||||
### Quadrant Fill Styling
|
||||
|
||||
Unfilled quads: `background: rgba(0,0,0,0.3)` (dark placeholder).
|
||||
|
||||
Filled quads use a gradient + inset shadow for depth:
|
||||
|
||||
```css
|
||||
/* Standard filled quad */
|
||||
.diamond-quad.filled {
|
||||
background: linear-gradient(135deg, {highlight} 0%, {color} 50%, {color-darkened-75%} 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(255,255,255,0.45),
|
||||
inset 0 -1px 2px rgba(0,0,0,0.35),
|
||||
inset 1px 0 2px rgba(255,255,255,0.15);
|
||||
}
|
||||
```
|
||||
|
||||
### Approved Effect: Metallic Sheen + Pulse Glow
|
||||
|
||||
The approved diamond effect combines metallic inset highlights with an animated glow pulse.
|
||||
|
||||
**Metallic gradient** (replaces standard gradient on filled quads):
|
||||
```css
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255,255,255,0.9) 0%,
|
||||
{highlight} 20%,
|
||||
{color} 50%,
|
||||
{color-darkened-60%} 80%,
|
||||
{highlight} 100%);
|
||||
```
|
||||
|
||||
**Metallic inset shadows** (boosted highlights):
|
||||
```css
|
||||
.diamond-quad.metallic.filled {
|
||||
box-shadow:
|
||||
inset 0 1px 3px rgba(255,255,255,0.7),
|
||||
inset 0 -1px 2px rgba(0,0,0,0.5),
|
||||
inset 1px 0 3px rgba(255,255,255,0.3),
|
||||
inset -1px 0 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
```
|
||||
|
||||
**Glow pulse animation** (tight diameter, applied to `.tier-diamond` container):
|
||||
```css
|
||||
@keyframes diamond-glow-pulse {
|
||||
0% { box-shadow:
|
||||
0 0 0 1.5px rgba(0,0,0,0.7),
|
||||
0 2px 5px rgba(0,0,0,0.5),
|
||||
0 0 5px 1px var(--diamond-glow-color);
|
||||
}
|
||||
50% { box-shadow:
|
||||
0 0 0 1.5px rgba(0,0,0,0.5),
|
||||
0 2px 4px rgba(0,0,0,0.3),
|
||||
0 0 8px 3px var(--diamond-glow-color),
|
||||
0 0 14px 5px color-mix(in srgb, var(--diamond-glow-color) 25%, transparent);
|
||||
}
|
||||
100% { /* same as 0% */ }
|
||||
}
|
||||
|
||||
.tier-diamond.diamond-glow {
|
||||
animation: diamond-glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
Metallic effect **automatically** enables the glow pulse (no separate toggle needed in production).
|
||||
|
||||
---
|
||||
|
||||
## 2. Tier Diamond Colors
|
||||
|
||||
| Tier | Color (body) | Highlight (bright edge) | Glow Color | Intent |
|
||||
|------|-------------|------------------------|------------|--------|
|
||||
| T1 | `#d46a1a` | `#f0a050` | `#d46a1a` | Orange |
|
||||
| T2 | `#b82020` | `#e85050` | `#b82020` | Red |
|
||||
| T3 | `#7b2d8e` | `#b860d0` | `#7b2d8e` | Purple |
|
||||
| T4 | `#1a6af0` | `#60b0ff` | `#1a6af0` | Blue flame |
|
||||
|
||||
Progression: warm → hot → regal → transcendent.
|
||||
|
||||
---
|
||||
|
||||
## 3. T3 Gold Shimmer Sweep (Header Animation)
|
||||
|
||||
A single narrow gold stripe sweeps left-to-right across the card header.
|
||||
|
||||
- **Duration**: 2.5s loop, ease-in-out
|
||||
- **Gradient**: 105° diagonal, peak opacity 0.38
|
||||
- **Key colors**: `rgba(255,240,140,0.18)` → `rgba(255,220,80,0.38)` → `rgba(255,200,60,0.30)`
|
||||
- **Scope**: Header only (`.card-header` has `overflow: hidden`)
|
||||
- **z-index**: 5
|
||||
|
||||
```css
|
||||
@keyframes t3-shimmer {
|
||||
0% { transform: translateX(-130%); }
|
||||
100% { transform: translateX(230%); }
|
||||
}
|
||||
```
|
||||
|
||||
### Playwright APNG Capture
|
||||
|
||||
For static card rendering, the shimmer position is driven by `--anim-progress` (0.0–1.0) instead of CSS animation. Playwright captures 8 frames to produce an APNG.
|
||||
|
||||
---
|
||||
|
||||
## 4. T4 Superfractor — Layered Animation System
|
||||
|
||||
T4 stacks four independent effect layers for a premium look qualitatively different from T3.
|
||||
|
||||
### Layer 1: Prismatic Rainbow Header Sweep
|
||||
|
||||
- Seamless loop using a 200%-wide element with two mirrored rainbow bands
|
||||
- `translateX(-50%)` over 6s linear = continuous wrap
|
||||
- Colors: red → gold → green → blue → violet → pink, all at ~0.28 opacity
|
||||
- z-index: 1 (behind header text at z-index: 2)
|
||||
- Header children get `position: relative; z-index: 2` to sit above rainbow
|
||||
|
||||
### Layer 2+3: Gold/Teal Dual Glow Pulse
|
||||
|
||||
- Applied via `::before` on the card element
|
||||
- Gold and teal in opposition: when gold brightens, teal dims and vice versa
|
||||
- 2s ease-in-out loop
|
||||
- Inset box-shadows (`45px 12px` gold, `80px 5px` teal)
|
||||
- z-index: 4
|
||||
|
||||
```css
|
||||
@keyframes t4-dual-pulse {
|
||||
0% { box-shadow: inset 0 0 45px 12px rgba(201,169,78,0.40),
|
||||
inset 0 0 80px 5px rgba(45,212,191,0.08); }
|
||||
50% { box-shadow: inset 0 0 45px 12px rgba(201,169,78,0.08),
|
||||
inset 0 0 80px 5px rgba(45,212,191,0.38); }
|
||||
100% { /* same as 0% */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 4: Column Bar Shimmer
|
||||
|
||||
- White highlight (`rgba(255,255,255,0.28)`) sweeps across each column header bar
|
||||
- 1.6s ease-in-out loop, staggered by -0.25s per bar for a ripple effect
|
||||
- 6 bars total (3 left group, 3 right group)
|
||||
|
||||
---
|
||||
|
||||
## 5. T4b Variant — Full-Card Rainbow
|
||||
|
||||
Same as T4 but the prismatic rainbow covers the entire card height (not just header).
|
||||
|
||||
- Applied via `::after` on `.pd-card` instead of `.card-header::after`
|
||||
- Slightly reduced opacity (0.18–0.22 vs 0.28–0.32)
|
||||
- z-index: 6 (above content)
|
||||
- Dual glow pulse uses a separate `.dual-pulse-overlay` div at 2.8s (slightly slower)
|
||||
- Column bar shimmer identical to T4
|
||||
|
||||
**Status**: Experimental variant. May or may not ship — kept as an option.
|
||||
|
||||
---
|
||||
|
||||
## 6. Corner Accents (T4 Only)
|
||||
|
||||
L-shaped corner brackets on all four card corners.
|
||||
|
||||
- **Color**: `#c9a94e` (gold)
|
||||
- **Size**: 35px arms, 3px thick
|
||||
- **Implementation**: Four absolutely-positioned divs with two-sided borders each
|
||||
- **z-index**: 6
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Notes for Production
|
||||
|
||||
### What to port
|
||||
|
||||
1. **Diamond indicator CSS** (`.tier-diamond`, `.diamond-quad`, keyframes) → add to card template stylesheet
|
||||
2. **Diamond HTML generation** → add to Playwright card renderer (4 divs in a grid)
|
||||
3. **Metallic + glow effect** → always apply metallic class + glow animation to filled diamonds
|
||||
4. **T3 shimmer** → APNG capture with `--anim-progress` variable (8 frames)
|
||||
5. **T4 layered effects** → APNG capture with `--anim-progress` driving all 4 layers
|
||||
6. **Diamond colors** → store in tier config or derive from tier level
|
||||
7. **Corner accents** → T4 only, simple border divs
|
||||
|
||||
### What NOT to port
|
||||
|
||||
- The mockup control panel UI (sliders, dropdowns, color pickers)
|
||||
- The `diamondEffect` dropdown with 5 options (we chose metallic — hardcode it)
|
||||
- The separate `diamondGlow` toggle (metallic always includes glow)
|
||||
- Border preset / header type controls (these are already in production tier configs)
|
||||
- T4b full-card rainbow (unless explicitly promoted later)
|
||||
|
||||
### Database/API considerations
|
||||
|
||||
The diamond fill count is already derivable from the tier level — no new database fields needed:
|
||||
- `refractor_tier = 1` → `diamondFill = 1`, color = orange
|
||||
- `refractor_tier = 2` → `diamondFill = 2`, color = red
|
||||
- `refractor_tier = 3` → `diamondFill = 3`, color = purple
|
||||
- `refractor_tier = 4` → `diamondFill = 4`, color = blue-flame
|
||||
|
||||
Diamond colors are purely visual (CSS) — they don't need to be stored.
|
||||
@ -5,7 +5,6 @@ Commands for uploading card images to AWS S3.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@ -41,27 +40,14 @@ def s3(
|
||||
dry_run: bool = typer.Option(
|
||||
False, "--dry-run", "-n", help="Preview without uploading"
|
||||
),
|
||||
concurrency: int = typer.Option(
|
||||
8, "--concurrency", "-j", help="Number of parallel uploads (default: 8)"
|
||||
),
|
||||
api_url: str = typer.Option(
|
||||
"https://pd.manticorum.com/api",
|
||||
"--api-url",
|
||||
help="API base URL for card rendering (use http://localhost:8000/api for local server)",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Upload card images to AWS S3.
|
||||
|
||||
Fetches card images from Paper Dynasty API and uploads to S3 bucket.
|
||||
Cards are processed concurrently; use --concurrency to tune parallelism.
|
||||
|
||||
For high-concurrency local rendering, start the API server locally and use:
|
||||
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
|
||||
|
||||
Example:
|
||||
pd-cards upload s3 --cardset "2005 Live" --limit 10
|
||||
pd-cards upload s3 --cardset "2005 Live" --concurrency 16
|
||||
"""
|
||||
console.print()
|
||||
console.print("=" * 70)
|
||||
@ -79,10 +65,8 @@ def s3(
|
||||
console.print("Skipping: Batting cards")
|
||||
if skip_pitchers:
|
||||
console.print("Skipping: Pitching cards")
|
||||
console.print(f"API URL: {api_url}")
|
||||
console.print(f"Upload to S3: {upload and not dry_run}")
|
||||
console.print(f"Update URLs: {update_urls and not dry_run}")
|
||||
console.print(f"Concurrency: {concurrency} parallel tasks")
|
||||
console.print()
|
||||
|
||||
if dry_run:
|
||||
@ -92,53 +76,39 @@ def s3(
|
||||
raise typer.Exit(0)
|
||||
|
||||
try:
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from pd_cards.core.upload import upload_cards_to_s3
|
||||
import check_cards_and_upload as ccu
|
||||
|
||||
def progress_callback(_count: int, label: str) -> None:
|
||||
console.print(f" Progress: {label}")
|
||||
# Configure the module's globals
|
||||
ccu.CARDSET_NAME = cardset
|
||||
ccu.START_ID = start_id
|
||||
ccu.TEST_COUNT = limit if limit else 9999
|
||||
ccu.HTML_CARDS = html
|
||||
ccu.SKIP_BATS = skip_batters
|
||||
ccu.SKIP_ARMS = skip_pitchers
|
||||
ccu.UPLOAD_TO_S3 = upload
|
||||
ccu.UPDATE_PLAYER_URLS = update_urls
|
||||
|
||||
# Re-initialize S3 client if uploading
|
||||
if upload:
|
||||
import boto3
|
||||
|
||||
ccu.s3_client = boto3.client("s3", region_name=ccu.AWS_REGION)
|
||||
else:
|
||||
ccu.s3_client = None
|
||||
|
||||
console.print("[bold]Starting S3 upload...[/bold]")
|
||||
console.print()
|
||||
|
||||
result = asyncio.run(
|
||||
upload_cards_to_s3(
|
||||
cardset_name=cardset,
|
||||
start_id=start_id,
|
||||
limit=limit,
|
||||
html_cards=html,
|
||||
skip_batters=skip_batters,
|
||||
skip_pitchers=skip_pitchers,
|
||||
upload=upload,
|
||||
update_urls=update_urls,
|
||||
on_progress=progress_callback,
|
||||
concurrency=concurrency,
|
||||
api_url=api_url,
|
||||
)
|
||||
)
|
||||
|
||||
success_count = len(result["successes"])
|
||||
error_count = len(result["errors"])
|
||||
upload_count = len(result["uploads"])
|
||||
url_update_count = len(result["url_updates"])
|
||||
asyncio.run(ccu.main([]))
|
||||
|
||||
console.print()
|
||||
console.print("=" * 70)
|
||||
console.print("[bold green]✓ S3 UPLOAD COMPLETE[/bold green]")
|
||||
console.print("=" * 70)
|
||||
console.print(f" Successes: {success_count}")
|
||||
console.print(f" S3 uploads: {upload_count}")
|
||||
console.print(f" URL updates: {url_update_count}")
|
||||
if error_count:
|
||||
console.print(f" [red]Errors: {error_count}[/red]")
|
||||
for player, err in result["errors"][:10]:
|
||||
console.print(
|
||||
f" - player {player.get('player_id', '?')} "
|
||||
f"({player.get('p_name', '?')}): {err}"
|
||||
)
|
||||
if error_count > 10:
|
||||
console.print(f" ... and {error_count - 10} more (see logs)")
|
||||
|
||||
except ImportError as e:
|
||||
console.print(f"[red]Error importing modules: {e}[/red]")
|
||||
|
||||
@ -4,7 +4,6 @@ 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
|
||||
import urllib.parse
|
||||
@ -26,7 +25,7 @@ def get_s3_base_url(
|
||||
return f"https://{bucket}.s3.{region}.amazonaws.com"
|
||||
|
||||
|
||||
async def fetch_card_image(session, card_url: str, timeout: int = 10) -> bytes:
|
||||
async def fetch_card_image(session, card_url: str, timeout: int = 6) -> bytes:
|
||||
"""
|
||||
Fetch card image from URL and return raw bytes.
|
||||
|
||||
@ -107,9 +106,6 @@ def upload_card_to_s3(
|
||||
raise
|
||||
|
||||
|
||||
DEFAULT_PD_API_URL = "https://pd.manticorum.com/api"
|
||||
|
||||
|
||||
async def upload_cards_to_s3(
|
||||
cardset_name: str,
|
||||
start_id: Optional[int] = None,
|
||||
@ -122,18 +118,9 @@ async def upload_cards_to_s3(
|
||||
bucket: str = DEFAULT_AWS_BUCKET,
|
||||
region: str = DEFAULT_AWS_REGION,
|
||||
on_progress: callable = None,
|
||||
concurrency: int = 8,
|
||||
api_url: str = DEFAULT_PD_API_URL,
|
||||
) -> dict:
|
||||
"""
|
||||
Upload card images to S3 for a cardset using concurrent async tasks.
|
||||
|
||||
Cards are fetched and uploaded in parallel, bounded by ``concurrency``
|
||||
semaphore slots. boto3 S3 calls (synchronous) are offloaded to a thread
|
||||
pool via ``loop.run_in_executor`` so they do not block the event loop.
|
||||
|
||||
Individual card failures are collected and do NOT abort the batch;
|
||||
a summary is logged once all tasks complete.
|
||||
Upload card images to S3 for a cardset.
|
||||
|
||||
Args:
|
||||
cardset_name: Name of the cardset to process
|
||||
@ -147,7 +134,6 @@ async def upload_cards_to_s3(
|
||||
bucket: S3 bucket name
|
||||
region: AWS region
|
||||
on_progress: Callback function for progress updates
|
||||
concurrency: Number of parallel card-processing tasks (default 8)
|
||||
|
||||
Returns:
|
||||
Dict with counts of errors, successes, uploads, url_updates
|
||||
@ -179,225 +165,163 @@ async def upload_cards_to_s3(
|
||||
timestamp = int(now.timestamp())
|
||||
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
|
||||
|
||||
# PD API base URL for card generation (configurable for local rendering)
|
||||
PD_API_URL = api_url
|
||||
logger.info(f"Using API URL: {PD_API_URL}")
|
||||
# PD API base URL for card generation
|
||||
PD_API_URL = "https://pd.manticorum.com/api"
|
||||
|
||||
# Initialize S3 client if uploading (boto3 client is thread-safe for reads;
|
||||
# we will call it from a thread pool so we create it once here)
|
||||
# Initialize S3 client if uploading
|
||||
s3_client = boto3.client("s3", region_name=region) if upload else None
|
||||
|
||||
# Build the filtered list of players to process, respecting start_id / limit
|
||||
max_count = limit or 9999
|
||||
filtered_players = []
|
||||
for x in all_players:
|
||||
if len(filtered_players) >= max_count:
|
||||
break
|
||||
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
|
||||
filtered_players.append(x)
|
||||
|
||||
total = len(filtered_players)
|
||||
logger.info(f"Processing {total} cards with concurrency={concurrency}")
|
||||
|
||||
# Shared mutable state protected by a lock
|
||||
errors = []
|
||||
successes = []
|
||||
uploads = []
|
||||
url_updates = []
|
||||
completed = 0
|
||||
progress_lock = asyncio.Lock()
|
||||
results_lock = asyncio.Lock()
|
||||
cxn_error = False
|
||||
count = 0
|
||||
max_count = limit or 9999
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def report_progress():
|
||||
"""Increment the completed counter and log every 20 completions."""
|
||||
nonlocal completed
|
||||
async with progress_lock:
|
||||
completed += 1
|
||||
if completed % 20 == 0 or completed == total:
|
||||
logger.info(f"Progress: {completed}/{total} cards processed")
|
||||
if on_progress:
|
||||
on_progress(completed, f"{completed}/{total}")
|
||||
|
||||
async def process_single_card(x: dict) -> None:
|
||||
"""
|
||||
Process one player entry: fetch card image(s), upload to S3, and
|
||||
optionally patch the player record with the new S3 URL.
|
||||
|
||||
Both the primary card (image) and the secondary card for two-way
|
||||
players (image2) are handled here. Errors are appended to the
|
||||
shared ``errors`` list rather than re-raised so the batch continues.
|
||||
"""
|
||||
async with semaphore:
|
||||
player_id = x["player_id"]
|
||||
|
||||
# --- primary card ---
|
||||
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"]:
|
||||
async with results_lock:
|
||||
errors.append((x, f"Bad card url: {x['image']}"))
|
||||
await report_progress()
|
||||
return
|
||||
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"
|
||||
pd_card_url = (
|
||||
f"{PD_API_URL}/v2/players/{player_id}/{card_type}card?d={release_date}"
|
||||
)
|
||||
|
||||
# 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 = 10
|
||||
timeout = 6
|
||||
|
||||
primary_ok = False
|
||||
try:
|
||||
if upload and not html_cards:
|
||||
# Fetch card image bytes directly
|
||||
image_bytes = await fetch_card_image(
|
||||
session, card_url, timeout=timeout
|
||||
)
|
||||
# boto3 is synchronous — offload to thread pool
|
||||
s3_url = await loop.run_in_executor(
|
||||
None,
|
||||
upload_card_to_s3,
|
||||
s3_url = upload_card_to_s3(
|
||||
s3_client,
|
||||
image_bytes,
|
||||
player_id,
|
||||
x["player_id"],
|
||||
card_type,
|
||||
release_date,
|
||||
cardset["id"],
|
||||
bucket,
|
||||
region,
|
||||
)
|
||||
async with results_lock:
|
||||
uploads.append((player_id, card_type, s3_url))
|
||||
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=player_id,
|
||||
object_id=x["player_id"],
|
||||
params=[("image", s3_url)],
|
||||
)
|
||||
async with results_lock:
|
||||
url_updates.append((player_id, card_type, s3_url))
|
||||
logger.info(f"Updated player {player_id} image URL to S3")
|
||||
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)
|
||||
|
||||
primary_ok = True
|
||||
|
||||
except ConnectionError as e:
|
||||
logger.error(f"Connection error for player {player_id}: {e}")
|
||||
async with results_lock:
|
||||
errors.append((x, e))
|
||||
if cxn_error:
|
||||
raise e
|
||||
cxn_error = True
|
||||
errors.append((x, e))
|
||||
|
||||
except ValueError as e:
|
||||
async with results_lock:
|
||||
errors.append((x, e))
|
||||
errors.append((x, e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"S3 upload/update failed for player {player_id}: {e}")
|
||||
async with results_lock:
|
||||
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
|
||||
|
||||
if not primary_ok:
|
||||
await report_progress()
|
||||
return
|
||||
|
||||
# --- secondary card (two-way players) ---
|
||||
# Handle image2 (dual-position players)
|
||||
if x["image2"] is not None:
|
||||
if "sombaseball" in x["image2"]:
|
||||
async with results_lock:
|
||||
errors.append((x, f"Bad card url: {x['image2']}"))
|
||||
await report_progress()
|
||||
return
|
||||
|
||||
card_type2 = "pitching" if "pitching" in x["image2"] else "batting"
|
||||
pd_card_url2 = f"{PD_API_URL}/v2/players/{player_id}/{card_type2}card?d={release_date}"
|
||||
card_url2 = f"{pd_card_url2}&html=true" if html_cards else pd_card_url2
|
||||
pd_card_url2 = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type2}card?d={release_date}"
|
||||
|
||||
try:
|
||||
if upload and not html_cards:
|
||||
image_bytes2 = await fetch_card_image(
|
||||
session, card_url2, timeout=10
|
||||
)
|
||||
s3_url2 = await loop.run_in_executor(
|
||||
None,
|
||||
upload_card_to_s3,
|
||||
s3_client,
|
||||
image_bytes2,
|
||||
player_id,
|
||||
card_type2,
|
||||
release_date,
|
||||
cardset["id"],
|
||||
bucket,
|
||||
region,
|
||||
)
|
||||
async with results_lock:
|
||||
uploads.append((player_id, card_type2, s3_url2))
|
||||
if html_cards:
|
||||
card_url2 = f"{pd_card_url2}&html=true"
|
||||
else:
|
||||
card_url2 = pd_card_url2
|
||||
|
||||
if update_urls:
|
||||
await db_patch(
|
||||
"players",
|
||||
object_id=player_id,
|
||||
params=[("image2", s3_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
|
||||
)
|
||||
async with results_lock:
|
||||
url_updates.append((player_id, card_type2, s3_url2))
|
||||
logger.info(f"Updated player {player_id} image2 URL to S3")
|
||||
else:
|
||||
await url_get(card_url2, timeout=10)
|
||||
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)
|
||||
|
||||
async with results_lock:
|
||||
successes.append(x)
|
||||
|
||||
except ConnectionError as e:
|
||||
logger.error(f"Connection error for player {player_id} image2: {e}")
|
||||
async with results_lock:
|
||||
except ConnectionError as e:
|
||||
if cxn_error:
|
||||
raise e
|
||||
cxn_error = True
|
||||
errors.append((x, e))
|
||||
|
||||
except ValueError as e:
|
||||
async with results_lock:
|
||||
except ValueError as e:
|
||||
errors.append((x, e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"S3 upload/update failed for player {player_id} image2: {e}"
|
||||
)
|
||||
async with results_lock:
|
||||
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:
|
||||
async with results_lock:
|
||||
successes.append(x)
|
||||
|
||||
await report_progress()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = [process_single_card(x) for x in filtered_players]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Log final summary
|
||||
success_count = len(successes)
|
||||
error_count = len(errors)
|
||||
logger.info(
|
||||
f"Upload complete: {success_count} succeeded, {error_count} failed "
|
||||
f"out of {total} cards"
|
||||
)
|
||||
if error_count:
|
||||
for player, err in errors:
|
||||
logger.warning(
|
||||
f" Failed: player {player.get('player_id', '?')} "
|
||||
f"({player.get('p_name', '?')}): {err}"
|
||||
)
|
||||
successes.append(x)
|
||||
|
||||
return {
|
||||
"errors": errors,
|
||||
@ -414,7 +338,6 @@ async def refresh_card_images(
|
||||
limit: Optional[int] = None,
|
||||
html_cards: bool = False,
|
||||
on_progress: callable = None,
|
||||
api_url: str = DEFAULT_PD_API_URL,
|
||||
) -> dict:
|
||||
"""
|
||||
Refresh card images for a cardset by triggering regeneration.
|
||||
@ -434,7 +357,7 @@ async def refresh_card_images(
|
||||
raise ValueError(f'Cardset "{cardset_name}" not found')
|
||||
cardset = c_query["cardsets"][0]
|
||||
|
||||
CARD_BASE_URL = f"{api_url}/v2/players"
|
||||
CARD_BASE_URL = "https://pd.manticorum.com/api/v2/players"
|
||||
|
||||
# Get all players
|
||||
p_query = await db_get(
|
||||
@ -547,10 +470,7 @@ async def refresh_card_images(
|
||||
|
||||
|
||||
async def check_card_images(
|
||||
cardset_name: str,
|
||||
limit: Optional[int] = None,
|
||||
on_progress: callable = None,
|
||||
api_url: str = DEFAULT_PD_API_URL,
|
||||
cardset_name: str, limit: Optional[int] = None, on_progress: callable = None
|
||||
) -> dict:
|
||||
"""
|
||||
Check and validate card images without uploading.
|
||||
@ -586,7 +506,7 @@ async def check_card_images(
|
||||
now = datetime.datetime.now()
|
||||
timestamp = int(now.timestamp())
|
||||
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
|
||||
PD_API_URL = api_url
|
||||
PD_API_URL = "https://pd.manticorum.com/api"
|
||||
|
||||
errors = []
|
||||
successes = []
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import urllib.parse
|
||||
import pandas as pd
|
||||
from typing import Any, Dict
|
||||
from typing import Dict
|
||||
|
||||
from creation_helpers import (
|
||||
get_all_pybaseball_ids,
|
||||
@ -196,8 +196,8 @@ async def create_new_players(
|
||||
{
|
||||
"p_name": f"{f_name} {l_name}",
|
||||
"cost": NEW_PLAYER_COST,
|
||||
"image": f"{card_base_url}/{df_data['player_id']}/"
|
||||
f"pitchingcard{urllib.parse.quote('?d=')}{release_dir}",
|
||||
"image": f'{card_base_url}/{df_data["player_id"]}/'
|
||||
f'pitchingcard{urllib.parse.quote("?d=")}{release_dir}',
|
||||
"mlbclub": CLUB_LIST[df_data["Tm_vL"]],
|
||||
"franchise": FRANCHISE_LIST[df_data["Tm_vL"]],
|
||||
"cardset_id": cardset["id"],
|
||||
@ -268,7 +268,7 @@ async def calculate_pitching_cards(
|
||||
|
||||
def create_pitching_card(df_data):
|
||||
logger.info(
|
||||
f"Creating pitching card for {df_data['name_first']} {df_data['name_last']} / fg ID: {df_data['key_fangraphs']}"
|
||||
f'Creating pitching card for {df_data["name_first"]} {df_data["name_last"]} / fg ID: {df_data["key_fangraphs"]}'
|
||||
)
|
||||
pow_data = cde.pow_ratings(
|
||||
float(df_data["Inn_def"]), df_data["GS"], df_data["G"]
|
||||
@ -298,13 +298,11 @@ async def calculate_pitching_cards(
|
||||
int(df_data["GF"]), int(df_data["SV"]), int(df_data["G"])
|
||||
),
|
||||
"hand": df_data["pitch_hand"],
|
||||
"batting": f"#1W{df_data['pitch_hand']}-C",
|
||||
"batting": f'#1W{df_data["pitch_hand"]}-C',
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Skipping fg ID {df_data['key_fangraphs']} due to exception"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Skipping fg ID {df_data["key_fangraphs"]} due to: {e}')
|
||||
|
||||
print("Calculating pitching cards...")
|
||||
pitching_stats.apply(create_pitching_card, axis=1)
|
||||
@ -335,7 +333,7 @@ async def create_position(
|
||||
|
||||
def create_pit_position(df_data):
|
||||
if df_data["key_bbref"] in df_p.index:
|
||||
logger.debug(f"Running P stats for {df_data['p_name']}")
|
||||
logger.debug(f'Running P stats for {df_data["p_name"]}')
|
||||
pit_positions.append(
|
||||
{
|
||||
"player_id": int(df_data["player_id"]),
|
||||
@ -357,7 +355,7 @@ async def create_position(
|
||||
try:
|
||||
pit_positions.append(
|
||||
{
|
||||
"player_id": int(float(df_data["player_id"])),
|
||||
"player_id": int(df_data["key_bbref"]),
|
||||
"position": "P",
|
||||
"innings": 1,
|
||||
"range": 5,
|
||||
@ -366,7 +364,7 @@ async def create_position(
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"Could not create pitcher position for {df_data['key_bbref']}"
|
||||
f'Could not create pitcher position for {df_data["key_bbref"]}'
|
||||
)
|
||||
|
||||
print("Calculating pitcher fielding lines now...")
|
||||
@ -388,7 +386,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers:
|
||||
pitching_ratings.extend(cpi.get_pitcher_ratings(df_data))
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"Could not create a pitching card for {df_data['key_fangraphs']}"
|
||||
f'Could not create a pitching card for {df_data["key_fangraphs"]}'
|
||||
)
|
||||
|
||||
print("Calculating card ratings...")
|
||||
@ -402,7 +400,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers:
|
||||
|
||||
|
||||
async def post_player_updates(
|
||||
cardset: Dict[str, Any],
|
||||
cardset: Dict[str, any],
|
||||
player_description: str,
|
||||
card_base_url: str,
|
||||
release_dir: str,
|
||||
@ -527,8 +525,8 @@ async def post_player_updates(
|
||||
[
|
||||
(
|
||||
"image",
|
||||
f"{card_base_url}/{df_data['player_id']}/pitchingcard"
|
||||
f"{urllib.parse.quote('?d=')}{release_dir}",
|
||||
f'{card_base_url}/{df_data["player_id"]}/pitchingcard'
|
||||
f'{urllib.parse.quote("?d=")}{release_dir}',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@ -23,8 +23,6 @@ dependencies = [
|
||||
"pydantic>=2.9.0",
|
||||
# AWS
|
||||
"boto3>=1.35.0",
|
||||
# Environment
|
||||
"python-dotenv>=1.0.0",
|
||||
# Scraping
|
||||
"beautifulsoup4>=4.12.0",
|
||||
"lxml>=5.0.0",
|
||||
|
||||
@ -23,9 +23,9 @@ multidict==6.1.0
|
||||
numpy==2.1.2
|
||||
packaging==24.1
|
||||
pandas==2.2.3
|
||||
peewee==3.19.0
|
||||
peewee
|
||||
pillow==11.0.0
|
||||
polars==1.36.1
|
||||
polars
|
||||
pluggy==1.5.0
|
||||
propcache==0.2.0
|
||||
# pyarrow==17.0.0
|
||||
|
||||
@ -53,30 +53,21 @@ PROMO_INCLUSION_RETRO_IDS = [
|
||||
# 'haraa001', # Aaron Harang (SP)
|
||||
# 'hofft001', # Trevor Hoffman (RP)
|
||||
]
|
||||
MIN_PA_VL = 20 # 1 for PotM
|
||||
MIN_PA_VR = 40 # 1 for PotM
|
||||
MIN_TBF_VL = 20
|
||||
MIN_TBF_VR = 40
|
||||
CARDSET_ID = 27 # 27: 2005 Live, 28: 2005 Promos
|
||||
MIN_PA_VL = 20 if "live" in PLAYER_DESCRIPTION.lower() else 1 # 1 for PotM
|
||||
MIN_PA_VR = 40 if "live" in PLAYER_DESCRIPTION.lower() else 1 # 1 for PotM
|
||||
MIN_TBF_VL = MIN_PA_VL
|
||||
MIN_TBF_VR = MIN_PA_VR
|
||||
CARDSET_ID = (
|
||||
27 if "live" in PLAYER_DESCRIPTION.lower() else 28
|
||||
) # 27: 2005 Live, 28: 2005 Promos
|
||||
|
||||
# Per-Update Parameters
|
||||
SEASON_PCT = 81 / 162 # Through end of July (~half season)
|
||||
START_DATE = 20050403 # YYYYMMDD format - 2005 Opening Day
|
||||
# END_DATE = 20050531 # YYYYMMDD format - May PotM
|
||||
END_DATE = 20050731 # End of July 2005
|
||||
SEASON_END_DATE = 20051002 # 2005 regular season end date (used to derive SEASON_PCT)
|
||||
SEASON_PCT = min(
|
||||
(
|
||||
datetime.datetime.strptime(str(END_DATE), "%Y%m%d")
|
||||
- datetime.datetime.strptime(str(START_DATE), "%Y%m%d")
|
||||
).days
|
||||
/ (
|
||||
datetime.datetime.strptime(str(SEASON_END_DATE), "%Y%m%d")
|
||||
- datetime.datetime.strptime(str(START_DATE), "%Y%m%d")
|
||||
).days,
|
||||
1.0,
|
||||
)
|
||||
POST_DATA = True
|
||||
LAST_WEEK_RATIO = 0.0
|
||||
LAST_WEEK_RATIO = 0.0 if PLAYER_DESCRIPTION == "Live" else 0.0
|
||||
LAST_TWOWEEKS_RATIO = 0.0
|
||||
LAST_MONTH_RATIO = 0.0
|
||||
|
||||
@ -1438,7 +1429,7 @@ def calc_pitching_cards(ps: pd.DataFrame, season_pct: float) -> pd.DataFrame:
|
||||
"closer_rating": [
|
||||
cpi.closer_rating(int(row["GF"]), int(row["SV"]), int(row["G"]))
|
||||
],
|
||||
"batting": [f"#1W{row['pitch_hand'].upper()}-C"],
|
||||
"batting": [f'#1W{row["pitch_hand"].upper()}-C'],
|
||||
}
|
||||
)
|
||||
return y.loc[0]
|
||||
@ -1607,7 +1598,7 @@ def calc_positions(bs: pd.DataFrame) -> pd.DataFrame:
|
||||
]:
|
||||
if row["key_bbref"] in pos_df.index:
|
||||
logger.info(
|
||||
f"Running {position} stats for {row['use_name']} {row['last_name']}"
|
||||
f'Running {position} stats for {row["use_name"]} {row["last_name"]}'
|
||||
)
|
||||
try:
|
||||
if "bis_runs_total" in pos_df.columns:
|
||||
@ -1874,8 +1865,8 @@ async def get_or_post_players(
|
||||
|
||||
def new_player_payload(row, ratings_df: pd.DataFrame):
|
||||
return {
|
||||
"p_name": f"{row['use_name']} {row['last_name']}",
|
||||
"cost": f"{ratings_df.loc[row['key_bbref']]['cost']}",
|
||||
"p_name": f'{row["use_name"]} {row["last_name"]}',
|
||||
"cost": f'{ratings_df.loc[row['key_bbref']]["cost"]}',
|
||||
"image": "change-me",
|
||||
"mlbclub": CLUB_LIST[row["Tm"]],
|
||||
"franchise": FRANCHISE_LIST[row["Tm"]],
|
||||
@ -1925,11 +1916,11 @@ async def get_or_post_players(
|
||||
# Update positions for existing players too
|
||||
all_pos = get_player_record_pos(def_rat_df, row)
|
||||
patch_params = [
|
||||
("cost", f"{bat_rat_df.loc[row['key_bbref']]['cost']}"),
|
||||
("cost", f'{bat_rat_df.loc[row['key_bbref']]["cost"]}'),
|
||||
("rarity_id", int(bat_rat_df.loc[row["key_bbref"]]["rarity_id"])),
|
||||
(
|
||||
"image",
|
||||
f"{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}",
|
||||
f'{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}',
|
||||
),
|
||||
]
|
||||
# Add position updates - set all 8 slots to clear any old positions
|
||||
@ -1973,7 +1964,7 @@ async def get_or_post_players(
|
||||
params=[
|
||||
(
|
||||
"image",
|
||||
f"{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}",
|
||||
f'{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}',
|
||||
)
|
||||
],
|
||||
)
|
||||
@ -2012,11 +2003,11 @@ async def get_or_post_players(
|
||||
|
||||
# Determine pitcher positions based on ratings
|
||||
patch_params = [
|
||||
("cost", f"{pit_rat_df.loc[row['key_bbref']]['cost']}"),
|
||||
("cost", f'{pit_rat_df.loc[row['key_bbref']]["cost"]}'),
|
||||
("rarity_id", int(pit_rat_df.loc[row["key_bbref"]]["rarity_id"])),
|
||||
(
|
||||
"image",
|
||||
f"{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}",
|
||||
f'{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}',
|
||||
),
|
||||
]
|
||||
|
||||
@ -2090,7 +2081,7 @@ async def get_or_post_players(
|
||||
params=[
|
||||
(
|
||||
"image",
|
||||
f"{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}",
|
||||
f'{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}',
|
||||
)
|
||||
],
|
||||
)
|
||||
@ -2114,10 +2105,10 @@ async def get_or_post_players(
|
||||
raise KeyError("Could not get players - not enough stat DFs were supplied")
|
||||
|
||||
pd.DataFrame(player_deltas[1:], columns=player_deltas[0]).to_csv(
|
||||
f"{'batter' if bstat_df is not None else 'pitcher'}-deltas.csv"
|
||||
f'{"batter" if bstat_df is not None else "pitcher"}-deltas.csv'
|
||||
)
|
||||
pd.DataFrame(new_players[1:], columns=new_players[0]).to_csv(
|
||||
f"new-{'batter' if bstat_df is not None else 'pitcher'}s.csv"
|
||||
f'new-{"batter" if bstat_df is not None else "pitcher"}s.csv'
|
||||
)
|
||||
|
||||
players_df = pd.DataFrame(all_players).set_index("bbref_id")
|
||||
@ -2289,7 +2280,7 @@ async def post_positions(pos_df: pd.DataFrame, delete_existing: bool = False):
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to delete cardposition {pos['id']}: {e}"
|
||||
f'Failed to delete cardposition {pos["id"]}: {e}'
|
||||
)
|
||||
logger.info(f"Deleted {deleted_count} positions for players in current run")
|
||||
|
||||
|
||||
@ -96,7 +96,7 @@ def build_c_throw(all_positions, pos_code):
|
||||
|
||||
async def fetch_data(data):
|
||||
start_time = log_time("start", print_to_console=False)
|
||||
this_query = await db_get(endpoint=data[0], params=data[1], timeout=120)
|
||||
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
|
||||
|
||||
|
||||
@ -1,290 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# WP-00: Paper Dynasty Card Render & Upload Pipeline Benchmark
|
||||
# Phase 0 - Render Pipeline Optimization
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/benchmark_render.sh # Run full benchmark (dev API)
|
||||
# ./scripts/benchmark_render.sh --prod # Run against production API
|
||||
# ./scripts/benchmark_render.sh --quick # Connectivity check only
|
||||
#
|
||||
# Requirements: curl, bc
|
||||
# =============================================================================
|
||||
|
||||
# --- Configuration -----------------------------------------------------------
|
||||
|
||||
DEV_API="https://pddev.manticorum.com/api"
|
||||
PROD_API="https://pd.manticorum.com/api"
|
||||
API_URL="$DEV_API"
|
||||
|
||||
# Player IDs in the 12000-13000 range (2005 Live cardset)
|
||||
# Mix of batters and pitchers across different teams
|
||||
PLAYER_IDS=(12785 12790 12800 12810 12820 12830 12840 12850 12860 12870)
|
||||
|
||||
RESULTS_FILE="$(dirname "$0")/benchmark_results.txt"
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
RUN_LABEL="benchmark-$(date +%s)"
|
||||
|
||||
# --- Argument parsing ---------------------------------------------------------
|
||||
|
||||
QUICK_MODE=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--prod) API_URL="$PROD_API" ;;
|
||||
--quick) QUICK_MODE=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--prod] [--quick]"
|
||||
echo " --prod Use production API instead of dev"
|
||||
echo " --quick Connectivity check only (1 request)"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Helpers -----------------------------------------------------------------
|
||||
|
||||
hr() { printf '%0.s-' {1..72}; echo; }
|
||||
|
||||
# bc-based float arithmetic
|
||||
fadd() { echo "$1 + $2" | bc -l; }
|
||||
fdiv() { echo "scale=6; $1 / $2" | bc -l; }
|
||||
flt() { echo "$1 < $2" | bc -l; } # returns 1 if true
|
||||
fmt3() { printf "%.3f" "$1"; } # format to 3 decimal places
|
||||
|
||||
# Print and simultaneously append to results file
|
||||
log() { echo "$@" | tee -a "$RESULTS_FILE"; }
|
||||
|
||||
# Single card render with timing; sets LAST_HTTP, LAST_TIME, LAST_SIZE
|
||||
measure_card() {
|
||||
local player_id="$1"
|
||||
local card_type="${2:-batting}"
|
||||
local cache_bust="${RUN_LABEL}-${player_id}"
|
||||
local url="${API_URL}/v2/players/${player_id}/${card_type}card?d=${cache_bust}"
|
||||
|
||||
# -s silent, -o discard body, -w write timing vars separated by |
|
||||
local result
|
||||
result=$(curl -s -o /dev/null \
|
||||
-w "%{http_code}|%{time_total}|%{time_connect}|%{time_starttransfer}|%{size_download}" \
|
||||
--max-time 30 \
|
||||
"$url" 2>&1)
|
||||
|
||||
LAST_HTTP=$(echo "$result" | cut -d'|' -f1)
|
||||
LAST_TIME=$(echo "$result" | cut -d'|' -f2)
|
||||
LAST_CONN=$(echo "$result" | cut -d'|' -f3)
|
||||
LAST_TTFB=$(echo "$result" | cut -d'|' -f4)
|
||||
LAST_SIZE=$(echo "$result" | cut -d'|' -f5)
|
||||
LAST_URL="$url"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# START
|
||||
# =============================================================================
|
||||
|
||||
# Truncate results file for this run and write header
|
||||
cat > "$RESULTS_FILE" << EOF
|
||||
Paper Dynasty Card Render Benchmark
|
||||
Run timestamp : $TIMESTAMP
|
||||
API target : $API_URL
|
||||
Cache-bust tag: $RUN_LABEL
|
||||
EOF
|
||||
echo "" >> "$RESULTS_FILE"
|
||||
|
||||
echo ""
|
||||
log "=============================================================="
|
||||
log " Paper Dynasty Card Render Benchmark - WP-00 / Phase 0"
|
||||
log " $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
log " API: $API_URL"
|
||||
log "=============================================================="
|
||||
echo ""
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 1: Connectivity Check
|
||||
# =============================================================================
|
||||
|
||||
log "--- Section 1: Connectivity Check ---"
|
||||
log ""
|
||||
log "Sending single request to verify API is reachable..."
|
||||
log " Player : 12785 (batting card)"
|
||||
log " URL : ${API_URL}/v2/players/12785/battingcard?d=${RUN_LABEL}-probe"
|
||||
echo ""
|
||||
|
||||
measure_card 12785 batting
|
||||
|
||||
if [ "$LAST_HTTP" = "200" ]; then
|
||||
log " HTTP : $LAST_HTTP OK"
|
||||
log " Total : $(fmt3 $LAST_TIME)s"
|
||||
log " Connect: $(fmt3 $LAST_CONN)s"
|
||||
log " TTFB : $(fmt3 $LAST_TTFB)s"
|
||||
log " Size : ${LAST_SIZE} bytes ($(echo "scale=1; $LAST_SIZE/1024" | bc)KB)"
|
||||
log ""
|
||||
log " Connectivity: PASS"
|
||||
elif [ -z "$LAST_HTTP" ] || [ "$LAST_HTTP" = "000" ]; then
|
||||
log " ERROR: Could not reach $API_URL (no response / timeout)"
|
||||
log " Aborting benchmark."
|
||||
echo ""
|
||||
exit 1
|
||||
else
|
||||
log " HTTP : $LAST_HTTP"
|
||||
log " WARNING: Unexpected status code. Continuing anyway."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
if [ "$QUICK_MODE" = true ]; then
|
||||
log "Quick mode: exiting after connectivity check."
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 2: Sequential Card Render Benchmark (10 cards)
|
||||
# =============================================================================
|
||||
|
||||
log ""
|
||||
hr
|
||||
log "--- Section 2: Sequential Card Render Benchmark ---"
|
||||
log ""
|
||||
log "Rendering ${#PLAYER_IDS[@]} cards sequentially with fresh cache busts."
|
||||
log "Each request forces a full server-side render (bypasses nginx cache)."
|
||||
log ""
|
||||
log "$(printf '%-8s %-10s %-10s %-10s %-10s %-8s' 'Player' 'HTTP' 'Total(s)' 'TTFB(s)' 'Connect(s)' 'Size(KB)')"
|
||||
log "$(printf '%0.s-' {1..62})"
|
||||
|
||||
# Accumulators
|
||||
total_time="0"
|
||||
min_time=""
|
||||
max_time=""
|
||||
success_count=0
|
||||
fail_count=0
|
||||
all_times=()
|
||||
|
||||
for pid in "${PLAYER_IDS[@]}"; do
|
||||
measure_card "$pid" batting
|
||||
|
||||
size_kb=$(echo "scale=1; $LAST_SIZE/1024" | bc)
|
||||
row=$(printf '%-8s %-10s %-10s %-10s %-10s %-8s' \
|
||||
"$pid" \
|
||||
"$LAST_HTTP" \
|
||||
"$(fmt3 $LAST_TIME)" \
|
||||
"$(fmt3 $LAST_TTFB)" \
|
||||
"$(fmt3 $LAST_CONN)" \
|
||||
"$size_kb")
|
||||
|
||||
if [ "$LAST_HTTP" = "200" ]; then
|
||||
log "$row"
|
||||
total_time=$(fadd "$total_time" "$LAST_TIME")
|
||||
all_times+=("$LAST_TIME")
|
||||
success_count=$((success_count + 1))
|
||||
|
||||
# Track min
|
||||
if [ -z "$min_time" ] || [ "$(flt $LAST_TIME $min_time)" = "1" ]; then
|
||||
min_time="$LAST_TIME"
|
||||
fi
|
||||
# Track max
|
||||
if [ -z "$max_time" ] || [ "$(flt $max_time $LAST_TIME)" = "1" ]; then
|
||||
max_time="$LAST_TIME"
|
||||
fi
|
||||
else
|
||||
log "$row << FAILED"
|
||||
fail_count=$((fail_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
log ""
|
||||
log "--- Section 2: Results Summary ---"
|
||||
log ""
|
||||
|
||||
if [ "$success_count" -gt 0 ]; then
|
||||
avg_time=$(fdiv "$total_time" "$success_count")
|
||||
log " Cards requested : ${#PLAYER_IDS[@]}"
|
||||
log " Successful : $success_count"
|
||||
log " Failed : $fail_count"
|
||||
log " Total wall time : $(fmt3 $total_time)s"
|
||||
log " Average per card : $(fmt3 $avg_time)s"
|
||||
log " Minimum : $(fmt3 $min_time)s"
|
||||
log " Maximum : $(fmt3 $max_time)s"
|
||||
log ""
|
||||
|
||||
# Rough throughput estimate (sequential)
|
||||
cards_per_min=$(echo "scale=1; 60 / $avg_time" | bc)
|
||||
log " Sequential throughput: ~${cards_per_min} cards/min"
|
||||
|
||||
# Estimate full cardset at ~500 players * 2 cards each = 1000 renders
|
||||
est_1000=$(echo "scale=0; (1000 * $avg_time) / 1" | bc)
|
||||
log " Est. full cardset (1000 renders, sequential): ~${est_1000}s (~$(echo "scale=1; $est_1000/60" | bc) min)"
|
||||
else
|
||||
log " No successful renders to summarize."
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 3: Upload Pipeline Reference
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
log ""
|
||||
hr
|
||||
log "--- Section 3: Upload Pipeline Benchmark Commands ---"
|
||||
log ""
|
||||
log "The upload pipeline (pd_cards/core/upload.py) fetches rendered PNG cards"
|
||||
log "and uploads them to S3. It uses a persistent aiohttp session with a 6s"
|
||||
log "timeout per card."
|
||||
log ""
|
||||
log "To time a dry-run batch of 20 cards:"
|
||||
log ""
|
||||
log " cd /mnt/NV2/Development/paper-dynasty/card-creation"
|
||||
log " time pd-cards upload s3 --cardset \"2005 Live\" --limit 20 --dry-run"
|
||||
log ""
|
||||
log "To time a real upload batch of 20 cards (writes to S3, updates DB URLs):"
|
||||
log ""
|
||||
log " time pd-cards upload s3 --cardset \"2005 Live\" --limit 20"
|
||||
log ""
|
||||
log "Notes:"
|
||||
log " - dry-run validates card URLs exist without uploading"
|
||||
log " - Remove --limit for full cardset run"
|
||||
log " - Pipeline is currently sequential (one card at a time per session)"
|
||||
log " - Each card: fetch PNG (~2-4s render) + S3 put (~0.1-0.5s) = ~2.5-4.5s/card"
|
||||
log " - Parallelism target (Phase 0 goal): 10-20 concurrent fetches via asyncio"
|
||||
log ""
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 4: Before/After Comparison Template
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
hr
|
||||
log "--- Section 4: Before/After Comparison Template ---"
|
||||
log ""
|
||||
log "Fill in after optimization work is complete."
|
||||
log ""
|
||||
log " Metric Before After Delta"
|
||||
log " $(printf '%0.s-' {1..64})"
|
||||
|
||||
if [ "$success_count" -gt 0 ]; then
|
||||
log " Avg render time (s) $(fmt3 $avg_time) ___._____ ___._____"
|
||||
log " Min render time (s) $(fmt3 $min_time) ___._____ ___._____"
|
||||
log " Max render time (s) $(fmt3 $max_time) ___._____ ___._____"
|
||||
log " Sequential cards/min ${cards_per_min} ___.___ ___.___"
|
||||
else
|
||||
log " Avg render time (s) (no data) ___._____ ___._____"
|
||||
fi
|
||||
log " Upload batch (20 cards) ___._____s ___._____s ___._____s"
|
||||
log " Upload cards/min ___.___ ___.___ ___.___"
|
||||
log " Full cardset time (est) ___._____min ___._____min ___ min saved"
|
||||
log ""
|
||||
|
||||
# =============================================================================
|
||||
# DONE
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
hr
|
||||
log "Benchmark complete."
|
||||
log "Results saved to: $RESULTS_FILE"
|
||||
log ""
|
||||
|
||||
# Voice notify
|
||||
curl -s -X POST http://localhost:8888/notify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"message\":\"Benchmark complete. Average render time $(fmt3 ${avg_time:-0}) seconds per card\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
@ -1,93 +0,0 @@
|
||||
Paper Dynasty Card Render Benchmark
|
||||
Run timestamp : 2026-03-12 23:40:54
|
||||
API target : https://pddev.manticorum.com/api
|
||||
Cache-bust tag: benchmark-1773376854
|
||||
|
||||
==============================================================
|
||||
Paper Dynasty Card Render Benchmark - WP-00 / Phase 0
|
||||
2026-03-12 23:40:54
|
||||
API: https://pddev.manticorum.com/api
|
||||
==============================================================
|
||||
--- Section 1: Connectivity Check ---
|
||||
|
||||
Sending single request to verify API is reachable...
|
||||
Player : 12785 (batting card)
|
||||
URL : https://pddev.manticorum.com/api/v2/players/12785/battingcard?d=benchmark-1773376854-probe
|
||||
HTTP : 200 OK
|
||||
Total : 1.944s
|
||||
Connect: 0.010s
|
||||
TTFB : 1.933s
|
||||
Size : 192175 bytes (187.6KB)
|
||||
|
||||
Connectivity: PASS
|
||||
|
||||
--- Section 2: Sequential Card Render Benchmark ---
|
||||
|
||||
Rendering 10 cards sequentially with fresh cache busts.
|
||||
Each request forces a full server-side render (bypasses nginx cache).
|
||||
|
||||
Player HTTP Total(s) TTFB(s) Connect(s) Size(KB)
|
||||
--------------------------------------------------------------
|
||||
12785 200 0.056 0.046 0.008 187.6
|
||||
12790 200 1.829 1.815 0.008 202.3
|
||||
12800 200 2.106 2.096 0.008 192.4
|
||||
12810 200 1.755 1.745 0.009 189.8
|
||||
12820 200 2.041 2.030 0.009 193.1
|
||||
12830 200 2.433 2.423 0.009 180.3
|
||||
12840 200 2.518 2.507 0.009 202.3
|
||||
12850 200 2.191 2.174 0.009 187.6
|
||||
12860 200 2.478 2.469 0.009 190.4
|
||||
12870 200 2.913 2.901 0.009 192.8
|
||||
|
||||
--- Section 2: Results Summary ---
|
||||
|
||||
Cards requested : 10
|
||||
Successful : 10
|
||||
Failed : 0
|
||||
Total wall time : 20.321s
|
||||
Average per card : 2.032s
|
||||
Minimum : 0.056s
|
||||
Maximum : 2.913s
|
||||
|
||||
Sequential throughput: ~29.5 cards/min
|
||||
Est. full cardset (1000 renders, sequential): ~2032s (~33.8 min)
|
||||
|
||||
--- Section 3: Upload Pipeline Benchmark Commands ---
|
||||
|
||||
The upload pipeline (pd_cards/core/upload.py) fetches rendered PNG cards
|
||||
and uploads them to S3. It uses a persistent aiohttp session with a 6s
|
||||
timeout per card.
|
||||
|
||||
To time a dry-run batch of 20 cards:
|
||||
|
||||
cd /mnt/NV2/Development/paper-dynasty/card-creation
|
||||
time pd-cards upload s3 --cardset "2005 Live" --limit 20 --dry-run
|
||||
|
||||
To time a real upload batch of 20 cards (writes to S3, updates DB URLs):
|
||||
|
||||
time pd-cards upload s3 --cardset "2005 Live" --limit 20
|
||||
|
||||
Notes:
|
||||
- dry-run validates card URLs exist without uploading
|
||||
- Remove --limit for full cardset run
|
||||
- Pipeline is currently sequential (one card at a time per session)
|
||||
- Each card: fetch PNG (~2-4s render) + S3 put (~0.1-0.5s) = ~2.5-4.5s/card
|
||||
- Parallelism target (Phase 0 goal): 10-20 concurrent fetches via asyncio
|
||||
|
||||
--- Section 4: Before/After Comparison Template ---
|
||||
|
||||
Fill in after optimization work is complete.
|
||||
|
||||
Metric Before After Delta
|
||||
----------------------------------------------------------------
|
||||
Avg render time (s) 2.032 ___._____ ___._____
|
||||
Min render time (s) 0.056 ___._____ ___._____
|
||||
Max render time (s) 2.913 ___._____ ___._____
|
||||
Sequential cards/min 29.5 ___.___ ___.___
|
||||
Upload batch (20 cards) ___._____s ___._____s ___._____s
|
||||
Upload cards/min ___.___ ___.___ ___.___
|
||||
Full cardset time (est) ___._____min ___._____min ___ min saved
|
||||
|
||||
Benchmark complete.
|
||||
Results saved to: scripts/benchmark_results.txt
|
||||
|
||||
75
scripts/supabase_doodling.py
Normal file
75
scripts/supabase_doodling.py
Normal file
@ -0,0 +1,75 @@
|
||||
from typing import Literal
|
||||
import requests
|
||||
from exceptions import logger, log_exception
|
||||
|
||||
AUTH_TOKEN = {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNucGhwbnV2aGp2cXprY2J3emRrIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTgxMTc4NCwiZXhwIjoyMDYxMzg3Nzg0fQ.7dG_y2zU2PajBwTD8vut5GcWf3CSaZePkYW_hMf0fVg",
|
||||
"apikey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNucGhwbnV2aGp2cXprY2J3emRrIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTgxMTc4NCwiZXhwIjoyMDYxMzg3Nzg0fQ.7dG_y2zU2PajBwTD8vut5GcWf3CSaZePkYW_hMf0fVg",
|
||||
}
|
||||
DB_URL = "https://cnphpnuvhjvqzkcbwzdk.supabase.co/rest/v1"
|
||||
|
||||
|
||||
def get_req_url(endpoint: str, params: list = None):
|
||||
req_url = f"{DB_URL}/{endpoint}?"
|
||||
|
||||
if params:
|
||||
other_params = False
|
||||
for x in params:
|
||||
req_url += f'{"&" if other_params else "?"}{x[0]}={x[1]}'
|
||||
other_params = True
|
||||
|
||||
return req_url
|
||||
|
||||
|
||||
def log_return_value(log_string: str, log_type: Literal["info", "debug"]):
|
||||
if log_type == "info":
|
||||
logger.info(
|
||||
f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n'
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n'
|
||||
)
|
||||
|
||||
|
||||
def db_get(
|
||||
endpoint: str,
|
||||
params: dict = None,
|
||||
limit: int = 1000,
|
||||
offset: int = 0,
|
||||
none_okay: bool = True,
|
||||
timeout: int = 3,
|
||||
):
|
||||
req_url = f"{DB_URL}/{endpoint}?limit={limit}&offset={offset}"
|
||||
logger.info(f"HTTP GET: {req_url}, params: {params}")
|
||||
|
||||
response = requests.request("GET", req_url, params=params, headers=AUTH_TOKEN)
|
||||
logger.info(response)
|
||||
|
||||
if response.status_code != requests.codes.ok:
|
||||
log_exception(Exception, response.text)
|
||||
|
||||
data = response.json()
|
||||
if isinstance(data, list) and len(data) == 0:
|
||||
if none_okay:
|
||||
return None
|
||||
else:
|
||||
log_exception(Exception, "Query returned no results and none_okay = False")
|
||||
|
||||
return data
|
||||
|
||||
# async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
# async with session.get(req_url) as r:
|
||||
# logger.info(f'session info: {r}')
|
||||
# if r.status == 200:
|
||||
# js = await r.json()
|
||||
# log_return_value(f'{js}')
|
||||
# return js
|
||||
# elif none_okay:
|
||||
# e = await r.text()
|
||||
# logger.error(e)
|
||||
# return None
|
||||
# else:
|
||||
# e = await r.text()
|
||||
# logger.error(e)
|
||||
# raise ValueError(f'DB: {e}')
|
||||
@ -170,7 +170,6 @@ class TestDataFetcher:
|
||||
|
||||
@patch("automated_data_fetcher.pb.batting_stats_bref")
|
||||
@patch("automated_data_fetcher.pb.pitching_stats_bref")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_baseball_reference_data(
|
||||
self,
|
||||
mock_pitching,
|
||||
@ -207,7 +206,6 @@ class TestDataFetcher:
|
||||
|
||||
@patch("automated_data_fetcher.pb.batting_stats")
|
||||
@patch("automated_data_fetcher.pb.pitching_stats")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_fangraphs_data(
|
||||
self,
|
||||
mock_pitching,
|
||||
@ -233,7 +231,6 @@ class TestDataFetcher:
|
||||
|
||||
@patch("automated_data_fetcher.pb.batting_stats_range")
|
||||
@patch("automated_data_fetcher.pb.pitching_stats_range")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_fangraphs_data_with_dates(
|
||||
self,
|
||||
mock_pitching,
|
||||
@ -256,7 +253,6 @@ class TestDataFetcher:
|
||||
mock_pitching.assert_called_once_with(start_date, end_date)
|
||||
|
||||
@patch("automated_data_fetcher.get_all_pybaseball_ids")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_players_existing_function(self, mock_get_ids, fetcher):
|
||||
"""Test getting player IDs using existing function"""
|
||||
mock_get_ids.return_value = ["12345", "67890", "11111"]
|
||||
@ -268,7 +264,6 @@ class TestDataFetcher:
|
||||
|
||||
@patch("automated_data_fetcher.get_all_pybaseball_ids")
|
||||
@patch("automated_data_fetcher.pb.batting_stats")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_players_fallback(
|
||||
self, mock_batting, mock_get_ids, fetcher, sample_batting_data
|
||||
):
|
||||
@ -284,7 +279,6 @@ class TestDataFetcher:
|
||||
assert result == expected_ids
|
||||
|
||||
@patch("automated_data_fetcher.pb.get_splits")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_player_splits(
|
||||
self, mock_get_splits, fetcher, sample_splits_data
|
||||
):
|
||||
@ -339,7 +333,6 @@ class TestLiveSeriesDataFetcher:
|
||||
|
||||
@patch.object(DataFetcher, "fetch_baseball_reference_data")
|
||||
@patch.object(DataFetcher, "fetch_fangraphs_data")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_live_data(self, mock_fg_data, mock_bref_data, live_fetcher):
|
||||
"""Test fetching live series data"""
|
||||
# Mock return values
|
||||
@ -367,7 +360,6 @@ class TestUtilityFunctions:
|
||||
"""Test cases for utility functions"""
|
||||
|
||||
@patch("automated_data_fetcher.DataFetcher")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_season_data(self, mock_fetcher_class):
|
||||
"""Test fetch_season_data function"""
|
||||
# Create mock fetcher instance
|
||||
@ -397,7 +389,6 @@ class TestUtilityFunctions:
|
||||
assert any("AUTOMATED DOWNLOAD COMPLETE" in call for call in print_calls)
|
||||
|
||||
@patch("automated_data_fetcher.LiveSeriesDataFetcher")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_live_series_data(self, mock_fetcher_class):
|
||||
"""Test fetch_live_series_data function"""
|
||||
# Create mock fetcher instance
|
||||
@ -425,7 +416,6 @@ class TestErrorHandling:
|
||||
return DataFetcher(2023, "Season")
|
||||
|
||||
@patch("automated_data_fetcher.pb.pitching_stats_bref")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_baseball_reference_data_error(self, mock_pitching, fetcher):
|
||||
"""Test error handling in Baseball Reference data fetch"""
|
||||
# Mock function to raise an exception
|
||||
@ -435,7 +425,6 @@ class TestErrorHandling:
|
||||
await fetcher.fetch_baseball_reference_data()
|
||||
|
||||
@patch("automated_data_fetcher.pb.batting_stats")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_fangraphs_data_error(self, mock_batting, fetcher):
|
||||
"""Test error handling in FanGraphs data fetch"""
|
||||
# Mock function to raise an exception
|
||||
@ -446,7 +435,6 @@ class TestErrorHandling:
|
||||
|
||||
@patch("automated_data_fetcher.get_all_pybaseball_ids")
|
||||
@patch("automated_data_fetcher.pb.batting_stats")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_players_complete_failure(
|
||||
self, mock_batting, mock_get_ids, fetcher
|
||||
):
|
||||
@ -461,7 +449,6 @@ class TestErrorHandling:
|
||||
assert result == []
|
||||
|
||||
@patch("automated_data_fetcher.pb.get_splits")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_player_splits_individual_errors(
|
||||
self, mock_get_splits, fetcher
|
||||
):
|
||||
@ -492,7 +479,6 @@ class TestIntegration:
|
||||
"""Integration tests that require network access"""
|
||||
|
||||
@pytest.mark.skip(reason="Requires network access and may be slow")
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_data_fetch(self):
|
||||
"""Test fetching real data from pybaseball (skip by default)"""
|
||||
fetcher = DataFetcher(2022, "Season") # Use a complete season
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
from creation_helpers import mround, sanitize_chance_output
|
||||
from creation_helpers import pd_positions_df, mround, sanitize_chance_output
|
||||
|
||||
|
||||
def test_positions_df():
|
||||
cardset_19_pos = pd_positions_df(19)
|
||||
|
||||
assert True == True
|
||||
|
||||
|
||||
def test_mround():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user