@vscada/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/bin/vscada.js +58 -0
  3. package/dist/commands/preview.js +53 -0
  4. package/dist/scene-path.js +22 -0
  5. package/dist/scene-plugin.js +35 -0
  6. package/dist/template-registry.js +42 -0
  7. package/package.json +34 -0
  8. package/preview-app/index.html +55 -0
  9. package/preview-app/main.ts +149 -0
  10. package/preview-app/tsconfig.json +11 -0
  11. package/preview-app/vite-env.d.ts +10 -0
  12. package/templates/boiler-turbine-generator/README.md +9 -0
  13. package/templates/boiler-turbine-generator/scene.test.ts +26 -0
  14. package/templates/boiler-turbine-generator/scene.ts +91 -0
  15. package/templates/conveyor-batching/README.md +9 -0
  16. package/templates/conveyor-batching/scene.test.ts +30 -0
  17. package/templates/conveyor-batching/scene.ts +68 -0
  18. package/templates/hello-tank/README.md +18 -0
  19. package/templates/hello-tank/scene.test.ts +24 -0
  20. package/templates/hello-tank/scene.ts +50 -0
  21. package/templates/substation-single-line/README.md +9 -0
  22. package/templates/substation-single-line/scene.test.ts +31 -0
  23. package/templates/substation-single-line/scene.ts +63 -0
  24. package/templates/template-test-utils.ts +49 -0
  25. package/templates/tsconfig.json +11 -0
  26. package/templates/vitest.config.ts +11 -0
  27. package/templates/waste-to-energy/README.md +13 -0
  28. package/templates/waste-to-energy/scene.test.ts +15 -0
  29. package/templates/waste-to-energy/scene.ts +250 -0
  30. package/templates/waste-to-energy/wte-checklist.test.ts +105 -0
  31. package/templates/waste-to-energy/wte-topology.test.ts +38 -0
@@ -0,0 +1,68 @@
1
+ import { conveyor, createScene, drum, hopper, pushbutton, screwFeeder, selectorSwitch } from '@vscada/builder';
2
+
3
+ /**
4
+ * conveyor-batching — Story 5.4 Task 4 / spec-amendments §C2 item 5: the
5
+ * manufacturing vertical. Feed hopper → screw feeder → conveyor → batching
6
+ * vessel, with a small selector/pushbutton control panel alongside — much
7
+ * lighter than `waste-to-energy` by design (Dev Notes: "sized meaningfully
8
+ * smaller").
9
+ */
10
+ const feedHopper = hopper('HPR-01')
11
+ .at(40, 40)
12
+ .label('Feed hopper')
13
+ .medium('solid')
14
+ .bind({ tag: 'CVB_HPR01.level', type: 'level', min: 0, max: 100, unit: '%' });
15
+
16
+ const feeder = screwFeeder('SCF-01')
17
+ .at(200, 90)
18
+ .label('Screw feeder')
19
+ .bind(
20
+ { tag: 'CVB_SCF01.state', type: 'state', map: { '0': 'stopped', '1': 'running' } },
21
+ { tag: 'CVB_SCF01.rpm', type: 'rotate', min: 0, max: 100 },
22
+ );
23
+
24
+ const conveyorEl = conveyor('CNV-01')
25
+ .at(340, 90)
26
+ .label('Conveyor')
27
+ .bind(
28
+ { tag: 'CVB_CNV01.state', type: 'state', map: { '0': 'stopped', '1': 'running' } },
29
+ { tag: 'CVB_CNV01.speed', type: 'rotate', min: 0, max: 100 }, // drives the belt-dash playback rate
30
+ );
31
+
32
+ const batchVessel = drum('DRM-01').at(530, 60).label('Batching vessel').bind({ tag: 'CVB_DRM01.level', type: 'level', min: 0, max: 100, unit: '%' });
33
+
34
+ const modeSelector = selectorSwitch('SEL-01')
35
+ .at(680, 60)
36
+ .label('Mode')
37
+ .bind({ tag: 'CVB_SEL01.pos', type: 'state', map: { '0': 'off', '1': 'manual', '2': 'auto' } });
38
+
39
+ // Read-only display per base spec §1.2 (no write-back path — AD-1).
40
+ const stopButton = pushbutton('PB-01').at(680, 110).label('STOP').bind({ tag: 'CVB_PLANT.stop', type: 'state', map: { '0': 'released', '1': 'pressed' } });
41
+
42
+ export default createScene('conveyor-batching', {
43
+ canvas: { width: 800, height: 220 },
44
+ title: 'Conveyor / batching line',
45
+ theme: 'classic-hmi',
46
+ })
47
+ .add(feedHopper)
48
+ .add(feeder)
49
+ .add(conveyorEl)
50
+ .add(batchVessel)
51
+ .add(modeSelector)
52
+ .add(stopButton)
53
+ .connect(feedHopper.port('outlet-bot'), feeder.port('inlet-top'), {
54
+ id: 'C-1',
55
+ medium: 'solid',
56
+ bindings: [{ tag: 'CVB_FEED01.flow', type: 'flow', min: 0, max: 50 }],
57
+ })
58
+ .connect(feeder.port('outlet'), conveyorEl.port('infeed'), {
59
+ id: 'C-2',
60
+ medium: 'solid',
61
+ bindings: [{ tag: 'CVB_FEED02.flow', type: 'flow', min: 0, max: 50 }],
62
+ })
63
+ .connect(conveyorEl.port('discharge'), batchVessel.port('inlet-top'), {
64
+ id: 'C-3',
65
+ medium: 'solid',
66
+ bindings: [{ tag: 'CVB_FEED03.flow', type: 'flow', min: 0, max: 50 }],
67
+ })
68
+ .inferTopology();
@@ -0,0 +1,18 @@
1
+ # hello-tank
2
+
3
+ The 10-minute quickstart. One tank, one pipe, one pump, one gauge — about 20 lines of heavily-commented builder code.
4
+
5
+ Run it:
6
+
7
+ ```sh
8
+ vscada preview --template hello-tank
9
+ ```
10
+
11
+ Copy it into your own project to start from a working scene instead of a blank canvas:
12
+
13
+ ```sh
14
+ cp -r node_modules/@vscada/cli/templates/hello-tank ./my-scene
15
+ vscada preview ./my-scene/scene.ts
16
+ ```
17
+
18
+ Read `scene.ts` top to bottom — it's the same file the quickstart docs (Story 5.6) walk through line by line.
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assertSchemaValid, assertSimCoversEveryBoundTag } from '../template-test-utils';
3
+ import sceneBuilder from './scene';
4
+
5
+ describe('hello-tank template (Story 5.4 Task 2, AC1)', () => {
6
+ const scene = sceneBuilder.build();
7
+
8
+ it('builds a schema-valid scene', () => {
9
+ assertSchemaValid(scene);
10
+ });
11
+
12
+ it('has a tank, a pump, and a gauge, connected by one pipe', () => {
13
+ const typesById = new Map(scene.elements.map((el) => [el.id, el.type]));
14
+ expect(typesById.get('T-101')).toBe('tank-vertical');
15
+ expect(typesById.get('PMP-01')).toBe('pump-centrifugal');
16
+ expect(typesById.get('PI-01')).toBe('gauge-analog');
17
+ expect(scene.connections).toHaveLength(1);
18
+ expect(scene.connections[0]).toMatchObject({ from: 'T-101.outlet-bot', to: 'PMP-01.suction' });
19
+ });
20
+
21
+ it('gives the simulator generator coverage for every bound tag', () => {
22
+ assertSimCoversEveryBoundTag(scene);
23
+ });
24
+ });
@@ -0,0 +1,50 @@
1
+ import { createScene, gaugeAnalog, pumpCentrifugal, tankVertical } from '@vscada/builder';
2
+
3
+ /**
4
+ * hello-tank — the 10-minute quickstart (Story 5.4 AC1; Story 5.6 walks
5
+ * through this exact file line by line). One tank, one pipe, one pump, one
6
+ * gauge. Run it with `vscada preview --template hello-tank`, then copy this
7
+ * directory into your own project and start editing.
8
+ */
9
+
10
+ // 1. Every primitive type has a typed factory — `tankVertical(id)` starts a
11
+ // tank whose scene id is "T-101". Chain calls to place and configure it.
12
+ const tank = tankVertical('T-101')
13
+ .at(80, 60) // top-left corner, in scene pixels
14
+ .label('Feed tank')
15
+ .medium('water') // the active theme colors pipes/fills by medium
16
+ .bind({ tag: 'T101.level', type: 'level', min: 0, max: 100, unit: '%' }); // drives the fill height
17
+
18
+ // 2. Relative placement (`.rightOf`) beats hand-picked coordinates once a
19
+ // scene has more than one or two elements — this pump sits 120px to the
20
+ // right of the tank, top-aligned with it. (`.alignPorts` is for lining
21
+ // up two ports that face the SAME axis, e.g. two side ports on a
22
+ // horizontal run — the tank's outlet faces down, so it isn't a fit here.)
23
+ const pump = pumpCentrifugal('PMP-01')
24
+ .rightOf(tank.id, 120)
25
+ .label('Feed pump')
26
+ .bind({ tag: 'PMP01.status', type: 'state', map: { '0': 'stopped', '1': 'running' } }); // drives the impeller spin
27
+
28
+ // 3. A gauge reading the pump's discharge pressure, placed below it.
29
+ const gauge = gaugeAnalog('PI-01')
30
+ .below(pump.id, 40)
31
+ .label('Discharge PSI')
32
+ .bind({ tag: 'PMP01.psi', type: 'needle', min: 0, max: 150 });
33
+
34
+ export default createScene('hello-tank', {
35
+ canvas: { width: 500, height: 340 },
36
+ title: 'Hello Tank',
37
+ theme: 'classic-hmi',
38
+ })
39
+ .add(tank)
40
+ .add(pump)
41
+ .add(gauge)
42
+ // 4. The "pipe" is a `.connect()` between two ports, not a separate
43
+ // element — this one also carries its own `flow` binding so it
44
+ // animates while the pump runs.
45
+ .connect(tank.port('outlet-bot'), pump.port('suction'), {
46
+ id: 'P-01',
47
+ medium: 'water',
48
+ bindings: [{ tag: 'P01.flow', type: 'flow', min: 0, max: 50 }],
49
+ })
50
+ .inferTopology(); // derives the topology graph straight from the one .connect() above
@@ -0,0 +1,9 @@
1
+ # substation-single-line
2
+
3
+ A single-line diagram: generator → breaker → main bus → breaker → step-down transformer → breaker → secondary bus → feeder breaker. The one template that exercises the electrical primitive group end to end (breakers, transformer, busbars, generator), with `direct` connection routing and an isolated feeder breaker demonstrating energization/isolation.
4
+
5
+ Run it:
6
+
7
+ ```sh
8
+ vscada preview --template substation-single-line
9
+ ```
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assertSchemaValid, assertSimCoversEveryBoundTag } from '../template-test-utils';
3
+ import sceneBuilder from './scene';
4
+
5
+ describe('substation-single-line template (Story 5.4 Task 4)', () => {
6
+ const scene = sceneBuilder.build();
7
+
8
+ it('builds a schema-valid scene', () => {
9
+ assertSchemaValid(scene);
10
+ });
11
+
12
+ it('exercises the electrical primitives with direct routing', () => {
13
+ const typesById = new Map(scene.elements.map((el) => [el.id, el.type]));
14
+ expect(typesById.get('GEN-01')).toBe('generator');
15
+ expect([...typesById.values()].filter((t) => t === 'breaker')).toHaveLength(4);
16
+ expect([...typesById.values()].filter((t) => t === 'busbar')).toHaveLength(2);
17
+ expect(typesById.get('TRF-01')).toBe('transformer');
18
+ for (const connection of scene.connections) {
19
+ expect(connection.routing).toBe('direct');
20
+ }
21
+ });
22
+
23
+ it('leaves the feeder breaker downstream port unconnected (the isolation demo)', () => {
24
+ const feederConnections = scene.connections.filter((c) => c.from.startsWith('BRK-04') || c.to.startsWith('BRK-04'));
25
+ expect(feederConnections).toHaveLength(1); // only its upstream tie, nothing downstream
26
+ });
27
+
28
+ it('gives the simulator generator coverage for every bound tag', () => {
29
+ assertSimCoversEveryBoundTag(scene);
30
+ });
31
+ });
@@ -0,0 +1,63 @@
1
+ import { breaker, busbar, createScene, generator, transformer } from '@vscada/builder';
2
+
3
+ /**
4
+ * substation-single-line — Story 5.4 Task 4 / spec-amendments §C2 item 4:
5
+ * a single-line diagram exercising the electrical primitive group (Story
6
+ * 3.10's composite "grown up") that no other template touches —
7
+ * generator → breaker → busbar → breaker → transformer → breaker → busbar
8
+ * → feeder breaker. Every connection uses `routing: 'direct'` (single-line
9
+ * diagrams draw straight ties, not orthogonal pipe runs). `BRK-04`, the
10
+ * feeder breaker, is deliberately left with an unconnected downstream port
11
+ * — the isolation demo: AD-12's topology-inferred state resolves an
12
+ * isolated branch's visual state from the breaker's own bound state,
13
+ * nothing scripted here needs to force it.
14
+ */
15
+ const gen = generator('GEN-01')
16
+ .at(40, 120)
17
+ .label('Generator')
18
+ .bind(
19
+ { tag: 'SUB_GEN01.power', type: 'readout', unit: 'MW', decimals: 1 },
20
+ { tag: 'SUB_GEN01.state', type: 'state', map: { '0': 'offline', '1': 'online' } },
21
+ );
22
+
23
+ const brk1 = breaker('BRK-01').at(160, 145).label('Generator breaker').bind({ tag: 'SUB_BRK01.state', type: 'state', map: { '0': 'open', '1': 'closed' } });
24
+
25
+ const bus1 = busbar('BUS-01').at(270, 150).size(300, 20).label('Main bus').bind({ tag: 'SUB_BUS01.state', type: 'state', map: { '0': 'de-energized', '1': 'energized' } });
26
+
27
+ const brk2 = breaker('BRK-02').at(610, 145).label('Bus tie breaker').bind({ tag: 'SUB_BRK02.state', type: 'state', map: { '0': 'open', '1': 'closed' } });
28
+
29
+ const transformerEl = transformer('TRF-01')
30
+ .at(720, 135)
31
+ .label('Step-down transformer')
32
+ .bind({ tag: 'SUB_TRF01.load', type: 'readout', unit: 'MW', decimals: 1 });
33
+
34
+ const brk3 = breaker('BRK-03').at(830, 145).label('Secondary breaker').bind({ tag: 'SUB_BRK03.state', type: 'state', map: { '0': 'open', '1': 'closed' } });
35
+
36
+ const bus2 = busbar('BUS-02').at(940, 150).size(300, 20).label('Secondary bus').bind({ tag: 'SUB_BUS02.state', type: 'state', map: { '0': 'de-energized', '1': 'energized' } });
37
+
38
+ const brk4 = breaker('BRK-04')
39
+ .at(1280, 145)
40
+ .label('Feeder breaker (isolated)')
41
+ .bind({ tag: 'SUB_BRK04.state', type: 'state', map: { '0': 'open', '1': 'closed' } });
42
+
43
+ export default createScene('substation-single-line', {
44
+ canvas: { width: 1400, height: 300 },
45
+ title: 'Substation single-line diagram',
46
+ theme: 'classic-hmi',
47
+ })
48
+ .add(gen)
49
+ .add(brk1)
50
+ .add(bus1)
51
+ .add(brk2)
52
+ .add(transformerEl)
53
+ .add(brk3)
54
+ .add(bus2)
55
+ .add(brk4)
56
+ .connect(gen.port('output'), brk1.port('line-in'), { id: 'C-1', medium: 'electricity', routing: 'direct' })
57
+ .connect(brk1.port('line-out'), bus1.port('line-in'), { id: 'C-2', medium: 'electricity', routing: 'direct' })
58
+ .connect(bus1.port('line-out'), brk2.port('line-in'), { id: 'C-3', medium: 'electricity', routing: 'direct' })
59
+ .connect(brk2.port('line-out'), transformerEl.port('primary'), { id: 'C-4', medium: 'electricity', routing: 'direct' })
60
+ .connect(transformerEl.port('secondary'), brk3.port('line-in'), { id: 'C-5', medium: 'electricity', routing: 'direct' })
61
+ .connect(brk3.port('line-out'), bus2.port('line-in'), { id: 'C-6', medium: 'electricity', routing: 'direct' })
62
+ .connect(bus2.port('line-out'), brk4.port('line-in'), { id: 'C-7', medium: 'electricity', routing: 'direct' })
63
+ .inferTopology();
@@ -0,0 +1,49 @@
1
+ import type { AnyElement, Binding, Scene } from '@vscada/core';
2
+ import { validateScene } from '@vscada/core/scene-authoring';
3
+ import { createSimulator } from '@vscada/sim';
4
+ import { expect } from 'vitest';
5
+
6
+ /** `off-page` elements (Story 2.6) carry a scene-navigation `target`, not tag bindings — every other element type does. */
7
+ export function elementBindings(element: AnyElement | undefined): readonly Binding[] {
8
+ return element && 'bindings' in element ? element.bindings : [];
9
+ }
10
+
11
+ /**
12
+ * Story 5.4 Task 5 — the two assertions every template's own test repeats:
13
+ * (1) the built scene is schema-valid (redundant with `.build()`'s own
14
+ * always-on `validateScene` call, but asserted explicitly here so a future
15
+ * refactor of `SceneBuilder.build()` can't silently start returning
16
+ * unvalidated data without a test noticing), and (2) `@vscada/sim`'s
17
+ * generator inference (Story 5.2) produces a value for every tag the scene
18
+ * itself binds — a template that references a tag with no generator
19
+ * coverage would sit frozen at "no value yet" forever in a live preview.
20
+ */
21
+ export function assertSchemaValid(scene: Scene): void {
22
+ const result = validateScene(scene);
23
+ if (!result.success) {
24
+ expect.fail(`scene failed schema validation:\n${JSON.stringify(result.issues, null, 2)}`);
25
+ }
26
+ }
27
+
28
+ function allBoundTags(scene: Scene): Set<string> {
29
+ const tags = new Set<string>();
30
+ for (const element of scene.elements) {
31
+ // `off-page` elements (Story 2.6) carry a scene-navigation `target`, not tag bindings.
32
+ if (!('bindings' in element)) continue;
33
+ for (const binding of element.bindings) tags.add(binding.tag);
34
+ }
35
+ for (const connection of scene.connections) {
36
+ for (const binding of connection.bindings ?? []) tags.add(binding.tag);
37
+ }
38
+ return tags;
39
+ }
40
+
41
+ export function assertSimCoversEveryBoundTag(scene: Scene): void {
42
+ const expectedTags = allBoundTags(scene);
43
+ const sim = createSimulator(scene, { seed: 1, quality: { enabled: false } });
44
+ const pushed = sim.step(1000);
45
+ const pushedTags = new Set(pushed.map((v) => v.tag));
46
+
47
+ const missing = [...expectedTags].filter((tag) => !pushedTags.has(tag));
48
+ expect(missing, `tags with no simulator coverage: ${missing.join(', ')}`).toEqual([]);
49
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": false,
5
+ "declaration": false,
6
+ "declarationMap": false,
7
+ "emitDeclarationOnly": false,
8
+ "noEmit": true
9
+ },
10
+ "include": ["."]
11
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ // Runs post-build only (`pnpm test:cli-templates`) -- these tests import
6
+ // @vscada/core's BUILT dist via subpath exports (e.g.
7
+ // `@vscada/core/scene-authoring`), which don't exist until `pnpm build`
8
+ // has run. See ../vitest.config.ts for the matching exclude.
9
+ include: ['**/*.test.ts'],
10
+ },
11
+ });
@@ -0,0 +1,13 @@
1
+ # waste-to-energy
2
+
3
+ The flagship template — base spec §16's reference scene, in full: feed hopper → furnace → flue-gas train (duct, two ID fans, coil bank, stack + plume) alongside the boiler → steam valve → turbine → condenser → feedwater pump → **back to the boiler** recycle loop (the loop that exercises topology cycle detection, Story 2.3). Motor starter panel, four analog gauges, an ash off-page connector, and a bottom status bar with a STOP button round it out.
4
+
5
+ This scene is also the visual-regression baseline and the Storybook landing page (Story 7.4 wires it in).
6
+
7
+ Run it:
8
+
9
+ ```sh
10
+ vscada preview --template waste-to-energy
11
+ ```
12
+
13
+ See `scene.ts` for a documented catalog gap: the element catalog's `boiler` type has no gas-side port, so the flue-gas path runs through the coil bank rather than literally piping into the boiler drum — a follow-up for whoever next touches the `boiler`/`heat-exchanger-coil` catalog entries.
@@ -0,0 +1,15 @@
1
+ import { describe, it } from 'vitest';
2
+ import { assertSchemaValid, assertSimCoversEveryBoundTag } from '../template-test-utils';
3
+ import sceneBuilder from './scene';
4
+
5
+ describe('waste-to-energy template (Story 5.4 Task 3, AC2)', () => {
6
+ const scene = sceneBuilder.build();
7
+
8
+ it('builds a schema-valid scene', () => {
9
+ assertSchemaValid(scene);
10
+ });
11
+
12
+ it('gives the simulator generator coverage for every bound tag', () => {
13
+ assertSimCoversEveryBoundTag(scene);
14
+ });
15
+ });
@@ -0,0 +1,250 @@
1
+ import {
2
+ boiler,
3
+ condenser,
4
+ createScene,
5
+ duct,
6
+ fanCentrifugal,
7
+ furnace,
8
+ gaugeAnalog,
9
+ heatExchangerCoil,
10
+ hopper,
11
+ motorStarterPanel,
12
+ offPage,
13
+ plume,
14
+ pumpCentrifugal,
15
+ pushbutton,
16
+ readoutPanel,
17
+ stack,
18
+ turbine,
19
+ valveControl,
20
+ } from '@vscada/builder';
21
+
22
+ /**
23
+ * waste-to-energy — Story 5.4 AC2 / base spec §16's reference scene, the
24
+ * flagship template: visual-regression baseline, Storybook landing page,
25
+ * and §16 compliance artifact. Every element and connection below is
26
+ * commented with the §16 bullet it satisfies.
27
+ *
28
+ * KNOWN CATALOG GAP (documented per Dev Notes — "gaps reopen the owning
29
+ * story, templates don't get local hacks"): §16 asks for "furnace → boiler
30
+ * drum via flue gas duct", but `ELEMENT_CATALOG['boiler']` only declares
31
+ * water-side ports (`feedwater-inlet`, `steam-outlet`) — no gas-side port
32
+ * to receive flue gas. The gas path below runs furnace → duct → ID fan →
33
+ * coil bank → ID fan → stack instead (heat recovery happens through the
34
+ * coil bank, matching real WtE gas-train topology); the boiler drum
35
+ * appears immediately below the gas path, thermally adjacent but not
36
+ * port-connected to it. A future revision of the element catalog
37
+ * (owning story: whichever story next touches `heat-exchanger-coil`/
38
+ * `boiler`) should add a gas-side port pair to `boiler` so this can be a
39
+ * literal connection.
40
+ */
41
+
42
+ // --- Waste feed → furnace (§16: "Feed hopper (level) → furnace (state, firing rate)") ---
43
+ const wasteHopper = hopper('HPR-01')
44
+ .at(40, 100)
45
+ .label('Waste feed hopper')
46
+ .medium('waste')
47
+ .bind({ tag: 'HPR01.level', type: 'level', min: 0, max: 100, unit: '%' })
48
+ .thresholds(
49
+ { tag: 'HPR01.level', level: 'low-low', value: 10, direction: 'below' },
50
+ { tag: 'HPR01.level', level: 'high-high', value: 90, direction: 'above' },
51
+ );
52
+
53
+ const furnaceEl = furnace('FCE-01')
54
+ .at(240, 100)
55
+ .label('Furnace')
56
+ .bind(
57
+ { tag: 'FCE01.state', type: 'state', map: { '0': 'idle', '1': 'firing' } },
58
+ // The furnace primitive (Story 3.7) specifically looks for a `color-ramp`
59
+ // binding to drive its firing-rate glow — matching §16's own wording.
60
+ { tag: 'FCE01.firingRate', type: 'color-ramp', min: 0, max: 100 },
61
+ );
62
+
63
+ const furnaceTempGauge = gaugeAnalog('GA-01').at(240, 260).label('Furnace temp').bind({ tag: 'FCE01.temp', type: 'needle', min: 0, max: 1200 });
64
+
65
+ // --- Gas path (§16: "Furnace → boiler drum via flue gas duct", "Two ID fans on the gas path", "Heat exchanger coil bank", "Stack with temperature readout and plume") ---
66
+ const flueDuct = duct('DCT-01').at(420, 130).label('Flue gas duct');
67
+
68
+ const idFan1 = fanCentrifugal('FAN-01')
69
+ .at(580, 115)
70
+ .label('ID Fan 1')
71
+ .bind({ tag: 'IDF01.state', type: 'state', map: { '0': 'stopped', '1': 'running' } }, { tag: 'IDF01.rpm', type: 'rotate', min: 0, max: 1800 });
72
+
73
+ const coilBank = heatExchangerCoil('COIL-01').at(690, 140).label('Heat exchanger coil bank');
74
+
75
+ const idFan2 = fanCentrifugal('FAN-02')
76
+ .at(830, 115)
77
+ .label('ID Fan 2')
78
+ .bind({ tag: 'IDF02.state', type: 'state', map: { '0': 'stopped', '1': 'running' } }, { tag: 'IDF02.rpm', type: 'rotate', min: 0, max: 1800 });
79
+
80
+ const stackEl = stack('STK-01').at(960, 50).label('Stack').bind({ tag: 'STK01.temp', type: 'readout', unit: 'degC', decimals: 0 });
81
+
82
+ const plumeEl = plume('PLM-01').at(955, 0).bind({ tag: 'PLM01.density', type: 'emission', min: 0, max: 100 });
83
+
84
+ const stackTempGauge = gaugeAnalog('GA-02').at(1010, 120).label('Stack temp').bind({ tag: 'STK01.temp', type: 'needle', min: 0, max: 600 });
85
+
86
+ // --- Steam / water recycle loop (§16: "Boiler drum (level, pressure) → turbine via steam line",
87
+ // "Turbine (power, RPM readouts) → condenser → feedwater pump → back to drum — THE recycle loop,
88
+ // exercises cycle detection", "Steam control valve (position 0–100) on the turbine inlet") ---
89
+ const boilerDrum = boiler('BLR-01')
90
+ .at(240, 400)
91
+ .label('Boiler drum')
92
+ .bind(
93
+ { tag: 'BLR01.level', type: 'level', min: 0, max: 100, unit: '%' },
94
+ { tag: 'BLR01.pressure', type: 'readout', unit: 'bar', decimals: 1 },
95
+ )
96
+ .thresholds(
97
+ { tag: 'BLR01.level', level: 'low-low', value: 20, direction: 'below' },
98
+ { tag: 'BLR01.level', level: 'high-high', value: 85, direction: 'above' },
99
+ );
100
+
101
+ const steamValve = valveControl('VLV-01').at(400, 440).label('Steam control valve').bind({ tag: 'VLV01.pos', type: 'position', min: 0, max: 100 });
102
+
103
+ const turbineEl = turbine('TRB-01')
104
+ .at(500, 415)
105
+ .label('Turbine')
106
+ .bind({ tag: 'TRB01.power', type: 'readout', unit: 'MW', decimals: 1 }, { tag: 'TRB01.rpm', type: 'rotate', min: 0, max: 3600 });
107
+
108
+ const steamFlowGauge = gaugeAnalog('GA-04').at(560, 500).label('Steam flow').bind({ tag: 'STM02.flow', type: 'needle', min: 0, max: 100 });
109
+
110
+ const condenserEl = condenser('CND-01').at(650, 430).label('Condenser');
111
+
112
+ const feedwaterPump = pumpCentrifugal('PMP-02')
113
+ .at(820, 430)
114
+ .label('Feedwater pump')
115
+ .bind({ tag: 'FWP01.state', type: 'state', map: { '0': 'stopped', '1': 'running' } }, { tag: 'FWP01.rpm', type: 'rotate', min: 0, max: 1800 });
116
+
117
+ // Motor starter panel — §16: "Motor starter panels for each pump" (one pump in this scene).
118
+ // Bound to the SAME tag as the pump's own status: the panel mirrors the pump's run state,
119
+ // not an independent value — one generator, two visual representations (Story 5.2 precedent).
120
+ const feedwaterMsp = motorStarterPanel('MSP-01')
121
+ .at(900, 410)
122
+ .label('Feedwater pump starter')
123
+ .bind({ tag: 'FWP01.state', type: 'state', map: { '0': 'stopped', '1': 'running' } });
124
+
125
+ const feedwaterFlowGauge = gaugeAnalog('GA-03').at(820, 530).label('Feedwater flow').bind({ tag: 'FW01.flow', type: 'needle', min: 0, max: 100 });
126
+
127
+ // --- Ash byproduct (§16 media list includes "ash"; §16 gives the furnace no ash-handling
128
+ // port — see the KNOWN CATALOG GAP note above — so the ash hopper is shown adjacent to the
129
+ // gas train and its own outlet feeds an off-page connector, exercising the "ash" medium on
130
+ // a real connection without inventing a furnace port that doesn't exist in the catalog) ---
131
+ const ashHopper = hopper('ASH-01').at(40, 600).label('Ash hopper').medium('ash').bind({ tag: 'ASH01.level', type: 'level', min: 0, max: 100, unit: '%' });
132
+
133
+ // `.target()` is required by both the Zod schema (`OffPageElementSchema`)
134
+ // and the always-on structural validator ("off-page must declare target") —
135
+ // this scene predates that requirement and was never updated, so it built
136
+ // "successfully" (the generic element schema's plain z.union silently
137
+ // absorbed the malformed off-page element) while failing structural
138
+ // validation at render time. "ash-handling"/"from-boiler" mirrors the
139
+ // existing off-page fixture convention (structural-validator.test.ts) for
140
+ // the same "ash" flow this element represents.
141
+ const ashOffPage = offPage('OFP-01').at(220, 650).label('Ash to disposal').target('ash-handling', 'from-boiler');
142
+
143
+ // --- Bottom status bar (§16: "Bottom status bar with plant-level readouts and a STOP button") ---
144
+ // readout-panel (Story 3.6) only renders `readout`-typed bindings — one row each.
145
+ const statusPanel = readoutPanel('RDP-01')
146
+ .at(700, 800)
147
+ .label('Plant status')
148
+ .bind(
149
+ { tag: 'PLANT.load', type: 'readout', unit: 'MW', decimals: 1 },
150
+ { tag: 'PLANT.steamOutput', type: 'readout', unit: 't/h', decimals: 1 },
151
+ );
152
+
153
+ // Read-only display per base spec §1.2 (no write-back path — AD-1: values enter only via
154
+ // `values`/`setValues()`; nothing here dispatches a command back into the store).
155
+ const stopButton = pushbutton('PB-01').at(820, 810).label('STOP').bind({ tag: 'PLANT.stop', type: 'state', map: { '0': 'released', '1': 'pressed' } });
156
+
157
+ export default createScene('waste-to-energy', {
158
+ canvas: { width: 1150, height: 900 },
159
+ title: 'Waste to Energy — reference scene (base spec §16)',
160
+ theme: 'classic-hmi',
161
+ })
162
+ .add(wasteHopper)
163
+ .add(furnaceEl)
164
+ .add(furnaceTempGauge)
165
+ .add(flueDuct)
166
+ .add(idFan1)
167
+ .add(coilBank)
168
+ .add(idFan2)
169
+ .add(stackEl)
170
+ .add(plumeEl)
171
+ .add(stackTempGauge)
172
+ .add(boilerDrum)
173
+ .add(steamValve)
174
+ .add(turbineEl)
175
+ .add(steamFlowGauge)
176
+ .add(condenserEl)
177
+ .add(feedwaterPump)
178
+ .add(feedwaterMsp)
179
+ .add(feedwaterFlowGauge)
180
+ .add(ashHopper)
181
+ .add(ashOffPage)
182
+ .add(statusPanel)
183
+ .add(stopButton)
184
+ // Media exercised (§16): waste, flue-gas, steam, feedwater, condensate, ash.
185
+ .connect(wasteHopper.port('outlet-bot'), furnaceEl.port('fuel-inlet'), {
186
+ id: 'C-WASTE',
187
+ medium: 'waste',
188
+ bindings: [{ tag: 'WASTE01.flow', type: 'flow', min: 0, max: 20 }],
189
+ })
190
+ .connect(furnaceEl.port('flue-outlet'), flueDuct.port('inlet'), {
191
+ id: 'C-FG1',
192
+ medium: 'flue-gas',
193
+ bindings: [{ tag: 'FG01.flow', type: 'flow', min: 0, max: 100 }],
194
+ })
195
+ .connect(flueDuct.port('outlet'), idFan1.port('inlet'), {
196
+ id: 'C-FG2',
197
+ medium: 'flue-gas',
198
+ bindings: [{ tag: 'FG02.flow', type: 'flow', min: 0, max: 100 }],
199
+ })
200
+ .connect(idFan1.port('outlet'), coilBank.port('inlet'), {
201
+ id: 'C-FG3',
202
+ medium: 'flue-gas',
203
+ bindings: [{ tag: 'FG03.flow', type: 'flow', min: 0, max: 100 }],
204
+ })
205
+ .connect(coilBank.port('outlet'), idFan2.port('inlet'), {
206
+ id: 'C-FG4',
207
+ medium: 'flue-gas',
208
+ bindings: [{ tag: 'FG04.flow', type: 'flow', min: 0, max: 100 }],
209
+ })
210
+ .connect(idFan2.port('outlet'), stackEl.port('inlet'), {
211
+ id: 'C-FG5',
212
+ medium: 'flue-gas',
213
+ bindings: [{ tag: 'FG05.flow', type: 'flow', min: 0, max: 100 }],
214
+ })
215
+ .connect(boilerDrum.port('steam-outlet'), steamValve.port('inlet'), {
216
+ id: 'C-STM1',
217
+ medium: 'steam',
218
+ bindings: [{ tag: 'STM01.flow', type: 'flow', min: 0, max: 100 }],
219
+ })
220
+ .connect(steamValve.port('outlet'), turbineEl.port('inlet'), {
221
+ id: 'C-STM2',
222
+ medium: 'steam',
223
+ bindings: [{ tag: 'STM02.flow', type: 'flow', min: 0, max: 100 }],
224
+ })
225
+ .connect(turbineEl.port('outlet'), condenserEl.port('inlet'), {
226
+ id: 'C-STM3',
227
+ medium: 'steam',
228
+ bindings: [{ tag: 'STM03.flow', type: 'flow', min: 0, max: 100 }],
229
+ })
230
+ .connect(condenserEl.port('outlet'), feedwaterPump.port('suction'), {
231
+ id: 'C-COND',
232
+ medium: 'condensate',
233
+ bindings: [{ tag: 'COND01.flow', type: 'flow', min: 0, max: 100 }],
234
+ })
235
+ // The recycle loop (§16): feedwater pump discharge closes back onto the boiler drum's own
236
+ // feedwater inlet — this edge is what makes the topology graph a cycle (Story 2.3).
237
+ .connect(feedwaterPump.port('discharge'), boilerDrum.port('feedwater-inlet'), {
238
+ id: 'C-FW',
239
+ medium: 'feedwater',
240
+ bindings: [{ tag: 'FW01.flow', type: 'flow', min: 0, max: 100 }],
241
+ })
242
+ .connect(ashHopper.port('outlet-bot'), ashOffPage.port('link'), {
243
+ id: 'C-ASH',
244
+ medium: 'ash',
245
+ bindings: [{ tag: 'ASH01.flow', type: 'flow', min: 0, max: 20 }],
246
+ })
247
+ // Boiler section collapsible (Dev Notes: "2.5 in anger") — the steam-generation core.
248
+ .group('boiler-section', { label: 'Boiler', collapsed: false, x: 220, y: 380, w: 280, h: 180, children: ['BLR-01', 'VLV-01'] })
249
+ .group('status-bar-group', { label: 'Plant status bar', collapsed: false, x: 680, y: 780, w: 200, h: 100, children: ['RDP-01', 'PB-01'] })
250
+ .inferTopology();