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
| Fact | Value |
|---|---|
| Published at | site-context.json at the dataset root, beside sites.json and models.json |
| Parse | parseSiteContextJson from @azohra/meteo.briefing/contract |
| Zod authority | siteContextSchema, pinning SITE_CONTEXT_SCHEMA_VERSION (2) |
| JSON Schema | site-context.schema.json — the generated artifact for other languages |
| Cadence | none — 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 —
lidarbc— 1 m bare-earth lidar ground returns, where its pixels prove out;mrdem30— the national 30 m DTM, everywhere in Canada;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.
| Field | Meaning |
|---|---|
elevationM | Terrain-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. |
slopeDeg | Terrain slope at the launch, degrees (Horn 3×3 on the source grid). |
aspectDeg | Compass 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).
| Field | Meaning |
|---|---|
atLaunch | The 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 id | Product | Kind | Resolution | Licence |
|---|---|---|---|---|
glo30 | Copernicus GLO-30 DEM | surface model (DSM) | 30 m | Copernicus DEM licence |
lidarbc | LidarBC bare-earth DEM | bare-earth model (DTM) | 1 m | OGL-BC |
mrdem30 | NRCan MRDEM DTM (CanElevation) | bare-earth model (DTM) | 30 m | OGL-Canada |
worldcover2021 | ESA WorldCover 2021 v200 | land cover | 10 m | CC-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 alpinetest-ridge) and +12.4 m at forestedtest-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:
pnpm exec meteo forecast terrain --sites ./sites.json --output ./site-context.jsonOne 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 readsbuiltUp— 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. TreataspectDegas 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
elevationpick is the measured launch elevation a consumer passes at render time asMeteogramOptions.launch(and toanalyzeForecast/compareForecastsas theirlaunchoptions). - The terrain gap. Every profile publishes
site.modelElevationM— the model’s smoothed terrain; the context document adds what the launch actually sits on.
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.