Pure derivations
windgram/derive owns calculations that are pure functions of published
documents: moisture conversions, vector wind, lapse and stability, thermal
index, shear, the B/S ratio, local-day grouping, projection, valid-time
alignment, units, smoothing, and parameterized usable lift. It does not
duplicate pipeline derivations that require raw model inputs; that split —
and its one exception — is defined in the
project overview.
That exception lives here: usableLiftTopM(inputs, sinkRateMs) projects the
published inputs for another sink rate without replacing the stored default,
and at 1.0 m/s it matches the pipeline fixture exactly. The scene does not
apply that p50 recomputation to ensembles because it would not equal a
per-member derivation aggregated to percentiles.
One document, a second sink rate — no republication
usableLiftTopM(inputs, sinkRateMs) projects the published inputs for another glider without replacing the stored default.
A teaching windgram whose solid usable-lift line is the pipeline's stored 1.0 m/s series, overlaid with a dashed line recomputed at 2 m/s sink by the package's usableLiftTopM. At the peak hour the projection reaches 3,661 m against the published 3,946 m.
published series (1.0 m/s, pipeline authority) · projected at 2 m/s sink — a heavier wing climbs to 3,661 m instead of 3,946 m at 15:00
Deterministic and ensemble inputs
Section titled “Deterministic and ensemble inputs”Derivation functions take numbers. Select a percentile before passing an ensemble scalar:
import type { WindgramProfile } from "windgram/contract";import { p50, surfaceLapseCPer1000Ft, stabilityClass } from "windgram/derive";
export function firstStability(profile: WindgramProfile): string | null { const hour = profile.hours[0]; const level = hour?.levels[0]; if (!hour || !level) return null;
const surfaceTemperatureC = p50(hour.surface.temperatureC); const heightM = p50(level.heightM); const temperatureC = p50(level.temperatureC); if (surfaceTemperatureC === null || heightM === null || temperatureC === null) return null;
const lapse = surfaceLapseCPer1000Ft( surfaceTemperatureC, profile.site.modelElevationM, { heightM, temperatureC }, ); return lapse === null ? null : stabilityClass(lapse);}p50 returns null for a full ensemble dropout, so numeric derivations guard
the selected percentile before use.
Choose shear for the terrain
Section titled “Choose shear for the terrain”surfaceToBoundaryLayerShearMs subtracts the surface and boundary-layer-top
wind vectors. That construction assumes both vectors sample one air mass. A
mountain valley can place thermally driven surface flow beneath separate flow
aloft, making the ratio low even on a deeply convective day.
buoyancyShearRatio returns Infinity when nonzero buoyancy faces zero shear
and null for 0/0. Use the height-resolved windShear field when terrain
separates the surface circulation from the winds aloft. The
valley B/S case study records the measured case.
Window in the site’s timezone
Section titled “Window in the site’s timezone”Profiles publish all forecast hours in UTC; the optional
site.timeZone echo
may be absent on an older document, so callers still need an explicit
fallback.
import type { WindgramProfile } from "windgram/contract";import { groupByLocalDay, windgramDisplayHours } from "windgram/derive";
export function displayDays(profile: WindgramProfile, olderProfileTimeZone?: string) { const timeZone = profile.site.timeZone ?? olderProfileTimeZone; if (!timeZone) throw new Error("older profile needs an explicit IANA timezone"); const display = windgramDisplayHours(profile.hours, { timeZone }); return groupByLocalDay(display, timeZone);}groupByLocalDay and windgramDisplayHours are tested across timezones,
custom bounds, short days, and empty input. Pass a returned day’s hours
directly to buildScene.
Subtract fields with projectProfile
Section titled “Subtract fields with projectProfile”projectProfile reduces a document to the hours and fields a reader needs.
It can select one local calendar day, replace every levels array with [],
and keep named field subsets. Every retained value is copied unchanged: the
function applies no threshold, aggregation, interpolation, or judgment.
import type { WindgramProfile } from "windgram/contract";import { projectProfile } from "windgram/derive";
export function compactTeachingInput(profile: WindgramProfile, day: string) { return projectProfile(profile, { day, dropLevels: true, fields: { surface: ["windSpeedMs", "windGustMs"], derived: ["thermalVelocityMs", "usableLiftTopM"], }, });}Day selection uses options.timeZone first and profile.site.timeZone
second. If neither exists, projectProfile throws instead of guessing which
UTC hours belong to the requested local day. Projection without a day needs
no timezone.
With no field selection, the result remains a full contract-shaped document;
dropLevels also remains valid because an empty levels array is allowed.
Selecting fields produces ProjectedWindgramProfile, whose hour blocks are
partial by design. Do not pass that partial projection back through the full
profile parser or into buildScene.
Derate thermals for smoke
Section titled “Derate thermals for smoke”windgram/derive carries the smoke-correction chain as small pure
functions over published values, with the physics constants exported as
named, cited claims (SMOKE_MASS_EXTINCTION_M2_PER_G — Reid et al.
2005; SMOKE_TRANSMITTANCE_K_MIDDAY / K_VERTICAL — Donaldson 2021,
Chubarova 2012, McKendry 2019):
import type { SmokeDocument, WindgramProfile } from "windgram/contract";import { cosSolarZenith, isSmokeAwareProfile, p50, smokeAdjustedThermalVelocityMs, smokeAotFromColumn, smokeHoursByValidAt, smokeTransmittance,} from "windgram/derive";
export function adjustedWStar( profile: WindgramProfile, smoke: SmokeDocument, hourIndex: number,): number | null { // Already smoke-aware (HRRR): the published w* includes the model's own // smoke attenuation, and derating it again would double-count. if (isSmokeAwareProfile(profile)) return null; const hour = profile.hours[hourIndex]; const joined = hour && smokeHoursByValidAt(smoke).get(hour.validAt); if (!hour || !joined) return null;
const columnMgm2 = p50(joined.smokePlumeColumnMgm2); const wStar = p50(hour.derived.thermalVelocityMs); if (columnMgm2 === null || wStar === null) return null;
const transmittance = smokeTransmittance( smokeAotFromColumn(columnMgm2), cosSolarZenith(hour.validAt, profile.site.latitude, profile.site.longitude), ); return smokeAdjustedThermalVelocityMs(wStar, transmittance);}The guard comes first for a reason: on models whose fluxes already feel
their own smoke (isSmokeAwareProfile — HRRR), the published w* is
already derated and applying the correction again double-counts. The
adjustment itself is one multiply — w* × ∛f — because Deardorff’s w*
is the cube root of the heat flux; no flux re-derivation is needed or
performed. Scope and derivation narrative:
Smoke and thermals.
Interpret measured irradiance
Section titled “Interpret measured irradiance”Observation documents carry measured W/m²; three functions make that
number mean something beside a forecast. clearSkyGhiWm2 is the cited
expectation — Haurwitz (1945), chosen per Reno, Hansen & Stein 2012
(SAND2012-2389) as the best clear-sky model needing only the sun’s
zenith — and observedTransmittance is measured over expected: 1 is a
textbook sky, ~0.85 a moderate smoke plume, well under 0.5 serious
cloud, null near the horizon where the ratio means nothing.
nearestObservation is the join: observations live at the product’s
native cadence (GOES scan starts), so an exact-key match against a
forecast validAt never hits.
import type { ObservationDocument, WindgramProfile } from "windgram/contract";import { cosSolarZenith, nearestObservation, observedTransmittance,} from "windgram/derive";
export function transmittanceAtHour( profile: WindgramProfile, observed: ObservationDocument, hourIndex: number,): number | null { const hour = profile.hours[hourIndex]; const nearest = hour && nearestObservation(observed, hour.validAt); // Entry shapes differ by product; transmittance wants the DSR kind. if (!hour || !nearest || !("downwardShortwaveWm2" in nearest.observation)) return null; return observedTransmittance( nearest.observation.downwardShortwaveWm2, cosSolarZenith(hour.validAt, profile.site.latitude, profile.site.longitude), );}Put that beside smokeTransmittance(aot) from the same site’s smoke
document and you are running the comparison the smoke correction’s
constants were fitted from — measurement against claim, per hour.
Intersect instants with alignByValidAt
Section titled “Intersect instants with alignByValidAt”alignByValidAt returns only the UTC validAt instants shared by every input
profile. Each row keeps each model’s original hour, keyed by its slug.
import type { WindgramProfile } from "windgram/contract";import { alignByValidAt, p50 } from "windgram/derive";
export function sharedSurfaceWind(profiles: readonly WindgramProfile[]) { return alignByValidAt(profiles).map((row) => ({ validAt: row.validAt, values: Object.entries(row.byModel).map(([model, hour]) => ({ model, windSpeedMs: p50(hour.surface.windSpeedMs), })), }));}The join performs string equality on published UTC instants and preserves each
model’s original values, elevation, semantics, and run identity. Empty input
returns no rows; duplicate model slugs throw. Use
compareProfiles for cross-model findings.