The field of wire segments, the mutual inductance of loops, and 2D finite-element magnetic and electric fields with energy, capacitance and force.
How the precision is kept
Arithmetic is 64-bit throughout. The finite-element results are limited by the mesh of linear triangles and by the conjugate-gradient stop at a relative residual of 1e-11. The Neumann inductance is a midpoint sum, and saturating iron is iterated to a 1e-3 relative change. The physical constants are the μ0 fixed before 2019 and an 11-digit ε0.
Open item. Take μ0 and ε0 from one shared place so the field and antenna solvers use the same values.
Calculations under this policy
12 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.
fields.jsShared shell, every app64-bit11 calculationsReviewed
μ0 is the value that was exact before 2019; the measured value since then differs from it in the 10th digit. ε0 is a measured value written to 11 digits. A result that scales with either constant has about 10 digits in absolute terms at most.
The magnetic flux density at a point from current-carrying segments.
B = μ0 I ÷ (4π d) × (z_b ÷ √(z_b² + d²) − z_a ÷ √(z_a² + d²))
The closed form for a straight segment. When the point is far beyond one end of the segment the two ratios are nearly equal and their difference loses digits. A point on the wire (d below 1e-12 of the segment length) contributes nothing instead of infinity.
fields.js
lines 14 to 25
/** B (tesla) at p from straight segments [{a:[x,y,z], b:[x,y,z], I}] (exact finite-segment formula). */
export function biotSavart(segs, p) {
const B = [0, 0, 0];
for (const s of segs) {
const L = sub(s.b, s.a), l = Math.hypot(...L); if (l === 0) continue; const u = L.map((x) => x / l);
const ap = sub(s.a, p), za = dot(ap, u), rho = [ap[0] - za * u[0], ap[1] - za * u[1], ap[2] - za * u[2]], d = Math.hypot(...rho);
if (d < 1e-12 * Math.max(l, 1e-30)) continue; // on the wire: the field is not defined
const zb = za + l, ph = cross(u, rho.map((x) => x / d)), f = MU0 * s.I / (4 * Math.PI * d) * (zb / Math.hypot(zb, d) - za / Math.hypot(za, d));
B[0] += f * -ph[0]; B[1] += f * -ph[1]; B[2] += f * -ph[2]; // rho points from p to the foot, so the direction from the wire to p is -rho
}
return B;
}
Loop as straight segments
64-bit
A circular loop turned into chords for the segment formulas.
n chords with their ends on the circle, 128 by default
The field of a loop is computed as that of a polygon. With 128 chords the polygon's area is smaller than the circle's by about 0.04 %.
fields.js
lines 27 to 33
/** A circular loop as n straight segments: centre c, radius r, unit normal nrm, current I (right-hand rule about nrm). */
export function circleLoop(c, r, nrm, I, n = 128) {
const nl = Math.hypot(...nrm), z = nrm.map((x) => x / nl); const t = Math.abs(z[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
const x = cross(t, z), xl = Math.hypot(...x), xu = x.map((v) => v / xl), yu = cross(z, xu), pts = [];
for (let k = 0; k <= n; k++) { const a = 2 * Math.PI * k / n; pts.push([0, 1, 2].map((i) => c[i] + r * (Math.cos(a) * xu[i] + Math.sin(a) * yu[i]))); }
return pts.slice(1).map((b, k) => ({ a: pts[k], b, I }));
}
Mutual inductance of two loops
64-bit
The Neumann integral over two loops given as segments.
M = μ0 ÷ 4π × Σ Σ (dl₁ · dl₂) ÷ R
Midpoint rule with 4 sub-samples per segment. Pairs closer than 1e-15 are skipped, which drops the singular self terms, so the sum is an approximation whose error depends on the segment length against the spacing of the loops.
fields.js
lines 35 to 42
/** Neumann mutual inductance M = μ0/4π ∮∮ dl1·dl2/R of two loops given as segment lists (current values ignored). Midpoint rule per segment pair, sub-sampled. */
export function mutualInductance(l1, l2, sub_ = 4) {
let sum = 0;
const pts = (s) => { const out = [], d = sub(s.b, s.a); for (let k = 0; k < sub_; k++) out.push({ r: [0, 1, 2].map((i) => s.a[i] + d[i] * (k + 0.5) / sub_), dl: d.map((v) => v / sub_) }); return out; };
const P2 = l2.flatMap(pts);
for (const s of l1) for (const p of pts(s)) for (const q of P2) { const R = Math.hypot(...sub(p.r, q.r)); if (R < 1e-15) continue; sum += dot(p.dl, q.dl) / R; }
return MU0 / (4 * Math.PI) * sum;
}
Complete elliptic integrals
64-bit
K(k) and E(k), used for coaxial loops.
K = π ÷ (2a) at the limit of the arithmetic–geometric mean; E = K (1 − Σ 2ⁿ⁻¹ cₙ²)
The mean converges quadratically; the loop stops when |c| ≤ 1e-16 or after 30 steps. For k close to 1 the start value √(1 − k²) loses digits to the subtraction.
fields.js
lines 44 to 48
export function agmKE(k) { // complete elliptic integrals K(k), E(k) by the arithmetic–geometric mean
let a = 1, b = Math.sqrt(1 - k * k), c = k, sumE = 0.5 * c * c, p = 0.5;
for (let i = 0; i < 30 && Math.abs(c) > 1e-16; i++) { const an = (a + b) / 2, cn = (a - b) / 2; b = Math.sqrt(a * b); a = an; c = cn; p *= 2; sumE += p * c * c; }
const K = Math.PI / (2 * a); return { K, E: K * (1 - sumE) };
}
Mutual inductance of coaxial loops
64-bit
Maxwell's formula for two circular loops on one axis.
M = μ0 √(r₁ r₂) ((2 ÷ k − k) K − (2 ÷ k) E); k² = 4 r₁ r₂ ÷ ((r₁ + r₂)² + d²)
Closed form. For loops far apart or of very different size k is small, K and E are both close to π ÷ 2, and the two terms, each about π ÷ k, cancel to a remainder near π k³ ÷ 16. Digits are lost in proportion to 16 ÷ k⁴.
fields.js
lines 49 to 53
/** Maxwell's exact mutual inductance of two coaxial circular loops (radii r1, r2, axial gap d). */
export function coaxialLoopsM(r1, r2, d) {
const k2 = 4 * r1 * r2 / ((r1 + r2) ** 2 + d * d), k = Math.sqrt(k2), { K, E } = agmKE(k);
return MU0 * Math.sqrt(r1 * r2) * ((2 / k - k) * K - 2 / k * E);
}
2D finite-element solve
64-bit
The potential at every mesh node and its gradient in every triangle.
∇·(k ∇u) = −s on linear triangles; element matrix k (bᵢ · bⱼ) A
Assembly is 64-bit. The linear system is solved by conjugate gradients to a relative residual of 1e-11, so the nodal values carry that stop. The gradient is constant inside each triangle, and the mesh, not the arithmetic, then sets how close the values are to the true field.
fields.js
lines 82 to 113
/**
* Solve ∇·(k ∇u) = −s on a triangle mesh. mesh {nodes, tris}; k (number | per-triangle array); s (number | per-triangle array of source per area);
* fixed: Map or object node → value; extra: optional per-triangle [gx, gy] adds ∫ (gx ∂w/∂x + gy ∂w/∂y) to the right side (magnets).
* Returns {u, grad (per tri [ux, uy]), area (per tri), problems}.
*/
export function fem2d(mesh, { k = 1, s = 0, fixed = new Map(), extra = null, tol = 1e-11 } = {}) {
const { nodes, tris } = mesh, n = nodes.length, rows = Array.from({ length: n }, () => new Map()), b = new Float64Array(n), problems = [];
const fx = fixed instanceof Map ? fixed : new Map(Object.entries(fixed).map(([a, v]) => [+a, v]));
const kk = (e) => (Array.isArray(k) ? k[e] : k), ss = (e) => (Array.isArray(s) ? s[e] : s), area = new Float64Array(tris.length), grad = [];
if (!fx.size) problems.push({ id: 'em:fem.no-boundary', severity: 'error', message: 'No node has a fixed value; the problem has no reference.', source: { kind: 'engine', engine: 'em' } });
tris.forEach((t, e) => {
const [p, q, r] = t.map((i) => nodes[i]), A2 = area2(nodes, t); if (Math.abs(A2) < 1e-300) { problems.push({ id: 'em:fem.degenerate', severity: 'error', message: 'Triangle ' + e + ' has no area.', source: { kind: 'engine', engine: 'em' } }); area[e] = 0; return; }
area[e] = Math.abs(A2) / 2;
const bx = [q[1] - r[1], r[1] - p[1], p[1] - q[1]].map((v) => v / A2), by = [r[0] - q[0], p[0] - r[0], q[0] - p[0]].map((v) => v / A2), ke = kk(e), se = ss(e);
for (let i = 0; i < 3; i++) {
b[t[i]] += se * area[e] / 3;
if (extra) b[t[i]] += (extra[e][0] * bx[i] + extra[e][1] * by[i]) * area[e];
for (let j = 0; j < 3; j++) { const v = ke * (bx[i] * bx[j] + by[i] * by[j]) * area[e], r_ = rows[t[i]]; r_.set(t[j], (r_.get(t[j]) || 0) + v); }
}
});
if (problems.some((p) => p.severity === 'error')) return { ok: false, problems };
// eliminate fixed nodes
const x = new Float64Array(n); for (const [i, v] of fx) x[i] = v;
for (let i = 0; i < n; i++) if (!fx.has(i)) for (const [j, v] of rows[i]) if (fx.has(j)) b[i] -= v * x[j];
const free = [], pos = new Int32Array(n).fill(-1); for (let i = 0; i < n; i++) if (!fx.has(i)) { pos[i] = free.length; free.push(i); }
const R = free.map((i) => { const out = []; for (const [j, v] of rows[i]) if (pos[j] >= 0) out.push([pos[j], v]); return out; });
const res = cgSolve(R, free.map((i) => b[i]), { tol, maxIter: 40000 });
if (!res.ok) problems.push({ id: 'em:fem.no-convergence', severity: 'error', message: 'The field solve did not converge in ' + res.iterations + ' iterations.', source: { kind: 'engine', engine: 'em' } });
free.forEach((i, m) => { x[i] = res.x[m]; });
tris.forEach((t, e) => { const [p, q, r] = t.map((i) => nodes[i]), A2 = area2(nodes, t), bx = [q[1] - r[1], r[1] - p[1], p[1] - q[1]], by = [r[0] - q[0], p[0] - r[0], q[0] - p[0]]; grad.push([(bx[0] * x[t[0]] + bx[1] * x[t[1]] + bx[2] * x[t[2]]) / A2, (by[0] * x[t[0]] + by[1] * x[t[1]] + by[2] * x[t[2]]) / A2]); });
return { ok: !problems.some((p) => p.severity === 'error'), u: x, grad, area, iterations: res.iterations, problems };
}
Magnetic field, energy and saturating iron
64-bit
Flux density per triangle, stored energy per meter, and the iteration for a B–H curve.
B = (∂A/∂y, −∂A/∂x); W = Σ ½ ν B² A
With a B–H curve the reluctivity is iterated with damping until its largest relative change is below 1e-3, for at most 200 steps; a run that does not get there returns a warning and the last field. The energy is a plain sum over triangles.
fields.js
lines 115 to 140
/**
* Magnetostatics, planar (per metre of depth). regions per triangle: nu = 1/μ (number | array), J (A/m², +z), M ([Mx,My] A/m per triangle or null).
* Boundary: A = 0 on `fixed` nodes (outer boundary far enough away). Optional nonlinear iron: nuOf(B²) per triangle in `iron` (array of booleans).
* Returns {A, B (per tri [Bx,By]), energy (J/m), ...}.
*/
export function magnetostatic2d(mesh, { nu, J = 0, M = null, fixed, nuOf = null, iron = null, relax = 0.5, maxIter = 200, tolNu = 1e-3 } = {}) {
const nT = mesh.tris.length, nuA = Array.from({ length: nT }, (_, e) => (Array.isArray(nu) ? nu[e] : nu));
const Jv = Array.from({ length: nT }, (_, e) => (Array.isArray(J) ? J[e] : J));
// magnet weak form: ∫ν μ0 (Mx ∂w/∂y − My ∂w/∂x) = ∫ (gx ∂w/∂x + gy ∂w/∂y). The magnet's ν stays its own when iron iterates.
let r, its = 0, worst = Infinity, prev = Infinity, rl = relax, converged = !nuOf;
for (; its < (nuOf ? maxIter : 1); its++) {
r = fem2d(mesh, { k: nuA, s: Jv, fixed, extra: M ? M.map((m, e) => (m ? [nuA[e] * MU0 * -m[1], nuA[e] * MU0 * m[0]] : [0, 0])) : null });
if (!r.ok || !nuOf) break;
worst = 0; const target = new Float64Array(nT);
for (let e = 0; e < nT; e++) if (iron ? iron[e] : true) { target[e] = nuOf(r.grad[e][0] ** 2 + r.grad[e][1] ** 2); worst = Math.max(worst, Math.abs(target[e] - nuA[e]) / nuA[e]); }
if (worst < tolNu) { converged = true; break; }
if (worst > prev) rl = Math.max(0.02, rl / 2); // the iteration is oscillating: damp harder (geometric blend of old and target reluctivity)
prev = worst;
for (let e = 0; e < nT; e++) if (iron ? iron[e] : true) nuA[e] = Math.exp((1 - rl) * Math.log(nuA[e]) + rl * Math.log(target[e]));
}
if (!r.ok) return r;
if (nuOf && !converged) r.problems.push({ id: 'em:magnet.no-convergence', severity: 'warning', message: 'The saturating-iron iteration stopped after ' + its + ' steps with the reluctivity still moving by ' + (worst * 100).toFixed(1) + ' %.', source: { kind: 'engine', engine: 'em' }, fix: 'A smaller current or a smoother B–H curve.' });
const B = r.grad.map((g) => [g[1], -g[0]]); let W = 0;
for (let e = 0; e < nT; e++) { const B2 = B[e][0] ** 2 + B[e][1] ** 2; W += 0.5 * nuA[e] * B2 * r.area[e]; }
return { ...r, A: r.u, B, nu: nuA, energy: W, outerIterations: its + 1, converged };
}
Electric field and energy
64-bit
Field per triangle and stored energy per meter.
E = −∇V; W = Σ ½ ε E² A
One finite-element solve, then a sum over triangles in 64-bit.
fields.js
lines 142 to 148
/** Electrostatics: eps per triangle (absolute permittivity), rho (C/m³), fixed potentials. Returns {V, E, energy (J/m)}. */
export function electrostatic2d(mesh, { eps = EPS0, rho = 0, fixed } = {}) {
const r = fem2d(mesh, { k: eps, s: rho, fixed }); if (!r.ok) return r;
const E = r.grad.map((g) => [-g[0], -g[1]]); let W = 0;
E.forEach((v, e) => { W += 0.5 * (Array.isArray(eps) ? eps[e] : eps) * (v[0] ** 2 + v[1] ** 2) * r.area[e]; });
return { ...r, V: r.u, E, energy: W };
}
Capacitance from energy
64-bit
Capacitance per meter from the stored energy.
C = 2W ÷ ΔV²
One expression. The capacitance has the digits of the energy, which depends on the mesh.
fields.js
lines 150 to 151
/** Capacitance per metre from stored energy at potential difference dV: C = 2W/dV². */
export const capacitanceFromEnergy = (W, dV) => 2 * W / (dV * dV);
Force from the Maxwell stress
64-bit
The force per meter on a body in the solved field.
F = Σ ((B · n) B − ½ B² n) × edge length ÷ μ
Uses the mean of the two triangles' fields on each boundary edge. The code comment limits it to surfaces in air away from sharp corners; the sum itself is 64-bit.
fields.js
lines 153 to 171
/**
* Force per metre (N/m) on the body made of triangles `inside` (Set of triangle indices) from the Maxwell stress on the edges that
* separate it from the rest, using the mean of the two neighbouring triangles' B. Reliable only with the surface in air, away from sharp corners.
*/
export function maxwellForce(mesh, B, inside, mu = MU0) {
const edges = new Map(), key = (a, b) => (a < b ? a + ':' + b : b + ':' + a);
mesh.tris.forEach((t, e) => { for (let k = 0; k < 3; k++) { const a = t[k], c = t[(k + 1) % 3], kk = key(a, c); if (!edges.has(kk)) edges.set(kk, []); edges.get(kk).push({ e, a, c }); } });
let Fx = 0, Fy = 0;
for (const list of edges.values()) {
if (list.length !== 2) continue; const [p, q] = list; if (inside.has(p.e) === inside.has(q.e)) continue;
const inn = inside.has(p.e) ? p : q, a = mesh.nodes[inn.a], c = mesh.nodes[inn.c], dx = c[0] - a[0], dy = c[1] - a[1], len = Math.hypot(dx, dy);
let nx = dy / len, ny = -dx / len; // outward from the body: check against centroid of the inside triangle
const cen = mesh.tris[inn.e].reduce((s, i) => [s[0] + mesh.nodes[i][0] / 3, s[1] + mesh.nodes[i][1] / 3], [0, 0]);
if ((cen[0] - a[0]) * nx + (cen[1] - a[1]) * ny > 0) { nx = -nx; ny = -ny; }
const Bx = (B[p.e][0] + B[q.e][0]) / 2, By = (B[p.e][1] + B[q.e][1]) / 2, Bn = Bx * nx + By * ny, B2 = Bx * Bx + By * By;
Fx += (Bn * Bx - 0.5 * B2 * nx) * len / mu; Fy += (Bn * By - 0.5 * B2 * ny) * len / mu;
}
return [Fx, Fy];
}
linalg.jsShared shell, every app64-bit1 calculationReviewed
Jacobi-preconditioned conjugate gradients. The residual tested is the one updated inside the iteration, not b − A x computed afresh, so the true residual can differ from the one tested. Default tolerance 1e-10 and 20,000 iterations; the field solver passes 1e-11 and 40,000.
linalg.js
lines 48 to 70
/** Conjugate-gradient for symmetric positive definite sparse systems given as a row-list matrix [[ [col, val], … ], …]. */
export function cgSolve(rows, b, { tol = 1e-10, maxIter = 20000, x0 } = {}) {
const n = b.length, x = x0 ? Float64Array.from(x0) : new Float64Array(n);
const mul = (v, out) => { for (let i = 0; i < n; i++) { let s = 0; const r = rows[i]; for (let k = 0; k < r.length; k++) s += r[k][1] * v[r[k][0]]; out[i] = s; } };
const dg = new Float64Array(n); for (let i = 0; i < n; i++) for (const [c, v] of rows[i]) if (c === i) dg[i] += v;
const r = new Float64Array(n), z = new Float64Array(n), p = new Float64Array(n), ap = new Float64Array(n);
mul(x, ap); for (let i = 0; i < n; i++) r[i] = b[i] - ap[i];
const bn = Math.sqrt(b.reduce((s, v) => s + v * v, 0)) || 1;
for (let i = 0; i < n; i++) { z[i] = dg[i] > 0 ? r[i] / dg[i] : r[i]; p[i] = z[i]; }
let rz = 0; for (let i = 0; i < n; i++) rz += r[i] * z[i];
let it = 0;
for (; it < maxIter; it++) {
let rn = 0; for (let i = 0; i < n; i++) rn += r[i] * r[i];
if (Math.sqrt(rn) <= tol * bn) break;
mul(p, ap); let pap = 0; for (let i = 0; i < n; i++) pap += p[i] * ap[i];
if (!(pap > 0)) return { x, iterations: it, ok: false };
const al = rz / pap; for (let i = 0; i < n; i++) { x[i] += al * p[i]; r[i] -= al * ap[i]; }
for (let i = 0; i < n; i++) z[i] = dg[i] > 0 ? r[i] / dg[i] : r[i];
let rz2 = 0; for (let i = 0; i < n; i++) rz2 += r[i] * z[i];
const be = rz2 / rz; rz = rz2; for (let i = 0; i < n; i++) p[i] = z[i] + be * p[i];
}
return { x, iterations: it, ok: it < maxIter };
}
In detail
Method
Finite-segment Biot–Savart formula; Neumann double integral by the midpoint rule; Maxwell's elliptic-integral formula for coaxial loops with K and E from the arithmetic–geometric mean; linear-triangle finite elements with Jacobi-preconditioned conjugate gradients; Maxwell stress on element edges.
Number types
64-bit numbers; Float64Array for the solution and areas, Int32Array for node numbering.
Tolerances
Conjugate gradients: ‖r‖ ≤ 1e-11 × ‖b‖ from the field solver (1e-10 by default), 40,000 iterations. Arithmetic–geometric mean: stops when |c| ≤ 1e-16 or after 30 steps. Biot–Savart skips a point closer to the wire than 1e-12 of the segment length. The Neumann sum skips pairs closer than 1e-15 (absolute). Degenerate triangle: |2A| < 1e-300. Iron iteration: reluctivity change below 1e-3, 200 steps.
Limits
MU0 = 4π × 10⁻⁷ and EPS0 = 8.8541878128e-12; the antenna file of the same engine uses 1 ÷ (μ0 c²) for ε0, which differs in the 10th digit. Planar problems only. The force uses the mean field of two neighboring triangles.
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.