Skip to content

Getting started

This page takes you from nothing to a blended forecast. It assumes no prior knowledge of the project and no meteorology.


What you need

1. A station observation database. A SQLite file written by ambientweather2sqlite, containing an observations table with one row per sample (roughly one per minute). This is your ground truth — the thing every forecast is scored against.

2. A provider forecast archive. A SQLite file written by omni-weather-forecast-apis, containing the forecasts that a set of weather APIs published for your location, recorded over time.

The archive is the hard part

The archive has to have been recorded as it happened. You cannot go back and ask a provider what it predicted last Tuesday. If you have not started polling yet, do that first — see Start the cron — because until the archive has some age, this system has very little to work with. It will tell you so honestly rather than pretending.

3. Python 3.13+ and uv.


Install

uv tool install grounded-weather-forecast

uv creates an isolated environment and exposes the command on your path. Check it worked:

grounded-weather-forecast --version
grounded-weather-forecast --help

To contribute to the project instead, clone the source repository and run uv sync --dev.


Configure

Download the example config and edit it:

curl -L https://raw.githubusercontent.com/hbmartin/grounded-weather-forecast/main/config.example.toml \
  -o config.toml

config.toml is git-ignored, so your local paths stay local. Here is what each section does.

[station] — where truth comes from

[station]
db_path = "aw2sqlite.db"          # your observation database
timezone = "America/Los_Angeles"  # YOUR local timezone. "Today's high" means
                                  # the max over the LOCAL calendar day.
latitude  = 34.2768
longitude = -117.1692
elevation_m = 1400.0              # REQUIRED: reduces pressure to sea level and
                                  # anchors the solar-geometry features
immutable = true                  # true = a static snapshot file
                                  # false = a live database another process writes

Set immutable = false for a live database

immutable = true tells SQLite "nobody is writing this file, skip the locking". That is a lie if your collector daemon is running, and SQLite will happily read garbage. Use true only for a copy.

[station.columns] and [station.units] — what your sensors are called

Different stations name things differently. This maps your column names onto the canonical channels the system understands:

[station.columns]
outTemp   = "temp"              # station column  ->  canonical channel
outHumi   = "humidity"
avgwind   = "wind_speed"
gustspeed = "wind_gust"
eventrain = "rain_counter"
AbsPress  = "pressure_station"

[station.units]
temp             = "degF"       # canonical channel  ->  its unit in YOUR data
wind_speed       = "mph"
rain_counter     = "inch"
pressure_station = "inHg"

Everything is converted to metric internally (°C, m/s, mm, hPa). The defaults above cover a standard AmbientWeather unit; if yours reports Celsius, write temp = "degC" and it will pass through unchanged.

[forecasts] — where the providers' opinions come from

[forecasts]
db_path = "crestline_forecasts.sqlite"
immutable = false              # false while the collector writes this WAL DB
sources = []                    # empty = use every provider in the archive
max_forecast_age_hours = 12.0   # a forecast older than this is not used

The rest

[dataset]
dir = "data"                    # where parquet outputs go (git-ignored)
precip_reset_fraction = 0.5     # a rain-counter drop below this fraction of the
                                # prior value is a real reset; a smaller dip is noise

[reports]
dir = "reports"                 # where markdown leaderboards go

[artifacts]
dir = "artifacts"               # alignment studies and promoted releases

Defaults are sensible for [qc], [provider_qc], [backtest] and [predict] — you can ignore them until you read Advanced usage.


Your first run

1. Check your truth

grounded-weather-forecast qc

This is the most important command to run first, and the one people skip. It tells you what your station actually recorded, and what the quality-control filters think of it:

observations: 76507 samples, 2025-06-14 18:39:53 .. 2026-07-13 19:21:03
┌──────────────────┬─────────┬─────────┬───────────────┬───────┬──────────┬───────┐
│ channel          ┆ samples ┆ missing ┆ out_of_bounds ┆ spike ┆ flatline ┆ clean │
╞══════════════════╪═════════╪═════════╪═══════════════╪═══════╪══════════╪═══════╡
│ temp             ┆ 76507   ┆ 2774    ┆ 0             ┆ 0     ┆ 0        ┆ 73733 │
│ pressure_station ┆ 76507   ┆ 24541   ┆ 0             ┆ 0     ┆ 0        ┆ 51966 │
...

How to read this:

  • missing — the sensor reported nothing. Some of this is normal.
  • out_of_bounds — physically implausible values (a −60 °C reading in July).
  • spike — an isolated jump-and-return, the signature of a sensor glitch rather than real weather.
  • flatline — the value never changed for hours. A stuck sensor.
  • clean — usable as truth.

Flagged samples are excluded, never corrected. If clean is very low for a channel, forecasts for that variable will be poorly trained and the system will tell you so rather than quietly making things up.

It also prints how many hours and days survived aggregation. If those numbers are much smaller than you expect, you have gaps — worth knowing before you draw conclusions from anything downstream.

2. Build the dataset

If [ensembles].models is configured, ingest the latest ensemble cycles first:

grounded-weather-forecast ingest-ensembles

Ensemble statistics are materialized into the matrix, so every later ingest must be followed by another build-dataset before backtesting or serving.

grounded-weather-forecast build-dataset

This reads both databases and writes parquet files into data/: the QC'd truth at minute, hour and day resolution, and the supervised matrix — one row per (moment a forecast could have been made × hour it was about), with every provider's opinion as a column and the truth that eventually happened as the label.

dataset fingerprint: c5cdd0ee7777973f
sources: met_norway, meteosource, nws, open_meteo, pirate_weather, visual_crossing, weatherapi, weatherbit
snapshots: 2
  truth_minute: 76507 rows
  hourly_matrix: 700 rows
  ...

snapshots: 2 is the number of distinct moments your archive can reconstruct. If that number is small, your archive is young — see below.

3. Get a forecast

grounded-weather-forecast predict

This prints a JSON document with all three products. Trimmed:

{
  "schema_version": 5,
  "issued_at": "2026-08-08T13:46:00+00:00",
  "latitude": 34.2768,
  "longitude": -117.1692,
  "timezone": "America/Los_Angeles",
  "status": "ready",
  "status_reason": null,
  "release_ids": ["a639c6834b5031af"],
  "dataset_fingerprint": "d6627e2682a82537",
  "observation_at": "2026-08-08T13:45:17+00:00",
  "sources": ["met_norway", "nbm", "nws", "open_meteo", "..."],
  "minutely": [
    {"valid_time": "...T13:47:00+00:00", "minutes_ahead": 1,
     "temp_c": 22.383, "precip_intensity_mmh": 0.0, "pop": 0.0,
     "methods": {"temp_c": "anchored_hourly_blend", "pop": "native_equal_weight"}}
  ],
  "hourly": [
    {"valid_time": "...T14:00:00+00:00", "lead_hours": 0.23, "lead_bucket": "0-1h",
     "values":  {"temp_c": 22.383, "wind_speed_ms": 0.088, "pop": 0.0},
     "methods": {"temp_c": "anchored_fitted_ewma", "pop": "damped_grounded_equal_weight"},
     "quantiles": {"temp_c": {"0.05": 19.562, "0.25": 21.596, "0.75": 23.328, "0.95": 24.663}},
     "quantiles_source": {"temp_c": "dressed_bucket"},
     "selection_reasons": {"temp_c": "lowest backtest MAE among promotable common-case methods"},
     "release_ids": {"temp_c": "a639c6834b5031af"},
     "truth_semantics": {"temp_c": "mean"}}
  ],
  "daily": [
    {"date_local": "2026-08-08", "lead_days": 0,
     "values":  {"temp_max_c": 35.3, "temp_min_c": 21.0, "pop": 0.0},
     "methods": {"temp_max_c": "damped_grounded_equal_weight", "temp_min_c": "equal_weight"},
     "truth_semantics": {"temp_max_c": "inst", "temp_min_c": "inst"}}
  ]
}

Things worth noticing:

  • observation_at — the station reading the nowcast is anchored to. The minutely forecast starts from what your yard says right now and relaxes toward the providers' consensus over the hour. That live reading is the one input no provider has.
  • status and release_ids — say whether compatible live evidence justified the forecast and identify the promoted decision, both at document level and per hourly/daily variable. Schema 5 also records each variable's truth_semantics, its selection_reasons, and — via quantiles_source — whether an uncertainty band is the method's own distribution or was dressed from live residual quantiles. A young archive emits an explicit degraded equal-weight forecast rather than pretending grounding was fitted.
  • methodsevery single value tells you which method produced it. When you wonder why today's high is 35.3 °C, the answer is in the document.
  • lead_bucket — how far ahead this is, grouped. Skill is measured per bucket because a method that wins at 2 hours often loses at 7 days.

Write it to a file instead of stdout:

grounded-weather-forecast predict --out forecast.json

"It didn't work" — the two messages you will probably see

cannot predict: no provider forecast within 12.0h of ...

Your archive's most recent forecast is more than 12 hours old, so the system refuses to serve a stale forecast rather than pretending it is current. Either your polling cron is not running, or you are testing with an old archive.

To load the exact archived document previously served at an instant—or, if none was served then, reconstruct one using only data/evidence available by that instant (handy for testing):

grounded-weather-forecast predict --now 2026-03-22T17:00:00

no rolling-origin folds. The archive spans 0.0 days ...

You ran backtest and it found nothing to test. This is not a failure — it is the system being honest. Backtesting means "train on the past, test on the future, repeatedly", and that needs an archive with some history. With a 97-day requirement (90 days training + a 7-day test step, both configurable) and a one-day-old archive, there is nothing to do.

Two ways forward, and you should do both:

  1. Start the cron so the archive begins accumulating (below).
  2. Backfill a synthetic archive so you can measure something today — see Advanced usage.

4. Look at the leaderboard

Once you have an archive (real or backfilled) with some history:

grounded-weather-forecast backtest --source live      # or --source synthetic
grounded-weather-forecast report

report writes markdown into reports/ and prints the winners:

winners (scores_hourly_synthetic)
┌─────────┬───────────────┬─────────────┬───────────────┬──────┬──────────┐
│ product ┆ variable      ┆ lead_bucket ┆ method_id     ┆ n    ┆ mae      │
╞═════════╪═══════════════╪═════════════╪═══════════════╪══════╪══════════╡
│ hourly  ┆ temp_c        ┆ 24-48h      ┆ gbm           ┆ 752  ┆ 1.288    │
│ hourly  ┆ temp_c        ┆ 96-168h     ┆ equal_weight  ┆ 2256 ┆ 1.748    │
└─────────┴───────────────┴─────────────┴───────────────┴──────┴──────────┘

predict then uses only winners from a live evaluation run compatible with the current dataset. You do not have to choose a method — promotion chooses, and if it has no evidence for a slice, the document is marked degraded, uses fit-free equal weight, and says why.

The markdown report has much more: skill against the best single provider, Diebold–Mariano significance tests (is that difference real, or noise?), a provider error-correlation matrix, and — once you have served some forecasts — a self-verification section scoring what this system actually predicted against what actually happened.


5. Start the cron (do this today)

This is the highest-value thing you can do, and it is not part of grounded-weather-forecast — it belongs to the upstream omni-weather-forecast-apis project. Something like:

# hourly for a diverse core of providers
5 * * * *  cd /path/to/collector && uvx --from "omni-weather-forecast-apis[cli]" omni-weather --config ./config.toml --lat 34.2768 --lon -117.1692 --sqlite forecasts.sqlite

# every 6 hours for the rest (respects free-tier quotas)
5 */6 * * * cd /path/to/collector && uvx --from "omni-weather-forecast-apis[cli]" omni-weather --config ./config-extended.toml --lat 34.2768 --lon -117.1692 --sqlite forecasts.sqlite

On macOS, prefer the ready-made launchd template instead — see Scheduling, which also covers the ensemble-ingest, predict, and nightly-maintain jobs and their cadence rationale.

Poll a diverse core hourly — Open-Meteo with several explicit models (ecmwf_ifs025, gfs_seamless, icon_seamless), NWS, MET Norway, and one or two commercial providers — and the rest every 6 hours. Diversity matters more than count: this project's own measurements show eight providers behaving like fewer than two independent ones, because most of them repackage the same underlying weather models.

Every week you delay is a week of training data that cannot be recovered.


Where to go next

  • Advanced usage — backfilling, tuning, adding your own blending method, reading the leaderboard properly.
  • Theory — why the system does what it does.
  • Limitations — what it cannot do, and three real bugs the evaluation harness caught. Read this before trusting any number.
  • CONTEXT.md — the project glossary (issue time, valid time, lead, grounding, anchoring, …). If a word in the output confuses you, it is defined there.