# Shade Map: geometry and sun validation

Data refresh 2026-10-01 (the spring training parks in Arizona and Florida, section 10;
the 30 MLB parks are unchanged since 2026-09-25). Engine: `public/shademap/solar.js` and
`public/shademap/shade.js`. Tests: `test/shademap.test.mjs` and
`test/shademap-spring.test.mjs`. The method as a whole, with figures, is written up in
`public/shademap/methods.html` (https://craigmerry.com/shademap/methods.html). Regenerate
the calendar tables with
`node scripts/shademap/validate_calendar.mjs --vendor <ballpark-shadow-geometry clone>`.

The rim profiles, home plate coordinates, centre-field bearings and the shadow calendar
used below are measurements by **Adrien Peltzer**, from
https://github.com/apelt001/ballpark-shadow-geometry at commit
`dec934ba48b4fdc2f01ac18c4495610e9595e784`, released under **CC BY 4.0**. Code in that
repository is MIT. `rims.json` and `parks.json` carry the same attribution.

## 1. What the vendor horizon profile is, and whether seats can use it

Read from `scripts/lidar_rim_pins.py` and `data/park_geometry_COLUMNS.md`:

- Origin: **home plate**, from `data/sun_shadow/plate_overrides.csv` (not the
  `park_geometry.csv` coordinates, which are stadium centroids 33 to 60 m off).
- Vertical datum: field level z0 = median of ground-classified USGS 3DEP lidar returns
  within 30 m of the plate. `height_ft` is above that.
- Per 1 degree azimuth bin about the plate, the 4th highest return by elevation angle
  (K = 4 rejects birds and wires). Each row keeps that return's plan position
  (`east_m`, `north_m`, metres), ground distance (`dist_ft`, feet) and height
  (`height_ft`, feet). `el_deg` is the elevation angle **as seen from the plate at field
  level**, observer height zero.
- Narrow sharp-edged structures (light banks, masts) are suppressed: the `*_rim_*`
  columns replace them with the local rolling-median rim, which is what the vendor's own
  calendar uses. We use `east_rim_m`, `north_rim_m`, `height_rim_ft`.
- Search radius 250 m, so far skyline (downtown towers, hills) can win a bin.

Because every bin carries a real 3-D point, not just an angle, the profile can be
rebuilt as a plan polyline about the plate, which is what `shade.js` does. It is not
only valid at the plate, but it is biased toward the plate's view:

1. **Selection.** In each bin the winner is the point highest *as seen from the plate*.
   A structure hidden from the plate by a taller one behind it (a lower nearer roof, the
   underside of an upper deck) is absent, and in some bins a distant tall building wins
   over the grandstand. A seat close to that hidden structure would be shaded by it and
   the model cannot know.
2. **One point per degree from the plate.** The rim is a single star-shaped curve about
   the plate; overhangs and upper decks above lower seating are not represented. Overhang
   rules cover this only where data exists (Sutter Health Park).
3. **What it does represent well** is the top edge that casts the big afternoon shadow:
   for a seat whose sun ray crosses the rim on the far side of the bowl, the crossing
   point is the same physical edge the plate sees, seen from tens of metres closer; for a
   seat with the sun behind it, the crossing is the top edge directly behind, which is
   what the plate also sees in that bin.
4. **Vintage.** Lidar collections range from 2010 (Oracle Park) and 2011 (Angel Stadium)
   to 2022. `parks.json` records the 3DEP project for each park.

Independent check of point 3: the vendor also hand-placed Google Earth pins on the rim
edge at 11 parks. For each hand pin, the lidar rim at the same azimuth from the plate:

| park | hand pins | median plan offset, pin to lidar rim at the same azimuth (m) | median height diff, lidar minus hand (m) |
|---|---|---|---|
| Oriole Park at Camden Yards | 8 | 1.1 | +0.5 |
| Great American Ball Park | 12 | 8.3 | +1.6 |
| Citi Field | 10 | 0.5 | -0.1 |
| UNIQLO Field at Dodger Stadium | 12 | 3.7 | +0.9 |
| Nationals Park | 11 | 1.5 | +0.9 |
| Oracle Park | 6 | 1.1 | +0.4 |
| PNC Park | 5 | 6.6 | +1.4 |
| Progressive Field | 8 | 3.1 | +0.7 |
| Target Field | 8 | 8.3 | -0.1 |
| Truist Park | 6 | 6.7 | +2.2 |
| Yankee Stadium | 13 | 1.3 | +1.7 |

The lidar points sit within about 1 to 8 m in plan and 2 m in height of where a person
put the rim edge by eye, so they are physical rim positions, usable for seats. The
larger error for seat-level answers at the 21 measured parks is the **generic bowl**
(section 6), not the rim.

## 2. Sun position

`solar.js` implements the NOAA Solar Calculator algorithm (Meeus) with NOAA's refraction,
from UTC. Reference values, two independent sources, fetched 2026-09-25:

- **NOAA**: the calculator's own code, `https://gml.noaa.gov/grad/solcalc/main.js`
  (sha256 `3832956f24724eafacf9e18173b9299b1554d6252bed0784d9c9aca6d9f54856`), executed
  headless with its `calculate()` arithmetic, rounded as the page rounds. These are the
  numbers the page shows for these inputs; the page itself was not driven in a browser.
- **JPL Horizons** API, Sun, geodetic site, refracted apparent azimuth and elevation.

| site | UTC | NOAA az / alt | JPL az / alt | ours az / alt |
|---|---|---|---|---|
| Sutter Health Park | 2026-09-27 19:05 | 160.46 / 47.84 | 160.461 / 47.835 | 160.455 / 47.835 |
| Fenway Park | 2026-06-21 20:00 | 260.40 / 45.90 | 260.398 / 45.894 | within 0.02 |
| Truist Park | 2026-12-21 21:00 | 227.53 / 15.00 | 227.532 / 15.000 | within 0.02 |
| Coors Field | 2026-03-20 22:30 | 242.46 / 29.28 | 242.453 / 29.274 | within 0.02 |
| Petco Park | 2026-09-22 01:30 | 268.75 / 2.87 | 268.753 / 2.862 | within 0.02 |

The test holds all five to 0.5 deg against both sources and to 0.02 deg against NOAA.

## 3. Park clocks and DST

Local date and wall-clock time go to UTC through the park's IANA zone with
`Intl.DateTimeFormat` only. Tested each side of the 2026 changes in Los Angeles
(03-07 / 03-09, 10-31 / 11-02), Toronto, and Phoenix (no DST). Wall-clock times that
do not exist or happen twice follow JavaScript Temporal's "compatible" rule:

- nonexistent (02:30 on 2026-03-08 in Los Angeles) moves forward by the gap to 03:30 PDT,
  status `nonexistent`;
- repeated (01:30 on 2026-11-01 in Los Angeles) takes the first, 01:30 PDT (08:30 UTC),
  status `ambiguous`.

## 4. Shadow on home plate against the vendor calendar

Definition, matched to the vendor's `scan_day()` in `scripts/kml_shadow_windows.py`: an
observer at home plate at field level (no eye height); scan 10:00 to 22:00 local in
1-minute steps; skip minutes with the sun at or below the horizon; report the first
minute at which the plate is shaded **while the mound is still lit** (mound 60.5 ft out
along the CF bearing, 10 in up). The vendor value is `plate_shadow` in
`data/sun_shadow/shadow_window_calendar.csv`, source `lidar`. Its reference year is 2026,
so the requested dates exist exactly; no nearest-day substitution was needed. A calendar
value of 10:00 means "already shaded when the scan starts".

Three parks with different orientations (CF bearing 26, 85 and 157.8 deg):

| park | CF bearing | date | ours | theirs | diff (min) | our sun az / alt at shade | their sun az / alt |
|---|---|---|---|---|---|---|---|
| Oracle Park | 85 | 2026-03-20 | 16:32 | 16:33 | -1 | 241.9 / 31.5 | 242.1 / 31.3 |
| Oracle Park | 85 | 2026-06-21 | 17:40 | 17:40 | 0 | 276.5 / 31.7 | 276.5 / 31.7 |
| Oracle Park | 85 | 2026-09-22 | 16:16 | 16:17 | -1 | 241.5 / 31.6 | 241.7 / 31.5 |
| Oracle Park | 85 | 2026-12-21 | 11:49 | 11:42 | +7 | 175.1 / 28.7 | 173.3 / 28.5 |
| Truist Park | 157.8 | 2026-03-20 | 16:46 | 16:46 | 0 | 241.1 / 35.9 | 241.1 / 35.9 |
| Truist Park | 157.8 | 2026-06-21 | 17:07 | 17:08 | -1 | 271.1 / 43.9 | 271.2 / 43.7 |
| Truist Park | 157.8 | 2026-09-22 | 16:31 | 16:31 | 0 | 241.0 / 35.9 | 241.0 / 35.9 |
| Truist Park | 157.8 | 2026-12-21 | 15:05 | 15:15 | -10 | 217.0 / 22.7 | 219.0 / 21.4 |
| UNIQLO Field at Dodger Stadium | 26 | 2026-03-20 | 16:26 | 16:26 | 0 | 246.1 / 31.2 | 246.1 / 31.2 |
| UNIQLO Field at Dodger Stadium | 26 | 2026-06-21 | 17:46 | 17:43 | +3 | 281.4 / 26.6 | 281.1 / 27.2 |
| UNIQLO Field at Dodger Stadium | 26 | 2026-09-22 | 16:11 | 16:11 | 0 | 245.9 / 31.2 | 245.9 / 31.2 |
| UNIQLO Field at Dodger Stadium | 26 | 2026-12-21 | 10:00 | 10:00 | 0 | 151.4 / 26.7 | 151.4 / 26.7 |

All 21 measured parks on the same four dates: 75 of 84 comparable, median |diff| 1 min,
90th percentile 6 min. Nothing was tuned.

**Separating sun from geometry.** Re-running the vendor's own method (their 5-degree
lidar KML pins, elevation angle interpolated linearly in azimuth, their `pin_overrides.csv`
applied, observer at their plate and mound pins) with **our** sun and clock reproduces
**84 of 84** calendar entries to the minute. So the sun, the time zones and DST agree
exactly; every remaining difference is rim geometry: our 1-degree polyline and ray cast
against their 5-degree pins.

Every case over 6 minutes, or with a window on one side only:

| park | date | ours | theirs | diff (min) | vendor method, our sun | ours from 12:00 | vendor method from 12:00 |
|---|---|---|---|---|---|---|---|
| Citizens Bank Park | 2026-12-21 | 11:28 | none | n/a | none | none | none |
| Comerica Park | 2026-03-20 | 16:46 | 16:54 | -8 | 16:54 | 16:46 | 16:54 |
| Comerica Park | 2026-09-22 | 16:31 | 16:39 | -8 | 16:39 | 16:31 | 16:39 |
| Coors Field | 2026-03-20 | 16:01 | 10:00 | 361 | 10:00 | 16:01 | none |
| Coors Field | 2026-09-22 | 15:47 | 10:00 | 347 | 10:00 | 15:47 | none |
| Oracle Park | 2026-12-21 | 11:49 | 11:42 | 7 | 11:42 | 12:00 | 12:00 |
| Progressive Field | 2026-06-21 | 17:55 | 17:45 | 10 | 17:45 | 17:55 | 17:45 |
| Truist Park | 2026-12-21 | 15:05 | 15:15 | -10 | 15:15 | 15:05 | 15:15 |

Explanations, each checked against the 1-degree profile:

- **Truist Park 12-21 (-10)**: the rim steps from 15.8 to 22.6 deg between the 215.5 and
  216.5 deg bins (the edge of a taller section). The 5-degree pins at 215.5 and 220.5
  interpolate 18.3 deg at the sun's azimuth of 217.0, so the sun at 22.7 deg clears it;
  the real step shades the plate 10 minutes sooner.
- **Oracle Park 12-21 (+7)**: the mirror image. The rim jumps from 24.9 to 33.3 deg at
  175.5 deg; pin interpolation from 170.5 smears the tall section 5 degrees early
  (29.4 deg at 173.5 against 24.3 measured), so the calendar shades the plate earlier.
- **Progressive Field 06-21 (+10)**: bins 271.5 to 273.5 deg are a 3-degree notch at
  31 deg between two suppressed light banks (both carried at about 34 deg); the pins at
  270.5 and 275.5 bridge it at 34 deg. The sun passes through the notch from 17:45 to
  17:52, so the plate stays lit until 17:55.
- **Comerica Park 03-20 and 09-22 (-8)**: a real bump to 30.9 deg at 236.5 to 238.5 deg
  that falls between pins (interpolated 28.5 to 29.2 deg).
- **Coors Field 03-20 and 09-22 (+361, +347)**: two effects. In the morning the pins at
  120.5 (36.6 deg) and 125.5 (33.6 deg) bridge a notch at 121.5 to 123.5 deg (32.1 to
  32.5 deg), so the calendar puts the plate in shade at 10:00 and the 1-degree profile
  does not: sampling. In the afternoon both methods then hinge on a knife edge: the plate
  and mound thresholds at azimuth 233 to 238 are within 0.1 to 0.7 deg of each other, so
  whether a plate-shaded, mound-lit minute exists at all depends on sub-degree rim detail.
  Ours finds one at 16:01 (15:47); the vendor method finds none after noon.
- **Citizens Bank Park 12-21 (one-sided)**: the plate is shaded all of midday in both
  (plate threshold 33 deg, sun at most 26.5 deg). The sun at culmination sits within
  0.2 deg of the **mound** threshold for about 30 minutes, so "mound still lit" flips on
  0.1 deg differences in the mound profile. Not a plate-shadow disagreement.

No difference over 10 minutes remains unexplained, and none is a bug in the sun, clock
or ray cast. They show that 5-degree sampling misses real rim features of 1 to 3
degrees; the finer profile is the one Shade Map uses. For seats, the lesson is that
shade-line times near a rim step or notch carry about 10 minutes of uncertainty from the
rim alone.

## 5. Performance

Measured by the test on this Raspberry Pi 5 (4 cores) **while four Quantum ESPRESSO
`ph.x` processes held every core (load average about 7)**, so an idle Pi or a phone
should do better. Whole-game grid for one park, 3 h at 5-minute steps (37 steps):

- LAD, 5 zones x 3 depth bands, 10 sample seats per cell, 360-segment rim, first
  computation for the park: 18 to 37 ms across runs; repeat 3 to 14 ms.
- Sutter Health Park, every section and row (644 seats): 20 to 50 ms.
- The first call after page load also pays JIT and `Intl.DateTimeFormat` start-up:
  60 to 80 ms once.

Sun positions are computed once per time step and shared; the rim polyline is built once
per park; each time step buckets rim segments by their offset across the sun direction so
a seat tests a handful of segments instead of all of them (checked equal to brute force).

## 6. Seats: what is modelled and what is assumed

- **Measured parks (21):** rim from lidar; bowl generic (`bowl_defaults` in
  `parks.json`): field wall 18 m behind the plate and 15 m outside the foul lines, seats
  rising from 2 m above the field to 3 m below the rim, last row 1.5 m inside the rim,
  eye 1.1 m. Zones by bearing relative to the CF bearing: RF corner 50 to 80, 1B side 80
  to 150, behind home 150 to 210, 3B side 210 to 280, LF corner 280 to 310 deg.
- **Retractable roofs (7):** generic circular rim, 40.5 m high at 58.2 m (the median over
  the 21 measured parks of each park's median rim height and distance over the arc
  behind the plate, back axis +/- 90 deg). Roof assumed open. It ignores the roof panels
  and trusses that cast most of the real shade there: low confidence.
- **Tropicana Field:** dome flag only; every seat is under the roof.
- **Sutter Health Park:** section 7.
- **Spring training parks (19 mapped of 23):** rim and seat surface from lidar, seats in
  the same five zones plus three outfield zones where seating was found behind the wall,
  no overhangs; section 10.

## 7. Sutter Health Park

Since 2026-09-25 Sutter uses measured geometry: the lidar rim, home plate and bearing
from section 8, and seats placed from the lidar bowl profile. Its label is
`measured_bowl` ("Measured bowl"). The section boundaries, row counts and overhang rows
are still estimated. Until then it used the section overlay ported from the Athletics
shade-map UI prototype; that version is kept below as the old baseline.

**Measured** (USGS 3DEP lidar, project `USGS_LPC_CA_NoCAL_Wildfires_B5a_2018`, flown
2019-02-10 and 2019-02-12; method adapted from Adrien Peltzer's ballpark-shadow-geometry,
code MIT, data CC BY 4.0):

- Rim: `rims.json` `parks.ATH`, 360 one-degree bins in the same format as the 21 vendor
  rims, with its own attribution. The ATH record has `rim: { model: 'lidar' }`.
- Home plate 38.5802061, -121.5140357 (about 1 m uncertainty) and CF bearing 46.1 deg.
  The record used the MLB API venue point, about 140 m away, and 46 deg before.
- Seats: every section carries a `profile`: distance from the plate to the front row and
  to the last row the lidar sees, and the height of each above the field, read from
  `bowl_profile` at the section's middle bearing. Rows are spread evenly between the two
  along the plate ray, and seat height is interpolated linearly from front to top
  (`sectionEdge` / `sectionSeatAt` in shade.js). A front reading under 0.3 m above the
  field is a warning-track slope or a dugout read as the wall; it takes the mean front
  distance and height of its neighbours (bearings 165, 180, 185, 200, 210, 215, 240,
  245, 265, 275, 345 and 350). Fronts come out 21.6 to 29.9 m from the plate, top rows
  42.4 to 59.5 m, front rows 0.8 to 1.6 m and top rows 4.6 to 6.4 m above the field.

**Estimated:** the section boundaries (the prototype layout: 101 to 107 outside the 1B
line, 108 to 115 in eight equal slices behind home, 116 to 122 outside the 3B line; now
used only for each section's bearings from the plate), row counts (30; 26 at 101, 102,
121 and 122) and overhang rows (3 at 102 to 106, 2 at 107 to 118, shaded all day). On
the overhang: ShadedSeats (updated 2026-03-24) says only the very back of 103 to 105 is
under the club-level overhang, and the 8 Jun 2025 Getty photo (photo 2 in section 9)
shows shade in the back 3B rows under the suite overhang and under the RF party-deck
roof. Nothing I found confirms or contradicts the rows at 107 to 118, and lidar cannot
see under a roof, so they stay.

`scripts/shademap/build_parks.mjs` reads `sutter-lidar.json` as a third input and writes
all of this, so the byte-for-byte rebuild test covers it.

**Why the switch.** The lidar version is measured and the prototype was guessed, and the
things that held it back are settled: the "05:08" board in the 10 Jul 2025 photo is a
time-of-day clock (five other readings of the same box match known times), and nothing
built for 2025 or 2026 sits above the stands (the Rooftop is above left field, the new
clubhouse beyond it, the video boards in the outfield). Against the nine dated photos in
section 9 the two versions agree at midday and at 5:24 pm, are both late on the 3B side
at 4:31 and 5:08 pm, and the lidar version is closer on the two evening photos.

**Regression, then and now.** Sunday 2026-09-27, first pitch 12:05 PDT, 5-minute steps
to 2:45 pm (33 steps), one seat at the middle of the section.

| seat | prototype | A: prototype geometry (old baseline) | C: lidar, shipped | C seat: from plate, above field |
|---|---|---|---|---|
| 104 row 22 | shaded all game (1.00), line at row 22 at first pitch | 1.00, line row 22, lowest row 21 | 0.24: 12:05 to 12:40 | 41.8 m, 4.19 m |
| 107 row 20 | sun all game (0.00), line never below row 21 | 0.00, lowest row 21 | 0.55: 12:05 to 12:20, then from 1:40 pm | 36.8 m, 3.81 m |
| 109 row 19 | first shade 2:10 pm, 0.24 | 2:10 pm, 0.24 | 0.45: 1:05 and 1:10 pm, then from 1:45 pm | 35.6 m, 3.71 m |
| 112 row 18 | first shade 2:35 pm, 0.09 | 2:35 pm, 0.09 | 0.27: from 2:05 pm | 34.9 m, 3.78 m |

All four moved, and the reason is the geometry, not the shade engine. The prototype put
row 22 of 104 at 9.6 m above the field and the structure 7 m above its top row, 19.8 m
up. The lidar puts that seat at 4.2 m and the roof at 16 m, about 10.5 m above the top
row. The seats are about 7 m lower and the roof relatively taller and closer, so these
mid-bowl rows see shade earlier. In the prototype the two errors largely cancelled,
which is why its answers looked plausible. From 1:00 to 2:00 pm the sun sits within
about 1.5 deg of the roof edge as seen from 109 row 19, so a one-degree bin where the
lidar roof edge reads 16.6 m instead of 16.0 m decides its two early steps.

Over all 644 section rows, A against C: the verdict (all game / never / partial) changes
for 70 (11%); all game 94 to 78, never 446 to 444, partial 104 to 122; median
first-shade change 0 min over the 173 rows shaded in both.

The prototype geometry is kept in `test/fixtures/sutter-prototype-park.json` (the ATH
record as it was, `rim.model` 'sections') and is still tested. With the brief's 1.1 m
eye it reproduces the prototype exactly. The prototype floors its shaded-row count,
which is worth about 0.1 m of eye height at these sun angles, so with its own 1.0 m eye
the ray cast is one step more shaded at 107 and 112. The tolerance (verdicts exact,
first shade within 15 minutes, fraction within 0.10, shade line within one row) is
justified in the test file.

## 8. Sutter Health Park from lidar

Measured 2026-09-25 with `scripts/shademap/sutter_lidar_fetch.py`, `sutter_lidar.py`
and `sutter_lidar_compare.mjs`; output `public/shademap/data/sutter-lidar.json`.
Wired into the model on 2026-09-25 as scenario C below (section 7). The rest of this
section is the measurement and the comparison made before the switch.
Lidar: USGS 3DEP, public domain. Method adapted from Adrien Peltzer's
ballpark-shadow-geometry (`lidar_rim_pins.py`, `lidar_check_cf.py`, commit dec934b;
code MIT, data CC BY 4.0).

**Source.** `USGS_LPC_CA_NoCAL_Wildfires_B5a_2018` is the only 3DEP project covering
the park. It is published as an Entwine octree, not tiles: 88 EPT nodes to depth 12
(~0.25 m node spacing), 1.2 M returns within 250 m of the plate, no starved azimuth
bin (fewest returns in a 1 degree bin: 2,409). Point GPS times put the flight on
**2019-02-10 and 2019-02-12** (the "2018" is the project name). No building class:
roofs are class 1.

**Home plate, from the lidar alone.** The home-plate dirt circle is dark in lidar
intensity and the mound is a 0.29 m bump (by rule 10 in). The darkest 13 ft disc gives
the circle centre; the dz-weighted centroid of the bump gives the mound centre, 18.81 m
away. The plate (back point) is placed 59 ft back from the mound centre along that line:
**38.5802061, -121.5140357**. It is 0.83 m from the dirt-circle centre, where the rule
predicts about 0.3 m, so the uncertainty is about 1 m. The MLB API venue point that
parks.json used until the switch (38.57994, -121.51246) is about 140 m ESE of the plate, which is fine for
the sun and wrong for geometry.

**CF bearing: 46.1 deg** (plate-circle centre to mound centre). The vendor's disc method
gives 46.2 but is weak here (+0.03 m peak) because the plane fit is tilted by the sunken
area behind the plate; the free mound peak reads 47.1. The brief's and MLB API's 46 deg
are confirmed; the OSM-based 40 deg low end is not.

**Rim.** K = 4 skyline per 1 degree bin, 15 to 250 m out, field level z0 = median
ground return within 30 m of the plate. Automatic suppression (vendor rule: at most
32 deg wide, sharp step at both ends) removed seven light towers, 32 to 48 m tall, at
azimuths about 28, 43-50, 64, 110, 174-178, 277-281 and 342-344. **No manual edits.**
Candidates I looked at and left in place:

- Outfield bins (about 350 to 108 deg) are won by trees and buildings 80 to 245 m out
  (4 to 8 deg high as seen from the plate). They are real. They matter only near sunset
  and do not affect the bowl in a day game.
- A thin element 22.5 to 26.3 m above the field, 2 to 3 m deep, at 53 to 56 m from the
  plate, across azimuth 263 to 305 (behind sections 115 to 118). It sits about 10 m
  above the 16 m roof. It has only 306 returns, and it is too wide for the narrow-structure
  rule. If it is porous (a truss, screen or sign frame) it overstates shade on the
  3B side late in the day. The photos in section 9 show more 3B-side shade than the
  model, not less, so if anything it is not overstating.

Behind the bowl the winner is a flat roof **15.9 to 16.1 m above the field**, 44 to 47 m
from the plate, from about 140 to 262 deg and 305 to 345 deg. At the 1B end (110 to 135
deg) the first structure behind the seats is about 9.7 m high, with a 15 to 16 m roof 8 m
further back. The field is sunk about 6.7 m below street level.

**Bowl, measured.** From the median return height in 0.5 m radial bins within 1 degree
of the bearing (22 section middles plus every 5 deg; `bowl_profile` in the JSON). Seats
under a roof are invisible, so "top" is the last visible row.

| where | front (m from plate) | last visible row (m) | depth (m) | row height front to top above field (m) | structure above top row (m) |
|---|---|---|---|---|---|
| 1B end, 101-102 | 28-30 | 53-57 | 25-27 | 1.5 to 4.8 | 4.9 (first structure), 10.5 (roof behind) |
| 1B, 103-107 | 22-27 | 44-50 | 22-25 | 1.5 to 5.4 | 10.4 to 11.9 |
| behind plate, 108-114 | 21-23 | 43-45 | 20-23 | 0.9 to 5.4-6.4 | 9.6 to 10.7 |
| 3B, 115-118 | 22-23 | 42-44 | 20-21 | 0.8-1.6 to 4.8-5.5 | 5.1 to the 16 m roof; up to 21 to the 26 m element |
| 3B end, 119-122 | 24-30 | 50-59 | 25-30 | 1.6 to 5.5-5.7 | 10.5 to 11.7 |

Against the overlay assumptions: 30 rows x 0.85 m = 25.5 m of depth is about right
(22 to 30 m measured). The bowl is further out: the front is 21 to 23 m behind the
plate, not 18.3 m. It is also **much flatter and lower**: the top row is about 5.4 m
above the field, not 12.8 m (a rise of about 0.15 m per row, not 0.40 m), and the rake
is about 12 deg. The structure behind the top row is about **10.5 m above it** at every
section, corners included, not 7 m behind 102-120 and 3 m at the corners. In absolute
height the roof is 16 m, where the overlay puts it at 19.8 m (14.2 m at the corners).
The seats sit 7 m lower, so the rim above them ends up about the same. The bowl also
continues past both overlay ends: the 1B side to about 108 deg and the 3B side to about
350 deg, which supports the research note that sections run past 122.

**Effect on the shade results** (computed before the switch, with A's section bearings,
cf 46 deg and the MLB venue point; the shipped C in section 7 gives the same four seats
and 70 verdict changes instead of 72). Sun 2026-09-27, first pitch 12:05 PDT, 160 min, 5 min
steps, one seat per section and row (644 seats). A = current estimated overlay.
B = lidar rim substituted, seats unchanged (rim.model 'custom'). C = lidar rim with
seats re-placed from the measured bowl along the plate ray at each section's middle,
overhang rows as in A (a sensitivity case).

| seat | A estimated | B lidar rim, same seats | C lidar rim, lidar seats |
|---|---|---|---|
| 104 row 22 | 1.00, 12:05, all game | 0.00, never | 0.24, from 12:05, partial |
| 107 row 20 | 0.00, never | 0.00, never | 0.55, from 12:05, partial |
| 109 row 19 | 0.24, from 14:10 | 0.00, never | 0.45, from 13:05 |
| 112 row 18 | 0.09, from 14:35 | 0.00, never | 0.27, from 14:05 |

- A to B: verdict (all game / never / partial) changes for **163 of 644 seats (25%)**.
  All game goes from 94 to 47, never from 446 to 579, partial from 104 to 18. Of the
  seats shaded in both runs, the median change in first shade is 0 min, and 155 seats
  are shaded in only one of the two. B drops most shade because the overlay seats sit
  7 m too high for a 16 m roof. Six B seats lie beyond the lidar rim as seen from the
  plate. **B is not a usable model.**
- A to C: verdict changes for 72 of 644 seats (11%): all game 78, never 442, partial
  124. Median first-shade change 0 min over the 173 seats shaded in both runs. The
  overlay's too-high seats and too-high rim largely cancel.

**Photos.** Superseded by section 9, which uses nine dated photos and a confirmed clock.
The earlier reading of photo 6 (10 Jul 2025) here doubted the clock and suspected 2025
structure over the stands; neither holds up.

**Vintage.** What was built for the Athletics (MLB.com, the A's press release, the River
Cats ticket pages, KTXL, i5LED, Stadium Journey): the Rooftop group space for about 130
guests is above left field in home-run territory; a two-story clubhouse beyond the
left-field wall; a 75 ft 7 in by 31 ft 6 in video board in right-centre, the old board
turned into a ribbon above the left-field wall, two small LED displays at the backstop;
new field lights; redesigned clubs at concourse and second-deck level; shade covering at
the bullpens. For 2026 MLB.com lists dugout rooms, sod and batting-cage work. None of it
is over the seating bowl, and at 38.6 deg N the sun is never in the northern sky at game
time, so buildings beyond left field cannot shade the stands. The one open item is two
2026 City of West Sacramento permits at 400 Ballpark Dr to Best National Carports, a
carport and shade-canopy contractor: 26CAP00000001WQ ($200,000, applied 2026-04-13,
finaled) and 26CAP00000002VR ($190,000, applied 2026-06-03, permitted). Their location is
not published.

**What was done.** Rim and seats moved together (C); the rim alone (B) is not usable.

- rim: `rims.json` `parks.ATH`, written by `build_parks.mjs` from this file, and the ATH
  `rim` set to `{ "model": "lidar" }`.
- seats: per-section `profile` (front and top distance, front and top height) instead of
  a park-wide tread and rise, supported in `sectionSeatAt`, `sectionEdge` and the map
  drawing; a section without a profile is placed exactly as before.
- structure: `structure_above_top_row_m` dropped from the sections, since the lidar rim
  replaces it. The overhang rows stay.
- the prototype regression now pins C, and A is tested from a fixture (section 7).

## 9. Ground truth: dated photos

Nine Getty photos of Sutter Health Park from 2025 and 2026, each loaded and zoomed
(research 2026-09-25). Getty strips camera EXIF and shows only the date, so the time
comes from one of two places:

- **The clock box** on the left-field auxiliary board. It is a time-of-day clock: it read
  12:56 during the anthem before a 13:06 first pitch (8 Jun 2025), 04:31 and 05:24 during
  batting practice before 19:05 games, 10:11 before a 12:05 game, and 6:28 during the
  anthem before an 18:41 first pitch. For photo 6 the countdown readings are ruled out:
  05:08 before the 18:06 first pitch would be 18:00:52, after the photographer
  transmitted the frame (Getty `dateSubmittedVideo` 17:57:33 PDT). Only the stand-alone
  boxed hh:mm is the clock; the same wall also shows pitch counts and stats.
- **The MLB game feed** (`statsapi.mlb.com/api/v1.1/game/<gamePk>/feed/live`), which
  times every plate appearance: the batter and pitcher on the video board pin the frame
  to a window of a minute or a few.

Test: `test/shademap-photos.test.mjs`. Regions are runs of sections; only structure
shade counts, so the overhang rows are left out. "Shade" means at least 90% of the
region's rows shaded, "sun" at most 10%; "front" means rows 1 to 10. The time is a
window (a clock reading +/- 1 minute, or the feed's bounds), and a photo passes when all
its checks hold at one minute in the window. These rules were fixed before comparing,
and nothing was tuned. A is the prototype geometry, C the shipped lidar model; the
percentages are the share of the region's rows shaded.

| # | photo | time (PDT) and how | observed | A | C | verdict (C) |
|---|---|---|---|---|---|---|
| 1 | [2220370206](https://www.gettyimages.com/detail/news-photo/x/2220370206), Sun 8 Jun 2025 | 12:56, clock box during the anthem | 3B side sunlit front to back; 1B mid rows sunlit | 3B 0%, 1B 0% | 3B 0%, 1B 0% | match |
| 2 | [2218687353](https://www.gettyimages.com/detail/news-photo/x/2218687353), Sun 8 Jun 2025 | 14:37 to 14:39, feed: Wilson leading off the 5th against Sugano (reading the "5" is medium confidence; the loose bound is 13:14 to 14:46) | lower bowl sunlit everywhere; shade only under the suite overhang and the RF party-deck roof | at most 7% in any region | at most 8% (behind home, 3B half) | match |
| 3 | [2228392745](https://www.gettyimages.com/detail/news-photo/x/2228392745), Sun 3 Aug 2025 | 14:21 to 14:28, feed: top 5th, Perdomo batting, Bido pitching | lower bowl essentially all sun; thin band at the back under the suite overhang | at most 6% | at most 8% | match |
| 4 | [2285585948](https://www.gettyimages.com/detail/news-photo/x/2285585948), Sun 5 Jul 2026 | 14:41 to 14:42, feed: Serven batting against Perez, bottom 3rd | lower bowl sunlit everywhere | at most 6% | at most 8% | match |
| 5 | [2214553577](https://www.gettyimages.com/detail/news-photo/x/2214553577), Fri 9 May 2025 | 16:31, clock box, batting practice | 3B side and behind home in shade, shadow over the 3B dugout onto the warning track; 1B side sunlit | 1B 0%; 3B side (116-122) 46%, behind home 3B half 54% | 1B 0%; 3B side 56%, behind home 3B half 55% | 1B right; 3B side late: 90% at 17:02, all rows at 17:15 (31 to 44 min); behind home 3B half 90% at 17:12 (41 min) |
| 6 | [2224556111](https://www.gettyimages.com/detail/news-photo/x/2224556111), Thu 10 Jul 2025 | 17:08, clock box; transmitted 17:57 | 3B side fully shaded, wall to back, home to the LF corner, shadow onto the warning track; 3B half behind home shaded; 1B half front rows lit; 1B side sunlit | 1B, RF 0%; 1B half front 0%; 3B side 63%, 3B half 64% | 1B, RF 0%; 1B half front 0%; 3B side 70%, 3B half 67% | 1B side right; 3B side late: 90% at 17:20, all rows at 17:41 (12 to 33 min); 3B half 90% at 17:36 (28 min) |
| 7 | [2228135660](https://www.gettyimages.com/detail/news-photo/x/2228135660), Fri 1 Aug 2025 | 17:24, clock box, batting practice | 3B side and behind home fully shaded, shadow running diagonally to the backstop; 1B side sunlit | 3B side 87% (LF corner 121-122 only 50%); 3B half 100%, its front rows 100%; 1B 0% | 3B side 100%; 3B half 90%, its front rows 73%; 1B 0% | match, except the front rows behind home on the 3B half (full at 17:29, 5 min late) |
| 8 | [2284428762](https://www.gettyimages.com/detail/news-photo/x/2284428762), Sat 4 Jul 2026 | 18:28, clock box during the anthem | 3B side fully shaded; 1B side toward RF sunlit; the 1B sections next to home lit in front, shaded further back (edge hard to read) | 3B side 94%; 101-107 0%; front rows of 108-111 83% shaded | 3B side 100%; 101-107 0%; front rows of 108-111 50% shaded | match, except the front rows of 108-111 (a medium-confidence detail; neither version has them lit) |
| 9 | [2269728460](https://www.gettyimages.com/detail/news-photo/x/2269728460), Fri 3 Apr 2026 | 18:28 to 18:40, anthem before an 18:41 first pitch, no clock visible (the 4 Jul 2026 anthem for the same start was at 18:28) | nearly everything shaded (3B side, behind home, 1B near the infield); sun only on the outer 1B/RF-corner sections | 105-122 100%; RF corner 63% at 18:28, rising | 105-122 100%; RF corner 6% at 18:28, 35% at 18:34, 73% at 18:40 | match if taken at 18:28 or 18:29; A never matches in the window |

Summary: the lidar model matches the four midday photos and the 5:24 pm one, and the two
evening photos apart from a hard-to-read edge near home. It is late on the third-base
side in the 4:31 and 5:08 pm photos: the photos show all of it in shade with the shadow
out on the warning track, and the model gets there 30 to 45 minutes later (10 to 40
minutes by the 90% rule). The prototype geometry is late there too, by as much or more (its 3B side reaches 90%
at 17:21 and 17:43).

**What might explain the 3B lag.** None of these is confirmed.

- The rim is a skyline seen from home plate: one point per degree, the return that tops
  the horizon from the plate. Behind 115 to 118 that point is the thin 22.5 to 26.3 m
  element (section 8), and its height is read as the fourth-highest return in each bin,
  which for a sparse, thin face may sit below its real top. By my rough geometry, to
  shade the front rows of the 3B side at 4:31 pm it would need to be about 24 to 26 m
  high along its whole width; the rim has it at 20 to 26 m. Anything a 3B-side seat sees behind the plate's skyline point is dropped. A
  full height grid instead of a plate skyline would test this.
- Seat heights: at 116 to 118 the profile reads the front rows 0.8 to 1.6 m and the top
  rows 4.8 to 5.0 m above the field. Seats that sit lower than that would be shaded
  earlier.
- A canopy from the 2026 carport permits (26CAP00000001WQ, 26CAP00000002VR) could only
  matter for 2026 photos. Both misses are 2025 photos, so it cannot explain them.

## 10. Spring training parks from lidar

Measured 2026-10-01 with `scripts/shademap/spring_lidar_fetch.py` and
`scripts/shademap/spring_lidar.py`; output `public/shademap/data/spring-lidar.json`,
which `build_parks.mjs` turns into park records (bowl type `profile`) and rims;
`scripts/shademap/wall_check.py` writes the wall check, `data/wall-check.json`. Lidar:
USGS 3DEP, public domain. Method adapted from Adrien Peltzer's ballpark-shadow-geometry
(`lidar_rim_pins.py`, commit dec934b; code MIT, data CC BY 4.0), through the functions
already ported for Sutter Health Park (section 8).

**Which parks.** The MLB snapshot (`teams?sportId=1&hydrate=...,springVenue(...)`, fetched
2026-10-01) gives each club's spring venue and league: fifteen Cactus League clubs at ten
venues in Arizona and fifteen Grapefruit League clubs at thirteen in Florida. Each venue
becomes a park record keyed by its MLB venue id, so "Pick a game" queries the schedule
exactly as for the other parks. Rebuilt from this snapshot, the 30 MLB park records and
their rims are byte for byte the ones built from the 2026-09-25 snapshot.

**Four Florida parks are not mapped.** Their only USGS lidar (checked against the 3DEP
EPT index and The National Map, 2026-10-01) predates the stands, so a map would show
stands that are gone. They are listed in the park picker with the reason and no map:

- George M. Steinbrenner Field: lidar from 2007 (FL_HillsboroughCo_2007); the 2017
  renovation added outfield seating, a shaded right-field terrace and a 360-degree
  concourse.
- TD Ballpark: lidar flown December 2018 (FL_Peninsular_Pinellas_2018); rebuilt in 2019
  and 2020, reopened 24 February 2020.
- Clover Park: lidar flown March 2019 (FL_Peninsular_FDEM_StLucie_2018); renovated for
  spring training 2020.
- CoolToday Park: lidar flown December 2018 (FL_Peninsular_FDEM_Sarasota_2018), while
  the park was being built (it opened 24 March 2019): no infield yet, and the field
  finder found no field. Its MLB venue point is also about 8 km east of the stadium.

**Source.** Arizona: `AZ_MaricopaPinal_1_2020`, the only 3DEP project containing any of
the ten parks, flown 2020-11-20 to 2020-11-28. Florida: for each park the most recent
3DEP project containing it, flown 2018-06-10 to 2019-04-15 (table). Entwine octrees, read
to the first depth whose node spacing is at most 0.37 m (depth 12, about 0.30 m, in
Arizona; depth 11, 0.22 to 0.36 m, in Florida); 1.8 M to 6.1 M returns within 250 m of
each plate;
no starved 1 degree azimuth bin (fewest returns in a bin: 1,734). Point GPS times give the
flight dates. Arizona has no building class (roofs are class 1); the Florida projects put
roofs in class 6, which the method treats like any return. The anchor only centres the
download: the stadium outline's centroid from OpenStreetMap at 16 parks, the MLB venue
point at Camelback Ranch, Goodyear Ballpark and American Family Fields of Phoenix.

**Finding the stadium field.** A spring complex has up to a dozen fields, and there is
no hand pin, so the field is found in the lidar:

1. Mound candidates: on a 0.5 m grid of ground returns, the mean height within 1.5 m
   minus the mean of a 4.5 to 9 m ring, where that ring is flat (standard deviation under
   0.06 m) and the rise is 0.12 to 0.5 m (a mound is 10 in by rule). Up to 60 local
   maxima within 120 m of the anchor.
2. A candidate needs structures round it (90th percentile height of all returns 30 to 60
   m away at least 4 m; Roger Dean's low stands read 5.4 m, so 6 m was too strict) and an
   infield round it: the lidar intensity 6 to 40 m from it must be mirror-symmetric about
   some line through it (correlation at least 0.7, 1 degree search). Of those within 80 m
   of the anchor, the nearest. Only at Roger Dean did two qualify.
3. Mound centre: the dz-weighted centroid of the bump (Sutter's `mound_centre`).

At two Florida parks the finder sees no mound. On the measure of step 1 the 17 mounds it
chose rise 0.17 to 0.24 m. At LECOM Park (flown November 2018) the mound was gone:
nothing within 15 m of where it belongs rises more than 0.04 m, in ground or unclassified
returns. At Hammond Stadium at the Lee Health Sports Complex (June 2018) it is there, 0.19
m, but its returns, like most of that infield's, are class 1 (unclassified) rather than
2 (ground), and step 1 reads ground returns only. At both the plate comes from the
home-plate dirt circle instead: the darkest 13 ft disc within 4 m of a rough guess read
off the lidar intensity image (recorded in `spring_lidar_fetch.py`), then the symmetry
axis found about the point 20 m from it toward the open field; the plate is the circle
centre on the axis. `field_finder.mound_check` records the bump search at both parks. At
Hammond the mound in ground and unclassified returns sits 0.4 m from where that plate
and axis put it (59 ft out along the axis): a check on the fallback, which never saw it.

**Centre-field bearing.** The axis of mirror symmetry of the field's lidar intensity
(grass, dirt cutouts, base paths, the seating bowl) through the mound centre, 6 to 40 m
from it on a 0.5 m grid: 0.25 deg search, then 0.05 deg with a lateral offset of up to
1 m. Home is the end of the axis with the grandstand. The chosen axis correlates at 0.78
to 0.96, and the best axis more than 20 deg from it at 0.27 to 0.55, so no park has a
second candidate axis. Re-running the coarse search with the window cut to 25 m and to
30 m (`coarse_peak_deg_smaller_windows`) lands within 2.5 deg of the final axis at LECOM
Park, 1.3 deg at Tempe Diablo, 1.0 deg at Sloan Park and 0.8 deg or less elsewhere.

**Home plate.** The mound centre projected onto the axis, then 59 ft (17.98 m) toward
home: the mound circle's centre is 59 ft from the back point of the plate by rule. This
is Sutter's construction with the axis in place of the dirt-circle centre.

| park | clubs | lidar project, flown (UTC) | returns within 250 m | plate method | symmetry (best other axis) | CF bearing | plate to dirt-circle centre |
|---|---|---|---|---|---|---|---|
| American Family Fields of Phoenix (AFF) | Brewers | AZ_MaricopaPinal_1_2020, 2020-11-25 to 2020-11-26 | 3,044,316 | mound | 0.93 (0.41) | 55.4 | 0.35 m |
| Camelback Ranch (CBR) | Dodgers and White Sox | AZ_MaricopaPinal_1_2020, 2020-11-26 | 3,797,853 | mound | 0.93 (0.49) | 135.4 | 0.25 m |
| Goodyear Ballpark (GDY) | Guardians and Reds | AZ_MaricopaPinal_1_2020, 2020-11-20 | 2,802,293 | mound | 0.86 (0.37) | 43.9 | 0.00 m |
| Hohokam Stadium (HOH) | Athletics | AZ_MaricopaPinal_1_2020, 2020-11-20 to 2020-11-25 | 3,285,365 | mound | 0.89 (0.39) | 39.6 | 0.25 m |
| Peoria Stadium (PEO) | Mariners and Padres | AZ_MaricopaPinal_1_2020, 2020-11-26 to 2020-11-28 | 3,180,511 | mound | 0.92 (0.42) | 49.9 | 0.50 m |
| Salt River Fields at Talking Stick (SRF) | Diamondbacks and Rockies | AZ_MaricopaPinal_1_2020, 2020-11-26 | 3,835,544 | mound | 0.89 (0.50) | 72.8 | 0.25 m (faint circle) |
| Scottsdale Stadium (SCO) | Giants | AZ_MaricopaPinal_1_2020, 2020-11-25 to 2020-11-26 | 3,743,583 | mound | 0.91 (0.29) | 45.2 | 0.71 m |
| Sloan Park (SLN) | Cubs | AZ_MaricopaPinal_1_2020, 2020-11-20 | 2,942,923 | mound | 0.84 (0.50) | 31.5 | 0.25 m |
| Surprise Stadium (SUR) | Rangers and Royals | AZ_MaricopaPinal_1_2020, 2020-11-26 to 2020-11-28 | 2,924,331 | mound | 0.87 (0.41) | 44.4 | 0.71 m |
| Tempe Diablo Stadium (TDS) | Angels | AZ_MaricopaPinal_1_2020, 2020-11-20 to 2020-11-25 | 3,515,465 | mound | 0.83 (0.40) | 55.1 | 2.66 m (no clear circle) |
| BayCare Ballpark (BCB) | Phillies | FL_Peninsular_Pinellas_2018, 2018-12-07 | 2,819,707 | mound | 0.83 (0.35) | 44.8 | 0.00 m (faint circle) |
| CACTI Park of the Palm Beaches (CPB) | Astros and Nationals | FL_Peninsular_FDEM_PalmBeach_2019, 2019-04-15 | 1,778,936 | mound | 0.92 (0.41) | 35.4 | 0.25 m (no clear circle) |
| Charlotte Sports Park (CSP) | Rays | FL_Peninsular_FDEM_Charlotte_2018, 2018-12-12 | 2,311,903 | mound | 0.85 (0.27) | 56.9 | 0.35 m |
| Ed Smith Stadium (EDS) | Orioles | FL_Peninsular_FDEM_Sarasota_2018, 2018-11-30 to 2018-12-05 | 3,923,024 | mound | 0.90 (0.36) | 134.9 | 2.36 m (no clear circle) |
| JetBlue Park (JBP) | Red Sox | USGS_LPC_FL_Southwest_A_2018_LAS_2019, 2018-06-26 to 2018-10-23 | 3,961,809 | mound | 0.87 (0.31) | 45.6 | 2.06 m |
| LECOM Park (LEC) | Pirates | FL_Peninsular_FDEM_Manatee_2018, 2018-11-30 | 3,041,202 | dirt circle (mound gone) | 0.78 (0.35) | 44.8 | the plate is the circle |
| Lee Health Sports Complex (LHS) | Twins | USGS_LPC_FL_Southwest_A_2018_LAS_2019, 2018-06-10 to 2018-06-17 | 3,879,720 | dirt circle (mound not classed as ground) | 0.86 (0.30) | 26.8 | the plate is the circle |
| Publix Field at Joker Marchant Stadium (JMS) | Tigers | FL_Peninsular_FDEM_Polk_2018, 2019-03-24 to 2019-03-25 | 6,099,129 | mound | 0.81 (0.55) | 38.2 | 0.79 m |
| Roger Dean Chevrolet Stadium (RDS) | Cardinals and Marlins | FL_Peninsular_FDEM_PalmBeach_2019, 2019-04-15 | 3,546,067 | mound | 0.96 (0.45) | 59.0 | 0.25 m (no clear circle) |

**Checks on the plate and the bearing.**

- *Dirt circle* (Sutter's `find_plate`, a 13 ft dark disc searched within 2 m of the
  plate on a 0.25 m grid; independent of the mound). At the eleven mound-method parks
  where the circle is clear (contrast below -7,000) its centre is 0.0 to 0.79 m from the
  plate, except JetBlue Park at 2.06 m; the rule puts the plate's back point about 0.2 to
  0.3 m behind the circle's centre, and the grid step is 0.25 m. JetBlue's lidar comes
  from two flights four months apart (June and October 2018), and its plate is the least
  certain, about 2 m.
- *Outfield wall against MLB's published distances* (independent of everything above;
  `wall_check.py`, `data/wall-check.json`, methods Figure 5). From the plate along each
  degree of fair territory, the wall is the first 0.5 m radial bin past 60 m holding at
  least three returns 1.2 to 8 m above the field that continues: two of the four
  neighbouring degrees have such a bin within 3 m of it, which skips equipment left on
  the field (JetBlue's left field, Joker Marchant's centre field). A degree where nothing
  continues has no wall; no degree at these parks is like that. The table uses the
  median of the three bearings 1 to 3 deg inside each foul line (the line itself carries
  the foul pole) and of cf - 1, cf and cf + 1 for center. "No wall seen" means the lidar
  has no return 1.2 to 8 m up within 2 m of the published distance on those bearings: a
  see-through fence (chain link), so that check says nothing about the plate.

| park | LF line (ft, lidar vs MLB) | center | RF line |
|---|---|---|---|
| American Family Fields of Phoenix | 353 vs 350 (+3) | 395 vs 400 (-5) | 348 vs 340 (+8) |
| Camelback Ranch | 343 vs 345 (-2) | 407 vs 410 (-3) | 343 vs 345 (-2) |
| Goodyear Ballpark | 344 vs 340 (+4) | 408 vs 400 (+8), no wall seen | 343 vs 340 (+3) |
| Hohokam Stadium | 340 vs 340 (0) | 408 vs 410 (-2) | 346 vs 350 (-4) |
| Peoria Stadium | 343 vs 340 (+3) | 407 vs 410 (-3) | 341 vs 340 (+1) |
| Salt River Fields at Talking Stick | 344 vs 345 (-1) | 407 vs 410 (-3) | 344 vs 345 (-1) |
| Scottsdale Stadium | 359 vs 360 (-1) | 427 vs 430 (-3) | 330 vs 330 (0) |
| Sloan Park | 358 vs 360 (-2) | 407 vs 410 (-3) | 359 vs 360 (-1) |
| Surprise Stadium | 348 vs 350 (-2) | 397 vs 400 (-3) | 348 vs 350 (-2) |
| Tempe Diablo Stadium | 340 vs 340 (0) | 417 vs 420 (-3) | 358 vs 360 (-2) |
| BayCare Ballpark | 328 vs 329 (-1) | 400 vs 401 (-1) | 326 vs 330 (-4) |
| CACTI Park of the Palm Beaches | 338 vs 329 (+9), no wall seen | 405 vs 408 (-3) | 379 vs 330 (+49), no wall seen |
| Charlotte Sports Park | 366 vs 343 (+23), no wall seen | 412 vs 414 (-2) | 341 vs 343 (-2) |
| Ed Smith Stadium | 340 vs 340 (0) | 397 vs 400 (-3) | 340 vs 340 (0) |
| JetBlue Park | 308 vs 310 (-2) | 390 vs 420 (-30) | 325 vs 302 (+23) |
| LECOM Park | 335 vs 335 (0) | 400 vs 400 (0) | 333 vs 335 (-2) |
| Lee Health Sports Complex | 333 vs 330 (+3) | 405 vs 405 (0) | 333 vs 330 (+3) |
| Publix Field at Joker Marchant Stadium | 340 vs 340 (0) | 418 vs 420 (-2) | 336 vs 340 (-4) |
| Roger Dean Chevrolet Stadium | 395 vs 335 (+60), no wall seen | 431 vs 400 (+31), no wall seen | 417 vs 375 (+42), no wall seen |

  47 of the 50 checks where the lidar shows a wall at the published distance are within
  5 ft. In Arizona: foul lines 19 of 20 within 4 ft, median -1 ft; center 9 of 10 within
  5 ft, median -3 ft, the tenth being Goodyear's +8 ft, where the lidar shows no wall at
  the published 400 ft. The three misses: American Family Fields'
  right-field line (+8 ft), and JetBlue's center (-30 ft, where the published 420 ft is
  the deepest corner, not straightaway) and right-field line (+23 ft, where the wall bends
  away from the pole). The wall reading about 3 ft short in straightaway center at most
  parks would fit a plate placed about 1 m toward center field (the dz-weighted mound
  centroid sitting toward the rubber) plus the 0.5 m bins; I have not tested that.
  Published alley distances are not compared: their bearings are not defined.
- *MLB API bearing.* The API gives an azimuth for Salt River Fields only among the mapped
  parks: 72 deg, against 72.8 from the lidar.
- *Vendor disc bearing* (`cf_from_mound` from the plate above): within 0.7 deg at 15 of
  the 17 mound-method parks (Sloan Park 2.1 deg; at Joker Marchant it finds no bump at
  all), but not independent: it re-reads the bearing to the mound bump from a plate
  placed on the axis through that bump. Not used at the two dirt-circle parks.

**Rim.** Same as Sutter: K = 4 skyline per 1 degree bin, 15 to 250 m from the plate,
field level z0 = median ground return within 30 m of the plate, narrow-structure
suppression (at most 32 deg wide, sharp step at both ends), **no manual edits.** One
step is new: the playing field is cleared first. In fair territory, returns more than 2
m inside the outfield wall are dropped, because equipment left on the field when it was
flown would otherwise win a skyline bin. The clearing never reaches past MLB's published
fence line (interpolated between the published points), because behind a see-through
fence the wall the lidar finds is the next structure out and the ground between is not
field. This changes the rim only at Joker Marchant (35 bins), Roger Dean (4) and JetBlue
(1); no Arizona rim changes (`source.rim_bins_changed_by_field_clearing`). The rule
removed 6 to 9 narrow features per Arizona park: light towers 30 to 53 m tall, and three
narrower features 12 to 19 m tall, one each at Hohokam, Scottsdale and Tempe Diablo.
Distant poles on the practice fields can win single outfield bins (at Salt River Fields,
bins 11 to 12, 31 to 32, 43, 132 to 133 and 139: 185 to 249 m out, 31 to 34 m high);
seen from the plate they are no higher than the near rim, so the rule keeps them. They
lie in the outfield directions, where the sun is only in the morning.

**Seats.** These parks are low. Behind home (bearings cf + 150 to 210) the last row the
lidar sees is 1.9 to 5.2 m above the field in Arizona and 2.3 to 8.0 m in Florida (the
median at each park), 6.5 to 23 m behind the front row, under a rim 12 to 22 m high. The
generic bowl of section 6 (seats rising to 3 m below the rim, at most 35 deg), computed
by the engine's own `bowlAt` on the same rim, would put that row 4.8 to 12.9 m higher
(the median over those bearings at each park), the kind of error the Sutter sketch made
(section 7). So the seats are measured:

1. `seat_profile()`, every 2.5 deg of bearing from the plate, returns within 1 deg, 0.5 m
   radial bins: on the foul side from 12 to 120 m; in fair territory (the outfield
   seating) from 1 m behind the outfield wall to 200 m. Behind a see-through fence the
   wall the lidar finds is something farther out, so there the profile starts 1 m behind
   MLB's published fence line instead: at a published point when the wall check finds
   no wall at the published distance and the first one more than 6 m beyond it (Roger
   Dean's three points, CACTI Park's right-field line, Charlotte's left-field line), and
   between points only beside such a point, where the same holds at the degree itself:
   the line between published points is interpolated, and JetBlue's Fenway-shaped wall
   sits metres outside it (`outfield_wall.profile_starts_at_published_line`). Sutter's `bowl_profile` (front =
   first bin more than 0.5 m above the field; the front height is the median 1 to 2.5 m
   behind it; the last row is the last bin before the height jumps more than 2.5 m within
   1.5 m) plus one stop it lacked: the last row is also the last bin before the height
   *drops* more than 2.5 m within 1.5 m and does not come back within 4 m. That is the
   back of an open stand; a vomitory comes back. Without it the profile ran on across the
   ground behind Scottsdale's first-base stand to the next building (top 56 m at 1.3 m,
   instead of 44.5 m at 6.1 m).
2. `build_parks.mjs` cleans the profile, the foul side and fair territory separately: a
   bank must end at least 1 m above the field, at least 6 m behind its front and above
   it (that drops dugouts, walkways and gaps); in fair territory its front must also be at
   most 4 m up (a batter's eye or a scoreboard behind the wall starts higher than any lawn
   or bleacher); a front under 0.3 m is a mis-detection and takes the nearest good fronts
   on either side within 15 deg (Sutter's rule); each value is then the median of the good
   entries within 5 deg, which fills single gaps and replaces single odd entries such as
   an aisle that cut a bank short.
3. In the engine, a seat at depth d (0 = front row, 1 = last row the lidar sees) along
   bearing b sits on the plate ray at front + d (top - front), at height z_front +
   d (z_top - z_front), interpolated linearly between the 2.5 deg entries, eye 1.1 m above
   it. No seats next to a missing entry. Five zones on the foul side as at every zone
   park, and three outfield zones (left, center, right field, 30 deg each) at the 17 parks
   whose profile found seating behind the wall; Ed Smith Stadium and Roger Dean show none.
   At least 80 percent of the foul-side entries have seats at every park except the two
   with roofed grandstands, LECOM Park (59 percent) and Hammond Stadium (75 percent).

Two diagnostics are recorded per entry in `spring-lidar.json` and not used by the model:

- `low_rows_before_front_m`: how far the bank reaches toward the plate in front of the
  detected front, in bins that are mostly non-ground returns and not sunken, up to 5 m.
  These are rows lower than the 0.5 m that defines the front. In Arizona the median along
  the foul side is 0 to 1.5 m per park; behind home, 3 m at Goodyear, 2 m at Camelback
  Ranch and Tempe Diablo, 1.25 m at Surprise, 0.5 m or less elsewhere. In Florida it is
  larger: 0 to 2.5 m along the foul side (Ed Smith Stadium the most); behind home, 3.5 m
  at LECOM Park, 3 m at Charlotte, Ed Smith and JetBlue, 2.5 m at Roger Dean, 2.25 m at
  Joker Marchant, 1 m or less elsewhere. Where it is large, the model's front row is two
  to four rows back from the real one.
- `line_rms_m`: RMS distance of the raw bank from the straight seat line the model draws
  (front height to last-row height). Over all foul-side banks at the 19 parks the median
  is 0.49 m and the 90th percentile 0.88 m (Arizona 0.48 and 0.86 m, Florida 0.52 and
  0.91 m), per park 0.27 to 0.77 m; it includes the up and down of the rows.

What this leaves out, and the page says so on every spring park: **seats under a roof**
(lidar sees the roof; Sloan Park's roof, 15 m up, starts 32 m from the plate and is 20 m
deep, so most of its upper rows are hidden; LECOM Park's grandstand is roofed along both
lines), cross-aisle steps (the line is straight from front to last row), and anything
built after the flight. Any surface behind the outfield wall that passes the cleaning
rules counts as seating, so a sloping lawn nobody sits on would count too, and seats on
top of a tall wall do not (JetBlue's left-field wall, whose top the lidar sees 11.8 m
up, past the 4 m limit). Behind a see-through fence the profile starts at the published line (step
1); Roger Dean still shows nothing behind its fence that passes the cleaning. Where the
fence is see-through but the first solid thing is less than 6 m beyond it (CACTI
Park's left-field line, Goodyear's center), the profile starts behind that thing, so a
row or two in between could be missed.

**Not yet checked.** No dated photos of any spring training park have been compared, so
these parks are measured but not ground-truthed. The sun and the clock are the code
validated in sections 2 and 3 (America/Phoenix, no daylight saving, and America/New_York,
which changes clocks during spring training, are in the clock tests). Whole game for one
spring park, every zone and depth row including the outfield: 6 to 10 ms after the first
call.
