Issues
16 open · 24 resolved
· source: docs/ISSUES.md · Last groomed: 2026-09-08
The tracker is docs/ISSUES.md in the repo — edit it there (or
ask the assistant to log an issue) and this page follows. One entry per
issue: What / Impact / Next step.
ISS-1 · Lake 9822 has no valid shore met point — `FAIL` in met_validation
What:
`_shore_met_point` finds no land cell within 300 m of lake elevation among the bbox-edge/midway candidates (steep canyon reservoir). Air-temp forcing falls back to the centroid water cell.
Impact:
GLM forcing + displayed air temp for 9822 may carry the weather model's internal-lake-temp contamination.
Next:
widen the probe ring (more candidates along the shoreline polygon) or relax the elevation tolerance for canyon lakes; re-run `scripts/validate_lake_met.py 9822`.
ISS-2 · 77 lakes `NO_BBOX` — met validation can't run
What:
fleet met validation (2026-08-15: 117 OK / 77 WARN / 1 FAIL) warns on lakes without `data/processed/{sid}/bbox.json` — partially onboarded lakes.
Impact:
low today (those lakes ship no forecast products), but they'll onboard eventually.
Next:
the `met_validation` workflow step requires `bbox_compute`, so full onboarding covers this; optionally run `bbox_compute` + validation for the 77 to pre-clear them.
ISS-3 · 64 lakes have no satellite temp anchor
What:
no MODIS calibrated series and no recent ECOSTRESS/Landsat scenes — `latest_anchor()` returns None (audit 2026-08-15).
Impact:
their GLM headlines are unverifiable and uncorrected; only the climatology display cap protects them (several showed 33–36 °C raw before capping).
Next:
extend the MODIS extract/calibration pipeline to these siteids (pooled global model already allows zero-pair calibration).
ISS-4 · Utah Lake far-tail heat-wave peaks still read a few °F hot
What:
GLM afternoon peaks 5+ days out exceed the observed envelope under calm heat waves; the climatology soft cap compresses but doesn't eliminate.
Impact:
cosmetic-to-moderate on the forecast chart tail.
Next:
revisit calibration with wave-breaking/convective mixing params, or fit sw_factor seasonally; re-run `scratchpad`-style level+diel sweep after the heat wave passes with fresh MODIS verification.
ISS-5 · Echo deep-hole hypolimnion disagreement
What:
tiled tier-2 hindcast holds ~6–8 °C at 9 m+ in the deepest hole; the live whole-lake forecast says bottom ~17 °C.
Impact:
cosmetic — visible in the 3D cross-section only (~65 of ~9k points).
Next:
compare against any deep DWQ cast; likely the tiled deep column under-mixes (coef_mix_hyp for deep tiles).
ISS-18 · 18:47 UTC GLM cycle timed out daily (Sep 2–8) — cap raised, slowness unexplained
What:
`lakemonster-glm-cycle.service` had `TimeoutStartSec=30min`. The 00/06/12 UTC cycles finish in 22–28 min; the 18:47 UTC (12:47 MDT) cycle hit the cap and was SIGTERMed on Sep 2, 3, 5, 6, 7, 8 — status 143, and because the pool only prints per-lake JSON after all workers finish, the log shows *nothing* for the killed run (232 per-lake logs were touched in the window vs 87 for the morning run, so it was running, just slower).
Impact:
no fresh GLM forecast for ~6 h every afternoon; the +30 min anchor correction ran against stale warm state.
Fix (2026-09-08):
`TimeoutStartSec=55min`; anchor-correct timer moved to +60 min (`01,07,13,19:47 UTC`). `systemctl --user reset-failed` applied.
Next:
find why the 18 UTC cycle is slower (metro open-meteo HRRR 18Z ingest? concurrent `forecasts`/`bloom-tags` timers at :55/:12?). Run the pool with `-v` once so per-lake `runtime=` lines land in the log, or check `data/logs/glm_cycle/*.log` mtimes in the 18:47–19:40 UTC window.
ISS-19 · Lake Merwin #519 `terrain_rgb_sample` fails the brightness gate on every retry
What:
all 8 probed S2 candidates have lake-area mean brightness 0.128–0.136, below the 0.15 floor (`derive_terrain_rgb.py --min-brightness`), so the reonboard workflow defers 3600 s and repeats (2× so far on 2026-09-08; it *passed* on 2026-09-02).
Impact:
#519 registry workflow never completes; one deferral log line per hour.
Next:
per-lake brightness floor override in lake_config, or accept the brightest candidate when all fail by a small margin (<0.03); or widen the candidate window (`max_candidates`).
ISS-8 · Anchor dead band leaves ±1.5–3 °C persistent bias
What:
confidence-scaled nudge thresholds (1.5 °C med/high, 3 °C low) mean a stable model bias just under threshold persists (Rockport pattern). Display-time obs bias corrects the shown value; the model state stays biased.
Next:
consider a slow integral-style nudge (e.g. 0.3 °C/cycle toward anchor regardless of threshold) so state converges without churn.
ISS-9 · Legacy UTC remnants until natural re-push
What:
timestamp localization ships on change-detection: objects that rarely change (and pre-change day cubes/tier2_3d cached files) keep UTC strings until re-rendered/re-pushed.
Impact:
shrinking daily; day cubes refresh on the hourly timer.
Next:
none needed unless a stale page is user-visible; spot-check in a week.
ISS-11 · Confirm fleet tier-2 rebuild completion
What:
the full-fleet tier-2 rolling rebuild (started early 2026-08-15 evening) runs with a 4 h budget + requeue; leftovers heal via nightly timer/watchdog.
Next:
check `data/logs/tier2_rolling.log` + per-lake logs for failures; re-run stragglers with `--single`.
ISS-12 · lakemonster-web restarts still drop in-flight requests
What:
deploys restart uvicorn (`--no-reload`); each restart is a brief 502 blip behind go.7.fish. Mitigated 2026-08-15: peer session added `TimeoutStopSec=10` (was ~2 min drain) and the homepage now cold-serves in 15 ms from a persisted snapshot — but the blip itself remains.
Impact:
seconds of 502s per deploy; only matters when deploying during active use.
Next:
graceful reload (uvicorn workers + SIGHUP) or a blue-green pair behind the proxy; batch code changes into single restarts meanwhile.
ISS-13 · Learn per-lake diel air-temp bias from consensus residuals
What:
conditions-now air temp is a median of 3 estimators (station obs / metro-HRRR shore / WeatherKit); per-source residuals vs the consensus now accrue in `derived/air_temp_residuals.jsonl` per lake. Mountain-valley lakes show systematic hour-of-day model bias (inversion cold pools: metro +2–3 °C mornings — fleet comparison 2026-08-16: median agreement 1.3 °C, valley outliers to 4.7 °C).
Impact:
consensus already caps single-source errors; learned diel corrections would tighten the last ~1–2 °C.
Next:
after ~2 weeks of residuals, fit per-lake per-hour-of-day offsets for the metro source (extend `derive_met_bias.py`) and apply before the median.
ISS-14 · WWTP nutrient-loading context from EPA ECHO
What:
EPA ECHO REST + DMR services expose the 7 major CWA dischargers around Utah Lake (and equivalents fleet-wide) with monthly effluent flow + nutrient DMRs.
Impact:
would add seasonal nutrient-loading markers/context to the Algae page (monthly resolution — not per-scene attribution).
Next:
small fetcher caching per-lake NPDES DMRs; band markers on the algae graph; correlate seasonal loading vs bloom onset.
ISS-17 · Sea-level/tidal lakes never promote — `bed_elev_navd88_5m.tif` skipped (FIXED)
What:
`bathy publish` only wrote the NAVD88 bed-elevation raster when `pool_elev_m > 0`, but `promote_to_bathymetry` hard-requires it. Any water body whose surface elevation is ≤ 0 or whose lakemon record stored `elevation_m` as `0`/null (tidal/coastal/low ponds) got its bed DEM silently skipped and then permanently failed promote with the misleading "publish step must have failed". Pre-existing since ≥2026-08-02.
Affected (from logs):
St Johns River #3342 (Jacksonville tidal river, surface ≈ 0 m NAVD88 — parked/retried for a month), Stink Lake #13588, Crystal Springs #13649, Goode Ski Lake #14886.
Fix (2026-09-08, `~/bathy` `cli.py` publish):
treat surface elevation as an `Optional[float]` — `None` = genuinely unknown (skip), any *known* value including ≤ 0 produces the bed DEM (`bed = surface − depth`, correctly landing below the datum). A stored `0` is treated as missing and falls through to the authoritative USGS EPQS lookup. Validated: #3342 republished with `bed_elev_navd88_5m.tif` (surface 0 m, bed 0 to −11.9 m); unparked to complete. Whole class fixed.
Follow-up:
`promote_to_bathymetry`'s error text ("publish step must have failed") is misleading when publish succeeded but legitimately had no elevation anchor — reword, and consider a non-parking "no NAVD88 anchor" skip for genuinely unknown-elevation lakes.
ISS-16 · `get_shape` API serves degenerate geometry for some lakes (Chelan #90, Pickwick #2468)
What:
`vps.lakemon.com/.../get_shape&siteid=<id>` returns valid JSON but a degenerate polygon — Chelan #90 ≈ 105 m × 82 m (a real 80 km fjord), Pickwick #2468 ≈ 30 m — instead of the true lake extent (confirmed consistent across retries 2026-09-07). Left unchecked this yielded a 1–2 tile "chart" and an all-nodata DEM that crashed `fuse` downstream (that was the real cause of both lakes' bathy parks, on top of the ISS-15 OOM history they also had).
Not the deep_water shape-refresh pipeline:
zero entries for 90/2468 in `shape_refresh*.log` — the S2 refresh never touched them, so the bad geometry lives in the VPS lake database from the original seed/import.
Client mitigation (2026-09-07, `~/bathy` `sources/lakemon_api.py`):
`fetch_lake` now (a) retries `get_shape`/meta on empty/non-JSON bodies (the other intermittent failure mode — an empty body → JSONDecodeError), and (b) rejects a degenerate geometry (bbox < `BATHY_MIN_AOI_BBOX_M`, default 200 m, or empty) with a clear error, so it fails fast at `fetch-aoi` instead of publishing garbage. `navionics-dem` also hard-fails on zero valid pixels (see ISS-15).
Audit (2026-09-08):
`scripts/audit_lake_shapes.py` sweeps `get_shape` for every lake in the bathy queue and classifies OK / DEGENERATE / MISSING_GEOMETRY / NOT_FOUND / ERROR → `docs/triage/shape_audit.md`. Current active-queue result (25 lakes, 21 OK): **4 bad shapes, all need a server-side fix on vps.lakemon.com:** - #90 Lake Chelan — DEGENERATE (bbox ≈ 70×82 m) - #496 Lake Wallula — DEGENERATE (bbox ≈ 82×63 m) - #2468 Pickwick Lake — DEGENERATE (bbox ≈ 30×30 m) - #16998 Lake Higgins — NOT_FOUND (HTTP 404, no shape at all)
Next (server-side, needs operator):
regenerate the 4 shapes above in the vps.lakemon.com lake DB (create one for #16998). The client guards make all 4 fail fast + clean until then. To cover lakes not currently in the queue, extend the audit to the full onboarded fleet (it accepts `--siteids`, or wire it to the all_lakes list).
ISS-15 · Big-lake `navionics-dem` needs >124 GB — OOM loop crashed the host
What:
the bathy CLI's `navionics-dem` (sometimes `fetch-navionics`) step allocates ~124 GB+ on very large lakes (Superior, Okeechobee, Oahe, Sakakawea, Great Salt Lake, Ozarks, Hartwell #1029, Guntersville #1186, Chickamauga #959, …). The kernel OOM-killed it 1,889 times between Aug 15 and Sep 7 2026; each 6 h-backoff retry re-ran the same doomed job, and with ~20+ such lakes rotating the host OOMed every ~15–20 min until the desktop froze on 2026-09-07 and needed a reboot.
Impact:
host instability (the Sep 7 "crash"), the dedicated bathy lane spends most of its time on jobs that can never finish, and those lakes never get bathymetry.
Mitigation (2026-09-07):
systemd drop-in `~/.config/systemd/user/lakemonster-bathy-dispatcher.service.d/memory-cap.conf` sets `MemoryMax=64G` + `MemorySwapMax=0`, so a runaway job dies inside its own cgroup in ~2 min instead of starving the host.
Mitigation 2 (2026-09-07):
max-attempts park — after `LAKEMON_BATHY_MAX_ATTEMPTS` (default 10) failures the dispatcher parks the lake (status `parked`, queue score +inf; seeders can't resurrect it) and kicks `lakemonster-bathy-triage.service`, which runs a read-only headless Claude investigation and writes `docs/triage/bathy_<sid>.md` (2 h sweep timer catches stragglers). Review parks with `scripts/dispatch_bathy_queue.py --list-parked`; requeue after a fix with `--unpark <siteid>`.
Fix (2026-09-07, in `~/bathy`, uncommitted):
size-aware zoom via a shared `_memory_capped_zoom()` helper in `cli.py`. BOTH steps apply it: `fetch-navionics` estimates mosaic pixels from the AOI before fetching, and `navionics-dem` caps its zoom search too (a stale oversized z17 chart from an old attempt must not be loaded — that was the gap that re-OOMed Eufaula/St. Clair). Auto-drops the zoom (floor z11) to stay under `BATHY_MAX_MOSAIC_PX`. **Budget calibrated by measurement:** the DEM step's peak RSS is ~66 bytes/mosaic-px (siteid 307: 0.78 Gpx → 51.2 GB). Default budget **0.65 Gpx ≈ 43 GB peak**, ~20 GB headroom under the 64 GB cgroup cap. (An initial 1.5 Gpx guess was too high — Sakakawea #231 OOMed at z15/1.35 Gpx before recalibration.) Resulting zooms: Sakakawea z14, Superior z15, Okeechobee/Ozarks z16, Erie z15. Fusion terrain preview falls back across z17..z14; raw tile dict freed before the GeoTIFF write. - **Watch out (cost me a cascade 2026-09-07 21:45):** the helpers must sit ABOVE the `@main.command("fetch-navionics")` decorator block. If placed between the decorators and `def fetch_navionics_cmd`, the decorators bind to the helper instead, silently turning `fetch-navionics` into a no-op that fails EVERY lake with rc=1. `bathy fetch-navionics --help` exiting 0 is the quick smoke check.
Validated 2026-09-07 22:19:
Lake of the Woods #307 (12.3 Gpx at z17, the largest yet) completed end-to-end — auto-reduced to z15, real DEM (298M valid px, 66 rings), fused `bed_elev_30m.tif` + hypsograph uploaded. All OOM-class lakes (Erie, Mead, Michigan, Eufaula, St. Clair, Pickwick, …) unparked; queue is clear of parks.
Also hardened (`navionics-dem`):
now hard-fails when segmentation yields zero valid pixels instead of uploading an all-nodata DEM that crashes `fuse` downstream — surfaces the real cause at the source. (From the Pickwick #2468 triage report; that lake's 82nd attempt was a transient degenerate 1-tile AOI from the lakemon `SHAPE_URL` API, on top of its underlying ISS-15 OOM.)
Next / follow-ups:
- ~~Commit the `~/bathy` changes~~ — done (`947c851` size-aware zoom + tile-archive manifest, `518cf34`, `567bd09`; verified 2026-09-08). - Add an AOI-size sanity guard in `fetch-navionics` (reject/retry when `aoi_to_tile_range` yields a suspiciously tiny tile count vs the lake's known extent) — catches the degenerate-AOI glitch one step earlier; and investigate why `SHAPE_URL` returned a near-empty geometry for #2468 on that call. - Optional: 256 GB RAM would let the mid-tier reservoirs (Chickamauga-class) run at full z17 (1.2 m/px) instead of z16.
Resolved (24)
- **ISS-6 root cause found — disk-full was the bathy contour tileset rebuild.** `dispatch_bathy_queue.py` re-ran `build_bathy_vector_tiles.py` after EVERY completed lake: dump all ~865 lakes' isolines into one ~45 GB GeoJSONL in `/tmp/bathy_tiles_*`, tippecanoe the whole fleet, upload. The build crossed the dispatcher's 3600 s `subprocess.run` timeout on 2026-09-08 (62 min); a SIGKILLed child never runs `TemporaryDirectory` cleanup, so each hourly kill leaked 43 GB — 13 dirs / **546 GB in /tmp** by 17:20 (disk 42 %). Same mechanism as the 2026-08-14 ENOSPC. Fix: (a) 12 orphan dirs removed (→ 29 %); (b) builder rewritten as **incremental per-lake tiles** — each lake's isolines → tippecanoe → `bathy/{sid}/published/contours.mbtiles` (+ `.json` source stamp) uploaded to MinIO immediately, scratch deleted per lake; the fleet tileset is now a `tile-join --no-tile-size-limit` of those small files (`--lake`, `--fleet-only`, `--force-lakes` modes); (c) dispatcher cuts a lake's tiles right after it publishes (seconds) and flags a Redis-pending fleet merge that runs when the lane is idle or ≥30 min since the last good merge (`LAKEMON_BATHY_TILES_MIN_INTERVAL_S`), in its own process group with `killpg` on timeout (`LAKEMON_BATHY_TILES_TIMEOUT_S`, 2 h) — no more orphaned tippecanoe/tile-join; (d) builder + heal watchdog reap `bathy_tiles_*`/`bathy_lake_*`/`bathy_merge_*`/`swot_*` scratch older than 12 h; (e) heal watchdog summary now carries a `disk` block and logs `DISK ALARM` under 400 GB free / over 85 % (`LAKEMON_DISK_ALARM_*`); (f) per-lake refresh uses a fork process pool (thread pool serialized behind the GIL at ~4 lakes/min; now ~60/min — fleet backfill ≈ 15 min); (g) `~/bathy` SDB step leaked one `nav.tif` per run via bare `mkdtemp()` — scoped to a `TemporaryDirectory` (committed in `~/bathy`).
- **ISS-7 climatology builder scheduled:** `lakemonster-temp-climatology.timer` monthly on the 3rd 10:40 UTC (`ops/systemd/`, in `install.sh`).
- **ISS-10 fallback safety block** now carries `timezone`/`tz_abbrev` (`build_safety` in `vps_lake_sync.py`).
- **Service logs never rotated** (`data/logs/*.log`: web 172 MB, vps_sync 214 MB, process_queue 135 MB, 1.8 GB total). Added unprivileged `ops/logrotate/lakemonster.conf` (100 MB × 4, compress, copytruncate) run daily by `lakemonster-logrotate.timer`; first rotation applied.
- Side effect to know about: re-installing the anchor-correct timer with a new `OnCalendar` + `Persistent=true` fired one immediate catch-up run at 17:31 MDT on 2026-09-08 (harmless).
- Fleet water-level date audit (2026-08-27): 21/27 gauge lakes fully fresh; 6 "stale" lakes traced to USBR ITSELF not publishing past Aug 24 (verified at source — their QA lag, not ours). Format audit caught `nwis_stage` (22 lakes) missing from the date-only daily list → added; all 50 gauge lakes force-resnapshotted so chart dates and "as of" labels are correct immediately. Satellite-only lakes (122) sparse by nature. Lake 10's morning elevation refresh hit the 1200 s step timeout (requeue convention heals it; data was already source-fresh).
- Water-level "as of" off by one day (Rockport showed Aug 24 while holding the Aug 25 reading): daily gauge readings are stamped midnight-UTC and the localize-times walker shifted them to the previous local day. Fix: daily-cadence sources (usbr_daily, twdb_daily, nwis_dv, state_monthly) now ship DATE-ONLY ``t``/ ``as_of`` strings, which the localizer leaves untouched. Fleet transitions on each lake's next daily snapshot. (2026-08-26)
- Rockport GLM recalibrated FROM user ground truth (2026-08-26): two consecutive noon boat readings (75 °F) vs raw-model ~73 °F exposed a diel phase error; the 2024 cal had picked kw=0.6 at the sweep-grid EDGE against flat ECOSTRESS scores. Diel-scored resweep (16 runs, hour-matched vs boat + MODIS day/night) → kw=1.0 (`glm_calibration_2026.json`): midday cold bias halved (+2.3 → +1.7 °C raw; boat + day-MODIS agree independently), surface diel range now 3.8 °C. Next-noon forecast 74.4 °F vs the boat's consistent 75. Night-MODIS argues for lower kw but carries the documented narrow-reservoir night land-mixing cold bias — trusted the boat. Mixing params had NO diel effect (negative result).
- Rockport display 3 °F cold vs boat (75 °F noon 2026-08-25): raw median in `_thermal_obs_bias` let 2 night MODIS obs outvote 1 day scene (night pixels mix cooling shoreline land; 3-5 px on a narrow reservoir). Fix: class-balanced bias (mean of per-class medians: sat-day / sat-night / ground-truth) + the ground-truth ledger is now a first-class obs source. Display 72.3 → 75.6 °F; fleet spot-checks sane (10: +1.5 C n=22, 2: +1.9 n=7, 9: +0.5 n=18); smoke OK. (2026-08-25)
- Rockport fire release annotated + generic lake-events mechanism: curated `data/events/lake_events.yaml` + auto rapid-drawdown detector (`lakemonster/lakes/events.py`: 7-day mean rate ≥2× month median AND ≥12 cm/day, ≥7 days; curated suppresses overlapping auto). Shipped in `/api/lake/{sid}/elevation` → shaded bands on the ops chart, and in the sync elevation `current.events` for lakemonster.com. Context: regression (122 obs, 2018-24) attributes Rockport's 77 °F to ~+1.8 °F drawdown (−0.27 °F/m, p=1e-4) + ~+1.1 °F warm week. (2026-08-25)
- Sync wind moved to METRO PRIMARY (user directive) — `_fetch_safety_wind` now calls the self-hosted metro grid first (72 h wind, 48 h gusts+CAPE, no 429s), public 3-model ensemble demoted to fallback; weather_code filled by a single-point public side-call (None on 429, cosmetic). Cache v10. Verified on Rockport: full rows, no fallback warnings.
- Rockport headline temp GROUND-TRUTHED: boat thermometer 77 °F at 4 pm 2026-08-24 vs site 77 °F — exact match. Ledger: `data/ground_truth/water_temp.jsonl`. (2026-08-24)
- Utah Lake water level "as of Aug 20" on Aug 23 — layered lag: USBR posts day D−1 around midday, but `lakemonster-elevation.timer` ran once daily at 05:13 (before USBR posts), so the freshest local point was D−2; the user additionally caught the page before the morning vps sync pushed it (D−3 visible). Fix: second daily elevation refresh at 14:13 local (+ immediate manual run), so the site carries D−1 from mid-afternoon. The 15-min sync picks it up automatically. (2026-08-23)
- Algae history index truncation — the imagery hook's 120-day lookback rewrote every index with only 120 days (Powell 83 → 7; 107 lakes truncated). Lookback now bounds rendering only; smoke test asserts history depth. (2026-08-18)
- Lake grid 503s from Open-Meteo 429 rate limiting — metro fallback + last-good cache in `lakewind.fetch_wind_grid`. The earlier "transient" smoke failure on lakegrid[10] was this. (2026-08-18)
- Echo 3D viewer 54 °F pixels — tier-2 segment-stitch ghost profiles + per-tile ice-lock + level-correction blowout. Fixed in `build_tier2_rolling.py` / `build_tier2_day.py`.
- Rockport hot / Utah Lake cold headlines — mountain-cell shore met point, anchor race, destroyed MODIS series, MODIS night-scene anchor bias. Fixed: elevation-validated shore points, inline cycle anchoring, blended MODIS anchors, series rebuilt.
- Utah Lake 17 °C diel swing — recalibrated (kw 1.5, wind_factor 1.2, sw_factor 0.88) against dense MODIS series.
- 94 °F forecast peaks — lead-tapered obs bias + per-lake climatology soft cap (`build_temp_climatology.py`).
- Surface-grid scrubber 55–96 °F + vertical stripes — winsorization + same display corrections; de-striped `_nearest_anomaly` (radius mean, ±4 °C clamp).
- UTC times everywhere — shared `lakemonster/lakes/timezones.py`; all VPS payloads, advisories (day-qualified), 3D cubes (`ts_local`), viewer endpoints localized; all 194 lakes' zones in DB.
- Fleet v5 rescore (2026-08-17): 23,050 renders / 214 lakes, ~2.3 h. After: 17,174 history entries, 56 % good / 22 % dark (dark had dominated most clear lakes); 83 composites. Spot checks: sid 9 12→63 good, sid 956 6→33, sid 38 47→145; sid 10 (turbid) unchanged behavior. Post-run smoke FAIL on lakegrid[10] was a transient race with rescore disk churn — endpoint healthy (14 cells, 2.6 s) and rerun smoke OK.
- Lake Powell "very few readings" — two fleet-wide causes. (a) Tile-split coverage starvation: S2 MGRS tiles / Landsat path-rows each fail the coverage gate on big lakes (Powell: 484/566 unusable) → same-sensor scene stitching (`build_scene_composites`, window 3 d, cloud-clearing fill, sensor-classified `S2X_CMP_*`/`LC08X_CMP_*` stems), hooked into `update_algae_history` for every lake. (b) Absolute dark gate hid clear-water lakes’ archives fleet-wide (Powell bright_med ~62 year-round → 61/82 scenes hidden; many mountain lakes 60–95 %) → v5 lake-relative gate (0.7 × archive-median bright_med, floor 35). Powell: 12 → 63 good scenes. See `docs/ALGAE.md` decision log.
- Advisory wind 2–3× reality — single sample point in terrain-accelerated corner → area-scaled polygon grid median (`lakemonster/io/lakewind.py`), then 3-model ensemble median (best_match + ECMWF + ICON) after GFS phantom-storm false alarm. Wind grid ops page at `/lake/{sid}/windgrid`; spatial-variation risk line in advisories; `met_validation` onboarding step.