backcaster 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (32) hide show
  1. {backcaster-0.4.0/src/backcaster.egg-info → backcaster-0.4.2}/PKG-INFO +70 -18
  2. {backcaster-0.4.0 → backcaster-0.4.2}/README.md +65 -16
  3. {backcaster-0.4.0 → backcaster-0.4.2}/pyproject.toml +4 -1
  4. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/__init__.py +1 -1
  5. backcaster-0.4.2/src/backcaster/batters.py +175 -0
  6. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/cli.py +19 -6
  7. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/data.py +2 -2
  8. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/html_report.py +26 -27
  9. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/lahman.py +80 -36
  10. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/marcel.py +109 -74
  11. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/outputs.py +37 -4
  12. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/schemas.py +4 -5
  13. backcaster-0.4.2/src/backcaster/stat_columns.py +55 -0
  14. {backcaster-0.4.0 → backcaster-0.4.2/src/backcaster.egg-info}/PKG-INFO +70 -18
  15. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/SOURCES.txt +1 -0
  16. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/requires.txt +4 -1
  17. backcaster-0.4.2/tests/test_batters.py +241 -0
  18. {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_cli.py +58 -2
  19. {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_html_report.py +5 -5
  20. {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_lahman.py +205 -13
  21. {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_marcel_math.py +180 -152
  22. {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_outputs.py +101 -5
  23. backcaster-0.4.0/src/backcaster/batters.py +0 -118
  24. backcaster-0.4.0/tests/test_batters.py +0 -120
  25. {backcaster-0.4.0 → backcaster-0.4.2}/LICENSE +0 -0
  26. {backcaster-0.4.0 → backcaster-0.4.2}/THIRD_PARTY_NOTICES.md +0 -0
  27. {backcaster-0.4.0 → backcaster-0.4.2}/setup.cfg +0 -0
  28. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster/normalize.py +0 -0
  29. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/dependency_links.txt +0 -0
  30. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/entry_points.txt +0 -0
  31. {backcaster-0.4.0 → backcaster-0.4.2}/src/backcaster.egg-info/top_level.txt +0 -0
  32. {backcaster-0.4.0 → backcaster-0.4.2}/tests/test_normalize.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backcaster
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Marcel-style MLB projection CLI using local Lahman CSV data
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
@@ -18,7 +18,7 @@ Requires-Python: >=3.13
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
20
  License-File: THIRD_PARTY_NOTICES.md
21
- Requires-Dist: pandas>=2.0
21
+ Requires-Dist: pandas>=2.2.2
22
22
  Requires-Dist: numpy>=2.0
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -27,6 +27,9 @@ Requires-Dist: ruff>=0.11.0; extra == "dev"
27
27
  Requires-Dist: mypy>=1.10; extra == "dev"
28
28
  Requires-Dist: pandas-stubs>=2.2; extra == "dev"
29
29
  Requires-Dist: build>=1.2; extra == "dev"
30
+ Requires-Dist: setuptools>=77; extra == "dev"
31
+ Requires-Dist: wheel; extra == "dev"
32
+ Requires-Dist: twine; extra == "dev"
30
33
  Dynamic: license-file
31
34
 
32
35
  # Backcaster
@@ -56,10 +59,10 @@ Upgrade to the latest release available on PyPI:
56
59
  python -m pip install --upgrade backcaster
57
60
  ```
58
61
 
59
- To install a specific release, such as 0.4.0:
62
+ To install a specific release, such as 0.4.2:
60
63
 
61
64
  ```powershell
62
- python -m pip install --upgrade backcaster==0.4.0
65
+ python -m pip install --upgrade backcaster==0.4.2
63
66
  ```
64
67
 
65
68
  Run the command using the same Python environment where you run Backcaster.
@@ -73,7 +76,7 @@ python -m pip show backcaster
73
76
 
74
77
  ```bash
75
78
  # from repo root
76
- uv sync
79
+ uv sync --locked
77
80
  ```
78
81
 
79
82
  This installs runtime dependencies and the `backcaster` CLI entrypoint defined in `pyproject.toml`.
@@ -81,10 +84,10 @@ This installs runtime dependencies and the `backcaster` CLI entrypoint defined i
81
84
  ### Install in editable/dev mode
82
85
 
83
86
  ```bash
84
- uv sync --extra dev
87
+ uv sync --locked --extra dev
85
88
  ```
86
89
 
87
- This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`).
90
+ This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`, `setuptools`, `wheel`, `twine`).
88
91
 
89
92
  ### Build/install as a package
90
93
 
@@ -102,7 +105,7 @@ The `player`, `team`, and `batch` commands also require `--kind batting|pitching
102
105
  The `hitters` and `pitchers` commands choose their kind automatically.
103
106
 
104
107
  Subcommand-specific required options:
105
- - `player` requires `--name`
108
+ - `player` requires exactly one of `--name` or `--player-id`
106
109
  - `team` requires `--team`
107
110
  - `batch`, `hitters`, and `pitchers` require no player or team option
108
111
 
@@ -133,7 +136,9 @@ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
133
136
  They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
134
137
  For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
135
138
  For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
136
- File formats require `--out`; the CLI format prints to the terminal.
139
+ File formats require `--out`; the CLI format prints to the terminal and rejects
140
+ `--out`. Invalid output options fail before data is loaded. Unwritable output
141
+ paths report an error on stderr and exit with status 2.
137
142
 
138
143
  Players qualify for selection by meeting the existing PA/IP threshold in at least
139
144
  one of those three seasons. This is a historical eligibility list, not an active
@@ -195,7 +200,15 @@ Blank numeric counting-stat cells are also treated as zero; use a dataset with
195
200
  complete records for the seasons you intend to project.
196
201
  Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
197
202
  batters faced. Lahman player IDs keep different players with the same name separate;
198
- ambiguous name lookups report the matching IDs.
203
+ ambiguous name lookups report the matching IDs. Select one with `--player-id`
204
+ instead of `--name`, for example:
205
+
206
+ ```bash
207
+ backcaster player --player-id bettsmo01 --year 2026 --kind batting
208
+ ```
209
+
210
+ IDs match Lahman `People.csv.playerID` exactly and retain the same qualification
211
+ rules as name lookups.
199
212
 
200
213
  Batter league regression and rebaselining use all non-pitcher player-seasons;
201
214
  qualification selects the displayed players. Pitching retains its qualified-only
@@ -226,7 +239,7 @@ backcaster player --name "Mookie Betts" --year 2026 --kind batting --format csv
226
239
  backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out/lan_pitching_2026.html
227
240
  ```
228
241
 
229
- > `--out` is required for `csv` and `html` formats.
242
+ > `--out` is required for `csv` and `html` formats and cannot be used with `cli`.
230
243
 
231
244
  ---
232
245
 
@@ -238,8 +251,8 @@ Selected columns from a 2025 Mookie Betts projection (actual output includes
238
251
  additional counting statistics, reliability, and age):
239
252
 
240
253
  ```text
241
- PlayerID PA AB H HR AVG OBP SLG OPS
242
- bettsmo01 527 461 128 24 0.278 0.362 0.507 0.869
254
+ Name Year PlayerID Age PA AB H HR BA OBP SLG OPS
255
+ Mookie Betts 2025 bettsmo01 33 527 461 128 24 0.278 0.362 0.507 0.869
243
256
  ```
244
257
 
245
258
  ### Number formatting and baseball innings
@@ -248,8 +261,13 @@ Terminal, CSV, and HTML outputs use the same display rules:
248
261
 
249
262
  - Counting statistics are rounded to the nearest whole number, with halves rounded
250
263
  up. Age and year are displayed as whole numbers too.
251
- - AVG, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
252
- - ERA and WHIP always show two decimal places, such as `3.46` and `1.23`.
264
+ - BA, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
265
+ BA is the canonical batter output label (previously AVG).
266
+ Rates with zero denominators remain undefined (NaN) and display blank;
267
+ OPS is undefined when OBP or SLG is undefined.
268
+ - ERA shows two decimals; WHIP shows three. W-L% shows a three-decimal
269
+ fraction, such as `0.524`. H9, HR9, BB9, and SO9 show one decimal; SO/W shows two.
270
+ W-L% without any decisions and SO/W without walks are undefined and display blank.
253
271
  - IP is rounded to the nearest out and displayed in baseball notation:
254
272
  `161.0` is 161 innings, `161.1` is 161 innings plus one out, and `161.2` is
255
273
  161 innings plus two outs. Three outs carry into the next whole inning.
@@ -257,13 +275,34 @@ Terminal, CSV, and HTML outputs use the same display rules:
257
275
  Calculations and Python projection DataFrames retain full precision. Rates are
258
276
  computed from unrounded totals, then formatted; they are not recalculated from
259
277
  rounded displayed counts. Independently rounded totals may therefore not add up
260
- exactly. Reliability formatting is unchanged.
278
+ exactly. Reliability shows six decimal places in CLI, CSV, and HTML output.
261
279
 
262
280
  **CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
263
281
  arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
264
282
  Backcaster uses decimal innings (`IPouts / 3`), so ERA and WHIP calculations use
265
283
  true innings rather than the displayed notation.
266
284
 
285
+ ### Tracked statistics and order
286
+
287
+ All outputs put available Name, Year, Player ID, and Age metadata first, followed
288
+ by these statistics in order and then Reliability:
289
+
290
+ ```text
291
+ Batters: PA,AB,R,H,2B,3B,HR,RBI,SB,CS,BB,SO,BA,OBP,SLG,OPS,TB,GDP,HBP,SH,SF,IBB
292
+ Pitchers: W,L,W-L%,ERA,SV,IP,H,R,ER,HR,BB,IBB,SO,HBP,BK,WP,BF,WHIP,H9,HR9,BB9,SO9,SO/W
293
+ ```
294
+
295
+ Lahman's batting `GIDP` is exported as `GDP`. The expanded counting-stat columns
296
+ must be present in the appropriate local CSVs; missing columns produce an error.
297
+ TB is derived as `H + 2B + 2×3B + 3×HR`; stolen bases do not add total bases.
298
+ BA is H/AB, SLG is TB/AB, OBP is `(H+BB+HBP)/(AB+BB+HBP+SF)`, and OPS is OBP+SLG.
299
+ IBB is already part of BB and is never added again to OBP or PA. Runs, RBI, SB,
300
+ CS, and GDP are contextual counts rather than additional PA outcomes.
301
+
302
+ Pitcher per-nine rates use unrounded counts and decimal IP: `9 × count / IP`.
303
+ SO/W is SO/BB; W-L% is W/(W+L). Runs allowed (`R`) and earned runs (`ER`) are
304
+ separate projections; ERA uses ER, while WHIP uses H+BB without adding IBB again.
305
+
267
306
  ### CSV output (`--format csv`)
268
307
  A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
269
308
  Both populations include `PlayerID` for matching records across files.
@@ -302,6 +341,13 @@ Lahman distribution from the same edition. Existing online caches are ignored.
302
341
 
303
342
  ---
304
343
 
344
+ ### Python data-loading errors
345
+
346
+ Local data-loading and validation failures raise
347
+ `backcaster.data.DataLoadError` (renamed from `DataFetchError`). Player lookup
348
+ failures continue to raise `PlayerLookupError`. The CLI reports these failures
349
+ and filesystem errors on stderr with exit status 2.
350
+
305
351
  ## Dependency notes
306
352
 
307
353
  Runtime dependencies are intentionally minimal:
@@ -315,7 +361,12 @@ Development dependencies (`--extra dev`) include:
315
361
 
316
362
  ### Lockfile policy (`uv.lock`)
317
363
 
318
- `uv.lock` pins transitive versions for reproducible environments when working from a Git checkout.
364
+ `uv.lock` is committed and pins runtime, development, and build dependencies. CI uses
365
+ `uv sync --locked --extra dev` and fails if the lockfile is out of date. CI first
366
+ installs the locked tools with `--no-install-project`, then installs Backcaster
367
+ with `--no-build-isolation` to keep build dependencies pinned. CI builds
368
+ without build isolation using the locked build tools; wheel smoke tests install
369
+ hash-checked runtime dependencies exported from the same lockfile.
319
370
 
320
371
  Release/source ZIP archives may not include `uv.lock`. If it is missing in your environment, regenerate it locally:
321
372
 
@@ -323,7 +374,8 @@ Release/source ZIP archives may not include `uv.lock`. If it is missing in your
323
374
  uv lock
324
375
  ```
325
376
 
326
- Do not hand-edit `uv.lock`; regenerate it when dependencies change.
377
+ Do not hand-edit `uv.lock`; run `uv lock` and commit the updated lockfile when
378
+ dependencies change. To deliberately refresh all pins, run `uv lock --upgrade`.
327
379
 
328
380
  ---
329
381
 
@@ -25,10 +25,10 @@ Upgrade to the latest release available on PyPI:
25
25
  python -m pip install --upgrade backcaster
26
26
  ```
27
27
 
28
- To install a specific release, such as 0.4.0:
28
+ To install a specific release, such as 0.4.2:
29
29
 
30
30
  ```powershell
31
- python -m pip install --upgrade backcaster==0.4.0
31
+ python -m pip install --upgrade backcaster==0.4.2
32
32
  ```
33
33
 
34
34
  Run the command using the same Python environment where you run Backcaster.
@@ -42,7 +42,7 @@ python -m pip show backcaster
42
42
 
43
43
  ```bash
44
44
  # from repo root
45
- uv sync
45
+ uv sync --locked
46
46
  ```
47
47
 
48
48
  This installs runtime dependencies and the `backcaster` CLI entrypoint defined in `pyproject.toml`.
@@ -50,10 +50,10 @@ This installs runtime dependencies and the `backcaster` CLI entrypoint defined i
50
50
  ### Install in editable/dev mode
51
51
 
52
52
  ```bash
53
- uv sync --extra dev
53
+ uv sync --locked --extra dev
54
54
  ```
55
55
 
56
- This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`).
56
+ This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`, `setuptools`, `wheel`, `twine`).
57
57
 
58
58
  ### Build/install as a package
59
59
 
@@ -71,7 +71,7 @@ The `player`, `team`, and `batch` commands also require `--kind batting|pitching
71
71
  The `hitters` and `pitchers` commands choose their kind automatically.
72
72
 
73
73
  Subcommand-specific required options:
74
- - `player` requires `--name`
74
+ - `player` requires exactly one of `--name` or `--player-id`
75
75
  - `team` requires `--team`
76
76
  - `batch`, `hitters`, and `pitchers` require no player or team option
77
77
 
@@ -102,7 +102,9 @@ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
102
102
  They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
103
103
  For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
104
104
  For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
105
- File formats require `--out`; the CLI format prints to the terminal.
105
+ File formats require `--out`; the CLI format prints to the terminal and rejects
106
+ `--out`. Invalid output options fail before data is loaded. Unwritable output
107
+ paths report an error on stderr and exit with status 2.
106
108
 
107
109
  Players qualify for selection by meeting the existing PA/IP threshold in at least
108
110
  one of those three seasons. This is a historical eligibility list, not an active
@@ -164,7 +166,15 @@ Blank numeric counting-stat cells are also treated as zero; use a dataset with
164
166
  complete records for the seasons you intend to project.
165
167
  Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
166
168
  batters faced. Lahman player IDs keep different players with the same name separate;
167
- ambiguous name lookups report the matching IDs.
169
+ ambiguous name lookups report the matching IDs. Select one with `--player-id`
170
+ instead of `--name`, for example:
171
+
172
+ ```bash
173
+ backcaster player --player-id bettsmo01 --year 2026 --kind batting
174
+ ```
175
+
176
+ IDs match Lahman `People.csv.playerID` exactly and retain the same qualification
177
+ rules as name lookups.
168
178
 
169
179
  Batter league regression and rebaselining use all non-pitcher player-seasons;
170
180
  qualification selects the displayed players. Pitching retains its qualified-only
@@ -195,7 +205,7 @@ backcaster player --name "Mookie Betts" --year 2026 --kind batting --format csv
195
205
  backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out/lan_pitching_2026.html
196
206
  ```
197
207
 
198
- > `--out` is required for `csv` and `html` formats.
208
+ > `--out` is required for `csv` and `html` formats and cannot be used with `cli`.
199
209
 
200
210
  ---
201
211
 
@@ -207,8 +217,8 @@ Selected columns from a 2025 Mookie Betts projection (actual output includes
207
217
  additional counting statistics, reliability, and age):
208
218
 
209
219
  ```text
210
- PlayerID PA AB H HR AVG OBP SLG OPS
211
- bettsmo01 527 461 128 24 0.278 0.362 0.507 0.869
220
+ Name Year PlayerID Age PA AB H HR BA OBP SLG OPS
221
+ Mookie Betts 2025 bettsmo01 33 527 461 128 24 0.278 0.362 0.507 0.869
212
222
  ```
213
223
 
214
224
  ### Number formatting and baseball innings
@@ -217,8 +227,13 @@ Terminal, CSV, and HTML outputs use the same display rules:
217
227
 
218
228
  - Counting statistics are rounded to the nearest whole number, with halves rounded
219
229
  up. Age and year are displayed as whole numbers too.
220
- - AVG, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
221
- - ERA and WHIP always show two decimal places, such as `3.46` and `1.23`.
230
+ - BA, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
231
+ BA is the canonical batter output label (previously AVG).
232
+ Rates with zero denominators remain undefined (NaN) and display blank;
233
+ OPS is undefined when OBP or SLG is undefined.
234
+ - ERA shows two decimals; WHIP shows three. W-L% shows a three-decimal
235
+ fraction, such as `0.524`. H9, HR9, BB9, and SO9 show one decimal; SO/W shows two.
236
+ W-L% without any decisions and SO/W without walks are undefined and display blank.
222
237
  - IP is rounded to the nearest out and displayed in baseball notation:
223
238
  `161.0` is 161 innings, `161.1` is 161 innings plus one out, and `161.2` is
224
239
  161 innings plus two outs. Three outs carry into the next whole inning.
@@ -226,13 +241,34 @@ Terminal, CSV, and HTML outputs use the same display rules:
226
241
  Calculations and Python projection DataFrames retain full precision. Rates are
227
242
  computed from unrounded totals, then formatted; they are not recalculated from
228
243
  rounded displayed counts. Independently rounded totals may therefore not add up
229
- exactly. Reliability formatting is unchanged.
244
+ exactly. Reliability shows six decimal places in CLI, CSV, and HTML output.
230
245
 
231
246
  **CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
232
247
  arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
233
248
  Backcaster uses decimal innings (`IPouts / 3`), so ERA and WHIP calculations use
234
249
  true innings rather than the displayed notation.
235
250
 
251
+ ### Tracked statistics and order
252
+
253
+ All outputs put available Name, Year, Player ID, and Age metadata first, followed
254
+ by these statistics in order and then Reliability:
255
+
256
+ ```text
257
+ Batters: PA,AB,R,H,2B,3B,HR,RBI,SB,CS,BB,SO,BA,OBP,SLG,OPS,TB,GDP,HBP,SH,SF,IBB
258
+ Pitchers: W,L,W-L%,ERA,SV,IP,H,R,ER,HR,BB,IBB,SO,HBP,BK,WP,BF,WHIP,H9,HR9,BB9,SO9,SO/W
259
+ ```
260
+
261
+ Lahman's batting `GIDP` is exported as `GDP`. The expanded counting-stat columns
262
+ must be present in the appropriate local CSVs; missing columns produce an error.
263
+ TB is derived as `H + 2B + 2×3B + 3×HR`; stolen bases do not add total bases.
264
+ BA is H/AB, SLG is TB/AB, OBP is `(H+BB+HBP)/(AB+BB+HBP+SF)`, and OPS is OBP+SLG.
265
+ IBB is already part of BB and is never added again to OBP or PA. Runs, RBI, SB,
266
+ CS, and GDP are contextual counts rather than additional PA outcomes.
267
+
268
+ Pitcher per-nine rates use unrounded counts and decimal IP: `9 × count / IP`.
269
+ SO/W is SO/BB; W-L% is W/(W+L). Runs allowed (`R`) and earned runs (`ER`) are
270
+ separate projections; ERA uses ER, while WHIP uses H+BB without adding IBB again.
271
+
236
272
  ### CSV output (`--format csv`)
237
273
  A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
238
274
  Both populations include `PlayerID` for matching records across files.
@@ -271,6 +307,13 @@ Lahman distribution from the same edition. Existing online caches are ignored.
271
307
 
272
308
  ---
273
309
 
310
+ ### Python data-loading errors
311
+
312
+ Local data-loading and validation failures raise
313
+ `backcaster.data.DataLoadError` (renamed from `DataFetchError`). Player lookup
314
+ failures continue to raise `PlayerLookupError`. The CLI reports these failures
315
+ and filesystem errors on stderr with exit status 2.
316
+
274
317
  ## Dependency notes
275
318
 
276
319
  Runtime dependencies are intentionally minimal:
@@ -284,7 +327,12 @@ Development dependencies (`--extra dev`) include:
284
327
 
285
328
  ### Lockfile policy (`uv.lock`)
286
329
 
287
- `uv.lock` pins transitive versions for reproducible environments when working from a Git checkout.
330
+ `uv.lock` is committed and pins runtime, development, and build dependencies. CI uses
331
+ `uv sync --locked --extra dev` and fails if the lockfile is out of date. CI first
332
+ installs the locked tools with `--no-install-project`, then installs Backcaster
333
+ with `--no-build-isolation` to keep build dependencies pinned. CI builds
334
+ without build isolation using the locked build tools; wheel smoke tests install
335
+ hash-checked runtime dependencies exported from the same lockfile.
288
336
 
289
337
  Release/source ZIP archives may not include `uv.lock`. If it is missing in your environment, regenerate it locally:
290
338
 
@@ -292,7 +340,8 @@ Release/source ZIP archives may not include `uv.lock`. If it is missing in your
292
340
  uv lock
293
341
  ```
294
342
 
295
- Do not hand-edit `uv.lock`; regenerate it when dependencies change.
343
+ Do not hand-edit `uv.lock`; run `uv lock` and commit the updated lockfile when
344
+ dependencies change. To deliberately refresh all pins, run `uv lock --upgrade`.
296
345
 
297
346
  ---
298
347
 
@@ -21,7 +21,7 @@ classifiers = [
21
21
  ]
22
22
  requires-python = ">=3.13"
23
23
  dependencies = [
24
- "pandas>=2.0",
24
+ "pandas>=2.2.2",
25
25
  "numpy>=2.0",
26
26
  ]
27
27
 
@@ -38,6 +38,9 @@ dev = [
38
38
  "mypy>=1.10",
39
39
  "pandas-stubs>=2.2",
40
40
  "build>=1.2",
41
+ "setuptools>=77",
42
+ "wheel",
43
+ "twine",
41
44
  ]
42
45
 
43
46
  [project.scripts]
@@ -1,4 +1,4 @@
1
1
  """backcaster package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.4.0"
4
+ __version__ = "0.4.2"
@@ -0,0 +1,175 @@
1
+ """Population-level batter projections; no projected-year statistics are read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from backcaster.marcel import ProjectionError
9
+ from backcaster.normalize import safe_divide
10
+ from backcaster.stat_columns import BATTING_STAT_COLUMNS, CONTEXT_STATS, IDENTITY_COLUMNS
11
+
12
+ EVENTS = ["1B", "2B", "3B", "HR", "BB", "HBP", "SO", "SH", "SF", "OUT"]
13
+
14
+
15
+ def _events(frame: pd.DataFrame) -> pd.DataFrame:
16
+ events = frame.reindex(columns=EVENTS, fill_value=0).astype(float)
17
+ events["1B"] = frame["H"] - frame["2B"] - frame["3B"] - frame["HR"]
18
+ events["OUT"] = frame["AB"] - frame["H"] - frame["SO"]
19
+ if not np.isfinite(events.to_numpy()).all() or (events < 0).any().any():
20
+ raise ProjectionError(
21
+ "Batter counts must be finite and consistent (H <= AB, SO <= AB - H)."
22
+ )
23
+ return events
24
+
25
+
26
+ def _rebaseline(events: pd.DataFrame, pa: pd.Series, target: pd.Series) -> pd.DataFrame:
27
+ """Rake mutually exclusive events to fixed player PA and league event totals."""
28
+ desired = target * float(pa.sum())
29
+ # Positive league outcomes must be reachable, including a historically absent
30
+ # event in the projected population. Seed only those cells, then rake.
31
+ values = events.clip(lower=1e-12).mul(desired.gt(0), axis=1)
32
+ for _ in range(200):
33
+ values = values.mul(desired.div(values.sum()).fillna(0), axis=1)
34
+ values = values.mul(pa.div(values.sum(axis=1)).fillna(0), axis=0)
35
+ if np.allclose(values.sum(), desired, rtol=1e-10, atol=1e-8):
36
+ return values
37
+ raise ProjectionError("Batter league rebaselining did not converge.")
38
+
39
+
40
+ def _context_counts(history: pd.DataFrame, events: pd.DataFrame) -> pd.DataFrame:
41
+ counts = history[list(CONTEXT_STATS)].astype(float)
42
+ if not np.isfinite(counts.to_numpy()).all() or (counts < 0).any().any():
43
+ raise ProjectionError("Contextual batter counts must be finite and nonnegative.")
44
+ if (counts["IBB"] > history["BB"]).any() or (counts["GDP"] > events["OUT"]).any():
45
+ raise ProjectionError(
46
+ "IBB must not exceed BB and GDP must not exceed non-strikeout AB outs."
47
+ )
48
+ return counts
49
+
50
+
51
+ def _regress(
52
+ history: pd.DataFrame, counts: pd.DataFrame, year: int
53
+ ) -> tuple[pd.DataFrame, pd.Series, pd.DataFrame]:
54
+ weights = history["Season"].map({year - 1: 5.0, year - 2: 4.0, year - 3: 3.0})
55
+ exposure = history["PA"] * weights
56
+ league_pa = history.groupby("Season")["PA"].sum()
57
+ if year - 1 not in league_pa:
58
+ raise ProjectionError("Latest completed season has no non-pitcher batting PA.")
59
+ league_rates = counts.groupby(history["Season"]).sum().div(league_pa, axis=0)
60
+ expected = league_rates.reindex(history["Season"]).set_axis(history.index).mul(exposure, axis=0)
61
+ evidence = counts.mul(weights, axis=0).groupby(history["PlayerID"]).sum()
62
+ evidence_pa = exposure.groupby(history["PlayerID"]).sum()
63
+ baseline = expected.groupby(history["PlayerID"]).sum().div(evidence_pa, axis=0)
64
+ return (evidence + 1200 * baseline).div(evidence_pa + 1200, axis=0), evidence_pa, league_rates
65
+
66
+
67
+ def _playing_time(history: pd.DataFrame, year: int) -> pd.Series:
68
+ prior = (
69
+ history.pivot_table(index="PlayerID", columns="Season", values="PA", aggfunc="sum")
70
+ .reindex(columns=[year - 1, year - 2], fill_value=0)
71
+ .fillna(0)
72
+ )
73
+ return 0.5 * prior[year - 1] + 0.1 * prior[year - 2] + 200
74
+
75
+
76
+ def _age_rates(
77
+ rates: pd.DataFrame, people: pd.DataFrame, year: int
78
+ ) -> tuple[pd.DataFrame, pd.Series]:
79
+ birth = pd.to_numeric(people["birthYear"], errors="coerce")
80
+ age = year - birth
81
+ if birth.isna().any() or not np.isfinite(age).all() or ((age < 15) | (age > 70)).any():
82
+ raise ProjectionError(
83
+ "Valid birthYear is required for every batter in the projection population."
84
+ )
85
+ adjustment = (age - 29) * np.where(age > 29, 0.003, 0.006)
86
+ aged = rates.copy()
87
+ positive = ["1B", "2B", "3B", "HR", "BB", "HBP", "R", "RBI", "SB", "IBB"]
88
+ negative = ["SO", "OUT", "CS", "GDP"]
89
+ aged[positive] = aged[positive].mul(1 - adjustment, axis=0)
90
+ aged[negative] = aged[negative].mul(1 + adjustment, axis=0)
91
+ return aged, age
92
+
93
+
94
+ def _bounded_rebaseline(values: pd.Series, target: float, cap: pd.Series) -> pd.Series:
95
+ """Preserve a contextual league total while respecting per-player capacity."""
96
+ capacity = float(cap.sum())
97
+ tolerance = max(1e-8, capacity * 1e-9)
98
+ if target < 0 or target > capacity + tolerance:
99
+ raise ProjectionError("Contextual league target exceeds its available event capacity.")
100
+ if target == 0:
101
+ return values * 0
102
+ if target >= capacity:
103
+ return cap.copy()
104
+ # Normalize weights so target/smallest weight is a finite upper bound.
105
+ weights = np.maximum(values.to_numpy(dtype=float), 1e-12)
106
+ weights = weights / float(weights.max())
107
+ limits = cap.to_numpy(dtype=float)
108
+ low, high = 0.0, target / float(weights.min())
109
+ for _ in range(150):
110
+ scale = (low + high) / 2
111
+ result = np.minimum(weights * scale, limits)
112
+ if float(result.sum()) < target:
113
+ low = scale
114
+ else:
115
+ high = scale
116
+ return pd.Series(
117
+ np.minimum(weights * ((low + high) / 2), limits), index=values.index, name=values.name
118
+ )
119
+
120
+
121
+ def _project_context(
122
+ rates: pd.DataFrame, pa: pd.Series, target: pd.Series, events: pd.DataFrame
123
+ ) -> pd.DataFrame:
124
+ projected = rates[list(CONTEXT_STATS)].mul(pa, axis=0)
125
+ desired = target[list(CONTEXT_STATS)] * float(pa.sum())
126
+ # Runs/RBI/baserunning are overlapping events, not extra PA outcomes.
127
+ for stat in ["R", "RBI", "SB", "CS"]:
128
+ projected[stat] *= safe_divide(float(desired[stat]), float(projected[stat].sum()))
129
+ projected["IBB"] = _bounded_rebaseline(projected["IBB"], float(desired["IBB"]), events["BB"])
130
+ projected["GDP"] = _bounded_rebaseline(projected["GDP"], float(desired["GDP"]), events["OUT"])
131
+ return projected
132
+
133
+
134
+ def _summarize(projected: pd.DataFrame, pa: pd.Series) -> pd.DataFrame:
135
+ out = projected.copy()
136
+ out["H"] = out[["1B", "2B", "3B", "HR"]].sum(axis=1)
137
+ out["AB"] = out["H"] + out["SO"] + out["OUT"]
138
+ out["PA"] = pa
139
+ out["TB"] = out["H"] + out["2B"] + 2 * out["3B"] + 3 * out["HR"]
140
+ out["BA"] = out["H"] / out["AB"].replace(0, np.nan)
141
+ # BB includes IBB already. SB/CS/R/RBI/GDP do not add PA or total bases.
142
+ out["OBP"] = (out["H"] + out["BB"] + out["HBP"]) / (
143
+ out["AB"] + out["BB"] + out["HBP"] + out["SF"]
144
+ ).replace(0, np.nan)
145
+ out["SLG"] = out["TB"] / out["AB"].replace(0, np.nan)
146
+ out["OPS"] = out["OBP"] + out["SLG"]
147
+ return out
148
+
149
+
150
+ def project_batters(seasons: pd.DataFrame, year: int) -> pd.DataFrame:
151
+ """Use all non-pitcher seasons for regression and population rebaselining."""
152
+ history = seasons[seasons["Season"].isin([year - 1, year - 2, year - 3])].copy()
153
+ if history.empty:
154
+ raise ProjectionError("No non-pitcher batting history in the three prior seasons.")
155
+ history = history[history["PA"] > 0].copy()
156
+ events = _events(history)
157
+ counts = events.join(_context_counts(history, events))
158
+ rates, evidence_pa, league_rates = _regress(history, counts, year)
159
+ pa = _playing_time(history, year)
160
+ people = (
161
+ history.sort_values("Season")
162
+ .drop_duplicates("PlayerID", keep="last")
163
+ .set_index("PlayerID")
164
+ .reindex(rates.index)
165
+ )
166
+ rates, age = _age_rates(rates, people, year)
167
+ event_rates = rates[EVENTS].div(rates[EVENTS].sum(axis=1), axis=0)
168
+ target = league_rates.loc[[year - 1]].iloc[0]
169
+ projected = _rebaseline(event_rates.mul(pa, axis=0), pa, target[EVENTS])
170
+ context = _project_context(rates, pa, target, projected)
171
+ out = _summarize(projected.join(context), pa)
172
+ out["Reliability"] = evidence_pa / (evidence_pa + 1200)
173
+ out["Age"], out["Name"], out["Year"] = age, people["Name"], year
174
+ out = out.reset_index()
175
+ return out[[*IDENTITY_COLUMNS, *BATTING_STAT_COLUMNS, "Reliability"]]
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
 
8
8
  import pandas as pd
9
9
 
10
- from backcaster.data import DataFetchError, PlayerLookupError
10
+ from backcaster.data import DataLoadError, PlayerLookupError
11
11
  from backcaster.lahman import LahmanSource
12
12
  from backcaster.marcel import ProjectionError
13
13
  from backcaster.outputs import to_cli_table, to_csv, to_html
@@ -25,12 +25,19 @@ def _default_data_dir() -> str:
25
25
  return os.environ.get("LAHMAN_DATA_DIR", default)
26
26
 
27
27
 
28
+ def _validate_output_options(output_format: str, out: str | None) -> None:
29
+ if output_format == "cli" and out is not None:
30
+ raise ValueError("--out cannot be used with --format cli; choose --format csv or html")
31
+ if output_format in _FILE_RENDERERS and not out:
32
+ raise ValueError(f"--out is required for {output_format} output")
33
+
34
+
28
35
  def _render(df: pd.DataFrame, output_format: str, out: str | None) -> None:
36
+ _validate_output_options(output_format, out)
29
37
  if output_format == "cli":
30
38
  print(to_cli_table(df))
31
39
  elif output_format in _FILE_RENDERERS:
32
- if not out:
33
- raise ValueError(f"--out is required for {output_format} output")
40
+ assert out is not None
34
41
  _FILE_RENDERERS[output_format](df, out)
35
42
  else:
36
43
  raise ValueError(f"Unsupported format: {output_format}")
@@ -47,7 +54,7 @@ def build_parser() -> argparse.ArgumentParser:
47
54
  else:
48
55
  sp.set_defaults(kind=kind)
49
56
  sp.add_argument("--format", choices=_OUTPUT_FORMAT_CHOICES, default="cli")
50
- sp.add_argument("--out")
57
+ sp.add_argument("--out", help="Output path (required for csv/html; invalid with cli)")
51
58
  sp.add_argument(
52
59
  "--data-dir",
53
60
  default=_default_data_dir(),
@@ -56,7 +63,11 @@ def build_parser() -> argparse.ArgumentParser:
56
63
 
57
64
  pp = sub.add_parser("player")
58
65
  add_common(pp)
59
- pp.add_argument("--name", required=True)
66
+ player_selector = pp.add_mutually_exclusive_group(required=True)
67
+ player_selector.add_argument("--name", help="Player name (must identify one qualified player)")
68
+ player_selector.add_argument(
69
+ "--player-id", help="Exact Lahman player ID (for example, bettsmo01)"
70
+ )
60
71
 
61
72
  tp = sub.add_parser("team")
62
73
  add_common(tp)
@@ -75,16 +86,18 @@ def main(argv: list[str] | None = None) -> int:
75
86
  parser = build_parser()
76
87
  args = parser.parse_args(argv)
77
88
  try:
89
+ _validate_output_options(args.format, args.out)
78
90
  result = LahmanSource(args.data_dir).project(
79
91
  args.command,
80
92
  args.year,
81
93
  args.kind,
82
94
  name=getattr(args, "name", None),
83
95
  team=getattr(args, "team", None),
96
+ player_id=getattr(args, "player_id", None),
84
97
  )
85
98
  _render(result, args.format, args.out)
86
99
  return 0
87
- except (DataFetchError, PlayerLookupError, ProjectionError, ValueError) as exc:
100
+ except (DataLoadError, PlayerLookupError, ProjectionError, ValueError, OSError) as exc:
88
101
  print(f"Error: {exc}", file=sys.stderr)
89
102
  return 2
90
103
 
@@ -1,8 +1,8 @@
1
1
  """Errors reported when local source data cannot be loaded or resolved."""
2
2
 
3
3
 
4
- class DataFetchError(RuntimeError):
5
- pass
4
+ class DataLoadError(RuntimeError):
5
+ """Local source files are missing, unreadable, or invalid."""
6
6
 
7
7
 
8
8
  class PlayerLookupError(RuntimeError):