Resources Numerical accuracy Calculation policy

Heating and cooling loads per room

Works out what bounds each room, the U-value of each bounding element, and from them the design heating load, a degree-day year and a cooling peak.

How the precision is kept

What bounds a room is found by sampling: each triangle of the room's shell is cut into pieces no longer than 0.25 m on a side and a ray from the middle of each piece finds the element it meets. Boundary areas are sums of those pieces, then windows and doors are set to their stated width times height. The loads are 64-bit sums of U × A × ΔT; the air constant, the default U-values and the conductivities by material keyword are round typical figures.

Calculations under this policy

11 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.

energy-bim.js BimCraft 0.8.1 64-bit 11 calculations Reviewed

Tests tools/bimcraft-test/ops-energy.mjs

Heat capacity of air per volume

64-bit

The energy to warm one cubic meter of air by one kelvin, in watt-hours.

ρ·c = 1.2 kg/m³ × 1005 J/kgK ÷ 3600 = 0.335 Wh/m³K

The density 1.2 and the heat capacity 1005 are round typical values for air, not functions of temperature or pressure. The product is computed in 64-bit; 3600 is exact.

  • energy-bim.js
  • line 38
  • BimCraft 0.8.1
export const RHO_C = 1.2 * 1005 / 3600;   // 0.335 Wh/m³K

Does a ray reach a box

64-bit

Whether a ray passes through an element's bounding box within its reach.

The slab test, with the box widened by 1e-9 m on every side. A direction component below 1e-12 is treated as parallel to that pair of faces, so nothing is divided by a near-zero number.

  • energy-bim.js
  • lines 57 to 68
  • BimCraft 0.8.1
// ---------------------------------------------------------------- rays
function rayBox(o, d, b, tMax) {
  let t0 = 0, t1 = tMax;
  for (let k = 0; k < 3; k++) {
    if (Math.abs(d[k]) < 1e-12) { if (o[k] < b.lo[k] - 1e-9 || o[k] > b.hi[k] + 1e-9) return false; continue; }
    let a = (b.lo[k] - 1e-9 - o[k]) / d[k], c = (b.hi[k] + 1e-9 - o[k]) / d[k];
    if (a > c) [a, c] = [c, a];
    t0 = Math.max(t0, a); t1 = Math.min(t1, c);
    if (t1 < t0) return false;
  }
  return true;
}

Where a ray crosses triangles

64-bit

Every distance along a ray at which it crosses a triangle of an element.

det = e₁ · (d × e₂); u = (t · (d × e₂)) ÷ det; v = (d · (t × e₁)) ÷ det; distance = (e₂ · (t × e₁)) ÷ det

The determinant limit 1e-14 is absolute: it is not scaled to the size of the triangle's edges. The barycentric limits are widened by 1e-9, so a ray along an edge shared by two triangles is counted by both. Hits at 1e-7 m or nearer are ignored.

  • energy-bim.js
  • lines 69 to 86
  • BimCraft 0.8.1
/** Every t (0 < t ≤ tMax) where the ray crosses a triangle of tr (Möller–Trumbore). */
function rayTris(o, d, tr, tMax, out) {
  for (let i = 0; i + 8 < tr.length; i += 9) {
    const e1x = tr[i + 3] - tr[i], e1y = tr[i + 4] - tr[i + 1], e1z = tr[i + 5] - tr[i + 2], e2x = tr[i + 6] - tr[i], e2y = tr[i + 7] - tr[i + 1], e2z = tr[i + 8] - tr[i + 2];
    const px = d[1] * e2z - d[2] * e2y, py = d[2] * e2x - d[0] * e2z, pz = d[0] * e2y - d[1] * e2x;
    const det = e1x * px + e1y * py + e1z * pz;
    if (Math.abs(det) < 1e-14) continue;
    const inv = 1 / det, tx = o[0] - tr[i], ty = o[1] - tr[i + 1], tz = o[2] - tr[i + 2];
    const u = (tx * px + ty * py + tz * pz) * inv;
    if (u < -1e-9 || u > 1 + 1e-9) 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) * inv;
    if (v < -1e-9 || u + v > 1 + 1e-9) continue;
    const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
    if (t > 1e-7 && t <= tMax) out.push(t);
  }
  return out;
}

Is a point inside a room

64-bit

Whether a point lies inside the closed shell of a space.

inside when a slanted ray crosses the shell an odd number of times

One fixed slanted direction is used for every point, written to 7 digits. The crossings are counted by rayTris, so a ray that runs along a shared edge counts twice and can give the wrong parity.

  • energy-bim.js
  • lines 87 to 93
  • BimCraft 0.8.1
/** A point inside a closed shell: crossings along a slanted ray, counted. */
function inside(p, sp) {
  const b = sp.box;
  if (p[0] < b.lo[0] || p[0] > b.hi[0] || p[1] < b.lo[1] || p[1] > b.hi[1] || p[2] < b.lo[2] || p[2] > b.hi[2]) return false;
  const d = [0.8660254, 0.3826834, 0.3213938];
  return rayTris(p, d, sp.tr, 1e9, []).length % 2 === 1;
}

Room volume

64-bit

The volume enclosed by a room's triangles.

V = |Σ a · (b × c)| ÷ 6 over the triangles

Each term is a triple product of the corners' own coordinates, measured from the model origin, not from a point in the room. For a room far from the origin the terms are large and nearly cancel in the sum.

  • energy-bim.js
  • lines 94 to 98
  • BimCraft 0.8.1
function meshVolume(tr) {
  let v = 0;
  for (let i = 0; i + 8 < tr.length; i += 9) v += (tr[i] * (tr[i + 4] * tr[i + 8] - tr[i + 5] * tr[i + 7]) - tr[i + 1] * (tr[i + 3] * tr[i + 8] - tr[i + 5] * tr[i + 6]) + tr[i + 2] * (tr[i + 3] * tr[i + 7] - tr[i + 4] * tr[i + 6])) / 6;
  return Math.abs(v);
}

Sample pieces of a room's surface

64-bit

Cuts each triangle of the room's shell into small pieces and starts one ray from each.

k = ⌈longest edge ÷ 0.25 m⌉; k² pieces of area (triangle area ÷ k²); start = piece center + 1e-4 m × normal

Each triangle gives k² pieces of equal area, so the pieces add back to the triangle's area; what is sampled is which element each piece is given to. A boundary between two elements is therefore placed to the nearest piece, whose sides are 0.25 m at most.

  • energy-bim.js
  • lines 127 to 135
  • BimCraft 0.8.1
const k = Math.max(1, Math.ceil(Math.max(Math.hypot(...ab), Math.hypot(...ac), Math.hypot(B[0] - C[0], B[1] - C[1], B[2] - C[2])) / spacing));
const sub = area / (k * k);
// the k² small triangles of the barycentric subdivision: (i, j) upright and (i, j) upside down; their centres
for (let a = 0; a < k; a++) for (let b = 0; a + b < k; b++) {
  const cells = [[a + 1 / 3, b + 1 / 3]];
  if (a + b < k - 1) cells.push([a + 2 / 3, b + 2 / 3]);
  for (const [u, v] of cells) {
    const s = u / k, t2 = v / k;
    const p = [A[0] + ab[0] * s + ac[0] * t2 + n[0] * 1e-4, A[1] + ab[1] * s + ac[1] * t2 + n[1] * 1e-4, A[2] + ab[2] * s + ac[2] * t2 + n[2] * 1e-4];

What lies beyond a bounding element

64-bit

Whether a piece of the room's surface faces another room, the ground or the outside.

probe = start + normal × (farthest crossing + 0.25 m)

The far face is the farthest crossing of the first element within 1.2 m past its near face. The probe point 0.25 m beyond is tested against every other space. A floor of a room on the lowest level is ground; everything else not adjacent is outside.

  • energy-bim.js
  • lines 150 to 155
  • BimCraft 0.8.1
const ts = rayTris(p, n, first[1].tr, tF + 1.2, []);
const exitT = Math.max(...ts);
const probe = [p[0] + n[0] * (exitT + 0.25), p[1] + n[1] * (exitT + 0.25), p[2] + n[2] * (exitT + 0.25)];
const adj = others.find(([, q]) => inside(probe, q));
const exposure = adj ? 'adjacent' : n[2] < -0.7 && lowest ? 'ground' : 'outside';
add(first[0], first[1].cls, exposure, adj ? adj[0] : null, sub);

Window and door areas

64-bit

Sets each window's and door's area to its own width times height and takes the difference from its wall.

A = OverallWidth × OverallHeight (in m); wall area = wall area − (A − sampled area)

The stated size replaces the sampled area, so openings do not carry the sampling error. The difference goes to the largest wall part with the same exposure, and only if that wall's area stays positive; otherwise the sampled area is kept.

  • energy-bim.js
  • lines 165 to 167
  • BimCraft 0.8.1
const want = ow * mm * oh * mm, d = want - e.area;
const host = list.filter((x) => /WALL|CURTAINWALL|PLATE/.test(x.cls) && x.exposure === e.exposure).sort((x, y) => y.area - x.area)[0];
if (host && host.area - d > 0) { host.area -= d; e.area = want; }

U-value from material layers

64-bit

The U-value of an element from the thickness and conductivity of its layers.

U = 1 ÷ (Rsi + Σ dᵢ ÷ λᵢ + Rse); Rsi, Rse = 0.13, 0.04 sideways; 0.10, 0.04 upward; 0.17, 0.04 downward

Used only when every layer has a thickness above zero and a known conductivity, so no term divides by zero. A conductivity that was not typed comes from a keyword table of typical values given to one, two or three significant figures, which limits the result to that.

  • energy-bim.js
  • lines 215 to 217
  • BimCraft 0.8.1
const [rsi, rse] = kind === 'roof' ? SURFACE.up : kind === 'floor' ? SURFACE.down : SURFACE.side;
u = 1 / (rsi + layers.reduce((s, x) => s + x.d / x.lambda, 0) + rse);
source = 'layers';

Heating load, annual energy and cooling peak

64-bit

Each room's design heat loss, its degree-day year and its cooling peak.

H_vent = 0.335 × n × V; heat = H_out·ΔT + H_ground·ΔT_g + H_vent·ΔT; annual kWh = (H_out + H_vent + H_ground × ΔT_g ÷ ΔT) × DD × 24 ÷ 1000

Sums of U × A over the room's parts in order, then a few products. The annual figure is computed only when ΔT is above zero, which guards its division. The cooling total is floored at zero. Nothing is rounded here.

  • energy-bim.js
  • lines 255 to 272
  • BimCraft 0.8.1
// ---------------------------------------------------------------- loads
export function loads(e, uMap, inputs = {}, { solar = null } = {}) {
  const I = { ...INPUTS, ...inputs };
  const dT = I.ti - I.te, dTg = I.ti - I.tg, dTs = I.teSummer - I.tiCool;
  return e.rooms.map((r) => {
    const rows = r.parts.map((p) => { const u = uMap.get(p.id); return { ...p, u: u?.u ?? DEFAULT_U.other, source: u?.source ?? 'default', ua: (u?.u ?? DEFAULT_U.other) * p.area }; });
    const sum = (exp) => rows.filter((x) => x.exposure === exp).reduce((s, x) => s + x.ua, 0);
    const H = { out: sum('outside'), ground: sum('ground'), vent: RHO_C * I.ach * r.volume };
    const heat = { fabric: H.out * dT, ground: H.ground * dTg, vent: H.vent * dT };
    heat.total = heat.fabric + heat.ground + heat.vent;
    const annual = dT > 0 ? ((H.out + H.vent + H.ground * (dTg / dT)) * I.dd * 24) / 1000 : 0;
    const people = I.perPerson > 0 ? (r.floorArea / I.perPerson) * I.wPerson : 0;
    const sr = solar?.rooms?.find((x) => x.id === r.id) || null;
    const cool = { fabric: H.out * dTs, vent: H.vent * dTs, people, lights: I.wLights * r.floorArea, equip: I.wEquip * r.floorArea, solar: sr ? sr.peak : 0, at: sr ? sr.peakAt : null };
    cool.total = Math.max(0, cool.fabric + cool.vent + cool.people + cool.lights + cool.equip + cool.solar);
    return { ...r, rows, H, heat, annual, cool, inputs: I };
  });
}

Degree-days from a CSV file

64-bit

The sum of the degree-day column of a CSV file, for a year.

total = Σ of the last numeric cell of each row

A cell is a number only if it is digits with at most one decimal point or comma; a decimal comma is read as a point. The values are added in file order with no compensation, and the first cell of a row is never taken as the value.

  • energy-bim.js
  • lines 274 to 288
  • BimCraft 0.8.1
export function degreeDaysCsv(text) {
  const lines = String(text ?? '').replace(/^/, '').split(/\r\n|\n|\r/).filter((l) => l.trim());
  if (lines.length > 50000) throw err('energy.err.dd-big');
  const sep = [',', ';', '\t'].map((c) => [c, (lines[0] || '').split(c).length]).sort((a, b) => b[1] - a[1])[0][0];
  const split = (l) => { const out = []; let cur = '', q = false; for (const ch of l) { if (ch === '"') q = !q; else if (ch === sep && !q) { out.push(cur); cur = ''; } else cur += ch; } out.push(cur); return out.map((x) => x.trim()); };
  let total = 0, rows = 0;
  for (const l of lines) {
    const cells = split(l);
    const nums = cells.map((x) => (/^[+-]?\d+([.,]\d+)?$/.test(x) ? Number(x.replace(',', '.')) : NaN)).filter((x, i) => Number.isFinite(x) && i > 0);
    if (!nums.length) continue;
    total += nums[nums.length - 1]; rows++;
  }
  if (!rows) throw err('energy.err.dd-none');
  return { total, rows };
}

In detail

Method
Steady state, in the style of EN 12831 (the file's header): heat = Σ U·A·(θi − θe) over outside parts + Σ U·A·(θi − θg) over ground parts + 0.335 · n · V · (θi − θe). U-values come from the IFC property ThermalTransmittance, else from material layers with R = Rsi + Σ d ÷ λ + Rse (the header cites the ISO 6946 surface resistances), else a default by kind. Ray and triangle crossings by the Möller–Trumbore test.
Number types
Float64Array triangles in meters; 64-bit numbers.
Tolerances
Sample spacing 0.25 m. Rays start 1e-4 m outside the room's surface. A ray and triangle with a determinant below 1e-14 are skipped; barycentric limits are widened by 1e-9; hits nearer than 1e-7 m are ignored. Rays reach 1.5 m sideways and down and 8 m up. The probe for what lies beyond an element is 0.25 m past its far face. A surface is a floor when its normal's z is below −0.7; a room is on the lowest level within 0.3 m of the lowest space.
Limits
The header calls it a design aid, not a compliance calculation, and not the ISO 52016-1 monthly method. Boundary areas are resolved to the sample pieces. Air is taken as 1.2 kg/m³ and 1005 J/kgK.

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