Resources Numerical accuracy Calculation policy

Solar arrays on the model

Reads the roof planes from the model, plans an array on a roof or the ground, stores it in the file, and combines shading over the modules for the yield.

How the precision is kept

Roof and obstruction triangles are read from the IFC library's mesh, whose vertex buffer is a Float32Array, so the roof planes, their areas and the module positions fitted to them start from 32-bit coordinates. The array is then stored as text with module centers rounded to 0.1 mm and its plane's axes to 6 decimals, and read back from that text.

Open item. Read roof and obstruction triangles from 64-bit vertex positions instead of the mesh's 32-bit buffer.

Calculations under this policy

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

pv-calc.js BimCraft 0.8.1 32-bit in part 7 calculations Reviewed

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

Site position and time zone

64-bit

The latitude, longitude, north direction and time zone used for the sun.

time zone = round(longitude ÷ 15) unless given

Latitude and longitude are taken as typed or as found in the model and must lie within ±90 and ±180. The default time zone is the longitude divided by 15 and rounded to a whole hour.

  • pv-calc.js
  • lines 34 to 39
  • BimCraft 0.8.1
export function pvSite(w, override = null) {
  const found = siteOf(w.api, w.mid), o = override || {};
  const lat = Number.isFinite(o.lat) ? o.lat : found?.lat, lon = Number.isFinite(o.lon) ? o.lon : found?.lon;
  if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
  return { lat, lon, north: o.north || found?.north || [0, 1], elevation: Number.isFinite(o.elevation) ? o.elevation : 0, tz: Number.isFinite(o.tz) ? o.tz : Math.round(lon / 15), fromModel: !!found && !Number.isFinite(o.lat) };
}

Model triangles in millimeters

32-bit in part

Every placed triangle of the model that can shade or carry modules.

mm = mesh position × unit in mm

The positions come from productTriangles in ifc-util.js, which reads the IFC library's vertex buffer; that buffer is a Float32Array (ifc/geo-mesh.js interleaved). Roof faces, their area, tilt and azimuth, and the modules fitted to them are computed from these 32-bit coordinates.

  • pv-calc.js
  • lines 41 to 63
  • BimCraft 0.8.1
/** Placed triangles (mm) of every product that can shade or carry modules, except the PV devices themselves. */
export function modelTriangles(w) {
  const api = w.api, mid = w.mid, mm = lengthUnitMm(api, mid), out = [];
  const roofParent = new Set(tryIds(api, mid, 'IFCROOF', true));
  const parent = new Map();
  for (const rid of tryIds(api, mid, 'IFCRELAGGREGATES')) { const r = api.GetLine(mid, rid, false); for (const h of r?.RelatedObjects || []) parent.set(h.value, r.RelatingObject?.value); }
  const pv = new Set(readArrays(w).map((a) => a.expressId));
  for (const id of tryIds(api, mid, 'IFCPRODUCT', true)) {
    if (pv.has(id)) continue;
    const l = api.GetLine(mid, id, false);
    const cls = String(l?.constructor?.name || '').toUpperCase();
    if (!l || !l.Representation || SKIP.test(cls)) continue;
    let t;
    try { t = productTriangles(api, mid, id, mm); } catch { continue; }
    if (!t.indices.length) continue;
    const tris = new Array(t.indices.length * 3);
    for (let i = 0; i < t.indices.length; i++) { const v = t.indices[i] * 3; tris[i * 3] = t.positions[v] * mm; tris[i * 3 + 1] = t.positions[v + 1] * mm; tris[i * 3 + 2] = t.positions[v + 2] * mm; }
    const pre = String(val(l.PredefinedType) ?? '');
    const roof = cls === 'IFCROOF' || roofParent.has(parent.get(id)) || (cls === 'IFCSLAB' && /ROOF/.test(pre));
    out.push({ id, cls, guid: val(l.GlobalId), roof, tris });
  }
  return out;
}

Array tilt and power

64-bit

The tilt, azimuth and peak power recorded for a planned array.

tilt = acos(clamp(n_z, −1, 1)) × 180 ÷ π; kWp = modules × Wp ÷ 1000

n_z is clamped before Math.acos. Tilt, azimuth and kWp are rounded to 0.01. For a roof array the tilt and azimuth are then replaced by the roof face's values, which were already rounded to 0.01°.

  • pv-calc.js
  • lines 77 to 84
  • BimCraft 0.8.1
function arrayOf(o) {
  const { module, fit, mount, site, name, faceId, orient, setback, gap, existing = [] } = o;
  const n = fit.n, ta = o.tilt !== undefined ? { tilt: o.tilt, azimuth: o.azimuth } : null;
  const hz = Math.hypot(n[0], n[1]);
  const tilt = ta ? ta.tilt : Math.acos(Math.max(-1, Math.min(1, n[2]))) * 180 / Math.PI;
  return { id: nextId(existing), name: name || null, mount, module, orient, tilt: r2(tilt), azimuth: ta ? ta.azimuth : (hz < 0.0087 ? null : r2(o.azimuth)), normal: n, u: fit.u, v: fit.v, across: fit.across, along: fit.along, setback, gap, faceId: faceId || null,
    tables: fit.tables || null, modules: fit.modules, kwp: r2(fit.modules.length * module.wp / 1000), lat: site.lat };
}

Array definition stored in the file

64-bit

The text that records an array's plane, module size and module centers.

normal, u, v: round to 1e-6; module centers and table corners: round to 0.1 mm

Module centers and table corners are rounded to 0.1 mm and the three direction vectors to 6 decimals before they are written as JSON. This text is what readArrays later rebuilds the array from.

  • pv-calc.js
  • lines 118 to 120
  • BimCraft 0.8.1
const defOf = (a) => JSON.stringify({ ver: 1, id: a.id, name: a.name, mount: a.mount, orient: a.orient, tilt: a.tilt, azimuth: a.azimuth, faceId: a.faceId, setback: a.setback, gap: a.gap, normal: a.normal.map((x) => Math.round(x * 1e6) / 1e6),
  u: a.u.map((x) => Math.round(x * 1e6) / 1e6), v: a.v.map((x) => Math.round(x * 1e6) / 1e6), across: a.across, along: a.along, module: a.module, rowPitchMm: a.rowPitchMm ?? null,
  tables: a.tables ? a.tables.map((t) => t.map((p) => p.map((x) => Math.round(x * 10) / 10))) : null, modules: a.modules.map((m) => [...m.c.map((x) => Math.round(x * 10) / 10), m.row, m.col, m.table ?? 0]) });

Arrays read back from the file

64-bit

Rebuilds each array's modules and corners from its stored definition.

corner = c ± u × across ÷ 2 ± v × along ÷ 2

Corners are recomputed from the 0.1 mm centers and the 6-decimal axis vectors, which are used as stored without normalizing. Peak power is recomputed and rounded to 0.01 kWp.

  • pv-calc.js
  • lines 122 to 138
  • BimCraft 0.8.1
export function readArrays(w) {
  const out = [];
  for (const cls of ['IFCSOLARDEVICE', 'IFCBUILDINGELEMENTPROXY']) {
    for (const id of tryIds(w.api, w.mid, cls)) {
      let ps = null;
      try { ps = w.readPset(id, PV_PSET); } catch { ps = null; }
      if (!ps || !ps.Definition) continue;
      let d;
      try { d = JSON.parse(String(ps.Definition)); } catch { continue; }
      if (!d || d.ver !== 1 || !Array.isArray(d.modules)) continue;
      const u = d.u, v = d.v, A = d.across, B = d.along;
      const mods = d.modules.map(([x, y, z, row, col, table]) => { const c = [x, y, z]; return { c, row, col, table, corners: corners(c, u, v, A, B) }; });
      out.push({ ...d, expressId: id, guid: w.guidOf(id), modules: mods, kwp: r2(mods.length * d.module.wp / 1000) });
    }
  }
  return out;
}

Module frames in the level's axes

64-bit

Turns module centers and the array's directions from model axes into the level's own axes for the stored solids.

point′ = S⁻¹ · p; direction′ = S⁻¹ · d − S⁻¹ · 0

A direction is transformed as a point and the transformed origin is subtracted, which removes the translation. For a level far from the origin that subtraction is of two large nearly equal numbers. The frame is then written with 12-decimal directions and 6-decimal positions.

  • pv-calc.js
  • lines 147 to 150
  • BimCraft 0.8.1
const Si = inv4(w.storeyWorld(st)), o0 = apply4(Si, 0, 0, 0);
const toL = (p) => apply4(Si, p[0], p[1], p[2]);
const dirL = (d) => { const q = apply4(Si, d[0], d[1], d[2]); return [q[0] - o0[0], q[1] - o0[1], q[2] - o0[2]]; };
const zL = dirL(A.normal), xL = dirL(A.u);

Shade fraction of an array

64-bit

The shaded share of each array for every month and hour.

fraction = Σ (table fraction × table samples) ÷ Σ samples

Each table's shade grid is weighted by its sample count and the sums are divided by the total, which is guarded against 0. Hours when the sun is down stay empty.

  • pv-calc.js
  • lines 185 to 202
  • BimCraft 0.8.1
/** The shade grid of each array: { [id]: frac[12][24] } and a summary. */
export function shadeArrays(arrays, months, items) {
  const obs = obstructions([...items, ...tablesAsItems(arrays)]), grid = sunGridOf(months), out = {}, info = {};
  for (const a of arrays) {
    const groups = new Map();
    for (const m of a.modules) { const t = m.table ?? 0; if (!groups.has(t)) groups.set(t, []); groups.get(t).push(m); }
    let acc = null, total = 0;
    for (const [t, mods] of groups) {
      const r = shadeGrid(mods, a.normal, grid, obs, { owner: a.tables ? a.id + ':' + t : null });
      if (!acc) acc = r.frac.map((row) => row.map((f) => (f === null ? null : 0)));
      for (let m = 0; m < 12; m++) for (let h = 0; h < 24; h++) if (r.frac[m][h] !== null) acc[m][h] += r.frac[m][h] * r.samples;
      total += r.samples;
    }
    out[a.id] = acc.map((row) => row.map((f) => (f === null ? null : f / (total || 1))));
    info[a.id] = { samples: total };
  }
  return { frac: out, info };
}

In detail

Method
Mesh triangles transformed to file units and millimeters; tilt = acos(n_z); module corners rebuilt as center ± half sizes along the stored axes; shade fractions averaged over tables weighted by sample count.
Number types
Float32Array vertex positions from the mesh (ifc/geo-mesh.js interleaved); 64-bit numbers after that; the stored definition is JSON text.
Tolerances
Stored definition: module centers and table corners 0.1 mm, normal and axes 1e-6, tilt and power 0.01. A normal with horizontal part under 0.0087 has no azimuth.
Limits
A 32-bit coordinate carries about 7 digits. Arrays read back from the file are rebuilt from the rounded definition, and its axis vectors are not normalized again.

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