Skip to content

Use case

Build a training table from per-observation rows

You want an hourly (or finer) feature table for a model. dataset() is deliberately daily — one row per local-standard-time day. The raw per-observation path is obs(granularity=“observation”): every METAR/SPECI row as reported, source-identified. This recipe turns those rows into a clean training table.

CallGranularityUse it for
dataset(station, from, to)one row per LST daydaily targets, daily features
obs(station, from, to, granularity="observation")one row per observationhourly features, intraday signals

obs() lives in mostlyright.weather. With granularity="observation" it returns the merged per-report METAR/SPECI rows — the same ones dataset() aggregates — window-trimmed to the queried range with no bucketing or resampling. Pin source= when you need every row to carry a single named provenance so train and infer see identical data. See Source identity.

from mostlyright.weather import obs
# Historical archive coverage — pin the IEM feed, per-report grain.
rows = obs(
"KNYC", "2024-01-15", "2024-01-20",
source="iem", granularity="observation",
)
print(len(rows), "rows") # thousands — one per observation, not per day
print(rows[["observed_at", "observation_type", "temp_f", "dewpoint_f"]].head())

Three things to know about the rows you get back:

  • Use source="iem" (or "iem.archive") for history. The live aviation feed (AWC) only serves roughly the last 168 hours; IEM is the full hourly archive.
  • Rows are window-trimmed. granularity="observation" trims to the queried UTC range — no whole-calendar-month over-fetch. Callers needing the last LST day’s pre-midnight UTC tail should extend to_date by one day.
  • METAR + SPECI are mixed. Routine hourly observations are observation_type == "METAR" (near HH:51); off-cycle specials are "SPECI". raw_metar is preserved on every row so you can re-parse with MetPy if you need a field the SDK does not surface.
import pandas as pd
rows["observed_at"] = pd.to_datetime(rows["observed_at"], utc=True)
rows = rows.set_index("observed_at").sort_index()
# Strictly-hourly grid: keep routine METARs, drop off-cycle SPECIs
hourly = rows[rows["observation_type"] == "METAR"].copy()

If you want a fixed cadence regardless of reporting gaps, resample and forward-fill within a short limit:

hourly = hourly.resample("1h").last().ffill(limit=2)
from mostlyright.transforms import relative_humidity
hourly["rh"] = relative_humidity(hourly["temp_f"], hourly["dewpoint_f"])
# Lags + rolling windows over the hourly index
hourly["temp_f_lag3h"] = hourly["temp_f"].shift(3)
hourly["temp_f_roll6h_max"] = hourly["temp_f"].rolling("6h").max()
hourly["dewpoint_f_roll6h"] = hourly["dewpoint_f"].rolling("6h").mean()
# Time-of-day + calendar
hourly["hour"] = hourly.index.hour
hourly["doy"] = hourly.index.dayofyear

A common setup: predict the day’s realized high from features known by mid-morning. Pull the daily target with dataset() and join on the local date:

import mostlyright
daily = mostlyright.dataset("KNYC", "2024-01-15", "2024-01-20") # one row per LST day
daily = daily[["date", "obs_high_f"]].rename(columns={"obs_high_f": "target_high_f"})
hourly["date"] = hourly.index.tz_convert("America/New_York").strftime("%Y-%m-%d")
table = hourly.merge(daily, on="date", how="left")

The target is the realized high — it lies in the future relative to your morning features. That’s the textbook leakage trap. Filter your feature rows to what was knowable at decision time with KnowledgeView, and audit the assembled table before you train:

from mostlyright.core.temporal import KnowledgeView
morning_view = KnowledgeView(table, as_of="2024-01-16T14:00:00Z").filter_known_at()

See Walk-forward model evaluation and Temporal safety.

Train on source="iem" and you should infer on source="iem". The feeds disagree at the half-degree level (IEM may emit non-integer °F; the aviation feed is integer), which silently degrades a model deployed against the wrong source. Pinning the source with obs(source=...) is the whole point — and validate_dataframe raises SourceMismatchError if a row comes back tagged otherwise. An unpinned fused frame carries the merged.live_v1 tag, which a single-source pinned schema rejects, so a fused frame can never masquerade as single-source.

The TS SDK’s dataset() returns the same daily rows. TS obs() defaults to granularity: "observation" — the per-report rows it has always returned — so a TS caller gets per-observation rows out of the box; granularity: "daily" is not yet ported in TS (documented parity ticket) and throws rather than silently returning per-report rows. For the full offline feature build, Python is first today — build the table in Python, export it, and serve features to a TS runtime.

  • Gap-fill intelligently. ffill(limit=2) is a blunt instrument; real stations have multi-hour outages you may want to mask, not fill.
  • Cross-source union. Stitching IEM history onto the live AWC tail needs dedupe by observed_at — and reopens the source-identity question.
  • Resample the target. This builds hourly features against a daily target. A pure intraday target (next-hour temp) skips step 4 entirely.