Resources Numerical accuracy Calculation policy

Luminaire files and point-by-point illuminance

Reads luminaire photometry files (IES LM-63 and EULUMDAT) and works out the direct illuminance on a grid of points, with its minimum, average, maximum, uniformity and power density.

How the precision is kept

All arithmetic is 64-bit. The accuracy is set by the method and the data: candela values are interpolated linearly between the measured angles of the file, only the direct component is computed (the file's header and RESULT_NOTE say so), and the statistics are taken over a grid of cell centers. The CSV reports round lux to 1 decimal and coordinates to whole millimeters.

Calculations under this policy

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

addons/lighting/lib/mep/photometry.js CadCraft 0.8.2 64-bit 15 calculations Reviewed

Tests tools/bimcraft-test/eg-mep-test.mjs

Numbers read from an IES file

64-bit

Splits the numeric part of an IES file into numbers.

Tokens are split on spaces and commas and read with Number(), so they keep the decimals in the file. A number written with a decimal comma would be split in two.

  • addons/lighting/lib/mep/photometry.js
  • line 24
  • CadCraft 0.8.2
const nums = (text) => String(text).split(/[\s,]+/).filter(Boolean).map(Number);

IES candela values and luminaire size

64-bit

Scales the candela table and converts the luminous opening to millimeters.

cd = value × multiplier × ballast factor; lumens = lamps × lumens per lamp; size mm = value × 304.8 (feet) or × 1000 (meters)

304.8 mm per foot is exact. A multiplier or ballast factor of 0 is treated as 1. The value between the ballast factor and the input watts is not used. Vertical and horizontal angles must increase strictly, which keeps the interpolation from dividing by zero.

  • addons/lighting/lib/mep/photometry.js
  • lines 59 to 66
  • CadCraft 0.8.2
const k = units === 1 ? 304.8 : 1000;    // 1 = feet, 2 = metres
if (vA.some((a, j) => j && a <= vA[j - 1]) || hA.some((a, j) => j && a <= hA[j - 1])) return { ok: false, key: 'ies.angles' };
const cd = [];
for (let a = 0; a < nH; a++) cd.push(flat.slice(a * nV, (a + 1) * nV).map((x) => x * (mult || 1) * (ballast || 1)));
const full = expandIes(hA, cd);
if (!full) return { ok: false, key: 'ies.hangles' };
return finish({ format: 'ies', name: keywords.LUMINAIRE || keywords.LUMCAT || keywords.TEST || '', lumens: lumPerLamp > 0 ? nLamps * lumPerLamp : 0, watts: inputW, vAngles: vA, hAngles: full.h, cd: full.cd, sym: full.sym,
  width: Math.abs(w) * k, length: Math.abs(l) * k, height: Math.abs(h) * k, keywords });

Full set of horizontal planes

64-bit

Mirrors the horizontal planes of a symmetric IES file to cover 0 to 360 degrees.

quadrant: a, 180 − a, 180 + a, 360 − a; bilateral: a, 360 − a

Mirrored angles are sums and differences of the file's angles, reduced modulo 360 and rounded to 6 decimals as map keys so that equal planes coincide. The first plane stored for an angle is kept. The symmetry is recognized by exact comparison of the first and last angle with 0, 90, 180, 270 and 360.

  • addons/lighting/lib/mep/photometry.js
  • lines 68 to 86
  • CadCraft 0.8.2
/** LM-63 horizontal angle sets → the full 0..360 set (mirrored by the symmetry the angles announce). */
function expandIes(h, cd) {
  const last = h[h.length - 1], m = new Map();
  const put = (a, row) => { const k = Math.round((((a % 360) + 360) % 360) * 1e6) / 1e6; if (!m.has(k)) m.set(k, row); };
  let sym = 'none';
  if (h.length === 1) { if (h[0] !== 0) return null; put(0, cd[0]); sym = 'axial'; }
  else if (h[0] === 0 && last === 90) { h.forEach((a, i) => { put(a, cd[i]); put(180 - a, cd[i]); put(180 + a, cd[i]); put(360 - a, cd[i]); }); sym = 'quadrant'; }
  else if (h[0] === 0 && last === 180) { h.forEach((a, i) => { put(a, cd[i]); put(360 - a, cd[i]); }); sym = 'bilateral'; }
  else if (h[0] === 90 && last === 270) { h.forEach((a, i) => { put(a, cd[i]); put(540 - a, cd[i]); }); sym = 'bilateral'; }
  else if (h[0] === 0 && last === 360) { h.forEach((a, i) => put(a, cd[i])); }
  else if (h[0] === 0 && last < 360) { h.forEach((a, i) => put(a, cd[i])); }
  else return null;
  const keys = [...m.keys()].sort((x, y) => x - y);
  const H = keys.slice(), C = keys.map((k) => m.get(k));
  if (H[0] !== 0) return null;
  if (H[H.length - 1] !== 360) { H.push(360); C.push(C[0]); }
  if (H.length === 2) { /* axial: both ends equal */ }
  return { h: H, cd: C, sym };
}

EULUMDAT lamp flux and watts

64-bit

Adds up the lamp flux and wattage of the lamp sets in an EULUMDAT file.

lumens = Σ |count| × flux; watts = Σ watts

Each lamp set is read as six consecutive lines; the flux is multiplied by the absolute lamp count. Numbers are read with a decimal comma turned into a point.

  • addons/lighting/lib/mep/photometry.js
  • lines 99 to 100
  • CadCraft 0.8.2
let k = 26, lumens = 0, watts = 0;
for (let s = 0; s < nSets; s++) { const cnt = n(k), flux = n(k + 2), w = n(k + 5); lumens += Math.abs(cnt) * flux; watts += w; k += 6; }

EULUMDAT candela scale

64-bit

Turns the file's cd per 1000 lm values into candela.

cd = value × (lumens ÷ 1000) × (LORL ÷ 100) × conversion factor

The table values are multiplied by the lamp flux over 1000, by the light output ratio when it is above 0, and by the conversion factor (1 when it is 0 or missing). With no lamp flux, 1000 lm is assumed.

  • addons/lighting/lib/mep/photometry.js
  • lines 110 to 114
  • CadCraft 0.8.2
const scale = (lumens || 1000) / 1000 * (lorl > 0 ? lorl / 100 : 1) * cfac;      // cd/klm → cd: lamp flux × light output ratio
const full = (angle) => ({ angle, row: null });
// plane angles of the stored planes
const step = Dc > 0 ? Dc : (Mc ? 360 / Mc : 0);
const stored = raw.map((row, i) => ({ c: symm === 1 ? 0 : (cAng[i] ?? i * step), row: row.map((x) => x * scale) }));

Position in an angle table

64-bit

Finds the two table angles on either side of an angle and the fraction between them.

f = (x − a_lo) ÷ (a_hi − a_lo)

A binary search over the angle list; the shift in the search halves an index, not a measured value. Outside the table the nearest end is used with f = 0.

  • addons/lighting/lib/mep/photometry.js
  • lines 149 to 156
  • CadCraft 0.8.2
// ------------------------------------------------------------------------------------------------------------- lookup and maths
function bracket(a, x) {
  if (x <= a[0]) return [0, 0, 0];
  if (x >= a[a.length - 1]) return [a.length - 1, a.length - 1, 0];
  let lo = 0, hi = a.length - 1;
  while (hi - lo > 1) { const m = (lo + hi) >> 1; if (a[m] <= x) lo = m; else hi = m; }
  return [lo, hi, (x - a[lo]) / (a[hi] - a[lo])];
}

Candela in a direction

64-bit

The luminous intensity at a vertical and horizontal angle, interpolated from the table.

I = (1 − f_h) (I₀₀ (1 − f_v) + I₀₁ f_v) + f_h (I₁₀ (1 − f_v) + I₁₁ f_v)

Bilinear interpolation between the four nearest measured values, so the result is as fine as the file's angle steps. The intensity is 0 beyond the last vertical angle when the table stops short of 179.99°, with a 1e-9 allowance.

  • addons/lighting/lib/mep/photometry.js
  • lines 157 to 165
  • CadCraft 0.8.2
/** Candela at vertical angle gamma (0 = nadir … 180) and horizontal angle c (0…360): bilinear; zero beyond the last measured vertical angle when it is short of 180. */
export function candela(photo, gamma, c) {
  const cc = ((c % 360) + 360) % 360, g = Math.max(0, Math.min(180, gamma));
  const V = photo.vAngles, last = V[V.length - 1];
  if (g > last + 1e-9 && last < 179.99) return 0;
  const [h0, h1, fh] = bracket(photo.hAngles, cc), [v0, v1, fv] = bracket(V, g);
  const a = photo.cd[h0][v0] * (1 - fv) + photo.cd[h0][v1] * fv, b = photo.cd[h1][v0] * (1 - fv) + photo.cd[h1][v1] * fv;
  return a * (1 - fh) + b * fh;
}

Luminaire axes

64-bit

The nadir, C0 and C90 directions of an aimed and rotated luminaire.

n = aim ÷ |aim|; r₀ = axis − (axis·n) n, normalized; r = r₀ cos t + (n × r₀) sin t

One Gram-Schmidt step against the x axis, or the y axis when the aim is within about 26° of x (|nₓ| ≥ 0.9), then a rotation and a re-normalization. A zero aim vector is divided by 1 instead of 0.

  • addons/lighting/lib/mep/photometry.js
  • lines 169 to 177
  • CadCraft 0.8.2
/** Luminaire axes: n = nadir (default straight down), r = the C0 direction (default +x, turned `rotDeg` about n), s = r turned 90° about n. */
export function frame(aim = [0, 0, -1], rotDeg = 0) {
  const n = unit(aim);
  let r0 = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
  r0 = unit([r0[0] - dot(r0, n) * n[0], r0[1] - dot(r0, n) * n[1], r0[2] - dot(r0, n) * n[2]]);
  const s0 = cross(n, r0), t = rad(rotDeg);
  const r = unit([r0[0] * Math.cos(t) + s0[0] * Math.sin(t), r0[1] * Math.cos(t) + s0[1] * Math.sin(t), r0[2] * Math.cos(t) + s0[2] * Math.sin(t)]);
  return { n, r, s: cross(n, r) };
}

Illuminance at a point

64-bit

The direct illuminance in lux at a point on a surface from all luminaires.

E = LLF × Σ I(γ, C) × scale × cos θ ÷ d², d in meters

Distances are millimeters divided by 1000. The acos for γ is clamped to [−1, 1]; near straight below, where its argument is close to 1, acos is ill-conditioned, but the table is flat there for most luminaires. Luminaires within 1 mm of the point and those behind the surface add nothing. The sum runs in luminaire order.

  • addons/lighting/lib/mep/photometry.js
  • lines 178 to 193
  • CadCraft 0.8.2
/** Luminaire { photo, p: [x, y, z] mm, aim?, rot?, scale? (flux factor, 1) , f? (cached frame) }. Illuminance in lux on a surface with the given unit normal. */
export function illuminanceAt(luminaires, p, normal = [0, 0, 1], llf = DEFAULT_LLF.value) {
  let E = 0;
  for (const L of luminaires) {
    const f = L.f || (L.f = frame(L.aim, L.rot));
    const v = [(p[0] - L.p[0]) / 1000, (p[1] - L.p[1]) / 1000, (p[2] - L.p[2]) / 1000], d2 = dot(v, v);
    if (d2 < 1e-6) continue;
    const d = Math.sqrt(d2), u = [v[0] / d, v[1] / d, v[2] / d];
    const gamma = Math.acos(Math.max(-1, Math.min(1, dot(u, f.n)))) * 180 / Math.PI;
    const c = Math.atan2(dot(u, f.s), dot(u, f.r)) * 180 / Math.PI;
    const cosT = -dot(u, normal);               // the light arrives against the surface normal
    if (cosT <= 0) continue;
    E += (candela(L.photo, gamma, c) * (L.scale ?? 1)) * cosT / d2;
  }
  return E * llf;
}

Calculation grid

64-bit

The points the illuminance is computed at: cell centers inside the area at the working height.

nₓ = max(1, round(width ÷ spacing)); x = x₀ + (width ÷ nₓ) × (i + 0.5)

The cell count is rounded to a whole number, so the cell size is the extent divided by that count, not exactly the typed spacing. Points are computed from the index each time, not by adding a step. A point is kept when the even-odd test puts it inside and it is at least the margin from every edge.

  • addons/lighting/lib/mep/photometry.js
  • lines 204 to 216
  • CadCraft 0.8.2
/** Cell-centre points inside the polygon [[x, y]] mm at `height` (workplane), spacing mm, none nearer than `margin` to the edge. At most 40 000 points. */
export function gridPoints(polygon, { spacing = 500, height = 800, margin = 0 } = {}) {
  const xs = polygon.map((p) => p[0]), ys = polygon.map((p) => p[1]);
  const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
  const nx = Math.max(1, Math.round((x1 - x0) / spacing)), ny = Math.max(1, Math.round((y1 - y0) / spacing));
  if (nx * ny > 40000) return { points: [], tooMany: true };
  const dx = (x1 - x0) / nx, dy = (y1 - y0) / ny, pts = [];
  for (let i = 0; i < nx; i++) for (let j = 0; j < ny; j++) {
    const x = x0 + dx * (i + 0.5), y = y0 + dy * (j + 0.5);
    if (inside(polygon, x, y) && (!margin || distToEdge(polygon, x, y) >= margin)) pts.push([x, y, height]);
  }
  return { points: pts, nx, ny, tooMany: false };
}

Minimum, average, maximum and uniformity

64-bit

The statistics of the grid values.

avg = Σ E ÷ n; uniformity = min ÷ avg; min ÷ max

One pass with a plain running sum in grid order and no compensation. The two ratios are guarded against a zero divisor.

  • addons/lighting/lib/mep/photometry.js
  • lines 217 to 223
  • CadCraft 0.8.2
export function stats(values) {
  if (!values.length) return { n: 0, min: 0, max: 0, avg: 0, minAvg: 0, minMax: 0 };
  let min = Infinity, max = -Infinity, sum = 0;
  for (const v of values) { if (v < min) min = v; if (v > max) max = v; sum += v; }
  const avg = sum / values.length;
  return { n: values.length, min, max, avg, minAvg: avg > 0 ? min / avg : 0, minMax: max > 0 ? min / max : 0 };
}

Area and lighting power density

64-bit

The area in square meters, the total lumens and watts, and the watts per square meter.

area m² = |Σ (xᵢ yᵢ₊₁ − xᵢ₊₁ yᵢ)| ÷ 2e6; LPD = watts ÷ area

The shoelace sum is in millimeters squared with absolute coordinates, then divided by 2e6. Lumens and watts are plain sums of the file values times each luminaire's scale.

  • addons/lighting/lib/mep/photometry.js
  • lines 224 to 233
  • CadCraft 0.8.2
/** computeGrid({ luminaires, polygon, spacing, height, margin, normal, llf, areaM2? }) → { points, values, stats, tooMany, lumens, watts, lpd (W/m²) }. */
export function computeGrid({ luminaires, polygon, spacing = 500, height = 800, margin = 0, normal = [0, 0, 1], llf = DEFAULT_LLF.value }) {
  const g = gridPoints(polygon, { spacing, height, margin });
  if (g.tooMany) return { points: [], values: [], stats: stats([]), tooMany: true };
  const values = g.points.map((p) => illuminanceAt(luminaires, p, normal, llf));
  let area = 0; for (let i = 0; i < polygon.length; i++) { const a = polygon[i], b = polygon[(i + 1) % polygon.length]; area += a[0] * b[1] - b[0] * a[1]; }
  area = Math.abs(area) / 2e6;
  const lumens = luminaires.reduce((s, L) => s + (L.photo.lumens || 0) * (L.scale ?? 1), 0), watts = luminaires.reduce((s, L) => s + (L.photo.watts || 0) * (L.scale ?? 1), 0);
  return { points: g.points, values, spacing, stats: stats(values), tooMany: false, areaM2: area, lumens, watts, lpd: area > 0 ? watts / area : 0, nx: g.nx, ny: g.ny };
}

Checks against typed targets

64-bit

Compares the average, minimum, uniformity and power density with the targets the person typed.

avg ≥ target; min ≥ target; min ÷ avg ≥ target; LPD ≤ limit

Plain comparisons of unrounded 64-bit values with no tolerance, so a result a rounding error under its target fails.

  • addons/lighting/lib/mep/photometry.js
  • lines 234 to 242
  • CadCraft 0.8.2
/** Checks against typed targets { avgLux, minLux, uniformity (min/avg), lpd (W/m² limit) } → [{ key, req, found, ok }]. */
export function checkTargets(res, targets = {}) {
  const out = [], s = res.stats;
  if (targets.avgLux > 0) out.push({ key: 'avg', op: '>=', req: targets.avgLux, found: s.avg, ok: s.avg >= targets.avgLux });
  if (targets.minLux > 0) out.push({ key: 'min', op: '>=', req: targets.minLux, found: s.min, ok: s.min >= targets.minLux });
  if (targets.uniformity > 0) out.push({ key: 'uniformity', op: '>=', req: targets.uniformity, found: s.minAvg, ok: s.minAvg >= targets.uniformity });
  if (targets.lpd > 0 && res.areaM2 > 0) out.push({ key: 'lpd', op: '<=', req: targets.lpd, found: res.lpd, ok: res.lpd <= targets.lpd });
  return out;
}

Grid CSV

64-bit

Writes each grid point and its illuminance.

x, y, z = round(mm); lux = value.toFixed(1)

Coordinates are rounded to whole millimeters and lux to 1 decimal in the file.

  • addons/lighting/lib/mep/photometry.js
  • lines 243 to 244
  • CadCraft 0.8.2
/** CSV of the grid (x, y, z mm, lux) and a header with the totals — for the report. */
export function gridCsv(res) { return 'x_mm,y_mm,z_mm,lux\n' + res.points.map((p, i) => p.map((x) => Math.round(x)).join(',') + ',' + res.values[i].toFixed(1)).join('\n') + '\n'; }

Lighting report CSV

64-bit

Writes one row per area with its totals and statistics, then every grid point.

area and lux 1 decimal; lumens and watts 0; W/m² and min ÷ avg 2

Every figure is written with toFixed at the stated decimals; grid coordinates are rounded to whole millimeters.

  • addons/lighting/lib/mep/photometry.js
  • lines 261 to 270
  • CadCraft 0.8.2
/** The lighting report as CSV: one row per area (name, luminaires, area, lumens, watts, W/m², lowest, average, highest lux, uniformity, checks) and then every grid point. Formula-looking names are quoted. */
export function lightingReportCsv(areas) {
  const f = (x, d = 1) => (Number.isFinite(x) ? x.toFixed(d) : '');
  const rows = [['area', 'luminaires', 'area_m2', 'lumens', 'watts', 'w_per_m2', 'min_lux', 'avg_lux', 'max_lux', 'min_over_avg', 'targets']];
  for (const a of areas) rows.push([a.name, a.luminaires, f(a.res.areaM2), f(a.res.lumens, 0), f(a.res.watts, 0), f(a.res.lpd, 2), f(a.res.stats.min), f(a.res.stats.avg), f(a.res.stats.max), f(a.res.stats.minAvg, 2),
    (a.checks || []).map((c) => c.key + (c.ok ? ' ok' : ' FAILS')).join('; ')]);
  rows.push([], ['area', 'x_mm', 'y_mm', 'z_mm', 'lux']);
  for (const a of areas) a.res.points.forEach((p, i) => rows.push([a.name, ...p.map((x) => Math.round(x)), f(a.res.values[i])]));
  return rows.map((r) => r.map(q).join(',')).join('\n') + '\n';
}
addons/lighting/main.js CadCraft 0.8.2 64-bit 2 calculations Reviewed

Tests None found by the review

Inputs of an illuminance run

64-bit

The mounting height, working height, grid spacing and light loss factor asked for a run.

mount ≥ 100 mm; work ≥ 0; spacing ≥ 50 mm; 0.05 ≤ LLF ≤ 1

A typed value below its minimum is replaced by the previous setting. The light loss factor is capped at 1.

  • addons/lighting/main.js
  • lines 318 to 322
  • CadCraft 0.8.2
const ask = async (key, def, min) => { const v = Number(await c.number(t(key), { default: def })); return Number.isFinite(v) && v >= min ? v : def; };
const mountMm = await ask('lux.mount', settings.mountMm, 100), workMm = await ask('lux.work', settings.workMm, 0);
const spacingMm = await ask('lux.spacing', settings.spacingMm, 50), llf = await ask('lux.llf', settings.llf, 0.05);
Object.assign(settings, { mountMm, workMm, spacingMm, llf: Math.min(llf, 1) }); save();
const r = runLux({ fixtures, library: lib, polygon: areaOf(pts, k), mountMm, workMm, spacingMm, llf: Math.min(llf, 1) });

Result printed in the command line

64-bit

The summary line of an illuminance run.

min, avg, max: whole lux; uniformity 2 decimals; area and W/m² 1 decimal

The printed figures are rounded for reading; the values drawn and exported come from the unrounded result.

  • addons/lighting/main.js
  • lines 328 to 329
  • CadCraft 0.8.2
c.print(t('lux.result', { n: r.luminaires, points: s.n, min: Math.round(s.min), avg: Math.round(s.avg), max: Math.round(s.max), uni: s.minAvg.toFixed(2) }));
c.print(t('lux.lpd', { lumens: Math.round(r.res.lumens), watts: Math.round(r.res.watts), area: r.res.areaM2.toFixed(1), lpd: r.res.lpd.toFixed(1) }));

In detail

Method
Inverse-square cosine law, E = I(γ, C) × cos θ ÷ d², summed over the luminaires and multiplied by the light loss factor; bilinear interpolation of the candela table; cell-center grid inside the polygon; shoelace area.
Number types
64-bit numbers; candela tables are plain arrays read with Number().
Tolerances
A point within 1 mm of a luminaire is skipped (d² < 1e-6 m²); candela is 0 beyond the last measured vertical angle when that is under 179.99°; horizontal angles are keyed to 6 decimals when mirrored; at most 40 000 grid points.
Checked by
tools/bimcraft-test/eg-mep-test.mjs runs BimCraft's mep/photometry.js, which was byte-identical to this file at the review. It compares the illuminance straight below a 1000 lm Lambertian source at 3 m with 1000 ÷ π ÷ 9 lux, an off-axis point with I cos θ ÷ d², the light loss factor scaling, and a surface facing away with 0.
Limits
No inter-reflection and no shadowing. Type B and type A IES files are refused. The grid spacing used is the extent divided by a whole number of cells, not exactly the typed spacing.

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