Turns a routed pipe run into a beam model, solves weight and thermal expansion, and checks sustained, expansion and occasional stresses against the allowables a person gives.
How the precision is kept
Section properties, loads and stress formulas are 64-bit closed forms, and the displacements and end forces come from the shared frame solver in Float64Array. Stations along the run are rounded to 0.001 mm when nodes are placed. Support lift-off and friction are found by a short iteration with a fixed pass limit.
Open item. Report when the lift-off and friction iteration ends at its 12-pass limit without settling.
Calculations under this policy
11 calculations in 1 source file. 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.
Metal area, second moment of area, torsion constant and section modulus of a pipe.
A = π(D² − d²) ÷ 4; I = π(D⁴ − d⁴) ÷ 64; J = 2I; Z = I ÷ (D ÷ 2)
Schedule sizes are inches × 25.4, which is exact. The area and second moment are differences of powers of the outside and inside diameters; for a thin wall those are close and some leading digits cancel.
process/equip/pipestress.js
lines 26 to 33
CadCraft 0.8.2
/** Pipe section properties from NPS text (schedule 40) or {D, t} in mm. */
export function pipeSection(size) {
let D, t;
if (size && typeof size === 'object') { D = size.D; t = size.t; }
else { const k = npsKey(size); if (!k) throw new Error('pipe size not understood: ' + size); D = NPS_SCH40[k][0] * 25.4; t = NPS_SCH40[k][1] * 25.4; }
const d = D - 2 * t, A = Math.PI / 4 * (D * D - d * d), I = Math.PI / 64 * (D ** 4 - d ** 4);
return { D, t, d, A, I, J: 2 * I, Z: I / (D / 2) };
}
Tee stress intensification
64-bit
The stress intensification factors of a welding, reinforced or unreinforced tee.
i_o = max(0.9 ÷ h^(2/3), 1); i_i = max(0.75·i_o + 0.25, 1); welding tee h = 4.4·T ÷ r
64-bit with Math.pow; both factors are floored at 1. The constants 4.4, 0.9 and 0.75 are the code's rounded values.
process/equip/pipestress.js
lines 42 to 49
CadCraft 0.8.2
/** Appendix D stress intensification of a tee at the run: { h, ii, io }. kind 'welding' (h = 4.4 T/r), 'unreinforced' (h = T/r), 'reinforced' (h = (T + Te/2)^2.5 / (T^1.5 r)). Typed from the code's table: check. */
export function teeSIF({ D, t, kind = 'welding', Te = 0 }) {
const r = (D - t) / 2, T = t;
const h = kind === 'welding' ? 4.4 * T / r : kind === 'unreinforced' ? T / r : kind === 'reinforced' ? Math.pow(T + Te / 2, 2.5) / (Math.pow(T, 1.5) * r) : null;
if (h == null) throw new Error('tee kind: welding | unreinforced | reinforced');
const io = Math.max(0.9 / Math.pow(h, 2 / 3), 1), ii = Math.max(0.75 * io + 0.25, 1);
return { h, io, ii };
}
Bend stress intensification and flexibility
64-bit
The flexibility characteristic, stress intensification factors and flexibility factor of a bend.
h = t·R₁ ÷ r²; i_i = 0.9 ÷ h^(2/3); i_o = 0.75 ÷ h^(2/3); k = 1.65 ÷ h
One 64-bit expression each, floored at 1. The mean radius is (D − t) ÷ 2.
process/equip/pipestress.js
lines 120 to 123
CadCraft 0.8.2
/** Appendix D stress intensification for a bend: { h, ii, io, k (flexibility, NOT applied) }. R1 bend radius (mm). */
export function bendSIF({ D, t, R1 }) {
const r = (D - t) / 2, h = t * R1 / (r * r); return { h, ii: Math.max(0.9 / Math.pow(h, 2 / 3), 1), io: Math.max(0.75 / Math.pow(h, 2 / 3), 1), k: Math.max(1.65 / h, 1) };
}
Bend tangent points
64-bit
Replaces each corner of the run by the two tangent points of a bend of the given radius.
The dot product is clamped to [−1, 1] before acos. Angles below 0.001 rad or within 0.001 rad of a full reversal are left as corners. A bend whose tangent length exceeds half of either neighbor segment is left as a stiff corner with a note.
process/equip/pipestress.js
lines 50 to 67
CadCraft 0.8.2
/** Replace each bend of the polyline by its two tangent points (radius R1): a chord between them is the bend. Returns the new points, the bend ranges and a map from the old distance to the new. */
function roundBends(pts, R1) {
const out = [pts[0]], ranges = [], knots = [[0, 0]], notes = []; let cumOld = 0, cumNew = 0;
const push = (p, oldS) => { cumNew += len(sub(p, out[out.length - 1])); out.push(p); knots.push([oldS, cumNew]); return cumNew; };
for (let i = 1; i < pts.length - 1; i++) {
const l1 = len(sub(pts[i], pts[i - 1])), l2 = len(sub(pts[i + 1], pts[i])); cumOld += l1;
const u1 = unit(sub(pts[i], pts[i - 1])), u2 = unit(sub(pts[i + 1], pts[i])), th = Math.acos(Math.max(-1, Math.min(1, dot(u1, u2))));
if (th < 1e-3 || th > Math.PI - 1e-3) { push(pts[i], cumOld); continue; }
const a = R1 * Math.tan(th / 2);
if (a > 0.5 * Math.min(l1, l2)) { notes.push('the bend at point ' + (i + 1) + ' is too tight for its neighbours at radius ' + R1.toFixed(0) + ' mm: left as a stiff corner'); push(pts[i], cumOld); continue; }
const P1 = pts[i].map((v, k) => v - u1[k] * a), P2 = pts[i].map((v, k) => v + u2[k] * a);
const s1 = push(P1, cumOld - a), s2 = push(P2, cumOld + a); ranges.push([s1, s2]);
}
cumOld += len(sub(pts[pts.length - 1], pts[pts.length - 2]));
// the last point: offset between old and new distances at the end
push(pts[pts.length - 1], cumOld);
return { pts: out, ranges, notes, mapS: (s) => { for (let k = 0; k + 1 < knots.length; k++) if (s <= knots[k + 1][0] + 1e-9) { const [a0, b0] = knots[k], [a1, b1] = knots[k + 1]; return a1 === a0 ? b0 : b0 + (b1 - b0) * (s - a0) / (a1 - a0); } return knots[knots.length - 1][1]; } };
}
Node stations along the run
64-bit
Places a node at every corner, support and tee by its distance along the run.
s = Σ segment lengths; key = s rounded to 0.001 mm; point = a + f × (b − a)
The distance along the run is rounded with toFixed(3) and that rounded value is the node's stored station; two stations closer than 0.001 mm become one node. The node coordinates are interpolated in 64-bit and are not rounded.
process/equip/pipestress.js
lines 78 to 87
CadCraft 0.8.2
const cum = [0]; for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + len(sub(pts[i], pts[i - 1])));
const marks = new Map(); pts.forEach((p, i) => marks.set(+cum[i].toFixed(3), { point: p, corner: i > 0 && i < pts.length - 1 }));
const place = (sNeed) => {
const s = Math.min(Math.max(sNeed, 0), cum[cum.length - 1]); const key = +s.toFixed(3);
if (!marks.has(key)) { let i = 1; while (i < cum.length - 1 && cum[i] < s) i++; const a = pts[i - 1], b = pts[i], f = (s - cum[i - 1]) / (cum[i] - cum[i - 1]); marks.set(key, { point: a.map((v, k) => v + f * (b[k] - v)), corner: false }); }
return key;
};
for (const sp of supportsIn) place(sp.s);
for (const tt of teesIn) { const key = place(tt.s); marks.get(key).tee = teeSIF({ D: sec.D, t: sec.t, kind: tt.kind, Te: tt.Te }); }
const order = [...marks.keys()].sort((x, y) => x - y), nodes = order.map((s, k) => ({ id: 'N' + k, s, point: marks.get(s).point, corner: marks.get(s).corner, tee: marks.get(s).tee }));
Weight and thermal loads
64-bit
The contents and insulation weight per length and the forces that represent free thermal expansion.
w = ρ_c × 10⁻⁹ × (π ÷ 4)·d² × g + (kg/m ÷ 1000) × g; F = E × A × α × ΔT
64-bit products with g = 9.80665. The thermal force of each member is added to its two end nodes with opposite signs, so forces of straight neighbors cancel at shared nodes by subtraction of equal numbers.
The moments come from getMoment, defined just above in the same function, which reads the frame solver's Float64Array. The out-of-plane moment is the component along the bend-plane normal, used only when the cross product of the two member axes is longer than 1e-6; the in-plane part is a square root of a difference, floored at 0. The sustained and occasional branches are the same expression.
process/equip/pipestress.js
lines 144 to 163
CadCraft 0.8.2
const rowsFor = (R, kind) => {
const rows = [];
for (let k = 0; k < nn; k++) {
const ms = getMoment(R, k); if (!ms.length) continue;
let n = null; if (b.nodes[k].corner && ms.length === 2) { const c = cross(ms[0].ex, ms[1].ex); if (len(c) > 1e-6) n = unit(c); }
let worst = null;
for (const a of ms) {
const Mb = len(a.M), Mo = n ? Math.abs(dot(a.M, n)) : 0, Mi = Math.sqrt(Math.max(Mb * Mb - Mo * Mo, 0));
const tee = b.nodes[k].tee, ii = tee ? tee.ii : (n ? sif.ii : 1), io = tee ? tee.io : (n ? sif.io : 1);
const tors = a.T;
let val;
if (kind === 'S') { const Isi = Math.max(0.75 * ii, 1), Iso = Math.max(0.75 * io, 1); val = Math.sqrt((Isi * Mi) ** 2 + (Iso * Mo) ** 2) / sec.Z + Math.abs(a.N) / sec.A; }
else if (kind === 'O') val = Math.sqrt((Math.max(0.75 * ii, 1) * Mi) ** 2 + (Math.max(0.75 * io, 1) * Mo) ** 2) / sec.Z + Math.abs(a.N) / sec.A;
else { const Sb = Math.sqrt((ii * Mi) ** 2 + (io * Mo) ** 2) / sec.Z, St = tors / (2 * sec.Z); val = Math.sqrt(Sb * Sb + 4 * St * St); }
if (!worst || val > worst.val) worst = { val, Mi, Mo, tors };
}
rows.push({ node: b.nodes[k].id, s: b.nodes[k].s, point: b.nodes[k].point, val: worst.val, Mi: worst.Mi, Mo: worst.Mo, T: worst.tors });
}
return rows;
};
Stress ratios against the allowables
64-bit
Sustained, expansion and occasional stress as a fraction of what is allowed.
Releases rests that would have to pull the pipe down and applies sliding friction at the rests that stay in contact.
release when R_z(weight) + R_z(thermal) < −10⁻⁶ N; F = −µ·N·(u ÷ |u|), relaxed by 0.6 per pass
At most 12 passes of the frame solve. Friction counts as settled when it moves less than 1e-3 of the force (or of 1 N if smaller forces). When the passes run out the last state is returned and nothing reports that the iteration did not settle. The lift-off threshold is absolute, in newtons.
process/equip/pipestress.js
lines 192 to 230
CadCraft 0.8.2
/** Lift-off of rests and Coulomb friction (opt-in through supports of type 'rest' and `mu`). The operating case is weight + thermal; a rest with a negative reaction there is released and the run solved again. */
function solveNonlinear(b, spec) {
const mu = spec.mu ?? 0, released = [], friction = [];
const restNodes = (b.model.supports || []).filter((sp) => sp.fix[2] && !sp.fix[0] && !sp.fix[1] && !sp.fix[3] && !sp.fix[4] && !sp.fix[5]).map((sp) => sp.node);
let off = new Set(), fric = new Map(), model = b.model, res = null;
const make = () => {
const mdl = JSON.parse(JSON.stringify(b.model)); mdl.supports = mdl.supports.filter((sp) => !off.has(sp.node));
if (fric.size) { const T = mdl.cases.find((c) => c.id === 'T'); for (const [node, F] of fric) T.loads.push({ type: 'node', node, F: [F[0], F[1], 0, 0, 0, 0] }); }
return mdl;
};
for (let it = 0; it < 12; it++) {
model = make(); res = solveFrame(model);
if (!res.ok) { if (off.size) { off = new Set([...off].slice(0, -1)); model = make(); res = solveFrame(model); } break; }
const idx = (id) => model.nodes.findIndex((n) => n.id === id), Rw = res.results['case:W'].reactions, Rt = res.results['case:T'].reactions;
let changed = false;
for (const node of restNodes) {
if (off.has(node)) continue;
const i = idx(node), op = Rw[6 * i + 2] + Rt[6 * i + 2];
if (op < -1e-6) { off.add(node); changed = true; }
}
if (changed) { fric = new Map(); continue; }
if (mu > 0) {
const u = res.results['case:T'].u; let moved = false; const next = new Map();
for (const node of restNodes) {
if (off.has(node)) continue;
const i = idx(node), N = Rw[6 * i + 2] + Rt[6 * i + 2], ux = u[6 * i], uy = u[6 * i + 1], h = Math.hypot(ux, uy);
if (h < 1e-6 || N <= 0) continue;
const F = [-mu * N * ux / h, -mu * N * uy / h], prev = fric.get(node) || [0, 0], relaxed = [prev[0] + 0.6 * (F[0] - prev[0]), prev[1] + 0.6 * (F[1] - prev[1])];
if (Math.hypot(relaxed[0] - prev[0], relaxed[1] - prev[1]) > 1e-3 * Math.max(1, Math.hypot(...F))) moved = true;
next.set(node, relaxed);
}
fric = next; if (moved) continue;
}
break;
}
for (const node of off) { const nd = b.nodes.find((n) => n.id === node); released.push({ node, s: nd ? nd.s : 0 }); }
for (const [node, F] of fric) friction.push({ node, F: Math.hypot(F[0], F[1]), mu });
return { res, model, released, friction };
}
Nozzle loads
64-bit
The force and moment a support passes to the equipment, with a check against a limit.
F = √(Fx² + Fy² + Fz²); M = √(Mx² + My² + Mz²)
Math.hypot of the three reaction components copied from the solver's Float64Array; a sum of squares, so nothing cancels.
process/equip/pipestress.js
lines 232 to 237
CadCraft 0.8.2
/** Loads a nozzle/anchor passes to the equipment (CH.9): the reaction at a support node, per case, in N and N·mm, and the same against a limit the caller gets from the vessel check. */
export function nozzleLoads(analysis, node, limit = null) {
const out = {}; for (const [c, r] of Object.entries(analysis.reactions)) out[c] = r[node] || null;
if (limit && out.thermal) { const F = Math.hypot(...out.thermal.slice(0, 3)), M = Math.hypot(...out.thermal.slice(3)); out.check = { F, M, ok: (!limit.F || F <= limit.F) && (!limit.M || M <= limit.M) }; }
return out;
}
In detail
Method
Beam model on the frame solver (frame/frame-solve.js); stress equations and stress intensification factors the file attributes to ASME B31.3 paragraphs 320.2 and 319.4.4 and Appendix D, marked in the file as typed from secondary sources.
Number types
64-bit numbers; solver results in Float64Array. Units mm, N, MPa. g = 9.80665 m/s².
Tolerances
Stations rounded to 0.001 mm; supports matched to nodes within 0.01 mm. Bend angle below 0.001 rad treated as straight. Rest released when its operating reaction is below −1e-6 N. Friction settled at 1e-3 relative, relaxation 0.6, at most 12 passes.
Limits
Bend flexibility is off unless asked for. Pressure thrust, cold spring, tee branches and dynamic loads are not modeled. Allowable stresses are inputs.
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.