Resources Numerical accuracy Calculation policy

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.

pv-sun.js BimCraft 0.8.1 64-bit 8 calculations Reviewed

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

Compass frame of the site

64-bit

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.

  • pv-sun.js
  • lines 51 to 57
  • BimCraft 0.8.1
/** Daily extraterrestrial radiation on a horizontal plane, kWh/m²/day (D&B eq. 1.10.3), day-of-year n. */
export function dailyExtraterrestrial(lat, n) {
  const phi = lat * RAD, dec = 23.45 * Math.sin(2 * Math.PI * (284 + n) / 365) * RAD;
  const ws = Math.acos(clamp(-Math.tan(phi) * Math.tan(dec), -1, 1));
  const gon = GSC * (1 + 0.033 * Math.cos(2 * Math.PI * n / 365));
  return 24 / Math.PI * gon / 1000 * (Math.cos(phi) * Math.cos(dec) * Math.sin(ws) + ws * Math.sin(phi) * Math.sin(dec));
}

Monthly diffuse fraction

64-bit

The share of a month's daily light that is diffuse, from the clearness index.

ω_s ≤ 81.4°: 1.391 − 3.56 k + 4.189 k² − 2.137 k³; else 1.311 − 3.022 k + 3.427 k² − 1.821 k³

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.

  • pv-sun.js
  • lines 58 to 59
  • BimCraft 0.8.1
/** Hd/H for a month's mean clearness index kt (Erbs et al. 1982, monthly form). wsDeg: the sunset hour angle. */
export const monthlyDiffuseFraction = (kt, wsDeg) => (wsDeg <= 81.4 ? 1.391 - 3.56 * kt + 4.189 * kt ** 2 - 2.137 * kt ** 3 : 1.311 - 3.022 * kt + 3.427 * kt ** 2 - 1.821 * kt ** 3);

Hourly share of the day's light

64-bit

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.

Every policy that takes this approach