How a windgram is computed
A windgram is a time–height chart of a soaring day: hours across, altitude up, and at every intersection the wind, temperature, moisture, and derived lift a pilot cares about. This pipeline builds one per catalogued launch from each new run of a numerical weather model and publishes it as JSON for anyone to render.
The derivation is a port of canadarasp's windgram,
with our thanks — constant for constant, deliberately. Pilots calibrate against
the original, and a windgram that disagreed with it for the same site and hour
would read as broken rather than rigorous. This page explains both halves: how
to read the picture, and how the numbers behind it are made. The ground truth
for everything below is windgrams/windgram.py.
Reading a windgram
The model's ground is not your ground
Every profile starts from a weather model on a grid — 2.5 km cells for the
HRDPS baseline. At that resolution mountains keep their shape but lose their
sharpness: the model's ground at a launch sits below the real launch because
the grid smooths the mountain down. The chart floor and every "surface" value
live at that model ground, which is why they can read a couple hundred metres
under where you are standing. Each profile carries both numbers —
modelElevationM and siteAltitudeM — so a renderer can show the
discrepancy instead of hiding it. (For a concrete example of how much this
matters across resolutions, see the model bestiary.)
The derived quantities
The model does not forecast "lift". Every soaring number on the chart is derived from raw model fields, the same way canadarasp derives them:
Boundary layer top (usually drawn dashed) — take the surface temperature, cool an imaginary parcel of it at the dry adiabatic rate of 9.8 °C per kilometre as it rises, and find the height where it stops being warmer than the air around it. Below that height the sun's heating stirs the air; above it, the atmosphere is stratified and calm.
Thermal strength (
thermalVelocityMs, w*) — Deardorff's convective velocity scale: the cube root of surface heat flux times boundary-layer depth over potential temperature. In words: how hard the sun drives the mixing, times how deep the mixing goes.Cloud base (
cloudBaseM, usually dotted) — the lifted condensation level, from how far surface air is from saturation. A per-levelcloudflag marks where the model already has condensation happening.Stability (
lapseCPer1000Fton each level) — the temperature lapse between adjacent pressure levels, in °C per 1000 ft. Renderers class it into bands from unstable (near the dry adiabatic 3.0 °C/1000 ft) through stable to inverted (temperature rising with height).Usable lift top (
usableLiftTopM) — the interesting one, below.
Why usable lift rides above the boundary layer
At first glance it looks like a bug: the usable-lift line sits consistently above the boundary-layer top, as if lift outlives the layer that makes it. It is deliberate.
In the 1980s, Lenschow and Stephens flew instrumented aircraft through convective boundary layers and fit a curve to the updrafts they measured. The average updraft at height z inside a boundary layer of depth D came out near:
w = w* × 1.34 × (z/D)^⅓ × (1 − 0.8·z/D)
canadarasp multiplies that coefficient by roughly three — giving the 4.0 in the code — because the core of a thermal is about three times stronger than the average across it. Their "usable lift top" (hcrit) is then the height where even that best core no longer out-climbs a 1 m/s sink rate, capped at cloud base because good pilots don't fly in cloud.
So the two lines answer different questions. The boundary-layer line says where average mixing stops — where a dry parcel from the surface loses its buoyancy. The usable-lift line says how high the best core of the day stays worth turning in — and cores genuinely overshoot the mixed-layer top before they die, so lift-top above boundary-layer is the model's honest opinion, not an error. Read it accordingly: an average pilot centring imperfect lift should expect the truth to sit between the two lines, closer to the boundary-layer top on an ordinary day.
Open question. Neither the average profile nor the ×3 core factor has been validated against any particular valley. Pilots record IGC tracks; a season of forecast lift-top against actually-achieved altitudes would give a site its own correction factor — at which point nobody would be choosing between forty-year-old curve fits. The history archives exist so that study is possible.
The computation, step by step
Inputs
Each build collects, for every catalogued site and every forecast hour:
- Nine surface fields — 2 m temperature, 10 m wind speed and direction, cloud cover, 2 m dew point depression, sensible heat flux, latent heat flux, mean sea-level pressure, and hourly precipitation.
- Five fields at each of nine pressure levels (925, 900, 875, 850, 800, 750, 700, 650, 600 hPa) — geopotential height, temperature, dew point depression, wind speed, and wind direction.
- Model terrain height at the launch, sampled once per run.
That is 54 field–level combinations per hour. How they are fetched differs per
model — WCS crops, GRIB2 byte ranges, whole files — but every builder ends at
the same source-hour shape and calls the same derive_windgram_profile.
Level filtering
Pressure levels are kept only when their height is finite and at least 20 m above the model's terrain. In mountain terrain the 925 hPa surface (and sometimes more) is underground; it is discarded rather than plotted below the chart floor.
Lapse rate and cloud flags
For each kept level, the lapse rate to the next level up is
(T_next − T) / (z_next − z) × 304.8 — °C per 1000 ft, negative when
temperature falls with height in the plotted convention. A level is flagged
cloud when its dew point depression is under 0.5 °C: the model has that
layer at or near saturation.
Cloud base
The lifted condensation level, by the classic surface approximation: 121 m of climb per degree of surface dew point depression, above the model terrain.
cloudBaseM = modelElevationM + max(0, dewPointDepressionC) × 121
Boundary-layer depth
A parcel at the surface temperature is lifted dry-adiabatically (0.0098 °C/m). Walking up the sorted levels, the first level where the parcel is no longer warmer than the environment brackets the boundary-layer top; the exact crossing height is solved by intersecting the parcel line with the environmental lapse between that level and the one below. If the parcel is warmer than every level, the top of the sounding is used — a deliberately honest degenerate case for very unstable profiles over a shallow sounding.
Thermal velocity w*
Deardorff's velocity scale, with canadarasp's exact constants:
Q_v = SHTFL + 0.000245268 × T_K × LHTFL # virtual heat flux, W/m²
θ = T_K × (1015 / p_first)^0.28482 # potential temperature
w* = ((0.0075516 / θ) × Q_v × D)^(1/3) # D = BL depth in metres
The odd-looking constants are physical quantities folded together:
0.000245268 ≈ 0.61·c_p/L_v converts latent heat flux into its buoyancy
contribution (moist air is lighter), 0.0075516 ≈ g/(ρ·c_p) converts W/m²
into a kinematic flux, and the exponent approximates R/c_p for dry air.
When the virtual heat flux is zero or negative — night, overcast, rain — w*
is zero and there is no thermal forecast, by construction.
Usable lift top (hcrit)
Starting from w* and the boundary-layer depth D:
- If the peak core updraft
w* × 2.02cannot beat the 1 m/s sink rate, there is no usable lift; the field is null. (2.02 is just the maximum of the ×3-core Lenschow–Stephens profile4·x^⅓·(1 − 0.8x).) - Otherwise walk up the levels from 0.25·D, evaluating the core updraft
w* × 4 × (z/D)^⅓ × (1 − 0.8·z/D)at each level's height. - The first level where the core drops to the sink rate brackets hcrit; linear interpolation against the previous evaluation pins it down.
- The result is capped at cloud base — and if the walk reaches cloud base first, cloud base is the answer.
- If the core never drops below the sink rate within the sounding, the usable top is the boundary-layer top itself (still capped at cloud base).
Smoothing
cloudBaseM and usableLiftTopM are the two derived series noisy enough to
flicker hour-to-hour, so each interior hour is smoothed with a 1‑2‑1 kernel —
(previous + 2×current + next) / 4 — applied only where the neighbours are
exactly one hour away and none of the three values is missing. Everything else
is published raw.
The flyable-day window
Profiles carry only hours a pilot can fly: 07:00–21:00 in the sites' local time (America/Vancouver for the founding catalogue), and only for days that have at least five such hours in the run — a run's ragged first or last day is dropped rather than shown as a two-hour stub. If the filter would empty the profile entirely, the unfiltered hours are kept: a degenerate run is published honestly rather than not at all.
Provenance of the constants
Every constant above — the 121 m/°C cloud base, the 0.28482 exponent, the 4.0 core coefficient, the 1 m/s sink rate, the 2.02 threshold — is canadarasp's, ported exactly. The Python module rebuilt the committed output of the original TypeScript implementation to within one double ULP before it replaced it, and the test suite keeps that parity (see engineering the pipeline). Improving on these constants is a real prospect — but it belongs in a new product with its own name, not in silent drift of this one. See where this goes next.