Skip to content

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.

Two models, one verdict, evidence attached Two controlled profiles with the same daytime development at different hours, rendered side by side, with the windowAgreement finding compareForecasts computed from them: 2 voters, unanimous true.

compare-profiles.ts
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).

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.

compare-cached.ts
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.id mismatch — one comparison, one site;
  • a duplicate (model, referenceTime) member — the same run twice is a programming error, not a second member;
  • vocabularyVersion skew against this package’s own ANALYZE_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;
  • timeZone mismatch — 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.

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.

join-provenance.ts
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)];
}

Each ComparisonMemberLedger states the facts that affect comparability:

FieldMeaning
member / modelThe member key ("{model}@{referenceTime}") every vote and analyses entry uses, and the plain model slug
kindDeterministic or ensemble document shape
referenceTime / runAgeHoursRun identity and age relative to the newest member
stepHours / hoursLeading forecast cadence (documents can widen mid-horizon) and published horizon
modelElevationM / elevationDeltaMGrid terrain and its difference from the comparison’s launch (null without a launch)
benchedA 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.

The vocabulary has four comparison kinds; narrow on kind and keep each finding’s own confessions beside its numbers.

comparison-findings.ts
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 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 benched entry 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 viaWindowFrom naming 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 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 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 — hourMax and instant are never pooled; the measured gap between the classes is why one spread cannot serve both. Members without a declared gust semantics roster under undeclared with 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. bandShear stays an analyze-only statement;
  • no directions here — that is windDirectionSpread’s job.

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.

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.