Files
isokit/crates/isokit-script/stdlib/datacentre.rhai
T
grabowski 2810477f1b
Deploy web Studio / deploy (push) Successful in 1m4s
Data centres: multi-storey shell, racks and pods, cooling, fire safety, power
datacentre.rhai (palette groups Data centre, Cooling, Fire safety, Power):

- dc_building: multi-storey shell (storeys of storey_h), walls in pieces,
  slabs, roof with parapet, louvres, front door and loading dock; levels
  cut it open storey by storey
- raised_floor; rack_row and dc_pod (cabinets by U, width, depth,
  equipment, fill, doors, LEDs; in-row coolers; cold / hot aisle
  containment with vent tiles, roof, end doors, ceiling duct, airflow);
  dc_cage for colocation
- crah, chiller (air-cooled, dry, adiabatic), cooling_tower, pipe_run
- smoke_detectors (grid + coverage), fire_pipe (VESDA, sprinkler, gas),
  gas_bank (Novec 1230, FM-200, Inergen sized for its halls), fire_device
  (panels, call points, abort switches, beacons, extinguishers)
- power_cab (UPS, battery, PDU, switchgear, RPP), busway, cable_tray,
  transformer
- tooltips sum IT load, kW/m2, cooling redundancy per storey, UPS load,
  battery runtime, plant cooling and agent quantity (warning when short)

Engine and stdlib:

- part_hidden(o) in house.rhai: parts on closed storeys inside a
  dc_building are not drawn (they only made sort cycles under the slab
  above); house parts, furniture and people use it. Existing examples
  render byte-identical.
- overhead services sort in their own "ceiling" layer with tight boxes
- sort records which sprites each broken cycle involved; isokit check
  names them (Rendered.cycle_at)

examples/datacentre (three storeys and a roof plant), Data centre demo in
the web Studio, tests, SCRIPTING/README/ROADMAP, MCP instructions.
2026-10-27 17:08:39 +07:00

1369 lines
64 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// isokit/datacentre: multi-storey data centres.
//
// `dc_building` is the shell: `storeys` of `storey_h` (the project parameter
// the house parts use too), cut open by the levels gf, 1f, ... (storeys below
// closed, the walls in front cut low, everything above left out). Inside it
// every part lives on a storey (its z) and stands on the `raised_floor` under
// it: rack rows, pods with cold or hot aisle containment, CRAH units, power
// cabinets. Overhead: busways, cable trays, fire pipes (VESDA, sprinklers,
// gas nozzles) and detector grids. Plant: chillers, cooling towers, pipe
// runs, transformers. Parts on closed storeys inside a dc_building are not
// drawn (the shell hides them); the project parameter `ceiling` = false
// leaves the overhead services out.
//
// Parts read the document: racks total their IT load per storey, CRAH units
// set the storey's cooling against it (N+1 and so on), gas cylinder banks
// size the agent for the halls on their storey, batteries give their runtime
// at the building's IT load.
fn dc_rack_types() { ["rack_row", "dc_pod"] }
/// Leave part o out? Like house parts (part_hidden in isokit/house): above
/// the storey a level cuts open, or on a closed storey inside a dc_building.
fn dc_skip(o) { part_hidden(o) }
/// Overhead services (trays, busways, fire pipes, detectors) draw in their
/// own layer after everything standing on the floor, lower ones first: runs
/// crossing at different heights over racks would otherwise make depth-sort
/// cycles (a tray beside a pod, a busway over the pod and under the tray).
fn dc_ceiling_layer() { layer("ceiling", #{ sort: true, zfirst: true }); }
/// Overhead services left out (project parameter `ceiling` = false)?
fn dc_no_ceiling() {
let v = cfg("ceiling");
type_of(v) == "bool" && !v
}
/// Height of the floor surface at world (wx, wy) above o.z: the top of a
/// raised floor there on o's storey, else 0.
fn dc_lift_at(o, wx, wy) {
let s = storey_of(o.z);
let best = 0.0;
for f in objects_at(["raised_floor"], wx, wy) {
if storey_of(f.z) == s { best = fmax(best, f.z + num(f.p.h) - o.z); }
}
best
}
fn dc_lift(o) { dc_lift_at(o, o.x, o.y) }
/// Level name of storey j: "GF", "1F", ...
fn dc_storey_name(j) { if j == 0 { "GF" } else { `${j}F` } }
/// x for a tooltip: whole numbers as they are, others to one decimal.
fn dc_num(x) {
let r = round1(x);
if (r - r.round()).abs() < 1e-9 { `${r.round().to_int()}` } else { `${r}` }
}
// ---------------------------------------------------------------- loads
/// Height of a cabinet of u rack units (42U: 2.0 m).
fn dc_rack_h(u) { num(u) * 0.04445 + 0.13 }
/// The units of a line of racks along local x, centred on 0:
/// [[x0, x1, cooler], ...]. With p.inrow = k > 0 an in-row cooler (0.3 m
/// wide) follows every k racks (at the end when there is only one group).
fn dc_units(p) {
let k = p.inrow;
let n = p.n;
let seq = [];
for i in 0..n {
seq.push(false);
if k > 0 && (i + 1) % k == 0 && i + 1 < n { seq.push(true); }
}
if k > 0 && !seq.contains(true) { seq.push(true); }
let rw = num(p.rw);
let L = 0.0;
for q in seq { L += if q { 0.3 } else { rw }; }
let out = [];
let x = -L / 2.0;
for q in seq {
let w = if q { 0.3 } else { rw };
out.push([x, x + w, q]);
x += w;
}
out
}
/// In-row coolers in one line of racks.
fn dc_coolers(p) {
let c = 0;
for q in dc_units(p) { if q[2] { c += 1; } }
c
}
fn dc_racks_of(r) { if r.type == "dc_pod" { 2 * r.p.n } else { r.p.n } }
fn dc_kw_of(r) { if r.p.kind == "empty" { 0.0 } else { dc_racks_of(r).to_float() * num(r.p.kw) } }
/// IT load of the racks on storey s (every storey when s < 0):
/// #{ kw, racks, rows }.
fn dc_it(s) {
let kw = 0.0;
let n = 0;
let rows = 0;
for r in objects_of(dc_rack_types()) {
if s >= 0 && storey_of(r.z) != s { continue; }
n += dc_racks_of(r);
kw += dc_kw_of(r);
rows += 1;
}
#{ kw: kw, racks: n, rows: rows }
}
/// Room cooling on storey s: CRAH units on duty and on standby and the
/// in-row coolers of its racks: #{ duty, standby, units }.
fn dc_cool(s) {
let duty = 0.0;
let sb = 0.0;
let n = 0;
for c in objects_of(["crah"]) {
if storey_of(c.z) != s { continue; }
n += 1;
if c.p.standby { sb += num(c.p.kw); } else { duty += num(c.p.kw); }
}
for r in objects_of(dc_rack_types()) {
if storey_of(r.z) != s { continue; }
let k = dc_coolers(r.p);
if r.type == "dc_pod" { k *= 2; }
n += k;
duty += k.to_float() * num(r.p.inrow_kw);
}
#{ duty: duty, standby: sb, units: n }
}
/// Redundancy of cooling c against kw of load: "N+1", "N", "short by ...".
fn dc_redundancy(c, kw) {
let total = c.duty + c.standby;
if c.units == 0 { return "no cooling units"; }
if total + 1e-6 < kw { return `short by ${fmt(kw - total)} kW`; }
let per = total / c.units.to_float();
let spare = ((total - kw) / per + 1e-6).floor().to_int();
if spare <= 0 { "N (no spare unit)" } else { `N+${spare}, ${fmt(total - kw)} kW spare` }
}
/// Plant cooling (chillers and cooling towers) on the site: #{ kw, units }.
fn dc_plant() {
let kw = 0.0;
let n = 0;
for c in objects_of(["chiller", "cooling_tower"]) {
let k = if c.type == "cooling_tower" { c.p.cells } else { 1 };
n += k;
kw += k.to_float() * num(c.p.kw);
}
#{ kw: kw, units: n }
}
// ---------------------------------------------------------------- building
fn meta_dc_building() {
#{ label: "DC building", category: "Data centre", icon: "🏢", shape: "rect",
help: "The shell of a multi-storey data centre: storeys of the project's storey height (parameter storey_h), outer walls, slabs and a flat roof with a parapet for the plant. Levels GF, 1F, ... cut it open at that storey; parts inside it on closed storeys are not drawn.",
props: [
#{ key: "w", type: "float", def: 40.0, min: 8.0, max: 200.0, step: 0.5, label: "Width (m)" },
#{ key: "d", type: "float", def: 24.0, min: 8.0, max: 200.0, step: 0.5, label: "Depth (m)" },
#{ key: "storeys", type: "int", def: 3, min: 1, max: 12, step: 1, label: "Storeys" },
#{ key: "t", type: "float", def: 0.3, min: 0.15, max: 0.8, step: 0.05, label: "Walls (m)" },
#{ key: "parapet", type: "float", def: 1.2, min: 0.0, max: 4.0, step: 0.1, label: "Parapet (m)" },
#{ key: "mat", type: "material", def: "concb", choices: wall_materials(), label: "Walls" },
#{ key: "roof_mat", type: "material", def: "roof2", choices: roof_materials(), label: "Roof" },
#{ key: "door", type: "bool", def: true, label: "Front door" },
#{ key: "door_at", type: "float", def: 0.25, min: 0.0, max: 1.0, step: 0.01, label: "Door at (0-1)" },
#{ key: "dock", type: "bool", def: true, label: "Loading dock" },
#{ key: "dock_at", type: "float", def: 0.75, min: 0.0, max: 1.0, step: 0.01, label: "Dock at (0-1)" },
#{ key: "louvres", type: "bool", def: true, label: "Louvres" },
] }
}
/// The building's sprite (ghosted on levels above its top storey).
fn dc_sprite(o, ghost) { if ghost { sprite(o.id, "dim") } else { sprite() } }
fn prefab_dc_building(o) {
let p = o.p;
let H = storey_h();
let n = p.storeys;
let w = num(p.w) / 2.0;
let d = num(p.d) / 2.0;
let t = num(p.t);
let k0 = floor_of(o.level);
let k = if k0 < 0 { -1 } else { k0 - storey_of(o.z) };
let ghost = k >= n;
let closed = k < 0 || ghost;
let top = n * H;
// the faces: start point, direction and length; walls along x run
// through the corners, the others stop at them
let faces = [
#{ f: "S", a: [-w, -d], u: [1.0, 0.0], L: 2.0 * w, n: [0.0, -1.0], lo: 0.0 },
#{ f: "E", a: [w, -d], u: [0.0, 1.0], L: 2.0 * d, n: [1.0, 0.0], lo: t },
#{ f: "N", a: [w, d], u: [-1.0, 0.0], L: 2.0 * w, n: [0.0, 1.0], lo: 0.0 },
#{ f: "W", a: [-w, d], u: [0.0, -1.0], L: 2.0 * d, n: [-1.0, 0.0], lo: t },
];
for F in faces {
let zt = if closed { top + num(p.parapet) } else if vis(F.n[0], F.n[1]) { k * H + 1.2 } else { (k + 1) * H };
let cap = if closed { p.mat } else { "cut" };
let ops = dc_openings(p, F, H, n, top);
let lo = F.lo;
let hi = F.L - F.lo;
let m = ((hi - lo) / 15.0).ceiling().to_int();
for i in 0..m {
let ua = lo + (hi - lo) * i / m;
let ub = lo + (hi - lo) * (i + 1) / m;
let pa = [F.a[0] + F.u[0] * ua, F.a[1] + F.u[1] * ua];
let pb = [F.a[0] + F.u[0] * ub, F.a[1] + F.u[1] * ub];
// the wall piece behind the face pa -> pb, t thick inwards
let ix = -F.n[0] * t;
let iy = -F.n[1] * t;
let x0 = fmin(fmin(pa[0], pb[0]), fmin(pa[0], pb[0]) + ix);
let x1 = fmax(fmax(pa[0], pb[0]), fmax(pa[0], pb[0]) + ix);
let y0 = fmin(fmin(pa[1], pb[1]), fmin(pa[1], pb[1]) + iy);
let y1 = fmax(fmax(pa[1], pb[1]), fmax(pa[1], pb[1]) + iy);
let s = dc_sprite(o, ghost);
s.box(x0, y0, x1, y1, 0.0, zt, p.mat, #{ top: cap });
for c in ops.keys() {
let list = [];
for q in ops[c] {
let u0 = fmax(q[0], ua);
let u1 = fmin(q[1], ub);
let z1 = fmin(q[3], zt);
if u1 > u0 + 0.01 && z1 > q[2] + 0.01 { list.push([u0 - ua, u1 - ua, q[2], z1]); }
}
if list.len() > 0 { s.open(pa, pb, list, c); }
}
}
}
let ix0 = -w + t;
let ix1 = w - t;
let iy0 = -d + t;
let iy1 = d - t;
if closed {
dc_sprite(o, ghost).box(ix0, iy0, ix1, iy1, top - 0.3, top, "plinth", #{ top: p.roof_mat });
} else if k == 0 {
layer("floor");
sprite().flat(rect(ix0, iy0, ix1, iy1), 0.005, "z-floor t");
layer("main");
} else {
sprite().box(ix0, iy0, ix1, iy1, k * H - 0.3, k * H, "plinth", #{ top: "z-floor" });
}
info_row("Storeys", `${n} × ${dc_num(H)} m, ${fmt(num(p.w) * num(p.d) * n)} m² gross`);
let s0 = storey_of(o.z);
for j in 0..n {
let it = dc_it(s0 + j);
if it.racks > 0 { info_row(dc_storey_name(j), `${it.racks} racks, ${fmt(it.kw)} kW IT`); }
}
let all = dc_it(-1);
let pl = dc_plant();
if all.racks > 0 { info_row("IT load", `${fmt(all.kw)} kW in ${all.racks} racks`); }
if pl.units > 0 { info_row("Plant cooling", `${fmt(pl.kw)} kW in ${pl.units} chiller / tower units`); }
}
/// Openings on face F of a dc_building, in face coordinates (u from the
/// face's start, z from the ground), by class: slab edges, louvres on the
/// side faces, the front door and the loading dock on the front (-y) face
/// (door_at, dock_at: how far along it, 0 to 1).
fn dc_openings(p, F, H, n, top) {
let out = #{};
let bands = [];
for j in 1..n { bands.push([0.0, F.L, j * H - 0.3, j * H]); }
bands.push([0.0, F.L, top - 0.3, top]);
out["dcband"] = bands;
if p.louvres && (F.f == "E" || F.f == "W") {
let lv = [];
let m = ((F.L - 4.0) / 7.0).floor().to_int();
for j in 0..n {
for i in 0..m {
let u = (F.L - (m - 1) * 7.0) / 2.0 + i * 7.0;
lv.push([u - 1.6, u + 1.6, j * H + 1.4, j * H + 3.4]);
}
}
if lv.len() > 0 { out["louvre"] = lv; }
}
if F.f == "S" {
// along the front from its -x end, kept off the corners
if p.door { let u = clamp(F.L * num(p.door_at), 1.2, F.L - 1.2); out["door"] = [[u - 0.9, u + 0.9, 0.0, 2.4]]; }
if p.dock { let u = clamp(F.L * num(p.dock_at), 2.4, F.L - 2.4); out["door2"] = [[u - 2.0, u + 2.0, 0.0, 4.2]]; }
}
out
}
// ---------------------------------------------------------------- raised floor
fn meta_raised_floor() {
#{ label: "Raised floor", category: "Data centre", icon: "▦", shape: "rect", storey: true,
help: "An access floor on pedestals (0.6 m tiles) whose void is the supply plenum. Racks, pods, CRAH units and cabinets on it stand on its top. Fit it inside the walls; walls take it for a room.",
props: [
#{ key: "w", type: "float", def: 12.0, min: 1.2, max: 200.0, step: 0.6, label: "Width (m)" },
#{ key: "d", type: "float", def: 8.4, min: 1.2, max: 200.0, step: 0.6, label: "Depth (m)" },
#{ key: "h", type: "float", def: 0.6, min: 0.15, max: 1.5, step: 0.05, label: "Void (m)" },
#{ key: "mat", type: "material", def: "dcrf", choices: ["dcrf", "dcrf2", "dcrf3"], label: "Tiles" },
#{ key: "grid", type: "bool", def: true, label: "Tile joints" },
] }
}
fn prefab_raised_floor(o) {
if dc_skip(o) { return; }
let p = o.p;
let w = num(p.w) / 2.0;
let d = num(p.d) / 2.0;
let h = num(p.h);
let s = sprite();
s.box(-w, -d, w, d, 0.0, h, "dcrfs", #{ top: p.mat });
// the void on the sides: dark, with the pedestals every tile
let sd = sides(-w, -d, w, d);
for f in ["S", "E", "N", "W"] {
let L = if f == "S" || f == "N" { 2.0 * w } else { 2.0 * d };
let legs = [];
let u = 0.3;
while u < L - 0.1 {
legs.push([u - 0.03, u + 0.03, 0.0, h - 0.04]);
u += 0.6;
}
s.open(sd[f][0], sd[f][1], [[0.0, L, 0.0, h - 0.04]], "dcvoid");
if legs.len() > 0 && legs.len() < 400 { s.open(sd[f][0], sd[f][1], legs, "dcleg"); }
}
if p.grid {
let segs = [];
let x = -w + 0.6;
while x < w - 0.05 { segs.push([x, -d, x, d]); x += 0.6; }
let y = -d + 0.6;
while y < d - 0.05 { segs.push([-w, y, w, y]); y += 0.6; }
if segs.len() < 700 { s.lines(segs, h, "dctile"); }
}
// what stands on it
let racks = 0;
let kw = 0.0;
for r in objects_of(dc_rack_types()) {
if storey_of(r.z) != storey_of(o.z) { continue; }
let l = local_of(o, r.x, r.y);
if l[0].abs() <= w && l[1].abs() <= d {
racks += dc_racks_of(r);
kw += dc_kw_of(r);
}
}
let area = num(p.w) * num(p.d);
info_row("Area", `${fmt(area)} m², ${fmt(area / 0.36)} tiles of 0.6 m`);
info_row("Void", `${dc_num(h)} m (supply air plenum)`);
if racks > 0 {
info_row("On it", `${racks} racks, ${fmt(kw)} kW IT`);
info_row("Density", `${dc_num(kw / area)} kW/m² over the floor, ${dc_num(kw / racks.to_float())} kW per rack`);
}
}
// ---------------------------------------------------------------- racks
fn dc_rack_kinds() { ["servers", "storage", "GPU", "network", "empty"] }
fn dc_rack_props() {
[
#{ key: "n", type: "int", def: 10, min: 1, max: 40, step: 1, label: "Racks" },
#{ key: "kind", type: "choice", def: "servers", choices: dc_rack_kinds(), label: "Equipment" },
#{ key: "kw", type: "float", def: 8.0, min: 0.0, max: 150.0, step: 0.5, label: "kW per rack" },
#{ key: "fill", type: "int", def: 70, min: 0, max: 100, step: 5, label: "Filled (%)" },
#{ key: "u", type: "int", def: 42, min: 20, max: 52, step: 1, label: "Height (U)" },
#{ key: "rw", type: "float", def: 0.6, min: 0.6, max: 1.0, step: 0.1, label: "Rack width (m)" },
#{ key: "rd", type: "float", def: 1.2, min: 0.6, max: 1.4, step: 0.05, label: "Rack depth (m)" },
#{ key: "doors", type: "choice", def: "mesh", choices: ["mesh", "glass", "solid", "none"], label: "Doors" },
#{ key: "inrow", type: "int", def: 0, min: 0, max: 20, step: 1, label: "Cooler every" },
#{ key: "inrow_kw", type: "float", def: 30.0, min: 5.0, max: 150.0, step: 5.0, label: "Cooler kW" },
#{ key: "leds", type: "bool", def: true, label: "Status LEDs" },
]
}
/// A face along local x at y with outward normal ny (-1 or 1), from -B to
/// B: #{ a, b, k } with u(x) = k * x + B along it.
fn dc_xface(B, y, ny) {
if ny < 0.0 { #{ a: [-B, y], b: [B, y], k: 1.0 } } else { #{ a: [B, y], b: [-B, y], k: -1.0 } }
}
/// [u0, u1, z0, z1] on face F (dc_xface) for x0..x1.
fn dc_span(F, B, x0, x1, z0, z1) {
let u0 = F.k * x0 + B;
let u1 = F.k * x1 + B;
[fmin(u0, u1), fmax(u0, u1), z0, z1]
}
/// A line of racks (and in-row coolers) along local x, centred at y = yc,
/// fronts towards f (-1: local -y, 1: +y), standing at z0, drawn into s.
/// key seeds which slots are filled and the LED timing.
fn dc_rack_line(s, p, yc, f, z0, key) {
let units = dc_units(p);
let h = dc_rack_h(p.u);
let zt = z0 + h;
let rd = num(p.rd) / 2.0;
let y0 = yc - rd;
let y1 = yc + rd;
let items = [];
for q in units {
let a = q[0];
let b = q[1];
let m = if q[2] { "dcinrow" } else { "dccab" };
let t = if q[2] { "dcinrow" } else { "dccabt" };
items.push([(a + b) / 2.0, yc, |c| c.box(a, y0, b, y1, z0, zt, m, #{ top: t })]);
}
s.seq(items);
let B = units[units.len() - 1][1];
let F = dc_xface(B, yc + f * rd, f);
let R = dc_xface(B, yc - f * rd, -f);
let kind = p.kind;
let bays = [];
let gear = [];
let gear2 = [];
let strips = [];
let rear = [];
let pdus = [];
let grilles = [];
let rgrilles = [];
let screens = [];
let handles = [];
let leds = [];
let zb = z0 + 0.1;
let zh = zt - 0.09;
let i = 0;
for q in units {
let a = q[0];
let b = q[1];
if q[2] {
grilles.push(dc_span(F, B, a + 0.03, b - 0.03, z0 + 0.12, zt - 0.32));
screens.push(dc_span(F, B, a + 0.07, b - 0.07, zt - 0.26, zt - 0.14));
rgrilles.push(dc_span(R, B, a + 0.03, b - 0.03, z0 + 0.12, zt - 0.12));
continue;
}
let ia = a + 0.045;
let ib = b - 0.045;
bays.push(dc_span(F, B, ia, ib, zb, zh));
rear.push(dc_span(R, B, ia, ib, zb, zh));
pdus.push(dc_span(R, B, ia + 0.03, ia + 0.07, zb + 0.15, zh - 0.15));
pdus.push(dc_span(R, B, ib - 0.07, ib - 0.03, zb + 0.15, zh - 0.15));
// equipment from the bottom (network: from the top), about `fill` of the slots
let slot = if kind == "storage" { 0.178 } else if kind == "GPU" { 0.267 } else if kind == "network" { 0.0445 } else { 0.0889 };
let ns = ((zh - zb - 0.04) / slot).floor().to_int();
let r = hash(key * 17.3 + i * 3.71);
let used = if kind == "empty" { 0 } else { clamp((ns * p.fill / 100.0 + (r - 0.5) * 3.0).round(), 0.0, ns).to_int() };
for j in 0..used {
if kind == "network" {
let zz = zh - 0.02 - (j + 1) * slot;
if j % 3 == 2 { continue; }
let row = dc_span(F, B, ia + 0.03, ib - 0.03, zz + 0.004, zz + slot - 0.004);
if j % 3 == 0 { gear2.push(row); } else { gear.push(row); }
} else {
// a gap now and then
if hash(key * 5.1 + i * 2.3 + j * 0.77) < 0.08 { continue; }
let za = zb + 0.02 + j * slot;
gear.push(dc_span(F, B, ia + 0.02, ib - 0.02, za + 0.004, za + slot - 0.004));
if kind == "GPU" { strips.push(dc_span(F, B, ia + 0.06, ib - 0.06, za + slot - 0.05, za + slot - 0.025)); }
if kind == "storage" { gear2.push(dc_span(F, B, ia + 0.05, ib - 0.05, za + slot * 0.45, za + slot * 0.55)); }
}
}
if p.doors == "solid" { handles.push(dc_span(F, B, ib - 0.07, ib - 0.05, z0 + 0.9, z0 + 1.3)); }
leds.push([(a + b) / 2.0, i]);
i += 1;
}
let gc = if kind == "storage" { "dcstor" } else if kind == "GPU" { "dcgpu" } else if kind == "network" { "dcnet" } else { "dcsrv" };
let g2 = if kind == "network" { "dcpatch" } else { "dcslot" };
if bays.len() > 0 {
s.open(F.a, F.b, bays, if p.doors == "solid" { "dcdoor" } else { "dcbay" });
if p.doors != "solid" {
if gear.len() > 0 { s.open(F.a, F.b, gear, gc); }
if gear2.len() > 0 { s.open(F.a, F.b, gear2, g2); }
if strips.len() > 0 { s.open(F.a, F.b, strips, "dcstrip"); }
if p.doors == "mesh" { s.open(F.a, F.b, bays, "dcmesh"); }
if p.doors == "glass" { s.open(F.a, F.b, bays, "dcglassd"); }
}
if handles.len() > 0 { s.open(F.a, F.b, handles, "metal"); }
s.open(R.a, R.b, rear, "dcrear");
s.open(R.a, R.b, pdus, "dcpdu0");
}
if grilles.len() > 0 {
s.open(F.a, F.b, grilles, "grille");
s.open(F.a, F.b, screens, "dcscr");
s.open(R.a, R.b, rgrilles, "grille");
}
if p.leds && vis(0.0, f) && kind != "empty" {
let yl = yc + f * (rd + 0.01);
for l in leds {
let x = l[0];
s.begin("a dcblink", `animation-delay:-${round1(hash(key * 7.7 + l[1] * 1.37) * 2.6)}s`);
s.circle(x - 0.12, yl, zt - 0.05, 0.3, "ledg");
s.end();
s.circle(x + 0.12, yl, zt - 0.05, 0.3, if hash(key + l[1] * 9.1) < 0.12 { "ledo" } else { "ledg" });
}
}
#{ B: B, h: h }
}
/// Vent tiles (flat at the floor top z0) between y = ya and yb in front of
/// the racks of a line, as rectangles for one flat.
fn dc_vents(p, ya, yb) {
let out = [];
for q in dc_units(p) {
if !q[2] { out.push(rect(q[0] + 0.02, ya + 0.02, q[1] - 0.02, yb - 0.02)); }
}
out
}
/// Tooltip rows shared by rack rows and pods.
fn dc_rack_info(o, lines) {
let p = o.p;
let n = lines * p.n;
let kw = if p.kind == "empty" { 0.0 } else { n.to_float() * num(p.kw) };
let side = if lines > 1 { `${lines} × ${p.n}` } else { `${p.n}` };
info_row("Racks", `${side} × ${p.u}U ${p.kind}, ${dc_num(num(p.rw) * 1000.0)} × ${dc_num(num(p.rd) * 1000.0)} mm`);
if p.kind != "empty" {
info_row("IT load", `${dc_num(num(p.kw))} kW per rack, ${fmt(kw)} kW in all`);
info_row("Filled", `about ${p.fill} % of ${n * p.u} U`);
}
let c = dc_coolers(p) * lines;
if c > 0 { info_row("In-row cooling", `${c} × ${fmt(num(p.inrow_kw))} kW = ${fmt(c.to_float() * num(p.inrow_kw))} kW`); }
}
fn meta_rack_row() {
#{ label: "Rack row", category: "Data centre", icon: "▥", storey: true,
help: "A row of cabinets along local x, fronts (the cold side) to local -y. It stands on the raised floor under it, with vent tiles in front; in-row coolers between groups of racks (Cooler every).",
props: dc_rack_props() + [ #{ key: "vents", type: "bool", def: true, label: "Vent tiles" } ] }
}
fn prefab_rack_row(o) {
if dc_skip(o) { return; }
let p = o.p;
let z0 = dc_lift(o);
let r = dc_rack_line(sprite(), p, 0.0, -1.0, z0, hash(o.x * 3.1 + o.y * 7.7 + o.z));
if p.vents && z0 > 0.0 && p.kind != "empty" {
let rd = num(p.rd) / 2.0;
sprite().flat(dc_vents(p, -rd - 0.6, -rd), z0, "dcperf");
}
dc_rack_info(o, 1);
}
fn meta_dc_pod() {
#{ label: "Rack pod", category: "Data centre", icon: "▤", storey: true,
help: "Two rows of racks along local x around a shared aisle. Cold aisle containment: the fronts face the aisle, which gets vent tiles, a roof and sliding doors at both ends; hot aisle: the backs face it (optionally with a duct to the ceiling). Stands on the raised floor under it.",
props: dc_rack_props() + [
#{ key: "contain", type: "choice", def: "cold", choices: ["cold", "hot", "none"], label: "Containment" },
#{ key: "aisle", type: "float", def: 1.2, min: 0.9, max: 3.0, step: 0.3, label: "Aisle (m)" },
#{ key: "roof", type: "bool", def: true, label: "Aisle roof" },
#{ key: "ends", type: "bool", def: true, label: "End doors" },
#{ key: "duct", type: "bool", def: false, label: "Duct to ceiling" },
#{ key: "duct_h", type: "float", def: 4.4, min: 3.0, max: 12.0, step: 0.1, label: "Ceiling (m)" },
#{ key: "air", type: "bool", def: true, label: "Airflow" },
] }
}
fn prefab_dc_pod(o) {
if dc_skip(o) { return; }
let p = o.p;
let z0 = dc_lift(o);
let a = num(p.aisle) / 2.0;
let rd = num(p.rd) / 2.0;
let hot = p.contain == "hot";
let cont = p.contain != "none";
let key = hash(o.x * 3.1 + o.y * 7.7 + o.z);
// cold aisle: fronts face the aisle; hot aisle: backs do
let fa = if hot { -1.0 } else { 1.0 };
let r = dc_rack_line(sprite(), p, -a - rd, fa, z0, key);
dc_rack_line(sprite(), p, a + rd, -fa, z0, key + 0.37);
let B = r.B;
let zt = z0 + r.h;
let units = dc_units(p);
let s = sprite();
let ends = if !cont || !p.ends { [] } else if vis(1.0, 0.0) { [-B, B] } else { [B, -B] };
if ends.len() > 0 { dc_aisle_door(s, ends[0], a, z0, zt, hot); }
if !hot && z0 > 0.0 && p.kind != "empty" { s.flat(dc_vents(p, -a, a), z0, "dcperf"); }
if p.air && p.kind != "empty" {
let i = 0;
for q in units {
if q[2] { continue; }
if i % 2 == 0 {
let x = (q[0] + q[1]) / 2.0;
if hot { s.puffs(x, 0.0, zt - 0.5, "dcheat", 2, 3.4); } else if z0 > 0.0 { s.puffs(x, 0.0, z0 + 0.15, "dcair", 2, 3.0); }
}
i += 1;
}
}
if ends.len() > 0 { dc_aisle_door(s, ends[1], a, z0, zt, hot); }
if cont && p.roof {
s.box(-B, -a, B, a, zt, zt + 0.04, if hot { "dcglassh" } else { "dcglass" });
let segs = [[-B, -a, B, -a], [-B, a, B, a]];
for q in units { segs.push([q[1], -a, q[1], a]); }
segs.push([-B, -a, -B, a]);
s.lines(segs, zt + 0.04, "dcframe");
}
if hot && p.duct && num(p.duct_h) > r.h + 0.3 {
s.box(-B + 0.2, -a + 0.05, B - 0.2, a - 0.05, zt + 0.04, num(p.duct_h), "dcalu");
}
s.ext_box(-B, -a, B, a, z0, z0);
dc_rack_info(o, 2);
let what = if p.contain == "cold" { "cold aisle" } else if hot { "hot aisle" } else { "none (open aisles)" };
let parts = [];
if cont && p.roof { parts.push("roof"); }
if cont && p.ends { parts.push("end doors"); }
if hot && p.duct { parts.push("ceiling duct"); }
let extra = "";
for x in parts { extra += if extra == "" { ", " + x } else { " and " + x }; }
info_row("Containment", `${what}, ${dc_num(num(p.aisle))} m${extra}`);
}
/// The sliding doors closing a contained aisle at x (an end of it).
fn dc_aisle_door(s, x, a, z0, zt, hot) {
let x0 = if x > 0.0 { x - 0.05 } else { x };
let x1 = x0 + 0.05;
let g = if hot { "dcglassh" } else { "dcglass" };
s.box(x0, -a, x1, -a + 0.05, z0, zt, "dcalu");
s.box(x0, a - 0.05, x1, a, z0, zt, "dcalu");
s.box(x0, -a + 0.05, x1, a - 0.05, zt - 0.1, zt, "dcalu");
s.box(x0 + 0.01, -a + 0.05, x1 - 0.01, a - 0.05, z0, zt - 0.1, g);
// the meeting stiles, on whichever side faces the viewer
s.open([x1, -a], [x1, a], [[a - 0.02, a + 0.02, z0, zt - 0.1]], "dcalu");
s.open([x0, a], [x0, -a], [[a - 0.02, a + 0.02, z0, zt - 0.1]], "dcalu");
s
}
fn meta_dc_cage() {
#{ label: "Cage", category: "Data centre", icon: "▢", shape: "rect", storey: true,
help: "A colocation cage: mesh panels on posts round the rectangle, a door on the local -y side. Leave room between the mesh and the racks inside.",
props: [
#{ key: "w", type: "float", def: 6.0, min: 1.2, max: 60.0, step: 0.3, label: "Width (m)" },
#{ key: "d", type: "float", def: 4.8, min: 1.2, max: 60.0, step: 0.3, label: "Depth (m)" },
#{ key: "h", type: "float", def: 2.4, min: 1.0, max: 4.0, step: 0.1, label: "Height (m)" },
#{ key: "door", type: "bool", def: true, label: "Door" },
] }
}
fn prefab_dc_cage(o) {
if dc_skip(o) { return; }
let p = o.p;
let z0 = dc_lift(o);
let w = num(p.w) / 2.0;
let d = num(p.d) / 2.0;
let h = num(p.h);
let t = 0.05;
// one sprite per side (the ones along x run through the corners)
let walls = [
[-w, -d, w, -d + t, "x"], [-w, d - t, w, d, "x"],
[-w, -d + t, -w + t, d - t, "y"], [w - t, -d + t, w, d - t, "y"],
];
let k = 0;
for q in walls {
let s = sprite();
let alongx = q[4] == "x";
let L = if alongx { q[2] - q[0] } else { q[3] - q[1] };
let gap = if k == 0 && p.door && L > 2.0 { [-0.5, 0.5] } else { [] };
// mesh panels between posts every 1.2 m (and either side of the door)
let cuts = [0.0];
let m = (L / 1.2).ceiling().to_int();
for i in 1..m { cuts.push(L * i / m); }
cuts.push(L);
let posts = [];
for c in cuts { posts.push(c); }
if gap.len() > 0 { posts.push(L / 2.0 + gap[0]); posts.push(L / 2.0 + gap[1]); }
let items = [];
for c in posts {
let px = if alongx { q[0] + clamp(c, 0.03, L - 0.03) } else { (q[0] + q[2]) / 2.0 };
let py = if alongx { (q[1] + q[3]) / 2.0 } else { q[1] + clamp(c, 0.03, L - 0.03) };
items.push([px, py, |r| r.box(px - 0.03, py - 0.03, px + 0.03, py + 0.03, z0, z0 + h, "steel")]);
}
let panels = [];
for i in 0..(cuts.len() - 1) {
let u0 = cuts[i] + 0.03;
let u1 = cuts[i + 1] - 0.03;
if gap.len() > 0 && u1 > L / 2.0 + gap[0] && u0 < L / 2.0 + gap[1] {
// the door leaf (closed) in the gap, panels either side of it
if L / 2.0 + gap[0] - 0.03 > u0 + 0.05 { panels.push([u0, L / 2.0 + gap[0] - 0.03]); }
if u1 > L / 2.0 + gap[1] + 0.08 { panels.push([L / 2.0 + gap[1] + 0.03, u1]); }
continue;
}
panels.push([u0, u1]);
}
for u in panels {
let a = u[0];
let b = u[1];
let cx = if alongx { q[0] + (a + b) / 2.0 } else { (q[0] + q[2]) / 2.0 };
let cy = if alongx { (q[1] + q[3]) / 2.0 } else { q[1] + (a + b) / 2.0 };
items.push([cx, cy, |r| if alongx { r.box(q[0] + a, q[1] + 0.01, q[0] + b, q[3] - 0.01, z0 + 0.05, z0 + h - 0.05, "dcmeshp") } else { r.box(q[0] + 0.01, q[1] + a, q[2] - 0.01, q[1] + b, z0 + 0.05, z0 + h - 0.05, "dcmeshp") }]);
}
if gap.len() > 0 {
let a = L / 2.0 + gap[0] + 0.03;
let b = L / 2.0 + gap[1] - 0.03;
items.push([q[0] + (a + b) / 2.0, (q[1] + q[3]) / 2.0, |r| r.box(q[0] + a, q[1] + 0.01, q[0] + b, q[3] - 0.01, z0 + 0.02, z0 + 2.05, "dcmeshd")]);
}
s.seq(items);
k += 1;
}
let racks = 0;
for r in objects_of(dc_rack_types()) {
if storey_of(r.z) != storey_of(o.z) { continue; }
let l = local_of(o, r.x, r.y);
if l[0].abs() <= w && l[1].abs() <= d { racks += dc_racks_of(r); }
}
info_row("Cage", `${dc_num(num(p.w))} × ${dc_num(num(p.d))} m, ${dc_num(h)} m mesh`);
if racks > 0 { info_row("Inside", `${racks} racks`); }
}
// ---------------------------------------------------------------- overhead
fn meta_cable_tray() {
#{ label: "Cable tray", category: "Data centre", icon: "☰", shape: "line", storey: true,
help: "An overhead ladder tray (power), fibre duct (yellow) or wire basket (copper) along local x, hung above the racks. Height above the storey's slab.",
props: [
#{ key: "w", type: "float", def: 10.0, min: 0.5, max: 60.0, step: 0.1, label: "Length (m)" },
#{ key: "kind", type: "choice", def: "ladder", choices: ["ladder", "fibre", "basket"], label: "Kind" },
#{ key: "width", type: "float", def: 0.45, min: 0.1, max: 1.2, step: 0.05, label: "Width (m)" },
#{ key: "h", type: "float", def: 3.6, min: 2.0, max: 8.0, step: 0.05, label: "Height (m)" },
#{ key: "fill", type: "int", def: 50, min: 0, max: 100, step: 10, label: "Cables (%)" },
] }
}
fn prefab_cable_tray(o) {
if dc_skip(o) || dc_no_ceiling() { return; }
let p = o.p;
let w = num(p.w) / 2.0;
let b = num(p.width) / 2.0;
let h = num(p.h);
let m = if p.kind == "fibre" { "dcfibre" } else if p.kind == "basket" { "dcbasket" } else { "dctray" };
let side = if p.kind == "fibre" { 0.1 } else { 0.08 };
let cab = if p.kind == "fibre" { "dcfibrec" } else if p.kind == "basket" { "dccablec" } else { "dccable" };
dc_ceiling_layer();
let near = if vis(0.0, 1.0) { 1.0 } else { -1.0 };
// the rails: the far one is drawn first, the near one last
let fy0 = if near > 0.0 { -b } else { b - 0.02 };
let ny0 = if near > 0.0 { b - 0.02 } else { -b };
let pieces = (num(p.w) / 15.0).ceiling().to_int();
for i in 0..pieces {
let x0 = -w + num(p.w) * i / pieces;
let x1 = -w + num(p.w) * (i + 1) / pieces;
let s = sprite();
// hangers first (behind), then the far rail, the bed, cables, the near rail
let x = x0 + 0.4;
while x < x1 - 0.1 {
s.stem(x, -near * (b - 0.02), h + side, h + side + 0.2, 0.1, "metal");
x += 1.5;
}
s.box(x0, fy0, x1, fy0 + 0.02, h, h + side, m);
if p.kind == "ladder" {
let rungs = [];
let x = x0 + 0.15;
while x < x1 { rungs.push([x - 0.02, -b + 0.02, x + 0.02, b - 0.02]); x += 0.3; }
s.flat(rungs, h + 0.005, m + " t");
} else {
s.box(x0, -b + 0.02, x1, b - 0.02, h, h + 0.015, m);
}
if p.fill > 0 {
let ch = 0.01 + (side - 0.02) * p.fill / 100.0;
s.box(x0, -b + 0.03, x1, b - 0.03, h + 0.015, h + 0.015 + ch, cab);
}
s.box(x0, ny0, x1, ny0 + 0.02, h, h + side, m);
// sort by the tray alone: its hangers would reach into the runs above
s.ext_reset();
s.ext_box(x0, -b, x1, b, h, h + side);
}
let what = if p.kind == "fibre" { "fibre duct" } else if p.kind == "basket" { "wire basket (copper)" } else { "ladder tray (power)" };
info_row("Tray", `${what}, ${dc_num(num(p.width) * 1000.0)} mm wide, ${dc_num(num(p.w))} m`);
info_row("Height", `${dc_num(h)} m above the slab, cables about ${p.fill} % full`);
}
fn meta_busway() {
#{ label: "Busway", category: "Power", icon: "≡", shape: "line", storey: true,
help: "Overhead busbar trunking along local x, hung over a rack row, with a tap-off box for each rack. Height above the storey's slab.",
props: [
#{ key: "w", type: "float", def: 8.0, min: 1.0, max: 60.0, step: 0.1, label: "Length (m)" },
#{ key: "h", type: "float", def: 3.2, min: 2.0, max: 8.0, step: 0.05, label: "Height (m)" },
#{ key: "amps", type: "int", def: 400, min: 100, max: 1600, step: 50, label: "Rating (A)" },
#{ key: "taps", type: "float", def: 0.6, min: 0.0, max: 5.0, step: 0.1, label: "Tap-off every (m)" },
] }
}
fn prefab_busway(o) {
if dc_skip(o) || dc_no_ceiling() { return; }
let p = o.p;
let w = num(p.w) / 2.0;
let h = num(p.h);
let tp = num(p.taps);
dc_ceiling_layer();
let pieces = (num(p.w) / 15.0).ceiling().to_int();
let taps = 0;
for i in 0..pieces {
let x0 = -w + num(p.w) * i / pieces;
let x1 = -w + num(p.w) * (i + 1) / pieces;
let s = sprite();
let x = x0 + 0.5;
while x < x1 - 0.1 {
s.stem(x, 0.0, h + 0.15, h + 0.35, 0.1, "metal");
x += 2.0;
}
if tp > 0.05 {
let items = [];
let x = -w + tp / 2.0;
while x < w {
if x >= x0 && x < x1 {
let c = x;
items.push([c, 0.0, |q| q.box(c - 0.11, -0.1, c + 0.11, 0.1, h - 0.26, h, "dctap")]);
taps += 1;
}
x += tp;
}
s.seq(items);
}
s.box(x0, -0.08, x1, 0.08, h, h + 0.15, "dcbus");
s.ext_reset();
s.ext_box(x0, -0.11, x1, 0.11, h - 0.26, h + 0.15);
}
info_row("Busway", `${fmt(p.amps)} A, about ${fmt(1.732 * 0.415 * p.amps * 0.95)} kW at 415 V`);
info_row("Length", `${dc_num(num(p.w))} m, ${taps} tap-off boxes, ${dc_num(h)} m above the slab`);
}
// ---------------------------------------------------------------- cooling
fn meta_crah() {
#{ label: "CRAH unit", category: "Cooling", icon: "❄", storey: true,
help: "A computer room air handler (chilled water) or CRAC (DX) unit, front to local -y, blowing down into the raised floor. Its tooltip sets the storey's cooling (units on duty, standby, in-row coolers) against the IT load of its racks.",
props: [
#{ key: "kind", type: "choice", def: "CRAH", choices: ["CRAH", "CRAC"], label: "Type" },
#{ key: "kw", type: "float", def: 120.0, min: 10.0, max: 600.0, step: 5.0, label: "Cooling (kW)" },
#{ key: "w", type: "float", def: 2.5, min: 0.8, max: 5.0, step: 0.1, label: "Width (m)" },
#{ key: "standby", type: "bool", def: false, label: "Standby" },
#{ key: "running", type: "bool", def: true, label: "Fans running" },
] }
}
fn prefab_crah(o) {
if dc_skip(o) { return; }
let p = o.p;
let z0 = dc_lift(o);
let W = num(p.w);
let w = W / 2.0;
let d = 0.45;
let h = 1.95;
let s = sprite();
s.box(-w, -d, w, d, z0, z0 + h, "dccrah");
let sd = sides(-w, -d, w, d);
let nd = fmax(1.0, (W / 0.85).round()).to_int();
let doors = [];
for i in 0..nd { doors.push([i * W / nd + 0.03, (i + 1) * W / nd - 0.03, z0 + 0.06, z0 + h - 0.06]); }
s.open(sd.S[0], sd.S[1], doors, "seam");
s.open(sd.S[0], sd.S[1], [[0.2, 0.62, z0 + h - 0.42, z0 + h - 0.2]], "dcscr");
s.open(sd.N[0], sd.N[1], [[0.1, W - 0.1, z0 + h - 0.5, z0 + h - 0.1]], "grille");
// return air grille on top, the fans under it
s.flat(rect(-w + 0.08, -d + 0.08, w - 0.08, d - 0.08), z0 + h, "grille");
let nf = fmax(1.0, (W / 0.8).floor()).to_int();
let spin = p.running && !p.standby;
for i in 0..nf {
let x = -w + (i + 0.5) * W / nf;
if spin { s.fan(x, 0.0, z0 + h + 0.005, 0.28); } else { s.disc(x, 0.0, z0 + h + 0.005, 0.28, "fanring"); }
}
if vis(0.0, -1.0) {
s.circle(-w + 0.75, -d - 0.01, z0 + h - 0.3, 0.35, if p.standby { "ledo" } else if p.running { "ledg" } else { "ledr" });
}
let st = storey_of(o.z);
let it = dc_it(st);
let c = dc_cool(st);
info_row("Type", if p.kind == "CRAC" { "CRAC (DX refrigerant)" } else { "CRAH (chilled water)" });
info_row("Cooling", `${fmt(num(p.kw))} kW, ${if p.standby { "standby" } else if p.running { "on duty" } else { "stopped" }}`);
info_row("This storey", `${fmt(it.kw)} kW IT in ${it.racks} racks; ${fmt(c.duty)} kW duty + ${fmt(c.standby)} kW standby cooling`);
info_row("Redundancy", dc_redundancy(c, it.kw));
}
fn meta_chiller() {
#{ label: "Chiller", category: "Cooling", icon: "⛆", storey: true,
help: "An air-cooled chiller, dry cooler or adiabatic cooler along local x with two rows of fans on top, on the roof or in the yard. Its tooltip sets all the chillers and towers against the IT load of every storey.",
props: [
#{ key: "kind", type: "choice", def: "chiller", choices: ["chiller", "dry cooler", "adiabatic"], label: "Type" },
#{ key: "w", type: "float", def: 9.0, min: 3.0, max: 20.0, step: 0.5, label: "Length (m)" },
#{ key: "kw", type: "float", def: 1000.0, min: 50.0, max: 5000.0, step: 50.0, label: "Cooling (kW)" },
#{ key: "running", type: "bool", def: true, label: "Fans running" },
] }
}
fn prefab_chiller(o) {
if dc_skip(o) { return; }
let p = o.p;
let W = num(p.w);
let w = W / 2.0;
let d = 1.15;
let h = 2.4;
let s = sprite();
s.box(-w, -d, w, d, 0.0, 0.15, "steel");
s.box(-w, -d + 0.08, w, d - 0.08, 0.15, h, "chiller");
let n = fmax(1.0, (W / 1.2).floor()).to_int();
let coils = [];
for i in 0..n { coils.push([i * W / n + 0.06, (i + 1) * W / n - 0.06, 0.45, h - 0.15]); }
let sd = sides(-w, -d + 0.08, w, d - 0.08);
let cc = if p.kind == "adiabatic" { "dcpad" } else { "dccoil" };
s.open(sd.S[0], sd.S[1], coils, cc);
s.open(sd.N[0], sd.N[1], coils, cc);
s.open(sd.W[0], sd.W[1], [[0.25, 1.1, 0.5, 1.9]], "panel");
s.open(sd.E[0], sd.E[1], [[0.3, 2.0 * d - 0.46, 0.4, h - 0.2]], "grille");
for i in 0..n {
let x = -w + (i + 0.5) * W / n;
for y in [-0.52, 0.52] {
if p.running { s.fan(x, y, h + 0.005, 0.42); } else { s.disc(x, y, h + 0.005, 0.42, "fanring"); }
}
}
let all = dc_it(-1);
let pl = dc_plant();
info_row("Type", if p.kind == "dry cooler" { "dry cooler (free cooling)" } else if p.kind == "adiabatic" { "adiabatic cooler (wetted pads)" } else { "air-cooled chiller" });
info_row("Cooling", `${fmt(num(p.kw))} kW, ${2 * n} fans${if p.running { "" } else { " (stopped)" }}`);
info_row("Plant", `${pl.units} units, ${fmt(pl.kw)} kW for ${fmt(all.kw)} kW IT (${dc_redundancy(#{ duty: pl.kw, standby: 0.0, units: pl.units }, all.kw)})`);
}
fn meta_cooling_tower() {
#{ label: "Cooling tower", category: "Cooling", icon: "♨", storey: true,
help: "Open evaporative cooling tower cells along local x (4 m each) with a fan stack on each and a plume when running.",
props: [
#{ key: "cells", type: "int", def: 2, min: 1, max: 6, step: 1, label: "Cells" },
#{ key: "kw", type: "float", def: 2000.0, min: 100.0, max: 10000.0, step: 100.0, label: "kW per cell" },
#{ key: "running", type: "bool", def: true, label: "Running (plume)" },
] }
}
fn prefab_cooling_tower(o) {
if dc_skip(o) { return; }
let p = o.p;
let n = p.cells;
let c = 4.0;
let h = 3.6;
let x0 = -n * c / 2.0;
let s = sprite();
let items = [];
for i in 0..n {
let a = x0 + i * c;
let b = a + c;
let x = (a + b) / 2.0;
items.push([x, 0.0, |q| {
q.box(a + 0.02, -c / 2.0, b - 0.02, c / 2.0, 0.0, h, "dctower", #{ top: "dctowert" });
let sd = sides(a + 0.02, -c / 2.0, b - 0.02, c / 2.0);
for f in ["S", "N"] { q.open(sd[f][0], sd[f][1], [[0.2, c - 0.24, 0.3, 1.5]], "louvre"); }
for f in ["E", "W"] { q.open(sd[f][0], sd[f][1], [[0.2, c - 0.2, 0.3, 1.5]], "louvre"); }
q.cyl(x, 0.0, 1.55, h, h + 1.0, "dctower", #{ no_top: true });
if p.running { q.fan(x, 0.0, h + 0.95, 1.35); } else { q.disc(x, 0.0, h + 0.95, 1.35, "fanring"); }
}]);
}
s.seq(items);
if p.running {
for i in 0..n { s.puffs(x0 + (i + 0.5) * c, 0.0, h + 1.3, "puff2", 3, 4.0); }
}
let all = dc_it(-1);
info_row("Cells", `${n} × ${fmt(num(p.kw))} kW = ${fmt(n * num(p.kw))} kW`);
if all.racks > 0 { info_row("Site IT load", `${fmt(all.kw)} kW`); }
}
fn meta_pipe_run() {
#{ label: "Pipe run", category: "Cooling", icon: "═", shape: "line", storey: true,
help: "Flow and return pipes along local x (drag to draw) on stands or overhead: chilled water (blue / red), condenser water (green) or glycol (purple). Height of the pipe centre above the object.",
props: [
#{ key: "w", type: "float", def: 10.0, min: 0.5, max: 60.0, step: 0.1, label: "Length (m)" },
#{ key: "kind", type: "choice", def: "chilled water", choices: ["chilled water", "condenser", "glycol"], label: "Medium" },
#{ key: "dn", type: "float", def: 0.25, min: 0.05, max: 0.8, step: 0.05, label: "Diameter (m)" },
#{ key: "h", type: "float", def: 0.8, min: 0.2, max: 8.0, step: 0.05, label: "Height (m)" },
#{ key: "pair", type: "bool", def: true, label: "Flow and return" },
#{ key: "stands", type: "bool", def: true, label: "Stands" },
] }
}
fn prefab_pipe_run(o) {
if dc_skip(o) { return; }
let p = o.p;
let w = num(p.w) / 2.0;
let r = num(p.dn) / 2.0;
let z = num(p.h);
let off = if p.pair { r + 0.06 } else { 0.0 };
let mats = if p.kind == "condenser" { ["dcpipeg", "dcpipeg2"] } else if p.kind == "glycol" { ["dcpipev", "dcpipev"] } else { ["pipec", "pipes"] };
// the -y pipe is the flow, +y the return; the far one first
let ys = if p.pair { if vis(0.0, 1.0) { [[-off, mats[0]], [off, mats[1]]] } else { [[off, mats[1]], [-off, mats[0]]] } } else { [[0.0, mats[0]]] };
let pieces = (num(p.w) / 15.0).ceiling().to_int();
for i in 0..pieces {
let x0 = -w + num(p.w) * i / pieces;
let x1 = -w + num(p.w) * (i + 1) / pieces;
let s = sprite();
if p.stands && z - r > 0.25 {
let items = [];
let x = x0 + 0.6;
while x < x1 - 0.2 {
let c = x;
items.push([c, 0.0, |q| {
q.box(c - 0.04, -0.04, c + 0.04, 0.04, 0.0, z - r - 0.05, "steel");
q.box(c - 0.05, -off - r, c + 0.05, off + r, z - r - 0.05, z - r, "steel");
}]);
x += 3.0;
}
s.seq(items);
}
// faces edged in their own shade, so a pipe reads as one surface
for q in ys { s.tube([x0, q[0], z], [x1, q[0], z], r, r, 10, q[1] + " beast"); }
}
let what = if p.kind == "condenser" { "condenser water" } else if p.kind == "glycol" { "glycol" } else { "chilled water" };
info_row("Pipes", `${what}, ${if p.pair { "flow and return" } else { "one pipe" }}, DN ${fmt(num(p.dn) * 1000.0)}`);
info_row("Run", `${dc_num(num(p.w))} m at ${dc_num(z)} m`);
}
// ---------------------------------------------------------------- fire safety
fn meta_smoke_detectors() {
#{ label: "Detectors", category: "Fire safety", icon: "◉", shape: "rect", storey: true,
help: "Point detectors on a grid over the rectangle at ceiling height, with their coverage circles. Data halls with high air change rates need a closer spacing than the usual 7.5 m radius.",
props: [
#{ key: "w", type: "float", def: 18.0, min: 1.0, max: 200.0, step: 0.5, label: "Width (m)" },
#{ key: "d", type: "float", def: 12.0, min: 1.0, max: 200.0, step: 0.5, label: "Depth (m)" },
#{ key: "spacing", type: "float", def: 6.0, min: 2.0, max: 15.0, step: 0.5, label: "Spacing (m)" },
#{ key: "h", type: "float", def: 4.5, min: 2.0, max: 12.0, step: 0.05, label: "Height (m)" },
#{ key: "kind", type: "choice", def: "smoke", choices: ["smoke", "heat", "multi-sensor"], label: "Kind" },
#{ key: "coverage", type: "bool", def: true, label: "Coverage circles" },
#{ key: "zone", type: "text", def: "", label: "Zone" },
] }
}
fn prefab_smoke_detectors(o) {
if dc_skip(o) || dc_no_ceiling() { return; }
let p = o.p;
let W = num(p.w);
let D = num(p.d);
let sp = num(p.spacing);
let h = num(p.h);
let nx = fmax(1.0, (W / sp).ceiling()).to_int();
let ny = fmax(1.0, (D / sp).ceiling()).to_int();
let r = (W / nx) * (W / nx) / 4.0 + (D / ny) * (D / ny) / 4.0;
r = r.sqrt();
dc_ceiling_layer();
let s = sprite();
let pts = [];
for i in 0..nx {
for j in 0..ny { pts.push([-W / 2.0 + (i + 0.5) * W / nx, -D / 2.0 + (j + 0.5) * D / ny]); }
}
if p.coverage {
for q in pts { s.disc(q[0], q[1], h - 0.06, r, "dccov"); }
}
// the detectors seen from above (the ceiling is not drawn): white discs
let m = if p.kind == "heat" { "dcdethd" } else { "dcdetd" };
for q in pts { s.disc(q[0], q[1], h - 0.04, 0.11, m); }
s.begin("a dcflash", `animation-delay:-${round1(hash(o.x + o.y) * 4.0)}s`);
for q in pts { s.circle(q[0], q[1], h - 0.075, 0.28, "dcdled"); }
s.end();
s.ext_reset();
s.ext_box(-W / 2.0, -D / 2.0, W / 2.0, D / 2.0, h - 0.08, h);
let n = nx * ny;
let z = if p.zone == "" { "" } else { `${p.zone}: ` };
info_row("Detectors", `${z}${n} ${p.kind} detectors, ${nx} × ${ny} at ${dc_num(W / nx)} × ${dc_num(D / ny)} m`);
info_row("Coverage", `${dc_num(W * D / n)} m² each, radius ${dc_num(r)} m to the farthest corner`);
}
fn meta_fire_pipe() {
#{ label: "Fire pipe", category: "Fire safety", icon: "┅", shape: "line", storey: true,
help: "A pipe at ceiling height along local x: VESDA aspirating smoke detection (sampling holes, the detector unit at the start, down the wall), pre-action sprinkler heads, or clean-agent gas discharge nozzles. Height above the storey's slab.",
props: [
#{ key: "w", type: "float", def: 12.0, min: 0.5, max: 100.0, step: 0.1, label: "Length (m)" },
#{ key: "kind", type: "choice", def: "VESDA", choices: ["VESDA", "sprinkler", "gas"], label: "Kind" },
#{ key: "spacing", type: "float", def: 4.0, min: 0.5, max: 12.0, step: 0.1, label: "Holes / heads every (m)" },
#{ key: "h", type: "float", def: 4.3, min: 2.0, max: 12.0, step: 0.05, label: "Height (m)" },
#{ key: "unit", type: "bool", def: true, label: "Detector unit (VESDA)" },
] }
}
fn prefab_fire_pipe(o) {
if dc_skip(o) || dc_no_ceiling() { return; }
let p = o.p;
let w = num(p.w) / 2.0;
let h = num(p.h);
let sp = num(p.spacing);
let k = p.kind;
let cls = if k == "gas" { "dcgasl" } else if k == "sprinkler" { "dcsprl" } else { "dcvesl" };
let n = fmax(1.0, (num(p.w) / sp).floor()).to_int();
let xs = [];
for i in 0..n { xs.push(-w + (i + 0.5) * num(p.w) / n); }
dc_ceiling_layer();
let s = sprite();
s.polyline([[[-w, 0.0, h], [w, 0.0, h]]], 0.0, cls);
if k == "VESDA" {
for x in xs { s.circle(x, 0.0, h - 0.02, 0.4, "dchole"); }
} else if k == "sprinkler" {
for x in xs { s.stem(x, 0.0, h - 0.12, h, 0.12, "dcspr"); s.circle(x, 0.0, h - 0.13, 0.42, "dcsprh"); }
} else {
let items = [];
for x in xs { let c = x; items.push([c, 0.0, |q| q.cyl(c, 0.0, 0.05, h - 0.14, h, "dcgaspipe")]); }
s.seq(items);
}
// a tight sort box (VESDA holes sit on the pipe, heads and nozzles hang below)
s.ext_reset();
s.ext_box(-w, -0.05, w, 0.05, if k == "VESDA" { h - 0.03 } else { h - 0.15 }, h + 0.01);
if k == "VESDA" && p.unit {
// the detector unit at the start, at eye height, the pipe dropping to it
let z0 = dc_lift_at(o, world_of(o, -w, 0.0)[0], world_of(o, -w, 0.0)[1]);
layer("main");
let u = sprite();
u.polyline([[[-w, 0.0, h], [-w, 0.0, z0 + 1.75]]], 0.0, cls);
u.box(-w - 0.06, -0.18, -w + 0.06, 0.18, z0 + 1.3, z0 + 1.75, "dcvesda", #{ top: "dcvesda" });
u.ext_box(-w - 0.06, -0.18, -w + 0.06, 0.18, z0 + 1.3, h);
}
let what = if k == "gas" { "gas discharge pipe" } else if k == "sprinkler" { "pre-action sprinkler pipe" } else { "VESDA sampling pipe" };
let heads = if k == "gas" { "nozzles" } else if k == "sprinkler" { "heads" } else { "sampling holes" };
info_row("Pipe", `${what}, ${dc_num(num(p.w))} m at ${dc_num(h)} m`);
info_row(if k == "VESDA" { "Holes" } else { "Heads" }, `${n} ${heads}, ${dc_num(num(p.w) / n)} m apart`);
if k == "VESDA" { info_row("Transport", `about ${fmt(num(p.w) / 1.5 + 5.0)} s from the far hole (aim for < 60 s)`); }
}
/// Clean agent needed for V m³ at t °C: #{ need, per (one cylinder), unit, c }.
fn dc_agent(agent, V, t, litres) {
if agent == "FM-200" {
let S = 0.1269 + 0.000513 * t;
let c = 7.0;
#{ need: V / S * c / (100.0 - c), per: litres * 1.0, unit: "kg", c: c, fill: "1.0 kg/l" }
} else if agent == "Inergen" {
let S = 0.65799 + 0.00239 * t;
let c = 39.9;
#{ need: 2.303 * (0.707 / S) * (100.0 / (100.0 - c)).ln() / 2.302585 * V, per: litres * 0.295, unit: "m³", c: c, fill: "300 bar" }
} else {
let S = 0.0664 + 0.0002741 * t;
let c = 4.5;
#{ need: V / S * c / (100.0 - c), per: litres * 1.2, unit: "kg", c: c, fill: "1.2 kg/l" }
}
}
/// Volume a gas bank on storey s protects: its halls (raised floors, void and
/// ceiling included) or, without any, the dc_building it stands in.
fn dc_gas_volume(o) {
let s = storey_of(o.z);
let H = storey_h() - 0.3;
let v = 0.0;
for f in objects_of(["raised_floor"]) {
if storey_of(f.z) == s { v += num(f.p.w) * num(f.p.d) * H; }
}
if v > 0.0 { return v; }
for b in objects_at(["dc_building"], o.x, o.y) {
v = (num(b.p.w) - 2.0 * num(b.p.t)) * (num(b.p.d) - 2.0 * num(b.p.t)) * H;
}
v
}
fn meta_gas_bank() {
#{ label: "Gas cylinders", category: "Fire safety", icon: "⛽", storey: true,
help: "A bank of clean-agent cylinders along local x, backs to +y (against a wall), with the manifold and release heads. It sizes the agent for the protected volume (the raised-floor halls on its storey, void and ceiling included, unless Volume is set) and warns when the bank is short.",
props: [
#{ key: "agent", type: "choice", def: "Novec 1230", choices: ["Novec 1230", "FM-200", "Inergen"], label: "Agent" },
#{ key: "n", type: "int", def: 8, min: 1, max: 40, step: 1, label: "Cylinders" },
#{ key: "litres", type: "int", def: 140, min: 40, max: 180, step: 10, label: "Cylinder (l)" },
#{ key: "vol", type: "float", def: 0.0, min: 0.0, max: 200000.0, step: 50.0, label: "Volume (m³, 0: auto)" },
#{ key: "temp", type: "float", def: 20.0, min: 0.0, max: 40.0, step: 1.0, label: "Design °C" },
] }
}
fn prefab_gas_bank(o) {
if dc_skip(o) { return; }
let p = o.p;
let z0 = dc_lift(o);
let r = 0.13 + (num(p.litres) - 80.0) * 0.0005;
let ch = 1.0 + num(p.litres) * 0.0045;
let pitch = 2.0 * r + 0.08;
let n = p.n;
let B = n * pitch / 2.0;
let s = sprite();
// the wall bracket behind, then the cylinders back to front, the manifold over them
s.box(-B, r + 0.02, B, r + 0.07, z0 + ch * 0.55, z0 + ch * 0.62, "steel");
let items = [];
let shoulder = if p.agent == "Inergen" { "dcgreen" } else { "firetank" };
for i in 0..n {
let x = -B + (i + 0.5) * pitch;
items.push([x, 0.0, |q| {
q.cyl(x, 0.0, r, z0, z0 + ch - r * 0.6, "firetank", #{ no_top: true });
q.frust(x, 0.0, r, r * 0.35, z0 + ch - r * 0.6, z0 + ch, shoulder, 12);
q.box(x - 0.06, -0.06, x + 0.06, 0.06, z0 + ch, z0 + ch + 0.1, "keyb");
}]);
}
s.seq(items);
let zm = z0 + ch + 0.25;
s.tube([-B, 0.0, zm], [B, 0.0, zm], 0.05, 0.05, 8, "dcgaspipe beast");
s.tube([B, 0.0, zm], [B, 0.0, zm + 1.0], 0.05, 0.05, 8, "dcgaspipe beast");
let V = if num(p.vol) > 0.0 { num(p.vol) } else { dc_gas_volume(o) };
let a = dc_agent(p.agent, V, num(p.temp), num(p.litres));
let have = n * a.per;
info_row("Agent", `${p.agent}, ${dc_num(a.c)} % design concentration at ${dc_num(num(p.temp))} °C`);
info_row("Volume", `${fmt(V)} m³${if num(p.vol) > 0.0 { "" } else { " (halls on this storey, void and ceiling included)" }}`);
info_row("Needed", `${fmt(a.need)} ${a.unit}`);
info_row("Installed", `${n} × ${p.litres} l = ${fmt(have)} ${a.unit} (${a.fill})`);
if V <= 0.0 {
info_row("Status", "no volume: set one or put raised floors on this storey");
} else if have + 1e-6 < a.need {
let more = ((a.need - have) / a.per).ceiling().to_int();
info_row("Status", `short: ${more} more cylinder${if more == 1 { "" } else { "s" }}`);
tag(0.0, 0.0, z0 + ch + 0.6, `Gas: ${more} cylinder${if more == 1 { "" } else { "s" }} short`, 9, o.id, "warntag");
} else {
info_row("Status", `enough, ${fmt((have / a.need - 1.0) * 100.0)} % spare`);
}
}
fn dc_device_kinds() { ["alarm panel", "release panel", "call point", "abort switch", "beacon", "extinguisher"] }
fn meta_fire_device() {
#{ label: "Fire device", category: "Fire safety", icon: "🔔", storey: true,
help: "A fire alarm panel, extinguishant release panel, manual call point, gas abort switch, sounder-beacon or extinguisher, its back to local +y (against a wall).",
props: [
#{ key: "kind", type: "choice", def: "call point", choices: dc_device_kinds(), label: "Kind" },
#{ key: "label", type: "text", def: "", label: "Zone / label" },
] }
}
fn prefab_fire_device(o) {
if dc_skip(o) { return; }
let p = o.p;
let z0 = dc_lift(o);
let k = p.kind;
// a wall device turned away from the viewer on the storey cut open hangs
// on the far side of a wall that is cut low: ghost it
let s = if k != "extinguisher" && house_view(o) == 1 && !vis(0.0, -1.0) { sprite(o.id, "ghost") } else { sprite() };
if k == "alarm panel" {
s.box(-0.3, 0.02, 0.3, 0.17, z0 + 1.1, z0 + 1.9, "dcpanel");
let sd = sides(-0.3, 0.02, 0.3, 0.17);
s.open(sd.S[0], sd.S[1], [[0.12, 0.48, z0 + 1.55, z0 + 1.78]], "dcscr");
s.open(sd.S[0], sd.S[1], [[0.12, 0.48, z0 + 1.2, z0 + 1.45]], "seam");
if vis(0.0, -1.0) { s.circle(-0.22, 0.01, z0 + 1.5, 0.3, "ledg"); s.circle(-0.22, 0.01, z0 + 1.4, 0.3, "ledo"); }
} else if k == "release panel" {
s.box(-0.22, 0.04, 0.22, 0.17, z0 + 1.2, z0 + 1.75, "dcswg");
let sd = sides(-0.22, 0.04, 0.22, 0.17);
s.open(sd.S[0], sd.S[1], [[0.04, 0.4, z0 + 1.6, z0 + 1.7]], "dcyel");
s.open(sd.S[0], sd.S[1], [[0.08, 0.36, z0 + 1.35, z0 + 1.52]], "dcscr");
if vis(0.0, -1.0) { s.circle(0.12, 0.03, z0 + 1.28, 0.3, "ledr"); }
} else if k == "call point" || k == "abort switch" {
let m = if k == "call point" { "dcpanel" } else { "dcyel" };
s.box(-0.07, 0.11, 0.07, 0.17, z0 + 1.33, z0 + 1.47, m);
let sd = sides(-0.07, 0.11, 0.07, 0.17);
s.open(sd.S[0], sd.S[1], [[0.03, 0.11, z0 + 1.36, z0 + 1.44]], if k == "call point" { "dcwhite" } else { "dcpanel" });
} else if k == "beacon" {
// the dome stays in front of the wall face at +y
s.box(-0.07, 0.07, 0.07, 0.17, z0 + 2.3, z0 + 2.4, "dcpanel");
s.frust(0.0, 0.12, 0.05, 0.03, z0 + 2.4, z0 + 2.52, "dcpanel", 10);
s.circle(0.0, 0.12, z0 + 2.47, 0.55, "ledr");
} else {
s.cyl(0.0, 0.05, 0.09, z0, z0 + 0.62, "firetank");
s.box(-0.03, 0.02, 0.03, 0.08, z0 + 0.62, z0 + 0.72, "keyb");
s.box(0.03, 0.03, 0.16, 0.07, z0 + 0.6, z0 + 0.66, "keyb");
}
let what = if k == "alarm panel" { "fire alarm control panel" } else if k == "release panel" { "extinguishant release control panel" } else if k == "call point" { "manual call point" } else if k == "abort switch" { "gas release abort switch (hold to delay)" } else if k == "beacon" { "sounder and flashing beacon" } else { "CO2 extinguisher, 5 kg" };
info_row("Device", what);
if p.label != "" { info_row("Zone", p.label); }
}
// ---------------------------------------------------------------- power
fn meta_power_cab() {
#{ label: "Power cabinets", category: "Power", icon: "🔋", storey: true,
help: "A line-up of UPS, battery, PDU, switchgear or remote power panel cabinets along local x, fronts to local -y. UPS and battery tooltips set rating and runtime against the IT load of the building (every rack row and pod).",
props: [
#{ key: "kind", type: "choice", def: "UPS", choices: ["UPS", "battery", "PDU", "switchgear", "RPP"], label: "Kind" },
#{ key: "n", type: "int", def: 4, min: 1, max: 30, step: 1, label: "Cabinets" },
#{ key: "kw", type: "float", def: 250.0, min: 0.0, max: 5000.0, step: 10.0, label: "kW / kVA each" },
#{ key: "kwh", type: "float", def: 40.0, min: 0.0, max: 1000.0, step: 5.0, label: "kWh (battery)" },
] }
}
fn prefab_power_cab(o) {
if dc_skip(o) { return; }
let p = o.p;
let k = p.kind;
let z0 = dc_lift(o);
let cw = if k == "battery" || k == "RPP" { 0.6 } else { 0.8 };
let cd = if k == "switchgear" { 1.0 } else if k == "RPP" { 0.5 } else if k == "UPS" { 0.9 } else { 0.8 };
let h = if k == "switchgear" { 2.2 } else if k == "PDU" { 1.9 } else { 2.0 };
let m = if k == "UPS" { "dcups" } else if k == "battery" { "dcbatt" } else if k == "PDU" || k == "RPP" { "dcpdu" } else { "dcswg" };
let n = p.n;
let B = n * cw / 2.0;
let d = cd / 2.0;
let s = sprite();
let items = [];
for i in 0..n {
let a = -B + i * cw;
let b = a + cw;
items.push([(a + b) / 2.0, 0.0, |q| q.box(a, -d, b, d, z0, z0 + h, m)]);
}
s.seq(items);
let F = dc_xface(B, -d, -1.0);
let lists = #{ seam: [], grille: [], dcscr: [], dcpanelb: [], dcbrk: [] };
for i in 0..n {
let a = -B + i * cw;
let b = a + cw;
lists.seam.push(dc_span(F, B, a + 0.03, b - 0.03, z0 + 0.05, z0 + h - 0.05));
if k == "UPS" {
lists.dcscr.push(dc_span(F, B, a + 0.2, b - 0.2, z0 + 1.45, z0 + 1.65));
lists.grille.push(dc_span(F, B, a + 0.1, b - 0.1, z0 + 0.15, z0 + 0.6));
} else if k == "battery" {
for j in 0..6 { lists.grille.push(dc_span(F, B, a + 0.08, b - 0.08, z0 + 0.2 + j * 0.28, z0 + 0.32 + j * 0.28)); }
} else if k == "PDU" {
lists.grille.push(dc_span(F, B, a + 0.1, b - 0.1, z0 + 0.15, z0 + 0.9));
lists.dcscr.push(dc_span(F, B, a + 0.25, b - 0.25, z0 + 1.35, z0 + 1.5));
} else if k == "RPP" {
lists.dcpanelb.push(dc_span(F, B, a + 0.08, b - 0.08, z0 + 0.5, z0 + 1.7));
for j in 0..10 { lists.dcbrk.push(dc_span(F, B, a + 0.12, b - 0.12, z0 + 0.58 + j * 0.11, z0 + 0.62 + j * 0.11)); }
} else {
for j in 0..3 { lists.dcpanelb.push(dc_span(F, B, a + 0.1, b - 0.1, z0 + 0.2 + j * 0.65, z0 + 0.75 + j * 0.65)); }
}
}
for c in ["seam", "dcpanelb", "dcbrk", "grille", "dcscr"] {
if lists[c].len() > 0 { s.open(F.a, F.b, lists[c], c); }
}
if vis(0.0, -1.0) && k != "battery" {
for i in 0..n { s.circle(-B + (i + 0.5) * cw + 0.2, -d - 0.01, z0 + h - 0.15, 0.3, "ledg"); }
}
let all = dc_it(-1);
let tot = n * num(p.kw);
if k == "UPS" {
info_row("UPS", `${n} × ${fmt(num(p.kw))} kW = ${fmt(tot)} kW`);
if all.kw > 0.0 {
info_row("Load", `${fmt(all.kw)} kW IT = ${fmt(all.kw / tot * 100.0)} % of the line-up`);
info_row("Redundancy", dc_redundancy(#{ duty: tot, standby: 0.0, units: n }, all.kw));
}
} else if k == "battery" {
// the strings work together: runtime from every battery cabinet on the site
let e = n * num(p.kwh);
let te = 0.0;
let tn = 0;
for b in objects_of(["power_cab"]) {
if b.p.kind == "battery" { te += b.p.n * num(b.p.kwh); tn += b.p.n; }
}
info_row("Batteries", `${n} × ${fmt(num(p.kwh))} kWh = ${fmt(e)} kWh`);
if tn > n { info_row("All strings", `${tn} cabinets, ${fmt(te)} kWh`); }
if all.kw > 0.0 { info_row("Runtime", `about ${dc_num(te * 60.0 * 0.9 / all.kw)} min at ${fmt(all.kw)} kW IT`); }
} else if k == "PDU" {
info_row("PDUs", `${n} × ${fmt(num(p.kw))} kVA = ${fmt(tot)} kVA`);
let it = dc_it(storey_of(o.z));
if it.kw > 0.0 { info_row("This storey", `${fmt(it.kw)} kW IT in ${it.racks} racks`); }
} else if k == "RPP" {
info_row("Power panels", `${n} remote power panels, ${n * 42} ways`);
} else {
info_row("Switchgear", `${n} panels`);
}
}
fn meta_transformer() {
#{ label: "Transformer", category: "Power", icon: "⚡",
help: "A distribution transformer on a plinth: oil-filled with radiators and bushings, or a dry type in a louvred enclosure.",
props: [
#{ key: "kva", type: "int", def: 2500, min: 100, max: 10000, step: 100, label: "Rating (kVA)" },
#{ key: "kind", type: "choice", def: "oil", choices: ["oil", "dry"], label: "Type" },
] }
}
fn prefab_transformer(o) {
if dc_skip(o) { return; }
let p = o.p;
let s = sprite();
s.box(-1.6, -1.3, 1.6, 1.3, 0.0, 0.2, "plinth");
if p.kind == "dry" {
s.box(-1.3, -1.0, 1.3, 1.0, 0.2, 2.4, "trafo");
let sd = sides(-1.3, -1.0, 1.3, 1.0);
for f in ["S", "N"] { s.open(sd[f][0], sd[f][1], [[0.2, 1.2, 0.5, 2.1], [1.4, 2.4, 0.5, 2.1]], "louvre"); }
for f in ["E", "W"] { s.open(sd[f][0], sd[f][1], [[0.2, 1.8, 1.4, 2.1]], "louvre"); }
} else {
// radiators either side of the tank, the far one first
let ys = if vis(0.0, 1.0) { [-1.0, 1.0] } else { [1.0, -1.0] };
let fins = [];
for i in 0..9 { fins.push([0.08 + i * 0.2, 0.16 + i * 0.2, 0.45, 1.75]); }
for y in ys {
let y0 = if y < 0.0 { -1.2 } else { 0.7 };
if y == ys[1] { s.box(-0.95, -0.7, 0.95, 0.7, 0.2, 2.0, "trafo"); }
s.box(-0.9, y0, 0.9, y0 + 0.5, 0.4, 1.8, "trafo");
let sd = sides(-0.9, y0, 0.9, y0 + 0.5);
s.open(sd.S[0], sd.S[1], fins, "seam");
s.open(sd.N[0], sd.N[1], fins, "seam");
}
let items = [];
for x in [-0.5, 0.0, 0.5] { let c = x; items.push([c, -0.3, |q| q.cyl(c, -0.3, 0.07, 2.0, 2.5, "bushing")]); }
for x in [-0.6, -0.2, 0.2, 0.6] { let c = x; items.push([c, 0.35, |q| q.cyl(c, 0.35, 0.05, 2.0, 2.25, "bushing")]); }
s.seq(items);
}
info_row("Transformer", `${fmt(p.kva)} kVA, ${if p.kind == "dry" { "dry type (cast resin)" } else { "oil-filled (ONAN)" }}`);
info_row("Carries", `about ${fmt(p.kva * 0.9 * 0.8)} kW at 0.9 pf and 80 % loading`);
}