Adds up the flow of each irrigation zone, works out the pressure it needs at the valve and at the supply, the precipitation rate and run time, and checks head coverage.
How the precision is kept
The engine works in SI with 64-bit numbers. Friction loss is the empirical Hazen-Williams formula and several conversion constants are written to a few digits. Coverage is counted on a grid. The file header calls it a calculation aid that a designer checks. The interface file converts feet with 0.3048 while the engine's project foot is 1200 ÷ 3937 m.
Open item. Convert feet in irr-ui.js with the same factor as the engine (1200 ÷ 3937, not 0.3048) and write the psi, gallon and triangle factors at full precision.
Calculations under this policy
20 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 factors between kPa and meters of water, kPa and psi, and L/min and US gallons per minute.
kPa per m = 9.80665; kPa per psi = 6.894757; L/min per gpm = 3.785412
9.80665 is the exact standard gravity and assumes water at 1000 kg/m³. 6.894757 is the psi rounded to 7 digits. 3.785412 is the US gallon rounded to 7 digits; its exact value is 3.785411784 L.
landscape/irrigation.js
lines 31 to 33
CivilCraft 0.8.1
export const G = 9.80665; // kPa per metre of water (ρ g / 1000)
export const KPA_PER_PSI = 6.894757;
export const LMIN_PER_GPM = 3.785412;
Pipe inside diameters
64-bit
The inside diameter of each pipe size, from its outside diameter and dimension ratio.
ID = (OD − 2 × OD ÷ SDR) × 0.0254 m (inch sizes); ID = (OD − 2 × OD ÷ 11) ÷ 1000 m (metric)
Computed once from the listed outside diameters; 0.0254 is the exact inch. The result is a nominal diameter from the dimension ratio, not a measured one.
landscape/irrigation.js
lines 55 to 58
CivilCraft 0.8.1
export const PIPE_SIZES = [
...IPS_OD.map(([n, od]) => { const sdr = n === '1/2' ? 13.5 : 21; return { key: 'ips-' + n, label: n + '" PVC SDR ' + sdr, id: (od - (2 * od) / sdr) * 0.0254, system: 'ft' }; }),
...METRIC_OD.map((od) => ({ key: 'pe-' + od, label: od + ' mm PE SDR 11', id: (od - (2 * od) / 11) / 1000, system: 'm' })),
];
Hazen-Williams loss in one pipe
64-bit
The head loss, pressure loss and velocity for a flow in a pipe.
hf = 10.67 × L × q^1.852 ÷ (C^1.852 × d^4.8704), q in m³/s; kPa = hf × 9.80665; v = q ÷ (π d² ÷ 4)
The factor 60000 between L/min and m³/s is exact. The diameter exponent here is 4.8704, while the water mains solver in water.js uses 4.871 for the same formula. A zero or negative flow or diameter returns zeros.
landscape/irrigation.js
lines 68 to 74
CivilCraft 0.8.1
// ---- the hydraulics ----
export function hazenWilliams({ Q, d, L, C = 150 }) {
const q = Q / 60000; // m³/s
if (!(q > 0) || !(d > 0)) return { hf: 0, kPa: 0, v: 0 };
const hf = (10.67 * L * q ** 1.852) / (C ** 1.852 * d ** 4.8704);
return { hf, kPa: hf * G, v: q / ((Math.PI * d * d) / 4) };
}
Loss in psi per 100 ft
64-bit
The US form of the Hazen-Williams formula.
psi per 100 ft = 4.52 × Q^1.852 ÷ (C^1.852 × d^4.87) × 100
The coefficient 4.52 and the exponent 4.87 are the rounded constants of the US form. No other file in the released CivilCraft build calls this function.
The reduction in friction loss for a pipe that discharges through N evenly spaced outlets.
F = 1 ÷ (m + 1) + 1 ÷ (2N) + √(m − 1) ÷ (6N²)
Closed form with one square root; m defaults to 1.852. The half-spacing form multiplies by 2N ÷ (2N − 1). No other file in the released CivilCraft build calls this function.
landscape/irrigation.js
lines 76 to 83
CivilCraft 0.8.1
/** Christiansen (1942): F = 1/(m+1) + 1/(2N) + √(m−1)/(6N²) with the first outlet a full spacing from the inlet; else the
* half-spacing form F = 2N/(2N−1) · (1/(m+1) + √(m−1)/(6N²)). */
export function outletFactor(N, { m = 1.852, firstFull = true } = {}) {
if (!(N >= 1)) return 1;
if (N === 1) return 1;
const a = 1 / (m + 1), c = Math.sqrt(m - 1) / (6 * N * N);
return firstFull ? a + 1 / (2 * N) + c : ((2 * N) / (2 * N - 1)) * (a + c);
}
Precipitation rate
64-bit
Millimeters of water per hour from a flow spread over an area.
mm/h = 60 × L/min ÷ m²
The factor 60 is exact, since one liter over one square meter is one millimeter. A zero area returns 0.
Inches of water per hour from gallons per minute over square feet.
in/h = 96.3 × gpm ÷ ft²
96.3 is the trade's rounded constant; the exact factor is 231 × 60 ÷ 144 = 96.25. No other file in the released CivilCraft build calls this function.
landscape/irrigation.js
line 85
CivilCraft 0.8.1
export const prInches = (gpm, areaFt2) => (areaFt2 > 0 ? (96.3 * gpm) / areaFt2 : 0); // in/h, the US form
Flow of one head
64-bit
The flow of a sprinkler head, from its own listed flow or from its design precipitation rate.
L/min = pr × r² × (arc ÷ 360) ÷ 60
One 64-bit expression. The rate is taken over a square of side r, the head-to-head spacing the comment describes, and scaled by the arc. A head type with a listed flow uses it as given.
landscape/irrigation.js
lines 135 to 142
CivilCraft 0.8.1
/** A head's flow (L/min): the type's own, else from its design precipitation rate — quoted, as catalogs do, at head-to-head
* square spacing (spacing = radius): flow = pr × r² × arc/360 (matched precipitation: a half circle gives half the flow). */
export function headFlow(h, type) {
if (!type) return 0;
if (type.flow && !type.pr) return type.flow * (type.kind === 'microspray' ? h.arc / 360 : 1);
const r = headRadius(h, type);
return (type.pr * r * r * (h.arc || 360)) / 360 / 60;
}
Precipitation rate at a head spacing
64-bit
The precipitation rate of heads of one type set out on a square or triangular grid.
area = s² (square) or 0.866 × s² (triangular)
The triangular factor is written as 0.866, three digits of √3 ÷ 2. No other file in the released CivilCraft build calls this function.
landscape/irrigation.js
lines 145 to 150
CivilCraft 0.8.1
/** The precipitation rate of heads of one type set out at `spacing` (m): square or triangular. */
export function spacingPR(type, spacing, pattern = 'square') {
const full = headFlow({ arc: 360, radius: null }, type);
const area = pattern === 'triangle' ? spacing * spacing * 0.866 : spacing * spacing;
return precipRate(full, area);
}
Joining pipe ends into nodes
64-bit
Decides which pipe ends, heads and drip lines meet at the same point.
same node when distance ≤ join tolerance ÷ meters per project unit
The tolerance is the joinTol setting, 0.05 m by default, an absolute distance. A point joins the first node found within it, so the result can depend on drawing order when points are closer than the tolerance to two nodes.
landscape/irrigation.js
lines 181 to 194
CivilCraft 0.8.1
// ---- the network: nodes where pipe ends meet, trees from each valve (laterals) and from the supply (main) ----
function network(irr, units) {
const k = unitM(units), tol = irr.settings.joinTol / k, nodes = [], at = (p) => {
for (const n of nodes) if (Math.hypot(n.x - p.x, n.y - p.y) <= tol) return n;
const n = { i: nodes.length, x: p.x, y: p.y, edges: [], heads: [], drips: [] };
nodes.push(n);
return n;
};
for (const p of irr.pipes) { const a = at(p.a), b = at(p.b); a.edges.push({ p, to: b }); b.edges.push({ p, to: a }); }
for (const h of irr.heads) { const n = nodes.find((m) => Math.hypot(m.x - h.x, m.y - h.y) <= tol); if (n) n.heads.push(h); }
for (const d of irr.drips) { const n = nodes.find((m) => Math.hypot(m.x - d.pts[0].x, m.y - d.pts[0].y) <= tol); if (n) n.drips.push(d); }
const nodeAt = (p) => nodes.find((m) => Math.hypot(m.x - p.x, m.y - p.y) <= tol) || null;
return { nodes, nodeAt, k };
}
Flow of a drip line
64-bit
The flow of a drip line from its length, emitter spacing and emitter rate.
The emitter count comes from Math.floor with no tolerance, so a length that is a whole number of spacings can lose one emitter to rounding in the division. The length is the plan length in meters.
The loss from the valve to every head and the pressure the worst head needs.
loss to node = loss to parent + friction kPa + (z node − z parent) × 9.80665; need = max(head nominal pressure + loss) + valve loss
Losses are accumulated down the tree in order. Each pipe's length is the plan distance between its nodes, so a sloping pipe is slightly longer than the length used. Drip lines are given a fixed need of 140 kPa. The velocity check allows 1e-9 of slack.
landscape/irrigation.js
lines 228 to 242
CivilCraft 0.8.1
const lossTo = new Map([[root.i, 0]]);
for (const n of order.slice(1)) {
const par = parent.get(n.i), L = Math.hypot(n.x - par.from.x, n.y - par.from.y) * k, sz = sizeOf(par.p.size);
const hw = hazenWilliams({ Q: down.get(n.i), d: sz.id, L, C: S.C });
lossTo.set(n.i, lossTo.get(par.from.i) + hw.kPa + (elev(n) - elev(par.from)) * G);
zone.pipes.push({ id: par.p.id, size: par.p.size, L, Q: down.get(n.i), v: hw.v, kPa: hw.kPa });
zone.length += L;
if (hw.v > S.vMax + 1e-9) zone.problems.push({ key: 'velocity', id: par.p.id, v: hw.v, max: S.vMax });
}
for (const n of order) for (const h of n.heads) {
const ty = cat.get(h.type), need = (ty?.pressure.nominal ?? 200) + lossTo.get(n.i);
if (!zone.worst || need > zone.worst.need) zone.worst = { head: h.id, need, loss: lossTo.get(n.i), x: h.x, y: h.y };
}
for (const n of order) for (const d of n.drips) { const need = 140 + lossTo.get(n.i); if (!zone.worst || need > zone.worst.need) zone.worst = { drip: d.id, need, loss: lossTo.get(n.i), x: d.pts[0].x, y: d.pts[0].y }; }
zone.pressureNeed = zone.worst ? zone.worst.need + S.valveLoss : 0;
Pressure needed at the supply
64-bit
The loss along the mainline from the supply to a zone's valve and the pressure that zone needs at the supply.
at supply = zone need + Σ (friction + elevation) along the main + supply loss
The main is taken to carry one zone's flow at a time, as the comment says. Terms are added from the valve back to the supply. The comparison with the available pressure and flow allows 1e-9 of slack.
landscape/irrigation.js
lines 267 to 274
CivilCraft 0.8.1
let loss = 0;
for (let n = vn; parent.get(n.i); n = parent.get(n.i).from) {
const par = parent.get(n.i), L = Math.hypot(n.x - par.from.x, n.y - par.from.y) * k, sz = sizeOf(par.p.size);
const hw = hazenWilliams({ Q: z.flow, d: sz.id, L, C: S.C }); // one zone runs at a time: the main carries that zone's flow
loss += hw.kPa + (elev(n) - elev(par.from)) * G;
if (hw.v > S.vMax + 1e-9 && !z.problems.some((x) => x.key === 'mainvel')) z.problems.push({ key: 'mainvel', id: par.p.id, v: hw.v, max: S.vMax });
}
z.atSupply = z.pressureNeed + loss + (irr.supply.loss || 0);
Zone precipitation rate and weekly run time
64-bit
The precipitation rate of a zone and the minutes a week it must run for the weekly water depth.
The area is the sum of each head's own square cell, not the area actually wetted on the plan, so overlap and gaps between heads do not enter. A zone with no cell area gets a rate of 0 and 0 minutes.
Counts of heads, total pipe length by size, drip length and emitter count.
pipe length = Σ √(Δx² + Δy²) × meters per project unit
Lengths are plan lengths in meters, summed per size in drawing order. No allowance for slope, fittings or waste is added.
landscape/irrigation.js
lines 283 to 290
CivilCraft 0.8.1
export function billOfMaterials(irr, units) {
const k = unitM(units), cat = catalogOf(irr), heads = new Map(), pipes = new Map();
for (const h of irr.heads) { const key = h.type + '|' + h.arc; const r = heads.get(key) || { type: h.type, name: cat.get(h.type)?.name || h.type, arc: h.arc, n: 0 }; r.n++; heads.set(key, r); }
for (const p of irr.pipes) { const L = Math.hypot(p.b.x - p.a.x, p.b.y - p.a.y) * k, r = pipes.get(p.size) || { size: p.size, label: sizeOf(p.size)?.label || p.size, m: 0 }; r.m += L; pipes.set(p.size, r); }
let drip = 0, emitters = 0;
for (const d of irr.drips) { const L = dripLen(d, k); drip += L; emitters += Math.floor(L / d.spacing) + 1; }
return { heads: [...heads.values()].sort((a, b) => a.name.localeCompare(b.name) || a.arc - b.arc), pipes: [...pipes.values()], drip, emitters, valves: irr.valves.length, stations: irr.valves.length };
}
Coverage grid and head-to-head check
64-bit
The share of the area reached by no head, one head or two and more, and the heads whose nearest neighbor is beyond their throw.
cell = larger extent ÷ 200, grown by 1.5 until at most 200000 cells; far when nearest head > 1.0001 × throw
Counts are taken at cell centers stepped by repeated addition, so the percentages are a sampling of the area. The arc test compares an angle from Math.atan2 in degrees with the arc plus 1e-9.
landscape/irrigation.js
lines 292 to 325
CivilCraft 0.8.1
/** Coverage on a grid inside `area` (or the heads' bounds): cells wet by none (dry), one, two or more heads; and the
* head-to-head check (every head reaches its nearest neighbour). */
export function coverage(irr, units, area = null, { cell = null, max = 200000 } = {}) {
const k = unitM(units), cat = catalogOf(irr), heads = irr.heads.map((h) => ({ ...h, r: headRadius(h, cat.get(h.type)) / k }));
if (!heads.length) return { cells: 0, dry: 0, single: 0, double: 0, far: [] };
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
const pts = area || heads.flatMap((h) => [{ x: h.x - h.r, y: h.y - h.r }, { x: h.x + h.r, y: h.y + h.r }]);
for (const p of pts) { x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y); x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y); }
let c = cell || Math.max((x1 - x0) / 200, (y1 - y0) / 200);
while (((x1 - x0) / c) * ((y1 - y0) / c) > max) c *= 1.5;
const inArc = (h, x, y) => {
const dx = x - h.x, dy = y - h.y, d = Math.hypot(dx, dy);
if (d > h.r) return false;
if (h.arc >= 360 || d < 1e-9) return true;
const a = (((Math.atan2(dy, dx) * 180) / Math.PI - h.rot) % 360 + 360) % 360;
return a <= h.arc + 1e-9;
};
const inside = (x, y) => { if (!area) return true; let w = false; for (let i = 0, j = area.length - 1; i < area.length; j = i++) { const a = area[i], b = area[j]; if ((a.y > y) !== (b.y > y) && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) w = !w; } return w; };
let cells = 0, dry = 0, single = 0, dbl = 0;
for (let x = x0 + c / 2; x < x1; x += c) for (let y = y0 + c / 2; y < y1; y += c) {
if (!inside(x, y)) continue;
cells++;
let n = 0;
for (const h of heads) if (inArc(h, x, y) && ++n >= 2) break;
if (n === 0) dry++; else if (n === 1) single++; else dbl++;
}
const far = [];
for (const h of heads) {
let nd = Infinity;
for (const o of heads) if (o !== h) nd = Math.min(nd, Math.hypot(o.x - h.x, o.y - h.y));
if (heads.length > 1 && nd > h.r * 1.0001) far.push({ id: h.id, gap: nd * k, r: h.r * k }); // head-to-head: the nearest head is within this head's throw
}
return { cells, dry, single, double: dbl, cell: c, far };
}
Turns kPa, L/min, meters and mm/h into the text shown, in US units for a feet project.
psi = kPa ÷ 6.894757; gpm = L/min ÷ 3.785412; ft = m ÷ 0.3048; in/h = mm/h ÷ 25.4
Values are formatted to 0, 1 or 2 decimals. The CSV export of the check table writes this same rounded text with its unit, not the full values. Feet are formed with 0.3048 although the engine's lengths in meters were made from project feet of 1200 ÷ 3937 m.
Stores a head's throw radius, typed in project units, as meters.
radius (m) = typed feet × 0.3048
The typed value is converted with the international foot, 0.3048, and the field shows the stored value to 2 decimals. The plan then draws the radius by dividing by 1200 ÷ 3937, so a typed radius is drawn about 2 parts in a million smaller in project feet.
Stores the supply's pressure, available flow and loss in kPa and L/min from values typed in psi and gpm.
kPa = psi × 6.894757; L/min = gpm × 3.785412
The stored value is the typed number times a 7-digit constant. The field is refilled from the stored value formatted to 1 decimal, which is display only; the stored number is not rounded.
Head counts and pipe and drip lengths passed to TakeoffCraft.
LF = round(m ÷ 0.3048 × 100) ÷ 100
Lengths are rounded to 2 decimals before they are handed over. In a feet project the meters, which were made with 1200 ÷ 3937 m per foot, are divided by 0.3048, so the length in feet is about 2 parts in a million larger than the drawn length.
landscape/irr-ui.js
lines 319 to 326
CivilCraft 0.8.1
quantities() {
const r = result().bom, m = !ft(), out = [];
for (const h of r.heads) out.push({ key: 'irr:' + h.type + ':' + h.arc, name: typeName(h.type) + ' ' + h.arc + '°', unit: 'EA', qty: h.n }); // i18n-ignore: a unit symbol
for (const p of r.pipes) out.push({ key: 'irr:' + p.size, name: p.label, unit: m ? 'm' : 'LF', qty: Math.round((m ? p.m : p.m / 0.3048) * 100) / 100 }); // i18n-ignore: unit symbols
if (r.valves) out.push({ key: 'irr:valves', name: t('ls.i.c.valves'), unit: 'EA', qty: r.valves }); // i18n-ignore: a unit symbol
if (r.drip) out.push({ key: 'irr:drip', name: t('ls.i.c.drip'), unit: m ? 'm' : 'LF', qty: Math.round((m ? r.drip : r.drip / 0.3048) * 100) / 100 }); // i18n-ignore: unit symbols
return out;
},
In detail
Method
Zones are trees of laterals from each valve; flows are summed upstream and losses accumulated downstream with Hazen-Williams, hf = 10.67 L Q^1.852 ÷ (C^1.852 d^4.8704), plus elevation. The worst head sets the pressure needed. Christiansen's multiple-outlet factor is cited to 1942 in the code. Coverage is a count of grid cells reached by no head, one head or more.
Number types
64-bit numbers.
Tolerances
Pipe ends, heads and valves join within the joinTol setting, 0.05 m by default. Velocity, pressure and flow checks use an absolute slack of 1e-9. The head-to-head check allows a factor of 1.0001 on the throw. The coverage grid has about 200 cells across the larger extent and at most 200000 cells.
Limits
Hazen-Williams is empirical. KPA_PER_PSI = 6.894757 and LMIN_PER_GPM = 3.785412 are rounded; the triangular spacing factor is 0.866 and the US precipitation constant is 96.3. Pipe lengths are plan lengths. Drip lines are given a fixed need of 140 kPa.
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.