@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.
- package/dist/.tsbuildinfo +1 -0
- package/dist/bin/vscada.js +58 -0
- package/dist/commands/preview.js +53 -0
- package/dist/scene-path.js +22 -0
- package/dist/scene-plugin.js +35 -0
- package/dist/template-registry.js +42 -0
- package/package.json +34 -0
- package/preview-app/index.html +55 -0
- package/preview-app/main.ts +149 -0
- package/preview-app/tsconfig.json +11 -0
- package/preview-app/vite-env.d.ts +10 -0
- package/templates/boiler-turbine-generator/README.md +9 -0
- package/templates/boiler-turbine-generator/scene.test.ts +26 -0
- package/templates/boiler-turbine-generator/scene.ts +91 -0
- package/templates/conveyor-batching/README.md +9 -0
- package/templates/conveyor-batching/scene.test.ts +30 -0
- package/templates/conveyor-batching/scene.ts +68 -0
- package/templates/hello-tank/README.md +18 -0
- package/templates/hello-tank/scene.test.ts +24 -0
- package/templates/hello-tank/scene.ts +50 -0
- package/templates/substation-single-line/README.md +9 -0
- package/templates/substation-single-line/scene.test.ts +31 -0
- package/templates/substation-single-line/scene.ts +63 -0
- package/templates/template-test-utils.ts +49 -0
- package/templates/tsconfig.json +11 -0
- package/templates/vitest.config.ts +11 -0
- package/templates/waste-to-energy/README.md +13 -0
- package/templates/waste-to-energy/scene.test.ts +15 -0
- package/templates/waste-to-energy/scene.ts +250 -0
- package/templates/waste-to-energy/wte-checklist.test.ts +105 -0
- 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
|
+
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();
|