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