Add Lineup Builder, Gameday screen, and matchup scoring system

Features:
- Lineup Builder screen: set batting order, assign positions, save/load lineups
- Gameday screen: integrated matchup scout + lineup builder side-by-side
- Matchup Scout: analyze batters vs opposing pitchers with standardized scoring
- Standardized scoring system with league AVG/STDEV calculations
- Score caching for fast matchup lookups

Lineup Builder (press 'l'):
- Dual-panel UI with available batters and 9-slot lineup
- Keyboard controls: a=add, r=remove, k/j=reorder, p=change position
- Save/load named lineups, delete saved lineups with 'd'

Gameday screen (press 'g'):
- Left panel: team/pitcher selection with matchup ratings
- Right panel: lineup builder with live matchup ratings per batter
- Players in lineup marked with * in matchup list
- Click highlighted row to toggle selection for screenshots

Other changes:
- Dynamic season configuration (removed hardcoded season=13)
- Added delete_lineup query function
- StandardizedScoreCache model for pre-computed scores
- Auto-rebuild score cache after card imports
This commit is contained in:
Cal Corum 2026-01-25 14:09:22 -06:00
parent 5b307a91a6
commit 3c76ce1cf0
14 changed files with 3620 additions and 33 deletions

448
PROJECT_PLAN.json Normal file
View File

@ -0,0 +1,448 @@
{
"meta": {
"version": "1.0.0",
"created": "2026-01-25",
"lastUpdated": "2026-01-25",
"planType": "feature",
"project": "SBA Scout TUI",
"description": "Fantasy baseball scouting TUI application for the SBA (Strat-o-Matic Baseball Association) league",
"totalEstimatedHours": 40,
"totalTasks": 18,
"completedTasks": 7
},
"categories": {
"critical": "Must fix immediately - blocks core functionality",
"high": "Required for production-ready app",
"medium": "Quality improvements and secondary features",
"low": "Polish, nice-to-have features",
"feature": "New capabilities and screens"
},
"tasks": [
{
"id": "FEAT-001",
"name": "Dashboard Screen",
"description": "Main dashboard showing roster summary (Majors/Minors/IL counts), sWAR usage, and quick navigation buttons",
"category": "feature",
"priority": 1,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{
"path": "src/sba_scout/app.py",
"lines": [30, 180],
"issue": "DashboardScreen class implementation"
}
],
"suggestedFix": null,
"estimatedHours": 4,
"notes": "Completed - shows roster counts and sWAR from settings"
},
{
"id": "FEAT-002",
"name": "Roster Screen",
"description": "Full roster view with tabbed display (Majors/Minors/IL), separate DataTables for batters and pitchers with ratings and stats",
"category": "feature",
"priority": 2,
"completed": true,
"tested": true,
"dependencies": ["FEAT-001"],
"files": [
{
"path": "src/sba_scout/screens/roster.py",
"lines": [1, 270],
"issue": "RosterScreen implementation"
}
],
"suggestedFix": null,
"estimatedHours": 6,
"notes": "Completed - batters show vL/vR/Ovr/Defense, pitchers show vL/vR/Ovr/S/R/C endurance"
},
{
"id": "FEAT-003",
"name": "Card Data Importer",
"description": "Import batter and pitcher card data from BatterCalcs.csv and PitcherCalcs.csv exports, including pre-calculated ratings",
"category": "feature",
"priority": 1,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{
"path": "src/sba_scout/api/importer.py",
"lines": [1, 400],
"issue": "CSV import functions"
}
],
"suggestedFix": null,
"estimatedHours": 4,
"notes": "Completed - imports vL/vR/Total ratings directly from spreadsheet"
},
{
"id": "FEAT-004",
"name": "API Sync",
"description": "Sync players, teams, and transactions from Major Domo league API",
"category": "feature",
"priority": 2,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{
"path": "src/sba_scout/api/client.py",
"lines": [1, 150],
"issue": "API client implementation"
},
{
"path": "src/sba_scout/api/sync.py",
"lines": [1, 200],
"issue": "Sync logic"
}
],
"suggestedFix": null,
"estimatedHours": 4,
"notes": "Completed - syncs from league API on demand"
},
{
"id": "FEAT-005",
"name": "Matchup Scout Screen",
"description": "Core scouting feature - analyze batter vs pitcher matchups, show expected outcomes based on handedness splits",
"category": "feature",
"priority": 3,
"completed": true,
"tested": true,
"dependencies": ["FEAT-002", "FEAT-003"],
"files": [
{
"path": "src/sba_scout/screens/matchup.py",
"lines": [1, 290],
"issue": "Full implementation complete"
},
{
"path": "src/sba_scout/calc/matchup.py",
"lines": [1, 130],
"issue": "Matchup calculation logic"
}
],
"suggestedFix": null,
"estimatedHours": 8,
"notes": "Implemented: team/pitcher selectors, matchup table with rating/tier, sort by rating/name/position, switch hitter logic (uses opposite of pitcher hand)"
},
{
"id": "FEAT-006",
"name": "Lineup Builder Screen",
"description": "Set batting order and defensive positions, save/load lineup configurations",
"category": "feature",
"priority": 4,
"completed": true,
"tested": true,
"dependencies": ["FEAT-002"],
"files": [
{
"path": "src/sba_scout/screens/lineup.py",
"lines": [1, 450],
"issue": "Full implementation complete"
}
],
"suggestedFix": null,
"estimatedHours": 6,
"notes": "Implemented: dual-panel UI with available batters and lineup tables, keyboard navigation (a=add, r=remove, k/j=move up/down, p=cycle position), save/load named lineups, position eligibility suggestions"
},
{
"id": "FEAT-007",
"name": "Transactions Screen",
"description": "View recent transactions, track player movements, manage roster moves",
"category": "feature",
"priority": 5,
"completed": false,
"tested": false,
"dependencies": ["FEAT-004"],
"files": [
{
"path": "src/sba_scout/app.py",
"lines": [234, 249],
"issue": "Placeholder TransactionsScreen needs implementation"
}
],
"suggestedFix": "1. Create src/sba_scout/screens/transactions.py\n2. Show recent league transactions\n3. Filter by team, player, date\n4. Show demotion weeks for minors players\n5. Highlight players who can be promoted",
"estimatedHours": 4,
"notes": "Transaction model already exists, needs UI"
},
{
"id": "FEAT-008",
"name": "Depth Chart Screen (by Position)",
"description": "View roster organized by defensive position showing depth at each spot",
"category": "feature",
"priority": 6,
"completed": false,
"tested": false,
"dependencies": ["FEAT-002"],
"files": [],
"suggestedFix": "1. Create src/sba_scout/screens/depth_chart.py\n2. Show positions as columns or tabs (C, 1B, 2B, SS, 3B, LF, CF, RF, DH)\n3. List eligible players at each position\n4. Show defensive ratings (range, error, arm)\n5. Highlight primary vs secondary positions",
"estimatedHours": 4,
"notes": "Different from Roster screen - organized by position rather than roster status"
},
{
"id": "FEAT-009",
"name": "Player Detail Modal",
"description": "Click on a player row to see full card details, stats, and ratings breakdown",
"category": "feature",
"priority": 7,
"completed": false,
"tested": false,
"dependencies": ["FEAT-002"],
"files": [
{
"path": "src/sba_scout/screens/roster.py",
"lines": [],
"issue": "Row selection currently does nothing"
}
],
"suggestedFix": "1. Create PlayerDetailModal widget\n2. Show full batter card (all vs LHP and vs RHP stats)\n3. Show full pitcher card (all vs LHB and vs RHB stats)\n4. Show fielding details at each position\n5. Show running game stats (stealing, speed)\n6. Display card image if available",
"estimatedHours": 4,
"notes": "Should work from both Roster and Matchup screens"
},
{
"id": "HIGH-001",
"name": "Dynamic Season Configuration",
"description": "Remove hardcoded season 13, get current season from config or API",
"category": "high",
"priority": 8,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{
"path": "src/sba_scout/app.py",
"lines": [116],
"issue": "Hardcoded season=13 in dashboard"
},
{
"path": "src/sba_scout/screens/roster.py",
"lines": [138],
"issue": "Hardcoded season=13 in roster"
},
{
"path": "src/sba_scout/api/client.py",
"lines": [32],
"issue": "Example uses season=13"
}
],
"suggestedFix": "1. Add current_season to TeamSettings in config.py\n2. Update .env.example with SBA_SCOUT_TEAM__CURRENT_SEASON=13\n3. Replace all hardcoded 13 with settings.team.current_season\n4. Optionally: fetch current season from league API",
"estimatedHours": 1,
"notes": "Quick fix but important for multi-season support"
},
{
"id": "MED-001",
"name": "Schedule Integration",
"description": "Show upcoming games, opponent info, and starting pitchers",
"category": "medium",
"priority": 9,
"completed": false,
"tested": false,
"dependencies": ["FEAT-004"],
"files": [],
"suggestedFix": "1. Add schedule endpoint to API client\n2. Create Schedule model in database\n3. Add upcoming games widget to dashboard\n4. Link to Matchup Scout with opponent pre-selected",
"estimatedHours": 4,
"notes": "Would enhance dashboard and matchup workflow"
},
{
"id": "MED-002",
"name": "Rating Recalculation Engine",
"description": "Rebuild rating calculation system for matchup analysis (was removed, needs fresh implementation)",
"category": "medium",
"priority": 10,
"completed": false,
"tested": false,
"dependencies": ["FEAT-005"],
"files": [
{
"path": "src/sba_scout/calc/__init__.py",
"lines": [],
"issue": "calc module is empty after removing ratings.py and matchup.py"
}
],
"suggestedFix": "1. Design new calculation approach based on matchup needs\n2. Consider: should matchups combine batter vL + pitcher vRHB?\n3. Implement matchup_score = f(batter_rating, pitcher_rating, handedness)\n4. Add tier system (A/B/C/D/F) for quick reference",
"estimatedHours": 4,
"notes": "Original calc was removed because roster ratings come pre-calculated from spreadsheet. Matchup calculations need different logic."
},
{
"id": "MED-003",
"name": "Keyboard Navigation Enhancement",
"description": "Add vim-style navigation, better hotkeys, command palette",
"category": "medium",
"priority": 11,
"completed": false,
"tested": false,
"dependencies": [],
"files": [],
"suggestedFix": "1. Add j/k navigation in tables\n2. Add search/filter with / key\n3. Implement command palette (ctrl+p)\n4. Add goto player by name",
"estimatedHours": 2,
"notes": "TUI apps benefit greatly from keyboard-first design"
},
{
"id": "MED-004",
"name": "Auto-Sync on Startup",
"description": "Optionally sync data from API when app starts, with configurable interval",
"category": "medium",
"priority": 12,
"completed": false,
"tested": false,
"dependencies": ["FEAT-004"],
"files": [
{
"path": "src/sba_scout/config.py",
"lines": [126, 129],
"issue": "refresh_interval setting exists but not implemented"
}
],
"suggestedFix": "1. Check last_sync timestamp on startup\n2. If stale (> refresh_interval), trigger sync\n3. Show sync status in dashboard\n4. Add background sync timer (optional)",
"estimatedHours": 2,
"notes": "refresh_interval setting already exists in config"
},
{
"id": "LOW-001",
"name": "Theme Support",
"description": "Light/dark theme toggle using Textual's theming system",
"category": "low",
"priority": 13,
"completed": false,
"tested": false,
"dependencies": [],
"files": [
{
"path": "src/sba_scout/config.py",
"lines": [129],
"issue": "theme setting exists but not implemented"
}
],
"suggestedFix": "1. Use Textual's built-in theme system\n2. Add theme toggle hotkey (t)\n3. Persist theme preference in config",
"estimatedHours": 1,
"notes": "theme setting already exists in config"
},
{
"id": "LOW-002",
"name": "Export Functionality",
"description": "Export lineups, matchup reports, or roster data to various formats",
"category": "low",
"priority": 14,
"completed": false,
"tested": false,
"dependencies": ["FEAT-005", "FEAT-006"],
"files": [],
"suggestedFix": "1. Export lineup to clipboard (for pasting into game)\n2. Export matchup report to markdown/text\n3. Export roster to CSV",
"estimatedHours": 2,
"notes": "Clipboard export would be most immediately useful"
},
{
"id": "LOW-003",
"name": "Draft Tools",
"description": "Tools for evaluating players during the draft",
"category": "low",
"priority": 15,
"completed": false,
"tested": false,
"dependencies": ["FEAT-003"],
"files": [],
"suggestedFix": "1. Player search across all teams\n2. Compare players side-by-side\n3. Track draft queue/watchlist\n4. sWAR value analysis",
"estimatedHours": 6,
"notes": "Would be valuable during draft season"
},
{
"id": "TEST-001",
"name": "Add Unit Tests",
"description": "Create test suite for core functionality",
"category": "medium",
"priority": 16,
"completed": false,
"tested": false,
"dependencies": [],
"files": [
{
"path": "tests/__init__.py",
"lines": [],
"issue": "Tests directory is empty"
}
],
"suggestedFix": "1. Test importer with sample CSV data\n2. Test database queries\n3. Test API client with mocked responses\n4. Test config loading",
"estimatedHours": 4,
"notes": "Focus on data layer tests first"
}
],
"quickWins": [
{
"taskId": "HIGH-001",
"estimatedMinutes": 30,
"impact": "Removes hardcoded season number, enables multi-season support"
},
{
"taskId": "LOW-001",
"estimatedMinutes": 30,
"impact": "Light theme option for users who prefer it"
}
],
"productionBlockers": [],
"weeklyRoadmap": {
"week1": {
"theme": "Core Scouting Features",
"tasks": ["HIGH-001", "FEAT-005"],
"estimatedHours": 9,
"description": "Fix hardcoded season, implement Matchup Scout screen"
},
"week2": {
"theme": "Lineup Management",
"tasks": ["FEAT-006", "FEAT-009"],
"estimatedHours": 10,
"description": "Lineup Builder screen and Player Detail modal"
},
"week3": {
"theme": "Secondary Features",
"tasks": ["FEAT-007", "FEAT-008", "MED-002"],
"estimatedHours": 12,
"description": "Transactions, Depth Chart, and matchup calculations"
},
"week4": {
"theme": "Polish and Testing",
"tasks": ["MED-003", "MED-004", "TEST-001"],
"estimatedHours": 8,
"description": "Keyboard navigation, auto-sync, and unit tests"
}
},
"completedHistory": [
{
"taskId": "FEAT-001",
"completedDate": "2026-01-25",
"notes": "Dashboard with roster summary and navigation"
},
{
"taskId": "FEAT-002",
"completedDate": "2026-01-25",
"notes": "Roster screen with batters/pitchers tables, proper ratings from CSV"
},
{
"taskId": "FEAT-003",
"completedDate": "2026-01-25",
"notes": "CSV importer using pre-calculated vL/vR/Total from spreadsheet"
},
{
"taskId": "FEAT-004",
"completedDate": "2026-01-25",
"notes": "API sync for players and teams from Major Domo"
},
{
"taskId": "HIGH-001",
"completedDate": "2026-01-25",
"notes": "Added current_season to TeamSettings config; replaced hardcoded season=13 in app.py and roster.py"
},
{
"taskId": "FEAT-005",
"completedDate": "2026-01-25",
"notes": "Matchup Scout screen with team/pitcher selection, matchup table showing batter ratings vs pitcher handedness, sorting options, tier grades (A-F)"
},
{
"taskId": "FEAT-006",
"completedDate": "2026-01-25",
"notes": "Lineup Builder screen with dual-panel UI (available batters + lineup), keyboard controls (a=add, r=remove, k/j=reorder, p=change position), save/load named lineups"
}
]
}

View File

@ -379,4 +379,14 @@ async def import_all_cards(
logger.warning(f"Pitcher CSV not found: {pitcher_csv}")
results["pitchers"]["errors"].append(f"File not found: {pitcher_csv}")
# Rebuild standardized score cache after importing cards
total_imported = results["batters"]["imported"] + results["pitchers"]["imported"]
if total_imported > 0:
from ..calc.score_cache import rebuild_score_cache
logger.info("Rebuilding standardized score cache after import...")
cache_result = await rebuild_score_cache(session)
results["score_cache"] = cache_result
logger.info(f"Score cache rebuilt: {cache_result}")
return results

View File

@ -17,6 +17,9 @@ from textual.widgets import Button, Footer, Header, Label, LoadingIndicator, Sta
from .config import get_settings
from .db.schema import close_database, get_session, init_database
from .screens.gameday import GamedayScreen
from .screens.lineup import LineupScreen
from .screens.matchup import MatchupScreen
from .screens.roster import RosterScreen
# Configure logging
@ -33,6 +36,7 @@ class DashboardScreen(Screen):
BINDINGS: ClassVar = [
Binding("r", "switch_screen('roster')", "Roster"),
Binding("m", "switch_screen('matchup')", "Matchup Scout"),
Binding("g", "switch_screen('gameday')", "Gameday"),
Binding("l", "switch_screen('lineup')", "Lineup Builder"),
Binding("t", "switch_screen('transactions')", "Transactions"),
Binding("s", "sync_data", "Sync Data"),
@ -84,12 +88,12 @@ class DashboardScreen(Screen):
yield Label("Quick Actions", classes="section-title")
with Horizontal(classes="action-buttons"):
yield Button("Gameday [g]", id="btn-gameday", variant="primary")
yield Button("Roster [r]", id="btn-roster", variant="primary")
yield Button("Matchup Scout [m]", id="btn-matchup", variant="primary")
with Horizontal(classes="action-buttons"):
yield Button("Matchup Scout [m]", id="btn-matchup", variant="default")
yield Button("Lineup Builder [l]", id="btn-lineup", variant="default")
yield Button("Transactions [t]", id="btn-transactions", variant="default")
# Status bar
with Horizontal(id="status-bar"):
@ -113,7 +117,7 @@ class DashboardScreen(Screen):
roster = await get_my_roster(
session,
settings.team.team_abbrev,
13, # TODO: Get current season from API
settings.team.current_season,
)
majors_count = len(roster.get("majors", []))
@ -142,6 +146,11 @@ class DashboardScreen(Screen):
settings = get_settings()
self.query_one("#majors-count", Label).update(f"--/{settings.team.major_league_slots}")
@on(Button.Pressed, "#btn-gameday")
def on_gameday(self) -> None:
"""Navigate to gameday screen."""
self.app.push_screen("gameday")
@on(Button.Pressed, "#btn-roster")
def on_roster(self) -> None:
"""Navigate to roster screen."""
@ -194,35 +203,7 @@ class DashboardScreen(Screen):
sync_btn.disabled = False
# RosterScreen is imported from screens.roster
class MatchupScreen(Screen):
"""Matchup scouting screen for analyzing batters vs pitchers."""
BINDINGS: ClassVar = [
Binding("escape", "app.pop_screen", "Back"),
Binding("q", "app.pop_screen", "Back"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Label("Matchup Scout - Coming Soon", id="placeholder")
yield Footer()
class LineupScreen(Screen):
"""Lineup builder for setting batting order and positions."""
BINDINGS: ClassVar = [
Binding("escape", "app.pop_screen", "Back"),
Binding("q", "app.pop_screen", "Back"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Label("Lineup Builder - Coming Soon", id="placeholder")
yield Footer()
# RosterScreen, MatchupScreen, and LineupScreen are imported from screens module
class TransactionsScreen(Screen):
@ -392,12 +373,199 @@ class SBAScoutApp(App):
DataTable > .datatable--cursor {
background: $accent;
}
/* Matchup Screen Styles */
#matchup-container {
height: 100%;
padding: 1;
}
#matchup-selectors {
height: 3;
margin-bottom: 1;
}
#matchup-selectors Label {
width: auto;
padding: 0 1;
content-align: center middle;
}
#matchup-selectors Select {
width: 1fr;
max-width: 40;
margin-right: 2;
}
#pitcher-info {
height: 2;
background: $surface;
padding: 0 1;
margin-bottom: 1;
text-style: bold;
color: $warning;
}
#matchup-table-container {
height: 1fr;
}
#matchup-table {
width: 100%;
height: 100%;
}
/* Lineup Builder Screen Styles */
#lineup-container {
height: 1fr;
padding: 1;
}
.lineup-panel {
width: 1fr;
height: 100%;
padding: 0 1;
border: solid $primary;
margin-right: 1;
}
.lineup-panel:last-of-type {
margin-right: 0;
}
.panel-title {
text-style: bold;
background: $surface;
padding: 0 1;
margin-bottom: 1;
}
#available-container, #lineup-scroll-container {
height: 1fr;
}
#available-table, #lineup-table {
width: 100%;
height: 100%;
}
.hint-text {
color: $text-muted;
height: 1;
padding: 0 1;
}
#lineup-controls {
height: 5;
padding: 1;
background: $surface;
dock: bottom;
}
#lineup-controls Label {
width: auto;
padding: 0 1;
height: 3;
content-align: center middle;
}
#lineup-name-input {
width: 30;
height: 3;
margin-right: 1;
}
#lineup-select {
width: 25;
height: 3;
margin-right: 1;
}
#btn-save {
width: auto;
margin-right: 1;
}
#btn-clear {
width: auto;
}
/* Gameday Screen Styles */
#gameday-container {
height: 1fr;
padding: 1;
}
.gameday-panel {
height: 100%;
padding: 0 1;
border: solid $primary;
}
#matchup-panel {
width: 3fr;
margin-right: 1;
}
#lineup-panel {
width: 2fr;
}
#gameday-selectors {
height: 3;
margin-bottom: 1;
}
#gameday-selectors Label {
width: auto;
padding: 0 1;
content-align: center middle;
}
#gameday-selectors Select {
width: 1fr;
margin-right: 1;
}
#gameday-selectors #team-select {
max-width: 12;
}
#gameday-selectors #pitcher-select {
max-width: 30;
}
#matchup-scroll, #lineup-scroll {
height: 1fr;
}
#lineup-save-controls {
height: 3;
margin-bottom: 1;
}
#lineup-save-controls Input {
width: 1fr;
height: 3;
margin-right: 1;
}
#lineup-save-controls Select {
width: 1fr;
height: 3;
margin-right: 1;
}
#lineup-save-controls Button {
width: auto;
}
"""
SCREENS = {
"dashboard": DashboardScreen,
"roster": RosterScreen,
"matchup": MatchupScreen,
"gameday": GamedayScreen,
"lineup": LineupScreen,
"transactions": TransactionsScreen,
}

View File

@ -0,0 +1,263 @@
"""
League statistics calculation for standardized scoring.
Calculates league-wide averages and standard deviations for batter and pitcher
card stats, which are used to convert raw values into standardized scores (-3 to +3).
"""
import statistics
from dataclasses import dataclass
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import BatterCard, PitcherCard
@dataclass
class StatDistribution:
"""Average and standard deviation for a single stat."""
avg: float
stdev: float
def __repr__(self) -> str:
return f"StatDistribution(avg={self.avg:.2f}, stdev={self.stdev:.2f})"
@dataclass
class BatterLeagueStats:
"""League-wide averages and standard deviations for batter card stats."""
# vs Left-Handed Pitchers
so_vlhp: StatDistribution
bb_vlhp: StatDistribution
hit_vlhp: StatDistribution
ob_vlhp: StatDistribution
tb_vlhp: StatDistribution
hr_vlhp: StatDistribution
dp_vlhp: StatDistribution
bphr_vlhp: StatDistribution
bp1b_vlhp: StatDistribution
# vs Right-Handed Pitchers
so_vrhp: StatDistribution
bb_vrhp: StatDistribution
hit_vrhp: StatDistribution
ob_vrhp: StatDistribution
tb_vrhp: StatDistribution
hr_vrhp: StatDistribution
dp_vrhp: StatDistribution
bphr_vrhp: StatDistribution
bp1b_vrhp: StatDistribution
@dataclass
class PitcherLeagueStats:
"""League-wide averages and standard deviations for pitcher card stats."""
# vs Left-Handed Batters
so_vlhb: StatDistribution
bb_vlhb: StatDistribution
hit_vlhb: StatDistribution
ob_vlhb: StatDistribution
tb_vlhb: StatDistribution
hr_vlhb: StatDistribution
dp_vlhb: StatDistribution
bphr_vlhb: StatDistribution
bp1b_vlhb: StatDistribution
# vs Right-Handed Batters
so_vrhb: StatDistribution
bb_vrhb: StatDistribution
hit_vrhb: StatDistribution
ob_vrhb: StatDistribution
tb_vrhb: StatDistribution
hr_vrhb: StatDistribution
dp_vrhb: StatDistribution
bphr_vrhb: StatDistribution
bp1b_vrhb: StatDistribution
def _calc_distribution(values: list[float]) -> StatDistribution:
"""Calculate average and standard deviation for a list of values."""
# Filter out None and zero values for average calculation (matching spreadsheet AVERAGEIF)
non_zero = [v for v in values if v and v > 0]
if len(non_zero) < 2:
# Not enough data - return defaults that will make all scores 0
return StatDistribution(avg=0.0, stdev=1.0)
avg = statistics.mean(non_zero)
# Use all values (including zeros) for stdev calculation
all_values = [v or 0 for v in values]
stdev = statistics.stdev(all_values) if len(all_values) >= 2 else 1.0
# Prevent division by zero
if stdev == 0:
stdev = 1.0
return StatDistribution(avg=avg, stdev=stdev)
async def calculate_batter_league_stats(
session: AsyncSession,
) -> BatterLeagueStats:
"""
Calculate league-wide averages and standard deviations for all batter stats.
Queries all batter cards in the database and computes statistics for each
stat column, separated by vs-LHP and vs-RHP splits.
Args:
session: Database session
Returns:
BatterLeagueStats with avg/stdev for each stat
"""
query = select(BatterCard)
result = await session.execute(query)
cards: Sequence[BatterCard] = result.scalars().all()
if not cards:
# Return default stats if no cards exist
default = StatDistribution(avg=0.0, stdev=1.0)
return BatterLeagueStats(
so_vlhp=default,
bb_vlhp=default,
hit_vlhp=default,
ob_vlhp=default,
tb_vlhp=default,
hr_vlhp=default,
dp_vlhp=default,
bphr_vlhp=default,
bp1b_vlhp=default,
so_vrhp=default,
bb_vrhp=default,
hit_vrhp=default,
ob_vrhp=default,
tb_vrhp=default,
hr_vrhp=default,
dp_vrhp=default,
bphr_vrhp=default,
bp1b_vrhp=default,
)
return BatterLeagueStats(
# vs LHP
so_vlhp=_calc_distribution([c.so_vlhp for c in cards]),
bb_vlhp=_calc_distribution([c.bb_vlhp for c in cards]),
hit_vlhp=_calc_distribution([c.hit_vlhp for c in cards]),
ob_vlhp=_calc_distribution([c.ob_vlhp for c in cards]),
tb_vlhp=_calc_distribution([c.tb_vlhp for c in cards]),
hr_vlhp=_calc_distribution([c.hr_vlhp for c in cards]),
dp_vlhp=_calc_distribution([c.dp_vlhp for c in cards]),
bphr_vlhp=_calc_distribution([c.bphr_vlhp for c in cards]),
bp1b_vlhp=_calc_distribution([c.bp1b_vlhp for c in cards]),
# vs RHP
so_vrhp=_calc_distribution([c.so_vrhp for c in cards]),
bb_vrhp=_calc_distribution([c.bb_vrhp for c in cards]),
hit_vrhp=_calc_distribution([c.hit_vrhp for c in cards]),
ob_vrhp=_calc_distribution([c.ob_vrhp for c in cards]),
tb_vrhp=_calc_distribution([c.tb_vrhp for c in cards]),
hr_vrhp=_calc_distribution([c.hr_vrhp for c in cards]),
dp_vrhp=_calc_distribution([c.dp_vrhp for c in cards]),
bphr_vrhp=_calc_distribution([c.bphr_vrhp for c in cards]),
bp1b_vrhp=_calc_distribution([c.bp1b_vrhp for c in cards]),
)
async def calculate_pitcher_league_stats(
session: AsyncSession,
) -> PitcherLeagueStats:
"""
Calculate league-wide averages and standard deviations for all pitcher stats.
Queries all pitcher cards in the database and computes statistics for each
stat column, separated by vs-LHB and vs-RHB splits.
Args:
session: Database session
Returns:
PitcherLeagueStats with avg/stdev for each stat
"""
query = select(PitcherCard)
result = await session.execute(query)
cards: Sequence[PitcherCard] = result.scalars().all()
if not cards:
# Return default stats if no cards exist
default = StatDistribution(avg=0.0, stdev=1.0)
return PitcherLeagueStats(
so_vlhb=default,
bb_vlhb=default,
hit_vlhb=default,
ob_vlhb=default,
tb_vlhb=default,
hr_vlhb=default,
dp_vlhb=default,
bphr_vlhb=default,
bp1b_vlhb=default,
so_vrhb=default,
bb_vrhb=default,
hit_vrhb=default,
ob_vrhb=default,
tb_vrhb=default,
hr_vrhb=default,
dp_vrhb=default,
bphr_vrhb=default,
bp1b_vrhb=default,
)
return PitcherLeagueStats(
# vs LHB
so_vlhb=_calc_distribution([c.so_vlhb for c in cards]),
bb_vlhb=_calc_distribution([c.bb_vlhb for c in cards]),
hit_vlhb=_calc_distribution([c.hit_vlhb for c in cards]),
ob_vlhb=_calc_distribution([c.ob_vlhb for c in cards]),
tb_vlhb=_calc_distribution([c.tb_vlhb for c in cards]),
hr_vlhb=_calc_distribution([c.hr_vlhb for c in cards]),
dp_vlhb=_calc_distribution([c.dp_vlhb for c in cards]),
bphr_vlhb=_calc_distribution([c.bphr_vlhb for c in cards]),
bp1b_vlhb=_calc_distribution([c.bp1b_vlhb for c in cards]),
# vs RHB
so_vrhb=_calc_distribution([c.so_vrhb for c in cards]),
bb_vrhb=_calc_distribution([c.bb_vrhb for c in cards]),
hit_vrhb=_calc_distribution([c.hit_vrhb for c in cards]),
ob_vrhb=_calc_distribution([c.ob_vrhb for c in cards]),
tb_vrhb=_calc_distribution([c.tb_vrhb for c in cards]),
hr_vrhb=_calc_distribution([c.hr_vrhb for c in cards]),
dp_vrhb=_calc_distribution([c.dp_vrhb for c in cards]),
bphr_vrhb=_calc_distribution([c.bphr_vrhb for c in cards]),
bp1b_vrhb=_calc_distribution([c.bp1b_vrhb for c in cards]),
)
# Cached league stats (computed once per session)
_batter_stats_cache: BatterLeagueStats | None = None
_pitcher_stats_cache: PitcherLeagueStats | None = None
async def get_batter_league_stats(session: AsyncSession) -> BatterLeagueStats:
"""Get cached batter league stats, computing if necessary."""
global _batter_stats_cache
if _batter_stats_cache is None:
_batter_stats_cache = await calculate_batter_league_stats(session)
return _batter_stats_cache
async def get_pitcher_league_stats(session: AsyncSession) -> PitcherLeagueStats:
"""Get cached pitcher league stats, computing if necessary."""
global _pitcher_stats_cache
if _pitcher_stats_cache is None:
_pitcher_stats_cache = await calculate_pitcher_league_stats(session)
return _pitcher_stats_cache
def clear_league_stats_cache() -> None:
"""Clear the cached league stats (call when card data changes)."""
global _batter_stats_cache, _pitcher_stats_cache
_batter_stats_cache = None
_pitcher_stats_cache = None

View File

@ -0,0 +1,475 @@
"""
Matchup calculation logic for SBA Scout.
Calculates batter performance ratings against specific pitchers using
standardized scoring based on league averages and standard deviations.
The calculation:
1. Convert each raw stat to a standardized score (-3 to +3) based on
how it compares to the league average using standard deviations
2. Multiply by the stat's weight
3. Sum all weighted scores for batter component
4. Do the same for pitcher component
5. INVERT pitcher score (so pitcher allowing hits = good for batter)
6. Total = Batter Component + Inverted Pitcher Component
Supports two modes:
- Real-time calculation (calculate_matchup): Uses league stats passed in
- Cached calculation (calculate_matchup_cached): Uses pre-computed scores from DB
"""
from dataclasses import dataclass
from typing import Literal
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import BatterCard, PitcherCard, Player, StandardizedScoreCache
from .league_stats import (
BatterLeagueStats,
PitcherLeagueStats,
)
from .weights import (
BATTER_WEIGHTS,
PITCHER_WEIGHTS,
calculate_weighted_score,
get_max_matchup_score,
)
@dataclass
class MatchupResult:
"""Result of a batter vs pitcher matchup calculation."""
player: Player
rating: float | None # None if no card data
tier: str # A, B, C, D, F, or "--" if no data
batter_hand: str # L, R, or S (actual batting hand used)
batter_split: str # "vLHP" or "vRHP" - batter's split used
pitcher_split: str # "vLHB" or "vRHB" - pitcher's split used
batter_component: float | None # Batter's contribution to rating
pitcher_component: float | None # Pitcher's contribution (before inversion)
@property
def rating_display(self) -> str:
"""Format rating for display (e.g., '+15', '-3', 'N/A')."""
if self.rating is None:
return "N/A"
sign = "+" if self.rating > 0 else ""
return f"{sign}{self.rating:.0f}"
@property
def split_display(self) -> str:
"""Format splits for display (e.g., 'vR/vL')."""
b = "vL" if self.batter_split == "vLHP" else "vR"
p = "vL" if self.pitcher_split == "vLHB" else "vR"
return f"{b}/{p}"
def get_tier(rating: float | None) -> str:
"""
Assign a letter tier based on matchup rating.
With standardized scoring (-3 to +3 per stat, weighted):
- Max batter score: ~66 (all stats at +3)
- Max pitcher score: ~69 (all stats at +3, then inverted)
- Max combined: ~135
Tier thresholds (roughly based on standard deviation bands):
A: >= 40 (Excellent matchup - significantly above average)
B: 20-39 (Good matchup - above average)
C: -19 to 19 (Average matchup)
D: -39 to -20 (Below average matchup)
F: < -40 (Poor matchup - significantly below average)
"""
if rating is None:
return "--"
if rating >= 40:
return "A"
if rating >= 20:
return "B"
if rating >= -19:
return "C"
if rating >= -39:
return "D"
return "F"
def _calculate_batter_component(
card: BatterCard,
vs_hand: Literal["L", "R"],
league_stats: BatterLeagueStats,
) -> float:
"""
Calculate batter's standardized weighted score vs a pitcher's hand.
Args:
card: Batter's card data
vs_hand: Pitcher's throwing hand ("L" or "R")
league_stats: League averages and standard deviations
Returns:
Batter's total weighted score
"""
total = 0.0
if vs_hand == "L":
# vs Left-Handed Pitchers
total += calculate_weighted_score(card.so_vlhp, league_stats.so_vlhp, BATTER_WEIGHTS["so"])
total += calculate_weighted_score(card.bb_vlhp, league_stats.bb_vlhp, BATTER_WEIGHTS["bb"])
total += calculate_weighted_score(
card.hit_vlhp, league_stats.hit_vlhp, BATTER_WEIGHTS["hit"]
)
total += calculate_weighted_score(card.ob_vlhp, league_stats.ob_vlhp, BATTER_WEIGHTS["ob"])
total += calculate_weighted_score(card.tb_vlhp, league_stats.tb_vlhp, BATTER_WEIGHTS["tb"])
total += calculate_weighted_score(card.hr_vlhp, league_stats.hr_vlhp, BATTER_WEIGHTS["hr"])
total += calculate_weighted_score(
card.bphr_vlhp, league_stats.bphr_vlhp, BATTER_WEIGHTS["bphr"]
)
total += calculate_weighted_score(
card.bp1b_vlhp, league_stats.bp1b_vlhp, BATTER_WEIGHTS["bp1b"]
)
total += calculate_weighted_score(card.dp_vlhp, league_stats.dp_vlhp, BATTER_WEIGHTS["dp"])
else:
# vs Right-Handed Pitchers
total += calculate_weighted_score(card.so_vrhp, league_stats.so_vrhp, BATTER_WEIGHTS["so"])
total += calculate_weighted_score(card.bb_vrhp, league_stats.bb_vrhp, BATTER_WEIGHTS["bb"])
total += calculate_weighted_score(
card.hit_vrhp, league_stats.hit_vrhp, BATTER_WEIGHTS["hit"]
)
total += calculate_weighted_score(card.ob_vrhp, league_stats.ob_vrhp, BATTER_WEIGHTS["ob"])
total += calculate_weighted_score(card.tb_vrhp, league_stats.tb_vrhp, BATTER_WEIGHTS["tb"])
total += calculate_weighted_score(card.hr_vrhp, league_stats.hr_vrhp, BATTER_WEIGHTS["hr"])
total += calculate_weighted_score(
card.bphr_vrhp, league_stats.bphr_vrhp, BATTER_WEIGHTS["bphr"]
)
total += calculate_weighted_score(
card.bp1b_vrhp, league_stats.bp1b_vrhp, BATTER_WEIGHTS["bp1b"]
)
total += calculate_weighted_score(card.dp_vrhp, league_stats.dp_vrhp, BATTER_WEIGHTS["dp"])
return total
def _calculate_pitcher_component(
card: PitcherCard,
vs_hand: Literal["L", "R"],
league_stats: PitcherLeagueStats,
) -> float:
"""
Calculate pitcher's standardized weighted score vs a batter's hand.
Note: This returns the pitcher's score from the PITCHER's perspective
(high = good for pitcher). The caller should INVERT this for matchup calc.
Args:
card: Pitcher's card data
vs_hand: Batter's batting hand ("L" or "R")
league_stats: League averages and standard deviations
Returns:
Pitcher's total weighted score (from pitcher's perspective)
"""
total = 0.0
if vs_hand == "L":
# vs Left-Handed Batters
total += calculate_weighted_score(card.so_vlhb, league_stats.so_vlhb, PITCHER_WEIGHTS["so"])
total += calculate_weighted_score(card.bb_vlhb, league_stats.bb_vlhb, PITCHER_WEIGHTS["bb"])
total += calculate_weighted_score(
card.hit_vlhb, league_stats.hit_vlhb, PITCHER_WEIGHTS["hit"]
)
total += calculate_weighted_score(card.ob_vlhb, league_stats.ob_vlhb, PITCHER_WEIGHTS["ob"])
total += calculate_weighted_score(card.tb_vlhb, league_stats.tb_vlhb, PITCHER_WEIGHTS["tb"])
total += calculate_weighted_score(card.hr_vlhb, league_stats.hr_vlhb, PITCHER_WEIGHTS["hr"])
total += calculate_weighted_score(
card.bphr_vlhb, league_stats.bphr_vlhb, PITCHER_WEIGHTS["bphr"]
)
total += calculate_weighted_score(
card.bp1b_vlhb, league_stats.bp1b_vlhb, PITCHER_WEIGHTS["bp1b"]
)
total += calculate_weighted_score(card.dp_vlhb, league_stats.dp_vlhb, PITCHER_WEIGHTS["dp"])
else:
# vs Right-Handed Batters
total += calculate_weighted_score(card.so_vrhb, league_stats.so_vrhb, PITCHER_WEIGHTS["so"])
total += calculate_weighted_score(card.bb_vrhb, league_stats.bb_vrhb, PITCHER_WEIGHTS["bb"])
total += calculate_weighted_score(
card.hit_vrhb, league_stats.hit_vrhb, PITCHER_WEIGHTS["hit"]
)
total += calculate_weighted_score(card.ob_vrhb, league_stats.ob_vrhb, PITCHER_WEIGHTS["ob"])
total += calculate_weighted_score(card.tb_vrhb, league_stats.tb_vrhb, PITCHER_WEIGHTS["tb"])
total += calculate_weighted_score(card.hr_vrhb, league_stats.hr_vrhb, PITCHER_WEIGHTS["hr"])
total += calculate_weighted_score(
card.bphr_vrhb, league_stats.bphr_vrhb, PITCHER_WEIGHTS["bphr"]
)
total += calculate_weighted_score(
card.bp1b_vrhb, league_stats.bp1b_vrhb, PITCHER_WEIGHTS["bp1b"]
)
total += calculate_weighted_score(card.dp_vrhb, league_stats.dp_vrhb, PITCHER_WEIGHTS["dp"])
return total
def calculate_matchup(
player: Player,
batter_card: BatterCard | None,
pitcher: Player,
pitcher_card: PitcherCard | None,
batter_league_stats: BatterLeagueStats,
pitcher_league_stats: PitcherLeagueStats,
) -> MatchupResult:
"""
Calculate batter's expected performance against a specific pitcher.
Uses standardized scoring:
1. Each stat converted to -3 to +3 based on league avg/stdev
2. Multiplied by weight
3. Summed for batter and pitcher components
4. Pitcher component INVERTED (bad pitcher = good for batter)
5. Total = Batter + (-Pitcher)
Args:
player: The batter Player object
batter_card: The batter's card data (may be None)
pitcher: The pitcher Player object
pitcher_card: The pitcher's card data (may be None)
batter_league_stats: League stats for batters
pitcher_league_stats: League stats for pitchers
Returns:
MatchupResult with combined rating, tier, and component breakdown
"""
batter_hand = player.hand or "R"
pitcher_hand = pitcher.hand or "R"
# Determine batter's effective batting hand (for switch hitters)
if batter_hand == "S":
effective_batting_hand: Literal["L", "R"] = "L" if pitcher_hand == "R" else "R"
else:
effective_batting_hand = "L" if batter_hand == "L" else "R"
# Determine which splits to use
batter_split = "vLHP" if pitcher_hand == "L" else "vRHP"
pitcher_split = "vLHB" if effective_batting_hand == "L" else "vRHB"
# No batter card - return N/A result
if batter_card is None:
return MatchupResult(
player=player,
rating=None,
tier="--",
batter_hand=effective_batting_hand,
batter_split=batter_split,
pitcher_split=pitcher_split,
batter_component=None,
pitcher_component=None,
)
# Calculate batter's component
batter_component = _calculate_batter_component(batter_card, pitcher_hand, batter_league_stats)
# Calculate pitcher's component (if available)
if pitcher_card is not None:
pitcher_component = _calculate_pitcher_component(
pitcher_card, effective_batting_hand, pitcher_league_stats
)
# INVERT pitcher component: good pitcher (high score) = bad for batter
total_rating = batter_component + (-pitcher_component)
else:
pitcher_component = None
total_rating = batter_component
return MatchupResult(
player=player,
rating=total_rating,
tier=get_tier(total_rating),
batter_hand=effective_batting_hand,
batter_split=batter_split,
pitcher_split=pitcher_split,
batter_component=batter_component,
pitcher_component=pitcher_component,
)
def calculate_team_matchups(
batters: list[Player],
pitcher: Player,
pitcher_card: PitcherCard | None,
batter_league_stats: BatterLeagueStats,
pitcher_league_stats: PitcherLeagueStats,
) -> list[MatchupResult]:
"""
Calculate matchups for all batters against a specific pitcher.
Args:
batters: List of batter Player objects (with batter_card relationship loaded)
pitcher: The opposing pitcher Player object
pitcher_card: The pitcher's card data (may be None)
batter_league_stats: League stats for batters
pitcher_league_stats: League stats for pitchers
Returns:
List of MatchupResults sorted by rating (highest first), with None ratings last
"""
results = []
for player in batters:
result = calculate_matchup(
player,
player.batter_card,
pitcher,
pitcher_card,
batter_league_stats,
pitcher_league_stats,
)
results.append(result)
# Sort: highest rating first, None ratings at the end
def sort_key(r: MatchupResult) -> tuple[int, float]:
if r.rating is None:
return (1, 0)
return (0, -r.rating)
results.sort(key=sort_key)
return results
# =============================================================================
# Cached Calculation Functions (use pre-computed scores from database)
# =============================================================================
async def calculate_matchup_cached(
session: AsyncSession,
player: Player,
batter_card: BatterCard | None,
pitcher: Player,
pitcher_card: PitcherCard | None,
) -> MatchupResult:
"""
Calculate matchup using pre-computed cached scores from the database.
This is much faster than real-time calculation as it only requires
two simple database lookups instead of computing standardized scores.
Args:
session: Database session for cache lookups
player: The batter Player object
batter_card: The batter's card data (may be None)
pitcher: The pitcher Player object
pitcher_card: The pitcher's card data (may be None)
Returns:
MatchupResult with combined rating, tier, and component breakdown
"""
from .score_cache import get_cached_batter_score, get_cached_pitcher_score
batter_hand = player.hand or "R"
pitcher_hand = pitcher.hand or "R"
# Determine batter's effective batting hand (for switch hitters)
if batter_hand == "S":
effective_batting_hand: Literal["L", "R"] = "L" if pitcher_hand == "R" else "R"
else:
effective_batting_hand = "L" if batter_hand == "L" else "R"
# Determine which splits to use
batter_split_name = "vLHP" if pitcher_hand == "L" else "vRHP"
pitcher_split_name = "vLHB" if effective_batting_hand == "L" else "vRHB"
# Convert to cache key format (lowercase)
batter_split_key = "vlhp" if pitcher_hand == "L" else "vrhp"
pitcher_split_key = "vlhb" if effective_batting_hand == "L" else "vrhb"
# No batter card - return N/A result
if batter_card is None:
return MatchupResult(
player=player,
rating=None,
tier="--",
batter_hand=effective_batting_hand,
batter_split=batter_split_name,
pitcher_split=pitcher_split_name,
batter_component=None,
pitcher_component=None,
)
# Get cached batter score
batter_cache = await get_cached_batter_score(session, batter_card.id, batter_split_key)
if batter_cache is None:
# Cache miss - should not happen if cache is properly maintained
# Fall back to returning None
return MatchupResult(
player=player,
rating=None,
tier="--",
batter_hand=effective_batting_hand,
batter_split=batter_split_name,
pitcher_split=pitcher_split_name,
batter_component=None,
pitcher_component=None,
)
batter_component = batter_cache.total_score
# Get cached pitcher score (if available)
if pitcher_card is not None:
pitcher_cache = await get_cached_pitcher_score(session, pitcher_card.id, pitcher_split_key)
if pitcher_cache is not None:
pitcher_component = pitcher_cache.total_score
# INVERT pitcher component
total_rating = batter_component + (-pitcher_component)
else:
pitcher_component = None
total_rating = batter_component
else:
pitcher_component = None
total_rating = batter_component
return MatchupResult(
player=player,
rating=total_rating,
tier=get_tier(total_rating),
batter_hand=effective_batting_hand,
batter_split=batter_split_name,
pitcher_split=pitcher_split_name,
batter_component=batter_component,
pitcher_component=pitcher_component,
)
async def calculate_team_matchups_cached(
session: AsyncSession,
batters: list[Player],
pitcher: Player,
pitcher_card: PitcherCard | None,
) -> list[MatchupResult]:
"""
Calculate matchups for all batters using cached scores.
Args:
session: Database session for cache lookups
batters: List of batter Player objects (with batter_card relationship loaded)
pitcher: The opposing pitcher Player object
pitcher_card: The pitcher's card data (may be None)
Returns:
List of MatchupResults sorted by rating (highest first), with None ratings last
"""
results = []
for player in batters:
result = await calculate_matchup_cached(
session,
player,
player.batter_card,
pitcher,
pitcher_card,
)
results.append(result)
# Sort: highest rating first, None ratings at the end
def sort_key(r: MatchupResult) -> tuple[int, float]:
if r.rating is None:
return (1, 0)
return (0, -r.rating)
results.sort(key=sort_key)
return results

View File

@ -0,0 +1,318 @@
"""
Standardized score caching system.
Pre-calculates and stores standardized scores for all card stats to avoid
expensive real-time calculations during matchup analysis.
"""
import hashlib
import json
import logging
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Sequence
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import BatterCard, PitcherCard, StandardizedScoreCache
from .league_stats import (
BatterLeagueStats,
PitcherLeagueStats,
StatDistribution,
calculate_batter_league_stats,
calculate_pitcher_league_stats,
)
from .weights import (
BATTER_WEIGHTS,
PITCHER_WEIGHTS,
StatWeight,
standardize_value,
)
logger = logging.getLogger(__name__)
@dataclass
class StatScore:
"""Score details for a single stat."""
raw: float
std: int # Standardized score (-3 to +3)
weighted: float # std * weight
def _compute_weights_hash() -> str:
"""Generate hash of current weight configuration."""
weights_data = {
"batter": {k: (v.weight, v.high_is_better) for k, v in BATTER_WEIGHTS.items()},
"pitcher": {k: (v.weight, v.high_is_better) for k, v in PITCHER_WEIGHTS.items()},
}
return hashlib.sha256(json.dumps(weights_data, sort_keys=True).encode()).hexdigest()[:16]
def _compute_league_stats_hash(
batter_stats: BatterLeagueStats,
pitcher_stats: PitcherLeagueStats,
) -> str:
"""Generate hash of league statistics."""
# Just hash a few key values to detect significant changes
key_values = [
batter_stats.hit_vrhp.avg,
batter_stats.hit_vrhp.stdev,
batter_stats.so_vrhp.avg,
pitcher_stats.hit_vrhb.avg,
pitcher_stats.so_vrhb.avg,
]
return hashlib.sha256(str(key_values).encode()).hexdigest()[:16]
def _calculate_batter_split_scores(
card: BatterCard,
split: str, # "vlhp" or "vrhp"
league_stats: BatterLeagueStats,
) -> tuple[float, dict[str, dict]]:
"""
Calculate standardized scores for all stats on a batter card split.
Returns:
Tuple of (total_score, stat_scores_dict)
"""
if split == "vlhp":
stats = [
("so", card.so_vlhp, league_stats.so_vlhp),
("bb", card.bb_vlhp, league_stats.bb_vlhp),
("hit", card.hit_vlhp, league_stats.hit_vlhp),
("ob", card.ob_vlhp, league_stats.ob_vlhp),
("tb", card.tb_vlhp, league_stats.tb_vlhp),
("hr", card.hr_vlhp, league_stats.hr_vlhp),
("bphr", card.bphr_vlhp, league_stats.bphr_vlhp),
("bp1b", card.bp1b_vlhp, league_stats.bp1b_vlhp),
("dp", card.dp_vlhp, league_stats.dp_vlhp),
]
else: # vrhp
stats = [
("so", card.so_vrhp, league_stats.so_vrhp),
("bb", card.bb_vrhp, league_stats.bb_vrhp),
("hit", card.hit_vrhp, league_stats.hit_vrhp),
("ob", card.ob_vrhp, league_stats.ob_vrhp),
("tb", card.tb_vrhp, league_stats.tb_vrhp),
("hr", card.hr_vrhp, league_stats.hr_vrhp),
("bphr", card.bphr_vrhp, league_stats.bphr_vrhp),
("bp1b", card.bp1b_vrhp, league_stats.bp1b_vrhp),
("dp", card.dp_vrhp, league_stats.dp_vrhp),
]
total = 0.0
stat_scores = {}
for stat_name, raw_val, dist in stats:
sw = BATTER_WEIGHTS[stat_name]
std_score = standardize_value(raw_val, dist, sw.high_is_better)
weighted = std_score * sw.weight
total += weighted
stat_scores[stat_name] = {
"raw": raw_val or 0,
"std": std_score,
"weighted": weighted,
}
return total, stat_scores
def _calculate_pitcher_split_scores(
card: PitcherCard,
split: str, # "vlhb" or "vrhb"
league_stats: PitcherLeagueStats,
) -> tuple[float, dict[str, dict]]:
"""
Calculate standardized scores for all stats on a pitcher card split.
Returns:
Tuple of (total_score, stat_scores_dict)
"""
if split == "vlhb":
stats = [
("so", card.so_vlhb, league_stats.so_vlhb),
("bb", card.bb_vlhb, league_stats.bb_vlhb),
("hit", card.hit_vlhb, league_stats.hit_vlhb),
("ob", card.ob_vlhb, league_stats.ob_vlhb),
("tb", card.tb_vlhb, league_stats.tb_vlhb),
("hr", card.hr_vlhb, league_stats.hr_vlhb),
("bphr", card.bphr_vlhb, league_stats.bphr_vlhb),
("bp1b", card.bp1b_vlhb, league_stats.bp1b_vlhb),
("dp", card.dp_vlhb, league_stats.dp_vlhb),
]
else: # vrhb
stats = [
("so", card.so_vrhb, league_stats.so_vrhb),
("bb", card.bb_vrhb, league_stats.bb_vrhb),
("hit", card.hit_vrhb, league_stats.hit_vrhb),
("ob", card.ob_vrhb, league_stats.ob_vrhb),
("tb", card.tb_vrhb, league_stats.tb_vrhb),
("hr", card.hr_vrhb, league_stats.hr_vrhb),
("bphr", card.bphr_vrhb, league_stats.bphr_vrhb),
("bp1b", card.bp1b_vrhb, league_stats.bp1b_vrhb),
("dp", card.dp_vrhb, league_stats.dp_vrhb),
]
total = 0.0
stat_scores = {}
for stat_name, raw_val, dist in stats:
sw = PITCHER_WEIGHTS[stat_name]
std_score = standardize_value(raw_val, dist, sw.high_is_better)
weighted = std_score * sw.weight
total += weighted
stat_scores[stat_name] = {
"raw": raw_val or 0,
"std": std_score,
"weighted": weighted,
}
return total, stat_scores
async def rebuild_score_cache(session: AsyncSession) -> dict[str, int]:
"""
Rebuild the entire standardized score cache.
Deletes all existing cached scores and recalculates from scratch
using current league statistics and weights.
Args:
session: Database session
Returns:
Dict with counts: {"batter_splits": N, "pitcher_splits": M}
"""
logger.info("Rebuilding standardized score cache...")
# Calculate current league stats
batter_league_stats = await calculate_batter_league_stats(session)
pitcher_league_stats = await calculate_pitcher_league_stats(session)
# Get hashes for cache validity tracking
weights_hash = _compute_weights_hash()
league_hash = _compute_league_stats_hash(batter_league_stats, pitcher_league_stats)
# Clear existing cache
await session.execute(delete(StandardizedScoreCache))
# Get all batter cards
batter_result = await session.execute(select(BatterCard))
batter_cards: Sequence[BatterCard] = batter_result.scalars().all()
# Get all pitcher cards
pitcher_result = await session.execute(select(PitcherCard))
pitcher_cards: Sequence[PitcherCard] = pitcher_result.scalars().all()
batter_count = 0
pitcher_count = 0
now = datetime.utcnow()
# Calculate and cache batter scores
for card in batter_cards:
for split in ["vlhp", "vrhp"]:
total, stat_scores = _calculate_batter_split_scores(card, split, batter_league_stats)
cache_entry = StandardizedScoreCache(
batter_card_id=card.id,
pitcher_card_id=None,
split=split,
total_score=total,
stat_scores=stat_scores,
computed_at=now,
weights_hash=weights_hash,
league_stats_hash=league_hash,
)
session.add(cache_entry)
batter_count += 1
# Calculate and cache pitcher scores
for card in pitcher_cards:
for split in ["vlhb", "vrhb"]:
total, stat_scores = _calculate_pitcher_split_scores(card, split, pitcher_league_stats)
cache_entry = StandardizedScoreCache(
batter_card_id=None,
pitcher_card_id=card.id,
split=split,
total_score=total,
stat_scores=stat_scores,
computed_at=now,
weights_hash=weights_hash,
league_stats_hash=league_hash,
)
session.add(cache_entry)
pitcher_count += 1
await session.flush()
logger.info(
f"Score cache rebuilt: {batter_count} batter splits, {pitcher_count} pitcher splits"
)
return {"batter_splits": batter_count, "pitcher_splits": pitcher_count}
async def get_cached_batter_score(
session: AsyncSession,
batter_card_id: int,
split: str, # "vlhp" or "vrhp"
) -> StandardizedScoreCache | None:
"""Get cached score for a batter card split."""
query = select(StandardizedScoreCache).where(
StandardizedScoreCache.batter_card_id == batter_card_id,
StandardizedScoreCache.split == split,
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_cached_pitcher_score(
session: AsyncSession,
pitcher_card_id: int,
split: str, # "vlhb" or "vrhb"
) -> StandardizedScoreCache | None:
"""Get cached score for a pitcher card split."""
query = select(StandardizedScoreCache).where(
StandardizedScoreCache.pitcher_card_id == pitcher_card_id,
StandardizedScoreCache.split == split,
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def is_cache_valid(session: AsyncSession) -> bool:
"""
Check if the score cache is valid (exists and has current weights/stats).
Returns False if:
- Cache is empty
- Weights have changed
- League stats have changed significantly
"""
# Check if any cache entries exist
query = select(StandardizedScoreCache).limit(1)
result = await session.execute(query)
entry = result.scalar_one_or_none()
if entry is None:
return False
# Check weights hash
current_weights_hash = _compute_weights_hash()
if entry.weights_hash != current_weights_hash:
logger.info("Cache invalid: weights have changed")
return False
# Could also check league_stats_hash here, but that would require
# recalculating stats which defeats the purpose of caching.
# For now, trust that cache is rebuilt when cards are imported.
return True
async def ensure_cache_exists(session: AsyncSession) -> None:
"""Ensure score cache exists, rebuilding if necessary."""
if not await is_cache_valid(session):
await rebuild_score_cache(session)
await session.commit()

View File

@ -0,0 +1,170 @@
"""
Stat weights and standardized scoring for matchup calculations.
Converts raw card values into standardized scores (-3 to +3) based on
league averages and standard deviations, then applies weights.
"""
from dataclasses import dataclass
from typing import Literal
from .league_stats import StatDistribution
@dataclass
class StatWeight:
"""Weight and direction for a single stat."""
weight: int
high_is_better: bool # If True, high values get positive scores
# =============================================================================
# Batter Stat Weights (for matchup calculation)
# =============================================================================
BATTER_WEIGHTS: dict[str, StatWeight] = {
"so": StatWeight(weight=1, high_is_better=False), # Strikeouts - low is better
"bb": StatWeight(weight=1, high_is_better=True), # Walks - high is better
"hit": StatWeight(weight=2, high_is_better=True), # Hits - high is better
"ob": StatWeight(weight=5, high_is_better=True), # On-base - high is better
"tb": StatWeight(weight=5, high_is_better=True), # Total bases - high is better
"hr": StatWeight(weight=2, high_is_better=True), # Home runs - high is better
"bphr": StatWeight(weight=3, high_is_better=True), # Ballpark HR - high is better
"bp1b": StatWeight(weight=1, high_is_better=True), # Ballpark 1B - high is better
"dp": StatWeight(weight=2, high_is_better=False), # Double plays - low is better
}
# =============================================================================
# Pitcher Stat Weights (for matchup calculation)
# =============================================================================
PITCHER_WEIGHTS: dict[str, StatWeight] = {
"so": StatWeight(weight=3, high_is_better=True), # Strikeouts - high is better for pitcher
"bb": StatWeight(weight=1, high_is_better=False), # Walks - low is better for pitcher
"hit": StatWeight(weight=2, high_is_better=False), # Hits - low is better for pitcher
"ob": StatWeight(weight=5, high_is_better=False), # On-base - low is better for pitcher
"tb": StatWeight(weight=2, high_is_better=False), # Total bases - low is better for pitcher
"hr": StatWeight(weight=5, high_is_better=False), # Home runs - low is better for pitcher
"bphr": StatWeight(weight=2, high_is_better=False), # Ballpark HR - low is better for pitcher
"bp1b": StatWeight(weight=1, high_is_better=False), # Ballpark 1B - low is better for pitcher
"dp": StatWeight(weight=2, high_is_better=True), # Double plays - high is better for pitcher
}
# =============================================================================
# Standardized Scoring Functions
# =============================================================================
def standardize_value(
value: float | None,
distribution: StatDistribution,
high_is_better: bool,
) -> int:
"""
Convert a raw stat value to a standardized score (-3 to +3).
Uses the following thresholds based on standard deviations from the mean:
> AVG + 2*STDEV: -3 (or +3 if high_is_better)
> AVG + 1*STDEV: -2 (or +2)
> AVG + 0.33*STDEV: -1 (or +1)
> AVG - 0.33*STDEV: 0
> AVG - 1*STDEV: +1 (or -1)
> AVG - 2*STDEV: +2 (or -2)
else: +3 (or -3)
Special case: value of 0 gets the best score (+3 for low_is_better, +3 for high after invert)
Args:
value: Raw stat value from card
distribution: League average and standard deviation
high_is_better: If True, high values get positive scores (inverted)
Returns:
Standardized score from -3 to +3
"""
if value is None or value == 0:
# Zero value = best possible (for stats like SO, HR where 0 is rare/great)
return 3 if not high_is_better else 3
avg = distribution.avg
stdev = distribution.stdev
# Calculate thresholds
thresh_plus_2sd = avg + (2 * stdev)
thresh_plus_1sd = avg + (1 * stdev)
thresh_plus_033sd = avg + (0.33 * stdev)
thresh_minus_033sd = avg - (0.33 * stdev)
thresh_minus_1sd = avg - (1 * stdev)
thresh_minus_2sd = avg - (2 * stdev)
# Determine base score (before inversion)
# High values get negative scores in base formula
if value > thresh_plus_2sd:
base_score = -3
elif value > thresh_plus_1sd:
base_score = -2
elif value > thresh_plus_033sd:
base_score = -1
elif value > thresh_minus_033sd:
base_score = 0
elif value > thresh_minus_1sd:
base_score = 1
elif value > thresh_minus_2sd:
base_score = 2
else:
base_score = 3
# Invert if high values are better
if high_is_better:
return -base_score
return base_score
def calculate_weighted_score(
value: float | None,
distribution: StatDistribution,
stat_weight: StatWeight,
) -> float:
"""
Calculate weighted score for a single stat.
Args:
value: Raw stat value
distribution: League avg/stdev for this stat
stat_weight: Weight and direction for this stat
Returns:
Weighted score (standardized_score * weight)
"""
std_score = standardize_value(value, distribution, stat_weight.high_is_better)
return std_score * stat_weight.weight
# =============================================================================
# Maximum Possible Scores (for reference)
# =============================================================================
def get_max_batter_score() -> int:
"""Get the maximum possible batter component score."""
# All stats at +3, multiplied by weights
return sum(3 * w.weight for w in BATTER_WEIGHTS.values())
def get_max_pitcher_score() -> int:
"""Get the maximum possible pitcher component score."""
return sum(3 * w.weight for w in PITCHER_WEIGHTS.values())
def get_max_matchup_score() -> int:
"""Get the maximum possible combined matchup score."""
return get_max_batter_score() + get_max_pitcher_score()
# Max scores:
# Batter: (1+1+2+5+5+2+3+1+2) * 3 = 22 * 3 = 66
# Pitcher: (3+1+2+5+2+5+2+1+2) * 3 = 23 * 3 = 69
# Combined max: 135

View File

@ -85,6 +85,7 @@ class TeamSettings(BaseModel):
team_id: int = Field(default=548, description="Your team's ID in the league")
team_abbrev: str = Field(default="WV", description="Your team's abbreviation")
team_name: str = Field(default="West Virginia Black Bears", description="Full team name")
current_season: int = Field(default=13, description="Current season number")
swar_cap: float = Field(default=29.5, description="Season sWAR cap for roster")
minor_league_slots: int = Field(default=5, description="Number of minor league roster slots")
major_league_slots: int = Field(default=26, description="Number of major league roster slots")

View File

@ -410,6 +410,52 @@ class MatchupCache(Base):
)
class StandardizedScoreCache(Base):
"""
Cached standardized scores for card stats.
Pre-computes the standardized score (-3 to +3) and weighted score for each
stat on each card, based on league averages and standard deviations.
Invalidated and recalculated when:
- Card data is imported/updated
- Weight values are changed
- League stats change significantly (new cards added)
"""
__tablename__ = "standardized_score_cache"
id = Column(Integer, primary_key=True, autoincrement=True)
# Card reference (either batter or pitcher, not both)
batter_card_id = Column(Integer, ForeignKey("batter_cards.id"), nullable=True)
pitcher_card_id = Column(Integer, ForeignKey("pitcher_cards.id"), nullable=True)
# Which split this score is for
split = Column(String(10), nullable=False) # "vlhp", "vrhp", "vlhb", "vrhb"
# Pre-computed total weighted score for this card/split
total_score = Column(Float, nullable=False)
# Individual stat scores (JSON for flexibility)
# Format: {"so": {"raw": 15.0, "std": 1, "weighted": 1}, "bb": {...}, ...}
stat_scores = Column(JSON, nullable=False)
# Cache validity
computed_at = Column(DateTime, default=datetime.utcnow)
weights_hash = Column(String(64), nullable=True) # Hash of weights used
league_stats_hash = Column(String(64), nullable=True) # Hash of league avg/stdev
# Relationships
batter_card = relationship("BatterCard", foreign_keys=[batter_card_id])
pitcher_card = relationship("PitcherCard", foreign_keys=[pitcher_card_id])
__table_args__ = (
UniqueConstraint("batter_card_id", "split", name="uq_batter_score_split"),
UniqueConstraint("pitcher_card_id", "split", name="uq_pitcher_score_split"),
)
class SyncStatus(Base):
"""
Tracks sync status with the league API.

View File

@ -463,3 +463,19 @@ async def save_lineup(
)
session.add(lineup)
return lineup
async def delete_lineup(
session: AsyncSession,
name: str,
) -> bool:
"""Delete a lineup by name.
Returns:
True if lineup was deleted, False if not found
"""
lineup = await get_lineup_by_name(session, name)
if lineup:
await session.delete(lineup)
return True
return False

View File

@ -0,0 +1,691 @@
"""
Gameday Screen - Integrated Matchup Scout + Lineup Builder.
Pre-game workflow: analyze matchups against opposing pitcher,
then build your lineup based on the matchup advantages.
Left panel: Matchup analysis (team/pitcher selection, batter ratings)
Right panel: Lineup builder (batting order with positions)
"""
import logging
from dataclasses import dataclass
from typing import ClassVar, Optional
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, ScrollableContainer, Vertical
from textual.screen import Screen
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
Input,
Label,
Select,
Static,
)
from ..calc.matchup import MatchupResult, calculate_team_matchups_cached
from ..calc.score_cache import ensure_cache_exists
from ..config import get_settings
from ..db.models import Lineup, Player, Team
from ..db.queries import (
delete_lineup,
get_all_teams,
get_lineups,
get_lineup_by_name,
get_my_roster,
get_pitchers,
save_lineup,
)
from ..db.schema import get_session
logger = logging.getLogger(__name__)
# Defensive positions
DEFENSIVE_POSITIONS = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]
@dataclass
class LineupSlot:
"""A slot in the batting order with position assignment."""
order: int # 1-9
player: Optional[Player] = None
position: Optional[str] = None
matchup_rating: Optional[float] = None # Rating vs current pitcher
class GamedayScreen(Screen):
"""Integrated matchup scout and lineup builder for game day preparation."""
BINDINGS: ClassVar = [
Binding("escape", "app.pop_screen", "Back"),
Binding("q", "app.pop_screen", "Back"),
# Matchup controls
Binding("s", "sort_by_rating", "Sort: Rating"),
Binding("n", "sort_by_name", "Sort: Name"),
# Lineup controls
Binding("a", "add_to_lineup", "Add to Lineup"),
Binding("r", "remove_from_lineup", "Remove"),
Binding("k", "move_up", "Move Up"),
Binding("j", "move_down", "Move Down"),
Binding("p", "cycle_position", "Change Pos"),
Binding("ctrl+s", "save_lineup", "Save Lineup"),
Binding("c", "clear_lineup", "Clear"),
]
# Matchup state
teams: list[Team] = []
opponent_pitchers: list[Player] = []
my_batters: list[Player] = []
selected_team_id: int | None = None
selected_pitcher: Player | None = None
matchup_results: list[MatchupResult] = []
current_sort: str = "rating"
# Lineup state
lineup_slots: list[LineupSlot] = []
saved_lineups: list[Lineup] = []
current_lineup_name: str = ""
def compose(self) -> ComposeResult:
"""Compose the gameday layout with side-by-side panels."""
yield Header()
with Horizontal(id="gameday-container"):
# Left panel - Matchup Scout
with Vertical(id="matchup-panel", classes="gameday-panel"):
yield Label("Matchup Scout", classes="panel-title")
# Team/Pitcher selectors
with Horizontal(id="gameday-selectors"):
yield Label("vs")
yield Select(
options=[],
prompt="Team",
id="team-select",
allow_blank=True,
)
yield Select(
options=[],
prompt="Pitcher",
id="pitcher-select",
allow_blank=True,
)
# Pitcher info
yield Static("Select opponent", id="pitcher-info")
# Matchup table
with ScrollableContainer(id="matchup-scroll"):
yield DataTable(
id="matchup-table",
cursor_type="row",
zebra_stripes=True,
)
yield Static("[a] Add to lineup [s/n] Sort", classes="hint-text")
# Right panel - Lineup Builder
with Vertical(id="lineup-panel", classes="gameday-panel"):
# Save/Load controls at top
with Horizontal(id="lineup-save-controls"):
yield Input(
placeholder="Lineup name",
id="lineup-name-input",
)
yield Select(
options=[],
prompt="Load",
id="lineup-select",
allow_blank=True,
)
yield Button("Save", id="btn-save", variant="success")
yield Label("Game Lineup", classes="panel-title")
# Lineup table
with ScrollableContainer(id="lineup-scroll"):
yield DataTable(
id="lineup-table",
cursor_type="row",
zebra_stripes=True,
)
yield Static("[k/j] Move [p] Pos [r] Remove", classes="hint-text")
yield Footer()
async def on_mount(self) -> None:
"""Initialize when screen mounts."""
self._init_lineup_slots()
await self._setup_tables()
await self._ensure_score_cache()
await self._load_teams()
await self._load_my_batters()
await self._load_saved_lineups()
def _init_lineup_slots(self) -> None:
"""Initialize empty lineup slots for 9 batters."""
self.lineup_slots = [LineupSlot(order=i) for i in range(1, 10)]
async def _setup_tables(self) -> None:
"""Configure both data tables."""
# Matchup table
matchup_table = self.query_one("#matchup-table", DataTable)
matchup_table.add_column("#", key="rank", width=3)
matchup_table.add_column("Name", key="name", width=18)
matchup_table.add_column("Pos", key="pos", width=12)
matchup_table.add_column("H", key="hand", width=2)
matchup_table.add_column("Rating", key="rating", width=7)
matchup_table.add_column("Tier", key="tier", width=4)
# Lineup table
lineup_table = self.query_one("#lineup-table", DataTable)
lineup_table.add_column("#", key="order", width=2)
lineup_table.add_column("H", key="hand", width=2)
lineup_table.add_column("Name", key="name", width=16)
lineup_table.add_column("Pos", key="pos", width=4)
lineup_table.add_column("Rating", key="rating", width=7)
lineup_table.add_column("Tier", key="tier", width=4)
async def _ensure_score_cache(self) -> None:
"""Ensure standardized score cache exists."""
try:
async with get_session() as session:
await ensure_cache_exists(session)
except Exception as e:
logger.error(f"Failed to ensure score cache: {e}")
async def _load_teams(self) -> None:
"""Load all teams for the opponent selector."""
settings = get_settings()
try:
async with get_session() as session:
self.teams = list(
await get_all_teams(session, settings.team.current_season, active_only=True)
)
team_select = self.query_one("#team-select", Select)
options = [
(f"{t.abbrev}", t.id) for t in self.teams if t.abbrev != settings.team.team_abbrev
]
team_select.set_options(options)
except Exception as e:
logger.error(f"Failed to load teams: {e}")
async def _load_my_batters(self) -> None:
"""Load my team's batters."""
settings = get_settings()
try:
async with get_session() as session:
roster = await get_my_roster(
session,
settings.team.team_abbrev,
settings.team.current_season,
)
self.my_batters = [p for p in roster.get("majors", []) if p.is_batter]
except Exception as e:
logger.error(f"Failed to load batters: {e}")
async def _load_opponent_pitchers(self, team_id: int) -> None:
"""Load pitchers for the selected opponent team."""
settings = get_settings()
try:
async with get_session() as session:
self.opponent_pitchers = list(
await get_pitchers(
session, team_id=team_id, season=settings.team.current_season
)
)
pitcher_select = self.query_one("#pitcher-select", Select)
options = [(f"{p.name} ({p.hand or '?'})", p.id) for p in self.opponent_pitchers]
pitcher_select.set_options(options)
self.selected_pitcher = None
self.query_one("#pitcher-info", Static).update("Select a pitcher")
self._clear_matchup_table()
except Exception as e:
logger.error(f"Failed to load pitchers: {e}")
async def _load_saved_lineups(self) -> None:
"""Load saved lineups for the dropdown."""
try:
async with get_session() as session:
self.saved_lineups = list(await get_lineups(session))
lineup_select = self.query_one("#lineup-select", Select)
options = [(lu.name, lu.name) for lu in self.saved_lineups]
lineup_select.set_options(options)
except Exception as e:
logger.error(f"Failed to load saved lineups: {e}")
def _clear_matchup_table(self) -> None:
"""Clear the matchup table."""
table = self.query_one("#matchup-table", DataTable)
table.clear()
self.matchup_results = []
async def _calculate_and_display_matchups(self) -> None:
"""Calculate matchups and populate the table."""
if not self.selected_pitcher:
return
pitcher_card = self.selected_pitcher.pitcher_card
async with get_session() as session:
self.matchup_results = await calculate_team_matchups_cached(
session,
self.my_batters,
self.selected_pitcher,
pitcher_card,
)
self._apply_sort()
self._populate_matchup_table()
self._update_lineup_ratings()
# Update pitcher info
pitcher_info = self.query_one("#pitcher-info", Static)
hand = self.selected_pitcher.hand or "?"
card_status = "" if pitcher_card else " [no card]"
pitcher_info.update(f"vs {self.selected_pitcher.name} ({hand}HP){card_status}")
def _apply_sort(self) -> None:
"""Sort matchup results."""
if self.current_sort == "rating":
def rating_key(r: MatchupResult) -> tuple[int, float]:
if r.rating is None:
return (1, 0)
return (0, -r.rating)
self.matchup_results.sort(key=rating_key)
elif self.current_sort == "name":
self.matchup_results.sort(key=lambda r: r.player.name)
def _populate_matchup_table(self) -> None:
"""Populate the matchup table."""
table = self.query_one("#matchup-table", DataTable)
table.clear()
# Get players already in lineup
lineup_player_ids = {slot.player.id for slot in self.lineup_slots if slot.player}
for i, result in enumerate(self.matchup_results, start=1):
player = result.player
positions = ", ".join(player.positions[:3]) # Limit to 3 positions
# Mark if already in lineup
name = player.name
if player.id in lineup_player_ids:
name = f"* {name}"
row = (
str(i),
name,
positions,
result.batter_hand,
result.rating_display,
result.tier,
)
table.add_row(*row, key=str(player.id))
def _populate_lineup_table(self) -> None:
"""Populate the lineup table."""
table = self.query_one("#lineup-table", DataTable)
cursor_row = table.cursor_row
table.clear()
# Build rating lookup from current matchup results
rating_lookup = {r.player.id: r for r in self.matchup_results}
for slot in self.lineup_slots:
if slot.player:
# Get matchup rating if available
matchup = rating_lookup.get(slot.player.id)
rating_str = matchup.rating_display if matchup else "---"
tier_str = matchup.tier if matchup else "-"
hand = slot.player.hand or "?"
row = (
str(slot.order),
hand,
slot.player.name,
slot.position or "---",
rating_str,
tier_str,
)
else:
row = (str(slot.order), "-", "---", "---", "---", "-")
table.add_row(*row, key=f"slot-{slot.order}")
# Restore cursor
if cursor_row is not None and cursor_row < 9:
table.move_cursor(row=cursor_row)
def _update_lineup_ratings(self) -> None:
"""Update ratings in lineup table after matchup calculation."""
self._populate_lineup_table()
def _get_selected_matchup_player(self) -> Optional[Player]:
"""Get the currently selected player from matchup table."""
table = self.query_one("#matchup-table", DataTable)
cursor_row = table.cursor_row
if cursor_row is None:
return None
try:
row_keys = list(table.rows.keys())
if cursor_row >= len(row_keys):
return None
player_id = int(row_keys[cursor_row].value)
return next((p for p in self.my_batters if p.id == player_id), None)
except (IndexError, ValueError, AttributeError):
return None
def _get_selected_lineup_slot(self) -> Optional[LineupSlot]:
"""Get the currently selected lineup slot."""
table = self.query_one("#lineup-table", DataTable)
if table.cursor_row is None:
return None
if 0 <= table.cursor_row < len(self.lineup_slots):
return self.lineup_slots[table.cursor_row]
return None
def _find_first_empty_slot(self) -> Optional[LineupSlot]:
"""Find the first empty slot in the lineup."""
for slot in self.lineup_slots:
if slot.player is None:
return slot
return None
def _suggest_position(self, player: Player) -> str:
"""Suggest the best available position for a player."""
positions = player.positions
filled_positions = {
slot.position for slot in self.lineup_slots if slot.position and slot.player
}
for pos in positions:
if pos in DEFENSIVE_POSITIONS and pos not in filled_positions:
return pos
if "DH" not in filled_positions:
return "DH"
return positions[0] if positions else "DH"
def _get_next_position(self, player: Player, current: Optional[str]) -> str:
"""Get next valid position for cycling."""
positions = list(player.positions)
if "DH" not in positions:
positions.append("DH")
if not current or current not in positions:
return positions[0] if positions else "DH"
idx = positions.index(current)
return positions[(idx + 1) % len(positions)]
# =========================================================================
# Event Handlers
# =========================================================================
async def on_select_changed(self, event: Select.Changed) -> None:
"""Handle selection changes."""
if event.select.id == "team-select":
if event.value and event.value != Select.BLANK:
self.selected_team_id = event.value
await self._load_opponent_pitchers(event.value)
else:
self.selected_team_id = None
self.query_one("#pitcher-select", Select).set_options([])
self._clear_matchup_table()
self.query_one("#pitcher-info", Static).update("Select opponent")
elif event.select.id == "pitcher-select":
if event.value and event.value != Select.BLANK:
self.selected_pitcher = next(
(p for p in self.opponent_pitchers if p.id == event.value), None
)
await self._calculate_and_display_matchups()
else:
self.selected_pitcher = None
self._clear_matchup_table()
self.query_one("#pitcher-info", Static).update("Select a pitcher")
elif event.select.id == "lineup-select":
if event.value and event.value != Select.BLANK:
await self._do_load_lineup(event.value)
async def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks."""
if event.button.id == "btn-save":
await self.action_save_lineup()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection - toggle cursor off if clicking same row."""
table = event.data_table
if table.id == "lineup-table":
# If cursor is "row" type, switch to "none" to hide highlight
# User can click again or use arrow keys to restore
if table.cursor_type == "row":
table.cursor_type = "none"
else:
table.cursor_type = "row"
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Re-enable cursor when navigating with keys."""
table = event.data_table
if table.id == "lineup-table" and table.cursor_type == "none":
# Re-enable cursor when user navigates
table.cursor_type = "row"
# =========================================================================
# Actions
# =========================================================================
def action_sort_by_rating(self) -> None:
"""Sort matchup table by rating."""
self.current_sort = "rating"
if self.matchup_results:
self._apply_sort()
self._populate_matchup_table()
def action_sort_by_name(self) -> None:
"""Sort matchup table by name."""
self.current_sort = "name"
if self.matchup_results:
self._apply_sort()
self._populate_matchup_table()
async def action_add_to_lineup(self) -> None:
"""Add selected matchup batter to lineup."""
player = self._get_selected_matchup_player()
if not player:
self.notify("Select a batter from matchup results", severity="warning")
return
# Check if already in lineup
if any(slot.player and slot.player.id == player.id for slot in self.lineup_slots):
self.notify(f"{player.name} already in lineup", severity="warning")
return
slot = self._find_first_empty_slot()
if not slot:
self.notify("Lineup is full (9 batters)", severity="warning")
return
slot.player = player
slot.position = self._suggest_position(player)
added_slot_idx = slot.order - 1
self._populate_matchup_table() # Update to show * prefix
self._populate_lineup_table()
# Move lineup cursor to new player
lineup_table = self.query_one("#lineup-table", DataTable)
lineup_table.move_cursor(row=added_slot_idx)
self.notify(f"Added {player.name} at {slot.position}")
async def action_remove_from_lineup(self) -> None:
"""Remove selected player from lineup."""
lineup_table = self.query_one("#lineup-table", DataTable)
cursor_row = lineup_table.cursor_row
slot = self._get_selected_lineup_slot()
if not slot or not slot.player:
self.notify("Select a player in the lineup", severity="warning")
return
player_name = slot.player.name
slot.player = None
slot.position = None
self._populate_matchup_table() # Update to remove * prefix
self._populate_lineup_table()
if cursor_row is not None:
lineup_table.move_cursor(row=cursor_row)
self.notify(f"Removed {player_name}")
async def action_move_up(self) -> None:
"""Move selected slot up in batting order."""
slot = self._get_selected_lineup_slot()
if not slot:
return
idx = slot.order - 1
if idx <= 0:
return
self.lineup_slots[idx], self.lineup_slots[idx - 1] = (
self.lineup_slots[idx - 1],
self.lineup_slots[idx],
)
for i, s in enumerate(self.lineup_slots):
s.order = i + 1
self._populate_lineup_table()
self.query_one("#lineup-table", DataTable).move_cursor(row=idx - 1)
async def action_move_down(self) -> None:
"""Move selected slot down in batting order."""
slot = self._get_selected_lineup_slot()
if not slot:
return
idx = slot.order - 1
if idx >= 8:
return
self.lineup_slots[idx], self.lineup_slots[idx + 1] = (
self.lineup_slots[idx + 1],
self.lineup_slots[idx],
)
for i, s in enumerate(self.lineup_slots):
s.order = i + 1
self._populate_lineup_table()
self.query_one("#lineup-table", DataTable).move_cursor(row=idx + 1)
async def action_cycle_position(self) -> None:
"""Cycle through positions for selected player."""
lineup_table = self.query_one("#lineup-table", DataTable)
cursor_row = lineup_table.cursor_row
slot = self._get_selected_lineup_slot()
if not slot or not slot.player:
self.notify("Select a player in the lineup", severity="warning")
return
slot.position = self._get_next_position(slot.player, slot.position)
self._populate_lineup_table()
if cursor_row is not None:
lineup_table.move_cursor(row=cursor_row)
self.notify(f"{slot.player.name} -> {slot.position}")
async def action_save_lineup(self) -> None:
"""Save current lineup."""
name_input = self.query_one("#lineup-name-input", Input)
name = name_input.value.strip()
if not name:
self.notify("Enter a lineup name", severity="warning")
name_input.focus()
return
batting_order = []
positions = {}
for slot in self.lineup_slots:
if slot.player:
batting_order.append(slot.player.id)
if slot.position:
positions[slot.position] = slot.player.id
if not batting_order:
self.notify("Lineup is empty", severity="warning")
return
try:
async with get_session() as session:
await save_lineup(
session,
name=name,
batting_order=batting_order,
positions=positions,
lineup_type="gameday",
)
await session.commit()
self.current_lineup_name = name
await self._load_saved_lineups()
self.notify(f"Saved: {name}")
except Exception as e:
logger.error(f"Failed to save lineup: {e}")
self.notify(f"Error saving: {e}", severity="error")
async def _do_load_lineup(self, name: str) -> None:
"""Load a lineup by name."""
try:
async with get_session() as session:
lineup = await get_lineup_by_name(session, name)
if not lineup:
self.notify(f"Lineup '{name}' not found", severity="error")
return
self._init_lineup_slots()
player_lookup = {p.id: p for p in self.my_batters}
batting_order = lineup.batting_order or []
positions_map = lineup.positions or {}
player_positions = {v: k for k, v in positions_map.items()}
for i, player_id in enumerate(batting_order[:9]):
player = player_lookup.get(player_id)
if player and i < len(self.lineup_slots):
self.lineup_slots[i].player = player
self.lineup_slots[i].position = player_positions.get(player_id)
self.current_lineup_name = name
self.query_one("#lineup-name-input", Input).value = name
self._populate_matchup_table()
self._populate_lineup_table()
self.notify(f"Loaded: {name}")
except Exception as e:
logger.error(f"Failed to load lineup: {e}")
self.notify(f"Error loading: {e}", severity="error")
async def action_clear_lineup(self) -> None:
"""Clear the lineup."""
self._init_lineup_slots()
self._populate_matchup_table()
self._populate_lineup_table()
self.query_one("#lineup-name-input", Input).value = ""
self.current_lineup_name = ""
self.notify("Lineup cleared")

View File

@ -0,0 +1,640 @@
"""
Lineup Builder Screen - Set batting order and defensive positions.
Allows managers to:
- Select batters from their roster for the starting lineup
- Set batting order (1-9)
- Assign defensive positions based on eligibility
- Save/load named lineups (vs LHP, vs RHP, etc.)
"""
import logging
from dataclasses import dataclass, field
from typing import ClassVar, Optional
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, ScrollableContainer, Vertical
from textual.screen import Screen
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
Input,
Label,
Select,
Static,
)
from textual.widgets.data_table import RowKey
from ..config import get_settings
from ..db.models import Lineup, Player
from ..db.queries import delete_lineup, get_lineups, get_my_roster, save_lineup, get_lineup_by_name
from ..db.schema import get_session
logger = logging.getLogger(__name__)
# Defensive positions in typical batting order
DEFENSIVE_POSITIONS = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]
@dataclass
class LineupSlot:
"""A slot in the batting order with position assignment."""
order: int # 1-9
player: Optional[Player] = None
position: Optional[str] = None
@property
def display_name(self) -> str:
return self.player.name if self.player else "---"
@property
def display_position(self) -> str:
return self.position or "---"
class LineupScreen(Screen):
"""Lineup builder for setting batting order and positions."""
BINDINGS: ClassVar = [
Binding("escape", "app.pop_screen", "Back"),
Binding("q", "app.pop_screen", "Back"),
Binding("a", "add_to_lineup", "Add to Lineup"),
Binding("r", "remove_from_lineup", "Remove"),
Binding("k", "move_up", "Move Up"),
Binding("j", "move_down", "Move Down"),
Binding("p", "cycle_position", "Change Position"),
Binding("s", "save_lineup", "Save"),
Binding("l", "load_lineup", "Load"),
Binding("d", "delete_lineup", "Delete Saved"),
Binding("c", "clear_lineup", "Clear"),
]
# Current lineup state
lineup_slots: list[LineupSlot] = []
available_batters: list[Player] = []
saved_lineups: list[Lineup] = []
current_lineup_name: str = ""
# Track which table has focus for actions
_focus_table: str = "available" # "available" or "lineup"
def compose(self) -> ComposeResult:
"""Compose the lineup builder layout."""
yield Header()
with Horizontal(id="lineup-container"):
# Left panel - Available batters
with Vertical(id="available-panel", classes="lineup-panel"):
yield Label("Available Batters", classes="panel-title")
with ScrollableContainer(id="available-container"):
yield DataTable(
id="available-table",
cursor_type="row",
zebra_stripes=True,
)
yield Static("[a] Add to lineup", classes="hint-text")
# Right panel - Current lineup
with Vertical(id="lineup-panel", classes="lineup-panel"):
yield Label("Current Lineup", classes="panel-title")
with ScrollableContainer(id="lineup-scroll-container"):
yield DataTable(
id="lineup-table",
cursor_type="row",
zebra_stripes=True,
)
yield Static("[k/j] Move Up/Down [p] Change Pos [r] Remove", classes="hint-text")
# Bottom controls
with Horizontal(id="lineup-controls"):
yield Label("Name:")
yield Input(
placeholder="Lineup name (e.g., 'vs LHP')",
id="lineup-name-input",
)
yield Select(
options=[],
prompt="Load saved...",
id="lineup-select",
allow_blank=True,
)
yield Button("Save [s]", id="btn-save", variant="success")
yield Button("Clear [c]", id="btn-clear", variant="warning")
yield Footer()
async def on_mount(self) -> None:
"""Initialize when screen mounts."""
self._init_lineup_slots()
await self._setup_tables()
await self._load_available_batters()
await self._load_saved_lineups()
def _init_lineup_slots(self) -> None:
"""Initialize empty lineup slots for 9 batters."""
self.lineup_slots = [LineupSlot(order=i) for i in range(1, 10)]
async def _setup_tables(self) -> None:
"""Configure both data tables."""
# Available batters table
avail_table = self.query_one("#available-table", DataTable)
avail_table.add_column("Name", key="name", width=20)
avail_table.add_column("Pos", key="pos", width=14)
avail_table.add_column("H", key="hand", width=3)
avail_table.add_column("vL", key="vl", width=5)
avail_table.add_column("vR", key="vr", width=5)
avail_table.add_column("sWAR", key="swar", width=6)
# Lineup table
lineup_table = self.query_one("#lineup-table", DataTable)
lineup_table.add_column("#", key="order", width=3)
lineup_table.add_column("Name", key="name", width=20)
lineup_table.add_column("Pos", key="pos", width=5)
lineup_table.add_column("Elig", key="elig", width=14)
lineup_table.add_column("H", key="hand", width=3)
lineup_table.add_column("sWAR", key="swar", width=6)
async def _load_available_batters(self) -> None:
"""Load batters from roster."""
settings = get_settings()
try:
async with get_session() as session:
roster = await get_my_roster(
session,
settings.team.team_abbrev,
settings.team.current_season,
)
# Only major league batters
self.available_batters = [p for p in roster.get("majors", []) if p.is_batter]
self._populate_available_table()
except Exception as e:
logger.error(f"Failed to load batters: {e}")
self.notify(f"Error loading batters: {e}", severity="error")
async def _load_saved_lineups(self) -> None:
"""Load saved lineups for the dropdown."""
try:
async with get_session() as session:
self.saved_lineups = list(await get_lineups(session))
# Populate select
lineup_select = self.query_one("#lineup-select", Select)
options = [(f"{lu.name} ({lu.lineup_type})", lu.name) for lu in self.saved_lineups]
lineup_select.set_options(options)
except Exception as e:
logger.error(f"Failed to load saved lineups: {e}")
def _populate_available_table(self) -> None:
"""Populate the available batters table."""
table = self.query_one("#available-table", DataTable)
table.clear()
# Get players already in lineup
lineup_player_ids = {slot.player.id for slot in self.lineup_slots if slot.player}
# Sort by name
batters = sorted(self.available_batters, key=lambda p: p.name)
for player in batters:
# Skip if already in lineup
if player.id in lineup_player_ids:
continue
positions = ", ".join(player.positions)
hand = player.hand or "?"
# Get ratings from card
vl = "---"
vr = "---"
if player.batter_card:
vl = (
f"{player.batter_card.rating_vl:.0f}" if player.batter_card.rating_vl else "---"
)
vr = (
f"{player.batter_card.rating_vr:.0f}" if player.batter_card.rating_vr else "---"
)
row = (
player.name,
positions,
hand,
vl,
vr,
f"{player.swar:.2f}" if player.swar else "0.00",
)
table.add_row(*row, key=str(player.id))
def _populate_lineup_table(self) -> None:
"""Populate the current lineup table."""
table = self.query_one("#lineup-table", DataTable)
table.clear()
for slot in self.lineup_slots:
if slot.player:
positions = ", ".join(slot.player.positions)
hand = slot.player.hand or "?"
row = (
str(slot.order),
slot.player.name,
slot.display_position,
positions,
hand,
f"{slot.player.swar:.2f}" if slot.player.swar else "0.00",
)
else:
row = (
str(slot.order),
"---",
"---",
"---",
"-",
"---",
)
table.add_row(*row, key=f"slot-{slot.order}")
def _get_selected_available_player(self) -> Optional[Player]:
"""Get the currently selected player from the available table."""
table = self.query_one("#available-table", DataTable)
# Get the cursor row index
cursor_row = table.cursor_row
if cursor_row is None:
return None
try:
# Get all row keys in order
row_keys = list(table.rows.keys())
if cursor_row >= len(row_keys):
return None
# RowKey objects have a .value attribute containing the actual key
row_key = row_keys[cursor_row]
player_id_str = row_key.value # This is the string we passed to add_row(key=...)
player_id = int(player_id_str)
return next((p for p in self.available_batters if p.id == player_id), None)
except (IndexError, ValueError, KeyError, AttributeError) as e:
logger.error(f"Failed to get selected player: {e}")
return None
def _get_selected_lineup_slot(self) -> Optional[LineupSlot]:
"""Get the currently selected lineup slot."""
table = self.query_one("#lineup-table", DataTable)
if table.cursor_row is None:
return None
if 0 <= table.cursor_row < len(self.lineup_slots):
return self.lineup_slots[table.cursor_row]
return None
def _find_first_empty_slot(self) -> Optional[LineupSlot]:
"""Find the first empty slot in the lineup."""
for slot in self.lineup_slots:
if slot.player is None:
return slot
return None
def _suggest_position(self, player: Player) -> str:
"""Suggest the best available position for a player."""
positions = player.positions
# Positions already filled in lineup
filled_positions = {
slot.position for slot in self.lineup_slots if slot.position and slot.player
}
# Find first eligible position not yet filled
for pos in positions:
if pos in DEFENSIVE_POSITIONS and pos not in filled_positions:
return pos
# Default to DH if nothing else available
if "DH" not in filled_positions:
return "DH"
# Just use first position if all else fails
return positions[0] if positions else "DH"
def _get_next_position(self, player: Player, current: Optional[str]) -> str:
"""Get next valid position for cycling."""
positions = player.positions
# Add DH if not already there
if "DH" not in positions:
positions = list(positions) + ["DH"]
if not current or current not in positions:
return positions[0] if positions else "DH"
# Find next position in cycle
idx = positions.index(current)
return positions[(idx + 1) % len(positions)]
# =========================================================================
# Event Handlers
# =========================================================================
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Track which table has focus."""
if event.data_table.id == "available-table":
self._focus_table = "available"
elif event.data_table.id == "lineup-table":
self._focus_table = "lineup"
async def on_select_changed(self, event: Select.Changed) -> None:
"""Handle lineup selection from dropdown."""
if event.select.id == "lineup-select":
if event.value and event.value != Select.BLANK:
await self._do_load_lineup(event.value)
async def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks."""
if event.button.id == "btn-save":
await self.action_save_lineup()
elif event.button.id == "btn-clear":
await self.action_clear_lineup()
# =========================================================================
# Actions
# =========================================================================
async def action_add_to_lineup(self) -> None:
"""Add selected available batter to lineup."""
player = self._get_selected_available_player()
if not player:
self.notify("Select a player from available batters", severity="warning")
return
slot = self._find_first_empty_slot()
if not slot:
self.notify("Lineup is full (9 batters)", severity="warning")
return
# Add player with suggested position
slot.player = player
slot.position = self._suggest_position(player)
# Remember which slot the player was added to (0-indexed)
added_slot_idx = slot.order - 1
# Refresh both tables
self._populate_available_table()
self._populate_lineup_table()
# Reset available batters cursor to top
avail_table = self.query_one("#available-table", DataTable)
if len(list(avail_table.rows)) > 0:
avail_table.move_cursor(row=0)
# Move lineup cursor to the newly added player and focus that table
lineup_table = self.query_one("#lineup-table", DataTable)
lineup_table.move_cursor(row=added_slot_idx)
lineup_table.focus()
self.notify(f"Added {player.name} at {slot.position}")
async def action_remove_from_lineup(self) -> None:
"""Remove selected player from lineup."""
lineup_table = self.query_one("#lineup-table", DataTable)
cursor_row = lineup_table.cursor_row # Save cursor position
slot = self._get_selected_lineup_slot()
if not slot or not slot.player:
self.notify("Select a player in the lineup to remove", severity="warning")
return
player_name = slot.player.name
slot.player = None
slot.position = None
# Refresh both tables
self._populate_available_table()
self._populate_lineup_table()
# Restore cursor position
if cursor_row is not None:
lineup_table.move_cursor(row=cursor_row)
self.notify(f"Removed {player_name}")
async def action_move_up(self) -> None:
"""Move selected lineup slot up in batting order."""
slot = self._get_selected_lineup_slot()
if not slot:
return
idx = slot.order - 1 # 0-indexed
if idx <= 0:
self.notify("Already at top of order")
return
# Swap with slot above
self.lineup_slots[idx], self.lineup_slots[idx - 1] = (
self.lineup_slots[idx - 1],
self.lineup_slots[idx],
)
# Update order numbers
for i, s in enumerate(self.lineup_slots):
s.order = i + 1
# Refresh and move cursor
self._populate_lineup_table()
table = self.query_one("#lineup-table", DataTable)
table.move_cursor(row=idx - 1)
async def action_move_down(self) -> None:
"""Move selected lineup slot down in batting order."""
slot = self._get_selected_lineup_slot()
if not slot:
return
idx = slot.order - 1 # 0-indexed
if idx >= 8: # Already at position 9
self.notify("Already at bottom of order")
return
# Swap with slot below
self.lineup_slots[idx], self.lineup_slots[idx + 1] = (
self.lineup_slots[idx + 1],
self.lineup_slots[idx],
)
# Update order numbers
for i, s in enumerate(self.lineup_slots):
s.order = i + 1
# Refresh and move cursor
self._populate_lineup_table()
table = self.query_one("#lineup-table", DataTable)
table.move_cursor(row=idx + 1)
async def action_cycle_position(self) -> None:
"""Cycle through available positions for selected player."""
table = self.query_one("#lineup-table", DataTable)
cursor_row = table.cursor_row # Save cursor position
slot = self._get_selected_lineup_slot()
if not slot or not slot.player:
self.notify("Select a player in the lineup", severity="warning")
return
slot.position = self._get_next_position(slot.player, slot.position)
self._populate_lineup_table()
# Restore cursor position
if cursor_row is not None:
table.move_cursor(row=cursor_row)
self.notify(f"{slot.player.name} -> {slot.position}")
async def action_save_lineup(self) -> None:
"""Save current lineup."""
name_input = self.query_one("#lineup-name-input", Input)
name = name_input.value.strip()
if not name:
self.notify("Enter a lineup name", severity="warning")
name_input.focus()
return
# Build batting order and positions
batting_order = []
positions = {}
for slot in self.lineup_slots:
if slot.player:
batting_order.append(slot.player.id)
if slot.position:
positions[slot.position] = slot.player.id
if not batting_order:
self.notify("Lineup is empty", severity="warning")
return
try:
async with get_session() as session:
await save_lineup(
session,
name=name,
batting_order=batting_order,
positions=positions,
lineup_type="standard",
description=f"Saved lineup with {len(batting_order)} batters",
)
await session.commit()
self.current_lineup_name = name
await self._load_saved_lineups() # Refresh dropdown
self.notify(f"Saved lineup: {name}", severity="information")
except Exception as e:
logger.error(f"Failed to save lineup: {e}")
self.notify(f"Error saving lineup: {e}", severity="error")
async def action_load_lineup(self) -> None:
"""Focus the lineup dropdown for selection."""
self.query_one("#lineup-select", Select).focus()
async def _do_load_lineup(self, name: str) -> None:
"""Load a specific lineup by name."""
try:
async with get_session() as session:
lineup = await get_lineup_by_name(session, name)
if not lineup:
self.notify(f"Lineup '{name}' not found", severity="error")
return
# Reset slots
self._init_lineup_slots()
# Build player lookup
player_lookup = {p.id: p for p in self.available_batters}
# Load batting order
batting_order = lineup.batting_order or []
positions_map = lineup.positions or {}
# Reverse map: player_id -> position
player_positions = {v: k for k, v in positions_map.items()}
for i, player_id in enumerate(batting_order[:9]):
player = player_lookup.get(player_id)
if player and i < len(self.lineup_slots):
self.lineup_slots[i].player = player
self.lineup_slots[i].position = player_positions.get(player_id)
# Update UI
self.current_lineup_name = name
name_input = self.query_one("#lineup-name-input", Input)
name_input.value = name
self._populate_available_table()
self._populate_lineup_table()
self.notify(f"Loaded lineup: {name}")
except Exception as e:
logger.error(f"Failed to load lineup: {e}")
self.notify(f"Error loading lineup: {e}", severity="error")
async def action_clear_lineup(self) -> None:
"""Clear all slots in the current lineup."""
self._init_lineup_slots()
self._populate_available_table()
self._populate_lineup_table()
# Clear name input
name_input = self.query_one("#lineup-name-input", Input)
name_input.value = ""
self.current_lineup_name = ""
self.notify("Lineup cleared")
async def action_delete_lineup(self) -> None:
"""Delete the currently loaded lineup from the database."""
name_input = self.query_one("#lineup-name-input", Input)
name = name_input.value.strip()
if not name:
self.notify("Enter or load a lineup name to delete", severity="warning")
return
# Check if this lineup exists
lineup_exists = any(lu.name == name for lu in self.saved_lineups)
if not lineup_exists:
self.notify(f"Lineup '{name}' not found in saved lineups", severity="warning")
return
try:
async with get_session() as session:
deleted = await delete_lineup(session, name)
await session.commit()
if deleted:
# Clear current lineup if it was the one deleted
if self.current_lineup_name == name:
self._init_lineup_slots()
self._populate_available_table()
self._populate_lineup_table()
self.current_lineup_name = ""
name_input.value = ""
await self._load_saved_lineups() # Refresh dropdown
self.notify(f"Deleted lineup: {name}", severity="information")
else:
self.notify(f"Lineup '{name}' not found", severity="error")
except Exception as e:
logger.error(f"Failed to delete lineup: {e}")
self.notify(f"Error deleting lineup: {e}", severity="error")

View File

@ -0,0 +1,341 @@
"""
Matchup Scout Screen - Analyze batters vs opposing pitchers.
Core scouting feature that helps set optimal lineups based on
batter/pitcher handedness matchups.
"""
import logging
from typing import ClassVar
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, ScrollableContainer, Vertical
from textual.screen import Screen
from textual.widgets import (
DataTable,
Footer,
Header,
Label,
Select,
Static,
)
from ..calc.matchup import MatchupResult, calculate_team_matchups_cached
from ..calc.score_cache import ensure_cache_exists
from ..config import get_settings
from ..db.models import Player, Team
from ..db.queries import get_all_teams, get_my_roster, get_pitchers, get_team_by_id
from ..db.schema import get_session
logger = logging.getLogger(__name__)
class MatchupScreen(Screen):
"""Matchup scouting screen for analyzing batters vs pitchers."""
BINDINGS: ClassVar = [
Binding("escape", "app.pop_screen", "Back"),
Binding("q", "app.pop_screen", "Back"),
Binding("r", "refresh_matchups", "Refresh"),
Binding("s", "sort_by_rating", "Sort: Rating", show=True),
Binding("n", "sort_by_name", "Sort: Name", show=True),
Binding("p", "sort_by_position", "Sort: Position", show=True),
]
# Current state
teams: list[Team] = []
opponent_pitchers: list[Player] = []
my_batters: list[Player] = []
selected_team_id: int | None = None
selected_pitcher: Player | None = None
matchup_results: list[MatchupResult] = []
current_sort: str = "rating" # "rating", "name", or "position"
def compose(self) -> ComposeResult:
"""Compose the matchup scout layout."""
yield Header()
with Vertical(id="matchup-container"):
# Selection row
with Horizontal(id="matchup-selectors"):
yield Label("Opponent:", id="opponent-label")
yield Select(
options=[],
prompt="Select Team",
id="team-select",
allow_blank=True,
)
yield Label("Pitcher:", id="pitcher-label")
yield Select(
options=[],
prompt="Select Pitcher",
id="pitcher-select",
allow_blank=True,
)
# Pitcher info header
yield Static("Select an opponent team and pitcher", id="pitcher-info")
# Matchup table
with ScrollableContainer(id="matchup-table-container"):
yield DataTable(id="matchup-table", cursor_type="none", zebra_stripes=True)
yield Footer()
async def on_mount(self) -> None:
"""Load initial data when screen mounts."""
await self._setup_table()
await self._ensure_score_cache()
await self._load_teams()
await self._load_my_batters()
async def _ensure_score_cache(self) -> None:
"""Ensure standardized score cache exists, building if necessary."""
try:
async with get_session() as session:
await ensure_cache_exists(session)
except Exception as e:
logger.error(f"Failed to ensure score cache: {e}")
self.notify(f"Error building score cache: {e}", severity="error")
async def _setup_table(self) -> None:
"""Configure the matchup data table columns."""
table = self.query_one("#matchup-table", DataTable)
# Columns: suggested order, name, hand, positions, rating, tier, sWAR
columns = [
("#", 3, ""), # Suggested batting order position
("Name", 20, ""),
("H", 3, ""), # Batter's hand
("Pos", 14, ""), # Positions
("Rating", 8, ""), # Matchup rating
("Tier", 5, ""), # Letter grade
("sWAR", 6, ""),
("Split", 5, ""), # Which split was used (vL/vR)
]
for name, width, prefix in columns:
label = f"{prefix}{name}"
table.add_column(label, key=name.lower(), width=width)
async def _load_teams(self) -> None:
"""Load all teams for the opponent selector."""
settings = get_settings()
try:
async with get_session() as session:
self.teams = list(
await get_all_teams(session, settings.team.current_season, active_only=True)
)
# Populate team select - exclude my team
team_select = self.query_one("#team-select", Select)
options = [
(f"{t.abbrev} - {t.short_name}", t.id)
for t in self.teams
if t.abbrev != settings.team.team_abbrev
]
team_select.set_options(options)
except Exception as e:
logger.error(f"Failed to load teams: {e}")
self.notify(f"Error loading teams: {e}", severity="error")
async def _load_my_batters(self) -> None:
"""Load my team's batters for matchup analysis."""
settings = get_settings()
try:
async with get_session() as session:
roster = await get_my_roster(
session,
settings.team.team_abbrev,
settings.team.current_season,
)
# Only include major league batters
self.my_batters = [p for p in roster.get("majors", []) if p.is_batter]
except Exception as e:
logger.error(f"Failed to load batters: {e}")
self.notify(f"Error loading batters: {e}", severity="error")
async def _load_opponent_pitchers(self, team_id: int) -> None:
"""Load pitchers for the selected opponent team."""
settings = get_settings()
try:
async with get_session() as session:
self.opponent_pitchers = list(
await get_pitchers(
session, team_id=team_id, season=settings.team.current_season
)
)
# Populate pitcher select
pitcher_select = self.query_one("#pitcher-select", Select)
options = [(f"{p.name} ({p.hand or '?'}HP)", p.id) for p in self.opponent_pitchers]
pitcher_select.set_options(options)
# Clear current pitcher selection
self.selected_pitcher = None
self.query_one("#pitcher-info", Static).update("Select a pitcher to see matchups")
self._clear_table()
except Exception as e:
logger.error(f"Failed to load pitchers: {e}")
self.notify(f"Error loading pitchers: {e}", severity="error")
def _clear_table(self) -> None:
"""Clear the matchup table."""
table = self.query_one("#matchup-table", DataTable)
table.clear()
self.matchup_results = []
async def _calculate_and_display_matchups(self) -> None:
"""Calculate matchups and populate the table."""
if not self.selected_pitcher:
return
pitcher_hand = self.selected_pitcher.hand or "R"
if pitcher_hand not in ("L", "R"):
pitcher_hand = "R" # Default to R if unknown
# Get pitcher's card data
pitcher_card = self.selected_pitcher.pitcher_card
# Calculate matchups using cached scores
async with get_session() as session:
self.matchup_results = await calculate_team_matchups_cached(
session,
self.my_batters,
self.selected_pitcher,
pitcher_card,
)
# Apply current sort
self._apply_sort()
# Update pitcher info - show if pitcher has card data
pitcher_info = self.query_one("#pitcher-info", Static)
positions = ", ".join(self.selected_pitcher.positions)
card_status = "" if pitcher_card else " [no card]"
pitcher_info.update(
f"vs {self.selected_pitcher.name} ({pitcher_hand}HP) - {positions}{card_status}"
)
# Populate table
self._populate_table()
def _apply_sort(self) -> None:
"""Sort matchup results based on current sort mode."""
if self.current_sort == "rating":
# Sort by rating descending (None at end)
def rating_key(r: MatchupResult) -> tuple[int, float]:
if r.rating is None:
return (1, 0)
return (0, -r.rating)
self.matchup_results.sort(key=rating_key)
elif self.current_sort == "name":
self.matchup_results.sort(key=lambda r: r.player.name)
elif self.current_sort == "position":
# Sort by primary position, then name
def pos_key(r: MatchupResult) -> tuple[str, str]:
positions = r.player.positions
primary = positions[0] if positions else "ZZ"
return (primary, r.player.name)
self.matchup_results.sort(key=pos_key)
def _populate_table(self) -> None:
"""Populate the table with matchup results."""
table = self.query_one("#matchup-table", DataTable)
table.clear()
for i, result in enumerate(self.matchup_results, start=1):
player = result.player
positions = ", ".join(player.positions)
row = (
str(i), # Suggested order
player.name,
result.batter_hand,
positions,
result.rating_display,
result.tier,
f"{player.swar:.2f}" if player.swar else "0.00",
result.split_display, # e.g., "vR/vL" showing batter/pitcher splits
)
table.add_row(*row, key=str(player.id))
# =========================================================================
# Event Handlers
# =========================================================================
async def on_select_changed(self, event: Select.Changed) -> None:
"""Handle selection changes in dropdowns."""
if event.select.id == "team-select":
if event.value and event.value != Select.BLANK:
self.selected_team_id = event.value
await self._load_opponent_pitchers(event.value)
else:
self.selected_team_id = None
# Clear pitcher select
pitcher_select = self.query_one("#pitcher-select", Select)
pitcher_select.set_options([])
self._clear_table()
self.query_one("#pitcher-info", Static).update(
"Select an opponent team and pitcher"
)
elif event.select.id == "pitcher-select":
if event.value and event.value != Select.BLANK:
# Find the selected pitcher
self.selected_pitcher = next(
(p for p in self.opponent_pitchers if p.id == event.value),
None,
)
await self._calculate_and_display_matchups()
else:
self.selected_pitcher = None
self._clear_table()
self.query_one("#pitcher-info", Static).update("Select a pitcher to see matchups")
# =========================================================================
# Actions
# =========================================================================
async def action_refresh_matchups(self) -> None:
"""Refresh matchup calculations."""
if self.selected_pitcher:
await self._calculate_and_display_matchups()
self.notify("Matchups refreshed", severity="information")
else:
self.notify("Select a pitcher first", severity="warning")
def action_sort_by_rating(self) -> None:
"""Sort table by matchup rating."""
self.current_sort = "rating"
if self.matchup_results:
self._apply_sort()
self._populate_table()
self.notify("Sorted by rating")
def action_sort_by_name(self) -> None:
"""Sort table by player name."""
self.current_sort = "name"
if self.matchup_results:
self._apply_sort()
self._populate_table()
self.notify("Sorted by name")
def action_sort_by_position(self) -> None:
"""Sort table by position."""
self.current_sort = "position"
if self.matchup_results:
self._apply_sort()
self._populate_table()
self.notify("Sorted by position")

View File

@ -135,7 +135,7 @@ class RosterScreen(Screen):
roster = await get_my_roster(
session,
settings.team.team_abbrev,
13, # TODO: Get current season from config/API
settings.team.current_season,
)
# Calculate sWAR totals