Sun position and light on a tilted plane for solar
Gives the sun's direction for each solar hour and the direct, sky and ground-reflected light falling on a module plane, from a clear-sky model or typed monthly totals.
How the precision is kept
The arithmetic is 64-bit, but the result is set by published empirical correlations with rounded coefficients and by using one representative day per month with one value per hour. Typed monthly totals are spread over the day and rescaled so that the hours add up to the typed total.
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.
The north and east unit vectors in the model's plan.
north = n ÷ |n|; east = (north_y, −northₓ)
The site's north vector is normalized with Math.hypot; a vector shorter than 1e-9 falls back to +Y.
pv-sun.js
lines 25 to 31
BimCraft 0.8.1
/** The site's compass frame in the model's plan: north (unit, default +Y) and east (to the right of north). */
export function frameOf(site) {
const n = site?.north && Math.hypot(site.north[0], site.north[1]) > 1e-9 ? site.north : [0, 1];
const l = Math.hypot(n[0], n[1]);
const north = [n[0] / l, n[1] / l];
return { north, east: [north[1], -north[0]] };
}
Clock hour for a solar hour
64-bit
The clock time at which the sun's time is a given hour.
clock = solar − (equation of time + 4 × longitude − 60 × time zone) ÷ 60
The equation of time depends on the clock time, so the expression is applied twice, starting from the solar hour. There is no convergence test; the loop always runs 2 passes.
pv-sun.js
lines 33 to 41
BimCraft 0.8.1
/** The clock hour (the sun.js argument) at which solar time is `solarHour`: clock = solar − (equation of time + 4 lon − 60 tz) / 60. */
export function solarToClock(site, month, day, solarHour, tz = Math.round(site.lon / 15)) {
let clock = solarHour;
for (let k = 0; k < 2; k++) {
const s = sunPosition({ month, day, hour: clock, lat: site.lat, lon: site.lon, tz });
clock = solarHour - (s.eqtime + 4 * site.lon - 60 * tz) / 60;
}
return clock;
}
Sun direction in model axes
64-bit
The sun's altitude, azimuth and direction for a solar hour, turned to the model's north.
dir = east × dₓ + north × d_y, with d_z unchanged; ω = 15° × (solar hour − 12)
The direction from the sun library is rotated into the model's plan with the site's north and east vectors. The year defaults to 2026. Returns nothing when the altitude is not above 0.
pv-sun.js
lines 42 to 49
BimCraft 0.8.1
/** The sun at solar hour `h` of a day, in the model's axes (its north from site.north); null when it is not up. */
export function sunAt(site, month, day, solarHour, { tz = Math.round(site.lon / 15), year = 2026 } = {}) {
const s = sunPosition({ month, day, hour: solarToClock(site, month, day, solarHour, tz), lat: site.lat, lon: site.lon, tz, year });
if (!(s.altitude > 0)) return null;
const F = frameOf(site);
const dir = [F.east[0] * s.dir[0] + F.north[0] * s.dir[1], F.east[1] * s.dir[0] + F.north[1] * s.dir[1], s.dir[2]];
return { alt: s.altitude, az: s.azimuth, dir, cosZ: s.dir[2], omega: 15 * (solarHour - 12), decl: s.declination, n: dayOfYear(month, day, year) };
}
Daily extraterrestrial radiation
64-bit
The energy on a horizontal plane above the atmosphere in one day.
δ = 23.45° × sin(2π(284 + n) ÷ 365); ω_s = acos(−tan φ · tan δ); H₀ = 24 ÷ π × G_on ÷ 1000 × (cos φ cos δ sin ω_s + ω_s sin φ sin δ)
The product of tangents is clamped to [−1, 1] before Math.acos, which covers polar day and night. The declination and the 0.033 orbit factor are the textbook approximations with rounded coefficients, and the year is taken as 365 days.
A cubic polynomial with the published 4-digit coefficients, evaluated term by term rather than in nested form. The correlation's own scatter is far larger than the arithmetic's rounding.
The share of a day's total and diffuse light that falls in one hour.
r_d = π ÷ 24 × (cos ω − cos ω_s) ÷ (sin ω_s − ω_s cos ω_s); r_t = (a + b cos ω) × r_d
The denominator sin ω_s − ω_s cos ω_s subtracts nearly equal numbers for very short days; a value of 1e-9 or less returns zero shares. The coefficients a and b use the published 4-digit constants.
pv-sun.js
lines 61 to 69
BimCraft 0.8.1
/** Collares-Pereira & Rabl's share of the day's total falling in an hour centred on hour angle ω (deg): { rt (total), rd (diffuse) }. */
function cpr(omegaDeg, wsDeg) {
const w = omegaDeg * RAD, ws = wsDeg * RAD, den = Math.sin(ws) - ws * Math.cos(ws);
if (!(den > 1e-9)) return { rt: 0, rd: 0 };
const a = 0.409 + 0.5016 * Math.sin(ws - 60 * RAD), b = 0.6609 - 0.4767 * Math.sin(ws - 60 * RAD);
const top = Math.cos(w) - Math.cos(ws);
if (top <= 0) return { rt: 0, rd: 0 };
return { rt: Math.max(0, Math.PI / 24 * (a + b * Math.cos(w)) * top / den), rd: Math.PI / 24 * top / den };
}
Hourly light from a typed monthly total
64-bit
Spreads a typed daily total over the hours and splits it into diffuse and direct light.
I = H × r_t ÷ Σ r_t; I_d = min(I, H_d × r_d ÷ Σ r_d); DNI = min((GHI − DHI) ÷ cos z, 0.8 × G_on)
Hourly shares are divided by their own sum, so the hours add up to the typed total to 64-bit rounding. Direct light divides by the cosine of the zenith angle only when it is above 0.05 and is capped at 0.8 of the extraterrestrial value.
pv-sun.js
lines 86 to 96
BimCraft 0.8.1
const phi = site.lat * RAD, dec = up[0].sun.decl * RAD, wsDeg = Math.acos(clamp(-Math.tan(phi) * Math.tan(dec), -1, 1)) / RAD;
const H0 = dailyExtraterrestrial(site.lat, n), kt = H / H0;
if (kt > 0.85) problems.push({ code: 'sky.kt', month, kt: Math.round(kt * 100) / 100 });
const fd = clamp(monthlyDiffuseFraction(Math.min(kt, 0.85), wsDeg), 0.05, 1), Hd = fd * H;
let st = 0, sd = 0;
const parts = up.map((x) => { const c = cpr(x.sun.omega, wsDeg); st += c.rt; sd += c.rd; return c; });
up.forEach((x, i) => {
const I = st > 0 ? H * parts[i].rt / st : 0, Id = sd > 0 ? Math.min(I, Hd * parts[i].rd / sd) : 0; // kWh/m² in the hour = kW/m² → W/m² × 1000
x.ghi = I * 1000; x.dhi = Id * 1000;
x.dni = x.sun.cosZ > 0.05 ? Math.min((x.ghi - x.dhi) / x.sun.cosZ, 0.8 * GSC * (1 + 0.033 * Math.cos(2 * Math.PI * n / 365))) : 0;
});
Light on a tilted plane
64-bit
The direct, sky and ground-reflected light on a module plane in one hour.
beam = DNI × cos θ; sky = DHI × (1 + cos β) ÷ 2; ground = GHI × ρ × (1 − cos β) ÷ 2; IAM = max(0, 1 − b₀(1 ÷ cos θ − 1))
cos θ is a dot product of the sun direction and the plane's normal. The 1 ÷ cos θ term is evaluated only for cos θ > 0 and the modifier is clamped at 0, so grazing light cannot go negative. For a nearly horizontal plane 1 − cos β subtracts nearly equal numbers; the ground term it scales is then very small.
pv-sun.js
lines 110 to 120
BimCraft 0.8.1
/** The light on a plane with unit normal `normal` (model axes) in an hour: { beam, sky, ground, total, cosI, iam } W/m². Needs irr.sun. */
export function poa(irr, normal, { albedo = 0.2, b0 = 0.05 } = {}) {
const s = irr.sun;
if (!s) return { beam: 0, sky: 0, ground: 0, total: 0, cosI: 0, iam: 0 };
const cosI = s.dir[0] * normal[0] + s.dir[1] * normal[1] + s.dir[2] * normal[2];
const cosB = normal[2];
const iam = cosI > 0 ? Math.max(0, 1 - b0 * (1 / cosI - 1)) : 0;
const beamRaw = cosI > 0 ? irr.dni * cosI : 0;
const skyD = irr.dhi * (1 + cosB) / 2, ground = irr.ghi * albedo * (1 - cosB) / 2;
return { beam: beamRaw, beamIam: beamRaw * iam, sky: skyD, ground, total: beamRaw * iam + skyD + ground, totalNoIam: beamRaw + skyD + ground, cosI: Math.max(0, cosI), iam };
}
In detail
Method
Sun position from render/sun.js; representative days of Klein 1977; extraterrestrial radiation (Duffie and Beckman eq. 1.10.3); diffuse fraction of Erbs, Klein and Duffie 1982; hourly shares of Collares-Pereira and Rabl 1979; Liu and Jordan isotropic sky; incidence-angle modifier 1 − b₀(1 ÷ cos θ − 1). All as cited in the file.
Number types
64-bit numbers; angles in degrees converted with π ÷ 180.
Tolerances
Solar-to-clock time uses 2 fixed-point passes. Direct light is computed only when cos(zenith) > 0.05 and is capped at 0.8 × the extraterrestrial value. Clearness index capped at 0.85 for the diffuse split; diffuse fraction clamped to 0.05 … 1. A typed daily total must be 0 to 12 kWh/m².
Limits
Solar constant 1367 W/m² (from daylight-bim-sky.js); declination 23.45° × sin(…); orbit factor 0.033; February counted as 28.25 days. The time zone defaults to the longitude ÷ 15 rounded to a whole hour.
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.