mound 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mound/__init__.py +14 -0
- mound/analysis.py +72 -0
- mound/cli.py +182 -0
- mound/config.py +28 -0
- mound/export.py +96 -0
- mound/http.py +41 -0
- mound/models.py +192 -0
- mound/pitches.py +267 -0
- mound/players.py +115 -0
- mound/savant.py +45 -0
- mound/statsapi.py +90 -0
- mound/viz.py +320 -0
- mound/zone.py +41 -0
- mound-0.1.0.dist-info/METADATA +193 -0
- mound-0.1.0.dist-info/RECORD +18 -0
- mound-0.1.0.dist-info/WHEEL +4 -0
- mound-0.1.0.dist-info/entry_points.txt +2 -0
- mound-0.1.0.dist-info/licenses/LICENSE +21 -0
mound/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Mound: a CLI and Python toolkit for MLB pitch-level data."""
|
|
2
|
+
|
|
3
|
+
from mound.pitches import PitchCollection, Pitcher
|
|
4
|
+
from mound.players import AmbiguousPlayerError, Player, PlayerNotFoundError
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Pitcher",
|
|
10
|
+
"PitchCollection",
|
|
11
|
+
"Player",
|
|
12
|
+
"PlayerNotFoundError",
|
|
13
|
+
"AmbiguousPlayerError",
|
|
14
|
+
]
|
mound/analysis.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Pitch usage and outcome calculations.
|
|
2
|
+
|
|
3
|
+
Kept intentionally small for the prototype -- pitch mix and strike rate --
|
|
4
|
+
but structured so additional Statcast metrics (whiff rate, chase rate, exit
|
|
5
|
+
velocity, etc.) can be added as more functions over the same
|
|
6
|
+
:class:`~mound.pitches.PitchCollection` shape.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from mound.pitches import PitchCollection
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pitch_mix(collection: PitchCollection) -> pd.Series:
|
|
20
|
+
"""Percentage of pitches thrown, by pitch type, sorted descending.
|
|
21
|
+
|
|
22
|
+
>>> roki.pitches(last=4).pitch_mix()
|
|
23
|
+
splitter 31.2
|
|
24
|
+
four-seam fastball 28.4
|
|
25
|
+
slider 19.1
|
|
26
|
+
...
|
|
27
|
+
"""
|
|
28
|
+
df = collection.to_frame()
|
|
29
|
+
if df.empty:
|
|
30
|
+
return pd.Series(dtype=float, name="pitch_mix")
|
|
31
|
+
|
|
32
|
+
counts = df["pitch_type"].value_counts()
|
|
33
|
+
pct = (counts / counts.sum() * 100).round(1)
|
|
34
|
+
pct.name = "pitch_mix"
|
|
35
|
+
pct.index.name = "pitch_type"
|
|
36
|
+
return pct
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def strike_rate(collection: PitchCollection, by_pitch_type: bool = False) -> float | pd.Series:
|
|
40
|
+
"""Strike rate (percentage of pitches that were strikes).
|
|
41
|
+
|
|
42
|
+
With ``by_pitch_type=True``, returns a :class:`pandas.Series` broken out
|
|
43
|
+
per pitch type instead of a single overall rate.
|
|
44
|
+
"""
|
|
45
|
+
df = collection.to_frame()
|
|
46
|
+
if df.empty:
|
|
47
|
+
return pd.Series(dtype=float, name="strike_rate") if by_pitch_type else float("nan")
|
|
48
|
+
|
|
49
|
+
if by_pitch_type:
|
|
50
|
+
rates = df.groupby("pitch_type")["is_strike"].mean() * 100
|
|
51
|
+
rates = rates.round(1).sort_values(ascending=False)
|
|
52
|
+
rates.name = "strike_rate"
|
|
53
|
+
return rates
|
|
54
|
+
|
|
55
|
+
return round(df["is_strike"].mean() * 100, 1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def usage_rate(collection: PitchCollection, by: str = "game_date") -> pd.DataFrame:
|
|
59
|
+
"""Pitch usage percentage by pitch type, grouped by ``by`` (e.g. per game or date).
|
|
60
|
+
|
|
61
|
+
Useful for comparing how a pitch's usage has changed across outings,
|
|
62
|
+
e.g. ``roki.pitches(last=8).usage_rate(by="game_date")``.
|
|
63
|
+
"""
|
|
64
|
+
df = collection.to_frame()
|
|
65
|
+
if df.empty:
|
|
66
|
+
return pd.DataFrame()
|
|
67
|
+
|
|
68
|
+
counts = df.groupby([by, "pitch_type"]).size().rename("count")
|
|
69
|
+
totals = df.groupby(by).size().rename("total")
|
|
70
|
+
result = counts.reset_index().merge(totals.reset_index(), on=by)
|
|
71
|
+
result["usage_rate"] = (result["count"] / result["total"] * 100).round(1)
|
|
72
|
+
return result.pivot(index=by, columns="pitch_type", values="usage_rate").fillna(0.0)
|
mound/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Mound's command-line interface.
|
|
2
|
+
|
|
3
|
+
mound search "Roki Sasaki"
|
|
4
|
+
mound pitches "Roki Sasaki" --last 4 --pitch splitter
|
|
5
|
+
mound mix "Roki Sasaki" --last 4
|
|
6
|
+
mound results "Roki Sasaki" --last 4 --pitch splitter
|
|
7
|
+
mound zone "Roki Sasaki" --pitch splitter --last 4 --out zone.png
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from mound.pitches import PitchCollection, Pitcher
|
|
16
|
+
from mound.players import AmbiguousPlayerError, PlayerNotFoundError
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="mound",
|
|
20
|
+
help="Retrieve, analyze and visualize MLB pitch-level data.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fail(message: str) -> None:
|
|
26
|
+
typer.secho(message, fg=typer.colors.RED, err=True)
|
|
27
|
+
raise typer.Exit(code=1)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_pitches(
|
|
31
|
+
name: str,
|
|
32
|
+
last: int | None,
|
|
33
|
+
since: str | None,
|
|
34
|
+
until: str | None,
|
|
35
|
+
game: int | None,
|
|
36
|
+
pitch: str | None,
|
|
37
|
+
) -> PitchCollection:
|
|
38
|
+
try:
|
|
39
|
+
pitcher = Pitcher(name)
|
|
40
|
+
except PlayerNotFoundError as exc:
|
|
41
|
+
_fail(str(exc))
|
|
42
|
+
except AmbiguousPlayerError as exc:
|
|
43
|
+
_fail(str(exc))
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
return pitcher.pitches(last=last, since=since, until=until, game=game, pitch_type=pitch)
|
|
47
|
+
except Exception as exc: # surface retrieval failures without a traceback
|
|
48
|
+
_fail(f"Failed to retrieve pitches for {pitcher.name}: {exc}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def search(
|
|
53
|
+
name: str = typer.Argument(..., help="Player name to search for, e.g. 'Roki Sasaki'"),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Find a player's MLB ID and basic info by name."""
|
|
56
|
+
from mound.players import search_players
|
|
57
|
+
|
|
58
|
+
matches = search_players(name)
|
|
59
|
+
if not matches:
|
|
60
|
+
_fail(f"No player found matching '{name}'")
|
|
61
|
+
|
|
62
|
+
for player in matches:
|
|
63
|
+
role = player.primary_position or "?"
|
|
64
|
+
team = player.team_name or "no current team"
|
|
65
|
+
typer.echo(f"{player.id}\t{player.full_name}\t{role}\t{team}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def pitches(
|
|
70
|
+
name: str = typer.Argument(..., help="Pitcher name or MLB player ID"),
|
|
71
|
+
last: int | None = typer.Option(None, "--last", help="Most recent N appearances"),
|
|
72
|
+
since: str | None = typer.Option(None, "--since", help="Start date (YYYY-MM-DD)"),
|
|
73
|
+
until: str | None = typer.Option(None, "--until", help="End date (YYYY-MM-DD)"),
|
|
74
|
+
game: int | None = typer.Option(None, "--game", help="A specific MLB game_pk"),
|
|
75
|
+
pitch: str | None = typer.Option(None, "--pitch", help="Pitch type, e.g. 'splitter'"),
|
|
76
|
+
export_path: str | None = typer.Option(None, "--export", help="Path to export results to"),
|
|
77
|
+
export_format: str | None = typer.Option(
|
|
78
|
+
None, "--format", help="Export format (csv/json/parquet); inferred from --export if omitted"
|
|
79
|
+
),
|
|
80
|
+
limit: int | None = typer.Option(20, "--limit", help="Rows to print (use 0 for all)"),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Retrieve individual pitch records for a pitcher."""
|
|
83
|
+
collection = _get_pitches(name, last, since, until, game, pitch)
|
|
84
|
+
|
|
85
|
+
if collection.empty:
|
|
86
|
+
typer.echo("No pitches found for the given filters.")
|
|
87
|
+
else:
|
|
88
|
+
df = collection.to_frame()
|
|
89
|
+
display_cols = [
|
|
90
|
+
"game_date",
|
|
91
|
+
"inning",
|
|
92
|
+
"pitch_type",
|
|
93
|
+
"velocity",
|
|
94
|
+
"pitch_call",
|
|
95
|
+
"at_bat_result",
|
|
96
|
+
]
|
|
97
|
+
display_df = df[display_cols]
|
|
98
|
+
if limit:
|
|
99
|
+
display_df = display_df.head(limit)
|
|
100
|
+
typer.echo(display_df.to_string(index=False))
|
|
101
|
+
typer.echo(f"\n{len(collection)} pitch(es) total.")
|
|
102
|
+
|
|
103
|
+
if export_path:
|
|
104
|
+
collection.export(export_path, format=export_format)
|
|
105
|
+
typer.echo(f"Exported {len(collection)} pitch(es) to {export_path}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def mix(
|
|
110
|
+
name: str = typer.Argument(..., help="Pitcher name or MLB player ID"),
|
|
111
|
+
last: int | None = typer.Option(None, "--last", help="Most recent N appearances"),
|
|
112
|
+
since: str | None = typer.Option(None, "--since", help="Start date (YYYY-MM-DD)"),
|
|
113
|
+
until: str | None = typer.Option(None, "--until", help="End date (YYYY-MM-DD)"),
|
|
114
|
+
game: int | None = typer.Option(None, "--game", help="A specific MLB game_pk"),
|
|
115
|
+
pitch: str | None = typer.Option(None, "--pitch", help="Pitch type, e.g. 'splitter'"),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Calculate a pitcher's pitch mix (usage percentage by pitch type)."""
|
|
118
|
+
collection = _get_pitches(name, last, since, until, game, pitch)
|
|
119
|
+
|
|
120
|
+
if collection.empty:
|
|
121
|
+
typer.echo("No pitches found for the given filters.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
mix_series = collection.pitch_mix()
|
|
125
|
+
for pitch_type, pct in mix_series.items():
|
|
126
|
+
typer.echo(f"{pitch_type:<24} {pct:>5.1f}%")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def results(
|
|
131
|
+
name: str = typer.Argument(..., help="Pitcher name or MLB player ID"),
|
|
132
|
+
last: int | None = typer.Option(None, "--last", help="Most recent N appearances"),
|
|
133
|
+
since: str | None = typer.Option(None, "--since", help="Start date (YYYY-MM-DD)"),
|
|
134
|
+
until: str | None = typer.Option(None, "--until", help="End date (YYYY-MM-DD)"),
|
|
135
|
+
game: int | None = typer.Option(None, "--game", help="A specific MLB game_pk"),
|
|
136
|
+
pitch: str | None = typer.Option(None, "--pitch", help="Pitch type, e.g. 'splitter'"),
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Show pitch counts, strikes/balls and strike rate, broken out by pitch type."""
|
|
139
|
+
collection = _get_pitches(name, last, since, until, game, pitch)
|
|
140
|
+
|
|
141
|
+
if collection.empty:
|
|
142
|
+
typer.echo("No pitches found for the given filters.")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
df = collection.to_frame()
|
|
146
|
+
summary = df.groupby("pitch_type").agg(
|
|
147
|
+
pitches=("pitch_type", "count"),
|
|
148
|
+
strikes=("is_strike", "sum"),
|
|
149
|
+
)
|
|
150
|
+
summary["balls"] = summary["pitches"] - summary["strikes"]
|
|
151
|
+
summary["strike_rate"] = (summary["strikes"] / summary["pitches"] * 100).round(1)
|
|
152
|
+
summary["usage_rate"] = (summary["pitches"] / summary["pitches"].sum() * 100).round(1)
|
|
153
|
+
summary = summary.sort_values("pitches", ascending=False)
|
|
154
|
+
summary = summary[["pitches", "strikes", "balls", "strike_rate", "usage_rate"]]
|
|
155
|
+
|
|
156
|
+
with pd.option_context("display.float_format", "{:.1f}".format):
|
|
157
|
+
typer.echo(summary.to_string())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def zone(
|
|
162
|
+
name: str = typer.Argument(..., help="Pitcher name or MLB player ID"),
|
|
163
|
+
last: int | None = typer.Option(None, "--last", help="Most recent N appearances"),
|
|
164
|
+
since: str | None = typer.Option(None, "--since", help="Start date (YYYY-MM-DD)"),
|
|
165
|
+
until: str | None = typer.Option(None, "--until", help="End date (YYYY-MM-DD)"),
|
|
166
|
+
game: int | None = typer.Option(None, "--game", help="A specific MLB game_pk"),
|
|
167
|
+
pitch: str | None = typer.Option(None, "--pitch", help="Pitch type, e.g. 'splitter'"),
|
|
168
|
+
kind: str = typer.Option("scatter", "--kind", help="'scatter' or 'heatmap'"),
|
|
169
|
+
out: str = typer.Option("zone.png", "--out", help="Output image path"),
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Plot pitch locations against a theoretical strike zone."""
|
|
172
|
+
collection = _get_pitches(name, last, since, until, game, pitch)
|
|
173
|
+
|
|
174
|
+
if collection.empty:
|
|
175
|
+
_fail("No pitches found for the given filters.")
|
|
176
|
+
|
|
177
|
+
collection.plot_zone(kind=kind, out=out)
|
|
178
|
+
typer.echo(f"Saved plot of {len(collection)} pitch(es) to {out}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
app()
|
mound/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Shared constants: API base URLs, headers and defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
STATSAPI_BASE = "https://statsapi.mlb.com/api"
|
|
6
|
+
STATSAPI_V1 = f"{STATSAPI_BASE}/v1"
|
|
7
|
+
|
|
8
|
+
SAVANT_BASE = "https://baseballsavant.mlb.com"
|
|
9
|
+
SAVANT_GAMEFEED_URL = f"{SAVANT_BASE}/gf"
|
|
10
|
+
|
|
11
|
+
# MLB's Stats API groups all affiliated leagues under sportId=1 for MLB itself.
|
|
12
|
+
MLB_SPORT_ID = 1
|
|
13
|
+
|
|
14
|
+
DEFAULT_SEASON_START_MONTH = 3 # spring training/season generally begins in March
|
|
15
|
+
|
|
16
|
+
USER_AGENT = "mound/0.1 (+https://github.com/stiles/mound; personal project)"
|
|
17
|
+
|
|
18
|
+
DEFAULT_HEADERS = {
|
|
19
|
+
"User-Agent": USER_AGENT,
|
|
20
|
+
"Accept": "application/json",
|
|
21
|
+
"Referer": "https://www.mlb.com/",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Reasonable network defaults for a courteous client against unofficial endpoints.
|
|
25
|
+
REQUEST_TIMEOUT = 15
|
|
26
|
+
RETRY_TOTAL = 4
|
|
27
|
+
RETRY_BACKOFF_FACTOR = 1.5
|
|
28
|
+
RETRY_STATUS_FORCELIST = (429, 500, 502, 503, 504)
|
mound/export.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Export pitch data to disk.
|
|
2
|
+
|
|
3
|
+
Serialization (turning a :class:`~mound.pitches.PitchCollection` into
|
|
4
|
+
CSV/JSON/Parquet bytes) is kept separate from *where* those bytes are
|
|
5
|
+
written. Today that's always :class:`LocalStorage`, but the split means a
|
|
6
|
+
future ``S3Storage`` can be dropped in without touching the format logic or
|
|
7
|
+
the core data model.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from mound.pitches import PitchCollection
|
|
19
|
+
|
|
20
|
+
SUPPORTED_FORMATS = ("csv", "json", "parquet")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Storage(ABC):
|
|
24
|
+
"""Destination for exported pitch data."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def write(self, path: str, data: bytes) -> None:
|
|
28
|
+
"""Persist ``data`` at ``path``."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LocalStorage(Storage):
|
|
32
|
+
"""Write exported data to the local filesystem, creating parent directories."""
|
|
33
|
+
|
|
34
|
+
def write(self, path: str, data: bytes) -> None:
|
|
35
|
+
file_path = Path(path)
|
|
36
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
file_path.write_bytes(data)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_default_storage = LocalStorage()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_storage(storage: Storage | None) -> Storage:
|
|
44
|
+
return storage if storage is not None else _default_storage
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_csv(
|
|
48
|
+
collection: PitchCollection, path: str, storage: Storage | None = None, **kwargs
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Export pitches to CSV."""
|
|
51
|
+
data = collection.to_frame().to_csv(index=False, **kwargs).encode("utf-8")
|
|
52
|
+
_resolve_storage(storage).write(path, data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def to_json(
|
|
56
|
+
collection: PitchCollection, path: str, storage: Storage | None = None, **kwargs
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Export pitches to JSON (one record per pitch)."""
|
|
59
|
+
kwargs.setdefault("orient", "records")
|
|
60
|
+
kwargs.setdefault("indent", 2)
|
|
61
|
+
kwargs.setdefault("date_format", "iso")
|
|
62
|
+
data = collection.to_frame().to_json(**kwargs).encode("utf-8")
|
|
63
|
+
_resolve_storage(storage).write(path, data)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def to_parquet(
|
|
67
|
+
collection: PitchCollection, path: str, storage: Storage | None = None, **kwargs
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Export pitches to Parquet. Requires the optional `pyarrow` dependency."""
|
|
70
|
+
try:
|
|
71
|
+
import pyarrow # noqa: F401
|
|
72
|
+
except ImportError as exc:
|
|
73
|
+
raise ImportError(
|
|
74
|
+
"Parquet export requires pyarrow. Install it with: pip install 'mound[parquet]'"
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
buffer = io.BytesIO()
|
|
78
|
+
collection.to_frame().to_parquet(buffer, engine="pyarrow", **kwargs)
|
|
79
|
+
_resolve_storage(storage).write(path, buffer.getvalue())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_FORMAT_HANDLERS = {"csv": to_csv, "json": to_json, "parquet": to_parquet}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def export(
|
|
86
|
+
collection: PitchCollection,
|
|
87
|
+
path: str,
|
|
88
|
+
format: str | None = None,
|
|
89
|
+
storage: Storage | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Export pitches, inferring the format from ``path``'s suffix if not given."""
|
|
92
|
+
fmt = format or Path(path).suffix.lstrip(".").lower()
|
|
93
|
+
handler = _FORMAT_HANDLERS.get(fmt)
|
|
94
|
+
if handler is None:
|
|
95
|
+
raise ValueError(f"Unsupported format: {fmt!r} (expected one of {SUPPORTED_FORMATS})")
|
|
96
|
+
handler(collection, path, storage=storage)
|
mound/http.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Shared HTTP session used by all Mound API clients.
|
|
2
|
+
|
|
3
|
+
Centralizing the session lets every module benefit from the same retry
|
|
4
|
+
policy, headers and timeout defaults without duplicating boilerplate.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from requests.adapters import HTTPAdapter
|
|
11
|
+
from urllib3.util.retry import Retry
|
|
12
|
+
|
|
13
|
+
from mound import config
|
|
14
|
+
|
|
15
|
+
_session: requests.Session | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_session() -> requests.Session:
|
|
19
|
+
"""Return a shared, retry-configured requests session (created lazily)."""
|
|
20
|
+
global _session
|
|
21
|
+
if _session is None:
|
|
22
|
+
session = requests.Session()
|
|
23
|
+
session.headers.update(config.DEFAULT_HEADERS)
|
|
24
|
+
retry = Retry(
|
|
25
|
+
total=config.RETRY_TOTAL,
|
|
26
|
+
backoff_factor=config.RETRY_BACKOFF_FACTOR,
|
|
27
|
+
status_forcelist=config.RETRY_STATUS_FORCELIST,
|
|
28
|
+
allowed_methods=("GET",),
|
|
29
|
+
)
|
|
30
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
31
|
+
session.mount("https://", adapter)
|
|
32
|
+
session.mount("http://", adapter)
|
|
33
|
+
_session = session
|
|
34
|
+
return _session
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_json(url: str, params: dict | None = None) -> dict:
|
|
38
|
+
"""GET a URL and return the parsed JSON body, raising on HTTP errors."""
|
|
39
|
+
response = get_session().get(url, params=params, timeout=config.REQUEST_TIMEOUT)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
return response.json()
|
mound/models.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The normalized ``Pitch`` record and pitch-type vocabulary.
|
|
2
|
+
|
|
3
|
+
Baseball Savant's raw ``/gf`` payload uses short codes (``FF``, ``FS``, ...)
|
|
4
|
+
and inconsistent field names. This module defines the flat schema Mound
|
|
5
|
+
normalizes every pitch into, plus the vocabulary that lets users filter by
|
|
6
|
+
familiar names like ``"splitter"`` instead of memorizing Statcast codes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, fields
|
|
12
|
+
|
|
13
|
+
from mound.zone import is_in_zone
|
|
14
|
+
|
|
15
|
+
# Statcast pitch-type code -> canonical human-readable name.
|
|
16
|
+
PITCH_TYPE_NAMES: dict[str, str] = {
|
|
17
|
+
"FF": "four-seam fastball",
|
|
18
|
+
"FT": "two-seam fastball",
|
|
19
|
+
"SI": "sinker",
|
|
20
|
+
"FC": "cutter",
|
|
21
|
+
"SL": "slider",
|
|
22
|
+
"ST": "sweeper",
|
|
23
|
+
"SV": "slurve",
|
|
24
|
+
"CU": "curveball",
|
|
25
|
+
"KC": "knuckle curve",
|
|
26
|
+
"CS": "slow curve",
|
|
27
|
+
"CH": "changeup",
|
|
28
|
+
"FS": "splitter",
|
|
29
|
+
"FO": "forkball",
|
|
30
|
+
"SC": "screwball",
|
|
31
|
+
"KN": "knuckleball",
|
|
32
|
+
"EP": "eephus",
|
|
33
|
+
"PO": "pitchout",
|
|
34
|
+
"IN": "intentional ball",
|
|
35
|
+
"UN": "unknown",
|
|
36
|
+
"AB": "automatic ball",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Common aliases users might type, mapped to the Statcast code they resolve
|
|
40
|
+
# to. Keys are matched case-insensitively with spaces/hyphens/underscores
|
|
41
|
+
# collapsed (see `normalize_pitch_type`).
|
|
42
|
+
PITCH_TYPE_ALIASES: dict[str, str] = {
|
|
43
|
+
"fastball": "FF",
|
|
44
|
+
"four seam": "FF",
|
|
45
|
+
"four seam fastball": "FF",
|
|
46
|
+
"4 seam": "FF",
|
|
47
|
+
"two seam": "FT",
|
|
48
|
+
"two seam fastball": "FT",
|
|
49
|
+
"sinker": "SI",
|
|
50
|
+
"cutter": "FC",
|
|
51
|
+
"cut fastball": "FC",
|
|
52
|
+
"slider": "SL",
|
|
53
|
+
"sweeper": "ST",
|
|
54
|
+
"sweeping slider": "ST",
|
|
55
|
+
"slurve": "SV",
|
|
56
|
+
"curveball": "CU",
|
|
57
|
+
"curve": "CU",
|
|
58
|
+
"knuckle curve": "KC",
|
|
59
|
+
"knuckle curveball": "KC",
|
|
60
|
+
"slow curve": "CS",
|
|
61
|
+
"changeup": "CH",
|
|
62
|
+
"change up": "CH",
|
|
63
|
+
"splitter": "FS",
|
|
64
|
+
"split finger": "FS",
|
|
65
|
+
"split finger fastball": "FS",
|
|
66
|
+
"forkball": "FO",
|
|
67
|
+
"screwball": "SC",
|
|
68
|
+
"knuckleball": "KN",
|
|
69
|
+
"knuckler": "KN",
|
|
70
|
+
"eephus": "EP",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize_key(text: str) -> str:
|
|
75
|
+
return " ".join(text.strip().lower().replace("-", " ").replace("_", " ").split())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_pitch_type(pitch_type: str) -> str | None:
|
|
79
|
+
"""Resolve a user-supplied pitch type (name, alias or code) to a Statcast code.
|
|
80
|
+
|
|
81
|
+
Returns ``None`` if the input doesn't match anything known. Matching is
|
|
82
|
+
case-insensitive and tolerant of hyphens/underscores/extra whitespace,
|
|
83
|
+
e.g. ``"Four-Seam"``, ``"four_seam"`` and ``"FF"`` all resolve to ``"FF"``.
|
|
84
|
+
"""
|
|
85
|
+
if not pitch_type:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
code = pitch_type.strip().upper()
|
|
89
|
+
if code in PITCH_TYPE_NAMES:
|
|
90
|
+
return code
|
|
91
|
+
|
|
92
|
+
key = _normalize_key(pitch_type)
|
|
93
|
+
if key in PITCH_TYPE_ALIASES:
|
|
94
|
+
return PITCH_TYPE_ALIASES[key]
|
|
95
|
+
|
|
96
|
+
for statcast_code, name in PITCH_TYPE_NAMES.items():
|
|
97
|
+
if _normalize_key(name) == key:
|
|
98
|
+
return statcast_code
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Pitch:
|
|
105
|
+
"""A single, normalized pitch."""
|
|
106
|
+
|
|
107
|
+
game_pk: int
|
|
108
|
+
game_date: str
|
|
109
|
+
pitch_id: str | None
|
|
110
|
+
at_bat_number: int | None
|
|
111
|
+
pitch_number: int | None
|
|
112
|
+
inning: int | None
|
|
113
|
+
half_inning: str | None
|
|
114
|
+
pitcher_id: int
|
|
115
|
+
pitcher_name: str | None
|
|
116
|
+
batter_id: int | None
|
|
117
|
+
batter_name: str | None
|
|
118
|
+
pitch_type_code: str | None
|
|
119
|
+
pitch_type: str | None
|
|
120
|
+
velocity: float | None
|
|
121
|
+
plate_x: float | None
|
|
122
|
+
plate_z: float | None
|
|
123
|
+
sz_top: float | None
|
|
124
|
+
sz_bot: float | None
|
|
125
|
+
in_zone: bool | None
|
|
126
|
+
balls: int | None
|
|
127
|
+
strikes: int | None
|
|
128
|
+
pitch_call: str | None
|
|
129
|
+
call_description: str | None
|
|
130
|
+
is_strike: bool | None
|
|
131
|
+
at_bat_result: str | None
|
|
132
|
+
description: str | None
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def field_names(cls) -> list[str]:
|
|
136
|
+
return [f.name for f in fields(cls)]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Savant `pitch_call` values that count as a strike for strike-rate purposes.
|
|
140
|
+
# Everything else (ball, hit-by-pitch, etc.) counts as not-a-strike.
|
|
141
|
+
STRIKE_CALLS = {
|
|
142
|
+
"called_strike",
|
|
143
|
+
"swinging_strike",
|
|
144
|
+
"swinging_strike_blocked",
|
|
145
|
+
"foul",
|
|
146
|
+
"foul_tip",
|
|
147
|
+
"foul_bunt",
|
|
148
|
+
"missed_bunt",
|
|
149
|
+
"bunt_foul_tip",
|
|
150
|
+
"hit_into_play",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def pitch_from_savant(raw: dict) -> Pitch:
|
|
155
|
+
"""Build a normalized :class:`Pitch` from one entry of a Savant ``/gf`` pitcher list."""
|
|
156
|
+
pitch_type_code = raw.get("pitch_type")
|
|
157
|
+
plate_x = raw.get("plate_x", raw.get("px"))
|
|
158
|
+
plate_z = raw.get("plate_z", raw.get("pz"))
|
|
159
|
+
sz_top = raw.get("sz_top")
|
|
160
|
+
sz_bot = raw.get("sz_bot")
|
|
161
|
+
pitch_call = raw.get("pitch_call")
|
|
162
|
+
|
|
163
|
+
game_pk_raw = raw.get("game_pk")
|
|
164
|
+
|
|
165
|
+
return Pitch(
|
|
166
|
+
game_pk=int(game_pk_raw) if game_pk_raw is not None else None,
|
|
167
|
+
game_date=raw.get("game_date") or "",
|
|
168
|
+
pitch_id=raw.get("play_id"),
|
|
169
|
+
at_bat_number=raw.get("ab_number"),
|
|
170
|
+
pitch_number=raw.get("pitch_number"),
|
|
171
|
+
inning=raw.get("inning"),
|
|
172
|
+
half_inning=raw.get("half_inning"),
|
|
173
|
+
pitcher_id=raw.get("pitcher"),
|
|
174
|
+
pitcher_name=raw.get("pitcher_name"),
|
|
175
|
+
batter_id=raw.get("batter"),
|
|
176
|
+
batter_name=raw.get("batter_name"),
|
|
177
|
+
pitch_type_code=pitch_type_code,
|
|
178
|
+
pitch_type=PITCH_TYPE_NAMES.get(pitch_type_code, pitch_type_code),
|
|
179
|
+
velocity=raw.get("start_speed"),
|
|
180
|
+
plate_x=plate_x,
|
|
181
|
+
plate_z=plate_z,
|
|
182
|
+
sz_top=sz_top,
|
|
183
|
+
sz_bot=sz_bot,
|
|
184
|
+
in_zone=is_in_zone(plate_x, plate_z, sz_top, sz_bot),
|
|
185
|
+
balls=raw.get("pre_balls", raw.get("balls")),
|
|
186
|
+
strikes=raw.get("pre_strikes", raw.get("strikes")),
|
|
187
|
+
pitch_call=pitch_call,
|
|
188
|
+
call_description=raw.get("call_name") or raw.get("description"),
|
|
189
|
+
is_strike=(pitch_call in STRIKE_CALLS) if pitch_call is not None else None,
|
|
190
|
+
at_bat_result=raw.get("result"),
|
|
191
|
+
description=raw.get("des"),
|
|
192
|
+
)
|