Places solar tables on the ground inside a region, spaces each row so the row in front does not shade it at the winter sun, and reports table positions, pile heights, ground slope, row pitch and installed power.
How the precision is kept
The layout works in meters in 64-bit numbers and converts back to project units at the end. The result is set by the search, not by the number type: rows are tried at steps of 0.1 m by default, the ground under a table is read at 12 sample points, and the terrain horizon is read every 10 m along rays every 5 degrees. Installed power is rounded to 0.01 kWp. The sun position, module data and yield come from the shared PV files in pv/.
Open item. Scale the 1e-12 determinant test in the plane fit to the spread of the sample points.
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.
The slope of the ground under one table, from a plane fitted through its sample points.
least squares z = a + b·x + c·y on centered points; b = (Sxz·Syy − Syz·Sxy) ÷ d, c = (Syz·Sxx − Sxz·Sxy) ÷ d, d = Sxx·Syy − Sxy²
The points are centered on their mean before the sums are formed, which keeps the normal equations from losing digits to large coordinates. The determinant is treated as zero when below 1e-12, an absolute value in m⁴ that is not scaled to the table size; the slope is then reported as zero.
solar.js
lines 53 to 60
CivilCraft 0.8.1
/** Fit a plane z = a + bx + cy by least squares through samples [[x,y,z]]. → { gx, gy } the gradient. */
function gradient(pts) {
const n = pts.length; let sx = 0, sy = 0, sz = 0; for (const p of pts) { sx += p[0]; sy += p[1]; sz += p[2]; }
const mx = sx / n, my = sy / n, mz = sz / n; let xx = 0, xy = 0, yy = 0, xz = 0, yz = 0;
for (const p of pts) { const x = p[0] - mx, y = p[1] - my, z = p[2] - mz; xx += x * x; xy += x * y; yy += y * y; xz += x * z; yz += y * z; }
const d = xx * yy - xy * xy;
return Math.abs(d) < 1e-12 ? { gx: 0, gy: 0 } : { gx: (xz * yy - yz * xy) / d, gy: (yz * xx - xz * xy) / d };
}
Table size and the shadow rule
64-bit
The width, slant length, plan depth and height of one table, and how fast the winter shadow drops behind a row.
D = Ls·cos β; H = Ls·sin β; shadow drop per meter = tan(α) ÷ max(0.2, cos Δ)
64-bit trigonometry with π ÷ 180 for the degree conversion. Module sizes are in millimeters and are added before the division by 1000. The cosine of the azimuth difference is floored at 0.2, which caps the rule when the sun is far off the rows' normal.
solar.js
lines 86 to 92
CivilCraft 0.8.1
const across0 = o.orient === 'landscape' ? module.lengthMm : module.widthMm, along0 = o.orient === 'landscape' ? module.widthMm : module.lengthMm;
const A = (o.perRow * across0 + (o.perRow - 1) * o.gap * 1000) / 1000, Ls = (o.stack * along0 + (o.stack - 1) * o.gap * 1000) / 1000;
const D = Ls * Math.cos(o.tilt * RAD), H = Ls * Math.sin(o.tilt * RAD), tanB = Math.tan(o.tilt * RAD);
const win = winterPitch({ slantMm: Ls * 1000, tilt: o.tilt, azimuth: az, site, hours: o.hours });
if (win.error) return { ok: false, error: win.error, tables: [] };
const dAz = (win.sunAzimuth - az) * RAD, tanE = Math.tan(win.elevation * RAD) / Math.max(0.2, Math.cos(dAz)); // the shadow's drop per metre behind the row
const flatPitch = win.pitchMm / 1000;
One table on its piles
64-bit
Tests a table position: inside the region, off kept-out ground, not too steep, and how high its lower edge must stand.
z₀ = max over samples of (z_ground − dq·tan β + clearance); pile = z₀ − z_ground at the lower edge; slope = atan(|gradient|)
Twelve ground samples (3 across by 4 up the table). The table plane is raised until every sample is the clearance below it, so the pile height is exact for the samples and approximate for the ground between them. Corner coordinates are 64-bit sums in the row's frame.
solar.js
lines 105 to 125
CivilCraft 0.8.1
/** Try a table whose lower edge middle is at (s, q): → table | { skip: reason }. */
function tryTable(sc, q) {
const samples = [], plan = [];
for (const ds of [-A / 2, 0, A / 2]) for (const dq of [0, D / 3, (2 * D) / 3, D]) {
const p = at(sc + ds, q + dq);
if (!ok(p.x, p.y)) return { skip: 'outside' };
if (K.length && kept(p.x, p.y)) return { skip: 'keep' };
const z = zg(p.x, p.y);
if (z === null) return { skip: 'ground' };
samples.push([p.x, p.y, z]); plan.push(dq);
}
const g = gradient(samples), slopeDeg = Math.atan(Math.hypot(g.gx, g.gy)) / RAD;
if (slopeDeg > o.maxSlope) return { skip: 'steep' };
let z0 = -Infinity; samples.forEach((p, i) => { z0 = Math.max(z0, p[2] - plan[i] * tanB + o.clearance); });
const lower = samples.filter((_, i) => plan[i] === 0);
const pileMax = Math.max(...lower.map((p) => z0 - p[2]));
if (pileMax > o.maxPile) return { skip: 'pile' };
const c = (ds, dq, z) => { const p = at(sc + ds, q + dq); return [p.x, p.y, z]; };
const m = at(sc, q);
return { sc, q, z0, x: m.x, y: m.y, slopeDeg, pileMax, corners: [c(-A / 2, 0, z0), c(A / 2, 0, z0), c(A / 2, D, z0 + H), c(-A / 2, D, z0 + H)] };
}
Row spacing on the ground
64-bit
Steps back from each placed table to the first position where its shadow no longer reaches the next table's lower edge.
A search by repeated addition of the step (0.1 m by default), so each row's position, and the reported minimum, mean and maximum pitch, are resolved to the step and carry the small rounding of the repeated additions. Column positions also advance by repeated addition.
solar.js
lines 128 to 142
CivilCraft 0.8.1
for (let sc = s0 + A / 2 + o.setback * 0; sc <= s1 - A / 2 + 1e-9 && tables.length < o.maxTables; sc += A + o.colGap, col++) {
let q = q0, prev = null;
while (q <= q1 - D + 1e-9 && tables.length < o.maxTables) {
const r = tryTable(sc, q);
if (r.skip) { if (r.skip !== 'outside' && r.skip !== 'ground') skipped[r.skip]++; q += Math.max(o.step * 2.5, 0.25); continue; }
// the shadow of the table before it must not reach this lower edge
if (prev) {
const topZ = prev.z0 + H, qTop = prev.q + D;
if (q < qTop + 0.02 || r.z0 < topZ - (q - qTop) * tanE - 1e-6) { q += o.step; continue; }
pitchStats.push(q - prev.q);
}
tables.push({ id: 't' + (tables.length + 1), col, ...r }); prev = r;
q += D + 0.02;
}
}
Module count and installed power
64-bit
The number of modules and the installed power of the array.
The highest angle of the terrain seen from a point, in each 5 degrees of bearing.
elevation angle = max over d of atan2(z(d) − (z₀ + observer height), d)
64-bit, with Math.atan2. The terrain is read every 10 m by default out to the radius, and distances along a ray are built by repeated addition of the step, so a ridge between samples can be missed. Angles are kept unrounded; negative angles are reported as zero.
solar.js
lines 167 to 183
CivilCraft 0.8.1
/** The highest terrain angle in every 5° of bearing seen from `at` ({x, y} project units) standing `observer` metres above the ground there. */
export function horizonProfile(ground, at, { units = 'ft', radius = 1000, step = 10, observer = 1.5, site = null } = {}) {
const M = mPer(units), z0 = ground.elevationAt(at.x, at.y);
const F = frameOf(site || {}), out = { az: [], el: [] };
if (!fin(z0)) return out;
for (let a = 0; a < 360; a += 5) {
const r = a * RAD, dx = F.north[0] * Math.cos(r) + F.east[0] * Math.sin(r), dy = F.north[1] * Math.cos(r) + F.east[1] * Math.sin(r);
let best = 0;
for (let d = step; d <= radius; d += step) {
const z = ground.elevationAt(at.x + (dx * d) / M, at.y + (dy * d) / M);
if (!fin(z)) break;
best = Math.max(best, Math.atan2(z * M - (z0 * M + observer), d) / RAD);
}
out.az.push(a); out.el.push(best);
}
return out;
}
Hours hidden by the horizon
64-bit
Marks the hours of the year when the sun is below the terrain horizon as fully shaded before the yield is computed.
shaded when sun altitude < horizon elevation at round(azimuth ÷ 5) × 5 degrees
The horizon is looked up at the nearest 5 degree bin with no interpolation between bins. The array's center used for the horizon is the mean of the table positions, formed as a sum of x ÷ n terms.
solar.js
lines 197 to 204
CivilCraft 0.8.1
if (o.ground) {
const c = plan.tables.reduce((a, t) => ({ x: a.x + t.x / plan.tables.length, y: a.y + t.y / plan.tables.length }), { x: 0, y: 0 });
horizon = horizonProfile(o.ground, c, { units: plan.units, site, radius: o.horizonRadius || 1500 });
if (horizon.az.length) {
const hAt = (az) => horizon.el[Math.round((((az % 360) + 360) % 360) / 5) % 72];
for (let m = 0; m < 12; m++) for (let h = 0; h < 24; h++) { const s = sg[m]?.[h]; if (s && frac[m][h] !== null && s.alt < hAt(s.az)) frac[m][h] = 1; }
}
}
In detail
Method
No-shade row rule on real ground: the next table's lower edge must be at least as high as the previous table's top edge minus the distance behind it times tan(sun elevation) ÷ cos(azimuth difference); the file cites Duffie and Beckman, Solar Engineering of Thermal Processes. Ground slope from a least-squares plane through the samples.
Number types
64-bit numbers.
Tolerances
Row search step 0.1 m by default (0.02 to 1); a 0.02 m gap is kept between rows; setback test with 1e-9; shade test with 1e-6 m; cos(azimuth difference) floored at 0.2; least-squares determinant treated as zero below 1e-12 (absolute); loop ends with 1e-9.
Limits
Table positions are multiples of the search step from the region's edge, so the pitch is found to the step. Ground slope and pile height come from 12 samples per table. The horizon is the highest terrain angle in 5 degree bins out to a set radius.
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.