Resources Numerical accuracy Calculation policy

Pipe stress checks

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.

process/equip/pipestress.js CadCraft 0.8.2 64-bit 11 calculations Reviewed

Tests tools/process-test/test-equip.mjs, tools/process-test/test-dec.mjs

Pipe section properties

64-bit

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.

θ = acos(u₁ · u₂); tangent length a = R₁ × tan(θ ÷ 2)

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.

  • process/equip/pipestress.js
  • lines 108 to 113
  • CadCraft 0.8.2
const dI = sec.d, wContents = (spec.contentsRho || 0) * 1e-9 * Math.PI / 4 * dI * dI * G, wIns = (spec.insulationKgPerM || 0) / 1000 * G;
const W = { id: 'W', name: 'Weight', kind: 'dead', selfWeight: 1, loads: fm.members.map((e) => ({ type: 'udl', member: e.id, dir: 'Z', w: -(wContents + wIns) })).filter((l) => l.w !== 0) };
// thermal: equivalent nodal loads of the free expansion along each member
const dT = (spec.Thot ?? 20) - (spec.Tinstall ?? 20), eps = m.alpha * dT, Fax = m.E * sec.A * eps, nodeF = new Map();
fm.members.forEach((e, k) => { const u = unit(sub(nodes[k + 1].point, nodes[k].point)); const add = (id, s) => { const f = nodeF.get(id) || [0, 0, 0, 0, 0, 0]; for (let q = 0; q < 3; q++) f[q] += s * Fax * u[q]; nodeF.set(id, f); }; add(nodes[k].id, -1); add(nodes[k + 1].id, 1); });
const T = { id: 'T', name: 'Thermal expansion', kind: 'other', selfWeight: 0, loads: [...nodeF].map(([node, F]) => ({ type: 'node', node, F })) };

Stress at each node

64-bit

The bending, torsion and axial stress at each node for the sustained, expansion and occasional cases.

expansion: S_E = √(S_b² + 4·S_t²), S_b = √((i_i·M_i)² + (i_o·M_o)²) ÷ Z, S_t = T ÷ 2Z; M_i = √(M_b² − M_o²)

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.

S_L = bending + P·D ÷ 4t; S_A = f·(1.25·S_c + 0.25·S_h) + max(0, S_h − S_L); occasional limit 1.33·S_h

64-bit; the ratios are stored unrounded and toFixed appears only in the problem texts. The occasional limit is written as 1.33 × Sh.

  • process/equip/pipestress.js
  • lines 164 to 172
  • CadCraft 0.8.2
const pressureLong = (spec.P || 0) * sec.D / (4 * sec.t), Sh = m.Sh, Sc = m.Sc ?? m.Sh, f = spec.f ?? 1, SAbase = f * (1.25 * Sc + 0.25 * Sh);
const sust = rowsFor(res.results['case:W'], 'S').map((r) => ({ ...r, S_L: r.val + pressureLong }));
const exp = rowsFor(res.results['case:T'], 'E');
const occ = res.results['case:Wd'] ? rowsFor(res.results['case:Wd'], 'O') : null;
const stresses = b.nodes.map((n, k) => {
  const sL = sust.find((r) => r.node === n.id), sE = exp.find((r) => r.node === n.id), sO = occ?.find((r) => r.node === n.id);
  const SL = sL?.S_L ?? pressureLong, SE = sE?.val ?? 0, SA = SAbase + Math.max(0, Sh - SL), occTotal = sO ? SL + sO.val : null;
  return { node: n.id, s: n.s, point: n.point, S_L: SL, ratioSustained: SL / Sh, S_E: SE, SA, ratioExpansion: SE / SA, ...(occTotal != null ? { S_occ: occTotal, ratioOccasional: occTotal / (1.33 * Sh) } : {}) };
});

Free thermal growth

64-bit

How much the developed length of the run would grow if it were free.

growth = ΔT × α × (s_last − s_first)

Uses the node stations, which were rounded to 0.001 mm when the nodes were placed.

  • process/equip/pipestress.js
  • line 185
  • CadCraft 0.8.2
const growth = b.dT * m.alpha * (b.nodes[nn - 1].s - b.nodes[0].s);

Support lift-off and friction

64-bit

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.

Every policy that takes this approach