Skip to content

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.

w* m/s 3 0 900m 2953ft 1668m 5472ft 2436m 7991ft 3203m 10510ft 3971m 13029ft 4739m 15548ft 10 11 12 13 14 15 16 17 18 19 launch 1050 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

The dashed series is computed at build time from the document's own published inputs (model elevation, boundary-layer top, W*, cloud base, level heights). Recomputed at the default 1.0 m/s, the same function reproduces the stored series within 0.4 m of the contract-rounded published values.Units heights m MSL · sink rate m/s · time UTC

Derivation functions take numbers. Select a percentile before passing an ensemble scalar:

median-lapse.ts
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.

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.

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.

local-days.ts
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.

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.

project-profile.ts
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.

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):

smoke-adjusted-w.ts
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.

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.

measured-transmittance.ts
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.

alignByValidAt returns only the UTC validAt instants shared by every input profile. Each row keeps each model’s original hour, keyed by its slug.

align-hours.ts
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.