Resources Numerical accuracy Calculation policy

Solar array layout on roofs and ground

Finds the roof planes that can carry modules, fits modules inside a plane with a setback, and lays out ground rows with a spacing that avoids shade at the winter solstice.

How the precision is kept

Planes, module corners and row spacing are computed in 64-bit millimeters and degrees. Triangles are grouped into one roof face when their normals are within 0.5° and their planes within 30 mm, and a module fits when nine sample points of its setback rectangle lie inside the face. Reported tilt, azimuth, area, pitch and power are rounded to 2 decimals.

Calculations under this policy

8 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.js BimCraft 0.8.1 64-bit 8 calculations Reviewed

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

Normal of a tilted plane

64-bit

The unit normal of a plane from its tilt and compass bearing.

n = (h_x·sin β, h_y·sin β, cos β), h = north·cos az + east·sin az

Angles in degrees are multiplied by π ÷ 180. The result is a unit vector to 64-bit rounding as long as the site's north vector is one; frameOf normalizes it.

  • pv.js
  • lines 106 to 107
  • BimCraft 0.8.1
/** The unit normal (out of the plane) of a plane tilted `tilt` degrees from horizontal and facing compass bearing `az`. */
export function planeNormal(tilt, az, site) { const h = bearing(az, site), s = Math.sin(tilt * RAD); return [h[0] * s, h[1] * s, Math.cos(tilt * RAD)]; }

Tilt and azimuth of a normal

64-bit

The tilt from horizontal and the compass bearing a plane faces.

tilt = acos(clamp(n_z, −1, 1)); azimuth = (atan2(n·east, n·north) + 360) mod 360

n_z is clamped to [−1, 1] before Math.acos. For a nearly flat plane the arccosine of a value near 1 loses digits; planes under 0.5° are reported flat with no azimuth.

  • pv.js
  • lines 108 to 114
  • BimCraft 0.8.1
/** → { tilt, azimuth (0–360, null for a flat plane), flat } of a unit normal. */
export function tiltAzimuthOf(n, site) {
  const F = frameOf(site), tilt = Math.acos(Math.max(-1, Math.min(1, n[2]))) / RAD;
  if (tilt < 0.5) return { tilt, azimuth: null, flat: true };
  const east = n[0] * F.east[0] + n[1] * F.east[1], north = n[0] * F.north[0] + n[1] * F.north[1];
  return { tilt, azimuth: (Math.atan2(east, north) / RAD + 360) % 360, flat: false };
}

Axes of a plane from its normal

64-bit

The along-eave and up-slope directions of a roof face.

u = (−n_y, nₓ, 0) ÷ √(nₓ² + n_y²); v = n × u

The horizontal part of the normal is normalized with Math.hypot; below 0.0087 (about 0.5°) the plane is treated as flat with east and north as its axes.

  • pv.js
  • lines 123 to 129
  • BimCraft 0.8.1
/** basisOf from an exact unit normal (n × u gives the slope direction, v). */
export function basisOfNormal(n, site) {
  const F = frameOf(site), h = Math.hypot(n[0], n[1]);
  if (h < 0.0087) return { n: [0, 0, 1], u: [F.east[0], F.east[1], 0], v: [F.north[0], F.north[1], 0] };
  const u = [-n[1] / h, n[0] / h, 0];
  return { n, u, v: cross(n, u) };
}

Roof faces from triangles

64-bit

Groups upward triangles into planes and gives each plane's normal, tilt, azimuth, area and centroid.

n = (B − A) × (C − A) ÷ |…|; area = |…| ÷ 2; face normal = unit(Σ area·n); centroid = Σ area·mid ÷ Σ area

A triangle joins the first group whose first triangle's normal is within 0.5° and whose plane is within 30 mm, so grouping depends on triangle order. Areas are summed in order. Tilt, azimuth and area are rounded to 0.01 in the returned face.

  • pv.js
  • lines 132 to 153
  • BimCraft 0.8.1
/** Upward planes of placed triangles (flat [x0,y0,z0, x1,y1,z1, x2,y2,z2, …] in mm) → faces, biggest first. */
export function roofFaces(tris, site, { minAreaM2 = 0.5, maxTilt = 80, angleTol = 0.5, offsetTol = 30 } = {}) {
  const cosMax = Math.cos(maxTilt * RAD), cosTol = Math.cos(angleTol * RAD), clusters = [];
  for (let i = 0; i + 8 < tris.length; i += 9) {
    const A = [tris[i], tris[i + 1], tris[i + 2]], B = [tris[i + 3], tris[i + 4], tris[i + 5]], C = [tris[i + 6], tris[i + 7], tris[i + 8]];
    const c = cross(sub(B, A), sub(C, A)), l = len(c);
    if (l < 1e-6) continue;
    const n = [c[0] / l, c[1] / l, c[2] / l];
    if (n[2] < cosMax) continue;
    const mid = [(A[0] + B[0] + C[0]) / 3, (A[1] + B[1] + C[1]) / 3, (A[2] + B[2] + C[2]) / 3], d = dot(n, mid), area = l / 2;
    let k = null;
    for (const cl of clusters) if (dot(cl.n0, n) >= cosTol && Math.abs(dot(cl.n0, mid) - dot(cl.n0, cl.m0)) <= offsetTol) { k = cl; break; }
    if (!k) { k = { n0: n, m0: mid, nsum: [0, 0, 0], msum: [0, 0, 0], area: 0, tris: [] }; clusters.push(k); }
    k.nsum = add(k.nsum, n, area); k.msum = add(k.msum, mid, area); k.area += area; k.tris.push(...A, ...B, ...C);
    void d;
  }
  const faces = clusters.filter((k) => k.area / 1e6 >= minAreaM2).sort((a, b) => b.area - a.area).map((k, i) => {
    const normal = unit(k.nsum), ta = tiltAzimuthOf(normal, site);
    return { id: 'F' + (i + 1), normal, basis: basisOfNormal(normal, site), tilt: r2(ta.tilt), azimuth: ta.azimuth === null ? null : r2(ta.azimuth), flat: ta.flat, areaM2: r2(k.area / 1e6), centroid: [k.msum[0] / k.area, k.msum[1] / k.area, k.msum[2] / k.area], tris: k.tris };
  });
  return faces;
}

Point inside the face's triangles

64-bit

Tests whether a point in the face's own axes lies in any of its triangles.

barycentric l₁, l₂, l₃ ≥ −eps ÷ √|d|

Barycentric coordinates by division by the triangle's doubled area d; triangles with |d| < 1e-9 mm² are skipped. The slack of 1 mm is scaled by √|d|, so it is approximate rather than a true 1 mm distance.

  • pv.js
  • lines 155 to 166
  • BimCraft 0.8.1
/** Is (s, t) inside any of the 2-D triangles (flat [s0,t0,s1,t1,s2,t2, …])? `eps` mm of slack so a seam between two triangles is not a gap. */
function inUnion(T2, s, t, eps = 1) {
  for (let i = 0; i + 5 < T2.length; i += 6) {
    const ax = T2[i], ay = T2[i + 1], bx = T2[i + 2], by = T2[i + 3], cx = T2[i + 4], cy = T2[i + 5];
    const d = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
    if (Math.abs(d) < 1e-9) continue;
    const l1 = ((s - ax) * (cy - ay) - (cx - ax) * (t - ay)) / d, l2 = ((bx - ax) * (t - ay) - (s - ax) * (by - ay)) / d, l3 = 1 - l1 - l2;
    const e = eps / Math.sqrt(Math.abs(d));
    if (l1 >= -e && l2 >= -e && l3 >= -e) return true;
  }
  return false;
}

Modules fitted in a roof face

64-bit

The positions and corners of the modules that fit inside a face with a setback.

position = (s₀ + col × (across + gap), t₀ + row × (along + gap)); corner = centroid + s·u + t·v

Triangle corners are projected on the face's axes after the centroid is subtracted. Positions are index × step, not accumulated sums. A module is kept when nine sample points of its rectangle grown by the setback are inside the face; of 16 grid offsets the one with most modules wins.

  • pv.js
  • lines 168 to 198
  • BimCraft 0.8.1
/**
 * Modules inside a roof face. opts: { orient 'portrait' | 'landscape', setback mm (300), gap mm (20), max (count cap), exclude: [[s0,t0,s1,t1]…] none yet }.
 * → { modules: [{ c: [x,y,z] centre, row, col, corners: 4 × [x,y,z] }], count, kwp, u, v, n, setbackMm, across, along }.
 */
export function fitFace(face, module, { orient = 'portrait', setback = 300, gap = 20, max = 100000 } = {}) {
  const basis = face.basis;
  const p0 = face.centroid, T = face.tris, T2 = new Float64Array(T.length / 3 * 2);
  let smin = Infinity, smax = -Infinity, tmin = Infinity, tmax = -Infinity;
  for (let i = 0, j = 0; i < T.length; i += 3, j += 2) {
    const q = [T[i] - p0[0], T[i + 1] - p0[1], T[i + 2] - p0[2]], s = dot(q, basis.u), t = dot(q, basis.v);
    T2[j] = s; T2[j + 1] = t; smin = Math.min(smin, s); smax = Math.max(smax, s); tmin = Math.min(tmin, t); tmax = Math.max(tmax, t);
  }
  const across = orient === 'landscape' ? module.lengthMm : module.widthMm, along = orient === 'landscape' ? module.widthMm : module.lengthMm;
  const du = across + gap, dv = along + gap;
  let best = null;
  for (let a = 0; a < 4; a++) for (let b = 0; b < 4; b++) {
    const s0 = smin + (a * du) / 4, t0 = tmin + (b * dv) / 4, list = [];
    for (let row = 0; t0 + row * dv + along <= tmax + 1; row++) for (let col = 0; s0 + col * du + across <= smax + 1; col++) {
      const sa = s0 + col * du, ta = t0 + row * dv;
      let ok = true;
      for (const fx of SAMPLE) { for (const fy of SAMPLE) { if (!inUnion(T2, sa - setback + fx * (across + 2 * setback), ta - setback + fy * (along + 2 * setback))) { ok = false; break; } } if (!ok) break; }
      if (ok) list.push([row, col, sa, ta]);
    }
    if (!best || list.length > best.length) best = list;
  }
  const modules = best.slice(0, max).map(([row, col, sa, ta]) => {
    const corner = (s, t) => add(add(p0, basis.u, s), basis.v, t);
    return { row, col, c: corner(sa + across / 2, ta + along / 2), corners: [corner(sa, ta), corner(sa + across, ta), corner(sa + across, ta + along), corner(sa, ta + along)] };
  });
  return { modules, count: modules.length, kwp: r2(modules.length * module.wp / 1000), u: basis.u, v: basis.v, n: basis.n, setbackMm: setback, across, along };
}

Row spacing without winter shade

64-bit

The distance between ground rows that leaves no shade at the winter solstice.

h = slant × sin β; shadow = h ÷ tan α × max(0, cos Δaz); pitch = slant × cos β + shadow

The sun is taken on December 21 in the northern hemisphere and June 21 in the southern, at solar noon or a given number of hours either side. A sun altitude of 1° or less returns an error, which keeps tan α away from 0.

  • pv.js
  • lines 201 to 214
  • BimCraft 0.8.1
/** The row spacing that leaves no shade at the winter-solstice sun `hours` from solar noon. → { pitchMm, shadowMm, heightMm, elevation, sunAzimuth, error? } */
export function winterPitch({ slantMm, tilt, azimuth, site, hours = 0 }) {
  const lat = site.lat, month = lat >= 0 ? 12 : 6, day = 21, tz = Number.isFinite(site.tz) ? site.tz : Math.round(site.lon / 15);
  const sunAt = (dt) => sunPosition({ month, day, hour: solarToClock(site, month, day, 12 + dt, tz), lat, lon: site.lon, tz });
  let worst = null;
  for (const dt of hours ? [-Math.abs(hours), Math.abs(hours)] : [0]) {
    const s = sunAt(dt);
    if (s.altitude <= 1) return { error: 'pv.err.polar' };
    const dAz = (s.azimuth - azimuth) * RAD, h = slantMm * Math.sin(tilt * RAD);
    const shadow = h / Math.tan(s.altitude * RAD) * Math.max(0, Math.cos(dAz));
    if (!worst || shadow > worst.shadowMm) worst = { shadowMm: shadow, heightMm: h, elevation: s.altitude, sunAzimuth: s.azimuth };
  }
  return { ...worst, pitchMm: slantMm * Math.cos(tilt * RAD) + worst.shadowMm };
}

Ground rows of tables

64-bit

The tables and modules of a ground array with their corners, the pitch and the ground cover ratio.

table width = n × across + (n − 1) × gap; row origin = origin − facing × pitch × r; GCR = slant ÷ pitch

Row origins are origin − direction × pitch × r, a product rather than a running sum. The counts per row, stack and rows are truncated with a bitwise OR 0, which is used on whole-number counts only. Pitch, shadow and GCR are rounded to 0.01 in the result; the corners use the unrounded pitch.

  • pv.js
  • lines 215 to 242
  • BimCraft 0.8.1
/**
 * Rows of tables on the ground (or a flat roof). opts: { site, module, orient, perRow (modules across a table), stack (modules up the slope, 1),
 * rows, tilt, azimuth, origin [x,y,z] (mm: the middle of the first table's lower edge, on the ground), clearance (500 mm), gap (20),
 * pitchMm (null = from the winter rule), hours (0), contains(x, y) (a table is kept only when its four lower corners are inside) }.
 * → { modules, tables: [{ row, corners }], pitchMm, shadowMm, gcr (slant ÷ pitch), count, kwp, rule }.
 */
export function groundLayout(o) {
  const { site, module } = o, tilt = o.tilt ?? 30, azimuth = o.azimuth ?? (site.lat >= 0 ? 180 : 0), orient = o.orient || 'portrait';
  const perRow = Math.max(1, o.perRow | 0), stack = Math.max(1, o.stack | 0 || 1), rows = Math.max(1, o.rows | 0), gap = o.gap ?? 20, clearance = o.clearance ?? 500;
  const across = orient === 'landscape' ? module.lengthMm : module.widthMm, along = orient === 'landscape' ? module.widthMm : module.lengthMm;
  const tableAcross = perRow * across + (perRow - 1) * gap, slant = stack * along + (stack - 1) * gap;
  const B = basisOf(tilt, azimuth, site), nh = bearing(azimuth, site);
  const rule = o.pitchMm > 0 ? { pitchMm: o.pitchMm, shadowMm: o.pitchMm - slant * Math.cos(tilt * RAD), manual: true } : winterPitch({ slantMm: slant, tilt, azimuth, site, hours: o.hours || 0 });
  if (rule.error) return { error: rule.error };
  const pitch = rule.pitchMm, org = o.origin || [0, 0, 0], modules = [], tables = [];
  for (let r = 0; r < rows; r++) {
    const o0 = [org[0] - nh[0] * pitch * r, org[1] - nh[1] * pitch * r, org[2] + clearance];
    const P = (s, t) => add(add(o0, B.u, s), B.v, t);
    const corners = [P(-tableAcross / 2, 0), P(tableAcross / 2, 0), P(tableAcross / 2, slant), P(-tableAcross / 2, slant)];
    if (o.contains && !corners.slice(0, 2).concat([[o0[0], o0[1], 0]]).every((q) => o.contains(q[0], q[1]))) continue;
    tables.push({ row: r, corners });
    for (let k = 0; k < stack; k++) for (let c = 0; c < perRow; c++) {
      const s0 = -tableAcross / 2 + c * (across + gap), t0 = k * (along + gap);
      modules.push({ row: r * stack + k, col: c, table: r, c: P(s0 + across / 2, t0 + along / 2), corners: [P(s0, t0), P(s0 + across, t0), P(s0 + across, t0 + along), P(s0, t0 + along)] });
    }
  }
  return { modules, tables, pitchMm: r2(pitch), shadowMm: r2(rule.shadowMm), gcr: r2(slant * 1 / pitch), count: modules.length, kwp: r2(modules.length * module.wp / 1000), rule, u: B.u, v: B.v, n: B.n, across, along, tilt, azimuth };
}

In detail

Method
Triangle normals by cross product; area-weighted mean normal per face; barycentric point-in-triangle tests; grid of module positions tried at 4 × 4 offsets; row pitch = slant × cos(tilt) + height ÷ tan(sun altitude) × cos(azimuth difference). The file's header cites Duffie and Beckman for the spacing rule.
Number types
64-bit numbers; Float64Array for the face's triangles in its own axes.
Tolerances
Face grouping 0.5° and 30 mm, compared with the first triangle of the group. Triangles with a cross product shorter than 1e-6 mm² are skipped. A plane under 0.5° of tilt is flat. Inside test slack 1 mm. Minimum face 0.5 m². Sun altitude must be above 1°.
Limits
The nine-point test can accept a module that crosses a narrow notch in the face. The tilt and azimuth of a face are rounded to 0.01° before they are passed on.

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