Forecast lead-time example using real NOAA water level forecasts - #693
Forecast lead-time example using real NOAA water level forecasts#693ecomodeller wants to merge 3 commits into
Conversation
Adds a worked example of assessing forecast skill as a function of lead time, built on real data rather than synthetic values. The dataset pairs NOAA Delaware Bay OFS (DBOFS) water level forecasts with observations at CO-OPS station 8557380 (Lewes, DE): 28 forecast cycles over one week, lead times 0-48 h, plus harmonic tide predictions as a reference forecast that is not initialised and so does not degrade with lead time. Both sources are US federal works in the public domain. 83 KB. The OFS station files on AWS make this practical — they carry time series at 64 predefined stations rather than the full model grid, so no grid subsetting is needed. build_dataset.py regenerates the CSV and declares its dependencies inline (PEP 723); it reproduces the committed file byte for byte. The example needs no new API: lead time rides along as an auxiliary variable and skill(by=["model", "lead_time"]) does the grouping. Note that overlapping forecast cycles mean 1161 of 1372 timestamps are duplicated, which from_matched accepts without complaint. README.md in the data directory records provenance and four known limitations: the model station is identified by position rather than name, model and observation datums are not reconciled, the week contains no storm surge, and the longest lead time has only 28 points.
There was a problem hiding this comment.
Pull request overview
Adds a new end-to-end documentation example demonstrating how to assess forecast skill as a function of lead time using real NOAA DBOFS water level forecasts paired with Lewes, DE observations and a harmonic tide reference, and includes the accompanying reproducible dataset and build script.
Changes:
- Add the Lewes forecast/observation/tide dataset (CSV) plus provenance/limitations documentation and a standalone rebuild script.
- Introduce a new Quarto example page showing
from_matched(..., aux_items=["lead_time"])andskill(by=["model", "lead_time"])workflows, including binning and horizon selection. - Wire the new example into the docs navigation.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/testdata/forecast_lewes/README.md | Documents dataset schema, sources, and known limitations for the Lewes lead-time example data. |
| tests/testdata/forecast_lewes/lewes_dbofs_forecast.csv | Adds the committed long-format forecast/obs/tide dataset used by the example. |
| tests/testdata/forecast_lewes/build_dataset.py | Standalone (PEP 723) script to regenerate the committed CSV from NOAA sources. |
| docs/examples/Forecast_lead_time.qmd | New worked example demonstrating lead-time skill grouping, binning, and filtering using auxiliary variables. |
| docs/_quarto.yml | Adds the new example to the documentation sidebar/navigation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "product": product, | ||
| "application": "modelskill", | ||
| "begin_date": DATES[0], | ||
| "end_date": "20260804", |
| | `observed` | observed water level, CO-OPS station 8557380 \[m, MSL] | | ||
| | `tide` | harmonic tide prediction for the same station \[m, MSL] — a reference forecast with no initialisation, so its error does not grow with lead time | | ||
|
|
||
| 28 cycles covering 2026-07-26 to 2026-08-01, hourly, 1372 rows. |
sk.rmse.plot.line() plots the lead time as a category, so all 49 tick labels were rendered and overlapped into an unreadable band.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
docs/examples/Forecast_lead_time.qmd:91
pd.cutuses right-closed bins by default, so with edges[-1, 12, 24, 36, 48]the second bin is(12, 24](i.e., 13–24), etc. The current labels (e.g.,"12-24h") don’t match the actual bin membership at the boundaries (12, 24, 36). Updating the labels avoids misinterpretation of the binned skill table.
horizon=pd.cut(
df.lead_time, [-1, 12, 24, 36, 48], labels=["0-12h", "12-24h", "24-36h", "36-48h"]
)
The tide-only reference appeared to improve with lead time, which is impossible for a harmonic prediction and revealed two problems. Each lead time is naturally scored over a different period, because a 48 h forecast can only verify against times at least 48 h after the first model run. The window slides with lead time, and the later days in this record happen to be easier, so the reference looked better at long lead times. Restricting to valid times shared by all lead times makes the tide exactly flat at 0.260 m and shows the forecast degrading more steeply than before, 0.106 to 0.132 m. Both series also carry a constant datum offset, -0.08 m for the forecast and -0.26 m for the tide. remove_bias() removes it, and this reverses the ranking: the tide sits at 0.047 m against the forecast's 0.092-0.104 m. Lewes is tide-dominated and this week has no surge, so harmonic prediction is hard to beat -- the earlier impression that the forecast won was the tide's datum offset, not skill. The lead-time signal survives and is cleaner. The reference forecast is what made both faults visible, which is the main lesson of the example.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
docs/examples/Forecast_lead_time.qmd:84
groupby(...).apply(..., include_groups=False)is not available in the project’s supported pandas range (pyproject.toml allows pandas>=1.4). This example will fail on older supported pandas versions; compute the daily RMSE with a version-agnostic groupby instead.
unique_times = df.drop_duplicates("valid_time")
unique_times.assign(day=unique_times.valid_time.dt.date).groupby("day").apply(
lambda d: ((d.tide - d["observed"]) ** 2).mean() ** 0.5, include_groups=False
).round(3)
tests/testdata/forecast_lewes/build_dataset.py:3
- The PEP 723 header declares
requires-python = ">=3.10", but the package itself requires Python >=3.12 (pyproject.toml). Using a lower requirement here is misleading when running viauvin this repo; align it with the project requirement.
# /// script
# requires-python = ">=3.10"
# dependencies = ["xarray", "netCDF4", "pandas"]
Draft — the data and example work end to end, but the caveats below want a decision before this is merge-ready.
Summary
A worked example of assessing forecast skill as a function of lead time, built on real forecasts. Using real data paid off immediately: it exposed two verification faults that synthetic data would have hidden, and both were caught by the reference forecast rather than the model.
The harmonic tide prediction cannot depend on lead time — it is not initialised — so a non-flat tide curve is proof the verification is wrong. It sagged, which led to:
remove_bias()removes it, and this reverses the ranking: tide sits flat at 0.047 m against the forecast's 0.092–0.104 m. Lewes is tide-dominated and this week has no surge, which is exactly when harmonic prediction is hard to beat. An earlier version of this PR claimed the forecast beat the tide table at every horizon; that was the datum offset, not skill.What survives is the lead-time signal, now clean: forecast error grows 0.092 → 0.104 m with residual bias drifting +0.020 → -0.005 m, while the reference stays flat.
Data — NOAA Delaware Bay OFS forecasts paired with observations and harmonic tide predictions from CO-OPS station 8557380 (Lewes, DE). 28 cycles over one week, lead times 0–48 h hourly, 1372 rows, 83 KB. Both sources are US federal works in the public domain.
The OFS station files are what make this cheap: 6.8 MB per cycle containing time series at 64 predefined stations rather than the full model grid, so no OPeNDAP or grid subsetting is involved.
build_dataset.pyregenerates the CSV, declares its dependencies inline (PEP 723), and reproduces the committed file byte for byte.No new API required. Lead time rides along as an auxiliary variable and
skill(by=["model", "lead_time"])does the grouping. Worth noting for the lead-time roadmap item: overlapping forecast cycles mean 1161 of 1372 timestamps are duplicated, andfrom_matched()accepts that without complaint, as do.plot.timeseries()and.plot.scatter().Suggested next step
This example shows the gap is ergonomics, not capability. Producing the central artefact of the whole feature — the skill-vs-lead-time curve — currently means creating the axes by hand and labelling them manually:
The axis is also plotted as a category rather than a numeric axis, so all 49 tick labels render and overlap into an unreadable band until they are thinned by hand — visible in the first render of this page.
ModelSkill already knows the grouping variable is a lead time, that it is numeric and in hours, and what the quantity and its unit are, so the user shouldn't have to restate any of it. The suggested next step is a dedicated accessor owning this, following the pattern
Comparer.verticalalready uses for depth — data stays 1-D along time, a non-dimension coordinate carries the second axis, and the accessor provides the binned skill and the characteristic plot:Two design questions to settle before implementing:
climpredmakes this an explicit user choice and is worth reading first.forecast_reference_timeandforecast_period, whilelead_timeis more widely understood and is what this example and the existing prototype notebooks already use.A lead-time-aware persistence baseline depends on this, since a persistence forecast is a different series at every horizon.
Open questions for review
lon_rho/lat_rho. Index 5 was chosen as nearest to the Lewes gauge, ~1.4 km away. Someone should confirm this against a published DBOFS station list before we publish skill numbers attributed to Lewes.remove_bias()and explains why. The committed CSV keeps the raw values. Reviewers should sanity-check that the -0.26 m tide offset really is a datum/epoch mismatch and not a mistake in how the CO-OPS prediction product was requested.tests/testdata/forecast_skill/data and its two notebooks be retired once this lands? They cover the same ground with unattributed values, and nothing documents where those numbers came from.Related: #692 corrects the roadmap entries this touches.