From 2810477f1b5470e4a47588086f191f2c2545da33 Mon Sep 17 00:00:00 2001 From: grabowski Date: Tue, 27 Oct 2026 17:08:39 +0700 Subject: [PATCH] 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. --- .gitea/workflows/deploy-web.yml | 1 + README.md | 29 +- crates/isokit-core/src/render.rs | 10 + crates/isokit-core/src/sort.rs | 28 + crates/isokit-project/src/check.rs | 12 +- crates/isokit-project/src/lib.rs | 4 + crates/isokit-project/src/mcp.rs | 12 +- crates/isokit-project/tests/demo.rs | 39 + crates/isokit-script/src/stdlib.rs | 4 + crates/isokit-script/src/tests.rs | 207 ++- crates/isokit-script/stdlib/datacentre.rhai | 1368 +++++++++++++++++++ crates/isokit-script/stdlib/furniture.rhai | 2 +- crates/isokit-script/stdlib/house.rhai | 29 +- crates/isokit-script/stdlib/people.rhai | 15 +- crates/isokit-theme/themes/default.toml | 200 +++ docs/ROADMAP.md | 25 + docs/SCRIPTING.md | 42 + examples/datacentre/isokit.toml | 56 + examples/datacentre/scene.json | 184 +++ tools/build_web.py | 1 + tools/check_web.py | 28 + web/web.js | 3 +- 22 files changed, 2281 insertions(+), 18 deletions(-) create mode 100644 crates/isokit-script/stdlib/datacentre.rhai create mode 100644 examples/datacentre/isokit.toml create mode 100644 examples/datacentre/scene.json diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index 3f91bec..b2b5baf 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -24,6 +24,7 @@ on: - "examples/factory/**" - "examples/streets/**" - "examples/country/**" + - "examples/datacentre/**" - "tools/build_web.py" - "Cargo.toml" - "Cargo.lock" diff --git a/README.md b/README.md index 9d331d1..2b1a41d 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ isokit build C:/dev/myproject/site-view # dist/index.html, no server needed Try the demos: `isokit studio examples/demo` (a works site), `isokit studio examples/house` (a furnished house built from parts), -`isokit studio examples/factory` (presses, conveyors and robot arms) and -`isokit studio examples/streets` (sidewalks, curves, junctions, a roundabout). +`isokit studio examples/factory` (presses, conveyors and robot arms), +`isokit studio examples/streets` (sidewalks, curves, junctions, a roundabout) and +`isokit studio examples/datacentre` (a three-storey data centre). ### Studio (for everyone) @@ -102,6 +103,30 @@ rounded, and the movers pass in front of and behind trees and houses as they go. The streets demo has traffic round the block, a bus, motorbikes, cyclists and people strolling. +**Data centres** (palette groups Data centre, Cooling, Fire safety, Power): +the DC building is the shell of a multi-storey data centre (storeys of the +project's `storey_h`, 5 m in the demo); GF, 1F, 2F cut it open, and parts on +closed storeys are not drawn at all. On each storey a Raised floor (0.6 m +tiles over the supply plenum) carries Rack rows and Rack pods: cabinets of +any width, depth and height in U (servers, storage, GPU, network or empty, +mesh, glass or solid doors, status LEDs), in-row coolers between groups of +racks, and pods with cold aisle containment (vent tiles, glass roof, sliding +end doors, cold air rising) or hot aisle containment (backs to the aisle, +optional duct to the ceiling); Cages for colocation. Cooling: CRAH / CRAC +units, chillers and dry or adiabatic coolers with fan rows, cooling towers +with a plume, flow and return pipe runs. Fire safety: detector grids with +their coverage, VESDA sampling pipes with the detector unit, pre-action +sprinkler and gas discharge pipes, clean-agent cylinder banks (Novec 1230, +FM-200, Inergen), alarm and release panels, call points, abort switches, +beacons, extinguishers. Power: UPS, battery, PDU, switchgear and RPP +line-ups, busways with tap-off boxes, cable trays and fibre ducts, +transformers. The tooltips do the sums: IT load per pod, floor and building, +kW/m², cooling against load per storey (N+1 and so on), UPS loading, +battery runtime, the agent a gas bank needs for its halls (a warning label +when it is short). `examples/datacentre` (Data centre demo in the web Studio) +has a power room, a hall with cold aisle pods, a GPU hall with hot aisle pods +and cages, and chillers on the roof. + **Fences and walls** around the plot: the Fence has six designs (mesh, wooden boards, steel bars, mesh on a stone base, cobble wall, brick wall with pillars). Fences and site walls whose ends meet join like house walls: corners diff --git a/crates/isokit-core/src/render.rs b/crates/isokit-core/src/render.rs index ceba699..b5d693b 100644 --- a/crates/isokit-core/src/render.rs +++ b/crates/isokit-core/src/render.rs @@ -59,6 +59,10 @@ pub struct Rendered { pub nodes: usize, /// Depth-sort cycles that had to be broken (0 is ideal). pub cycles: usize, + /// Where they were broken: "a before b, c" (sprite ids; a was drawn + /// before b and c, which it should have followed). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub cycle_at: Vec, pub warnings: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub order: Vec, @@ -714,6 +718,7 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered { let mut frame = [1e9f64, 1e9, -1e9, -1e9]; let mut sprites = 0; let mut cycles = 0; + let mut cycle_at = vec![]; let mut order = vec![]; let mut warnings = scene.warnings.clone(); @@ -748,6 +753,10 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered { .collect(); let o = sort::sort_hulls(&pairs, &hulls, layer.opts.zfirst); cycles += o.cycles; + for (b, before) in &o.broken { + let ids: Vec<&str> = before.iter().map(|&i| live[i].id.as_str()).collect(); + cycle_at.push(format!("{} before {}", live[*b].id, ids.join(", "))); + } o.order } else { (0..live.len()).collect() @@ -876,6 +885,7 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered { sprites, nodes, cycles, + cycle_at, warnings, order, html, diff --git a/crates/isokit-core/src/sort.rs b/crates/isokit-core/src/sort.rs index 616f1c3..f8747c0 100644 --- a/crates/isokit-core/src/sort.rs +++ b/crates/isokit-core/src/sort.rs @@ -153,6 +153,9 @@ fn rect_of(b: &Bb) -> Hull { pub struct Order { pub order: Vec, pub cycles: usize, + /// Each broken cycle: the sprite drawn early to break it and the sprites + /// not drawn yet that should have come before it. + pub broken: Vec<(usize, Vec)>, } pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order { @@ -203,6 +206,7 @@ pub fn sort_hulls(boxes: &[(Bb, Sb)], hulls: &[Option], zf: bool) -> Order let mut out = Order { order: Vec::with_capacity(n), cycles: 0, + broken: vec![], }; for _ in 0..n { let mut best: Option = None; @@ -227,6 +231,12 @@ pub fn sort_hulls(boxes: &[(Bb, Sb)], hulls: &[Option], zf: bool) -> Order } } let b = best.expect("n > 0"); + if indeg[b] > 0 { + let before: Vec = (0..n) + .filter(|&i| !done[i] && adj[i].contains(&b)) + .collect(); + out.broken.push((b, before)); + } done[b] = true; out.order.push(b); for &j in &adj[b] { @@ -245,6 +255,24 @@ mod tests { ([x0, y0, x1, y1, z0, z1], [0.0, 0.0, 10.0, 10.0]) } + #[test] + fn broken_cycles_name_the_sprites() { + // a before b (x), b before c (y), c before a (z): no order fits all three + let o = sort( + &[ + bx(0.0, 0.0, 1.0, 10.0, 5.0, 6.0), + bx(2.0, 0.0, 3.0, 1.0, 0.0, 5.0), + bx(0.0, 2.0, 3.0, 3.0, 0.0, 1.0), + ], + false, + ); + assert_eq!(o.cycles, 1); + assert_eq!(o.broken.len(), 1); + let (b, before) = &o.broken[0]; + assert_eq!(before.len(), 1); + assert_ne!(*b, before[0]); + } + #[test] fn separated_on_x_draws_far_first() { let o = sort( diff --git a/crates/isokit-project/src/check.rs b/crates/isokit-project/src/check.rs index affa23f..3f2dc29 100644 --- a/crates/isokit-project/src/check.rs +++ b/crates/isokit-project/src/check.rs @@ -137,7 +137,17 @@ pub fn check(p: &Project) -> Vec { )); } if v.cycles > 0 { - out.push(f("info", format!("{}/{rot}: {} depth-sort cycle(s) broken; split large sprites if something draws in front of what it stands behind", l.id, v.cycles), None)); + let mut at = v + .cycle_at + .iter() + .take(3) + .cloned() + .collect::>() + .join("; "); + if v.cycle_at.len() > 3 { + at.push_str("; ..."); + } + out.push(f("info", format!("{}/{rot}: {} depth-sort cycle(s) broken ({at}); split large sprites if something draws in front of what it stands behind", l.id, v.cycles), None)); } for cls in v .html diff --git a/crates/isokit-project/src/lib.rs b/crates/isokit-project/src/lib.rs index 13ab8f0..c1a102a 100644 --- a/crates/isokit-project/src/lib.rs +++ b/crates/isokit-project/src/lib.rs @@ -201,6 +201,9 @@ pub struct View { pub sprites: usize, pub nodes: usize, pub cycles: usize, + /// Where the depth sort broke its cycles ("a before b, c": sprite ids). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub cycle_at: Vec, pub warnings: Vec, /// Object id -> world box [x0, y0, z0, x1, y1, z1]. pub boxes: BTreeMap, @@ -428,6 +431,7 @@ impl Project { sprites: r.sprites, nodes: r.nodes, cycles: r.cycles, + cycle_at: r.cycle_at, warnings: r.warnings, boxes: built.boxes, ms: (t0.elapsed().as_secs_f64() * 1e4).round() / 10.0, diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index 41173ca..54421de 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -46,7 +46,17 @@ props.walk none/both/left/right adds raised sidewalks walk_w wide) ending on ano junction there (at any angle); a road_curve starts at its x, y heading along its rot, bends props.angle degrees \ left or right on radius r and ends where the next road starts; two roads ending at one point make a mitred corner. \ A roundabout (props.r ring radius, arm_e/n/w/s, arm_len) has its arm ends r + arm_len from its centre: put the \ -roads' ends exactly there, with the same width as road_d."; +roads' ends exactly there, with the same width as road_d. \ +Data centres (categories Data centre, Cooling, Fire safety, Power): a dc_building (w x d, props.storeys) is the shell; \ +its storeys are the project parameter storey_h apart (5 m suits data halls) and levels gf, 1f, 2f cut it open. Put \ +every part on its storey (z = storey x storey_h) inside the walls (inner faces at w/2 - t and d/2 - t): a \ +raised_floor (w x d) per hall, then rack_row (fronts to local -y) and dc_pod objects (two rows round a contained aisle: \ +props.contain cold, hot or none; props.inrow adds an in-row cooler every n racks) on it, which stand on its top by \ +themselves; crah units along the walls; power_cab line-ups (UPS, battery, PDU, switchgear, RPP). Overhead runs \ +(cable_tray, busway, fire_pipe: VESDA, sprinkler or gas) take props.h above the slab: keep crossing runs at different \ +heights (busway 3.2, fibre 3.6, ladder 4.0, gas or sprinkler 4.25, VESDA 4.4, smoke_detectors 4.5 for 5 m storeys). \ +A gas_bank sizes its agent for the raised-floor halls on its storey; tooltips report IT load, cooling redundancy, \ +UPS load and battery runtime. Chillers and cooling towers go on the roof (z = storeys x storey_h) or in the yard."; fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value { json!({ "name": name, "description": description, diff --git a/crates/isokit-project/tests/demo.rs b/crates/isokit-project/tests/demo.rs index 01dd822..dce034d 100644 --- a/crates/isokit-project/tests/demo.rs +++ b/crates/isokit-project/tests/demo.rs @@ -94,6 +94,45 @@ fn country_demo_renders_every_view_cleanly() { example_renders_every_view_cleanly("country"); } +#[test] +fn datacentre_demo_renders_every_view_cleanly() { + let p = Project::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/datacentre")) + .unwrap(); + assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err()); + // storeys of 5 m; the building covers |x| <= 21, |y| <= 13 and has three + // storeys, the plant on its roof (storey 3) + let storey = |z: f64| ((z + 0.5) / 5.0).floor() as i64; + for l in p.manifest.levels() { + let k: i64 = match l.id.as_str() { + "gf" => 0, + "1f" => 1, + "2f" => 2, + _ => -1, + }; + for rot in 0..4 { + let v = p.render(&l.id, rot, None, false).unwrap(); + assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings); + assert_eq!(v.cycles, 0, "{}/{rot}: {:?}", l.id, v.cycle_at); + assert!(!v.html.contains("NaN")); + for o in p.doc.objects.iter().filter(|o| o.kind != "group") { + let s = storey(o.z); + let ground = o.kind == "site" || o.kind == "area"; + let inside = !ground && o.x.abs() < 21.0 && o.y.abs() < 13.0; + // the cut storey shows, closed storeys inside the shell and storeys above do not + let shown = o.kind == "dc_building" || !inside || s == k || (k < 0 && s >= 3); + assert_eq!( + v.boxes.contains_key(&o.id), + shown, + "{} on {}/{rot}", + o.id, + l.id + ); + } + } + } + assert!(check::check(&p).is_empty(), "{:?}", check::check(&p)); +} + fn example_renders_every_view_cleanly(name: &str) { let p = Project::load( Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/isokit-script/src/stdlib.rs b/crates/isokit-script/src/stdlib.rs index 3a3bd93..03bd571 100644 --- a/crates/isokit-script/src/stdlib.rs +++ b/crates/isokit-script/src/stdlib.rs @@ -19,4 +19,8 @@ pub const MODULES: &[(&str, &str)] = &[ ("isokit/house", include_str!("../stdlib/house.rhai")), ("isokit/furniture", include_str!("../stdlib/furniture.rhai")), ("isokit/industry", include_str!("../stdlib/industry.rhai")), + ( + "isokit/datacentre", + include_str!("../stdlib/datacentre.rhai"), + ), ]; diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index 38f327f..ece515e 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -221,7 +221,8 @@ fn every_library_prefab_renders_cleanly_in_every_rotation() { let r = Runtime::new(&Bundle::default()).unwrap(); let mut doc = Doc::default(); for (i, m) in r.catalog().iter().enumerate() { - let mut o = obj(&format!("o{i}"), &m.kind, i as f64 * 15.0, 0.0, json!({})); + // 45 m apart: a data centre shell (40 m) leaves out what stands inside it + let mut o = obj(&format!("o{i}"), &m.kind, i as f64 * 45.0, 0.0, json!({})); // switch every bool prop on to exercise all branches for p in &m.props { if p.kind == "bool" { @@ -1379,3 +1380,207 @@ fn object_queries_and_frames() { ); assert_eq!(m[4], 1.0, "one opening on the east wall"); } + +// ---------------------------------------------------------------- data centres + +fn dc_cfg() -> Map { + let mut m = Map::new(); + m.insert("storey_h".into(), json!(5.0)); + m +} + +fn on_storey(mut o: Object, z: f64) -> Object { + o.z = z; + o +} + +/// A two-storey shell: power on GF, a hall on 1F. +fn dc_doc() -> Doc { + Doc { + objects: vec![ + obj( + "dc", + "dc_building", + 0.0, + 0.0, + json!({ "w": 30, "d": 20, "storeys": 2 }), + ), + obj( + "ups", + "power_cab", + 0.0, + 0.0, + json!({ "kind": "UPS", "n": 2, "kw": 200 }), + ), + obj( + "batt", + "power_cab", + 0.0, + -3.0, + json!({ "kind": "battery", "n": 4, "kwh": 50 }), + ), + obj("wall", "house_wall", 6.0, 0.0, json!({ "w": 8, "h": 4.7 })), + on_storey( + obj( + "rf", + "raised_floor", + 0.0, + 0.0, + json!({ "w": 28.8, "d": 19.2 }), + ), + 5.0, + ), + on_storey( + obj("pod", "dc_pod", -4.0, 0.0, json!({ "n": 10, "kw": 8 })), + 5.0, + ), + on_storey( + obj( + "row", + "rack_row", + 6.0, + 5.0, + json!({ "n": 6, "kw": 5, "inrow": 3, "inrow_kw": 30 }), + ), + 5.0, + ), + on_storey(obj("crah1", "crah", -8.0, 8.5, json!({ "kw": 100 })), 5.0), + on_storey(obj("crah2", "crah", 0.0, 8.5, json!({ "kw": 100 })), 5.0), + on_storey( + obj( + "crah3", + "crah", + 8.0, + 8.5, + json!({ "kw": 100, "standby": true }), + ), + 5.0, + ), + on_storey(obj("gas", "gas_bank", -12.0, -8.0, json!({ "n": 4 })), 5.0), + on_storey( + obj( + "det", + "smoke_detectors", + 0.0, + 0.0, + json!({ "w": 28.8, "d": 19.2, "spacing": 6 }), + ), + 5.0, + ), + on_storey( + obj("tray", "cable_tray", 0.0, -5.0, json!({ "w": 20 })), + 5.0, + ), + on_storey(obj("tech", "person", 0.0, -5.0, json!({})), 5.0), + ], + ..Default::default() + } +} + +#[test] +fn data_centre_parts_stand_on_the_raised_floor_and_add_up_their_load() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let doc = dc_doc(); + let b = r.build("1f", 0, &dc_cfg(), &doc).unwrap(); + assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); + let near = |a: f64, e: f64| (a - e).abs() < 1e-6; + // racks, coolers and people stand on the 0.6 m raised floor of their storey + for id in ["pod", "row", "crah1", "tech"] { + assert!(near(b.boxes[id][2], 5.6), "{id} {:?}", b.boxes[id]); + } + // a 42U cabinet is 2 m high + assert!(near(b.boxes["row"][5], 5.6 + 42.0 * 0.04445 + 0.13)); + let out = render(&b.scene, 0, &RenderOpts::default()); + assert_eq!(out.cycles, 0, "{:?}", out.cycle_at); + let row = |id: &str, k: &str| { + out.info[id] + .rows + .iter() + .find(|r| r.0 == k) + .map(|r| r.1.clone()) + .unwrap_or_default() + }; + // 20 racks at 8 kW + 6 at 5 kW; one in-row cooler after the first three racks + assert_eq!(row("pod", "IT load"), "8 kW per rack, 160 kW in all"); + assert_eq!( + row("pod", "Containment"), + "cold aisle, 1.2 m, roof and end doors" + ); + assert_eq!(row("row", "In-row cooling"), "1 × 30 kW = 30 kW"); + // cooling 200 kW duty + 30 kW in-row + 100 kW standby against 190 kW IT: one unit spare + assert_eq!( + row("crah1", "This storey"), + "190 kW IT in 26 racks; 230 kW duty + 100 kW standby cooling" + ); + assert_eq!(row("crah1", "Redundancy"), "N+1, 140 kW spare"); + assert_eq!(row("rf", "On it"), "26 racks, 190 kW IT"); + // Novec 1230 for the hall (28.8 x 19.2 m, 4.7 m slab to slab): 1,704 kg; 4 x 168 kg is short + assert_eq!(row("gas", "Needed"), "1,704 kg"); + assert_eq!(row("gas", "Status"), "short: 7 more cylinders"); + assert!(out.html.contains(">Gas: 7 cylinders short<")); + // overhead services sort in their own layer after everything on the floor + let ceiling = b.scene.layers.iter().find(|l| l.name == "ceiling").unwrap(); + let ids: Vec<&str> = ceiling.sprites.iter().map(|s| s.id.as_str()).collect(); + assert!( + ids.contains(&"tray") && ids.contains(&"det") && !ids.contains(&"pod"), + "{ids:?}" + ); + let off = r + .build( + "1f", + 0, + &{ + let mut c = dc_cfg(); + c.insert("ceiling".into(), json!(false)); + c + }, + &doc, + ) + .unwrap(); + assert!(!off.boxes.contains_key("tray") && !off.boxes.contains_key("det")); +} + +#[test] +fn data_centre_storeys_draw_only_what_can_be_seen() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let doc = dc_doc(); + let drawn = |level: &str| { + let b = r.build(level, 1, &dc_cfg(), &doc).unwrap(); + assert!( + b.scene.warnings.is_empty(), + "{level}: {:?}", + b.scene.warnings + ); + let out = render(&b.scene, 1, &RenderOpts::default()); + assert_eq!(out.cycles, 0, "{level}: {:?}", out.cycle_at); + (b.boxes, out) + }; + // closed: the shell hides every storey, so nothing inside it is drawn + let (site, _) = drawn("site"); + assert!(site.contains_key("dc")); + for id in ["ups", "wall", "rf", "pod", "crah1", "tech"] { + assert!( + !site.contains_key(id), + "{id} drawn inside the closed building" + ); + } + // GF cut open: the power room shows, the hall above is left out + let (gf, out) = drawn("gf"); + for id in ["ups", "batt", "wall"] { + assert!(gf.contains_key(id), "{id} missing on GF"); + } + assert!(!gf.contains_key("pod") && !gf.contains_key("rf")); + // batteries give their runtime at the IT load of every storey + let runtime = out.info["batt"] + .rows + .iter() + .find(|r| r.0 == "Runtime") + .unwrap() + .1 + .clone(); + assert_eq!(runtime, "about 56.8 min at 190 kW IT"); + // 1F cut open: the hall shows, the closed ground floor's rooms do not + let (f1, _) = drawn("1f"); + assert!(f1.contains_key("pod") && f1.contains_key("rf") && f1.contains_key("dc")); + assert!(!f1.contains_key("ups") && !f1.contains_key("wall")); +} diff --git a/crates/isokit-script/stdlib/datacentre.rhai b/crates/isokit-script/stdlib/datacentre.rhai new file mode 100644 index 0000000..6c0bf8c --- /dev/null +++ b/crates/isokit-script/stdlib/datacentre.rhai @@ -0,0 +1,1368 @@ +// 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`); +} diff --git a/crates/isokit-script/stdlib/furniture.rhai b/crates/isokit-script/stdlib/furniture.rhai index 9e37018..dd596e3 100644 --- a/crates/isokit-script/stdlib/furniture.rhai +++ b/crates/isokit-script/stdlib/furniture.rhai @@ -3,7 +3,7 @@ // on its object's z, so it can sit on any storey (see isokit/house). Pieces // on storeys above the one a level cuts open are left out. -fn furniture_hidden(o) { house_view(o) == 2 } +fn furniture_hidden(o) { part_hidden(o) } fn wood_choices() { ["wood", "woodd", "furnw", "steel", "kitchenc"] } fn fabric_choices() { ["uph1", "uph2", "uph3", "uph4", "sofa", "bed", "cloth"] } diff --git a/crates/isokit-script/stdlib/house.rhai b/crates/isokit-script/stdlib/house.rhai index 6dc9799..8782f5c 100644 --- a/crates/isokit-script/stdlib/house.rhai +++ b/crates/isokit-script/stdlib/house.rhai @@ -27,11 +27,26 @@ fn house_view(o) { if s < k { 0 } else if s == k { 1 } else { 2 } } +/// Leave part o out? On a storey above the one a level cuts open, or on a +/// closed storey inside a data centre shell (dc_building), whose walls and +/// slabs hide it anyway (and would sort badly against what stands above). +fn part_hidden(o) { + let hv = house_view(o); + if hv == 2 { return true; } + if hv == 1 { return false; } + let s = storey_of(o.z); + for b in objects_at(["dc_building"], o.x, o.y) { + let s0 = storey_of(b.z); + if s >= s0 && s < s0 + b.p.storeys { return true; } + } + false +} + fn wall_cut_h() { 1.0 } fn wall_types() { ["house_wall"] } fn opening_types() { ["window", "door"] } -fn room_types() { ["floor"] } -fn house_wall_materials() { ["render", "render2", "brick", "wood", "wall", "wall2", "wallp", "concb", "office", "furnw"] } +fn room_types() { ["floor", "raised_floor"] } +fn house_wall_materials() { ["render", "render2", "brick", "wood", "wall", "wall2", "wallp", "concb", "office", "furnw", "firewall"] } fn flooring_materials() { ["f-oak", "f-wood", "f-tile", "f-stone", "f-carpet", "f-conc", "z-floor", "z-living", "z-kitchen", "z-bed", "z-wc"] } fn house_roof_materials() { ["rooft", "roofr", "roof", "roof2", "roof3", "roofcap", "canopy"] } @@ -116,7 +131,7 @@ fn meta_house_wall() { fn prefab_house_wall(o) { let p = o.p; - if house_view(o) == 2 { return; } + if part_hidden(o) { return; } let top = wall_top(o); let cap = if top < p.h { "cut" } else { p.mat }; let t = p.t / 2.0; @@ -203,7 +218,7 @@ fn meta_window() { fn prefab_window(o) { let c = opening_at(o); let w = c.wall; - if house_view(w) == 2 { return; } + if part_hidden(w) { return; } let H = w.p.h; let t = w.p.t / 2.0; let top = wall_top(w); @@ -247,7 +262,7 @@ fn meta_door() { fn prefab_door(o) { let c = opening_at(o); let w = c.wall; - if house_view(w) == 2 { return; } + if part_hidden(w) { return; } let p = o.p; let H = w.p.h; let t = w.p.t / 2.0; @@ -314,7 +329,7 @@ fn meta_floor() { } fn prefab_floor(o) { - if house_view(o) == 2 { return; } + if part_hidden(o) { return; } let p = o.p; let w = p.w / 2.0; let d = p.d / 2.0; @@ -495,7 +510,7 @@ fn meta_stairs() { } fn prefab_stairs(o) { - if house_view(o) == 2 { return; } + if part_hidden(o) { return; } let p = o.p; let w = p.w / 2.0; let n = fmax(2.0, (p.rise / 0.18).round()).to_int(); diff --git a/crates/isokit-script/stdlib/people.rhai b/crates/isokit-script/stdlib/people.rhai index 71c6283..d11f711 100644 --- a/crates/isokit-script/stdlib/people.rhai +++ b/crates/isokit-script/stdlib/people.rhai @@ -3,30 +3,37 @@ fn people_colours() { ["hivis", "guest1", "guest2", "guest3", "guest4", "guide", "suit"] } fn meta_person() { - #{ label: "Person", category: "People", icon: "🧍", + #{ label: "Person", category: "People", icon: "🧍", storey: true, props: [ #{ key: "cls", type: "choice", def: "hivis", choices: people_colours(), label: "Clothes" }, #{ key: "seated", type: "bool", def: false, label: "Seated" }, ] } } +// People are left out like house parts (on a storey above the one a level +// cuts open, or inside a closed data centre storey); on a raised floor they +// stand on its top. fn prefab_person(o) { + if part_hidden(o) { return; } + let z = dc_lift(o); let s = sprite(); - if o.p.seated { s.sit(0.0, 0.0, 0.0, o.p.cls); } else { s.person(0.0, 0.0, 0.0, o.p.cls); } + if o.p.seated { s.sit(0.0, 0.0, z, o.p.cls); } else { s.person(0.0, 0.0, z, o.p.cls); } } fn meta_crowd() { - #{ label: "Crowd", category: "People", icon: "👥", + #{ label: "Crowd", category: "People", icon: "👥", storey: true, props: [ #{ key: "n", type: "int", def: 5, min: 2, max: 40, step: 1, label: "People" }, #{ key: "r", type: "float", def: 2.0, min: 0.6, max: 15.0, step: 0.1, label: "Spread (m)" }, ] } } fn prefab_crowd(o) { + if part_hidden(o) { return; } + let z = dc_lift(o); let cols = ["guest1", "guest2", "guest3", "guest4", "hivis"]; for i in 0..o.p.n { // one sprite each, scattered deterministically in a disc let a = hash(i * 1.7 + o.x) * 6.2832; let r = o.p.r * hash(i * 3.3 + o.y).sqrt(); - sprite().person(r * a.cos(), r * a.sin(), 0.0, cols[i % 5]); + sprite().person(r * a.cos(), r * a.sin(), z, cols[i % 5]); } } diff --git a/crates/isokit-theme/themes/default.toml b/crates/isokit-theme/themes/default.toml index f57e5a2..3c71a61 100644 --- a/crates/isokit-theme/themes/default.toml +++ b/crates/isokit-theme/themes/default.toml @@ -318,6 +318,66 @@ walk = "#555c6b" kerbf = "#7d8494" apron = "#4a4840" +# data centre +dccab = "#262b34" +dccabt = "#3a414d" +dcsrv = "#5b6678" +dcstor = "#6f7f96" +dcgpu = "#414b5c" +dcnet = "#2f7396" +dcpatch = "#c9ced6" +dcslot = "#2a3039" +dcinrow = "#aab4c2" +dcrf = "#9aa3b0" +dcrf2 = "#7f93ad" +dcrf3 = "#a69e90" +dcrfs = "#5a616d" +dcperf = "#5f86b8" +dccold = "#8fcaff" +dchot = "#ff8a5a" +dcalu = "#b9c2ce" +dcglass = "#8fc8ff" +dcglassh = "#ffb08a" +dccrah = "#c4ccd6" +dcups = "#b8c0cb" +dcbatt = "#3a414d" +dcpdu = "#5d6f8a" +dcswg = "#8a96a6" +dcbus = "#9ea4ad" +dctap = "#c46a24" +dctray = "#8e98a6" +dcfibre = "#e3b62b" +dcbasket = "#a7b0bc" +dccable = "#1b1f26" +dcfibrec = "#f0d060" +dccablec = "#3f78c0" +dcdet = "#e9edf2" +dcdeth = "#f2e2b6" +dcvesda = "#e0574a" +dcfirel = "#e0574a" +dcgaspipe = "#aab3be" +dcpanel = "#c03c35" +dcyel = "#e0b030" +dcwhite = "#f2f4f7" +dcgreen = "#3f9a5a" +dcscreen = "#58d0e0" +dctower = "#9fb0c2" +dctowert = "#7e8fa2" +dcpipeg = "#3f9a5a" +dcpipeg2 = "#2c7344" +dcpipev = "#8a5fc0" +dccoil = "#6f8193" +dcpad = "#8fa58a" +dcband = "#6d7480" +dcvoid = "#1a1e25" +dcleg = "#7a828e" +dcmeshp = "#9aa6b5" +dcpdu0 = "#b8413a" +dcrear = "#1e2229" +dcbay = "#15181d" +dcdoor = "#30363f" +dcbrk = "#2b2f37" +dcpanelb = "#9aa4b2" [palette.light] sky1 = "#a9d4fb" sky2 = "#e8f4ff" @@ -626,6 +686,66 @@ walk = "#d9d6cf" kerbf = "#a5a9b1" apron = "#b9ad98" +# data centre +dccab = "#3b424e" +dccabt = "#5a6371" +dcsrv = "#7d899b" +dcstor = "#94a3b8" +dcgpu = "#5d6878" +dcnet = "#3f8fb5" +dcpatch = "#eef1f5" +dcslot = "#454c58" +dcinrow = "#dde2e8" +dcrf = "#d3d8df" +dcrf2 = "#b6c6da" +dcrf3 = "#d8d0c2" +dcrfs = "#8d95a2" +dcperf = "#8eb0da" +dccold = "#3f86e0" +dchot = "#e0582a" +dcalu = "#a3adbb" +dcglass = "#5aa6e6" +dcglassh = "#f08a5a" +dccrah = "#eef1f4" +dcups = "#e3e7ec" +dcbatt = "#5d6573" +dcpdu = "#7f93ad" +dcswg = "#b2bcc8" +dcbus = "#c3c8cf" +dctap = "#dd7f2f" +dctray = "#aeb7c3" +dcfibre = "#e8c22e" +dcbasket = "#c3cad3" +dccable = "#2a2f38" +dcfibrec = "#f5dc70" +dccablec = "#4f8fd8" +dcdet = "#ffffff" +dcdeth = "#f7ecc8" +dcvesda = "#d24a3c" +dcfirel = "#d0453f" +dcgaspipe = "#9aa3ad" +dcpanel = "#d0453f" +dcyel = "#e8c22e" +dcwhite = "#ffffff" +dcgreen = "#2fae68" +dcscreen = "#2aa8bf" +dctower = "#c8d3df" +dctowert = "#a9b6c4" +dcpipeg = "#2fae68" +dcpipeg2 = "#23864f" +dcpipev = "#9b6fd8" +dccoil = "#8fa1b4" +dcpad = "#a9c0a2" +dcband = "#9aa1ab" +dcvoid = "#3a404b" +dcleg = "#a3abb6" +dcmeshp = "#b9c4d2" +dcpdu0 = "#c9473a" +dcrear = "#2f353f" +dcbay = "#22262d" +dcdoor = "#4a525f" +dcbrk = "#3a404b" +dcpanelb = "#c3cad3" [materials] warn = "#e0412f" suit = "suit" @@ -903,6 +1023,56 @@ f-carpet = "fcarpet" f-stone = "fstone" f-conc = "conc" +# data centre +dccab = "dccab" +dccabt = "dccabt" +dcsrv = "dcsrv" +dcstor = "dcstor" +dcgpu = "dcgpu" +dcnet = "dcnet" +dcpatch = "dcpatch" +dcslot = "dcslot" +dcinrow = "dcinrow" +dcrf = "dcrf" +dcrf2 = "dcrf2" +dcrf3 = "dcrf3" +dcrfs = "dcrfs" +dcalu = "dcalu" +dccrah = "dccrah" +dcups = "dcups" +dcbatt = "dcbatt" +dcpdu = "dcpdu" +dcswg = "dcswg" +dcbus = "dcbus" +dctap = "dctap" +dctray = "dctray" +dcfibre = "dcfibre" +dcbasket = "dcbasket" +dccable = "dccable" +dcfibrec = "dcfibrec" +dccablec = "dccablec" +dcvesda = "dcvesda" +dcgaspipe = "dcgaspipe" +dcpanel = "dcpanel" +dcyel = "dcyel" +dcwhite = "dcwhite" +dcgreen = "dcgreen" +dctower = "dctower" +dctowert = "dctowert" +dcpipeg = "dcpipeg" +dcpipeg2 = "dcpipeg2" +dcpipev = "dcpipev" +dccoil = "dccoil" +dcpad = "dcpad" +dcband = "dcband" +dcvoid = "dcvoid" +dcleg = "dcleg" +dcpdu0 = "dcpdu0" +dcrear = "dcrear" +dcbay = "dcbay" +dcdoor = "dcdoor" +dcbrk = "dcbrk" +dcpanelb = "dcpanelb" [rules] ".iso-ground .iso-dirt" = "stroke: var(--iso-edge); stroke-width: .4" ".iso-svg .iso-grid" = "fill: none; stroke: var(--iso-grid); stroke-width: .5" @@ -1029,3 +1199,33 @@ f-conc = "conc" ".iso-svg .iso-hobr" = "fill: none; stroke: #6a707b; stroke-width: .3" ".iso-svg .iso-metal" = "fill: var(--iso-steel); stroke: none" ".iso-svg .iso-keyb" = "fill: #2b2f37; stroke: none" +# data centre +".iso-svg .iso-dctile" = "fill: none; stroke: var(--iso-edge); stroke-width: .2; opacity: .3" +".iso-svg .iso-dcperf" = "fill: var(--iso-dcperf); stroke: none; opacity: .85" +".iso-svg .iso-dcglass, .iso-svg .iso-dcglass.iso-t" = "fill-opacity: .2; --b: var(--iso-dcglass); stroke-opacity: .55" +".iso-svg .iso-dcglassh, .iso-svg .iso-dcglassh.iso-t" = "fill-opacity: .2; --b: var(--iso-dcglassh); stroke-opacity: .55" +".iso-svg .iso-dcglassd" = "fill: var(--iso-dcglass); fill-opacity: .14; stroke: none" +".iso-svg .iso-dcmesh" = "fill: #000; fill-opacity: .3; stroke: none" +".iso-svg .iso-dcmeshp, .iso-svg .iso-dcmeshp.iso-t" = "fill-opacity: .25; --b: var(--iso-dcmeshp); stroke-opacity: .45" +".iso-svg .iso-dcmeshd, .iso-svg .iso-dcmeshd.iso-t" = "fill-opacity: .45; --b: var(--iso-dcmeshp); stroke-opacity: .6" +".iso-svg .iso-dcframe" = "fill: none; stroke: var(--iso-dcalu); stroke-width: .3" +".iso-svg path.iso-dcsrv, .iso-svg path.iso-dcstor, .iso-svg path.iso-dcgpu, .iso-svg path.iso-dcnet, .iso-svg path.iso-dcpatch, .iso-svg path.iso-dcslot, .iso-svg path.iso-dcbay, .iso-svg path.iso-dcrear, .iso-svg path.iso-dcpdu0, .iso-svg path.iso-dcvoid, .iso-svg path.iso-dcleg, .iso-svg path.iso-dcband, .iso-svg path.iso-dcbrk, .iso-svg path.iso-dcdoor" = "stroke: none" +".iso-svg .iso-dcstrip" = "fill: var(--iso-led-g); stroke: none; opacity: .9" +".iso-svg .iso-dcscr" = "fill: var(--iso-dcscreen); stroke: none" +".iso-svg .iso-dccov" = "fill: var(--iso-dchot); fill-opacity: .025; stroke: var(--iso-dchot); stroke-width: .22; stroke-dasharray: 1.2 2.2; opacity: .45; pointer-events: none" +".iso-svg .iso-dcdetd" = "fill: var(--iso-dcdet); stroke: var(--iso-edge); stroke-width: .12" +".iso-svg .iso-dcdethd" = "fill: var(--iso-dcdeth); stroke: var(--iso-edge); stroke-width: .12" +".iso-svg .iso-dcvesl" = "fill: none; stroke: var(--iso-dcvesda); stroke-width: .5; stroke-linecap: round" +".iso-svg .iso-dcsprl" = "fill: none; stroke: var(--iso-dcfirel); stroke-width: .8; stroke-linecap: round" +".iso-svg .iso-dcgasl" = "fill: none; stroke: var(--iso-dcgaspipe); stroke-width: .9; stroke-linecap: round" +".iso-svg .iso-dchole" = "fill: var(--iso-dcwhite); stroke: var(--iso-dcvesda); stroke-width: .2" +".iso-svg .iso-dcspr" = "fill: var(--iso-dcfirel); stroke: none" +".iso-svg .iso-dcsprh" = "fill: var(--iso-dcyel); stroke: none" +".iso-svg .iso-dcdled" = "fill: var(--iso-led-r); stroke: none" +".iso-svg .iso-dcflash" = "animation: iso-dcflash 4s linear infinite" +"@keyframes iso-dcflash" = "0%, 86%, 100% { opacity: .3 } 90% { opacity: 1 }" +".iso-svg .iso-dcblink" = "animation: iso-blink 1.9s ease-in-out infinite" +".iso-svg .iso-dcair, .iso-svg .iso-dcheat" = "transform-box: fill-box; transform-origin: center; animation: iso-dcrise 3s ease-out infinite; opacity: 0; pointer-events: none; stroke: none" +".iso-svg .iso-dcair" = "fill: var(--iso-dccold)" +".iso-svg .iso-dcheat" = "fill: var(--iso-dchot)" +"@keyframes iso-dcrise" = "0% { transform: translate(0, 0) scale(.5); opacity: 0 } 25% { opacity: .6 } 100% { transform: translate(0, -7px) scale(1.3); opacity: 0 }" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 887e44d..7522fa6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,6 +71,31 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. - [x] Diagonal walls (any angle, mitred corners, windows and doors on them) - [ ] Openings across two walls; floor openings for stairs +## Data centres (done) + +- [x] `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, parts on + closed storeys inside it are left out (`part_hidden`, also for house parts and people) +- [x] Raised floor (tiles, void with pedestals); racks, pods, units, cabinets and people stand on it +- [x] Rack row and Rack pod: cabinets by U, width, depth, equipment (servers, storage, GPU, network, + empty), fill, doors, status LEDs; in-row coolers; cold / hot aisle containment (vent tiles, + roof, end doors, ceiling duct, airflow); colocation Cage +- [x] Cooling: CRAH / CRAC units, chillers, dry and adiabatic coolers, cooling towers, pipe runs +- [x] Fire safety: detector grids with coverage, VESDA / sprinkler / gas discharge pipes, clean-agent + cylinder banks sized for their halls (Novec 1230, FM-200, Inergen), panels, call points, abort + switches, beacons, extinguishers +- [x] Power: UPS, battery, PDU, switchgear and RPP line-ups, busways, cable trays, transformers +- [x] Tooltips do the sums: IT load and kW/m², cooling redundancy per storey, UPS load, battery + runtime, plant cooling, agent quantity (warning label when short) +- [x] Overhead services in their own `ceiling` layer (z first) after the floor; `isokit check` names + where the depth sort broke a cycle +- [x] `examples/datacentre` (three storeys and a roof), Data centre demo in the web Studio +- [ ] Next: floor openings and a lift / stair core through the slabs; cold aisle airflow from the + CRAHs drawn under the raised floor (section cut, Phase 4); generator yard and fuel system + parts (day tanks, ATS); security (CCTV, access control, mantraps); leak detection; liquid + cooling (CDUs, rear-door heat exchangers, manifolds); a row-by-row power and cooling budget + table on the building + ## Backlog (2026-10) 1. ~~Web performance~~ (done, see below) diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md index 57b203f..8ce47d7 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -293,6 +293,48 @@ prefabs work together through the document, not through a parent object: - Ground-level floors and rugs go to the `floor` layer; upper floors are slabs whose top is at the object's z. Furniture starts 0.02 m above its z. +## Data centres + +`datacentre.rhai` lays out multi-storey data centres from parts that read +each other through the document, like house parts: + +- `dc_building` is the shell: `storeys` of `storey_h` (the project + parameter; 5 m suits data halls), outer walls split into pieces of at most + 15 m, a slab per storey and a flat roof with a parapet. On level `kf` the + storeys below are closed, the walls facing the viewer are cut 1.2 m above + storey k, the others reach its ceiling, and everything above is left out. +- `part_hidden(o)` (house.rhai) leaves out a part above the storey a level + cuts open, and a part on a closed storey inside a `dc_building`: the shell + hides it, and drawing it would only make sort cycles with what stands on + the slab above. House walls, doors, floors, stairs, furniture, people and + every data centre part use it (`dc_skip` is the same rule). +- `dc_lift(o)`: the top of a `raised_floor` under o on its storey; racks, + pods, CRAH units, cabinets, gas banks, devices and people stand on it. +- A rack line is one sprite with a box per cabinet (`seq`) and the faces + painted with `open()` per class (equipment, LED strips, rear PDUs), so a + pod of 20 racks costs a few dozen nodes. In-row coolers (`inrow`) are + 0.3 m units between groups of racks. A pod is three sprites (two rows and + the aisle between them: vent tiles, airflow puffs, end doors, roof, duct), + which never overlap. +- Overhead services (`cable_tray`, `busway`, `fire_pipe`, `smoke_detectors`) + draw in the layer `ceiling` (`dc_ceiling_layer()`: sorted, z first) after + everything on the floor, with sort boxes as tight as the run itself + (hangers do not count): a tray beside a pod with a busway over the pod and + under the tray has no valid order as boxes in one layer. Keep runs that + cross or lie side by side at heights that do not overlap. The project + parameter `ceiling` = false leaves them out. +- Totals: `dc_it(storey)` (IT load, racks; every storey for -1), + `dc_cool(storey)` (CRAH duty and standby, in-row coolers), + `dc_redundancy(cooling, kw)` ("N+1, 140 kW spare"), `dc_plant()` (chillers + and towers), `dc_agent(agent, V, t, litres)` (Novec 1230 at 4.5 %, FM-200 + at 7 % by NFPA 2001 / ISO 14520, Inergen at 39.9 %), `dc_gas_volume(o)`. + A gas bank that is short puts a `warntag` on the view. +- Wall devices facing away from the viewer on the storey cut open hang on + the far side of a wall that is cut low: they draw ghosted. + +`isokit check` names where the depth sort broke a cycle ("a before b, c": +sprite ids, `Rendered.cycle_at`), which points at the sprites to split. + ## Rules that keep the depth sort right These are lessons from the CNX/Mooban views, and the engine relies on them: diff --git a/examples/datacentre/isokit.toml b/examples/datacentre/isokit.toml new file mode 100644 index 0000000..ec0069a --- /dev/null +++ b/examples/datacentre/isokit.toml @@ -0,0 +1,56 @@ +# isokit data centre demo: a three-storey data centre laid out from parts. +# Open it with `isokit studio examples/datacentre`. +title = "isokit data centre" +note = "A three-storey data centre: power and plant on the ground floor, a hall with cold aisle containment on 1F, GPU pods with hot aisle containment and colocation cages on 2F, chillers on the roof. GF, 1F and 2F cut the building open; tap racks, coolers, gas banks and detectors for their numbers." +default_level = "1f" +default_rotation = 1 +prelude = [] +theme = [] + +[[level]] +id = "site" +label = "Site" +title = "The data centre closed, with its roof plant" + +[[level]] +id = "gf" +label = "GF" +title = "Ground floor: lobby and NOC, staging, power room" + +[[level]] +id = "1f" +label = "1F" +title = "Data hall A: cold aisle containment" + +[[level]] +id = "2f" +label = "2F" +title = "Data hall B: GPU pods (hot aisle) and colocation cages" + +[[param]] +name = "storey_h" +type = "float" +default = 5.0 +label = "Storey height (m)" + +[[param]] +name = "ceiling" +type = "bool" +default = true +label = "Ceiling services" + +[[param]] +name = "labels" +type = "bool" +default = true +label = "Labels" + +[[param]] +name = "running" +type = "bool" +default = true +label = "Animation" + +[build] +out = "dist/index.html" +budget = 12000 diff --git a/examples/datacentre/scene.json b/examples/datacentre/scene.json new file mode 100644 index 0000000..f11cb8c --- /dev/null +++ b/examples/datacentre/scene.json @@ -0,0 +1,184 @@ +{ + "version": 1, + "objects": [ + {"id": "site", "type": "site", "x": 0, "y": 2, "props": {"w": 92, "d": 74, "mat": "g-grass"}, "name": "Site", "locked": true}, + {"id": "apron", "type": "area", "x": 0, "y": 1, "props": {"w": 64, "d": 44, "mat": "g-conc"}, "name": "Yard apron", "locked": true}, + {"id": "road", "type": "road", "x": 0, "y": -29, "props": {"w": 92, "d": 7}, "name": "Main road"}, + {"id": "access", "type": "road", "x": 0, "y": -21.5, "rot": 90, "props": {"w": 15, "d": 6, "dashed": false}, "name": "Access road"}, + {"id": "parking", "type": "parking", "x": -22, "y": -17, "props": {"n": 8, "bay_w": 2.5, "bay_d": 5}, "name": "Staff parking"}, + {"id": "car-0", "type": "car", "x": -30.75, "y": -17, "rot": 90, "props": {"colour": "car1"}, "name": "Car"}, + {"id": "car-1", "type": "car", "x": -28.25, "y": -17, "rot": 90, "props": {"colour": "car3"}, "name": "Car"}, + {"id": "car-3", "type": "car", "x": -23.25, "y": -17, "rot": 90, "props": {"colour": "car4"}, "name": "Car"}, + {"id": "car-4", "type": "car", "x": -20.75, "y": -17, "rot": 90, "props": {"colour": "car1"}, "name": "Car"}, + {"id": "truck", "type": "truck", "x": 0, "y": -18.5, "rot": 90, "props": {"len": 8}, "name": "Delivery truck", "info": "Racks arriving at the loading dock"}, + {"id": "f-s1", "type": "fence", "x": -31.5, "y": -24.0, "props": {"w": 21.0, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-s2", "type": "fence", "x": -12.5, "y": -24.0, "props": {"w": 17.0, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-s3", "type": "fence", "x": 12.5, "y": -24.0, "props": {"w": 17.0, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-s4", "type": "fence", "x": 31.5, "y": -24.0, "props": {"w": 21.0, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-e1", "type": "fence", "x": 42.0, "y": -9.75, "rot": 90.0, "props": {"w": 28.5, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-e2", "type": "fence", "x": 42.0, "y": 18.75, "rot": 90.0, "props": {"w": 28.5, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-n1", "type": "fence", "x": 28.0, "y": 33.0, "rot": 180.0, "props": {"w": 28.0, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-n2", "type": "fence", "x": 0.0, "y": 33.0, "rot": 180.0, "props": {"w": 28.0, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-n3", "type": "fence", "x": -28.0, "y": 33.0, "rot": 180.0, "props": {"w": 28.0, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-w1", "type": "fence", "x": -42.0, "y": 18.75, "rot": 270.0, "props": {"w": 28.5, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "f-w2", "type": "fence", "x": -42.0, "y": -9.75, "rot": 270.0, "props": {"w": 28.5, "h": 2.4, "style": "mesh"}, "name": "Security fence"}, + {"id": "guard", "type": "shed", "x": 8, "y": -21, "props": {"w": 3, "d": 2.4, "h": 2.7}, "name": "Gatehouse", "tag": "Security"}, + {"id": "guard-p", "type": "person", "x": 6.2, "y": -21.6, "props": {"cls": "suit"}, "name": "Guard"}, + {"id": "gen-1", "type": "generator", "x": -15, "y": 19, "props": {"running": true}, "name": "Generator 1", "info": "2.5 MVA diesel genset, N+1"}, + {"id": "gen-2", "type": "generator", "x": -7, "y": 19, "props": {"running": false}, "name": "Generator 2", "info": "2.5 MVA diesel genset, N+1"}, + {"id": "gen-3", "type": "generator", "x": 1, "y": 19, "props": {"running": false}, "name": "Generator 3", "info": "2.5 MVA diesel genset, N+1"}, + {"id": "gen-4", "type": "generator", "x": 9, "y": 19, "props": {"running": false}, "name": "Generator 4", "info": "2.5 MVA diesel genset, N+1"}, + {"id": "fuel", "type": "tank", "x": 19, "y": 19.5, "props": {"r": 1.8, "h": 3.4, "mat": "tank"}, "name": "Diesel tank", "info": "48 h of fuel at full load"}, + {"id": "tx-1", "type": "transformer", "x": 29, "y": 7, "rot": 90, "props": {"kva": 3150}, "name": "Transformer T1", "tag": "T1"}, + {"id": "tx-2", "type": "transformer", "x": 29, "y": 0.5, "rot": 90, "props": {"kva": 3150}, "name": "Transformer T2", "tag": "T2"}, + {"id": "ct", "type": "cooling_tower", "x": 31, "y": -12, "props": {"cells": 2, "kw": 750}, "name": "Cooling tower", "tag": "Condenser water"}, + {"id": "ct-pipe", "type": "pipe_run", "x": 25.0, "y": -12, "props": {"w": 4.0, "kind": "condenser", "dn": 0.3, "h": 1.0}, "name": "Condenser pipes"}, + {"id": "tree-0", "type": "tree", "x": -30, "y": 28, "props": {"r": 2.2}, "name": "Tree"}, + {"id": "tree-1", "type": "tree", "x": -22, "y": 28, "props": {"r": 2.2}, "name": "Tree"}, + {"id": "tree-2", "type": "tree", "x": 22, "y": 28, "props": {"r": 2.2}, "name": "Tree"}, + {"id": "tree-3", "type": "tree", "x": 34, "y": 28, "props": {"r": 2.2}, "name": "Tree"}, + {"id": "trees-w", "type": "tree_row", "x": -37, "y": 4, "rot": 90, "props": {"w": 40, "spacing": 7, "r": 1.8}, "name": "Trees"}, + {"id": "lamp-0", "type": "lamp", "x": -12, "y": -23, "props": {"h": 6}, "name": "Lamp"}, + {"id": "lamp-1", "type": "lamp", "x": 12, "y": -23, "props": {"h": 6}, "name": "Lamp"}, + {"id": "lamp-2", "type": "lamp", "x": -26, "y": 12, "props": {"h": 6}, "name": "Lamp"}, + {"id": "lamp-3", "type": "lamp", "x": 26, "y": 14, "props": {"h": 6}, "name": "Lamp"}, + {"id": "dc", "type": "dc_building", "x": 0, "y": 0, "props": {"w": 42, "d": 26, "storeys": 3, "door_at": 0.16666666666666666, "dock_at": 0.5}, "name": "Data centre CNX-1", "info": "Three storeys: power and plant on the ground floor, two data halls above, chillers on the roof"}, + {"id": "gf-w1", "type": "house_wall", "x": -7, "y": 0, "rot": 90, "props": {"w": 25.2, "h": 4.7, "t": 0.2, "mat": "wall2"}, "name": "GF partition (lobby)"}, + {"id": "gf-w1-door", "type": "door", "x": -7, "y": -9, "rot": 90, "props": {"w": 1.0, "h": 2.2, "style": "panel", "mat": "doorg"}, "name": "Lobby door"}, + {"id": "gf-e1", "type": "house_wall", "x": 7, "y": 0, "rot": 90, "props": {"w": 25.2, "h": 4.7, "t": 0.2, "mat": "firewall"}, "name": "GF fire wall (power room)"}, + {"id": "gf-e1-door", "type": "door", "x": 7, "y": -9, "rot": 90, "props": {"w": 1.2, "h": 2.2, "style": "panel", "mat": "doorg"}, "name": "Power room door"}, + {"id": "gf-stairs", "type": "stairs", "x": -19.4, "y": 7.5, "props": {"w": 1.4, "run": 7, "rise": 4.7, "mat": "concb"}, "name": "Stairs to 1F"}, + {"id": "noc-1", "type": "desk", "x": -15, "y": -3, "props": {"w": 1.6}, "name": "NOC desk"}, + {"id": "noc-2", "type": "desk", "x": -12.6, "y": -3, "props": {"w": 1.6, "chair": false}, "name": "NOC desk"}, + {"id": "noc-3", "type": "desk", "x": -15, "y": -6.5, "props": {"w": 1.6}, "name": "NOC desk"}, + {"id": "noc-p", "type": "person", "x": -12.6, "y": -3.8, "props": {"cls": "suit", "seated": true}, "name": "NOC operator"}, + {"id": "gf-facp", "type": "fire_device", "x": -17.5, "y": -12.53, "rot": 180, "props": {"kind": "alarm panel", "label": "Main fire alarm panel"}, "name": "Fire alarm panel", "tag": "FACP"}, + {"id": "gf-cp-1", "type": "fire_device", "x": -12.4, "y": -12.53, "rot": 180, "props": {"kind": "call point", "label": "Lobby"}, "name": "Call point"}, + {"id": "gf-bc-1", "type": "fire_device", "x": -7.27, "y": 2, "rot": 270, "props": {"kind": "beacon"}, "name": "Sounder-beacon"}, + {"id": "gf-ext-1", "type": "fire_device", "x": -8, "y": -12.4, "rot": 180, "props": {"kind": "extinguisher"}, "name": "Extinguisher"}, + {"id": "staging", "type": "rack_row", "x": 0, "y": 5.5, "props": {"n": 6, "kind": "empty", "u": 48, "doors": "none"}, "name": "Racks waiting for install", "tag": "Staging"}, + {"id": "staging-p", "type": "person", "x": 1.8, "y": 3.6, "props": {"cls": "hivis"}, "name": "Technician"}, + {"id": "gf-ext-2", "type": "fire_device", "x": 6.73, "y": -6, "rot": 90, "props": {"kind": "extinguisher"}, "name": "Extinguisher"}, + {"id": "ups", "type": "power_cab", "x": 13.5, "y": 10.9, "props": {"kind": "UPS", "n": 6, "kw": 800}, "name": "UPS line-up", "tag": "UPS 6 × 800 kW"}, + {"id": "batt-1", "type": "power_cab", "x": 13.5, "y": 7.6, "props": {"kind": "battery", "n": 12, "kwh": 60}, "name": "Battery string A"}, + {"id": "batt-2", "type": "power_cab", "x": 13.5, "y": 4.6, "props": {"kind": "battery", "n": 12, "kwh": 60}, "name": "Battery string B"}, + {"id": "swg", "type": "power_cab", "x": 13.5, "y": -3.5, "props": {"kind": "switchgear", "n": 7}, "name": "LV switchgear", "tag": "Switchgear"}, + {"id": "gf-gas", "type": "gas_bank", "x": 20.45, "y": -8, "rot": 270, "props": {"agent": "Inergen", "n": 21, "litres": 140, "vol": 1620}, "name": "Inergen bank (power room)", "tag": "Inergen"}, + {"id": "gf-cp-2", "type": "fire_device", "x": 7.27, "y": -7.6, "rot": 90, "props": {"kind": "call point", "label": "Power room"}, "name": "Call point"}, + {"id": "gf-bc-2", "type": "fire_device", "x": 20.53, "y": 2, "rot": 270, "props": {"kind": "beacon"}, "name": "Sounder-beacon"}, + {"id": "gf-det-w", "type": "smoke_detectors", "x": -13.85, "y": 0, "props": {"w": 13.4, "d": 25.2, "spacing": 6.5, "h": 4.4, "zone": "GF lobby"}, "name": "Detectors GF lobby"}, + {"id": "gf-det-m", "type": "smoke_detectors", "x": 0, "y": 0, "props": {"w": 13.8, "d": 25.2, "spacing": 6.5, "h": 4.4, "zone": "GF staging"}, "name": "Detectors GF staging"}, + {"id": "gf-det-e", "type": "smoke_detectors", "x": 13.85, "y": 0, "props": {"w": 13.4, "d": 25.2, "spacing": 6.5, "h": 4.4, "kind": "multi-sensor", "zone": "GF power room"}, "name": "Detectors GF power room"}, + {"id": "1f-p1", "type": "house_wall", "x": -17.3, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 25.2, "h": 4.7, "t": 0.2, "mat": "firewall"}, "name": "1F fire wall (hall)"}, + {"id": "1f-p2", "type": "house_wall", "x": -18.95, "y": 2.9, "z": 5.0, "props": {"w": 3.3, "h": 4.7, "t": 0.2, "mat": "wall2"}, "name": "1F core wall"}, + {"id": "1f-p1-d1", "type": "door", "x": -17.3, "y": 8.5, "z": 5.0, "rot": 90, "props": {"w": 1.2, "h": 2.2, "style": "double", "mat": "doorg"}, "name": "Hall doors"}, + {"id": "1f-p1-d2", "type": "door", "x": -17.3, "y": -1.0, "z": 5.0, "rot": 90, "props": {"w": 1.0, "h": 2.1, "style": "panel", "mat": "doorg"}, "name": "Gas room door"}, + {"id": "1f-rf", "type": "raised_floor", "x": 1.8, "y": 0, "z": 5.0, "props": {"w": 37.8, "d": 25.2, "h": 0.6, "mat": "dcrf"}, "name": "1F raised floor"}, + {"id": "1f-det", "type": "smoke_detectors", "x": 1.8, "y": 0, "z": 5.0, "props": {"w": 37.8, "d": 25.2, "spacing": 6.0, "h": 4.55, "zone": "1F hall"}, "name": "Detectors 1F hall"}, + {"id": "1f-det-g", "type": "smoke_detectors", "x": -19.05, "y": -4.9, "z": 5.0, "props": {"w": 3.1, "d": 15.4, "spacing": 6.0, "h": 4.4, "coverage": false, "zone": "1F gas room"}, "name": "Detectors 1F gas room"}, + {"id": "1f-vesda-1", "type": "fire_pipe", "x": 1.75, "y": -9, "z": 5.0, "props": {"w": 37.5, "kind": "VESDA", "spacing": 4.0, "h": 4.4}, "name": "VESDA pipe 1"}, + {"id": "1f-vesda-2", "type": "fire_pipe", "x": 1.75, "y": -3, "z": 5.0, "props": {"w": 37.5, "kind": "VESDA", "spacing": 4.0, "h": 4.4}, "name": "VESDA pipe 2"}, + {"id": "1f-vesda-3", "type": "fire_pipe", "x": 1.75, "y": 3, "z": 5.0, "props": {"w": 37.5, "kind": "VESDA", "spacing": 4.0, "h": 4.4}, "name": "VESDA pipe 3"}, + {"id": "1f-vesda-4", "type": "fire_pipe", "x": 1.75, "y": 9, "z": 5.0, "props": {"w": 37.5, "kind": "VESDA", "spacing": 4.0, "h": 4.4}, "name": "VESDA pipe 4"}, + {"id": "1f-tray", "type": "cable_tray", "x": 2, "y": 0, "z": 5.0, "props": {"w": 36, "kind": "ladder", "width": 0.6, "h": 4.0, "fill": 60}, "name": "Main power tray"}, + {"id": "1f-rel", "type": "fire_device", "x": -17.03, "y": 5.4, "z": 5.0, "rot": 90, "props": {"kind": "release panel", "label": "1F hall"}, "name": "Release panel"}, + {"id": "1f-abort", "type": "fire_device", "x": -17.03, "y": 6.4, "z": 5.0, "rot": 90, "props": {"kind": "abort switch"}, "name": "Abort switch"}, + {"id": "1f-cp", "type": "fire_device", "x": -17.03, "y": 10.2, "z": 5.0, "rot": 90, "props": {"kind": "call point", "label": "1F hall"}, "name": "Call point"}, + {"id": "1f-bc-1", "type": "fire_device", "x": -17.03, "y": -6, "z": 5.0, "rot": 90, "props": {"kind": "beacon"}, "name": "Sounder-beacon"}, + {"id": "1f-bc-2", "type": "fire_device", "x": 20.53, "y": 5, "z": 5.0, "rot": 270, "props": {"kind": "beacon"}, "name": "Sounder-beacon"}, + {"id": "1f-ext", "type": "fire_device", "x": -16.8, "y": 1.2, "z": 5.0, "rot": 90, "props": {"kind": "extinguisher"}, "name": "Extinguisher"}, + {"id": "1f-stairs", "type": "stairs", "x": -19.4, "y": 7.9, "z": 5.0, "props": {"w": 1.4, "run": 6.4, "rise": 4.7, "mat": "concb"}, "name": "Stairs 1F"}, + {"id": "1f-crah-n1", "type": "crah", "x": -13, "y": 11.95, "z": 5.0, "props": {"kw": 150, "standby": false}, "name": "CRAH 1-N1"}, + {"id": "1f-crah-s1", "type": "crah", "x": -13, "y": -11.95, "z": 5.0, "rot": 180, "props": {"kw": 150, "standby": true}, "name": "CRAH 1-S1"}, + {"id": "1f-crah-n2", "type": "crah", "x": -6.5, "y": 11.95, "z": 5.0, "props": {"kw": 150, "standby": false}, "name": "CRAH 1-N2"}, + {"id": "1f-crah-s2", "type": "crah", "x": -6.5, "y": -11.95, "z": 5.0, "rot": 180, "props": {"kw": 150, "standby": false}, "name": "CRAH 1-S2"}, + {"id": "1f-crah-n3", "type": "crah", "x": 0, "y": 11.95, "z": 5.0, "props": {"kw": 150, "standby": false}, "name": "CRAH 1-N3"}, + {"id": "1f-crah-s3", "type": "crah", "x": 0, "y": -11.95, "z": 5.0, "rot": 180, "props": {"kw": 150, "standby": false}, "name": "CRAH 1-S3"}, + {"id": "1f-crah-n4", "type": "crah", "x": 6.5, "y": 11.95, "z": 5.0, "props": {"kw": 150, "standby": false}, "name": "CRAH 1-N4"}, + {"id": "1f-crah-s4", "type": "crah", "x": 6.5, "y": -11.95, "z": 5.0, "rot": 180, "props": {"kw": 150, "standby": false}, "name": "CRAH 1-S4"}, + {"id": "1f-crah-n5", "type": "crah", "x": 13, "y": 11.95, "z": 5.0, "props": {"kw": 150, "standby": true}, "name": "CRAH 1-N5"}, + {"id": "1f-crah-s5", "type": "crah", "x": 13, "y": -11.95, "z": 5.0, "rot": 180, "props": {"kw": 150, "standby": false}, "name": "CRAH 1-S5"}, + {"id": "1f-pod-A1", "type": "dc_pod", "x": -11.5, "y": 4.3, "z": 5.0, "rot": 90, "props": {"n": 10, "kind": "servers", "kw": 7, "fill": 75, "u": 42, "contain": "cold"}, "name": "Pod A1", "tag": "Pod A1"}, + {"id": "1f-pod-A2", "type": "dc_pod", "x": -11.5, "y": -4.3, "z": 5.0, "rot": 90, "props": {"n": 10, "kind": "servers", "kw": 7, "fill": 75, "u": 42, "contain": "cold"}, "name": "Pod A2", "tag": "Pod A2"}, + {"id": "1f-bus-AL", "type": "busway", "x": -12.7, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "h": 3.15, "amps": 630}, "name": "Busway"}, + {"id": "1f-bus-AR", "type": "busway", "x": -10.3, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "h": 3.15, "amps": 630}, "name": "Busway"}, + {"id": "1f-fibre-A", "type": "cable_tray", "x": -11.5, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "kind": "fibre", "width": 0.3, "h": 3.65, "fill": 40}, "name": "Fibre duct"}, + {"id": "1f-pod-B1", "type": "dc_pod", "x": -5.5, "y": 4.3, "z": 5.0, "rot": 90, "props": {"n": 10, "kind": "servers", "kw": 7, "fill": 75, "u": 42, "contain": "cold"}, "name": "Pod B1", "tag": "Pod B1"}, + {"id": "1f-pod-B2", "type": "dc_pod", "x": -5.5, "y": -4.3, "z": 5.0, "rot": 90, "props": {"n": 10, "kind": "servers", "kw": 7, "fill": 75, "u": 42, "contain": "cold"}, "name": "Pod B2", "tag": "Pod B2"}, + {"id": "1f-bus-BL", "type": "busway", "x": -6.7, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "h": 3.15, "amps": 630}, "name": "Busway"}, + {"id": "1f-bus-BR", "type": "busway", "x": -4.3, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "h": 3.15, "amps": 630}, "name": "Busway"}, + {"id": "1f-fibre-B", "type": "cable_tray", "x": -5.5, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "kind": "fibre", "width": 0.3, "h": 3.65, "fill": 40}, "name": "Fibre duct"}, + {"id": "1f-pod-C1", "type": "dc_pod", "x": 0.5, "y": 4.3, "z": 5.0, "rot": 90, "props": {"n": 10, "kind": "storage", "kw": 7, "fill": 75, "u": 42, "contain": "cold"}, "name": "Pod C1", "tag": "Pod C1"}, + {"id": "1f-pod-C2", "type": "dc_pod", "x": 0.5, "y": -4.3, "z": 5.0, "rot": 90, "props": {"n": 10, "kind": "storage", "kw": 7, "fill": 75, "u": 42, "contain": "cold"}, "name": "Pod C2", "tag": "Pod C2"}, + {"id": "1f-bus-CL", "type": "busway", "x": -0.7, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "h": 3.15, "amps": 630}, "name": "Busway"}, + {"id": "1f-bus-CR", "type": "busway", "x": 1.7, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "h": 3.15, "amps": 630}, "name": "Busway"}, + {"id": "1f-fibre-C", "type": "cable_tray", "x": 0.5, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "kind": "fibre", "width": 0.3, "h": 3.65, "fill": 40}, "name": "Fibre duct"}, + {"id": "1f-pod-D1", "type": "dc_pod", "x": 6.5, "y": 4.3, "z": 5.0, "rot": 90, "props": {"n": 10, "kind": "servers", "kw": 7, "fill": 75, "u": 42, "contain": "cold"}, "name": "Pod D1", "tag": "Pod D1"}, + {"id": "1f-pod-D2", "type": "dc_pod", "x": 6.5, "y": -4.3, "z": 5.0, "rot": 90, "props": {"n": 10, "kind": "servers", "kw": 7, "fill": 75, "u": 42, "contain": "cold"}, "name": "Pod D2", "tag": "Pod D2"}, + {"id": "1f-bus-DL", "type": "busway", "x": 5.3, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "h": 3.15, "amps": 630}, "name": "Busway"}, + {"id": "1f-bus-DR", "type": "busway", "x": 7.7, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "h": 3.15, "amps": 630}, "name": "Busway"}, + {"id": "1f-fibre-D", "type": "cable_tray", "x": 6.5, "y": 0, "z": 5.0, "rot": 90, "props": {"w": 15, "kind": "fibre", "width": 0.3, "h": 3.65, "fill": 40}, "name": "Fibre duct"}, + {"id": "1f-net", "type": "rack_row", "x": 13.6, "y": 0, "z": 5.0, "rot": 270, "props": {"n": 8, "kind": "network", "kw": 4, "fill": 80, "doors": "glass"}, "name": "Network row (meet-me)", "tag": "Meet-me room"}, + {"id": "1f-pdu", "type": "power_cab", "x": 17.2, "y": 0, "z": 5.0, "rot": 270, "props": {"kind": "PDU", "n": 6, "kw": 300}, "name": "PDUs 1F"}, + {"id": "1f-gaspipe-1", "type": "fire_pipe", "x": 1.75, "y": -6, "z": 5.0, "props": {"w": 37.5, "kind": "gas", "spacing": 6.2, "h": 4.25}, "name": "Gas discharge pipe"}, + {"id": "1f-gaspipe-2", "type": "fire_pipe", "x": 1.75, "y": 6, "z": 5.0, "props": {"w": 37.5, "kind": "gas", "spacing": 6.2, "h": 4.25}, "name": "Gas discharge pipe"}, + {"id": "1f-gas", "type": "gas_bank", "x": -20.45, "y": -5.4, "z": 5.0, "rot": 90, "props": {"agent": "Novec 1230", "n": 18, "litres": 140}, "name": "Novec 1230 bank (hall A)", "tag": "Novec 1230"}, + {"id": "1f-tech", "type": "person", "x": 3.5, "y": 0.6, "z": 5.0, "props": {"cls": "hivis"}, "name": "Technician"}, + {"id": "2f-p1", "type": "house_wall", "x": -17.3, "y": 0, "z": 10.0, "rot": 90, "props": {"w": 25.2, "h": 4.7, "t": 0.2, "mat": "firewall"}, "name": "2F fire wall (hall)"}, + {"id": "2f-p2", "type": "house_wall", "x": -18.95, "y": 2.9, "z": 10.0, "props": {"w": 3.3, "h": 4.7, "t": 0.2, "mat": "wall2"}, "name": "2F core wall"}, + {"id": "2f-p1-d1", "type": "door", "x": -17.3, "y": 8.5, "z": 10.0, "rot": 90, "props": {"w": 1.2, "h": 2.2, "style": "double", "mat": "doorg"}, "name": "Hall doors"}, + {"id": "2f-p1-d2", "type": "door", "x": -17.3, "y": -1.0, "z": 10.0, "rot": 90, "props": {"w": 1.0, "h": 2.1, "style": "panel", "mat": "doorg"}, "name": "Gas room door"}, + {"id": "2f-rf", "type": "raised_floor", "x": 1.8, "y": 0, "z": 10.0, "props": {"w": 37.8, "d": 25.2, "h": 0.6, "mat": "dcrf2"}, "name": "2F raised floor"}, + {"id": "2f-det", "type": "smoke_detectors", "x": 1.8, "y": 0, "z": 10.0, "props": {"w": 37.8, "d": 25.2, "spacing": 6.0, "h": 4.55, "zone": "2F hall"}, "name": "Detectors 2F hall"}, + {"id": "2f-det-g", "type": "smoke_detectors", "x": -19.05, "y": -4.9, "z": 10.0, "props": {"w": 3.1, "d": 15.4, "spacing": 6.0, "h": 4.4, "coverage": false, "zone": "2F gas room"}, "name": "Detectors 2F gas room"}, + {"id": "2f-vesda-1", "type": "fire_pipe", "x": 1.75, "y": -9, "z": 10.0, "props": {"w": 37.5, "kind": "VESDA", "spacing": 4.0, "h": 4.4}, "name": "VESDA pipe 1"}, + {"id": "2f-vesda-2", "type": "fire_pipe", "x": 1.75, "y": -3, "z": 10.0, "props": {"w": 37.5, "kind": "VESDA", "spacing": 4.0, "h": 4.4}, "name": "VESDA pipe 2"}, + {"id": "2f-vesda-3", "type": "fire_pipe", "x": 1.75, "y": 3, "z": 10.0, "props": {"w": 37.5, "kind": "VESDA", "spacing": 4.0, "h": 4.4}, "name": "VESDA pipe 3"}, + {"id": "2f-vesda-4", "type": "fire_pipe", "x": 1.75, "y": 9, "z": 10.0, "props": {"w": 37.5, "kind": "VESDA", "spacing": 4.0, "h": 4.4}, "name": "VESDA pipe 4"}, + {"id": "2f-tray", "type": "cable_tray", "x": 2, "y": 0, "z": 10.0, "props": {"w": 36, "kind": "ladder", "width": 0.6, "h": 4.0, "fill": 60}, "name": "Main power tray"}, + {"id": "2f-rel", "type": "fire_device", "x": -17.03, "y": 5.4, "z": 10.0, "rot": 90, "props": {"kind": "release panel", "label": "2F hall"}, "name": "Release panel"}, + {"id": "2f-abort", "type": "fire_device", "x": -17.03, "y": 6.4, "z": 10.0, "rot": 90, "props": {"kind": "abort switch"}, "name": "Abort switch"}, + {"id": "2f-cp", "type": "fire_device", "x": -17.03, "y": 10.2, "z": 10.0, "rot": 90, "props": {"kind": "call point", "label": "2F hall"}, "name": "Call point"}, + {"id": "2f-bc-1", "type": "fire_device", "x": -17.03, "y": -6, "z": 10.0, "rot": 90, "props": {"kind": "beacon"}, "name": "Sounder-beacon"}, + {"id": "2f-bc-2", "type": "fire_device", "x": 20.53, "y": 5, "z": 10.0, "rot": 270, "props": {"kind": "beacon"}, "name": "Sounder-beacon"}, + {"id": "2f-ext", "type": "fire_device", "x": -16.8, "y": 1.2, "z": 10.0, "rot": 90, "props": {"kind": "extinguisher"}, "name": "Extinguisher"}, + {"id": "2f-stairs", "type": "stairs", "x": -19.4, "y": 7.9, "z": 10.0, "props": {"w": 1.4, "run": 6.4, "rise": 4.7, "mat": "concb"}, "name": "Stairs 2F"}, + {"id": "2f-gpu-11", "type": "dc_pod", "x": -11.5, "y": 4.2, "z": 10.0, "rot": 90, "props": {"n": 8, "kind": "GPU", "kw": 30, "fill": 85, "u": 48, "contain": "hot", "inrow": 2, "inrow_kw": 90, "duct": true, "duct_h": 4.2, "doors": "glass"}, "name": "GPU pod 11", "tag": "GPU 11"}, + {"id": "2f-gpu-12", "type": "dc_pod", "x": -11.5, "y": -4.2, "z": 10.0, "rot": 90, "props": {"n": 8, "kind": "GPU", "kw": 30, "fill": 85, "u": 48, "contain": "hot", "inrow": 2, "inrow_kw": 90, "duct": true, "duct_h": 4.2, "doors": "glass"}, "name": "GPU pod 12", "tag": "GPU 12"}, + {"id": "2f-bus-1L", "type": "busway", "x": -12.7, "y": 0, "z": 10.0, "rot": 90, "props": {"w": 14, "h": 3.2, "amps": 1250}, "name": "Busway"}, + {"id": "2f-bus-1R", "type": "busway", "x": -10.3, "y": 0, "z": 10.0, "rot": 90, "props": {"w": 14, "h": 3.2, "amps": 1250}, "name": "Busway"}, + {"id": "2f-gpu-21", "type": "dc_pod", "x": -5.5, "y": 4.2, "z": 10.0, "rot": 90, "props": {"n": 8, "kind": "GPU", "kw": 30, "fill": 85, "u": 48, "contain": "hot", "inrow": 2, "inrow_kw": 90, "duct": false, "duct_h": 4.2, "doors": "glass"}, "name": "GPU pod 21", "tag": "GPU 21"}, + {"id": "2f-gpu-22", "type": "dc_pod", "x": -5.5, "y": -4.2, "z": 10.0, "rot": 90, "props": {"n": 8, "kind": "GPU", "kw": 30, "fill": 85, "u": 48, "contain": "hot", "inrow": 2, "inrow_kw": 90, "duct": false, "duct_h": 4.2, "doors": "glass"}, "name": "GPU pod 22", "tag": "GPU 22"}, + {"id": "2f-bus-2L", "type": "busway", "x": -6.7, "y": 0, "z": 10.0, "rot": 90, "props": {"w": 14, "h": 3.2, "amps": 1250}, "name": "Busway"}, + {"id": "2f-bus-2R", "type": "busway", "x": -4.3, "y": 0, "z": 10.0, "rot": 90, "props": {"w": 14, "h": 3.2, "amps": 1250}, "name": "Busway"}, + {"id": "2f-cage-1", "type": "dc_cage", "x": 4.6, "y": 4.5, "z": 10.0, "props": {"w": 7.2, "d": 5.7}, "name": "Cage 1", "tag": "Cage 1"}, + {"id": "2f-colo-1", "type": "dc_pod", "x": 4.6, "y": 4.5, "z": 10.0, "props": {"n": 8, "kind": "servers", "kw": 8, "fill": 55, "contain": "none", "doors": "solid"}, "name": "Cage 1 racks"}, + {"id": "2f-fibre-1", "type": "cable_tray", "x": 4.6, "y": 4.5, "z": 10.0, "props": {"w": 7.2, "kind": "fibre", "width": 0.3, "h": 3.65, "fill": 30}, "name": "Fibre duct"}, + {"id": "2f-cage-2", "type": "dc_cage", "x": 4.6, "y": -4.5, "z": 10.0, "props": {"w": 7.2, "d": 5.7}, "name": "Cage 2", "tag": "Cage 2"}, + {"id": "2f-colo-2", "type": "dc_pod", "x": 4.6, "y": -4.5, "z": 10.0, "props": {"n": 8, "kind": "network", "kw": 8, "fill": 65, "contain": "none", "doors": "mesh"}, "name": "Cage 2 racks"}, + {"id": "2f-fibre-2", "type": "cable_tray", "x": 4.6, "y": -4.5, "z": 10.0, "props": {"w": 7.2, "kind": "fibre", "width": 0.3, "h": 3.65, "fill": 30}, "name": "Fibre duct"}, + {"id": "2f-cage-3", "type": "dc_cage", "x": 13.6, "y": 4.5, "z": 10.0, "props": {"w": 7.2, "d": 5.7}, "name": "Cage 3", "tag": "Cage 3"}, + {"id": "2f-colo-3", "type": "dc_pod", "x": 13.6, "y": 4.5, "z": 10.0, "props": {"n": 8, "kind": "storage", "kw": 8, "fill": 75, "contain": "none", "doors": "solid"}, "name": "Cage 3 racks"}, + {"id": "2f-fibre-3", "type": "cable_tray", "x": 13.6, "y": 4.5, "z": 10.0, "props": {"w": 7.2, "kind": "fibre", "width": 0.3, "h": 3.65, "fill": 30}, "name": "Fibre duct"}, + {"id": "2f-cage-4", "type": "dc_cage", "x": 13.6, "y": -4.5, "z": 10.0, "props": {"w": 7.2, "d": 5.7}, "name": "Cage 4", "tag": "Cage 4"}, + {"id": "2f-colo-4", "type": "dc_pod", "x": 13.6, "y": -4.5, "z": 10.0, "props": {"n": 8, "kind": "servers", "kw": 8, "fill": 85, "contain": "none", "doors": "mesh"}, "name": "Cage 4 racks"}, + {"id": "2f-fibre-4", "type": "cable_tray", "x": 13.6, "y": -4.5, "z": 10.0, "props": {"w": 7.2, "kind": "fibre", "width": 0.3, "h": 3.65, "fill": 30}, "name": "Fibre duct"}, + {"id": "2f-crah-n1", "type": "crah", "x": 2, "y": 11.95, "z": 10.0, "props": {"kw": 150, "standby": false}, "name": "CRAH 2-N1"}, + {"id": "2f-crah-s1", "type": "crah", "x": 2, "y": -11.95, "z": 10.0, "rot": 180, "props": {"kw": 150}, "name": "CRAH 2-S1"}, + {"id": "2f-crah-n2", "type": "crah", "x": 9, "y": 11.95, "z": 10.0, "props": {"kw": 150, "standby": false}, "name": "CRAH 2-N2"}, + {"id": "2f-crah-s2", "type": "crah", "x": 9, "y": -11.95, "z": 10.0, "rot": 180, "props": {"kw": 150}, "name": "CRAH 2-S2"}, + {"id": "2f-crah-n3", "type": "crah", "x": 16, "y": 11.95, "z": 10.0, "props": {"kw": 150, "standby": true}, "name": "CRAH 2-N3"}, + {"id": "2f-crah-s3", "type": "crah", "x": 16, "y": -11.95, "z": 10.0, "rot": 180, "props": {"kw": 150}, "name": "CRAH 2-S3"}, + {"id": "2f-spr-1", "type": "fire_pipe", "x": 9.5, "y": -8.5, "z": 10.0, "props": {"w": 21, "kind": "sprinkler", "spacing": 3.0, "h": 4.25, "unit": false}, "name": "Pre-action sprinkler pipe"}, + {"id": "2f-spr-2", "type": "fire_pipe", "x": 9.5, "y": 8.5, "z": 10.0, "props": {"w": 21, "kind": "sprinkler", "spacing": 3.0, "h": 4.25, "unit": false}, "name": "Pre-action sprinkler pipe"}, + {"id": "2f-gas", "type": "gas_bank", "x": -20.45, "y": -5.4, "z": 10.0, "rot": 90, "props": {"agent": "FM-200", "n": 18, "litres": 140}, "name": "FM-200 bank (hall B)", "tag": "FM-200"}, + {"id": "2f-tech", "type": "person", "x": -2.0, "y": 1.0, "z": 10.0, "props": {"cls": "guest2"}, "name": "Engineer"}, + {"id": "chiller-1", "type": "chiller", "x": -12, "y": -7, "z": 15.0, "props": {"w": 9, "kw": 1000}, "name": "Chiller 1", "tag": "CH1"}, + {"id": "chiller-2", "type": "chiller", "x": -1, "y": -7, "z": 15.0, "props": {"w": 9, "kw": 1000}, "name": "Chiller 2"}, + {"id": "chiller-3", "type": "chiller", "x": 10, "y": -7, "z": 15.0, "props": {"w": 9, "kw": 1000}, "name": "Chiller 3"}, + {"id": "chiller-4", "type": "chiller", "x": -6.5, "y": 6, "z": 15.0, "props": {"w": 9, "kw": 1000}, "name": "Chiller 4"}, + {"id": "chiller-5", "type": "chiller", "x": 4.5, "y": 6, "z": 15.0, "props": {"w": 9, "kw": 1000}, "name": "Chiller 5"}, + {"id": "dry", "type": "chiller", "x": 15.5, "y": 6, "z": 15.0, "props": {"w": 8, "kw": 600, "kind": "adiabatic"}, "name": "Adiabatic cooler (GPU loop)"}, + {"id": "roof-pipes", "type": "pipe_run", "x": 0, "y": -0.5, "z": 15.0, "props": {"w": 38, "dn": 0.35, "h": 0.9}, "name": "Chilled water headers"} + ] +} diff --git a/tools/build_web.py b/tools/build_web.py index 90bbd6d..56a8b77 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -151,6 +151,7 @@ def main(): (web / "factory.json").write_text(json.dumps(demo_files("factory"), ensure_ascii=False), encoding="utf-8") (web / "streets.json").write_text(json.dumps(demo_files("streets"), ensure_ascii=False), encoding="utf-8") (web / "country.json").write_text(json.dumps(demo_files("country"), ensure_ascii=False), encoding="utf-8") + (web / "datacentre.json").write_text(json.dumps(demo_files("datacentre"), ensure_ascii=False), encoding="utf-8") h = hashlib.sha256() for p in sorted((out / "assets").rglob("*")): diff --git a/tools/check_web.py b/tools/check_web.py index 03d4603..0ed83cc 100644 --- a/tools/check_web.py +++ b/tools/check_web.py @@ -404,6 +404,34 @@ try: check(len(g) == 1 and members == ["bush-1", "bush-2"] and hid == 0 and not any(o["type"] == "group" for o in doc(pg)["objects"]), f"web Studio groups: {members} in {g[0]['id'] if g else None}, hidden with it, undone") + # ---------------------------------------------------------------- the data centre demo: storeys, racks, cooling, fire safety + pg.locator("#wb-projects").click() + pg.locator("#wb-ndc").click() + pg.wait_for_load_state() + ready(pg) + pg.wait_for_timeout(800) + nd = len(doc(pg)["objects"]) + ids_in = lambda sel: pg.evaluate(f"() => [...new Set([...document.querySelectorAll(`{sel}`)].map(e => e.dataset.obj))]") + pods = [i for i in ids_in(".iso-svg [data-obj]") if i.startswith("1f-pod")] + overhead = ids_in(".iso-svg [data-layer='ceiling'] [data-obj]") + # it opens on 1F: 8 pods; trays, busways, VESDA and gas pipes, detectors sort in the ceiling layer + check(title(pg) == "isokit data centre" and nd == 179 and len(pods) == 8 and len(overhead) == 21 + and pg.evaluate("() => __studio.S.warnings.length") == 0, + f"Data centre demo opens on 1F ({nd} objects, {len(pods)} pods, {len(overhead)} overhead runs, no warnings)") + irow = lambda id_, k: pg.evaluate(f"() => Object.fromEntries((__studio.view.info('{id_}') || {{rows: []}}).rows)['{k}'] || ''") + red, gas = irow("1f-crah-n1", "Redundancy"), irow("1f-gas", "Status") + check(red == "N+2, 348 kW spare" and gas == "enough, 3 % spare", f"1F cooling {red}; Novec bank {gas}") + shot(pg, "web-datacentre.jpg") + # GF cut open: the power room shows, the halls above are left out + pg.evaluate("__studio.view.setLevel('gf')") + pg.wait_for_timeout(1200) + gf = ids_in(".iso-svg [data-obj]") + run = irow("batt-1", "Runtime") + check("ups" in gf and not any(i.startswith("1f-") for i in gf) and run == "about 21.7 min at 3,584 kW IT", + f"GF shows the power room and none of 1F; batteries {run}") + pg.evaluate("__studio.view.setLevel('1f')") + pg.wait_for_timeout(600) + # ---------------------------------------------------------------- a system asking for less motion # (Windows: Animation effects off): the view starts still, but the button stays and turns it on rctx = b.new_context(viewport={"width": 1200, "height": 800}, reduced_motion="reduce") diff --git a/web/web.js b/web/web.js index b029186..8475a4e 100644 --- a/web/web.js +++ b/web/web.js @@ -367,7 +367,7 @@ function injectUI() { '

Projects

' + '
' + '
    ' + - '
    ' + + '
    ' + '
    Import: drop a project folder or .zip here, or ' + (canOpenFolder ? '' : "") + "
    " + '

    Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (isokit mcp), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "

    " + @@ -421,6 +421,7 @@ function wireProjects() { $("wb-nfactory").addEventListener("click", async function () { openProject((await createProject(await demoFiles("factory.json"))).id); }); $("wb-nstreets").addEventListener("click", async function () { openProject((await createProject(await demoFiles("streets.json"))).id); }); $("wb-ncountry").addEventListener("click", async function () { openProject((await createProject(await demoFiles("country.json"))).id); }); + $("wb-ndc").addEventListener("click", async function () { openProject((await createProject(await demoFiles("datacentre.json"))).id); }); $("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); }); $("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); }); $("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; });