Skip to content

Site context document

site-context.json is the third catalogue-root file, beside the hand-maintained models.json and sites.json: static per-site ground truth, machine-measured from open elevation and land-cover data and committed like the catalogues it annotates. It is the one published home of physical fact measured by the forecast engine — humans author WHERE (sites.json), the engine measures WHAT (this file) — and it answers “what is this launch, physically?” where a profile can only answer “what does this model think the atmosphere above it does?”. The teaching companion — why the numbers matter to a pilot reading a Meteogram — is The mountain the model sees.

On the wire

FactValue
Published atsite-context.json at the dataset root, beside sites.json and models.json
ParseparseSiteContextJson from @azohra/meteo.briefing/contract
Zod authoritysiteContextSchema, pinning SITE_CONTEXT_SCHEMA_VERSION (2)
JSON Schemasite-context.schema.json — the generated artifact for other languages
Cadencenone — no runs; regenerated when the site catalogue changes, never on a schedule

Envelope and join semantics

site-context.json
├── schemaVersion + generatedAt
├── sources[] { id, product, kind, resolutionM, licence, attribution, url }
└── sites { <site-slug> → entry }
├── elevation { source, elevationM }
├── terrain { source, elevationM, slopeDeg, aspectDeg, relief[] }
└── landCover { source, atLaunch, fractions[] }

Site slugs are the identity: join sites against sites.json by slug. Coordinates and timezone are deliberately not echoed here — sites.json is their home. Every source field inside a site block names a sources[] entry, and each sources[] entry carries the attribution statement its licence requires — the attribution travels with the data, and renderers that display a source’s values display it.

The elevation block

THE launch elevation — the number consumers render the launch line with (MeteogramOptions.launch) and read launch-relative analysis against. It is a measurement selection, not a computation: the forecast engine samples ground observations at the catalogued coordinates and picks by explicit priority —

  1. lidarbc — 1 m bare-earth lidar ground returns, where its pixels prove out;
  2. mrdem30 — the national 30 m DTM, everywhere in Canada;
  3. glo30 — the surface model, canopy included, as a loud last resort: the terrain command warns on stderr when a site falls through to it, because a surface height over forest is not ground.

source names the winning sources[] entry, so the resolution and licence attribution of the pick are one join away. In the committed document, sites covered by British Columbia’s 1 m lidar read from LidarBC (test-hill, test-valley); test-ridge, which no 1 m lidar covers, reads from MRDEM-30. Since schemaVersion 2 this block replaces v1’s optional bareEarth block — the pick is the best bare-earth answer, and keeping both would duplicate it. The generator cross-checks the pick against terrain.elevationM and warns when they disagree by more than 100 m — a gap that large suggests the pin lands on different terrain in different sources, not canopy.

The terrain block

Terrain analysis comes from one consistent elevation model across every site — Copernicus GLO-30 — so the numbers compare across the catalogue.

FieldMeaning
elevationMTerrain-model elevation at the launch point, metres MSL, bilinear. From a surface model this includes canopy — compare with the elevation pick before reading small differences as error.
slopeDegTerrain slope at the launch, degrees (Horn 3×3 on the source grid).
aspectDegCompass bearing of the downslope direction, degrees 0–359. Low-confidence on near-summit launches (relief percentile near 100), where tiny elevation noise swings the bearing.
relief[]Relief discs in ascending radius (1, 3, and 10 km here): the disc’s minM and maxM terrain, and the launch elevation’s percentile rank among it.

The percentile is the topological reading: 100 means the launch is the local summit, 50 means it sits mid-slope in its surroundings. Read the radii together — high at 1 km and low at 10 km is a foothill in front of bigger terrain.

The landCover block

What the ground around the launch is made of — the thermal-source character (forest holds heat back; clearcut, rock, and grass release it; water kills it).

FieldMeaning
atLaunchThe class of the single 10 m pixel under the launch point. One pixel is fragile — read it beside the 1 km fractions.
fractions[]Composition discs in ascending radius: byClass maps each class to its fraction of the disc, 0–1.

Classes are the ESA WorldCover taxonomy published as semantic names, not numeric codes (10 treeCover, 20 shrubland, 30 grassland, 40 cropland, 50 builtUp, 60 bareSparse, 70 snowIce, 80 water, 90 wetland, 95 mangroves, 100 mossLichen). Inside byClass, a class absent from the disc is omitted, and that absence means zero — the land-cover map is wall-to-wall — unlike data absences everywhere else in the contract, which mean “not published”.

Sources and licences — verified 2026-08-10

Source idProductKindResolutionLicence
glo30Copernicus GLO-30 DEMsurface model (DSM)30 mCopernicus DEM licence
lidarbcLidarBC bare-earth DEMbare-earth model (DTM)1 mOGL-BC
mrdem30NRCan MRDEM DTM (CanElevation)bare-earth model (DTM)30 mOGL-Canada
worldcover2021ESA WorldCover 2021 v200land cover10 mCC-BY 4.0

Facts established by sampling the live feeds at the catalogued sites:

  • GLO-30 is a surface model — radar-derived, canopy and buildings included, heights on EGM2008 [verified 2026-08-10]. In the committed document it lands within a few metres of the bare-earth pick over open ground (−0.5 m at test-valley, −2.3 m at alpine test-ridge) and +12.4 m at forested test-hill — the canopy. Its 90 m sibling GLO-90 smooths ridge-top launches low — measured up to 13 m at the founding catalogue’s summit site [verified 2026-08-10] — the same smoothing story, milder, that weather-model terrain tells at 2.5–10 km grids.
  • LidarBC serves British Columbia’s 1 m bare-earth lidar DTM from an anonymous object store under OGL-BC [verified 2026-08-10].
  • NRCan MRDEM-30 is the national 30 m DTM on the CanElevation open bucket under OGL-Canada [verified 2026-08-10]. It exists everywhere in Canada, which is exactly its role here: the bare-earth answer where no 1 m lidar project reaches.
  • ESA WorldCover 10 m 2021 v200 is CC-BY 4.0 with a global overall accuracy of 76.7% [verified 2026-08-10] — a single 10 m pixel’s class is fragile, which is why the disc fractions matter more than the point class.

Every licence above requires attribution, so the document itself carries each source’s required statement in sources[].attribution — a consumer that displays the values displays the attribution, without needing this page.

Generation and publication

The document is machine-written by the forecast engine’s one-shot meteo forecast terrain command. Its geospatial dependencies load only when this command runs, so scheduled forecast builds stay lean:

Terminal
pnpm exec meteo forecast terrain --sites ./sites.json --output ./site-context.json

One run reads the site catalogue, samples the four sources, and writes the whole document: about 27 MB fetched and under a minute for the entire catalogue, all from anonymous stores — no credentials [verified 2026-08-10]. Commit the result beside your site catalogue, and have your upload flow publish the two together at the dataset root, so the published context always describes the published sites. Regenerate it when the site catalogue changes — the configure-launches guide makes that a step of adding a site.

The coverage lesson: footprints overstate, pixels decide

NRCan’s 1 m HRDEM was the obvious bare-earth candidate — and at verification it covered only one of the founding catalogue’s four sites, because its lidar projects are valley-corridor focused [verified 2026-08-10]. The STAC search and bbox footprints said otherwise: a launch inside a project’s declared footprint can still sit on nodata, so footprint intersection overstates coverage and only sampling the pixel at the point proves data exists there. That is a reusable provider-verification lesson, the elevation twin of this project’s rule that provider documentation is hypothesis until a live response confirms it. It is why the elevation pick reads from LidarBC where its pixels prove out and from MRDEM-30 where they do not — never from the catalogue that claimed more.

Reading caveats — verified 2026-08-10

  • Vertical datum: the Canadian sources are on CGVD2013, Copernicus on EGM2008. The two differ by decimetres at these sites — negligible against DEM error, so the document does not convert between them.
  • Canopy: GLO-30’s surface heights include trees and buildings. Where a launch is forest-adjacent, expect it to sit above the bare-earth value; that offset is the canopy, not an error.
  • Point pixels: test-valley’s launch pixel reads builtUp — built ground under the launch point — while its 1 km disc is nearly half grassland with forest behind. The fractions carry the thermal character; the pixel is a trivia answer.
  • Aspect confidence: tiny elevation noise swings the downslope bearing wherever the ground barely falls away. At the founding catalogue’s summit site, aspect differed by ~46° between GLO-30 and GLO-90 [verified 2026-08-10]; in the committed document, test-hill’s 2.7° slope makes its 14° bearing the case to distrust. Treat aspectDeg as low-confidence wherever the slope is slight or the 1 km percentile is near 100.

Consume it beside a profile

Fetch site-context.json once from the dataset root — on your own schedule, it has no cadence — validate it at the trust boundary, and join by slug. It has two jobs beside a profile:

  • The launch elevation. Profiles are launch-agnostic samples; the elevation pick is the measured launch elevation a consumer passes at render time as MeteogramOptions.launch (and to analyzeForecast / compareForecasts as their launch options).
  • The terrain gap. Every profile publishes site.modelElevationM — the model’s smoothed terrain; the context document adds what the launch actually sits on.
terrain-gap.ts
import { parseSiteContextJson, parseSiteForecastJson } from "@azohra/meteo.briefing/contract";
export function modelTerrainGap(contextText: string, profileText: string): string {
const context = parseSiteContextJson(contextText);
const profile = parseSiteForecastJson(profileText);
if (!context || !profile) throw new Error("unsupported document");
const entry = context.sites[profile.site.id];
if (!entry) return `${profile.site.id}: no published terrain context`;
// The real mountain: the document's measured elevation pick.
const mountainM = entry.elevation.elevationM;
const gapM = profile.site.modelElevationM - mountainM;
const direction = gapM < 0 ? "below" : "above";
return (
`${profile.site.name}: model terrain ${profile.site.modelElevationM} m sits ` +
`${Math.abs(Math.round(gapM))} m ${direction} the real mountain (${mountainM} m)`
);
}

The parse guards return the typed document or null; rejected input is never patched into shape. A consumer that renders any of the document’s values also renders the matching sources[].attribution string.