Works out the effective height of a stack plume, the worst-case and hour-by-hour ground concentrations at receptors, and the contours drawn on a plan.
How the precision is kept
64-bit arithmetic on top of the shared plume equation. The reported maximum is the largest value on a fixed grid of stability classes, wind speeds and distances, and longer averaging times are fixed multipliers of the 1-hour value. The plume-rise and wind-profile constants are empirical.
Open item. Refine the screening maximum between grid distances (for example by a golden-section step around the best grid point) so the reported maximum does not depend on the grid.
Calculations under this policy
10 calculations in 2 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
The wind speed at a height from the speed measured at 10 m.
u = u₁₀ × (max(z, 1) ÷ 10)^p
One Math.pow. Heights below 1 m are treated as 1 m. The exponent p is a two-digit table value per stability class and terrain.
process/env/dispersion.js
lines 52 to 53
CadCraft 0.8.2
/** Wind speed (m/s) at height z from the 10 m value (power law). */
export function windAt(u10, z, stab, terrain = 'rural') { return u10 * Math.pow(Math.max(z, 1) / 10, (P_EXP[terrain] || P_EXP.rural)[STABILITY.indexOf(stab)]); }
Plume rise and effective height
64-bit
How far the plume rises above the stack and the effective release height used by the plume equation.
64-bit closed forms with g = 9.80665. The coefficients 21.425, 38.71, 2.6, 1.6 and the switch at Fb = 55 are empirical; at Fb = 55 the two fits give 432.7 ÷ u and 428.6 ÷ u, a step of about 1 %. The downwash-corrected stack height is floored at 0 before the rise is added.
process/env/dispersion.js
lines 55 to 70
CadCraft 0.8.2
/** Effective release height. stack { hs, d, vs, Ts }; opts { u (at stack top), stab, Ta, x (m, for the transitional rise; omit for the final rise), dthetadz }.
* → { H, dh, hs, Fb, downwash, kind } */
export function plumeRise(stack, { u, stab = 'D', Ta = 293.15, x = null, dthetadz = null } = {}) {
const { hs, d, vs, Ts } = stack;
if (!(hs >= 0 && d > 0 && vs >= 0 && Ts > 0 && u > 0)) throw new Error('A stack needs height, diameter, exit velocity and temperature, and the wind must be above zero.');
let h = hs, downwash = 0;
if (vs < 1.5 * u) { downwash = 2 * d * (1.5 - vs / u); h = hs - downwash; } // stack-tip downwash: h' = hs + 2d(vs/u - 1.5)
const Fb = Ts > Ta ? G * vs * d * d * (Ts - Ta) / (4 * Ts) : 0;
let dh, kind;
if (Fb > 0) {
if (stab === 'E' || stab === 'F') { const s = G / Ta * (dthetadz ?? (stab === 'E' ? 0.02 : 0.035)); dh = 2.6 * Math.cbrt(Fb / (u * s)); kind = 'buoyant stable'; }
else { dh = Fb < 55 ? 21.425 * Math.pow(Fb, 0.75) / u : 38.71 * Math.pow(Fb, 0.6) / u; kind = 'buoyant'; }
if (x != null && x > 0) dh = Math.min(dh, 1.6 * Math.cbrt(Fb) * Math.pow(x, 2 / 3) / u);
} else { dh = 3 * d * vs / u; kind = 'momentum'; }
return { H: Math.max(h, 0) + dh, dh, hs, Fb, downwash, kind };
}
Stack from a flowsheet stream
64-bit
Exit velocity, volume flow and the emission rate of each species from a process stream.
Ideal-gas volume flow with the gas constant imported from process/data.js; each species rate is one product of three 64-bit numbers. Nothing is rounded.
process/env/dispersion.js
lines 72 to 79
CadCraft 0.8.2
/** A stack from a flowsheet stream (CH.2): `th` the Thermo of the flowsheet, `stream` {T K, P Pa, F mol/s, z[]}; species = ids to report (g/s each).
* Gas volume flow by the ideal gas law (the stack is near atmospheric pressure); exit velocity from the diameter. */
export function stackFromStream(th, stream, { hs, d, species = [], id = 'stack', x = 0, y = 0 } = {}) {
if (!(hs >= 0 && d > 0)) throw new Error('Give the stack height and inner diameter.');
const vol = stream.F * R * stream.T / stream.P, vs = vol / (Math.PI * d * d / 4), Q = {};
for (const sp of species) { const i = th.ids.indexOf(sp); if (i < 0) throw new Error('Component ' + sp + ' is not in the flowsheet.'); Q[sp] = stream.F * stream.z[i] * th.c[i].MW; } // MW g/mol -> g/s
return { id, x, y, hs, d, vs, Ts: stream.T, vol, Q, from: 'stream' };
}
Concentration from one stack at one point
64-bit
The ground concentration in µg/m³ from one source for one wind speed and stability class.
Wind at the stack top, plume rise for this distance, wind again at the effective height, then the plume equation. The result is multiplied by 1e6 to convert g/m³ to µg/m³.
process/env/dispersion.js
lines 81 to 86
CadCraft 0.8.2
function atDistance(source, species, ctx, x, y = 0, z = 0) {
const { u10, stab, terrain, Ta, zi } = ctx;
const u0 = windAt(u10, source.hs, stab, terrain), r = plumeRise(source, { u: u0, stab, Ta, x });
const u = windAt(u10, Math.max(r.H, 1), stab, terrain);
return { c: plumeConc({ Q: (source.Q[species] ?? 0), u, H: r.H, stab, terrain, zi }, x, y, z) * 1e6, H: r.H, u }; // g/m3 -> ug/m3
}
Worst-case 1-hour concentration
64-bit
The highest ground-level concentration over all stability classes, wind speeds and distances, and its longer-period estimates.
The maximum is the largest of the grid values (161 distances by default, about 4.7 % apart, for each class and wind speed); it is not refined between grid distances. The 3 h, 8 h, 24 h and annual values are the 1-hour maximum times 0.9, 0.7, 0.4 and 0.08.
process/env/dispersion.js
lines 87 to 103
CadCraft 0.8.2
/** Screening: worst 1-hour ground-level concentration (ug/m3) over the class x wind grid and distances. opts { species, terrain, Ta, zi, xMin, xMax, averaging: [3, 8, 24, 'annual'] }.
* → { max: { c, x, stab, u10, H }, byClass: { A: { c, x, u10 } … }, averaged: { 3: c … }, notes } */
export function screen(source, { species, terrain = 'rural', Ta = 293.15, zi = 0, xMin = 20, xMax = 30000, steps = 160, averaging = [3, 8, 24, 'annual'], wind = GRID } = {}) {
if (!species || source.Q?.[species] == null) throw new Error('Name the species and give its rate (g/s) on the source.');
const best = { c: 0 }, byClass = {};
for (const stab of STABILITY) for (const u10 of wind[stab] || []) {
for (let i = 0; i <= steps; i++) {
const x = xMin * Math.pow(xMax / xMin, i / steps), r = atDistance(source, species, { u10, stab, terrain, Ta, zi }, x);
if (!byClass[stab] || r.c > byClass[stab].c) byClass[stab] = { c: r.c, x, u10, H: r.H };
if (r.c > best.c) Object.assign(best, { c: r.c, x, stab, u10, H: r.H });
}
}
const averaged = {}; for (const a of averaging) averaged[a] = best.c * (AVERAGING[a] ?? NaN);
const notes = ['Stability-class screening on flat ground, one source, no buildings. Ta ' + Ta + ' K' + (Ta === 293.15 ? ' (assumed: give the real ambient temperature)' : '') + '.',
'Averaging multipliers are screening guidance values typed without the document open; the 1-hour maximum is the model\'s number.'];
return { max: best, byClass, averaged, notes, species };
}
Receptor position in the wind frame
64-bit
The downwind and crosswind distance of a receptor from a source for one hour's wind direction, and the concentration there.
The offset is rotated with Math.sin and Math.cos of the bearing converted to radians. A receptor 1 m or less downwind returns 0.
process/env/dispersion.js
lines 106 to 112
CadCraft 0.8.2
/** One source's contribution (ug/m3) at a receptor for one met hour. met { u10, dirFrom (deg clockwise from north, where the wind blows FROM), stab, zi?, Ta? } */
export function receptorHour(source, species, rec, met, terrain = 'rural') {
const th = toRad((met.dirFrom + 180) % 360), dx = rec.x - source.x, dy = rec.y - source.y;
const xd = dx * Math.sin(th) + dy * Math.cos(th), yc = dx * Math.cos(th) - dy * Math.sin(th);
if (xd <= 1) return 0;
return atDistance(source, species, { u10: met.u10, stab: met.stab, terrain, Ta: met.Ta ?? 293.15, zi: met.zi ?? 0 }, xd, yc, rec.z ?? 0).c;
}
Hour-by-hour statistics at receptors
64-bit
The highest and second-highest hourly value, the period mean and the highest 24-hour mean at each receptor.
mean = Σ cₕ ÷ n; rolling₂₄ = max over windows of (Σ of 24 hours) ÷ 24
Source contributions are added in order for each hour. The 24-hour window keeps a running sum, adding the new hour and subtracting the one that left, so rounding can build up along a long series. Calm hours are skipped and count as 0 in both means.
process/env/dispersion.js
lines 113 to 129
CadCraft 0.8.2
/** Hour-by-hour run. sources [{ id, x, y, hs, d, vs, Ts, Q }], receptors [{ id, x, y, z? }], hours [met…]. opts { species, terrain, calm: 1 (m/s at 10 m, hours below it are skipped) }.
* → { receptors: { id: { max1h, second1h (high second high), mean, hourOfMax, rolling24 (max 24-h mean) } }, hours, calms, notes } */
export function hourly(sources, receptors, hours, { species, terrain = 'rural', calm = 1 } = {}) {
if (!species) throw new Error('Name the species.');
const out = {}; let calms = 0;
const series = Object.fromEntries(receptors.map((r) => [r.id, new Array(hours.length).fill(0)]));
hours.forEach((m, h) => {
if (!(m.u10 >= calm)) { calms++; return; }
for (const r of receptors) { let c = 0; for (const s of sources) c += receptorHour(s, species, r, m, terrain); series[r.id][h] = c; }
});
for (const r of receptors) {
const s = series[r.id], sorted = s.map((c, i) => [c, i]).sort((a, b) => b[0] - a[0]);
let roll = 0; if (s.length >= 24) { let acc = s.slice(0, 24).reduce((a, b) => a + b, 0); roll = acc; for (let i = 24; i < s.length; i++) { acc += s[i] - s[i - 24]; if (acc > roll) roll = acc; } roll /= 24; }
out[r.id] = { max1h: sorted[0]?.[0] ?? 0, second1h: sorted[1]?.[0] ?? 0, hourOfMax: sorted[0]?.[1] ?? -1, mean: s.reduce((a, b) => a + b, 0) / Math.max(1, s.length), rolling24: roll };
}
return { receptors: out, hours: hours.length, calms, series, notes: ['Hour-by-hour stability-class Gaussian model; ' + calms + ' calm hour(s) (10 m wind below ' + calm + ' m/s) are skipped and count as zero in the means.', 'Not AERMOD: no boundary-layer scaling, no terrain, no building downwash.'] };
}
Contours for a site plan
64-bit
The contour polygon of each concentration level for one wind condition, in site coordinates.
Each level is converted from µg/m³ by multiplying by 1e-6 and handed to the plume footprint. The polygon points are stored unrounded.
process/env/dispersion.js
lines 131 to 140
CadCraft 0.8.2
/** The Cend contour(s) of one source as plan shapes in site coordinates, for the wind blowing FROM dirFrom (deg clockwise from north). levels in ug/m3. */
export function toPlan(source, { species, levels, u10, stab, dirFrom, terrain = 'rural', Ta = 293.15, zi = 0 }) {
const u0 = windAt(u10, source.hs, stab, terrain), r = plumeRise(source, { u: u0, stab, Ta }), u = windAt(u10, Math.max(r.H, 1), stab, terrain);
const toAz = (dirFrom + 180) % 360, toMath = 90 - toAz, shapes = [];
for (const lv of levels) {
const f = plumeFootprint({ Q: source.Q[species], u, H: r.H, stab, terrain, zi }, lv * 1e-6, { cx: source.x, cy: source.y, toDeg: toMath });
if (f.reach) shapes.push({ id: source.id + ':' + species + ':' + lv, type: 'plume', level: lv, unit: 'ug/m3', label: lv + ' ug/m3', polygon: f.polygon, reach: f.reach, H: r.H });
}
return { shapes, H: r.H, notes: ['One met condition, ground-level contour, flat ground.'] };
}
Comparison with a limits pack
64-bit
Whether a result is above, or within 80 % of, the limit a person typed for that pollutant and averaging time.
fraction = c ÷ limit
The comparison uses the unrounded concentration and the stored found value is unrounded. toPrecision(3) and toFixed(0) appear only in the message text.
process/env/dispersion.js
lines 153 to 164
CadCraft 0.8.2
/** Compare results { pollutant, avg, c (ug/m3), where? }[] with a pack. Problems carry the pack and clause; an unconfirmed pack says so (rule 29). */
export function checkLimits(results, pack) {
const pk = cleanPack(pack), ps = [];
for (const r of results) {
const lim = pk.limits.find((l) => l.pollutant === r.pollutant && l.avg === r.avg);
if (!lim) { ps.push(problem('nolimit', 'note', `No limit for ${r.pollutant} (${r.avg}) in pack "${pk.name}": nothing was compared.`, [{ kind: 'pollutant', id: r.pollutant }])); continue; }
const frac = r.c / lim.value, tag = pk.review.state === 'confirmed' ? '' : ' The pack is NOT confirmed by a person.';
if (frac > 1) ps.push(problem('exceeds', 'error', `${r.pollutant} ${r.avg}: ${r.c.toPrecision(3)} ug/m3 is above ${lim.value} (${pk.name}, ${lim.clause || 'no clause'}).${tag}`, [{ kind: 'pollutant', id: r.pollutant }], { req: { op: '<=', value: lim.value, unit: 'ug/m3' }, found: r.c, pack: pk.name, clause: lim.clause }));
else if (frac > 0.8) ps.push(problem('near', 'warning', `${r.pollutant} ${r.avg}: ${(frac * 100).toFixed(0)} % of the limit (${pk.name}).${tag}`, [{ kind: 'pollutant', id: r.pollutant }], { found: r.c, pack: pk.name, clause: lim.clause }));
}
return ps;
}
The contour polylines, stack marker, wind arrow and labels placed in a drawing.
drawing point = site point × scale
Each coordinate is the site coordinate times scale, one multiplication, with no rounding. The reach and the wind bearing in the label text are rounded to whole numbers with toFixed(0), and the arrow head uses 0.87 and 0.5 for the cosine and sine of 30°.
process/env/plumedraw.js
lines 2 to 27
CadCraft 0.8.2
/**
* plumedraw.js — the plume contours as drawing entities (0.8 Track CH, CH.19; DOM-free). A host (CadCraft's P&ID add-on, CivilCraft's site plan) draws them.
* plumeDrawing({ shapes (dispersion.toPlan(…).shapes), stack: { x, y, label }, windFrom (deg clockwise from north), receptors?, scale = 1, layer = 'AIR' })
* → { entities: [{ type: 'polyline' | 'line' | 'circle' | 'text', layer, … }], legend: [{ layer, label }], extent }
* Units are the site coordinates of the shapes times `scale` (use 1000 for a drawing in mm). It adds the stack marker, a wind arrow that points the way the wind
* blows, a label at the far end of each contour (level, reach) and a note that this is a stability-class screening, not AERMOD.
*/
export function plumeDrawing({ shapes, stack, windFrom, receptors = [], scale = 1, layer = 'AIR' }) {
const E = [], legend = [], toAz = ((windFrom + 180) % 360) * Math.PI / 180, dx = Math.sin(toAz), dy = Math.cos(toAz);
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; const ext = (x, y) => { minx = Math.min(minx, x); miny = Math.min(miny, y); maxx = Math.max(maxx, x); maxy = Math.max(maxy, y); };
for (const s of shapes) {
const L = `${layer}-PLUME-${String(s.level).replace(/[^0-9A-Za-z.]/g, '_')}`; legend.push({ layer: L, label: s.label });
E.push({ type: 'polyline', layer: L, closed: true, points: s.polygon.map((p) => [p[0] * scale, p[1] * scale]), id: s.id });
for (const p of s.polygon) ext(p[0] * scale, p[1] * scale);
const far = s.polygon.reduce((a, p) => ((p[0] - stack.x) * dx + (p[1] - stack.y) * dy > (a[0] - stack.x) * dx + (a[1] - stack.y) * dy ? p : a), s.polygon[0]);
E.push({ type: 'text', layer: layer + '-TEXT', x: far[0] * scale, y: far[1] * scale, text: `${s.label}, reach ${s.reach.toFixed(0)} m`, h: 3 * scale });
}
const sx = stack.x * scale, sy = stack.y * scale, len = 30 * scale; ext(sx, sy);
E.push({ type: 'circle', layer: layer + '-STACK', cx: sx, cy: sy, r: 1.5 * scale }, { type: 'text', layer: layer + '-TEXT', x: sx + 2 * scale, y: sy - 4 * scale, text: stack.label || 'Stack', h: 3 * scale });
const ax = sx + dx * len, ay = sy + dy * len, hx = 0.25 * len;
E.push({ type: 'line', layer: layer + '-WIND', x1: sx, y1: sy, x2: ax, y2: ay }, { type: 'line', layer: layer + '-WIND', x1: ax, y1: ay, x2: ax - hx * (dx * 0.87 - dy * 0.5), y2: ay - hx * (dy * 0.87 + dx * 0.5) }, { type: 'line', layer: layer + '-WIND', x1: ax, y1: ay, x2: ax - hx * (dx * 0.87 + dy * 0.5), y2: ay - hx * (dy * 0.87 - dx * 0.5) });
E.push({ type: 'text', layer: layer + '-TEXT', x: ax, y: ay, text: `Wind from ${windFrom.toFixed(0)} deg`, h: 3 * scale });
for (const r of receptors) { E.push({ type: 'circle', layer: layer + '-RECEPTOR', cx: r.x * scale, cy: r.y * scale, r: 1 * scale }, { type: 'text', layer: layer + '-TEXT', x: r.x * scale + 1.5 * scale, y: r.y * scale, text: `${r.id}${r.c != null ? ' ' + r.c.toPrecision(3) + ' ug/m3' : ''}`, h: 2.5 * scale }); ext(r.x * scale, r.y * scale); }
E.push({ type: 'text', layer: layer + '-TEXT', x: minx, y: miny - 6 * scale, text: 'Stability-class screening, not AERMOD. Not an engineering approval.', h: 2.5 * scale });
return { entities: E, legend, extent: { minx, miny, maxx, maxy } };
}
In detail
Method
Briggs plume rise (buoyant, stable and momentum forms, stack-tip downwash), power-law wind profile, and the Gaussian plume of process/env/plume.js, as listed in the file's SOURCES.
Number types
64-bit numbers; hourly series are plain arrays of 64-bit numbers.
Tolerances
Screening grid: 161 distances at a constant ratio (20 m to 30 km by default). Receptors 1 m or less downwind get 0. Limit check: error above 100 % of the limit, warning above 80 %.
Limits
The file's REVIEW constant states the tables are not confirmed against the source documents. Flat ground, no buildings, no calm-hour treatment beyond skipping.
Approach: 64-bit, stated limit
The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.