Turn published forecasts into briefings

Point @azohra/meteo.briefing at a published forecast and get back answers you can put in front of pilots: when the day works, how high it goes, whether the wind crosses your limits — typed data your site renders its own way, plus the Meteogram when the answer should be a chart.

12:00–17:00 · launch 1050 m

Thermal window
12:00–17:00 — peak climb 3.09 m/s
Lift ceiling
2089.3 m — pinned to cloud base, under a boundary layer reaching 3826.1 m
Wind check
over your 8 m/s limit 16:00–17:00, peaking 8.8 m/s

From URL to briefing

The hero card above is this call and nothing else — fetch a published forecast, validate it, analyze it against your launch:

import { parseSiteForecastJson } from "@azohra/meteo.briefing/contract";
import { analyzeForecast } from "@azohra/meteo.briefing/analyze";

const response = await fetch(forecastUrl);
const forecast = parseSiteForecastJson(await response.text());
if (!forecast) throw new Error("failed contract validation");

const { findings } = analyzeForecast(forecast, {
  launch: { elevationM: 1050 },
  windCeilings: { surfaceMps: 6, bandMps: 8 },
});

Here is everything that call returned for the sample day — 8 findings, each written out from its own fields:

Thermal window thermalWindow
12:00–17:00, 6 h of usable lift, open at both edges of the document's horizon. Peak climb 3.09 m/s; the lift tops out at 2089.3 m at 17:00 — 1039.3 m above launch.
Lift ceiling liftCeiling
Cloud base caps the climb from 12:00 to 17:00: usable lift peaks at 2089.3 m under a 2089.3 m cloud base, while the heated boundary layer reaches 3826.1 m.
Wind windSummary
Strongest wind in the climb band: 8.8 m/s from 5° at 1125 m, 17:00.
Surface-wind ceiling windExceedance
Above your 6 m/s ceiling 16:00–17:00 (2 h), peaking 6.9 m/s at 17:00.
Band-wind ceiling windExceedance
Above your 8 m/s ceiling 16:00–17:00 (2 h), peaking 8.8 m/s at 17:00.
Wind direction windDirection
Surface flow veers 17° across the window — 355° at 2.2 m/s opening, 12° at 6.9 m/s closing. The climb band averages 1° at 4.46 m/s.
Shear in the climb band bandShear
Strongest layer shear 4.04 m/s across 1125–1625 m at 17:00 — 8.08 m/s per km, from 8.8 m/s at 5° up to 4.8 m/s at 10°.
Data caveats dataCaveats
This model never publishes windGustMps, capeJkg, cinJkg, pblHeightM, lowCloudPercent, midCloudPercent, highCloudPercent, levels[].verticalVelocityPaS, levels[].cloudFractionPercent, smoke — those read as absent, not zero.

Each row is a typed object — a kind, its numbers, the hours it cites, and the thresholds it was computed with — so your site decides what to show and how. The two wind ceilings (6 and 8 m/s) came from the call above: the package ships no safe-wind numbers, and with no ceiling there is no exceedance finding. The thermal-window thresholds (0.9 m/s of climb over at least 300 m) are defaults you can override per call.

Do the models agree?

compareForecasts answers with statements, not a winner: it analyzes every model's document under your launch and thresholds, then reports where they agree and how far they spread. Here it reads two documents for the same day — the same development, two hours apart:

Two controlled development timings

Two synthetic profiles can express the same daytime development at different teaching hours, making timing differences visible without treating either profile as correct.

Two synthetic profiles with matching daytime development shifted to earlier and later hours, rendered side by side from the comparison pair's documents.

Earlier developmentwindow 12:00–17:00 · open at the first published hour · peak 2.97 m/s
w* m/s 3 0 900m 2953ft 1599m 5245ft 2298m 7538ft 2996m 9831ft 3695m 12123ft 4394m 14416ft 12 13 14 15 16 17 launch 1050 m
Later developmentwindow 14:00–17:00 · start stated · peak 2.97 m/s
w* m/s 3 0 900m 2953ft 1599m 5245ft 2298m 7538ft 2996m 9831ft 3695m 12123ft 4394m 14416ft 12 13 14 15 16 17 launch 1050 m
One launch marker, one lift scale, two documents. The development matches; only its hours move.Units time UTC · altitude m MSL · thermal strength as shaded columns
Window agreement windowAgreement
2 voters, unanimous — both members call a thermal window. Only later states a start the day can time: 14:00. The other window's opening edge is clipped by its document's horizon, so it reads "open since at least" and stays out of the timing envelope — startSpreadHours is null, not a number no model stated.
Height spread heightSpread
0 m between the members' peak lifts — earlier puts 859.9 m above launch at 15:00; later puts 859.9 m above launch at 17:00. No mean, no consensus height: the spread is stated, weighting is yours.
Direction spread windDirectionSpread
3° between the members' window-mean surface flows (earlier 4° at 2.74 m/s, later 7° at 3.04 m/s), across a 0 m model-ground delta.

Has the forecast settled?

compareRuns answers by lining up what each successive run said about the same local day, newest first. The runs come out of the month archive every engine build appends to — one gzip member per build, existing bytes never rewritten — via loadForecastHistory:

data-sample/hrdps-continental/history/test-hill/2026-08.jsonl.gz · 2.0 kB

  • gzip member @ byte 0 · 2.0 kB · 1 line→ hrdps-continental · run 2026-08-12T12:00:00Z · generated 2026-08-12T18:22:29Zthe whole published document: Test Hill, 8 hours

sidecar 2026-08.index.json lists each member's byte offset and length, so a reader can fetch only the runs it is missing with a Range request — advisory, never authoritative

one run so far this month — each new build appends one more member

What compareRuns states

existenceTrajectory
every run's vote on the day — window, quiet, or an abstention with its stated reason
timingTrajectory
window edges across runs; a horizon-clipped edge reads “open since at least”, never as timing
magnitudeTrajectory
peak climb, launch-relative peak lift, and window duration per run — the deltas state themselves
identityDrift
republications and changed model facts, so a publisher fix is never read as weather
settled
whether the newest runs' lift magnitudes sit within one stated band — stability, not skill

It never editorializes: no "trending better", no weighting one run over another, no agreement grades — just the series and the arithmetic, for you to present as you see fit. History and run convergence documents every kind.

When the answer should be a chart

The meteogram tier draws the same document the findings above read: the profile laid out as a renderer-independent scene graph, then serialized to SVG. It is one tier of the package, not the package — Reading a Meteogram explains every mark, and Render your first Meteogram gets you to this chart from a URL.

Cloud base limits an otherwise deep climb

A saturated layer pins usable lift at cloud base even while the heated boundary layer extends higher.

Cloud base limits an otherwise deep climb. This Meteogram shows one atmospheric profile in Etc/UTC. A saturated layer pins usable lift at cloud base even while the heated boundary layer extends higher.

Precip mm/h 0.5 0 Cloud % 100 0 w* m/s 3.1 0 900m 2953ft 1599m 5245ft 2298m 7538ft 2996m 9831ft 3695m 12123ft 4394m 14416ft 12 13 14 15 16 17 23° 24° 26° 27° 26° 24° launch 1050 m 10° 20°
Usable lift Cloud base Boundary layer 0 °C Condensation
Usable lift rides the 2089.3 m cloud base all afternoon while the boundary layer arcs far above it — 3826.1 m at the peak-lift hour. The liftCeiling finding, drawn.Units altitude m and ft · wind km/h · temperature °C · precipitation mm · w* m/s

Start building

Install it, point it at a forecast URL, and you have findings and a chart. This example runs against a sample forecast hosted by this site, so it works as pasted:

pnpm add @azohra/meteo.briefing
import { loadForecast } from "@azohra/meteo.briefing/transport";
import { analyzeForecast } from "@azohra/meteo.briefing/analyze";
import { buildMeteogramScene, renderMeteogramSvg } from "@azohra/meteo.briefing/meteogram";

const loaded = await loadForecast({
  fetch,
  baseUrl: "https://meteo.azohra.com/data-sample",
  modelSlug: "hrdps-continental",
  siteSlug: "test-hill",
});
if ("miss" in loaded) throw new Error(`no forecast: ${loaded.miss}`);

const { findings } = analyzeForecast(loaded.profile, {
  launch: { elevationM: 1225.1 }, // site-context.json's elevation pick
});
const svg = renderMeteogramSvg(
  buildMeteogramScene(loaded.profile, { timeZone: "America/Vancouver" }),
);

Each capability is its own subpath — contract, transport, derive, analyze, compare, history, meteogram — and each has its own page in the briefing documentation.

This package reads forecasts. It doesn't make them.

Producing and publishing forecasts is the job of @azohra/meteo.forecast, the engine. Whatever the engine publishes, this package can read, analyze, compare, and draw — the full reference is the briefing documentation.