Resources Numerical accuracy Calculation policy

Energy model input file (IDF) and its results

Writes the rooms of a model as an IDF input file for an outside energy simulation engine, and reads back the design loads and yearly energy that engine reports.

How the precision is kept

The file is text: coordinates are written with 4 decimals of a meter and other numbers with 1 to 4 decimals. Room surfaces are rebuilt from the space's triangles by grouping them into planes with rounded keys. The simulation itself is not part of this code; the numbers read back are the engine's, copied as parsed.

Calculations under this policy

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

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

A number in the file

64-bit

A value written into the input file as text.

text = round(x × 10ᵈ) ÷ 10ᵈ, d = 4 unless stated

Every number in the file is rounded: coordinates and resistances to 4 decimals, U-value keys to 3, temperatures to 1. A value that is not finite is written as 0, not refused.

  • energy-ep.js
  • line 41
  • BimCraft 0.8.1
const f = (x, d = 4) => (Number.isFinite(x) ? (Math.round(x * 10 ** d) / 10 ** d).toString() : '0');

Triangles grouped into planes

64-bit

The triangles of a room's shell sorted into the flat faces they belong to.

key = (round(1000·n), round(1000·d)), n = unit normal, d = n · A

Two triangles share a face when their unit normals agree to 0.001 and their plane distances to 1 mm (d is in meters). Triangles with a cross product shorter than 1e-9 are dropped. Values that fall on either side of a rounding boundary land in different groups.

  • energy-ep.js
  • lines 45 to 59
  • BimCraft 0.8.1
function planes(tr) {
  const groups = new Map();
  for (let i = 0; i + 8 < tr.length; i += 9) {
    const A = [tr[i], tr[i + 1], tr[i + 2]], B = [tr[i + 3], tr[i + 4], tr[i + 5]], C = [tr[i + 6], tr[i + 7], tr[i + 8]];
    const ab = B.map((v, k) => v - A[k]), ac = C.map((v, k) => v - A[k]);
    const n = [ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]];
    const l = Math.hypot(...n);
    if (l < 1e-9) continue;
    const u = n.map((v) => v / l), d = u[0] * A[0] + u[1] * A[1] + u[2] * A[2];
    const key = u.map((v) => Math.round(v * 1000)).join(',') + '|' + Math.round(d * 1000);
    if (!groups.has(key)) groups.set(key, { n: u, tris: [] });
    groups.get(key).tris.push([A, B, C]);
  }
  return [...groups.values()];
}

Outline of a face

64-bit

One polygon from the triangles of a flat face.

Corners are matched by their coordinates rounded to 0.1 mm. Edges used by exactly one triangle are chained into the outline. A face that does not chain into a single loop is written as its separate triangles and said so. Points on a straight line are dropped when the cross product of the two edges is 1e-9 or less.

  • energy-ep.js
  • lines 60 to 74
  • BimCraft 0.8.1
/** One loop from a group's triangles (edges used once, chained), or null when it is not a single simple loop. */
function loop(tris) {
  const k = (p) => p.map((v) => Math.round(v * 10000)).join(',');
  const pts = new Map(), use = new Map();
  for (const t of tris) for (let i = 0; i < 3; i++) { const a = t[i], b = t[(i + 1) % 3]; pts.set(k(a), a); pts.set(k(b), b); const e = [k(a), k(b)].sort().join('|'); use.set(e, (use.get(e) || 0) + 1); }
  const next = new Map();
  for (const t of tris) for (let i = 0; i < 3; i++) { const a = k(t[i]), b = k(t[(i + 1) % 3]); if (use.get([a, b].sort().join('|')) === 1) { if (next.has(a)) return null; next.set(a, b); } }
  if (next.size < 3) return null;
  const start = next.keys().next().value, out = [];
  let at = start;
  for (let i = 0; i <= next.size; i++) { out.push(pts.get(at)); at = next.get(at); if (at === undefined) return null; if (at === start) break; }
  if (out.length !== next.size) return null;
  // drop points in a straight line
  return out.filter((p, i) => { const a = out[(i + out.length - 1) % out.length], b = out[(i + 1) % out.length]; const u = p.map((v, j) => v - a[j]), w = b.map((v, j) => v - p[j]); const c = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; return Math.hypot(...c) > 1e-9; });
}

Which way a polygon faces

64-bit

The normal of a polygon, used to order its corners counter-clockwise seen from outside.

n = Σ ((yᵢ − yⱼ)(zᵢ + zⱼ), (zᵢ − zⱼ)(xᵢ + xⱼ), (xᵢ − xⱼ)(yᵢ + yⱼ))

Newell's sum on the corner coordinates as they are. Only its sign against the outward direction is used, so its size does not reach the file.

  • energy-ep.js
  • line 75
  • BimCraft 0.8.1
const newell = (P) => { const n = [0, 0, 0]; for (let i = 0; i < P.length; i++) { const a = P[i], b = P[(i + 1) % P.length]; n[0] += (a[1] - b[1]) * (a[2] + b[2]); n[1] += (a[2] - b[2]) * (a[0] + b[0]); n[2] += (a[0] - b[0]) * (a[1] + b[1]); } return n; };

Construction by U-value

64-bit

The thermal resistance written for a wall, roof or floor that has no layer build-up.

R = max(0.001, 1 ÷ U − Rs), Rs = 0.17 wall, 0.14 roof, 0.21 floor

Constructions are looked up by the U-value rounded to 3 decimals, and the first surface with that key sets the U that is used; a later surface whose U differs in the fourth decimal takes the first one's. The resistance is written with 4 decimals. Rs is the sum of the two surface resistances.

  • energy-ep.js
  • lines 86 to 90
  • BimCraft 0.8.1
const consFor = (u, kind) => {
  const key = kind + ':' + f(u, 3);
  if (!cons.has(key)) { const rs = kind === 'roof' ? 0.14 : kind === 'floor' ? 0.21 : 0.17; cons.set(key, { name: 'C ' + key, kind, r: Math.max(0.001, 1 / u - rs), u }); }
  return cons.get(key).name;
};

Mean U-value of a room's walls, roof and floor

64-bit

The area-weighted U-value of each kind of surface in a room.

U = Σ (Aᵢ × Uᵢ) ÷ Σ Aᵢ

A part with no U-value counts as 0.3. A room with no parts of a kind takes 0.3 for walls, 0.2 for roofs and 0.25 for floors. The division is made only when the area is above zero.

  • energy-ep.js
  • lines 143 to 144
  • BimCraft 0.8.1
const avgU = (pred) => { let a = 0, ua = 0; for (const p of er.parts) if (p.id !== null && pred(p)) { const u = uMap.get(p.id)?.u ?? 0.3; a += p.area; ua += p.area * u; } return a > 0 ? ua / a : null; };
const uWall = avgU(isWall) ?? 0.3, uRoof = avgU(isRoof) ?? 0.2, uFloor = avgU(isFloor) ?? 0.25;

Boundary of a surface

64-bit

Whether a surface of a room faces another room, the ground or the outside, and its corners as written.

probe = mean of the corners + 0.6 m × outward normal

The probe is tested against the bounding boxes of the other spaces, not their shells, so a probe that falls in another room's box counts as facing that room even where the room's shape does not reach. Corners are written with 4 decimals of a meter.

  • energy-ep.js
  • lines 159 to 166
  • BimCraft 0.8.1
const cen = P.reduce((s, q) => s.map((v, k) => v + q[k] / P.length), [0, 0, 0]);
const probe = cen.map((v, k) => v + n[k] * 0.6);
const next = spaces.find(([id, q]) => id !== r.id && probe[0] >= q.box.lo[0] && probe[0] <= q.box.hi[0] && probe[1] >= q.box.lo[1] && probe[1] <= q.box.hi[1] && probe[2] >= q.box.lo[2] && probe[2] <= q.box.hi[2]);
const bc = next ? 'Adiabatic' : ground ? 'Ground' : 'Outdoors';
const u = kind === 'Wall' ? uWall : kind === 'Roof' ? uRoof : uFloor;
const lay = kind === 'Wall' ? mWall : kind === 'Roof' ? mRoof : mFloor;
const sname = nm(zone + ' ' + kind + ' ' + (++surfaces));
body += obj('BuildingSurface:Detailed', [sname, kind, lay && bc !== 'Adiabatic' ? lay : consFor(u, kind.toLowerCase()), zone, '', bc, '', bc === 'Outdoors' ? 'SunExposed' : 'NoSun', bc === 'Outdoors' ? 'WindExposed' : 'NoWind', 'autocalculate', P.length, ...P.flatMap((q) => [f(q[0]), f(q[1]), f(q[2])])]);

Window rectangle on its wall

64-bit

The four corners of a window as written on the wall it faces.

k = √(area ÷ (span × height)); half width = span × k ÷ 2; half height = height × k ÷ 2; corners projected onto the wall plane

The rectangle keeps the window's proportions and is scaled so its area equals the window area. The divisor is floored at 1e-9. Each corner is moved onto the wall's plane along the wall's normal. A window with no outside wall facing it within 0.8 m is left out and listed.

  • energy-ep.js
  • lines 174 to 178
  • BimCraft 0.8.1
const k = Math.sqrt(w.area / Math.max(1e-9, (w.span[1] - w.span[0]) * w.hz)), mid2 = (w.span[0] + w.span[1]) / 2, hw = (w.span[1] - w.span[0]) * k / 2, hh = w.hz * k / 2;
const cc = [w.origin[0] + w.s[0] * mid2, w.origin[1] + w.s[1] * mid2, w.origin[2]];
const onWall = (p) => { const t = wall.s.n[0] * p[0] + wall.s.n[1] * p[1] + wall.s.n[2] * p[2] - wall.s.d; return p.map((v, j) => v - wall.s.n[j] * t); };
const corners = [[-hw, hh], [-hw, -hh], [hw, -hh], [hw, hh]].map(([a, b]) => onWall([cc[0] + w.s[0] * a, cc[1] + w.s[1] * a, cc[2] + b]));
const P = ccwAbout(corners, wall.s.n);

Glazing written for every window

64-bit

The one window construction every window in the file uses.

U = 1.4 W/m²K, solar heat gain 0.6, visible transmittance 0.7

Three fixed values, the same for every window. The U-value a window carries in the model is not used here, while the quick loads of energy-bim.js do use it.

  • energy-ep.js
  • line 133
  • BimCraft 0.8.1
const glazing = obj('WindowMaterial:SimpleGlazingSystem', ['Glazing', '1.4', '0.6', '0.7']) + obj('Construction', ['C window', 'Glazing']);

Site and time zone

64-bit

The latitude, longitude and time zone written to the file.

time zone = round(longitude ÷ 15)

The time zone is whole hours from the longitude, not the civil time zone of the place. With no site in the model, latitude 51.5 and longitude 0 are written and a note says so. Latitude and longitude are written with 4 decimals.

  • energy-ep.js
  • line 80
  • BimCraft 0.8.1
const lat = site?.lat ?? 51.5, lon = site?.lon ?? 0, tz = Math.round(lon / 15);

Design loads read back

64-bit

Each zone's design heating and cooling load in watts, from the engine's sizing report.

The fourth field of each sizing line is read with Number() and kept as it is. The precision is whatever the engine printed.

  • energy-ep.js
  • lines 226 to 239
  • BimCraft 0.8.1
/** eplusout.eio: ` Zone Sizing Information, <ZONE>, Heating|Cooling, Calc Des Load {W}, …` → Map(ZONE → { heating, cooling }) in watts. */
export function parseSizing(eio) {
  const out = new Map();
  for (const line of String(eio || '').split(/\r?\n/)) {
    if (!/^\s*Zone Sizing Information,/.test(line)) continue;
    const x = line.split(',').map((v) => v.trim());
    const kind = /^heating/i.test(x[2]) ? 'heating' : /^cooling/i.test(x[2]) ? 'cooling' : null, w = Number(x[3]);
    if (!kind || !Number.isFinite(w)) continue;
    const key = String(x[1]).toUpperCase();
    if (!out.has(key)) out.set(key, { heating: null, cooling: null });
    out.get(key)[kind] = w;
  }
  return out;
}

Yearly energy per zone

64-bit

Each zone's yearly heating and cooling energy in kilowatt-hours.

kWh = J ÷ 3.6 × 10⁶

One division by 3.6e6, the exact number of joules in a kilowatt-hour. The joule value is the engine's, read with Number().

  • energy-ep.js
  • lines 263 to 274
  • BimCraft 0.8.1
/** parseEso() → Map(ZONE NAME → { heating, cooling }) in kWh (the variables are joules); the key is the ideal-loads object, ` IDEAL` stripped. */
export function yearByZone(map) {
  const out = new Map();
  for (const [k, j] of map) {
    const m = /^(.*)\|Zone Ideal Loads Zone Total (Heating|Cooling) Energy \[J\]/i.exec(k);
    if (!m) continue;
    const zone = m[1].replace(/ IDEAL$/, '');
    if (!out.has(zone)) out.set(zone, { heating: 0, cooling: 0 });
    out.get(zone)[m[2].toLowerCase()] = j / 3.6e6;
  }
  return out;
}

Difference between the two methods

64-bit

How far the engine's design loads are from the quick loads, per room.

ratio = (engine − quick) ÷ quick

Computed only when both values are finite and the quick load is above zero; otherwise the ratio is null. A subtraction of two loads of similar size, then one division.

  • energy-ep.js
  • lines 275 to 287
  • BimCraft 0.8.1
/** EnergyPlus's design loads against energy-bim.js's (`ours` = loads() rooms): the ratio (ep − ours) / ours per room, null when ours is 0. */
export function compareDesign(ep, ours, zoneOf) {
  const byId = new Map(ours.map((r) => [r.id, r]));
  const rows = [];
  for (const [zone, z] of zoneOf) {
    const e = ep.get(zone), o = byId.get(z.id);
    if (!e || !o) continue;
    const mine = { heating: o.heat?.total ?? null, cooling: o.cool?.total ?? null };
    const d = (a, b) => (Number.isFinite(a) && Number.isFinite(b) && b > 0 ? (a - b) / b : null);
    rows.push({ room: z.id, name: z.name, ep: e, ours: mine, dHeating: d(e.heating, mine.heating), dCooling: d(e.cooling, mine.cooling) });
  }
  return rows;
}

Rooms the two methods disagree on

64-bit

A note for each room whose design heating differs by more than 15 %.

note when |ratio| > 0.15

The comparison uses the unrounded ratio. The sentence shows whole percent and whole watts (Math.round). Only heating is compared here; the cooling ratio is computed but raises no note.

  • energy-ep.js
  • lines 288 to 298
  • BimCraft 0.8.1
/** The shared Problems shape (rule 29), source `bimcraft.energy.ep`: a run that did not complete, severe errors, rooms the two methods disagree on. */
export function problemsEp(cmp, err, { tolerance = 0.15 } = {}) {
  const P = (code, severity, text, extra = {}) => ({ code, severity, part: extra.part || '', msg: { key: 'ep.' + code, vars: extra.vars || {} }, text, about: extra.about || [], source: { name: 'bimcraft.energy.ep' }, ...(extra.req ? { req: extra.req } : {}), ...(extra.found ? { found: extra.found } : {}) });
  const out = [];
  if (err && !err.completed) out.push(P('run.failed', 'error', `${EP_NAME} did not finish${err.messages?.[0] ? ': ' + err.messages[0] : ''}.`));
  else if (err?.severe) out.push(P('run.severe', 'warning', `${EP_NAME} finished with ${err.severe} severe message${err.severe > 1 ? 's' : ''}${err.messages?.[0] ? ': ' + err.messages[0] : ''}.`));
  for (const r of cmp || []) {
    if (r.dHeating !== null && Math.abs(r.dHeating) > tolerance) out.push(P('heat.differs', 'note', `${r.name}: the two methods differ in design heating by ${Math.round(r.dHeating * 100)} % (${Math.round(r.ep.heating)} W against ${Math.round(r.ours.heating)} W).`, { part: 'room:' + r.room, about: [{ kind: 'room', id: String(r.room), label: r.name }], req: { op: '<=', value: Math.round(tolerance * 100), unit: '%' }, found: { value: Math.round(Math.abs(r.dHeating) * 100), unit: '%' } }));
  }
  return out;
}

In detail

Method
One zone per room. Triangles of the room's shell are grouped by plane and joined into one polygon per plane; each surface's boundary condition comes from a probe point 0.6 m outside it. Walls, roofs and floors take the area-weighted mean U-value of the room's parts of that kind. Windows are written as rectangles of the window's area on the wall they face.
Number types
64-bit numbers, written as decimal text.
Tolerances
Plane grouping: unit normal rounded to 0.001 and plane distance to 1 mm. Vertices matched at 0.1 mm. Collinear points are dropped when the cross product is 1e-9 or less. A window belongs to a wall whose normal agrees to cos > 0.99 and whose plane is within 0.8 m. Two methods are reported as differing above 15 %.
Limits
Every window is written with one fixed glazing (U 1.4, solar gain 0.6, light 0.7), whatever the model says. Constructions are shared by U-value rounded to 3 decimals. With no site in the model the file uses latitude 51.5 and longitude 0. The time zone is the longitude divided by 15, rounded.

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