Compare model profiles
@azohra/meteo.briefing/compare compares validated profiles for one site. It analyzes every
document with one timezone and threshold set, then compares the resulting
findings.
Build a comparison
Section titled “Build a comparison”import type { SiteForecast } from "@azohra/meteo.briefing/contract";import { compareForecasts, type ForecastComparison } from "@azohra/meteo.briefing/compare";
export function compareSite( profiles: readonly SiteForecast[], timeZone: string,): ForecastComparison { return compareForecasts(profiles, { timeZone, // ONE launch for the whole comparison — typically site-context.json's // elevation pick. Every member's analysis reads against it. launch: { elevationM: 1591 }, thresholds: { thermalWindow: { wstarMinMps: 1.0, depthMinM: 350 }, }, unavailable: [{ model: "nam", miss: "absent" }], });}Every profile must carry the same site.id. The function throws for an empty
input or mixed sites. The output records the resolved thresholds and retains
each member’s source analysis under analyses.
launch is optional and is one launch for the whole comparison, passed
to every member’s analysis (AnalyzeOptions.launch): documents are
launch-agnostic, and launch-relative votes compare only when every member
reads against the same launch. Without one the comparison degrades
gracefully — each member’s reference falls back to its model’s own ground,
the ledger’s elevationDeltaM is null, no member is benched for terrain
mismatch, and the launch-relative heightSpread finding is not emitted.
The envelope echoes the launch it used as site.launchAltitudeM (null
when none was supplied).
Compare cached analyses
Section titled “Compare cached analyses”compareForecasts is a wrapper — one construction — over the real seam:
compareAnalyses, which compares one site’s analysis envelopes at the
findings level. It is the door for cached and edge-produced envelopes:
analyze each profile where the document lives, serialize the
self-describing envelope
as JSON, and compare later without re-opening any profile. Note what its
options deliberately lack — timeZone, launch, thresholds come from
the members and are validated, never supplied; that is the whole point
of the envelope’s self-description.
import { ANALYZE_VOCABULARY_VERSION, type ForecastAnalysis } from "@azohra/meteo.briefing/analyze";import { compareAnalyses, type ForecastComparison } from "@azohra/meteo.briefing/compare";
export function compareCached( envelopes: readonly ForecastAnalysis[],): ForecastComparison { // Version skew is the one routine failure of a long-lived cache, and // re-analysis is its remedy. Left unchecked, compareAnalyses throws the // same fact as a named error: // compareAnalyses: vocabulary version skew — member // gdps@2026-08-09T00:00:00Z carries vocabularyVersion 4, this package // compares vocabulary 5; re-analyze the forecast with this package, or // compare with the package that produced it const skewed = envelopes.filter( (envelope) => envelope.vocabularyVersion !== ANALYZE_VOCABULARY_VERSION, ); if (skewed.length > 0) { throw new Error(`re-analyze ${skewed.length} cached member(s) with this package`); } return compareAnalyses(envelopes, { unavailable: [{ model: "nam", miss: "absent" }], });}Coherence is validated instead of reconstructed, and every failure is a distinct, named error:
- an empty member list;
site.idmismatch — one comparison, one site;- a duplicate
(model, referenceTime)member — the same run twice is a programming error, not a second member; vocabularyVersionskew against this package’s ownANALYZE_VOCABULARY_VERSION, with the remedy named (strict equality is v1 of this surface — tolerating older envelopes is a later, evidence-bearing decision, not a default);- missing self-description — an envelope serialized by a Windgram-era
release before 0.22 lacks
thresholds/deterministic/coveredDays, and the runtime check is the only door for parsed JSON the compile-time type cannot vouch for; timeZonemismatch — day keys pair only in one zone;- launch mismatch (
site.launchAltitudeM, null vs number included); - thresholds deep-inequality, naming the first differing path
(
thermalWindow.wstarMinMps: 0.9 vs 0.8).
Deliberately not validated: anything votes don’t read —
windCeilings and smoke are analysis inputs that shape kinds compare
never consumes.
Because compareForecasts analyzes every member itself with the
comparison’s single timezone, launch, and threshold set, its members
validate coherent by construction: the profile-shaped errors (mixed
sites, duplicate members, empty list) surface through the same
validation, and the version-skew and self-description errors cannot occur
there.
A member is a run, not a model
Section titled “A member is a run, not a model”Since vocabulary 2, member identity is the pair (model, referenceTime):
two runs of one model are two members, each with its own ledger row, votes,
and analyses entry. Passing the same run twice is a programming error and
throws.
The identity change re-keys the envelope — a documented breaking change
for vocabulary-1 consumers: the analyses record is keyed by the composite
member key "{model}@{referenceTime}" (the exported comparisonMemberKey
builds it) for every member, not by the model slug, because a model-slug
key can hold only one of a model’s two runs. Every vote, abstention, and
roster entry carries both member (the analyses / ledger key) and model
(the headline token), so provenance joins on one string without parsing it.
Turning the member axis fully onto one model’s runs through time is
compareRuns
in @azohra/meteo.briefing/history — the same discipline, the inverse axis, with its own
independently versioned vocabulary.
import { comparisonMemberKey, type ForecastComparison } from "@azohra/meteo.briefing/compare";
export function analysisFor( comparison: ForecastComparison, model: string, referenceTime: string,) { // v1 read comparison.analyses[model]; v2 keys every entry by member. return comparison.analyses[comparisonMemberKey(model, referenceTime)];}Read the member ledger
Section titled “Read the member ledger”Each ComparisonMemberLedger states the facts that affect comparability:
| Field | Meaning |
|---|---|
member / model | The member key ("{model}@{referenceTime}") every vote and analyses entry uses, and the plain model slug |
kind | Deterministic or ensemble document shape |
referenceTime / runAgeHours | Run identity and age relative to the newest member |
stepHours / hours | Leading forecast cadence (documents can widen mid-horizon) and published horizon |
modelElevationM / elevationDeltaM | Grid terrain and its difference from the comparison’s launch (null without a launch) |
benched | A terrain mismatch whose published lift never reaches the comparison’s launch |
These are stated facts for the consumer’s judgment, never applied as scores
— weighting is downstream. Transport misses belong in options.unavailable,
preserving the expected model roster even when a document is absent or
invalid.
Read the findings
Section titled “Read the findings”The vocabulary has four comparison kinds; narrow on kind and keep each
finding’s own confessions beside its numbers.
import type { ForecastComparison } from "@azohra/meteo.briefing/compare";
export function comparisonRows(comparison: ForecastComparison) { return comparison.findings.map((finding) => { switch (finding.kind) { case "windowAgreement": return { day: finding.day, windows: finding.windows.map((vote) => vote.member), quiet: finding.quiet.map((vote) => vote.member), abstained: finding.abstained, unanimous: finding.unanimous, wstarFlipAtMps: finding.sensitivity.wstarFlipAtMps, startSpreadHours: finding.timing.startSpreadHours, // Up to this many minus one hours of the spread is cadence // quantization, not disagreement. startStepHoursMax: finding.timing.startStepHoursMax, }; case "heightSpread": return { day: finding.day, spreadM: finding.spreadM, peaks: finding.peaks }; case "windDivergence": return { day: finding.day, bandWindSpreadMps: finding.bandWind.spreadMps, // Gust spreads exist only within one declared semantics class. hourMaxGustSpreadMps: finding.gust.hourMax.spreadMps, instantGustSpreadMps: finding.gust.instant.spreadMps, undeclaredGusts: finding.gust.undeclared.entries.length, }; case "windDirectionSpread": return { day: finding.day, maxAngularSeparationDeg: finding.maxAngularSeparationDeg, // Read the regime facts before the split: a large separation // across a large ground delta is two flow regimes, not two // opinions about one flow. acrossElevationDeltaM: finding.maxSeparation.elevationDeltaM, }; } });}windowAgreement
Section titled “windowAgreement”windowAgreement counts qualifying windows and complete quiet days as
votes, per local day. Every non-vote has a stated reason:
- a truncated quiet day abstains (
truncatedDay) — a model lacking a day’s data does not get to call the day; - a member whose horizon covers zero hours of a day abstains as
outOfHorizon, so “voters 3, unanimous true” cannot read as consensus when seven members never reached the day; - a benched member appears in no roster — the ledger’s
benchedentry is its stated reason for every day; - a window spanning local midnight votes on every day its cited hours
touch. On days other than the window’s own start day the vote carries
viaWindowFromnaming that day, because its numbers (duration, peaks) describe the whole window, not this day’s slice.
A day’s finding is suppressed only when it has zero voters and zero
abstentions: a horizon-edge day whose roster is pure abstentions keeps its
record, because “nobody could call the day” is a statement with reasons.
unanimous is null below two voters — unanimity of one is not a statement.
Window votes carry minimalPassingPercentile: the member’s same-day
percentileCrossing token (the lowest published percentile whose day
verdict passes the window floors). Null means the member emitted no
crossing — always for deterministic members, and for ensemble members
because every percentile agreed with p50. It is the absence of a crossing,
not a confidence claim.
sensitivity states the smallest threshold move that would flip a voter,
as the flip value itself — the voter’s own peak nearest each floor. For
window votes the flip is exact; for quiet votes it is necessary but not
sufficient, because the day peaks are per-quantity maxima at possibly
different hours and a window needs both floors met in the same hour.
The timing envelope uses unclipped edges only — an edge set by a
document’s horizon reads as “open since at least” / “still open at”, not as
timing. Every contributing edge carries its window’s stepHours, and
startStepHoursMax / endStepHoursMax state the widest step among the
contributors: a 3-hourly member’s 11:00 edge means “somewhere in
08:00–11:00”, so up to that many minus one hours of spread is quantization,
not disagreement. Multi-hour members stay in the spread — confession over
exclusion.
heightSpread
Section titled “heightSpread”heightSpread lists each voting member’s launch-relative peak and the
difference between the highest and lowest. It does not create a mean or
consensus height: measured spreads among comparable members run to
thousands of metres, and an average of that is a forecast no model made.
Ensemble peaks carry bandP10P90AboveLaunchM, the member’s own p10–p90
lift-top band at its peak hour — context only, never an outlier
detector: measured live, 57 of 61 deterministic peaks falling outside an
ensemble band sat above it. Exceedance is the norm (physics and
vertical-resolution regimes differ), so “outside the band” carries no
verdict weight.
windDivergence
Section titled “windDivergence”windDivergence rosters each window voter’s in-window climb-band wind
maximum and gust maximum (its windSummary restated), with the spread —
wind is a common flyability veto, and vocabulary 1 could not express
a wind split at all. The shape encodes the measured constraints:
- every entry carries
modelElevationM, mandatorily: cross-model mean-wind ratios spanned 0.18–1.22 at matched mountain sites, because models grounding a site hundreds of metres apart forecast different flow regimes — a spread read without the grounds beside it manufactures disagreement; - gust rosters group strictly within one declared semantics class —
hourMaxandinstantare never pooled; the measured gap between the classes is why one spread cannot serve both. Members without a declared gust semantics roster underundeclaredwith deliberately no spread, because an undeclared gust cannot be compared with anything, including another undeclared gust; - no shear rates appear anywhere in compare: subsampling a dense model
to a five-level ensemble grid read a median 0.41× of the dense rate on
identical hours — the sparse column reports a different, smeared layer,
so the rates are not comparable across level densities.
bandShearstays an analyze-only statement; - no directions here — that is
windDirectionSpread’s job.
windDirectionSpread
Section titled “windDirectionSpread”windDirectionSpread states the surface-flow direction split among a day’s
deterministic window voters: each member’s window vector-mean direction,
the maximum pairwise angular separation, and the max-separation pair with
both members’ model elevations. Ensembles never enter — published direction
percentiles are not circular statistics, and the analyze kind’s own gate
keeps them out. All aggregation is vector math; raw degrees are never
averaged, and a member whose vector-mean speed sits under the embedded
floor has no direction to roster.
The elevation-regime caveat rides the statement, never a footnote: measured
live, most daytime max-separations straddled a >300 m model-ground delta —
a low-terrain member forecasting a different flow regime, not a
disagreement about the same flow. Read maxSeparation.elevationDeltaM
before reading the angle.
Versioning
Section titled “Versioning”COMPARE_VOCABULARY_VERSION — currently 3 — versions these finding kinds independently of the published profile
schemaVersion. Vocabulary 3 rides analyze vocabulary 5: the bare-Ms
quantity fields take the Mps suffix grammar (peakThermalVelocityMps,
directionFloorMps), a pure rename with no kind change. Vocabulary 2 rode
analyze vocabulary 4 as one release:
votes read the renamed thermalWindow kind, and the analyses re-keying
above was that release’s breaking change. Downstream publishers choose
weighting, display language, and operational thresholds.
That Windgram-era 0.22 release rides under vocabulary 2 — no vocabulary event: the
comparison kind set is untouched. Its additions are compareAnalyses
above, and the widening of the envelope’s vocabularyVersion from the
literal to number under the
tolerant-reader convention
— the release’s one type-level break, zero wire change. Readers of
serialized comparison envelopes check the version at runtime and ignore
kinds and fields they do not know; the convention governs readers of the
closed set, never the set, which stays spike-gated and first-party.