Resources Numerical accuracy Calculation policy

Solar array shading

Works out, for each hour of each month's representative day, the share of a solar array's sample points that the model shades from the direct sun, and the share of the beam light lost.

How the precision is kept

A ray is cast from each sample point toward the sun and tested against the model's triangles in 64-bit. The sampling sets the accuracy: up to four by four points per module (two by two by default), every k-th module beyond 400, one day per month and one sun position per hour. A point is either shaded or not; the shadow edge inside a module is not followed.

Calculations under this policy

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

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

Is a ray to the sun blocked

64-bit

Whether the line from a point toward the sun meets a triangle of the model.

det = e₁ · (d × e₂); u = (t · (d × e₂)) ÷ det; v = (d · (t × e₁)) ÷ det; hit if 0 ≤ u, 0 ≤ v, u + v ≤ 1 and distance > tMin

64-bit cross and dot products. The parallel test |det| < 1e-12 is absolute, while det scales with the product of the two edge lengths in millimeters. The limits on u and v have no tolerance.

  • pv-shade.js
  • lines 33 to 62
  • BimCraft 0.8.1
/** Does the ray from `o` along unit `d` meet a triangle of `obs` (not owned by `skip`)? */
export function blocked(obs, o, d, skip = null, tMin = 1) {
  const inv = [1 / (Math.abs(d[0]) < EPS ? EPS : d[0]), 1 / (Math.abs(d[1]) < EPS ? EPS : d[1]), 1 / (Math.abs(d[2]) < EPS ? EPS : d[2])];
  const T = obs.tri, lo = obs.lo, hi = obs.hi;
  for (let k = 0; k < obs.count; k++) {
    if (skip !== null && obs.owner[k] === skip) continue;
    const j = k * 3;
    let t0 = 0, t1 = Infinity;
    for (let a = 0; a < 3; a++) {
      let ta = (lo[j + a] - o[a]) * inv[a], tb = (hi[j + a] - o[a]) * inv[a];
      if (ta > tb) { const x = ta; ta = tb; tb = x; }
      if (ta > t0) t0 = ta;
      if (tb < t1) t1 = tb;
      if (t0 > t1) break;
    }
    if (t0 > t1) continue;
    const i = k * 9;
    const e1x = T[i + 3] - T[i], e1y = T[i + 4] - T[i + 1], e1z = T[i + 5] - T[i + 2], e2x = T[i + 6] - T[i], e2y = T[i + 7] - T[i + 1], e2z = T[i + 8] - T[i + 2];
    const px = d[1] * e2z - d[2] * e2y, py = d[2] * e2x - d[0] * e2z, pz = d[0] * e2y - d[1] * e2x, det = e1x * px + e1y * py + e1z * pz;
    if (Math.abs(det) < 1e-12) continue;
    const id = 1 / det, tx = o[0] - T[i], ty = o[1] - T[i + 1], tz = o[2] - T[i + 2];
    const u = (tx * px + ty * py + tz * pz) * id;
    if (u < 0 || u > 1) continue;
    const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
    const v = (d[0] * qx + d[1] * qy + d[2] * qz) * id;
    if (v < 0 || u + v > 1) continue;
    if ((e2x * qx + e2y * qy + e2z * qz) * id > tMin) return true;
  }
  return false;
}

Sample points on the modules

64-bit

The points on each module from which rays are cast.

p = bilinear blend of the four corners at ((a + ½) ÷ k, (b + ½) ÷ k) + normal × lift

64-bit. The number of points per side is cut to a whole number and kept between 1 and 4; with more modules than maxModules only every k-th module is sampled.

  • pv-shade.js
  • lines 64 to 80
  • BimCraft 0.8.1
/**
 * Sample points of an array's modules. opts: perModule (points along each side, 2), maxModules (every k-th module is sampled past this, 400), lift (mm above the plane, 20).
 * → [[x, y, z]…] in mm.
 */
export function samplePoints(modules, normal, { perModule = 2, maxModules = 400, lift = 20 } = {}) {
  const stride = Math.max(1, Math.ceil(modules.length / maxModules)), pts = [];
  const k = Math.max(1, Math.min(4, perModule | 0));
  for (let m = 0; m < modules.length; m += stride) {
    const c = modules[m].corners;
    for (let a = 0; a < k; a++) for (let b = 0; b < k; b++) {
      const s = (a + 0.5) / k, t = (b + 0.5) / k;
      const p = [0, 1, 2].map((q) => c[0][q] * (1 - s) * (1 - t) + c[1][q] * s * (1 - t) + c[2][q] * s * t + c[3][q] * (1 - s) * t);
      pts.push([p[0] + normal[0] * lift, p[1] + normal[1] * lift, p[2] + normal[2] * lift]);
    }
  }
  return { pts, stride };
}

Shade fraction by month and hour

64-bit

The share of the sample points in shadow at each of 12 × 24 sun positions.

frac = shaded points ÷ points

A count divided by a count; null when the sun is down or behind the module plane. Triangles that lie wholly more than 1 mm below the lowest sample point are dropped before the rays are cast.

  • pv-shade.js
  • lines 82 to 107
  • BimCraft 0.8.1
export function shadeGrid(modules, normal, sunGrid, obs, { owner = null, perModule = 2, maxModules = 400, lift = 20 } = {}) {
  const { pts } = samplePoints(modules, normal, { perModule: modules.length * perModule * perModule > maxModules * 4 ? 1 : perModule, maxModules, lift });
  // triangles that can never be hit: wholly below the lowest sample point (the ray only climbs)
  let zmin = Infinity;
  for (const p of pts) zmin = Math.min(zmin, p[2]);
  const keep = { tri: [], owner: [], lo: [], hi: [], count: 0 };
  for (let k = 0; k < obs.count; k++) {
    if (obs.hi[k * 3 + 2] < zmin - 1) continue;
    for (let q = 0; q < 9; q++) keep.tri.push(obs.tri[k * 9 + q]);
    keep.owner.push(obs.owner[k]);
    for (let q = 0; q < 3; q++) { keep.lo.push(obs.lo[k * 3 + q]); keep.hi.push(obs.hi[k * 3 + q]); }
    keep.count++;
  }
  const frac = [], stat = { samples: pts.length, rays: 0, triangles: keep.count };
  for (let m = 0; m < 12; m++) {
    frac.push([]);
    for (let h = 0; h < 24; h++) {
      const s = sunGrid[m]?.[h];
      if (!s || s.dir[0] * normal[0] + s.dir[1] * normal[1] + s.dir[2] * normal[2] <= 0) { frac[m].push(null); continue; }
      let n = 0;
      if (keep.count) for (const p of pts) { stat.rays++; if (blocked(keep, p, s.dir, owner)) n++; }
      frac[m].push(pts.length ? n / pts.length : 0);
    }
  }
  return { frac, ...stat };
}

Beam light lost to shadows

64-bit

The share of the direct sunlight the array loses to shading, over the year or chosen months.

loss = Σ beam × frac ÷ Σ beam

Two running 64-bit sums over the hours in order; 0 when there is no beam.

  • pv-shade.js
  • lines 109 to 117
  • BimCraft 0.8.1
/** Beam lost to shadows: Σ shade × beam / Σ beam over the hours given (beamGrid[m][h] = the beam an hour would have delivered; months: indexes, default all). */
export function beamLoss(frac, beamGrid, months = null) {
  let lost = 0, all = 0;
  for (let m = 0; m < 12; m++) {
    if (months && !months.includes(m)) continue;
    for (let h = 0; h < 24; h++) { const b = beamGrid[m]?.[h] || 0, f = frac[m]?.[h]; if (b > 0 && f !== null && f !== undefined) { lost += b * f; all += b; } }
  }
  return all > 0 ? lost / all : 0;
}

In detail

Method
Bounding-box slab test, then the ray-triangle intersection of Möller and Trumbore (1997), which the file cites. Shade fraction = shaded points ÷ points. Beam loss = Σ shade × beam ÷ Σ beam.
Number types
64-bit numbers in plain arrays, millimeters.
Tolerances
A direction component smaller than 1e-9 is replaced by 1e-9 in the box test; a determinant smaller than 1e-12 skips the triangle as parallel; a hit must be more than 1 mm along the ray; sample points are lifted 20 mm off the module plane. All absolute.
Limits
Diffuse light is not shaded. The file's header says a part-shaded module is counted as losing that share of its beam.

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