Resources Numerical accuracy Calculation policy

Magnetics add-on: field readout, flux lines and exports

Works out each body's area and mean and peak flux density, compares the result with a hand formula, traces flux lines and writes CSV and VTK files.

How the precision is kept

The solver's result reaches this file with B (x, y and size) and A as Float32Array: phys-worker.js magnetSolve converts them from 64-bit before it returns. Mesh nodes stay Float64Array. Sums run in 64-bit, but every mean, peak, flux-line level and exported field value starts from 32-bit data. Exported numbers are rounded to 8 significant digits.

Open item. Return B and A from magnetSolve as Float64Array and write the exports with 17 significant digits.

Calculations under this policy

9 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.

addons/magnetics/lib/results.js PartCraft 0.8.1 32-bit in part 9 calculations Reviewed

Tests tools/addons-test/browser-magnetics.mjs

Triangle areas and centroids

64-bit

The area and center of each mesh triangle.

area = |(b − a) × (c − a)| ÷ 2; centroid = (a + b + c) ÷ 3 × 1000

Nodes are 64-bit, in meters. The area is one cross product of two edge vectors, so it does not depend on where the model sits. Centroids are scaled to mm after the mean.

  • addons/magnetics/lib/results.js
  • lines 35 to 43
  • PartCraft 0.8.1
function geom(res) {
  const N = res.mesh.nodes, T = res.mesh.tris, m = T.length / 3, area = new Float64Array(m), cx = new Float64Array(m), cy = new Float64Array(m);
  for (let e = 0; e < m; e++) {
    const a = T[3 * e], b = T[3 * e + 1], c = T[3 * e + 2];
    area[e] = Math.abs((N[2 * b] - N[2 * a]) * (N[2 * c + 1] - N[2 * a + 1]) - (N[2 * c] - N[2 * a]) * (N[2 * b + 1] - N[2 * a + 1])) / 2;
    cx[e] = (N[2 * a] + N[2 * b] + N[2 * c]) / 3 * MM; cy[e] = (N[2 * a + 1] + N[2 * b + 1] + N[2 * c + 1]) / 3 * MM;
  }
  return { m, area, cx, cy };
}

Area, mean and peak flux density of each body

32-bit in part

Each body's area in mm², its area-weighted mean |B| and its largest |B|.

mean B = Σ(Bₑ × areaₑ) ÷ Σ areaₑ

Bₑ is read from a Float32Array. Each area is multiplied by 1e6 (m² to mm²) inside its term, and the terms are added in element order in 64-bit. The peak is the largest 32-bit entry.

  • addons/magnetics/lib/results.js
  • lines 45 to 56
  • PartCraft 0.8.1
export function regionStats(res, meta) {
  const G = geom(res), out = new Map();
  for (const r of meta.regions) out.set(r.id, { id: r.id, loop: r.loop + 1, role: r.role, areaMm2: 0, meanB: 0, maxB: 0 });
  for (let e = 0; e < G.m; e++) {
    const s = out.get(res.mesh.region[e]);
    if (!s) continue;
    const b = res.B.mag[e];
    s.areaMm2 += G.area[e] * MM * MM; s.meanB += b * G.area[e] * MM * MM; if (b > s.maxB) s.maxB = b;
  }
  for (const s of out.values()) s.meanB = s.areaMm2 > 0 ? s.meanB / s.areaMm2 : 0;
  return [...out.values()].sort((a, b) => a.loop - b.loop);
}

Result against a hand formula

32-bit in part

The value the result gives where a template's hand formula applies, and its relative difference.

got = Σ(v × w) ÷ Σ w; diff = got ÷ want − 1

The mean-B kinds weight the 32-bit |B| by the 64-bit triangle area; the ring kind first scales each value by r ÷ r_ref. The force kind is Math.hypot of the two per-meter force components from the summary. diff is not computed unless the hand value is above 0.

  • addons/magnetics/lib/results.js
  • lines 58 to 79
  • PartCraft 0.8.1
export function measure(res, hand, sum) {
  if (!hand) return null;
  let got = null;
  if (hand.kind === 'force') {
    const b = sum.bodies.find((x) => x.loop === hand.body);
    if (b) got = Math.hypot(b.fxPerM, b.fyPerM);
  } else {
    const G = geom(res);
    let s = 0, a = 0;
    for (let e = 0; e < G.m; e++) {
      let w = 0, v = res.B.mag[e];
      if (hand.kind === 'meanB' && hand.region !== undefined) w = res.mesh.region[e] === hand.region ? G.area[e] : 0;
      else if (hand.kind === 'meanB' && hand.box) w = G.cx[e] > hand.box[0] && G.cx[e] < hand.box[2] && G.cy[e] > hand.box[1] && G.cy[e] < hand.box[3] ? G.area[e] : 0;
      else if (hand.kind === 'ringB') { const r = Math.hypot(G.cx[e] - hand.at[0], G.cy[e] - hand.at[1]); if (r > hand.r0 && r < hand.r1) { w = G.area[e]; v = v * r / hand.r; } }
      s += v * w; a += w;
    }
    if (a > 0) got = s / a;
  }
  if (got === null || !(hand.value > 0)) return null;
  const diff = got / hand.value - 1;
  return { got, want: hand.value, diff, ok: Math.abs(diff) <= hand.tol, tol: hand.tol, what: hand.what, kind: hand.kind, perMetre: !!hand.perMetre };
}

Flux lines

32-bit in part

Contour segments of the vector potential A, and the flux between neighboring lines.

levelᵢ = lo + ΔA × (i + 1), ΔA = (hi − lo) ÷ (k + 1); t = (L − aᵢ) ÷ (aⱼ − aᵢ)

A is a Float32Array, so the levels and the crossing points start from 32-bit values. The division by aⱼ − aᵢ only runs when the two ends lie on opposite sides of the level, so it is never zero. The number of levels is clamped to 2 to 60.

  • addons/magnetics/lib/results.js
  • lines 81 to 104
  • PartCraft 0.8.1
/** Marching triangles on the linear A: n levels evenly between the least and the largest A (the ends left out). */
export function fluxLines(res, n = 16) {
  const N = res.mesh.nodes, T = res.mesh.tris, A = res.A;
  let lo = Infinity, hi = -Infinity;
  for (let i = 0; i < A.length; i++) { if (A[i] < lo) lo = A[i]; if (A[i] > hi) hi = A[i]; }
  const k = Math.max(2, Math.min(60, Math.round(n)));
  if (!(hi > lo)) return { segs: [], lev: [], levels: [], dA: 0 };
  const dA = (hi - lo) / (k + 1), levels = Array.from({ length: k }, (_, i) => lo + dA * (i + 1)), segs = [], lev = [];
  for (let e = 0; e < T.length / 3; e++) {
    const id = [T[3 * e], T[3 * e + 1], T[3 * e + 2]], a = id.map((i) => A[i]);
    const amin = Math.min(...a), amax = Math.max(...a);
    for (let li = 0; li < levels.length; li++) {
      const L = levels[li];
      if (L <= amin || L > amax) continue;
      const pts = [];
      for (let q = 0; q < 3; q++) {
        const i = q, j = (q + 1) % 3, ai = a[i], aj = a[j];
        if ((ai < L) !== (aj < L)) { const t = (L - ai) / (aj - ai); pts.push((N[2 * id[i]] + t * (N[2 * id[j]] - N[2 * id[i]])) * MM, (N[2 * id[i] + 1] + t * (N[2 * id[j] + 1] - N[2 * id[i] + 1])) * MM); }
      }
      if (pts.length === 4) { segs.push(...pts); lev.push(li); }
    }
  }
  return { segs, lev, levels, dA };
}

Top of the color scale

32-bit, display only

The |B| that the top color stands for.

The 99th percentile of the triangles' |B|, from a sorted Float64Array copy, floored at 1e-12. It sets the color scale only.

  • addons/magnetics/lib/results.js
  • lines 29 to 34
  • PartCraft 0.8.1
export function colourTop(res) {
  const m = res.B.mag, n = m.length;
  if (!n) return 1;
  const s = Float64Array.from(m).sort();
  return Math.max(s[Math.min(n - 1, Math.floor(0.99 * n))], 1e-12);
}

Number format of the exports

64-bit

How a number is written into the CSV and VTK files.

toPrecision(8): every exported number is rounded to 8 significant digits.

  • addons/magnetics/lib/results.js
  • line 106
  • PartCraft 0.8.1
const num = (v) => String(+Number(v).toPrecision(8));

Field table (CSV)

32-bit in part

One row per triangle: center, region, area and B.

Centers and areas come from 64-bit nodes; B_x, B_y and |B| are read from Float32Array. All columns are written through num with 8 significant digits.

  • addons/magnetics/lib/results.js
  • lines 109 to 113
  • PartCraft 0.8.1
export function fieldCsv(res) {
  const G = geom(res), rows = ['x_mm,y_mm,region,area_mm2,Bx_T,By_T,B_T'];
  for (let e = 0; e < G.m; e++) rows.push([num(G.cx[e]), num(G.cy[e]), res.mesh.region[e], num(G.area[e] * MM * MM), num(res.B.x[e]), num(res.B.y[e]), num(res.B.mag[e])].join(','));
  return '' + rows.join('\r\n') + '\r\n';
}

VTK file

32-bit in part

The mesh in mm with B per triangle and A per node.

Point coordinates are the 64-bit nodes × 1000, written with 8 significant digits. The arrays are declared double, but B and A are read from Float32Array.

  • addons/magnetics/lib/results.js
  • lines 115 to 132
  • PartCraft 0.8.1
/** Legacy VTK (ASCII): the triangles in mm (z = 0), B per cell (vector and size), the region per cell, A per point. ParaView and VisIt read it. */
export function vtk(res, title = 'CadCraft PartCraft Magnetics') {
  const N = res.mesh.nodes, T = res.mesh.tris, n = N.length / 2, m = T.length / 3, out = ['# vtk DataFile Version 3.0', String(title).replace(/[\r\n]/g, ' ').slice(0, 250), 'ASCII', 'DATASET UNSTRUCTURED_GRID', `POINTS ${n} double`];
  for (let i = 0; i < n; i++) out.push(num(N[2 * i] * MM) + ' ' + num(N[2 * i + 1] * MM) + ' 0');
  out.push(`CELLS ${m} ${4 * m}`);
  for (let e = 0; e < m; e++) out.push(`3 ${T[3 * e]} ${T[3 * e + 1]} ${T[3 * e + 2]}`);
  out.push(`CELL_TYPES ${m}`);
  for (let e = 0; e < m; e++) out.push('5');
  out.push(`CELL_DATA ${m}`, 'VECTORS B_T double');
  for (let e = 0; e < m; e++) out.push(num(res.B.x[e]) + ' ' + num(res.B.y[e]) + ' 0');
  out.push('SCALARS Bmag_T double 1', 'LOOKUP_TABLE default');
  for (let e = 0; e < m; e++) out.push(num(res.B.mag[e]));
  out.push('SCALARS region int 1', 'LOOKUP_TABLE default');
  for (let e = 0; e < m; e++) out.push(String(res.mesh.region[e]));
  out.push(`POINT_DATA ${n}`, 'SCALARS Az_Wb_per_m double 1', 'LOOKUP_TABLE default');
  for (let i = 0; i < n; i++) out.push(num(res.A[i]));
  return out.join('\n') + '\n';
}

Summary table (CSV)

32-bit in part

Peak B, each body's area, mean and peak B, forces, torque and the hand-formula check.

Peak B, forces and torque come from the summary object; each body's mean and peak come from regionStats, which reads 32-bit B. Every number is written through num with 8 significant digits; the difference is written as 100 × diff.

  • addons/magnetics/lib/results.js
  • lines 160 to 167
  • PartCraft 0.8.1
export function summaryCsv(sum, stats, check, meta) {
  const rows = [['quantity', 'value', 'unit'], ['Bmax', num(sum.bmax), 'T'], ['Bmax at x', num(sum.bmaxAt[0]), 'mm'], ['Bmax at y', num(sum.bmaxAt[1]), 'mm'], ['depth', num(sum.depthMm), 'mm'], ['triangles', num(sum.tris), ''], ['iterations', num(sum.iterations), '']];
  for (const s of stats) rows.push([q(`body ${s.loop} (${s.role}) area`), num(s.areaMm2), 'mm2'], [q(`body ${s.loop} (${s.role}) mean B`), num(s.meanB), 'T'], [q(`body ${s.loop} (${s.role}) max B`), num(s.maxB), 'T']);
  for (const b of sum.bodies) rows.push([q(`body ${b.loop} Fx`), num(b.fx), 'N'], [q(`body ${b.loop} Fy`), num(b.fy), 'N'], [q(`body ${b.loop} torque about its centre`), num(b.torque), 'N m']);
  if (check) rows.push([q('hand formula ' + check.what), num(check.want), check.kind === 'force' ? 'N/m' : 'T'], [q('this result'), num(check.got), check.kind === 'force' ? 'N/m' : 'T'], [q('difference'), num(100 * check.diff), '%']);
  rows.push([q('model box x0 y0 x1 y1'), q(meta.box.map((v) => num(v * MM)).join(' ')), 'mm']);
  return '' + rows.map((r) => r.join(',')).join('\r\n') + '\r\n';
}

In detail

Method
Triangle area by the cross product; area-weighted mean of |B| per region; flux lines are contours of A by linear interpolation along triangle edges (the file calls it marching triangles).
Number types
Float64Array nodes, areas and centroids; Float32Array B and A; 64-bit sums.
Tolerances
The check against a hand formula passes when |got ÷ want − 1| ≤ the template's own tolerance. The color scale's top is at least 1e-12.
Limits
CSV and VTK numbers are written with toPrecision(8). The VTK file declares its arrays as double although B and A come from 32-bit data.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach