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 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
+ )