Compare commits

...
10 Commits
Author SHA1 Message Date
Jonas Bengtsson 647c209e89 🚧 Add environment maneuverability 2026-10-04 22:48:10 +02:00
Jonas Bengtsson 68d3c42d79 🚧 WIP 2026-10-04 20:59:22 +02:00
Jonas Bengtsson f82e8d4be9 🚧 Add debug env 2026-10-04 16:36:45 +02:00
Jonas Bengtsson 66b7e12f0f 🚧 Rewrite continous simulation. 2026-10-04 15:29:57 +02:00
Jonas Bengtsson 263afc9c1e 🚧 Implement continous simulation 2026-10-04 14:50:57 +02:00
Jonas Bengtsson eb36a2d947 🚧 WIP 2026-10-04 14:15:44 +02:00
Jonas Bengtsson c30b444ef1 📝 Update spec and README 2026-10-04 14:15:26 +02:00
Jonas Bengtsson 59679b96ee 🚧 WIP 2026-10-04 14:15:07 +02:00
Jonas Bengtsson e5df0b04ed 🚧 Change module numbering 2026-10-04 13:05:47 +02:00
Jonas Bengtsson 7948b794f5 🚧 Make analog indicators clickable 2026-10-04 11:22:55 +02:00
35 changed files with 4723 additions and 214 deletions
+22
View File
@@ -22,6 +22,28 @@
], ],
"console": "integratedTerminal" "console": "integratedTerminal"
}, },
{
// Serve the pump node WITH the example plant environment
// attached (FR-8.2.17): closed-loop tank physics ticked
// wall-clock-paced (10 ms) alongside the Modbus server.
// A PLC (or pymodbus script) drives DO0.0/AO0.0 and reads
// back the simulated field (AI0.0 level, DI0.1 low-level
// switch); runs until stopped (Shift+F5 / Ctrl+C).
"name": "wago-sim: serve (pump node + plant env)",
"type": "debugpy",
"request": "launch",
"module": "wago_sim.cli",
"args": [
"--verbose",
"serve",
"--node", "config/pump_node.yaml",
"--host", "127.0.0.1",
"--web",
"--port", "5020",
"--env", "envs.pump_station_env:PumpStationEnv"
],
"console": "integratedTerminal"
},
{ {
// Run the example FAT scenario headless and write reports. // Run the example FAT scenario headless and write reports.
"name": "wago-sim: run (pump_station_fat)", "name": "wago-sim: run (pump_station_fat)",
+980
View File
@@ -913,6 +913,986 @@
], ],
"isBackground": false, "isBackground": false,
"problemMatcher": [] "problemMatcher": []
},
{
"label": "wago-sim: pytest barclick",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: serve barclick",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8088"
],
"isBackground": true,
"problemMatcher": []
},
{
"label": "kill ports 4",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('killed')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: serve barclick2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8088"
],
"isBackground": true,
"problemMatcher": []
},
{
"label": "wago-sim: pytest webui final",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_webui.py",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "stop barclick serve",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('stopped')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "rewrite webui test codes",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; p = pathlib.Path('tests/test_webui.py'); t = p.read_text(encoding='utf-8');\nt = t.replace('AI4.0', 'AI0.0').replace('AO5.0', 'AO0.0').replace('DO2.0', 'DO0.0');\nt = t.replace('/api/channels/do/2/', '/api/channels/do/0/');\nt = t.replace('/api/channels/ai/4/', '/api/channels/ai/0/');\nt = t.replace('/api/channels/ao/5/', '/api/channels/ao/0/');\nt = t.replace('channel_range(\"ao\", 5, 0)', 'channel_range(\"ao\", 0, 0)');\nt = t.replace('channel_range(\"ai\", 4, 0)', 'channel_range(\"ai\", 0, 0)');\nt = t.replace('(\\\"ai\\\", 4, 0)', '(\\\"ai\\\", 0, 0)');\np.write_text(t, encoding='utf-8'); print('rewritten')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest per-type",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest per-type2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "fix webui test urls",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; p = pathlib.Path('tests/test_webui.py'); t = p.read_text(encoding='utf-8'); t = t.replace('channels/do/2/', 'channels/do/0/').replace('channels/ai/4/', 'channels/ai/0/').replace('channels/ao/5/', 'channels/ao/0/'); t = t.replace('(\"ai\", 4, 0)', '(\"ai\", 0, 0)').replace('(\"ao\", 5, 0)', '(\"ao\", 0, 0)').replace('channel_range(\"ao\", 5, 0)', 'channel_range(\"ao\", 0, 0)').replace('channel_range(\"ai\", 4, 0)', 'channel_range(\"ai\", 0, 0)'); p.write_text(t, encoding='utf-8'); print('done')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "run fix codes",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"scripts\\fix_codes.py"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest per-type3",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "run fix codes 2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"scripts\\fix_codes.py"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest per-type4",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest per-type5",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "remove fix script",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; p = pathlib.Path('scripts/fix_codes.py'); p.unlink(); print('removed')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: serve pertype",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8088"
],
"isBackground": true,
"problemMatcher": []
},
{
"label": "kill ports 5",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('killed')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: serve pertype2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8088"
],
"isBackground": true,
"problemMatcher": []
},
{
"label": "wago-sim: pytest pertype-final",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "stop pertype serve",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('stopped')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest dnd",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: serve dnd",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8088"
],
"isBackground": true,
"problemMatcher": []
},
{
"label": "kill ports dnd",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('killed')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: serve dnd2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8088"
],
"isBackground": true,
"problemMatcher": []
},
{
"label": "wago-sim: pytest dnd final",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "stop dnd serve",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('stopped')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest spec-only",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest continuous",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest continuous2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest all continuous",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: validate soak",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"validate",
"--scenario",
"scenarios/pump_soak.example.yaml"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "debug validate",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import yaml; from wago_sim.scenario.dsl import validate_scenario; s = yaml.safe_load(open('scenarios/pump_soak.example.yaml', encoding='utf-8')); print('on value:', s['degradation']['pump_capacity'].get('on')); print('outputs:', list(s.get('outputs', {}))); print(validate_scenario(s))"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: validate soak2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"validate",
"--scenario",
"scenarios/pump_soak.example.yaml"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "gen inflow csv",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; d = pathlib.Path('data'); d.mkdir(exist_ok=True); (d / 'inflow_day.csv').write_text('time,tank_level\\n0.0,1000\\n' + '\\n'.join(f'{t},{1000 + t * 50}' for t in range(1, 65)) + '\\n', encoding='utf-8'); print('csv written:', len((d / 'inflow_day.csv').read_text()), 'bytes')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: run soak smoke",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"run",
"--node",
"config/pump_node.yaml",
"--scenario",
"scenarios/soak_smoke.yaml",
"--report-jsonl",
"out/soak_smoke.jsonl"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "clean soak artifacts",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; pathlib.Path('scenarios/soak_smoke.yaml').unlink(); pathlib.Path('data/inflow_day.csv').unlink(); pathlib.Path('data').rmdir(); pathlib.Path('out/soak_smoke.jsonl').unlink(); print('cleaned')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest final continuous",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest final2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "mark phase3 done",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"scripts\\mark_phase3.py"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "remove mark script",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; pathlib.Path('scripts/mark_phase3.py').unlink(); print('ok')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "delete old plantenv",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; p = pathlib.Path('src/wago_sim/scenario/plantenv.py'); p.unlink(); print('deleted:', not p.exists())"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest env2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "debug load_env",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"from wago_sim.scenario.plantenv import load_env, PlantEnv; import envs.pump_station_env as m; print('class:', m.PumpStationEnv); print('is subclass:', issubclass(m.PumpStationEnv, PlantEnv)); print('module plantenv file:', __import__('wago_sim.scenario.plantenv', fromlist=['x']).__file__)"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "pytest load env detail",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py::TestPlantEnv::test_load_env_class_and_factory",
"-q",
"--tb=long",
"-s"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest env3",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest env4",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "debug missing env",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"from wago_sim.scenario.plantenv import load_env; from wago_sim.modules import DI_8; from wago_sim.app import build_app; app = build_app(modules=[DI_8]);\ntry:\n e = load_env('envs.missing_module', node=app.node)\n print('NO RAISE; got:', type(e))\nexcept Exception as exc:\n print('raised:', exc)"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "run debug env",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"scripts\\debug_env.py"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "debug in pytest",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py::TestPlantEnv::test_load_env_class_and_factory",
"-q",
"--tb=long"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "rm debug script",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; pathlib.Path('scripts/debug_env.py').unlink(); print('ok')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest all env",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: serve env",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"wago_sim.cli",
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8088",
"--env",
"envs.pump_station_env:PumpStationEnv"
],
"isBackground": true,
"problemMatcher": []
},
{
"label": "env closed-loop probe",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"scripts\\env_probe.py"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "headless sim probe",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"scripts\\sim_probe.py"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "headless sim probe2",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"scripts\\sim_probe.py"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "rm probes",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import pathlib; [pathlib.Path(p).unlink() for p in ('scripts/env_probe.py', 'scripts/sim_probe.py')]; print('ok')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "stop env serve",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-c",
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('stopped')"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest final env",
"type": "shell",
"command": ".venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests",
"-q",
"--tb=short"
],
"isBackground": false,
"problemMatcher": []
},
{
"label": "wago-sim: pytest dense ranges",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_webui.py",
"tests/test_continuous.py",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest full dense",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest channel-by-name",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest example env isolated",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py::TestPlantEnv::test_pump_station_example_env",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest env fix",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_continuous.py",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest full by-name",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest full final",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest envctl",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_webui.py",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest envctl2",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_webui.py",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest envctl3",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"tests/test_webui.py",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: pytest full m7",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"-q",
"--no-header"
],
"isBackground": false
},
{
"label": "wago-sim: serve m7 live",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\wago-sim.exe",
"args": [
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8080",
"--env",
"envs.pump_station_env:PumpStationEnv"
],
"isBackground": true
},
{
"label": "wago-sim: serve m7 live2",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\wago-sim.exe",
"args": [
"serve",
"--node",
"config/pump_node.yaml",
"--host",
"127.0.0.1",
"--port",
"5020",
"--web-port",
"8080",
"--env",
"envs.pump_station_env:PumpStationEnv"
],
"isBackground": true
},
{
"label": "wago-sim: pytest full m7 final",
"type": "shell",
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
"args": [
"-m",
"pytest",
"-q",
"--no-header"
],
"isBackground": false
} }
] ]
} }
+117 -21
View File
@@ -22,9 +22,11 @@ a virtual node whose I/O behavior is driven by scripted scenarios.
pulses, waits, and PASS/FAIL assertions with timeouts; state machines pulses, waits, and PASS/FAIL assertions with timeouts; state machines
and Python action plugins for complex plant models. and Python action plugins for complex plant models.
- **Two I/O addressing schemes** for aliases: *module-based* — - **Two I/O addressing schemes** for aliases: *module-based* —
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module slot and `{ type: di, module: 2, channel: 1 }` = `DI2.1` (module **numbered
channel both 0-based) — and *pool-based* — `{ type: di, channel: 3 }` 0-based within its type** — `DI0`, `DO0`, `AI0`, `AO0` start each
(1-based index across the node's DI pool). Both may be mixed. kind — and channel 0-based; v1.5) — and *pool-based* —
`{ type: di, channel: 3 }` (1-based index across the node's DI
pool). Both may be mixed.
- **Bench mode** *(requested, planned — spec §9)*: run the simulator - **Bench mode** *(requested, planned — spec §9)*: run the simulator
without a scenario (`wago-sim serve --node ...` already works); without a scenario (`wago-sim serve --node ...` already works);
planned additions: `--set DI0.1=ON` startup seeding, an interactive planned additions: `--set DI0.1=ON` startup seeding, an interactive
@@ -183,6 +185,59 @@ Configuration options: `--no-web`, `--web-port <n>` or env
benches (all mutating endpoints answer 403). Plain HTTP — keep the benches (all mutating endpoints answer 403). Plain HTTP — keep the
port inside the bench network. port inside the bench network.
## Plant environments (continuous simulation)
A **plant environment** simulates the physical world around the
distributed I/O: per deterministic tick it reads what the PLC
commanded (DO/AO), runs your plant physics, and writes what the field
now shows (DI/AI) — the sensors the PLC reads back. Environments run
**until manually stopped** (Ctrl+C / `docker stop`), optionally ending
sooner via an `is_done()` criterion.
Attach one to a live Modbus server (wall-clock-paced 10 ms ticks):
```bash
wago-sim serve --node config/pump_node.yaml --port 5020 \
--env envs.pump_station_env:PumpStationEnv
```
or run headless (unpaced, for plant-model development):
```bash
wago-sim sim --node config/pump_node.yaml \
--env envs.pump_station_env:PumpStationEnv
```
**Authoring one** is a small subclass — override `model()`:
```python
# myenvs/tank.py
from wago_sim.scenario.plantenv import PlantEnv
class TankEnv(PlantEnv):
def model(self, state, dt_s):
pump_on = bool(state.by_code("DO0.0")) # PLC command
level = float(state.by_code("AI0.0")) # current field
return {"AI0.0": level + (-5.0 if pump_on else 1.0) * dt_s}
Env = TankEnv # `--env myenvs.tank` picks this up
```
API surface (spec FR-8.2.14–17): `observe()` returns every
DI/DO/AI/AO channel (I/O codes, names, values, analog display
ranges) plus aliases and degradation parameters; `apply()` writes DI/AI (the field);
`force()` writes DO/AO master-side (headless runs, fault setup);
`is_done()` optional end; `reset()` deterministic replay.
`state.channel(key)` returns the full channel record by I/O code
or configured name; `state.channel_range(key)` gives an analog
channel's (min, max) directly. Reference
forms: `pkg.mod:ClassName`, `pkg.mod:factory`, or `pkg.mod` with
`Env`/`make_env`. The shipped example is
`envs/pump_station_env.py` (tank + pump physics with a low-level
switch). Scenario-continuous mode (§8 YAML: datasets, degradation,
invariants) remains available for scripted soak runs on the same
engine ticks.
## Documentation ## Documentation
- `docs/spec/software-spec.md` — the software specification - `docs/spec/software-spec.md` — the software specification
@@ -323,7 +378,7 @@ requirement ID for each item in parentheses.
statistics table (0x1029 words) with reset, LED-error statistics table (0x1029 words) with reset, LED-error
(0x1020/0x1021) and module-diagnosis (0x1050) fault injection (0x1020/0x1021) and module-diagnosis (0x1050) fault injection
and clear; SF LED on the coupler reflects injected LED errors. and clear; SF LED on the coupler reflects injected LED errors.
- [x] M5: configuration — coupler settings forms (watchdog time/masks/type/close-on-timeout via 0x1000–0x100B, response delay 0x1037, idle timeout 0x1030) with the device's own validation surfaced (writes refused while the watchdog runs → field error); module list editor over the 13-article catalog with SAVE CONFIG → live process-image rebuild (`rebuild_node`: datastore survives, values clear like a KBUS reset, 0x1022–0x1025 and 0x2030+ updated) + persistence to the node-config YAML via a comment-preserving custom emitter. - [x] M5: configuration — coupler settings forms (watchdog time/masks/type/close-on-timeout via 0x1000–0x100B, response delay 0x1037, idle timeout 0x1030) with the device's own validation surfaced (writes refused while the watchdog runs → field error); module list editor over the 13-article catalog with SAVE CONFIG → live process-image rebuild (`rebuild_node`: datastore survives, values clear like a KBUS reset, 0x1022–0x1025 and 0x2030+ updated) + persistence to the node-config YAML via a comment-preserving custom emitter. Modules re-orderable by drag-and-drop or ▲/▼ arrows (FR-11.2.6); with per-type I/O codes (FR-4.3.11 v1.5) cross-kind moves don't renumber other kinds' channels.
- [x] M5a: channel names for every module channel (FR-10.2.11) — - [x] M5a: channel names for every module channel (FR-10.2.11) —
node-config persisted (`channel_names:` mapping, I/O code → node-config persisted (`channel_names:` mapping, I/O code →
name), survive module saves and rebuilds, shown in the overview name), survive module saves and rebuilds, shown in the overview
@@ -348,9 +403,34 @@ requirement ID for each item in parentheses.
may be negative (-65534–65535; stored as 16-bit two's may be negative (-65534–65535; stored as 16-bit two's
complement), for signed plant quantities. DI/DO channels render complement), for signed plant quantities. DI/DO channels render
in vertical columns of eight (0–7 left, 8–15 next) on thin, in vertical columns of eight (0–7 left, 8–15 next) on thin,
tall module bezels mirroring real DIN-rail housings. tall module bezels mirroring real DIN-rail housings. Dense
store (2026-10-04): `node.channel_ranges` always carries every
analog channel of the current layout (unconfigured = the
materialized default, stale codes pruned on rebuild), so
environments read min/max without fallbacks; the YAML and the
config export stay lean (non-default entries only).
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6, - [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
FR-10.2.9) — deferred. FR-10.2.9) — deferred.
- [x] M7: plant environment control (FR-10.2.15, FR-10.2.17) — the
web UI loads/unloads environments at runtime (`--env` becomes the
startup default, not the only option): an environment card shows
ref, state (running/paused/done/none), sim-time and tick ms with
start (with hot reload of edited source, `sys.modules`
invalidation), stop (ticker cancelled, field freezes),
pause/resume (instance kept — pause is a deliberate FAT fault:
"plant stuck"), reset (deterministic fresh episode) and restart.
Exactly one env at a time; `is_done()` ends the ticker (state
`done`); SAVE CONFIG (module-list rebuild) discards the env like
a KBUS reset discards values; starting while loaded is refused;
readonly mode blocks every env mutation. API: `GET /api/env` +
`POST /api/env/{start,stop,pause,resume,reset,restart}`.
- [ ] M8: environment file authoring (FR-10.2.16, planned) —
create/modify/delete environment source files (e.g.
`envs/*.py`) through the web UI (editor pane + upload/download).
Security note: UI-written Python is RCE by design — same trust
boundary as the bench network; disabled in readonly mode; delete
refused while the env is loaded; the on-disk directory must be
explicit in configuration.
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx - [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx
`ASGITransport` (in-process, no socket) + live verification: `ASGITransport` (in-process, no socket) + live verification:
browser SAVE CONFIG rebuilds the node and persists YAML; Modbus browser SAVE CONFIG rebuilds the node and persists YAML; Modbus
@@ -392,50 +472,66 @@ requirement ID for each item in parentheses.
(FR-9.2.5). *(Plain `serve` without a scenario already works — (FR-9.2.5). *(Plain `serve` without a scenario already works —
see Completed above.)* see Completed above.)*
### Phase 3 — Long-duration (continuous) simulation (spec §8) ### Phase 3 — Long-duration (continuous) simulation (spec §8) — IMPLEMENTED 2026-10-04
The soak-testing feature requested 2026-10-03 — specified in §8, The soak-testing feature specified in §8 is implemented: continuous
not yet implemented. Datasets are **optional**: signal logic mode with the fixed tick pipeline, optional streaming CSV datasets,
(plugins, scheduled steps, degradation) runs in parallel with CSV degradation models, invariants/rate limits with bounded episode
reporting, snapshots, and the gym-style `PlantEnv`. 30 new tests
(`tests/test_continuous.py`); the shipped example
`scenarios/pump_soak.example.yaml` demonstrates the full DSL.
Datasets are **optional**: signal logic runs in parallel with CSV
streaming, or entirely without it. streaming, or entirely without it.
- [ ] `scenario/datasets.py` — streaming CSV playback for designated - [x] Plant environment `scenario/plantenv.py` (FR-8.2.14–17, reworked
2026-10-04 to environment-first): standalone `PlantEnv` —
`observe()` returns ALL modules (DI/DO/AI/AO channels, per-type
codes, names, parameters); `apply()` writes the FIELD side
(DI/AI) by default; `force()` writes DO/AO master-side (mirrors/
statistics/watchdog stay consistent); `model()`/`is_done()`
override hooks (subclass authoring, FR-8.2.17); `reset()`
deterministic. Runners: `serve --env ...` wall-clock-paced
alongside Modbus, runs until stopped; `sim --env ...` headless
unpaced until is_done()/Ctrl+C. Example: `envs/pump_station_env.py`.
Verified live: PLC coil write → env physics → level registers
drain/refill over Modbus.
- [x] `scenario/datasets.py` — streaming CSV playback for designated
inputs (OPTIONAL driver): header-driven time column, inputs (OPTIONAL driver): header-driven time column,
`interpolation: linear|hold`, `repeat` with seamless seams, `interpolation: linear|hold`, `repeat` with seamless seams,
`time_factor` acceleration, `until` limits, bounded memory, ERROR `time_factor` acceleration, `until` limits, bounded memory, ERROR
verdict with file/line on malformed data (FR-8.2.1–8.2.11). verdict with file/line on malformed data (FR-8.2.1–8.2.11).
- [ ] Parallel logic pipeline — fixed per-tick order: datasets → edge - [x] Parallel logic pipeline — fixed per-tick order: datasets → edge
detection → plant plugins → degradation → invariants/rate detection → plant plugins → degradation → invariants/rate
limits → scheduled steps → snapshots; plugins may read limits → scheduled steps → snapshots; plugins may read
dataset-driven and logic-driven aliases in the same tick dataset-driven and logic-driven aliases in the same tick
(FR-8.2.12). (FR-8.2.12).
- [ ] Logic-only continuous runs — no `datasets:` needed: demand - [x] Logic-only continuous runs — no `datasets:` needed: demand
profiles generated inside plugins, top-level `until:` bounds the profiles generated inside plugins, top-level `until:` bounds the
run; degradation/invariants/rate limits/snapshots apply run; degradation/invariants/rate limits/snapshots apply
identically (FR-8.2.13). identically (FR-8.2.13).
- [ ] Continuous-mode run-end + validation rules: run ends when all - [x] Continuous-mode run-end + validation rules: run ends when all
drivers exhausted; scenario with no datasets, no scheduled steps drivers exhausted; scenario with no datasets, no scheduled steps
and no `until:` rejected (FR-8.2.1). and no `until:` rejected (FR-8.2.1).
- [ ] `scenario/degradation.py` — wear counters (runtime hours, start - [x] `scenario/degradation.py` — wear counters (runtime hours, start
cycles, load-hours) and degradation models (`linear`, cycles, load-hours) and degradation models (`linear`,
`exponential`, `staircase`, plugin shapes) computing plant `exponential`, `staircase`, plugin shapes) computing plant
parameters such as `pump_capacity` (FR-8.3.1–8.3.2). parameters such as `pump_capacity` (FR-8.3.1–8.3.2).
- [ ] Degradation hooks in plant plugins: `engine.parameter()` accessor - [x] Degradation hooks in plant plugins: `engine.parameter()` accessor
used in loop physics, e.g. actual flow = nominal × capacity used in loop physics, e.g. actual flow = nominal × capacity
(FR-8.3.3). (FR-8.3.3).
- [ ] `maintenance_at` thresholds: diagnosis injection via register - [x] `maintenance_at` thresholds: diagnosis injection via register
`0x1050`, maintenance-alarm assertions, `maintenance_reset` `0x1050`, maintenance-alarm assertions, `maintenance_reset`
action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5, action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5,
FR-8.3.8). FR-8.3.8).
- [ ] `scenario/invariants.py` — per-tick `invariants` with bounded - [x] `scenario/invariants.py` — per-tick `invariants` with bounded
episode aggregation and `rate_limits` over sliding one-hour episode aggregation and `rate_limits` over sliding one-hour
windows (FR-8.4.1–8.4.2). windows (FR-8.4.1–8.4.2).
- [ ] `snapshot_every` periodic dumps of aliases, wear counters, and - [x] `snapshot_every` periodic dumps of aliases, wear counters, and
degradation parameters (FR-8.4.3). degradation parameters (FR-8.4.3).
- [ ] Continuous-mode verdict rules and bounded reporting for unattended - [x] Continuous-mode verdict rules and bounded reporting for unattended
multi-day runs (FR-8.4.4–8.4.5). multi-day runs (FR-8.4.4–8.4.5).
- [ ] Scheduled sequence steps via `at: <duration>` interleaved with - [x] Scheduled sequence steps via `at: <duration>` interleaved with
datasets on the sim-time axis (FR-8.4.6). datasets on the sim-time axis (FR-8.4.6).
- [ ] Acceptance tests per spec §8.8 (streaming 100k-row CSV, - [x] Acceptance tests per spec §8.8 (streaming 100k-row CSV,
interpolation checks, repeat determinism, time_factor math, interpolation checks, repeat determinism, time_factor math,
alias-ownership validation, degradation/recovery round-trip, alias-ownership validation, degradation/recovery round-trip,
episode recording, full 28-day soak, logic-only run, mixed episode recording, full 28-day soak, logic-only run, mixed
+22
View File
@@ -0,0 +1,22 @@
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
# Simulated device: WAGO 750-362 Modbus TCP coupler
# Edit by hand or via the UI; comments are not preserved on UI saves.
name: pump_node
coupler: 750-362
modules:
- "750-430" # 8-channel DI 24VDC
- "750-1405" # 16-channel DI 24VDC
- "750-530" # 8-channel DO 24VDC
- "750-1516" # 16-channel DO 24VDC
- "750-467" # 2-channel AI 4-20mA
- "750-554" # 2-channel AO 4-20mA
# Application names for module channels (I/O code -> name)
channel_names:
DI0.0: P1_DI
DI0.1: P1_AUTO
DI0.2: P1_FEL
DI0.3: rsv1
DO0.1: P1_START
AO0.0: P1_BORV
+18
View File
@@ -0,0 +1,18 @@
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
# Simulated device: WAGO 750-362 Modbus TCP coupler
# Edit by hand or via the UI; comments are not preserved on UI saves.
name: pump_node
coupler: 750-362
modules:
- "750-1405" # 16-channel DI 24VDC
- "750-430" # 8-channel DI 24VDC
- "750-530" # 8-channel DO 24VDC
- "750-1516" # 16-channel DO 24VDC
- "750-467" # 2-channel AI 4-20mA
- "750-554" # 2-channel AO 4-20mA
# Application names for module channels (I/O code -> name)
channel_names:
AO0.0: speed_setpoint
DI0.0: start_button
+6 -2
View File
@@ -14,5 +14,9 @@ modules:
# Application names for module channels (I/O code -> name) # Application names for module channels (I/O code -> name)
channel_names: channel_names:
AO5.0: speed_setpoint AI0.0: NG1
DI0.0: start_button AO0.0: P!_BORV
DI0.0: P1_DI
DI0.1: P1_AUTO
DI0.2: P1_FEL
DO0.0: P!_START
+188 -29
View File
@@ -7,7 +7,7 @@
| Project | `wago-modbustcp-simulator-py` | | Project | `wago-modbustcp-simulator-py` |
| Version | 1.4 (Draft for review) | | Version | 1.4 (Draft for review) |
| Date | 2026-10-03 | | Date | 2026-10-03 |
| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8), bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset now covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested | | Status | Framework phase; module addressing implemented (FR-4.3.11 v1.5 per-type numbering); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested |
| Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler | | Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler |
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) | | Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
@@ -303,14 +303,23 @@ sequence: # time-ordered script (or fully state-driven)
- FR-4.3.11: **Module-based channel addressing** — aliases may be addressed - FR-4.3.11: **Module-based channel addressing** — aliases may be addressed
as *module type + module number + channel number*, all components as *module type + module number + channel number*, all components
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
digital input channel 1 (0-based) on module slot 2 (0-based position in digital input channel 1 (0-based) on DI-module 2 (0-based **number
the node's module list), i.e. `DI2.1`. Both numbers are 0-based to match among the modules of the same type**, i.e. the third DI module in the
physical slot/channel labeling. The channel kind must match the module node), i.e. `DI2.1`. Channel numbers are 0-based. The channel kind must
(`DO` on a DI-only module is a validation error). This is an alternative match the module (`DO` on a DI-only module is a validation error).
to pool addressing (`{ type: di, channel: 3 }` = 3rd DI channel across This is an alternative to addressing by physical slot; both pool
the whole node); both schemes are supported and may be mixed, but an addressing (`{ type: di, channel: 3 }` = 3rd DI channel across the
alias uses exactly one. **Implemented** in v1.1 (`channel_by_module`, whole node) and module addressing are supported and may be mixed,
`Alias.module`, `alias_location`). but an alias uses exactly one. **Implemented** in v1.1
(`channel_by_module`, `Alias.module`, `alias_location`).
**Changed in v1.5 (2026-10-04, breaking)**: module numbers previously
counted physical slots (all modules in one sequence); they now number
**within the module's type** — `DI0`/`DO0`/`AI0`/`AO0` start each kind's
sequence (e.g. a node `[DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]` numbers
its DI modules `DI0`, `DI1` regardless of the DI/DO interleaving).
This matches how channels are named on real WAGO benches and makes
rearranging modules less disruptive to existing configs. Scenarios,
channel names, and analog ranges use the per-type codes.
### 4.4 Example FAT flow ### 4.4 Example FAT flow
@@ -469,8 +478,8 @@ stateDiagram-v2
## 8. Feature Request — Long-Duration (Continuous) Simulation ## 8. Feature Request — Long-Duration (Continuous) Simulation
> **Status: requested 2026-10-03, planned (roadmap phase 3), not yet implemented.** > **Status: implemented 2026-10-04** (roadmap phase 3 complete). Extends
> Extends the scenario engine (§4) with a `continuous` mode for endurance > the scenario engine (§4) with a `continuous` mode for endurance
> (“soak”) testing: the facility runs for hours, days or weeks of simulated > (“soak”) testing: the facility runs for hours, days or weeks of simulated
> time against evolving inputs and an aging plant. > time against evolving inputs and an aging plant.
@@ -558,6 +567,12 @@ unchanged whether or not CSV streaming is used.
(6) scheduled steps due at this tick execute, (6) scheduled steps due at this tick execute,
(7) snapshots are taken if due. (7) snapshots are taken if due.
This order is part of the specification and shall be covered by tests. This order is part of the specification and shall be covered by tests.
**With the plant environment (FR-8.2.14)**, the decision source sees this
pipeline as one atomic `env` tick: `observe()` reads the state as of the
start of the tick, `apply()` queues the decided outputs, and the engine
then advances the pipeline once — outputs written by `apply()` land in
the output image before the next tick's `observe()`, so every state the
decision source sees is pipeline-consistent.
- FR-8.2.13: **Logic-only continuous runs**: with no `datasets:` block at - FR-8.2.13: **Logic-only continuous runs**: with no `datasets:` block at
all, continuous mode shall work unchanged — demand profiles and other all, continuous mode shall work unchanged — demand profiles and other
exogenous signals may be generated *inside* plant-model plugins (e.g., a exogenous signals may be generated *inside* plant-model plugins (e.g., a
@@ -565,6 +580,57 @@ unchanged whether or not CSV streaming is used.
top-level `until:` bounding the run. All §8.3–8.4 machinery (degradation, top-level `until:` bounding the run. All §8.3–8.4 machinery (degradation,
invariants, rate limits, snapshots) applies identically, whether or not invariants, rate limits, snapshots) applies identically, whether or not
any CSV streaming is configured. any CSV streaming is configured.
- FR-8.2.14: **Plant environment abstraction** (requested 2026-10-04,
reworked same day to environment-first): `PlantEnv` is the **primary
interface** for continuous plant simulation, standalone-first — it
requires only a virtual node + datastore (no scenario), with the §8.3/8.4
machinery (degradation, invariants) as optional attachments. Per
deterministic tick (the engine or the runner advances the tick clock,
never wall-clock arithmetic): **`state = env.observe()`** returns the
state of ALL modules — DI/DO/AI/AO channels, engineering units, per-type
I/O codes, configured names, analog display ranges (FR-10.2.14),
plus active aliases and parameters — →
`env.model(state, dt)` (the caller-supplied plant physics) → **`apply()`
writes the new field state**. The environment owns no policy; plant
models are supplied by subclassing (FR-8.2.17).
- FR-8.2.15: **Action semantics**: `apply()` primarily writes the **input
side (DI/AI)** — the simulated field: what the sensors show, driven by
plant physics reacting to the PLC's output commands. Writing outputs
(DO/AO) is allowed as explicit **forcing** via a `force=True` keyword or
`env.force(...)` — for headless runs without a PLC and for bench
fault setup; forced outputs go through the datastore's internal write
paths so mirrors/statistics/watchdog stay consistent (FR-9.2.6).
Exactly one decision source may own an alias per tick (ownership
conflicts are validation errors, extending FR-8.2.2).
- FR-8.2.16: **Environment API surface** (concrete):
`env.observe() -> PlantState` (all channels with names and analog
display ranges, aliases, parameters, sim-time); `env.apply(values, force=False)` (keys: I/O code or alias
name; DI/AI default, DO/AO require force; unknown keys are errors);
`env.model(state, dt_s)` — override point for plant physics, returning
new field values; `env.is_done(state) -> bool` — optional end
criterion, default False (runs until manually stopped);
`env.reset()` — deterministic fresh episode; `env.tick_ms` /
`env.sim_time_ms` (read-only clock); `env.parameters` /
`env.invariants` attachments. `PlantState.channel(key)` returns
the full channel record by I/O code or by configured application
name (FR-10.2.11); `by_code()` returns values by I/O code;
`channel_range(key)` returns an analog channel's display range
(min, max) — always defined for AI/AO (dense store, FR-10.2.14).
- FR-8.2.17: **Environment authoring & runners**: environments are easy
to create and call — a subclass of `PlantEnv` overriding `model()`
(and optionally `is_done()`) in any importable module
(`--env myenvs.pump_station.PumpStationEnv`), or a module-level
factory function. Three runners: (a) `wago-sim serve --env ...` ticks
the environment on a **wall-clock-paced** periodic task (10 ms tick
alongside the Modbus server, real-time behavior for a live PLC) and
runs **until manually stopped** (SIGINT/SIGTERM — the existing
graceful-shutdown path); the ticker itself is owned by an
`EnvRunner` on the app so the web UI can stop/pause/resume/reset/
restart it at runtime (FR-10.2.15); (b) `wago-sim sim --node ...
--env ...` runs headless unpaced (as fast as determinism allows)
until `is_done()` or SIGINT. The scenario-continuous mode
(FR-8.2.1-13) remains a scripted layer on top of the same engine
ticks.
### 8.3 Requirements — degradation models (plant logic over time) ### 8.3 Requirements — degradation models (plant logic over time)
@@ -573,8 +639,11 @@ unchanged whether or not CSV streaming is used.
load-hours (integral of the analog value). Counters are deterministic state. load-hours (integral of the analog value). Counters are deterministic state.
- FR-8.3.2: A **degradation model** computes a named plant parameter (e.g. - FR-8.3.2: A **degradation model** computes a named plant parameter (e.g.
`pump_capacity`, range 0…1) from wear counters and sim-time. Built-in `pump_capacity`, range 0…1) from wear counters and sim-time. Built-in
shapes: `linear` (per runtime hour, per start cycle), `exponential`, shapes: `linear` (per runtime hour via `rate:`, or per start cycle with
`staircase` (threshold table); custom shapes via Python plugin. `per: cycles`), `exponential`, `staircase` (`staircase:` level table).
A model is configured with `observes:` naming the alias whose wear
counts feed it (NB: the key is `observes` — a YAML `on` key would be
parsed as boolean True by YAML 1.1).
- FR-8.3.3: Plant-model plugins read degraded parameters via - FR-8.3.3: Plant-model plugins read degraded parameters via
`engine.parameter("pump_capacity")` and shall use them in the loop physics `engine.parameter("pump_capacity")` and shall use them in the loop physics
(e.g., actual pump flow = nominal flow × capacity), so the PLC must (e.g., actual pump flow = nominal flow × capacity), so the PLC must
@@ -649,31 +718,30 @@ datasets:
repeat: 4 repeat: 4
degradation: degradation:
pump_wear: pump_capacity:
observes: pump_relay # wear counts from this alias
model: linear model: linear
parameter: pump_capacity # consumed by the plant model rate: 0.0004 # -0.04 %/h runtime → ~10 % after ~10.4 sim-days
per_runtime_hour: 0.0004 # 0.04 %/h → ~10 % after ~10.4 sim-days from: 1.0
per_start_cycle: 0.0002 # 0.02 % per start maintenance_at: 0.90 # threshold crossing injects diagnosis
floor: 0.80 diagnosis: { module: 4, channel: 1, value: 0x0001 }
maintenance_at: 0.90 # the PLC raises maintenance_alarm; the scenario asserts it:
on_threshold: # fires when capacity crosses 90 %
- inject_diagnosis: { module: 4, channel: 1, bits: [user_overrange] }
- assert: { label: "maintenance alarm raised",
conditions: { maintenance_alarm: ON }, within: 60s }
on_reset: # after maintenance_reset
- clear_diagnosis: { module: 4 }
sequence: # scheduled events (sim-time) sequence: # scheduled events (sim-time)
- at: 12d - at: 12d
maintenance_reset: pump_wear # service visit: capacity restored maintenance_reset: true # service visit: capacity restored
- at: 12d
assert: { label: "maintenance alarm raised",
conditions: { maintenance_alarm: ON }, within: 60s }
plugins: [plugins.pump_plant.pump_plant] # loop: d(level)/dt = inflow − pump_flow(speed, capacity) plugins: [plugins.pump_plant.pump_plant] # loop: d(level)/dt = inflow − pump_flow(speed, capacity)
invariants: invariants:
- { alias: level_sensor, min: 200, max: 9000, label: "tank level bounds" } - { alias: level_sensor, condition: '>= 200', label: "tank level floor" }
- { alias: level_sensor, condition: '<= 9000', label: "tank level brim" }
rate_limits: rate_limits:
- { alias: pump_relay, edge: rising, max_per_hour: 6, label: "pump start cycles" } - { alias: speed_setpoint, max_per_hour: 3000 } # ramp-rate bound
report: report:
snapshot_every: 1h snapshot_every: 1h
@@ -735,15 +803,20 @@ src/wago_sim/scenario/
│ # time_factor — OPTIONAL driver in continuous mode │ # time_factor — OPTIONAL driver in continuous mode
├── degradation.py # WearCounters, DegradationModel registry, maintenance actions ├── degradation.py # WearCounters, DegradationModel registry, maintenance actions
├── invariants.py # invariant & rate-limit evaluation, episode aggregation ├── invariants.py # invariant & rate-limit evaluation, episode aggregation
├── plantenv.py # PlantEnv: gym-style observe/apply/reset cycle over the
│ # process images — the decision-source interface
│ # (FR-8.2.14–16); plugins become thin adapters
└── engine.py # mode: continuous, parameter() accessor, snapshot └── engine.py # mode: continuous, parameter() accessor, snapshot
# scheduler; fixed tick pipeline per FR-8.2.12: # scheduler; fixed tick pipeline per FR-8.2.12:
# datasets -> edges -> plugins -> degradation -> # datasets -> edges -> plugins/env -> degradation ->
# invariants -> scheduled steps -> snapshots # invariants -> scheduled steps -> snapshots
``` ```
```mermaid ```mermaid
erDiagram erDiagram
ContinuousScenario ||--o{ Dataset : "datasets (optional)" ContinuousScenario ||--o{ Dataset : "datasets (optional)"
ContinuousScenario ||--|| PlantEnv : "environment (FR-8.2.14)"
PlantEnv ||--o{ DecisionSource : "plugins / external agents"
Dataset ||--|{ ColumnMapping : "alias columns" Dataset ||--|{ ColumnMapping : "alias columns"
ContinuousScenario ||--|{ PlantPlugin : "signal logic (always runs)" ContinuousScenario ||--|{ PlantPlugin : "signal logic (always runs)"
ContinuousScenario ||--|{ DegradationModel : degradation ContinuousScenario ||--|{ DegradationModel : degradation
@@ -755,6 +828,7 @@ erDiagram
ContinuousScenario ||--o{ Snapshot : "periodic dumps" ContinuousScenario ||--o{ Snapshot : "periodic dumps"
PlantPlugin }o--|| DegradationModel : "reads parameters" PlantPlugin }o--|| DegradationModel : "reads parameters"
PlantPlugin }o--o{ Alias : "reads dataset-driven + computes logic-driven" PlantPlugin }o--o{ Alias : "reads dataset-driven + computes logic-driven"
PlantEnv }o--|| ProcessImage : "observe/apply via datastore"
``` ```
### 8.7 Non-functional requirements (long-duration) ### 8.7 Non-functional requirements (long-duration)
@@ -800,6 +874,18 @@ erDiagram
tick-order test. tick-order test.
11. A continuous scenario with no datasets, no scheduled steps, and no 11. A continuous scenario with no datasets, no scheduled steps, and no
`until:` is rejected at validation time (FR-8.2.1). `until:` is rejected at validation time (FR-8.2.1).
12. **PlantEnv cycle** (FR-8.2.14): `observe()` returns every input channel
(values, names, codes, ranges, sim-time); `apply()` lands the decided
outputs in the output image before the next `observe()` — verified by a
deterministic two-tick test where the decision source copies an input
to an output and the second observation contains it.
13. **Plugin parity** (FR-8.2.15): the same plant logic runs unchanged as
a classic `plugins:` entry and as an `env.observe/apply` decision
source — identical outputs over a 1000-tick headless run (regression
gate for the adapter).
14. **External agent**: a decision source stepping the env over a
socket (non-Python agent) produces identical results to the in-process
variant on the same scenario (FR-8.2.15).
--- ---
@@ -910,6 +996,10 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
defined); `pulse` (with duration) and `ramp` (target + duration) actions defined); `pulse` (with duration) and `ramp` (target + duration) actions
on DI/DO/AI. All writes go through the datastore's Modbus-equivalent on DI/DO/AI. All writes go through the datastore's Modbus-equivalent
paths (FR-9.2.6) so mirrors, watchdog, and statistics stay consistent. paths (FR-9.2.6) so mirrors, watchdog, and statistics stay consistent.
Clicking an analog **bar indicator** (requested 2026-10-04) sets the
value proportionally to the click position within the channel's
display range (FR-10.2.14) — AI = field side, AO = master-side force;
exact values remain enterable via the channel list.
- FR-10.2.5: **Diagnostics view**: watchdog status (0x1006), statistics - FR-10.2.5: **Diagnostics view**: watchdog status (0x1006), statistics
block (0x1029, with reset button), module diagnosis (0x1050), LED error block (0x1029, with reset button), module diagnosis (0x1050), LED error
registers (0x1020/0x1021) — mirroring WBM pages of the real device — registers (0x1020/0x1021) — mirroring WBM pages of the real device —
@@ -974,6 +1064,52 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
onto the bar). Simulated AI values and AO forces accept negative onto the bar). Simulated AI values and AO forces accept negative
numbers within the same window; values are stored in the image numbers within the same window; values are stored in the image
as their 16-bit two's-complement representation. as their 16-bit two's-complement representation.
**Dense runtime store** (2026-10-04): `node.channel_ranges` is
always complete — every analog channel of the current layout has
an entry (unconfigured channels carry the materialized default),
codes that no longer match an analog channel are pruned on
rebuild, so envs and API consumers can read ranges without
default-fallback guesswork. Persistence (node YAML, config
export) stays **lean**: only explicit non-default entries are
written, so defaults survive a future DEFAULT_RANGE change.
- FR-10.2.15: **Plant environment control** (requested 2026-10-04):
the web UI can load/unload environments at runtime — the CLI's
`--env` becomes the startup default, not the only option. An
**environment card** shows the loaded ref, its state (running /
paused / done / none), sim-time, and tick ms; controls: **start**
(load + tick), **stop** (cancel the ticker, field freezes),
**pause/resume** (ticker cancelled or rescheduled, instance
kept — pause is a deliberate FAT fault: "plant stuck"),
**reset** (`env.reset()`, deterministic fresh episode,
NFR-8.7.3), **restart** (stop + start of the same ref, fresh
instance). Rules: exactly one environment at a time (one decision
source, FR-8.2.15); `is_done()` ends the ticker (state `done`, no
auto-restart); a module-list rebuild (SAVE CONFIG, FR-11.2.1)
**discards** the env — its bindings (channel bindings in the
model, sim state) belong to the old layout, exactly like values
are lost on a KBUS reset; starting a new env is refused while
one is loaded (stop first — explicit, no silent replacement);
readonly mode (FR-10.2.8) disables every mutating control.
- FR-10.2.16: **Environment file authoring** (requested 2026-10-04,
planned): create/modify/delete environment source files (e.g.
`envs/*.py`) through the web UI — an editor pane with the plain
PlantEnv subclass pattern (FR-8.2.17), plus upload/download.
Security note: the UI writing importable Python is remote code
execution **by design**; it requires the same trust boundary as
the bench network (auth, FR-10.2.8) and SHALL be disabled in
readonly mode; delete is refused while the env is loaded; the
on-disk directory must be explicit in configuration (today
`envs/` resolves from the working directory, not the installed
package). Status: planned, roadmap phase 5.
- FR-10.2.17: **Hot reload of environment code** (requested
2026-10-04, implemented same day as part of FR-10.2.15): Python
caches modules in `sys.modules`; a restart of an env whose
source changed on disk would silently reuse the old class.
`load_env(ref, reload=True)` — used by the UI's start/restart —
invalidates the cached module before import so a fresh instance
always picks up the edited code. The CLI's startup `--env` and
the headless `sim` runner do not reload (the process is new
anyway).
### 10.3 Implementation decisions (binding, 2026-10-03) ### 10.3 Implementation decisions (binding, 2026-10-03)
@@ -1036,6 +1172,10 @@ src/wago_sim/
| `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 | | `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 |
| `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 | | `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 |
| `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 | | `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 |
| `GET/PUT /api/config/channel-ranges` | read/write analog display ranges | 10.2.14 |
| `GET /api/env` | environment status: ref, state, sim-time, tick ms | 10.2.15 |
| `POST /api/env/start` | load + start an env by ref (refuses while one is loaded) | 10.2.15 |
| `POST /api/env/stop` · `pause` · `resume` · `reset` · `restart` | ticker lifecycle; reset = deterministic fresh episode | 10.2.15 |
### 10.6 Milestones ### 10.6 Milestones
@@ -1047,7 +1187,10 @@ src/wago_sim/
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection | | M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence | | M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence |
| M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) | | M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) |
| M5b | Analog display ranges (FR-10.2.14) |
| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) | | M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) |
| M7 | Plant environment control (FR-10.2.15 + FR-10.2.17): runtime load/stop/pause/resume/reset/restart + hot reload |
| M8 | (planned) Environment file authoring (FR-10.2.16) |
### 10.7 Acceptance criteria ### 10.7 Acceptance criteria
@@ -1063,6 +1206,13 @@ src/wago_sim/
with a visible message; the scenario continues unaffected. with a visible message; the scenario continues unaffected.
6. Two simultaneous browsers stay consistent with each other and with 6. Two simultaneous browsers stay consistent with each other and with
the Modbus image. the Modbus image.
7. **Environment control** (FR-10.2.15): an env started from the UI ticks
paced (its field changes visible to a Modbus master and the overview);
pause freezes the field; reset returns to the initial state; a
module-list SAVE CONFIG discards the loaded env (status none); an
edit to the env source on disk + restart runs the new code (hot
reload); starting while loaded is refused; readonly blocks all
env mutations.
--- ---
@@ -1122,6 +1272,15 @@ This feature request closes that gap.
- FR-11.2.5: A reconfiguration event is logged (sim-time, change - FR-11.2.5: A reconfiguration event is logged (sim-time, change
description, new image sizes) and visible in the web UI's I/O Config description, new image sizes) and visible in the web UI's I/O Config
page, mirroring the WBM SAVE CONFIG flow. page, mirroring the WBM SAVE CONFIG flow.
- FR-11.2.6: **Module reordering** (requested 2026-10-04, implemented
same day): the web UI's I/O configuration pane supports rearranging
modules two ways — drag-and-drop of module rows (HTML5 DnD) and
move up/down arrow controls per slot; both are one gesture each and
trigger the same process-image rebuild + persistence as any module
change (FR-11.2.1–11.2.3). Because I/O codes number modules per type
(FR-4.3.11 v1.5), moving a module past a module of a different kind
does not renumber the other kind's channels — only the relative
order within the same type affects addressing.
### 11.3 Requirements — address-selection DIP switch emulation ### 11.3 Requirements — address-selection DIP switch emulation
+8
View File
@@ -0,0 +1,8 @@
"""User-authored plant environments (spec FR-8.2.17).
Each module here is loadable by reference:
wago-sim serve --node ... --env envs.pump_station_env:PumpStationEnv
wago-sim sim --node ... --env envs.pump_station_env:make_env
wago-sim serve --node ... --env envs.pump_station_env # picks Env
"""
+94
View File
@@ -0,0 +1,94 @@
"""Example pump-station plant environment (spec FR-8.2.17).
Demonstrates environment authoring: subclass PlantEnv, override
``model()`` — read what the PLC commanded (DO/AO), compute what the
field now shows (DI/AI). Run it live against a Modbus master:
wago-sim serve --node config/pump_node.yaml --port 5020 \
--env envs.pump_station_env:PumpStationEnv
or headless for model development:
wago-sim sim --node config/pump_node.yaml \
--env envs.pump_station_env:PumpStationEnv
Physics (deliberately simple, for demonstration): one tank.
- inflow is constant
- the pump drains at `flow_max * capacity * speed` while DO0.0 is ON;
the speed fraction is AO0.0 normalized over its display range
(FR-10.2.14)
- the level integrates the balance, clamped at the brim; AI0.0
reports the level scaled over the channel's display range
(default 0-4095 counts = 100 %)
- a high-level switch DI0.1 closes when the level rises above 2.7 m
- optional is_done: stop after a given sim time (default: run forever)
"""
from __future__ import annotations
from wago_sim.scenario.plantenv import PlantEnv, PlantState
TANK_AREA = 5.0 # m^2
FLOW_MAX = 30.0 # m^3/h the pump moves at full speed
INFLOW = 12.0 # m^3/h constant inflow
LEVEL_SENSOR_MAX = 5.0 # m — tank level meter full-scale
LEVEL_BRIM = 2.9 # m — tank brim (physical clamp)
LEVEL_HIGH = 2.7 # m — high-level switch threshold
class PumpStationEnv(PlantEnv):
"""Closed-loop tank + pump physics over the pump_node layout."""
TICK_MS = 10.0 # overrides PlantEnv TICK_MS
def __init__(self, *args, stop_after_ms: float | None = None, **kw):
super().__init__(*args, **kw)
self._level_m = 1.2 # initial tank level
self.stop_after_ms = stop_after_ms
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
dt_h = dt_s / 3600.0
# what the PLC currently commands (master side: DO/AO)
pump_running = bool(state.by_code("DI0.0"))
# speed setpoint from AO0.0, normalized over its display range
ao_lo, ao_hi = state.channel_range("AO0.0")
ao_value = state.by_code("AO0.0")
speed_raw = float(ao_value) if isinstance(ao_value, (int, float)) else 0.0
try:
speed = min(1.0, max(0.0, (speed_raw - ao_lo) / (ao_hi - ao_lo)))
except ZeroDivisionError:
speed = 0.0
# physics: water balance with pump wear (parameters attachable)
capacity = state.parameters.get("pump_capacity", 1.0)
outflow = FLOW_MAX * capacity * speed if pump_running else 0.0
d_level_m = (INFLOW - outflow) / TANK_AREA * dt_h
self._level_m = min(LEVEL_BRIM, max(0.0, self._level_m + d_level_m))
# the field the PLC reads back: level over the AI display range
# (dense store, FR-10.2.14 — defaults to 0-4095 counts = 100 %)
ai_lo, ai_hi = state.channel_range("AI0.0")
level_raw = int((self._level_m / LEVEL_SENSOR_MAX) * (ai_hi - ai_lo)
+ ai_lo)
level_raw = min(ai_hi, max(ai_lo, level_raw))
high_switch = self._level_m > LEVEL_HIGH # DI0.1
return {
"AI0.0": level_raw,
"DI0.1": high_switch,
}
def is_done(self, state: PlantState) -> bool:
if self.stop_after_ms is None:
return False # default: run until stopped
return state.sim_time_ms >= self.stop_after_ms
def make_env(node=None, engine=None) -> PumpStationEnv:
"""Factory form (FR-8.2.17): envs.pump_station_env:make_env."""
return PumpStationEnv(node=node, engine=engine)
# bare-module form: `--env envs.pump_station_env` picks this up
Env = PumpStationEnv
+28
View File
@@ -0,0 +1,28 @@
"""Test-only environment: is_done() after 30 ms of sim time.
Used by the web-UI env-control tests (FR-10.2.15): the paced ticker
must notice is_done(), set state "done" (no auto-restart), and keep
the instance for inspection.
"""
from __future__ import annotations
from wago_sim.scenario.plantenv import PlantEnv, PlantState
class PumpStationEnv(PlantEnv):
"""Minimal env: fills AI0.0, done after 30 ms."""
def __init__(self, *args, stop_after_ms: float | None = 30.0, **kw):
super().__init__(*args, **kw)
self.stop_after_ms = stop_after_ms
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
level = float(state.by_code("AI0.0"))
return {"AI0.0": level + 1.0}
def is_done(self, state: PlantState) -> bool:
return state.sim_time_ms >= (self.stop_after_ms or 0.0)
Env = PumpStationEnv
+67
View File
@@ -0,0 +1,67 @@
"""Example plant/PLC model plugin for the pump-station FAT scenario.
In a real FAT bench the PLC under test drives the outputs. For a
headless demo (or pre-FAT scenario smoke tests) this plugin emulates
the expected PLC behavior so the scenario can PASS end-to-end.
Register it in a scenario via:
plugins: [plugins.pump_plant.pump_plant]
The engine calls it once per tick.
"""
from __future__ import annotations
# latch state per engine instance (module-level dict keyed by id)
_LATCH = {}
def pump_plant(engine, value) -> None:
"""Emulated PLC with classic start/stop latch circuit:
- rising edge on start_button latches the pump ON
- e-stop OFF breaks the latch (and the safe state follows)
- valve follows the pump; speed setpoint follows level above 4000
"""
key = id(engine)
start = bool(engine.alias_value("start_button"))
em_stop = bool(engine.alias_value("em_stop_ok"))
level = float(engine.alias_value("level_sensor"))
# rising edge of the start button sets the latch; e-stop clears it
if engine.consume_rising_edge("start_button"):
_LATCH[key] = True
if not em_stop:
_LATCH[key] = False
pump_on = _LATCH.get(key, False) and em_stop
speed = int(max(0.0, (level - 4000.0) * 10.0)) if pump_on else 0
_set_do(engine, "pump_relay", pump_on)
_set_do(engine, "valve_open", pump_on)
_set_ao(engine, "speed_setpoint", speed)
def _set_do(engine, alias_name: str, value: bool) -> None:
"""Emulate a Modbus master writing the output coil.
Uses the engine's global alias location: ``loc.bit`` is the 0-based
global digital channel index == the Modbus bit address (Table 122).
"""
loc = engine.alias_location(alias_name)
result = engine.datastore.write_bits(loc.bit, [bool(value)])
assert not isinstance(result, Exception), result
def _set_ao(engine, alias_name: str, value: int) -> None:
"""Emulate a Modbus master writing the output register.
``loc.word`` is the global analog output word index == the Modbus
word address in output area 1 (0x0000-0x00FF).
"""
alias = engine._aliases[alias_name]
loc = engine.alias_location(alias_name)
raw = int(value * alias.scale + alias.offset) & 0xFFFF
result = engine.datastore.write_words(loc.word, [raw])
assert not isinstance(result, Exception), result
+59
View File
@@ -0,0 +1,59 @@
# Example CONTINUOUS (soak) scenario — pump station endurance run.
#
# Demonstrates spec §8: long-duration simulation driven by a CSV
# inflow dataset IN PARALLEL with logic, degradation, invariants and
# snapshots. Run:
#
# wago-sim run --node config/pump_node.yaml \
# --scenario scenarios/pump_soak.yaml \
# --report-jsonl out/pump_soak.jsonl
name: pump_soak_8h
description: "8-hour soak: diurnal inflow, pump wear, level bounds"
mode: continuous
until: 8h # run length (sim time)
# datasets: inflow demand replayed from a historian export (optional;
# logic-only runs are equally valid — see FR-8.2.13)
datasets:
inflow:
file: data/inflow_day.csv
interpolation: linear
repeat: 1
# aliases (per-type addressing per FR-4.3.11 v1.5)
inputs:
tank_level: { type: ai, module: 0, channel: 0, scale: 0.1 } # AI0.0
inflow_cmd: { type: ai, module: 0, channel: 1, scale: 1.0 } # AI0.1
outputs:
pump_relay: { type: do, module: 0, channel: 0 } # DO0.0
# aging plant: capacity decays with pump runtime; maintenance alarm
# expected from the PLC when capacity drops below 75 %
degradation:
pump_capacity:
observes: pump_relay
model: linear
rate: 0.01 # -1% capacity per runtime hour
from: 1.0
maintenance_at: 0.75
diagnosis: { module: 0, channel: 0, value: 0x0001 }
# every-tick conditions; breaches fail the run as bounded episodes
invariants:
- { alias: tank_level, condition: '>= 0', label: level_floor }
- { alias: tank_level, condition: '<= 9000', label: level_brim }
rate_limits:
- { alias: tank_level, max_per_hour: 5000 } # level may not race
snapshot_every: 30m # periodic state dumps for the report
# scheduled discrete events interleave with the datasets (FR-8.4.6)
sequence:
- at: 4h
report: PASS # mid-run milestone check
- at: 7h
maintenance_reset: true # service the pump: capacity restored
+54
View File
@@ -0,0 +1,54 @@
# Example FAT scenario for a pump-station PLC program.
#
# With a REAL PLC under test: run `wago-sim serve` against this node
# configuration; the PLC drives the outputs and the scenario asserts on
# them. Remove the `plugins:` line below — the PLC is the plant.
#
# Headless (no PLC): the plugins line loads an emulated plant model so
# the scenario can be smoke-tested end-to-end before the FAT bench is
# wired.
name: pump_station_fat
description: "Basic FAT round: start button -> pump relay -> valve -> level ramp"
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
inputs: # simulated field signals (what the PLC reads)
# module-based addressing: module number is
# 0-based WITHIN ITS TYPE (DI0, DO0, AI0,
# AO0 — FR-4.3.11 v1.5); channel 0-based
start_button: { type: di, module: 0, channel: 0 } # DI0.0
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
level_sensor: { type: ai, module: 0, channel: 0, scale: 0.1 } # AI0.0, level mm -> counts
outputs: # PLC outputs observed by the scenario
pump_relay: { type: do, module: 0, channel: 0 } # DO0.0
valve_open: { type: do, module: 0, channel: 1 } # DO0.1
speed_setpoint: { type: ao, module: 0, channel: 0, scale: 0.1 } # AO0.0
macros:
pulse_start:
- set: { start_button: ON }
- wait: 250ms
- set: { start_button: OFF }
sequence:
# --- startup checks -------------------------------------------------------
- set: { em_stop_ok: ON }
- assert: { label: "pump idle", conditions: { pump_relay: OFF }, within: 1s }
# --- start sequence ---------------------------------------------------------
- macro: pulse_start
- assert: { label: "pump starts", conditions: { pump_relay: ON }, within: 2s }
- assert: { label: "valve opens", conditions: { valve_open: ON }, within: 2s }
# --- process dynamics -------------------------------------------------------
- ramp: { name: level_sensor, to: 8500, over: 30s }
- wait: 31s
- assert: { label: "speed setpoint raised", conditions: { speed_setpoint: ">= 1000" }, within: 3s }
# --- stop sequence ------------------------------------------------------------
- set: { em_stop_ok: OFF }
- assert: { label: "safe state on e-stop", conditions: { pump_relay: OFF, valve_open: OFF }, within: 2s }
- report: PASS
+7 -6
View File
@@ -14,16 +14,17 @@ description: "Basic FAT round: start button -> pump relay -> valve -> level ramp
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
inputs: # simulated field signals (what the PLC reads) inputs: # simulated field signals (what the PLC reads)
# module-based addressing: module slot and # module-based addressing: module number is
# channel number on the module, both 0-based # 0-based WITHIN ITS TYPE (DI0, DO0, AI0,
# AO0 — FR-4.3.11 v1.5); channel 0-based
start_button: { type: di, module: 0, channel: 0 } # DI0.0 start_button: { type: di, module: 0, channel: 0 } # DI0.0
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1 em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
level_sensor: { type: ai, module: 4, channel: 0, scale: 0.1 } # AI4.0, level mm -> counts level_sensor: { type: ai, module: 0, channel: 0, scale: 0.1 } # AI0.0, level mm -> counts
outputs: # PLC outputs observed by the scenario outputs: # PLC outputs observed by the scenario
pump_relay: { type: do, module: 2, channel: 0 } # DO2.0 pump_relay: { type: do, module: 0, channel: 0 } # DO0.0
valve_open: { type: do, module: 2, channel: 1 } # DO2.1 valve_open: { type: do, module: 0, channel: 1 } # DO0.1
speed_setpoint: { type: ao, module: 5, channel: 0, scale: 0.1 } # AO5.0 speed_setpoint: { type: ao, module: 0, channel: 0, scale: 0.1 } # AO0.0
macros: macros:
pulse_start: pulse_start:
+13 -2
View File
@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass, field
from .datastore import WagoDatastore from .datastore import WagoDatastore
from .envrunner import EnvRunner
from .modules import ModuleType, VirtualNode from .modules import ModuleType, VirtualNode
from .registers import ConfigRegisterBank from .registers import ConfigRegisterBank
from .server import WagoSimServer from .server import WagoSimServer
@@ -36,6 +37,8 @@ class SimulatorApp:
server: WagoSimServer server: WagoSimServer
web: WebUIConfig | None = None web: WebUIConfig | None = None
node_config_path: str | None = None # where the module list was loaded from node_config_path: str | None = None # where the module list was loaded from
runner: EnvRunner = field(init=False) # env lifecycle owner (FR-10.2.15);
# holds the loaded env + its ref
@property @property
def modules(self) -> list[ModuleType]: def modules(self) -> list[ModuleType]:
@@ -60,9 +63,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
datastore = app.datastore datastore = app.datastore
names = channel_names if channel_names is not None else app.node.channel_names names = channel_names if channel_names is not None else app.node.channel_names
ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges
# a loaded env binds to the OLD layout (its model() reads channels by
# code); the rebuild renumbers everything — discard it like the values
# are lost on a KBUS reset (FR-10.2.15 discard rule)
app.runner.dispose()
datastore.reset_images() datastore.reset_images()
app.node = VirtualNode(datastore, modules, app.node = VirtualNode(datastore, modules,
channel_names=names, channel_ranges=ranges) channel_names=names, channel_ranges=ranges)
# re-point the runner at the new layout so the next start binds to it
app.runner = EnvRunner(app.node)
app.registers.set_process_image_bits( app.registers.set_process_image_bits(
analog_in=app.node.analog_in_bits, analog_in=app.node.analog_in_bits,
analog_out=app.node.analog_out_bits, analog_out=app.node.analog_out_bits,
@@ -145,7 +154,7 @@ def build_app(
idle_timeout_provider=lambda: registers.idle_timeout_ms, idle_timeout_provider=lambda: registers.idle_timeout_ms,
) )
server_ref: dict[str, WagoSimServer] = {"server": server} server_ref: dict[str, WagoSimServer] = {"server": server}
return SimulatorApp( app = SimulatorApp(
datastore=datastore, datastore=datastore,
node=node, node=node,
stats=stats, stats=stats,
@@ -155,3 +164,5 @@ def build_app(
web=web, web=web,
node_config_path=node_config_path, node_config_path=node_config_path,
) )
app.runner = EnvRunner(node)
return app
+68 -3
View File
@@ -35,6 +35,7 @@ import os
import signal import signal
import sys import sys
from pathlib import Path from pathlib import Path
from typing import NoReturn
import yaml import yaml
@@ -101,7 +102,7 @@ def _build_logger(verbose: bool) -> None:
) )
def _fail(message: str, code: int = 2): def _fail(message: str, code: int = 2) -> NoReturn:
"""Report a bad-argument / bad-configuration error cleanly and exit. """Report a bad-argument / bad-configuration error cleanly and exit.
Prints a one-line ``wago-sim: error: ...`` to stderr (never a Prints a one-line ``wago-sim: error: ...`` to stderr (never a
@@ -195,6 +196,14 @@ async def _serve(args: argparse.Namespace) -> int:
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg, app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
node_config_path=args.node, channel_names=channel_names, node_config_path=args.node, channel_names=channel_names,
channel_ranges=channel_ranges) channel_ranges=channel_ranges)
# startup default (FR-10.2.15): load BEFORE the servers start so a
# bad --env fails cleanly with nothing half-started; the UI can
# stop/restart it at runtime
if getattr(args, "env", None):
try:
app.runner.start(args.env)
except ScenarioError as exc:
_fail(f"--env {args.env}: {exc}")
await app.server.start() await app.server.start()
web_server = None web_server = None
if web_cfg is not None: if web_cfg is not None:
@@ -203,6 +212,10 @@ async def _serve(args: argparse.Namespace) -> int:
f":{web_cfg.port} (user: {web_cfg.user})") f":{web_cfg.port} (user: {web_cfg.user})")
print(f"Simulating WAGO 750-362 with {len(modules)} modules " print(f"Simulating WAGO 750-362 with {len(modules)} modules "
f"on {args.host}:{args.port} (Ctrl+C to stop)") f"on {args.host}:{args.port} (Ctrl+C to stop)")
if app.runner.env is not None:
env = app.runner.env
print(f"Plant environment: {app.runner.env_ref} "
f"({env.TICK_MS:.0f} ms paced tick, Ctrl+C to stop)")
# Graceful shutdown on SIGINT/SIGTERM (docker stop, systemctl stop). # Graceful shutdown on SIGINT/SIGTERM (docker stop, systemctl stop).
# asyncio.run installs a SIGINT handler; SIGTERM arrives as a raw # asyncio.run installs a SIGINT handler; SIGTERM arrives as a raw
@@ -219,6 +232,8 @@ async def _serve(args: argparse.Namespace) -> int:
except asyncio.CancelledError: except asyncio.CancelledError:
pass # SIGINT via asyncio.run's handler pass # SIGINT via asyncio.run's handler
finally: finally:
if app.runner.env is not None:
await app.runner.stop()
if web_server is not None: if web_server is not None:
task = getattr(web_server, "_serve_task", None) task = getattr(web_server, "_serve_task", None)
web_server.should_exit = True web_server.should_exit = True
@@ -231,6 +246,34 @@ async def _serve(args: argparse.Namespace) -> int:
return 0 return 0
def _sim(args: argparse.Namespace) -> int:
"""Headless environment run (FR-8.2.17): unpaced, until is_done()
or Ctrl+C — for plant-model development without a PLC."""
node_config = _load_yaml_file(args.node, "node config")
modules = load_node_modules(node_config)
channel_names = load_channel_names(node_config)
channel_ranges = load_channel_ranges(node_config)
app = build_app(modules=modules, channel_names=channel_names,
channel_ranges=channel_ranges)
from .scenario.plantenv import load_env
env = load_env(args.env, node=app.node)
print(f"Running {args.env} headless (Ctrl+C to stop; is_done() to end)")
ticks = 0
try:
while True:
state = env.tick()
ticks += 1
if env.is_done(state):
print(f"is_done() at tick {ticks} (sim {state.sim_time_ms:.0f} ms)")
break
if ticks % 1000 == 0 and args.verbose:
print(f"tick {ticks}: {state.sim_time_ms:.0f} ms")
except KeyboardInterrupt:
print(f"\nstopped manually at tick {ticks} "
f"(sim {env.sim_time_ms:.0f} ms)")
return 0
def _run_scenario(args: argparse.Namespace) -> int: def _run_scenario(args: argparse.Namespace) -> int:
node_config = _load_yaml_file(args.node, "node config") node_config = _load_yaml_file(args.node, "node config")
modules = load_node_modules(node_config) modules = load_node_modules(node_config)
@@ -257,7 +300,13 @@ def _run_scenario(args: argparse.Namespace) -> int:
_fail(f"scenario {args.scenario}: {exc}") _fail(f"scenario {args.scenario}: {exc}")
engine.start() engine.start()
result = None result = None
max_ticks = 2_000_000 # sequence runs cap at 2M ticks; continuous runs scale with `until`
# (spec §8): a 28-day soak needs ~242M ticks, so the limit is
# derived from the configured run length plus headroom.
if engine.mode == "continuous":
max_ticks = int((engine._until_ms or 0) / engine.TICK_MS) + 10_000_000
else:
max_ticks = 2_000_000
ticks = 0 ticks = 0
while result is None and ticks < max_ticks: while result is None and ticks < max_ticks:
result = engine.pump() result = engine.pump()
@@ -266,7 +315,10 @@ def _run_scenario(args: argparse.Namespace) -> int:
print("Scenario did not terminate (tick limit reached)", file=sys.stderr) print("Scenario did not terminate (tick limit reached)", file=sys.stderr)
return 3 return 3
print(result_summary(result)) print(result_summary(result))
if report_jsonl: # continuous runs append episodes/snapshots to the JSONL report
if report_jsonl and engine.invariants is not None:
write_jsonl(result, report_jsonl, extra=engine.invariants.as_dict())
elif report_jsonl:
write_jsonl(result, report_jsonl) write_jsonl(result, report_jsonl)
if report_csv: if report_csv:
write_csv(result, report_csv) write_csv(result, report_csv)
@@ -322,6 +374,12 @@ def main(argv: list[str] | None = None) -> int:
"--web-port", type=_port_type, default=_env_port("WAGO_SIM_WEB_PORT", 8080), "--web-port", type=_port_type, default=_env_port("WAGO_SIM_WEB_PORT", 8080),
help="web UI port (or env: WAGO_SIM_WEB_PORT; default 8080)", help="web UI port (or env: WAGO_SIM_WEB_PORT; default 8080)",
) )
serve.add_argument(
"--env", default=None,
help=("plant environment module (FR-8.2.17): e.g. "
"envs.pump_station_env:PumpStationEnv — ticked wall-clock-"
"paced alongside Modbus; runs until stopped"),
)
serve.set_defaults(func=_serve) serve.set_defaults(func=_serve)
runp = sub.add_parser("run", help="run a scenario headless (no server)") runp = sub.add_parser("run", help="run a scenario headless (no server)")
@@ -335,6 +393,13 @@ def main(argv: list[str] | None = None) -> int:
val.add_argument("--scenario", required=True) val.add_argument("--scenario", required=True)
val.set_defaults(func=_validate) val.set_defaults(func=_validate)
sim = sub.add_parser(
"sim", help="run a plant environment headless (FR-8.2.17)")
sim.add_argument("--node", required=True, help="node config YAML")
sim.add_argument("--env", required=True,
help="environment module, e.g. envs.pump_station_env:PumpStationEnv")
sim.set_defaults(func=_sim)
args = parser.parse_args(argv) args = parser.parse_args(argv)
_build_logger(args.verbose) _build_logger(args.verbose)
# `--node` may come from the environment when omitted (service mode); # `--node` may come from the environment when omitted (service mode);
+184
View File
@@ -0,0 +1,184 @@
"""EnvRunner: web-UI-controllable ticker for a plant environment.
Owns the wall-clock-paced tick task that used to live as a closure in
the CLI's ``_serve`` (FR-8.2.17 runner (a)). Moved here so the web UI
can control the lifecycle at runtime (spec FR-10.2.15):
start(ref) load (+ hot reload, FR-10.2.17) + tick
stop() cancel the ticker; the field freezes
pause() cancel the ticker, keep the instance
resume() reschedule the ticker
reset() env.reset() — deterministic fresh episode
restart() stop + start of the same ref, fresh instance
State machine: none -> running <-> paused -> done; stop/discard -> none.
Exactly one env at a time (one decision source, FR-8.2.15): start is
refused while one is loaded.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from .scenario.engine import ScenarioError
from .scenario.plantenv import PlantEnv, load_env
if TYPE_CHECKING:
from .modules import VirtualNode
log = logging.getLogger(__name__)
class EnvRunner:
"""Paced ticker + lifecycle for one PlantEnv on a virtual node."""
def __init__(self, node: VirtualNode) -> None:
self.node = node
self.env: PlantEnv | None = None
self.env_ref: str | None = None
self._task: asyncio.Task | None = None
self._state: str = "none" # none | running | paused | done
self._done_reason: str | None = None
# -- introspection ---------------------------------------------------------
@property
def state(self) -> str:
return self._state
def status(self) -> dict[str, object]:
"""Status snapshot for the web UI (FR-10.2.15)."""
return {
"ref": self.env_ref,
"state": self._state,
"reason": self._done_reason,
"sim_time_ms": self.env.sim_time_ms if self.env else None,
"tick_ms": self.env.TICK_MS if self.env else None,
}
# -- lifecycle --------------------------------------------------------------
def start(self, ref: str) -> None:
"""Load + start an environment (refused while one is loaded).
``reload=True`` passes through to ``load_env`` (FR-10.2.17):
a cached module is invalidated first, so starting after an
edit of the source file runs the NEW code.
"""
if self.env is not None:
raise ScenarioError(
"an environment is already loaded "
f"({self.env_ref!r}); stop it first")
env = load_env(ref, node=self.node, reload=True)
self.env = env
self.env_ref = ref
self._state = "running"
self._done_reason = None
self._task = asyncio.create_task(self._run())
log.info("env started: %s", ref)
async def stop(self) -> None:
"""Cancel the ticker and drop the env (field freezes)."""
await self._cancel_task()
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
log.info("env stopped")
async def pause(self) -> None:
"""Cancel the ticker, keep the instance (plant stuck fault)."""
if self.env is None or self._state not in ("running", "done"):
raise ScenarioError(f"cannot pause: state is {self._state!r}")
if self._state == "done":
raise ScenarioError("environment already done; restart it")
await self._cancel_task()
self._state = "paused"
log.info("env paused (sim %.0f ms)", self.env.sim_time_ms)
async def resume(self) -> None:
"""Reschedule the ticker after a pause."""
if self.env is None or self._state != "paused":
raise ScenarioError(f"cannot resume: state is {self._state!r}")
self._state = "running"
self._task = asyncio.create_task(self._run())
log.info("env resumed (sim %.0f ms)", self.env.sim_time_ms)
def reset(self) -> None:
"""Deterministic fresh episode (NFR-8.7.3); ticker keeps running."""
if self.env is None:
raise ScenarioError("no environment loaded")
self.env.reset()
self._done_reason = None
if self._state == "done":
# the criterion may hold again on the fresh episode — but
# is_done() is re-checked every tick anyway, so just resume
self._state = "running"
self._task = asyncio.create_task(self._run())
log.info("env reset")
async def restart(self) -> None:
"""Fresh instance of the same ref (picks up edited source)."""
if self.env_ref is None:
raise ScenarioError("no environment to restart")
ref = self.env_ref
await self.stop()
self.start(ref)
async def discard(self) -> None:
"""Silent unload (module-list rebuild, shutdown path)."""
await self._cancel_task()
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
def dispose(self) -> None:
"""Synchronous unload without awaiting (rebuild path).
The API handlers ``await discard()`` BEFORE rebuilding; this
is the belt-and-braces guarantee for sync callers
(``rebuild_node``) that the ticker can never tick the new
image: ``cancel()`` takes effect before the loop resumes, and
a fully synchronous function yields no loop slots.
"""
if self._task is not None:
self._task.cancel()
self._task = None
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
# -- ticker -------------------------------------------------------------------
async def _run(self) -> None:
"""Monotonic-scheduled paced ticker (no drift), as in _serve."""
assert self.env is not None
period = self.env.TICK_MS / 1000.0
next_at = time.monotonic()
while True:
self.env.tick()
if self.env.is_done(self.env.observe()):
self._state = "done"
self._done_reason = "is_done()"
log.info("env is_done() satisfied; ticker stopped")
return
next_at += period
delay = next_at - time.monotonic()
if delay > 0:
await asyncio.sleep(delay)
else:
next_at = time.monotonic() # catch up, no drift burst
async def _cancel_task(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
+129 -30
View File
@@ -196,28 +196,77 @@ class VirtualNode:
self.digital_in_bits = digital_in_bits self.digital_in_bits = digital_in_bits
self.digital_out_bits = digital_out_bits self.digital_out_bits = digital_out_bits
self._densify_ranges()
def _densify_ranges(self) -> None:
"""Complete the analog range map (FR-10.2.14, dense store).
Every analog channel gets an entry — defaults materialized —
and codes that no longer match an analog channel are pruned
(module list changed, FR-11.2). Digital codes never belong in
the range map (ranges apply to analog bars only).
"""
analog_codes = {self.io_code_at_slot("ai", loc.module_index, loc.channel - 1)
for loc in self._ai_channels} | \
{self.io_code_at_slot("ao", loc.module_index, loc.channel - 1)
for loc in self._ao_channels}
dense: dict[str, tuple[int, int]] = {}
for code in sorted(analog_codes):
spec = self.channel_ranges.get(code)
if spec is None or spec == self.DEFAULT_RANGE:
dense[code] = self.DEFAULT_RANGE
else:
dense[code] = spec
self.channel_ranges = dense
def set_channel_ranges(self, ranges: dict[str, tuple[int, int]]) -> None:
"""Replace all analog display ranges, then re-densify.
The store ends dense (FR-10.2.14): every analog channel of the
CURRENT layout present (missing entries become the default),
codes that no longer match an analog channel pruned.
"""
self.channel_ranges = dict(ranges)
self._densify_ranges()
def description(self) -> str: def description(self) -> str:
"""Text for firmware-info register 0x2030 (module description).""" """Text for firmware-info register 0x2030 (module description)."""
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules)) return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
# -- channel naming (spec FR-10.2.11) ---------------------------------- # -- channel naming (spec FR-10.2.11) ----------------------------------
def io_code(self, kind: str, module: int, channel: int) -> str: def io_code_at_slot(self, kind: str, module_slot: int, channel: int) -> str:
"""I/O code for a module channel, e.g. ('di', 0, 0) -> 'DI0.0'.""" """I/O code for a channel identified by PHYSICAL slot, e.g.
return f"{kind.upper()}{module}.{channel}" ('di', 0, 0) -> 'DI0.0' (first DI module). Internal callers that
walk the channel pools (slot-keyed) use this; the code itself
carries the per-type module number (FR-4.3.11 v1.5)."""
try:
per_type = self.kind_index(module_slot).get(kind)
except IndexError:
return f"{kind.upper()}?.{channel}"
if per_type is None:
return f"{kind.upper()}?.{channel}"
return f"{kind.upper()}{per_type}.{channel}"
def channel_name(self, kind: str, module: int, channel: int) -> str | None: def channel_name(self, kind: str, module_slot: int, channel: int) -> str | None:
"""Configured application name for a channel, or None.""" """Configured application name for a channel, or None."""
return self.channel_names.get(self.io_code(kind, module, channel)) return self.channel_names.get(
self.io_code_at_slot(kind, module_slot, channel)
)
def channel_range(self, kind: str, module: int, channel: int) -> tuple[int, int]: def channel_range(self, kind: str, module_slot: int, channel: int) -> tuple[int, int]:
"""Display range (min, max) raw counts for an analog channel. """Display range (min, max) raw counts for an analog channel.
Defaults to (0, 4095) when unconfigured (FR-10.2.14). Always defined for analog channels (dense store, FR-10.2.14):
the node materializes the default (0, 4095) at layout time,
so this returns the stored entry directly. Digital kinds are
rejected — ranges apply to analog bars only.
""" """
return self.channel_ranges.get( if kind not in ("ai", "ao"):
self.io_code(kind, module, channel), self.DEFAULT_RANGE raise ValueError(f"channel_range() is analog-only, got kind {kind!r}")
) return self.channel_ranges[
self.io_code_at_slot(kind, module_slot, channel)
]
def validate_channel_names(self) -> list[str]: def validate_channel_names(self) -> list[str]:
"""Names whose I/O code does not exist on this node (after a """Names whose I/O code does not exist on this node (after a
@@ -226,7 +275,7 @@ class VirtualNode:
for kind, pool in (("di", self._di_channels), ("do", self._do_channels), for kind, pool in (("di", self._di_channels), ("do", self._do_channels),
("ai", self._ai_channels), ("ao", self._ao_channels)): ("ai", self._ai_channels), ("ao", self._ao_channels)):
for loc in pool: for loc in pool:
known.add(self.io_code(kind, loc.module_index, loc.channel - 1)) known.add(self.io_code_at_slot(kind, loc.module_index, loc.channel - 1))
return sorted(set(self.channel_names) - known) return sorted(set(self.channel_names) - known)
# -- named channel access (scenario engine API) ----------------------------- # -- named channel access (scenario engine API) -----------------------------
@@ -256,24 +305,57 @@ class VirtualNode:
raise IndexError(f"no {kind} channel {index} (have {len(pool)})") raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
return pool[index - 1] return pool[index - 1]
def kind_modules(self, kind: str) -> list[ModuleType]:
"""Modules providing the given kind, in physical order.
`channel_by_module` numbers modules 0-based within this list
(FR-4.3.11 v1.5: DI0, DO0, AI0, AO0 start each kind's sequence).
"""
kind_field = {
"di": "input_bits", "do": "output_bits",
"ai": "input_words", "ao": "output_words",
}
if kind not in kind_field:
raise ValueError(f"unknown channel kind: {kind!r}")
field = kind_field[kind]
return [m for m in self.modules
if getattr(m, kind_field[kind]) > 0]
def kind_index(self, module_index: int) -> dict[str, int]:
"""Per-type number (FR-4.3.11 v1.5) for the module at the given
physical slot: {'di': 0, ...} for the first DI module etc."""
mod = self.modules[module_index]
out: dict[str, int] = {}
for kind in ("di", "do", "ai", "ao"):
field = {"di": "input_bits", "do": "output_bits",
"ai": "input_words", "ao": "output_words"}[kind]
if getattr(mod, field) == 0:
continue
same_kind = [i for i, m in enumerate(self.modules)
if getattr(m, field) > 0]
out[kind] = same_kind.index(module_index)
return out
def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation: def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation:
"""Access a channel by module position and channel number, both 0-based. """Access a channel by module number and channel number, both 0-based.
Addresses a channel as *module type + module number + channel Addresses a channel as *module type + module number + channel
number*, e.g. DI0.1 = digital input, module slot 0, channel 1. number*, e.g. DI1.0 = digital inputs, the 2nd DI module, channel 0
The module number is the position in the configured module list; (0-based) — **the module number counts within the module's type**
the channel number is the channel on that module (0-based), i.e. (FR-4.3.11 v1.5): DI modules are numbered DI0, DI1, … regardless
DI0.1 selects the module's 2nd digital input. of their physical slots among DO/AI/AO modules.
The channel kind must match what the module actually provides — The channel kind must match what the module actually provides —
DI0.1 on a module with no inputs is a configuration error. DI1.0 on a module with no inputs is a configuration error.
""" """
if not 0 <= module < len(self.modules): modules_of_kind = self.kind_modules(kind)
if not 0 <= module < len(modules_of_kind):
raise IndexError( raise IndexError(
f"no module {module} (node has {len(self.modules)} modules, " f"no {kind.upper()} module {module} "
f"numbered 0-{len(self.modules) - 1})" f"(node has {len(modules_of_kind)} {kind.upper()} modules, "
f"numbered 0-{len(modules_of_kind) - 1} within the type)"
) )
mod = self.modules[module] mod = modules_of_kind[module]
pool_map = { pool_map = {
"di": (self._di_channels, mod.input_bits), "di": (self._di_channels, mod.input_bits),
"do": (self._do_channels, mod.output_bits), "do": (self._do_channels, mod.output_bits),
@@ -285,24 +367,41 @@ class VirtualNode:
pool, module_channel_count = pool_map[kind] pool, module_channel_count = pool_map[kind]
if module_channel_count == 0: if module_channel_count == 0:
raise ChannelMismatch( raise ChannelMismatch(
f"module {module} ({mod.article}) has no {kind.upper()} channels" f"{kind.upper()} module {module} ({mod.article}) has no "
f"{kind.upper()} channels"
) )
if not 0 <= channel < module_channel_count: if not 0 <= channel < module_channel_count:
raise IndexError( raise IndexError(
f"module {module} ({mod.article}) has no {kind.upper()} " f"{kind.upper()} module {module} ({mod.article}) has no "
f"channel {channel} (has {module_channel_count}: 0-" f"{kind.upper()} channel {channel} (has {module_channel_count}: "
f"{module_channel_count - 1})" f"0-{module_channel_count - 1})"
) )
# find the location: channels of one module are contiguous in the # channels of one module are contiguous in the kind pool; find the
# kind pool; locate the first channel of this module in the pool # physical slot of the per-type module and its matching location
physical_slot = [
i for i, m in enumerate(self.modules) if m is mod
][0]
for loc in pool: for loc in pool:
if loc.module_index == module and loc.channel == channel + 1: if (loc.module_index == physical_slot
and loc.channel == channel + 1):
return loc return loc
raise ChannelMismatch( raise ChannelMismatch(
f"module {module} ({mod.article}) channel {channel} is not a " f"{kind.upper()} module {module} ({mod.article}) channel "
f"{kind.upper()} channel" f"{channel} is not a {kind.upper()} channel"
) )
def io_code(self, kind: str, module: int, channel: int) -> str:
"""I/O code with the PER-TYPE module number, e.g. ('di', 0, 0) -> 'DI0.0'
where the module number is the index within the module's type
(FR-4.3.11 v1.5)."""
modules_of_kind = self.kind_modules(kind)
if not 0 <= module < len(modules_of_kind):
raise IndexError(
f"no {kind.upper()} module {module} "
f"(node has {len(modules_of_kind)} {kind.upper()} modules)"
)
return f"{kind.upper()}{module}.{channel}"
# -- field-side setters (simulated world) ------------------------------------- # -- field-side setters (simulated world) -------------------------------------
def set_di(self, channel: int, value: bool) -> None: def set_di(self, channel: int, value: bool) -> None:
+260
View File
@@ -0,0 +1,260 @@
"""Streaming CSV dataset playback for continuous scenarios (spec §8, FR-8.2.2-11).
A dataset drives designated input aliases from a time-series CSV:
datasets:
inflow:
file: data/inflow.csv # time column + one column per alias
interpolation: linear # analog only; digital always holds
repeat: 4 # optional seamless loop
time_factor: 10 # dataset seconds per sim-second
offset: 1h # optional start shift on sim-time
time_column: time # default 'time'
time_format: elapsed # 'elapsed' (seconds) | 'iso8601'
Design points (FR-8.2.6): files are STREAMED with one-row lookahead —
a months-long CSV never loads into RAM. Malformed rows raise a
DataError carrying file+line so the engine can end the run with
verdict ERROR (FR-8.2.11).
"""
from __future__ import annotations
import csv
from datetime import datetime
from pathlib import Path
from .engine import DataError, ScenarioError
def _parse_digital(text: str) -> bool:
v = text.strip().upper()
if v in ("ON", "1", "TRUE", "YES"):
return True
if v in ("OFF", "0", "FALSE", "NO", ""):
return False
raise ValueError(f"cannot parse digital value {text!r}")
def _parse_time(text: str, fmt: str) -> float:
"""Elapsed seconds or ISO-8601 -> elapsed seconds since the epoch."""
if fmt == "iso8601":
dt = datetime.fromisoformat(text.strip())
return dt.timestamp()
return float(text)
class CsvDataset:
"""One streaming dataset (FR-8.2.2-11).
The engine calls :meth:`apply_at` every tick with the current
sim-time; the dataset sets its aliases via the engine's set_alias.
Bounded memory: exactly one lookahead row is held.
"""
def __init__(
self,
engine,
name: str,
spec: dict,
*,
base_path: Path | None = None,
) -> None:
self.engine = engine
self.name = name
self.path = Path(spec["file"])
if not self.path.is_absolute() and base_path is not None:
self.path = base_path / self.path
self.time_column = str(spec.get("time_column", "time"))
self.time_format = str(spec.get("time_format", "elapsed"))
self.interpolation = str(spec.get("interpolation", "linear"))
self.repeat = int(spec.get("repeat", 1))
self.time_factor = float(spec.get("time_factor", 1.0))
self.offset_ms = _parse_duration_ms(spec.get("offset", 0))
self.aliases: list[str] = []
self._fh = None
self._reader = None
self._header: list[str] | None = None
self._row: list[str] | None = None # current row
self._next: list[str] | None = None # one-row lookahead
self._row_time = 0.0 # dataset time of _row
self._loop = 1 # current repeat loop
self._start_epoch: float | None = None # iso8601 anchor
self._finished = False
self._line_no = 0
# -- lifecycle ------------------------------------------------------------
def open(self) -> None:
"""Validate the header and alias columns; prime the lookahead."""
if not self.path.is_file():
raise ScenarioError(f"dataset file not found: {self.path}")
self._fh = self.path.open("r", encoding="utf-8", newline="")
self._reader = csv.reader(self._fh)
try:
header = next(self._reader)
except StopIteration:
raise ScenarioError(f"dataset {self.path} is empty") from None
self._line_no = 1
header = [h.strip() for h in header]
if self.time_column not in header:
raise DataError(
str(self.path), 1, f"time column {self.time_column!r} not in header")
cols = [c for c in header if c != self.time_column]
if not cols:
raise DataError(str(self.path), 1, "no alias columns")
for col in cols:
if col not in self.engine._aliases:
raise DataError(
str(self.path), 1,
f"column {col!r} is not a known alias")
alias = self.engine._aliases[col]
if alias.kind in ("do", "ao"):
raise DataError(
str(self.path), 1,
f"output alias {col!r} cannot be dataset-driven")
self.aliases = cols
self._header = header
self._advance_row() # prime _row / _row_time
# iso8601 datasets: anchor elapsed time at the first sample so
# `offset` positions the series on the sim-time axis
if self.time_format == "iso8601" and self._row is not None:
self._start_epoch = self._row_time
self._row_time = 0.0
def close(self) -> None:
if self._fh is not None:
self._fh.close()
self._fh = None
@property
def finished(self) -> bool:
return self._finished
# -- streaming -----------------------------------------------------------
def _read_next(self) -> list[str] | None:
"""Read the next data row, skipping comment lines (#)."""
while True:
try:
raw = next(self._reader)
except StopIteration:
return None
self._line_no += 1
if raw and raw[0].lstrip().startswith("#"):
continue
return raw
def _advance_row(self) -> None:
"""Move the lookahead into the current row slot."""
if self._next is None:
self._next = self._read_next()
if self._next is not None:
self._next_t = self._parse_time_of(self._next)
if self._next is None:
# end of file: loop if repeats remain
if self._loop < self.repeat:
self._loop += 1
self._fh.seek(0)
next(self._reader) # skip header
self._line_no = 1
self._next = self._read_next()
if self._next is None:
self._finished = True
return
self._next_t = self._parse_time_of(self._next)
# seamless seam (FR-8.2.7): continue the time axis from
# the last sample of the previous loop
self._next_t = self._row_time + (
self._next_t - self._loop_anchor)
else:
self._finished = True
return
self._row = self._next
self._row_time = self._next_t if self._loop == 1 else self._row_time + (
self._next_t - self._row_time)
self._next = self._read_next()
if self._next is not None:
self._next_t = self._parse_time_of(self._next)
if self._next_t <= self._row_time:
raise DataError(
str(self.path), self._line_no,
f"non-monotonic time {self._next_t}")
_loop_anchor = 0.0
def _parse_time_of(self, row: list[str]) -> float:
try:
idx = self._header.index(self.time_column)
return _parse_time(row[idx], self.time_format)
except (ValueError, IndexError) as exc:
raise DataError(
str(self.path), self._line_no,
f"cannot parse time: {exc}") from None
def _value_of(self, row: list[str], alias: str) -> object:
idx = self._header.index(alias)
if idx >= len(row):
raise DataError(str(self.path), self._line_no,
f"missing column {alias!r}")
alias_spec = self.engine._aliases[alias]
text = row[idx]
try:
if alias_spec.kind == "di":
return _parse_digital(text)
return float(text)
except ValueError:
raise DataError(
str(self.path), self._line_no,
f"cannot parse value for {alias!r}: {text!r}") from None
# -- per-tick playback ---------------------------------------------------
def apply_at(self, sim_time_ms: float) -> None:
"""Set this dataset's aliases to their values at sim_time_ms."""
if self._finished:
return
# dataset-time position on the sim-time axis (time_factor applied)
effective_ms = (sim_time_ms - self.offset_ms) * self.time_factor
if effective_ms < 0:
return # not started yet (offset)
effective_s = effective_ms / 1000.0
# advance the stream until the row pair brackets the time
while (self._next is not None
and self._next_t <= effective_s):
self._advance_row()
if self._row is None:
return
# interpolate/hold between _row and _next (FR-8.2.5)
for alias in self.aliases:
kind = self.engine._aliases[alias].kind
if kind == "di":
self.engine.set_alias(alias, self._value_of(self._row, alias))
continue
cur = self._value_of(self._row, alias)
if (self.interpolation == "linear" and self._next is not None):
nxt = self._value_of(self._next, alias)
span = self._next_t - self._row_time
if span > 0:
frac = (effective_s - self._row_time) / span
frac = min(1.0, max(0.0, frac))
cur = cur + (nxt - cur) * frac
self.engine.set_alias(alias, cur)
def _parse_duration_ms(value: str | int | float) -> float:
"""'1h30m' style offsets -> milliseconds (reuses engine parser)."""
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().lower()
multipliers = {"ms": 1.0, "s": 1000.0, "m": 60000.0, "h": 3600000.0}
for suffix in ("ms", "s", "m", "h"):
if text.endswith(suffix):
try:
return float(text[: -len(suffix)]) * multipliers[suffix]
except ValueError:
break
try:
return float(text)
except ValueError:
raise ScenarioError(f"cannot parse offset {value!r}") from None
+195
View File
@@ -0,0 +1,195 @@
"""Wear counters and degradation models (spec §8.3, FR-8.3.1-5).
Plant parameters (e.g. ``pump_capacity``) age from wear instead of
staying ideal forever, exercising the PLC's compensation and
maintenance logic:
degradation:
pump_capacity: # parameter name (float 0..1)
from: 1.0 # fresh value (also the reset target)
model: linear # linear | exponential | staircase
rate: 0.0002 # per runtime hour (linear/exp)
per: runtime_hours # wear basis
on: pump_relay # alias observed for wear (any kind)
staircase: [0.95, 0.85, 0.7] # staircase model: levels per step
maintenance_at: 0.75 # parameter threshold -> diagnosis
diagnosis: # injected when threshold crossed
module: 2 # per-type DI/DO... module number
channel: 0
value: 0x0001
Counters update once per tick from the observed alias truth (runtime
hours, rising/falling edges). The engine reads parameters via
``engine.parameter(name)`` (FR-8.3.3); the maintenance alarm is the
PLC's to raise — the scenario asserts it (FR-8.3.4); the built-in
``maintenance_reset`` action clears diagnosis and restores ``from``
(FR-8.3.5).
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
@dataclass
class WearCounters:
"""Per-alias wear (FR-8.3.1)."""
runtime_hours: float = 0.0
rising_edges: int = 0
falling_edges: int = 0
def as_dict(self) -> dict:
return {
"runtime_hours": self.runtime_hours,
"rising_edges": self.rising_edges,
"falling_edges": self.falling_edges,
}
@dataclass
class DegradationModel:
"""One aging parameter (FR-8.3.2-5)."""
name: str
parameter: str # parameter key, e.g. 'pump_capacity'
observes: str # alias name the wear counts come from
model: str = "linear" # linear | exponential | staircase
start: float = 1.0 # fresh value; maintenance_reset target
rate: float = 0.0 # per hour (linear/exponential) or per cycle
basis: str = "runtime_hours" # runtime_hours | cycles
staircase: list[float] = field(default_factory=list)
maintenance_at: float | None = None
diagnosis: dict | None = None
# runtime state
value: float = 1.0
cycles: int = 0
diagnosed: bool = False
def reset(self) -> None:
"""FR-8.3.5: restore to `start`; diagnosis cleared by the engine."""
self.value = self.start
self.cycles = 0
def update(self, dt_hours: float, wear: WearCounters) -> None:
"""Advance the parameter one tick (FR-8.3.2)."""
if self.basis == "cycles":
if self.model == "staircase":
level = min(self.cycles, len(self.staircase) - 1)
self.value = self.staircase[level]
elif self.model == "linear":
self.value = self.start - self.rate * self.cycles
elif self.model == "exponential":
self.value = self.start * (self.rate ** self.cycles)
else:
if self.model == "staircase":
# `rate` = levels per runtime hour; rate<=0 = one level/hour
per_level_hours = (1.0 / self.rate) if self.rate > 0 else 1.0
level = min(int(wear.runtime_hours / per_level_hours),
len(self.staircase) - 1)
self.value = self.staircase[level]
elif self.model == "exponential":
self.value = self.start * (1.0 - self.rate) ** wear.runtime_hours
else: # linear
self.value = self.start - self.rate * wear.runtime_hours
self.value = max(0.0, min(1.0, self.value))
class DegradationManager:
"""All aging parameters of one continuous run (spec §8.3)."""
def __init__(self, engine, spec: dict | None) -> None:
self.engine = engine
self.models: dict[str, DegradationModel] = {}
self.wear: dict[str, WearCounters] = {}
self._last_truth: dict[str, bool] = {}
for param_name, cfg in (spec or {}).items():
if not isinstance(cfg, dict):
raise ValueError(f"degradation {param_name!r} must be a mapping")
# NB: the key is 'observes', NOT 'on' — YAML 1.1 parses the
# bare key `on` as boolean True (a classic trap)
observes = str(cfg.get("observes", ""))
if observes not in engine._aliases:
raise ValueError(
f"degradation {param_name!r}: unknown alias {observes!r}")
model = DegradationModel(
name=param_name,
parameter=str(cfg.get("parameter", param_name)),
observes=observes,
model=str(cfg.get("model", "linear")),
start=float(cfg.get("from", 1.0)),
rate=float(cfg.get("rate", 0.0)),
basis=str(cfg.get("per", "runtime_hours")),
staircase=[float(v) for v in cfg.get("staircase", [])],
maintenance_at=(float(cfg["maintenance_at"])
if "maintenance_at" in cfg else None),
diagnosis=cfg.get("diagnosis"),
)
if model.model == "staircase" and not model.staircase:
raise ValueError(
f"degradation {param_name!r}: staircase model needs 'staircase'")
model.value = model.start
self.models[param_name] = model
self.wear.setdefault(observes, WearCounters())
# -- per-tick update ------------------------------------------------------
def tick(self, dt_ms: float) -> None:
"""Update wear counters and parameters for one tick (FR-8.2.12 step 4)."""
dt_hours = dt_ms / 3_600_000.0
for alias_name, wear in self.wear.items():
truth = bool(self.engine.alias_value(alias_name))
before = self._last_truth.get(alias_name, False)
if truth:
wear.runtime_hours += dt_hours
if truth and not before:
wear.rising_edges += 1
# count a cycle per completed run: rising edge closes it
for model in self.models.values():
if model.observes == alias_name and model.basis == "cycles":
model.cycles += 1
if (not truth) and before:
wear.falling_edges += 1
self._last_truth[alias_name] = truth
for model in self.models.values():
model.update(dt_hours, self.wear[model.observes])
if (model.maintenance_at is not None and not model.diagnosed
and model.value <= model.maintenance_at):
model.diagnosed = True
self._inject_diagnosis(model)
def _inject_diagnosis(self, model: DegradationModel) -> None:
"""FR-8.3.4: threshold crossed -> module diagnosis injected."""
diag = model.diagnosis or {}
module = int(diag.get("module", 0))
channel = int(diag.get("channel", 0))
value = int(diag.get("value", 0x0001))
registers = getattr(self.engine, "registers", None)
if registers is not None:
registers.set_module_diagnosis(module, channel, value)
# -- engine interface (FR-8.3.3) -------------------------------------------
def parameter(self, name: str) -> float:
"""Parameter value for plant physics (pump models etc.)."""
model = self.models.get(name)
if model is None:
raise KeyError(f"unknown parameter {name!r}")
return model.value
def maintenance_reset(self) -> None:
"""FR-8.3.5 action: restore parameters, clear diagnosis."""
for model in self.models.values():
model.reset()
model.diagnosed = False
registers = getattr(self.engine, "registers", None)
if registers is not None:
registers.set_module_diagnosis(0, 0, 0)
def snapshot(self) -> dict:
"""Wear + parameter values for report snapshots (FR-8.4.3)."""
return {
"parameters": {n: m.value for n, m in self.models.items()},
"wear": {a: w.as_dict() for a, w in self.wear.items()},
}
+45 -1
View File
@@ -136,6 +136,50 @@ def validate_scenario(scenario: dict) -> list[str]:
f"'module' is given" f"'module' is given"
) )
seq = scenario.get("sequence") or [] seq = scenario.get("sequence") or []
if not seq and not scenario.get("states"): mode = str(scenario.get("mode", "sequence"))
if not seq and not scenario.get("states") and mode != "continuous":
problems.append("neither 'sequence' nor 'states' defined") problems.append("neither 'sequence' nor 'states' defined")
# -- continuous mode (spec §8) -------------------------------------------
mode = str(scenario.get("mode", "sequence"))
if mode not in ("sequence", "continuous"):
problems.append(f"unknown mode {mode!r} (sequence | continuous)")
if mode == "continuous":
# sequence optional (FR-8.2.1); run-length drivers required
datasets = scenario.get("datasets") or {}
scheduled = [s for s in seq if isinstance(s, dict) and "at" in s]
until = scenario.get("until")
if not datasets and not scheduled and until is None and not seq:
problems.append(
"continuous scenario needs datasets, scheduled steps "
"(at:), or until: to bound the run (FR-8.2.1)"
)
if not isinstance(datasets, dict):
problems.append("'datasets' must be a mapping")
else:
for ds_name, spec in datasets.items():
if not isinstance(spec, dict) or "file" not in spec:
problems.append(f"datasets.{ds_name}: needs 'file'")
else:
for key in ("interpolation",):
if key in spec and spec[key] not in ("linear", "hold"):
problems.append(
f"datasets.{ds_name}: interpolation must be "
f"linear | hold (FR-8.2.5)")
# invariants / rate limits reference aliases (FR-8.4)
for inv in scenario.get("invariants") or []:
if not isinstance(inv, dict) or "alias" not in inv or "condition" not in inv:
problems.append("invariants need 'alias' and 'condition'")
for rl in scenario.get("rate_limits") or []:
if not isinstance(rl, dict) or "alias" not in rl or "max_per_hour" not in rl:
problems.append("rate_limits need 'alias' and 'max_per_hour'")
deg = scenario.get("degradation") or {}
if not isinstance(deg, dict):
problems.append("'degradation' must be a mapping")
else:
for pname, cfg in deg.items():
if not isinstance(cfg, dict) or "observes" not in cfg:
problems.append(f"degradation.{pname}: needs 'observes' (alias)")
elif cfg["observes"] not in {**inputs, **outputs}:
problems.append(
f"degradation.{pname}: unknown alias {cfg['observes']!r}")
return problems return problems
+239
View File
@@ -155,6 +155,122 @@ class ScenarioEngine:
} }
self._macro_return_wait: float | None = None self._macro_return_wait: float | None = None
self._plugin_actions = self._load_plugins() self._plugin_actions = self._load_plugins()
# -- continuous mode (spec §8) ---------------------------------------
self.mode = str(scenario.get("mode", "sequence"))
if self.mode not in ("sequence", "continuous"):
raise ScenarioError(f"unknown mode {self.mode!r}")
self.env = None # PlantEnv, attached in continuous mode
self.datasets = [] # CsvDataset instances
self.degradation = None # DegradationManager
self.invariants = None # InvariantMonitor
self._until_ms: float | None = None
self._scheduled: list[tuple[float, dict]] = [] # (due_ms, step)
self._ownership: dict[str, str] = {} # alias -> driver name
if self.mode == "continuous":
self._setup_continuous()
# -- continuous-mode setup (spec §8.2) --------------------------------------
def _setup_continuous(self) -> None:
from .datasets import CsvDataset
from .degradation import DegradationManager
from .invariants import InvariantMonitor
from .plantenv import PlantEnv
# run-length control (FR-8.2.1): until, scheduled steps, datasets
until = self.scenario.get("until")
if until is not None:
self._until_ms = _parse_duration(until)
# datasets (optional) — one driver per alias (FR-8.2.2)
base = self.scenario.get("_base_path")
for ds_name, spec in (self.scenario.get("datasets") or {}).items():
ds = CsvDataset(self, ds_name, spec, base_path=base)
ds.open()
for alias in ds.aliases:
self._claim_alias(alias, f"dataset:{ds_name}")
self.datasets.append(ds)
# scheduled steps: `at: <duration>` entries (FR-8.4.6); the rest of
# the sequence runs as a normal step queue when present
for step in self.scenario.get("sequence", []) or []:
if isinstance(step, dict) and "at" in step:
due = _parse_duration(step["at"])
inner = {k: v for k, v in step.items() if k != "at"}
self._scheduled.append((due, inner))
self._scheduled.sort(key=lambda s: s[0])
self.degradation = DegradationManager(self, self.scenario.get("degradation"))
for model in self.degradation.models.values():
self._claim_alias(model.observes, f"degradation:{model.name}")
self.invariants = InvariantMonitor(self, self.scenario)
self.env = PlantEnv(engine=self)
# FR-8.2.1: a continuous run needs at least one run-length driver
if (not self.datasets and not self._scheduled
and self._until_ms is None
and not self.scenario.get("sequence")):
raise ScenarioError(
"continuous scenario needs datasets, scheduled steps, or "
"until: to bound the run (it would run forever)"
)
def _claim_alias(self, alias: str, driver: str) -> None:
"""FR-8.2.2: exactly one driver may own an alias."""
if alias in self._ownership and self._ownership[alias] != driver:
raise ScenarioError(
f"alias {alias!r} claimed by both "
f"{self._ownership[alias]!r} and {driver!r}"
)
self._ownership[alias] = driver
def parameter(self, name: str) -> float:
"""Plant parameter accessor (FR-8.3.3): e.g. pump_capacity."""
if self.degradation is None:
raise ScenarioError("no degradation configured; parameter() unavailable")
return self.degradation.parameter(name)
def maintenance_reset(self) -> None:
"""Scenario action (FR-8.3.5): restore parameters, clear diagnosis."""
if self.degradation is not None:
self.degradation.maintenance_reset()
def reset_episode(self) -> None:
"""PlantEnv.reset (FR-8.2.16): deterministic fresh episode."""
self.datastore.reset_images()
self.sim_time_ms = 0.0
self._result = ScenarioResult(name=self.name)
self._edges = {}
self._ramps = []
self._last_snapshot = self._snapshot_aliases()
self._finished = False
self._step_queue = deque(self.scenario.get("sequence", []) or [])
self._wait_until_ms = None
self._pending_assert = None
self._assert_deadline_ms = None
for ds in self.datasets:
ds.close()
ds._loop = 1
ds._finished = False
ds._row = ds._next = None
ds._row_time = 0.0
ds._line_no = 0
ds.open()
if self.degradation is not None:
for model in self.degradation.models.values():
model.reset()
model.diagnosed = False
self.degradation._last_truth = {}
if self.invariants is not None:
self.invariants.episodes = []
for inv in self.invariants.invariants:
inv.active = None
for rl in self.invariants.rate_limits:
rl.samples = []
rl.breached = False
rl.peak_rate = 0.0
self.invariants.snapshots = []
self.invariants._last_snapshot_ms = 0.0
# -- setup --------------------------------------------------------------- # -- setup ---------------------------------------------------------------
@@ -328,6 +444,8 @@ class ScenarioEngine:
return self._result return self._result
dt = dt_ms if dt_ms is not None else float(self.TICK_MS) dt = dt_ms if dt_ms is not None else float(self.TICK_MS)
self.sim_time_ms += dt self.sim_time_ms += dt
if self.mode == "continuous":
return self._pump_continuous(dt)
# 1) detect alias edges (inputs set in the previous tick, outputs # 1) detect alias edges (inputs set in the previous tick, outputs
# written by the master/plant) # written by the master/plant)
self._detect_edges() self._detect_edges()
@@ -377,6 +495,118 @@ class ScenarioEngine:
if fn is not None: if fn is not None:
fn(self, None) fn(self, None)
# -- continuous tick pipeline (spec FR-8.2.12) -------------------------------
def _pump_continuous(self, dt: float) -> ScenarioResult | None:
"""One continuous-mode tick, fixed pipeline order (FR-8.2.12):
(1) datasets apply sampled/interpolated values,
(2) alias edges are detected,
(3) plant models run (plugins + env decisions flush),
(4) degradation updates,
(5) invariants and rate limits evaluate,
(6) scheduled steps due this tick execute,
(7) snapshots are taken if due.
Run end: until elapsed + all drivers exhausted (FR-8.2.1).
"""
# (1) datasets
for ds in self.datasets:
try:
ds.apply_at(self.sim_time_ms)
except DataError as exc:
self._record("dataset", Verdict.ERROR, str(exc))
self._finish(Verdict.ERROR)
return self._result
# (2) edges
self._detect_edges()
# (3) plant logic: plugins first, then queued env decisions land
# (the scenario layer drives env via observe/apply; queued
# values are already applied by the decision source)
self._run_tick_plugins()
self._advance_ramps(dt)
# (4) degradation
if self.degradation is not None:
self.degradation.tick(dt)
# (5) invariants + rate limits
if self.invariants is not None:
self.invariants.tick()
failure = self.invariants.rate_failure
if failure:
self._record("rate_limit", Verdict.FAIL, failure)
self._finish(Verdict.FAIL)
return self._result
# (6) scheduled steps due now (FR-8.4.6)
while self._scheduled and self._scheduled[0][0] <= self.sim_time_ms:
_, step = self._scheduled.pop(0)
for key, value in step.items():
self._execute_step_key(key, value)
# non-scheduled step queue (mixed runs): drain non-blocking steps
self._drain_sequence_in_continuous()
# (7) snapshots handled inside invariants.tick()
# -- run-end check (FR-8.2.1) -----------------------------------------
# The run ends when ALL of the following hold:
# - every dataset finished (or looped to its repeat limit)
# - no scheduled steps remain and the step queue is empty
# - no blocking step is pending (wait/assert)
# - the `until` duration elapsed (until ALWAYS bounds the run)
drivers_left = (any(not ds.finished for ds in self.datasets)
or bool(self._scheduled)
or bool(self._step_queue)
or self._wait_until_ms is not None
or self._pending_assert is not None)
until_elapsed = (self._until_ms is not None
and self.sim_time_ms >= self._until_ms)
if until_elapsed or (not drivers_left and self._until_ms is None):
self._end_continuous_run()
return None if not self._finished else self._result
def _end_continuous_run(self) -> None:
"""Continuous run-end verdict (FR-8.4.4): invariants/rate-limits fail."""
verdict = Verdict.PASS
if self.invariants is not None:
if self.invariants.episodes:
verdict = Verdict.FAIL
elif self.invariants.rate_failure:
verdict = Verdict.FAIL
for step in self._result.steps:
if step.verdict is Verdict.FAIL:
verdict = Verdict.FAIL
break
self._finish(verdict)
def _drain_sequence_in_continuous(self) -> None:
"""Run queued sequence steps that don't block (mixed continuous runs)."""
while self._step_queue:
step = self._step_queue[0]
if isinstance(step, dict) and "at" in step:
self._step_queue.popleft() # scheduled steps handled above
continue
blocked = self._run_next_step()
if blocked:
break
def _execute_step_key(self, key: str, value) -> None:
"""One key of a scheduled step (subset of sequence semantics)."""
if key == "set":
for alias_name, v in value.items():
self.set_alias(alias_name, v)
elif key == "maintenance_reset":
self.maintenance_reset()
elif key == "report":
self._record("scheduled", Verdict.PASS if str(value).upper() == "PASS"
else Verdict.FAIL, str(value))
elif key in self._plugin_actions:
self._plugin_actions[key](self, value)
elif key == "assert":
# immediate one-tick check; failures recorded as episodes
label = str(value.get("label", "assert")) if isinstance(value, dict) else "assert"
conditions = value.get("conditions", {}) if isinstance(value, dict) else value
verdict = Verdict.PASS if self._matcher.evaluate(conditions) else Verdict.FAIL
self._record(label, verdict, f"at {self.sim_time_ms:.0f} ms")
else:
raise ScenarioError(f"unknown scheduled step key: {key!r}")
def _run_next_step(self) -> bool: def _run_next_step(self) -> bool:
"""Execute one queued step. """Execute one queued step.
@@ -531,6 +761,15 @@ class ScenarioError(ValueError):
"""Invalid scenario definition or runtime misuse.""" """Invalid scenario definition or runtime misuse."""
class DataError(ScenarioError):
"""Malformed dataset content (FR-8.2.11); carries file and line."""
def __init__(self, path: str, line_no: int, message: str) -> None:
self.path = path
self.line_no = line_no
super().__init__(f"{path}:{line_no}: {message}")
def _parse_duration(value: str | int | float) -> float: def _parse_duration(value: str | int | float) -> float:
"""Parse '250ms', '30s', '2m', or a raw number (milliseconds).""" """Parse '250ms', '30s', '2m', or a raw number (milliseconds)."""
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
+210
View File
@@ -0,0 +1,210 @@
"""Invariants, rate limits, and snapshots for continuous runs (spec §8.4).
An invariant is a condition that must hold EVERY tick of the run
(e.g. tank level within bounds). A breach is recorded as one bounded
*episode* (start, end, peak violation) instead of per-tick spam
(FR-8.4.1); any episode fails the run verdict. Rate limits bound how
fast an alias may change over a sliding one-hour window (FR-8.4.2).
Snapshots periodically dump alias values, wear counters, and
degradation parameters (FR-8.4.3).
YAML::
invariants:
- { alias: tank_level, condition: '>= 0', label: level_bottom }
- { alias: tank_level, condition: '<= 8500', label: level_brim }
rate_limits:
- { alias: pump_speed, max_per_hour: 3000 }
snapshot_every: 1h
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
def _compare(value: float, condition: str) -> bool:
"""Evaluate 'value <condition>' e.g. 3.2 >= 0. Returns True when OK."""
text = condition.strip()
for op in ("<=", ">=", "!=", "==", "<", ">"):
if text.startswith(op):
ref = float(text[len(op):])
return {
"<=": value <= ref, ">=": value >= ref,
"!=": value != ref, "==": value == ref,
"<": value < ref, ">": value > ref,
}[op]
raise ValueError(f"cannot parse invariant condition {condition!r}")
@dataclass
class Episode:
"""One aggregated invariant breach (FR-8.4.1)."""
label: str
started_ms: float
ended_ms: float
peak_violation: float = 0.0
samples: int = 0
def as_dict(self) -> dict:
return {
"label": self.label,
"started_ms": self.started_ms,
"ended_ms": self.ended_ms,
"peak_violation": self.peak_violation,
"samples": self.samples,
}
@dataclass
class Invariant:
label: str
alias: str
condition: str
# episode runtime state
active: Episode | None = None
def evaluate(self, value: float) -> bool:
return _compare(float(value), self.condition)
@dataclass
class RateLimit:
"""Sliding-window rate bound (FR-8.4.2).
The window is one hour of SIM-TIME (not wall-clock): samples are
(sim_time, value) pairs; older ones age out of the window.
"""
alias: str
max_per_hour: float
window_ms: float = 3_600_000.0
samples: list[tuple[float, float]] = field(default_factory=list)
breached: bool = False
peak_rate: float = 0.0
def record(self, sim_ms: float, value: float) -> None:
self.samples.append((sim_ms, float(value)))
horizon = sim_ms - self.window_ms
if self.samples and self.samples[0][0] < horizon:
self.samples = [s for s in self.samples if s[0] >= horizon]
if len(self.samples) >= 2:
t0, v0 = self.samples[0]
t1, v1 = self.samples[-1]
hours = (t1 - t0) / 3_600_000.0
if hours > 0:
rate = abs(v1 - v0) / hours
self.peak_rate = max(self.peak_rate, rate)
if rate > self.max_per_hour:
self.breached = True
class InvariantMonitor:
"""Per-tick invariant + rate-limit evaluation (FR-8.2.12 step 5)."""
def __init__(self, engine, scenario: dict) -> None:
self.engine = engine
self.invariants: list[Invariant] = []
self.rate_limits: list[RateLimit] = []
for spec in scenario.get("invariants", []) or []:
alias = str(spec["alias"])
if alias not in engine._aliases:
raise ValueError(f"invariant: unknown alias {alias!r}")
self.invariants.append(Invariant(
label=str(spec.get("label", f"{alias} {spec.get('condition')}")),
alias=alias,
condition=str(spec["condition"]),
))
for spec in scenario.get("rate_limits", []) or []:
alias = str(spec["alias"])
if alias not in engine._aliases:
raise ValueError(f"rate_limit: unknown alias {alias!r}")
self.rate_limits.append(RateLimit(
alias=alias,
max_per_hour=float(spec["max_per_hour"]),
))
self.episodes: list[Episode] = []
self.snapshot_every_ms = _parse_ms(scenario.get("snapshot_every", 0))
self.snapshots: list[dict] = []
self._last_snapshot_ms = 0.0
# -- per-tick ------------------------------------------------------------
def tick(self) -> None:
"""Evaluate invariants + rate limits; take snapshots if due.
Returns a failure detail string when a rate limit is breached
(the engine turns that into a FAIL); invariant episodes are
recorded and reported (they fail the run at the end).
"""
sim = self.engine.sim_time_ms
for inv in self.invariants:
value = self.engine.alias_value(inv.alias)
ok = inv.evaluate(value if not isinstance(value, bool) else int(value))
if not ok:
if inv.active is None:
inv.active = Episode(inv.label, sim, sim)
self.episodes.append(inv.active)
inv.active.ended_ms = sim
inv.active.samples += 1
# peak violation: magnitude from the nearest bound
inv.active.peak_violation = max(
inv.active.peak_violation, _violation(value, inv.condition))
elif inv.active is not None:
inv.active = None # episode closed; bounded record kept
for rl in self.rate_limits:
rl.record(sim, float(self.engine.alias_value(rl.alias)))
if self.snapshot_every_ms and sim - self._last_snapshot_ms >= self.snapshot_every_ms:
self._take_snapshot()
def _take_snapshot(self) -> None:
self._last_snapshot_ms = self.engine.sim_time_ms
self.snapshots.append({
"sim_time_ms": self.engine.sim_time_ms,
"wall_time": time.time(),
"aliases": self.engine._snapshot_aliases(),
"degradation": (self.engine.degradation.snapshot()
if getattr(self.engine, "degradation", None) else None),
})
@property
def rate_failure(self) -> str | None:
for rl in self.rate_limits:
if rl.breached:
return (f"rate limit: {rl.alias} peak {rl.peak_rate:.1f}/h "
f"exceeds {rl.max_per_hour}/h")
return None
def as_dict(self) -> dict:
return {
"episodes": [e.as_dict() for e in self.episodes],
"rate_limits": [
{"alias": r.alias, "max_per_hour": r.max_per_hour,
"peak_rate": r.peak_rate, "breached": r.breached}
for r in self.rate_limits
],
"snapshots": self.snapshots,
}
def _violation(value: float, condition: str) -> float:
"""Distance beyond the violated bound (for the episode peak)."""
text = condition.strip()
for op, sign in (("<=", 1), (">=", -1)):
if text.startswith(op):
ref = float(text[len(op):])
return abs(value - ref)
return abs(float(value))
def _parse_ms(value: str | int | float) -> float:
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().lower()
multipliers = {"ms": 1.0, "s": 1000.0, "m": 60000.0, "h": 3600000.0}
for suffix in ("ms", "s", "m", "h"):
if text.endswith(suffix):
return float(text[: -len(suffix)]) * multipliers[suffix]
return float(text)
+403
View File
@@ -0,0 +1,403 @@
"""PlantEnv: gymnasium-style plant environment (spec §8, FR-8.2.14-17).
Standalone-first: requires only a virtual node + datastore — no
scenario. The environment is the interface for continuous plant
simulation; per deterministic tick (runner-owned clock, never
wall-clock arithmetic):
state = env.observe() # ALL modules: DI/DO/AI/AO channels,
# engineering units, names, parameters
values = env.model(state, dt) # plant physics (subclass hook)
env.apply(values) # new FIELD state -> DI/AI inputs;
# DO/AO via env.force() (master-side)
`model()` is the override point for plant physics: read what the PLC
commanded (DO/AO), compute what the field now shows (DI/AI), return
it. `is_done(state)` gives an optional end criterion; the default is
False — environments run until manually stopped (SIGINT/SIGTERM).
Attachments: degradation (§8.3), invariants (§8.4) and aliases
(scenario-continuous mode) hook onto the same tick when the env is
constructed on an engine.
"""
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from ..modules import VirtualNode
from .engine import ScenarioError
if TYPE_CHECKING:
from .engine import ScenarioEngine
@dataclass
class ChannelState:
"""One channel of the observed plant state (engineering units)."""
kind: str # di | do | ai | ao
module: int # per-type module number (FR-4.3.11 v1.5)
channel: int # 0-based channel on the module
io_code: str # e.g. "DI0.0"
name: str | None # configured application name, if any
value: object # bool for di/do, int/float for ai/ao
range: tuple[int, int] | None = None # (min, max) raw counts — analog only
@dataclass
class PlantState:
"""Full observation: every channel of every module + attachments."""
sim_time_ms: float
channels: list[ChannelState] = field(default_factory=list)
aliases: dict[str, object] = field(default_factory=dict)
parameters: dict[str, float] = field(default_factory=dict)
def by_code(self, io_code: str) -> object:
"""Value of one channel by I/O code, or KeyError."""
for ch in self.channels:
if ch.io_code == io_code:
return ch.value
raise KeyError(io_code)
def by_kind(self, kind: str) -> dict[str, object]:
"""{io_code: value} for all channels of one kind."""
return {c.io_code: c.value for c in self.channels if c.kind == kind}
def channel(self, key: str) -> ChannelState:
"""Full ChannelState (name, range, ...) by I/O code or name.
Matches the I/O code first ("AI0.0"), then the configured
application name ("speed_setpoint", FR-10.2.11). KeyError
when neither matches — including a `None` name (unconfigured
channels match by code only).
"""
for ch in self.channels:
if ch.io_code == key:
return ch
for ch in self.channels:
if ch.name == key:
return ch
raise KeyError(key)
def channel_range(self, key: str) -> tuple[int, int]:
"""Display range (min, max) of an analog channel, by I/O code
or name (FR-10.2.14).
Always defined for AI/AO — the range store is dense — so
env models can unpack it without None-handling; digital
channels carry no range and raise KeyError.
"""
rng = self.channel(key).range
if rng is None:
raise KeyError(key)
return rng
class PlantEnv:
"""gym-style plant environment (FR-8.2.14-17).
Constructed on a VirtualNode (standalone) or on an engine (the
scenario-continuous layer). The runner owns the tick clock; `tick()`
advances sim-time deterministically by TICK_MS per call so both
paced (serve) and unpaced (sim) runners behave identically.
"""
TICK_MS = 10.0
def __init__(self, node: VirtualNode | None = None, *,
engine: ScenarioEngine | None = None) -> None:
if node is None:
if engine is None:
raise ScenarioError("PlantEnv needs a node or an engine")
self.node: VirtualNode = engine.node
else:
self.node = node
self.engine = engine
self.datastore = self.node.datastore
self.sim_time_ms = 0.0
# attachments (optional; the scenario layer wires these)
self.degradation = getattr(engine, "degradation", None)
self.invariants = getattr(engine, "invariants", None)
self._aliases = getattr(engine, "_aliases", None) if engine else None
self._alias_value: Callable[[str], object] | None = (
engine.alias_value if engine is not None else None)
# -- observation (FR-8.2.14) -----------------------------------------------
def observe(self) -> PlantState:
"""Snapshot ALL modules (DI/DO/AI/AO) in engineering units."""
node = self.node
ds = self.datastore
channels: list[ChannelState] = []
for kind, pool in (("di", node.di), ("do", node.do),
("ai", node.ai), ("ao", node.ao)):
for i, loc in enumerate(pool, start=1):
if kind == "di":
value = ds.digital_input_channel(i)
elif kind == "do":
value = ds.digital_output_channel(i)
elif kind == "ai":
value = ds.get_input_word(loc.word)
else:
value = ds.get_output_word(loc.word)
code = node.io_code_at_slot(
kind, loc.module_index, loc.channel - 1)
channels.append(ChannelState(
kind=kind,
module=node.kind_index(loc.module_index)[kind],
channel=loc.channel - 1,
io_code=code,
name=node.channel_names.get(code),
value=value,
range=node.channel_ranges[code]
if kind in ("ai", "ao") else None,
))
aliases = ({name: self._alias_value(name)
for name in (self._aliases or {})}
if self._alias_value else {})
parameters: dict[str, float] = {}
if self.degradation is not None:
parameters = {n: m.value
for n, m in self.degradation.models.items()}
return PlantState(
sim_time_ms=self.sim_time_ms,
channels=channels,
aliases=aliases,
parameters=parameters,
)
# -- plant model hook (FR-8.2.16) -------------------------------------------
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
"""Plant physics: react to PLC commands, compute the new field.
Override in a subclass (FR-8.2.17). Receives the full observed
state and the tick delta in seconds; returns {I/O code or alias:
new value} for the channels it drives — DI/AI normally (the
simulated sensors); DO/AO entries are treated as forcing and go
through the master-side write paths. Returning {} (the default)
makes this a pure observation environment.
"""
return {}
def is_done(self, state: PlantState) -> bool:
"""Optional end criterion; default runs until manually stopped."""
return False
# -- actions (FR-8.2.15) ------------------------------------------------------
def apply(self, values: dict[str, object], *, force: bool = False) -> None:
"""Write new channel values into the process images.
DI/AI keys (the field) write directly — this is the primary
direction. DO/AO keys (PLC outputs) require force=True and go
through the datastore's internal Modbus dispatch so mirrors,
statistics, and watchdog stay consistent (FR-9.2.6) — for
headless runs and fault setup only.
Keys: I/O code ("AI0.0") or alias name. Unknown keys raise
immediately (FR-8.2.16).
"""
if not isinstance(values, dict):
raise ScenarioError("apply() values must be a mapping")
for key, value in values.items():
self._write_one(key, value, force=force)
def force(self, values: dict[str, object]) -> None:
"""Master-side forcing of DO/AO (FR-8.2.15 explicit form)."""
self.apply(values, force=True)
def _resolve(self, key: str):
"""-> (kind, ChannelLocation, alias_or_None) for a code or alias."""
# alias name first (scenario layer)
if self._aliases and key in self._aliases and self.engine is not None:
alias = self._aliases[key]
return alias.kind, self.engine._alias_location(alias), alias
# any channel by I/O code (per-type codes, FR-4.3.11 v1.5)
code_up = str(key).strip().upper()
node = self.node
for kind, pool in (("di", node.di), ("do", node.do),
("ai", node.ai), ("ao", node.ao)):
for loc in pool:
if node.io_code_at_slot(
kind, loc.module_index, loc.channel - 1) == code_up:
return kind, loc, None
raise ScenarioError(f"apply(): unknown channel {key!r}")
def _write_one(self, key: str, value: object, *, force: bool) -> None:
kind, loc, alias = self._resolve(key)
ds = self.datastore
if kind in ("do", "ao") and not force:
raise ScenarioError(
f"apply(): {key!r} is an output ({kind.upper()}); use "
"force=True / env.force() for master-side forcing")
scale = getattr(alias, "scale", 1.0) or 1.0
offset = getattr(alias, "offset", 0.0) or 0.0
if kind == "di":
ds.set_digital_input_channel(loc.bit + 1, bool(value))
elif kind == "ai":
raw = int(float(value) * scale + offset) & 0xFFFF # type: ignore[arg-type]
ds.set_input_word(loc.word, raw)
elif kind == "do":
result = ds.write_bits(loc.bit, [bool(value)])
if isinstance(result, Exception):
raise ScenarioError(f"force() write to {key!r}: {result}")
else: # ao
raw = int(float(value) * scale + offset) & 0xFFFF # type: ignore[arg-type]
result = ds.write_words(loc.word, [raw])
if isinstance(result, Exception):
raise ScenarioError(f"force() write to {key!r}: {result}")
# -- one full tick (FR-8.2.14 cycle) ---------------------------------------------
def tick(self, dt_ms: float | None = None) -> PlantState:
"""One environment tick: model() -> apply(field) -> advance.
Deterministic: sim-time advances by TICK_MS (or dt_ms) per call
— never from wall-clock. Attachments advance with the tick:
degradation (§8.3) then invariants (§8.4), FR-8.2.12 order.
"""
dt = dt_ms if dt_ms is not None else self.TICK_MS
state = self.observe()
if self.is_done(state):
return state
values = self.model(state, dt / 1000.0)
if values:
self.apply(values)
self.sim_time_ms += dt
if self.degradation is not None:
self.degradation.tick(dt)
if self.invariants is not None:
self.invariants.tick()
return self.observe()
# -- episode control (FR-8.2.16) ------------------------------------------------
def reset(self) -> PlantState:
"""Fresh episode: zero images, reset wear + parameters + clock
(deterministic — identical runs replay identically, NFR-8.7.3)."""
self.datastore.reset_images()
self.sim_time_ms = 0.0
if self.degradation is not None:
for model in self.degradation.models.values():
model.reset()
model.diagnosed = False
self.degradation._last_truth = {}
if self.invariants is not None:
self.invariants.episodes = []
for inv in self.invariants.invariants:
inv.active = None
for rl in self.invariants.rate_limits:
rl.samples = []
rl.breached = False
rl.peak_rate = 0.0
self.invariants.snapshots = []
self.invariants._last_snapshot_ms = 0.0
return self.observe()
# -- clock (FR-8.2.16) ---------------------------------------------------------
@property
def tick_ms(self) -> float:
return self.TICK_MS
@property
def parameters(self) -> dict[str, float]:
if self.degradation is None:
return {}
return {n: m.value for n, m in self.degradation.models.items()}
def load_env(ref: str, node: VirtualNode | None = None, *,
engine=None, reload: bool = False) -> PlantEnv:
"""Load an environment from a module reference (FR-8.2.17).
Accepts ``pkg.mod.ClassName``, ``pkg.mod.factory_fn`` (returns a
PlantEnv), or a bare ``pkg.mod`` whose module defines either a
class named ``Env`` or a function ``make_env(...)``.
``reload=True`` (FR-10.2.17) invalidates the cached module first:
Python keeps modules in ``sys.modules``, so re-importing an
edited file would silently reuse the old class. Used by the web
UI's start/restart; the CLI runners start a fresh process anyway
and pass the default.
"""
import importlib
import sys
# environments resolve from the WORKING DIRECTORY by contract
# (``envs/`` is not part of the installed package). A console
# script puts its Scripts dir — not the cwd — on sys.path, so
# make the cwd importable before trying (idempotent).
cwd = os.getcwd()
if cwd not in sys.path:
sys.path.insert(0, cwd)
parts = ref.split(":")
attr_path = None
mod = None
leaf = None
if len(parts) == 2:
# explicit module:attr form — ref is the WHOLE module path
attr_path = parts[1] or None
mod_name = parts[0]
else:
# no colon: try the whole ref as a module first, then as
# pkg.mod containing an attr (e.g. ...:Class omitted)
try:
mod = importlib.import_module(ref)
mod_name = ref
except ImportError:
mod_name, _, leaf = ref.rpartition(".")
if not mod_name or ("." not in mod_name and attr_path is None
and leaf is None):
raise ScenarioError(f"--env {ref!r}: reference must include a module path")
if reload and mod_name in sys.modules:
# hot reload (FR-10.2.17): drop the cached module so the
# import below reads the file from disk again
sys.modules.pop(mod_name, None)
mod = None
if mod is None:
try:
mod = importlib.import_module(mod_name)
except ImportError as exc:
raise ScenarioError(
f"--env {ref!r}: cannot import {mod_name!r}: {exc}")
obj = None
if attr_path is not None:
# explicit module:attr — must exist; never fall back silently
if not hasattr(mod, attr_path):
raise ScenarioError(
f"--env {ref!r}: no {attr_path!r} in {mod_name!r}")
obj = getattr(mod, attr_path)
elif leaf and hasattr(mod, leaf):
obj = getattr(mod, leaf)
elif hasattr(mod, "Env"):
obj = getattr(mod, "Env")
elif hasattr(mod, "make_env"):
obj = getattr(mod, "make_env")
else:
raise ScenarioError(
f"--env {ref!r}: no environment found in {mod_name!r} "
"(expected a class, Env, or make_env())")
env: PlantEnv
if isinstance(obj, type) and issubclass(obj, PlantEnv):
env = obj(node=node, engine=engine)
elif callable(obj):
made = obj(node=node, engine=engine) if node is not None \
else obj(engine=engine)
if not isinstance(made, PlantEnv):
raise ScenarioError(f"--env {ref!r}: did not return a PlantEnv")
env = made
else:
raise ScenarioError(
f"--env {ref!r}: not a PlantEnv subclass or factory")
return env
+6 -1
View File
@@ -22,7 +22,8 @@ def result_summary(result: ScenarioResult) -> str:
return "\n".join(lines) return "\n".join(lines)
def write_jsonl(result: ScenarioResult, path: str | Path) -> None: def write_jsonl(result: ScenarioResult, path: str | Path,
extra: dict | None = None) -> None:
path = Path(path) path = Path(path)
with path.open("w", encoding="utf-8") as fh: with path.open("w", encoding="utf-8") as fh:
fh.write(json.dumps({ fh.write(json.dumps({
@@ -39,6 +40,10 @@ def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
"detail": step.detail, "detail": step.detail,
"sim_time_ms": step.sim_time_ms, "sim_time_ms": step.sim_time_ms,
}) + "\n") }) + "\n")
if extra:
# continuous-run artifacts (spec §8.4): episodes, rate-limit
# peaks, and snapshots appended as a summary record
fh.write(json.dumps({"type": "continuous", **extra}) + "\n")
def write_csv(result: ScenarioResult, path: str | Path) -> None: def write_csv(result: ScenarioResult, path: str | Path) -> None:
+136 -14
View File
@@ -14,7 +14,8 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from ..app import SimulatorApp, rebuild_node from ..app import SimulatorApp, rebuild_node
from ..modules import CATALOG, ChannelMismatch, ModuleUnknown, resolve_module from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode,
resolve_module)
from ..scenario.engine import ScenarioError from ..scenario.engine import ScenarioError
from ..watchdog import RegisterWriteRefused from ..watchdog import RegisterWriteRefused
from . import state from . import state
@@ -417,7 +418,7 @@ async def put_module_config(
save_node_config(sim.node_config_path, modules, save_node_config(sim.node_config_path, modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=sim.node.channel_names, channel_names=sim.node.channel_names,
channel_ranges=sim.node.channel_ranges) channel_ranges=_nondefault_ranges(sim))
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
@@ -485,6 +486,7 @@ async def put_channel_names(
sim.node_config_path, sim.node.modules, sim.node_config_path, sim.node.modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=names, channel_names=names,
channel_ranges=sim.node.channel_ranges,
) )
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
@@ -520,8 +522,10 @@ async def put_channel_ranges(
"""Set per-channel analog display ranges (FR-10.2.14). """Set per-channel analog display ranges (FR-10.2.14).
Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}. Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}.
Codes are regex-validated; 0 <= min < max <= 65535 required; an Codes are regex-validated; -65534 <= min < max <= 65535 required.
entry with min=0/max=4095 (the default) is dropped from the store. The runtime store is DENSE (every analog channel present, default
0-4095 materialized); default-equal entries are kept in the store
but omitted from the persisted YAML (lean config).
""" """
_require_write(request, sessions) _require_write(request, sessions)
raw = payload.get("channel_ranges") raw = payload.get("channel_ranges")
@@ -550,10 +554,9 @@ async def put_channel_ranges(
status_code=400, status_code=400,
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535", detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535",
) )
if (lo, hi) == (0, 4095): ranges[code_up] = (lo, hi) # dense store keeps defaults
continue # default — keep the store clean sim.node.set_channel_ranges(ranges)
ranges[code_up] = (lo, hi) persisted = _nondefault_ranges(sim)
sim.node.channel_ranges = ranges
if payload.get("persist", True) and sim.node_config_path: if payload.get("persist", True) and sim.node_config_path:
from .persist import save_node_config from .persist import save_node_config
try: try:
@@ -561,7 +564,7 @@ async def put_channel_ranges(
sim.node_config_path, sim.node.modules, sim.node_config_path, sim.node.modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=sim.node.channel_names, channel_names=sim.node.channel_names,
channel_ranges=ranges, channel_ranges=persisted,
) )
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
@@ -570,10 +573,119 @@ async def put_channel_ranges(
) from None ) from None
return { return {
"ok": True, "ok": True,
"channel_ranges": {c: {"min": lo, "max": hi} for c, (lo, hi) in ranges.items()}, "channel_ranges": {c: {"min": lo, "max": hi}
for c, (lo, hi) in persisted.items()},
} }
# -- plant environment control (FR-10.2.15 / FR-10.2.17) -----------------------
@router.get("/api/env")
async def get_env(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> dict:
"""Environment status: ref, state, sim-time, tick ms."""
return sim.runner.status()
@router.post("/api/env/start")
async def env_start(
payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Load + start an environment by module ref (FR-10.2.15).
Payload: {"ref": "envs.pump_station_env:PumpStationEnv"}.
Refused while an env is loaded (stop first); the module is
hot-reloaded (FR-10.2.17) so edited source runs.
"""
_require_write(request, sessions)
ref = str(payload.get("ref", "")).strip()
if not ref:
raise HTTPException(status_code=400, detail="'ref' is required")
try:
sim.runner.start(ref)
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/stop")
async def env_stop(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Cancel the ticker and unload the env (field freezes)."""
_require_write(request, sessions)
await sim.runner.stop()
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/pause")
async def env_pause(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Pause: ticker cancelled, instance kept (plant-stuck fault)."""
_require_write(request, sessions)
try:
await sim.runner.pause()
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/resume")
async def env_resume(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Resume a paused env (reschedules the ticker)."""
_require_write(request, sessions)
try:
await sim.runner.resume()
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/reset")
async def env_reset(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Deterministic fresh episode (NFR-8.7.3); ticker keeps running."""
_require_write(request, sessions)
try:
sim.runner.reset()
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/restart")
async def env_restart(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Fresh instance of the same ref (picks up edited source)."""
_require_write(request, sessions)
try:
await sim.runner.restart()
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
# -- configuration import/export (FR-10.2.12) --------------------------------- # -- configuration import/export (FR-10.2.12) ---------------------------------
@@ -593,6 +705,14 @@ def _coupler_snapshot(sim: SimulatorApp) -> dict:
} }
def _nondefault_ranges(sim: SimulatorApp) -> dict[str, tuple[int, int]]:
"""Dense store minus default entries — for YAML persistence and
the config export (lean config: only explicit overrides).
"""
return {code: rng for code, rng in sim.node.channel_ranges.items()
if rng != VirtualNode.DEFAULT_RANGE}
@router.get("/api/config/export") @router.get("/api/config/export")
async def export_config( async def export_config(
sim: SimulatorApp = Depends(get_sim), sim: SimulatorApp = Depends(get_sim),
@@ -606,7 +726,7 @@ async def export_config(
"modules": [m.article for m in sim.node.modules], "modules": [m.article for m in sim.node.modules],
"channel_names": dict(sim.node.channel_names), "channel_names": dict(sim.node.channel_names),
"channel_ranges": {c: {"min": lo, "max": hi} "channel_ranges": {c: {"min": lo, "max": hi}
for c, (lo, hi) in sim.node.channel_ranges.items()}, for c, (lo, hi) in _nondefault_ranges(sim).items()},
"coupler_settings": _coupler_snapshot(sim), "coupler_settings": _coupler_snapshot(sim),
} }
@@ -663,11 +783,13 @@ async def import_config(
if not -65534 <= lo < hi <= 65535: if not -65534 <= lo < hi <= 65535:
raise HTTPException(status_code=400, raise HTTPException(status_code=400,
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535") detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535")
if (lo, hi) != (0, 4095): ranges[code_up] = (lo, hi) # defaults kept; the store is dense
ranges[code_up] = (lo, hi)
coupler = payload.get("coupler_settings") or {} coupler = payload.get("coupler_settings") or {}
# -- everything validated: apply atomically ------------------------- # -- everything validated: apply atomically -------------------------
# a loaded env binds to the old layout — discard before rebuilding
# (FR-10.2.15; same rule as SAVE CONFIG)
await sim.runner.discard()
rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges) rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
stale = sim.node.validate_channel_names() stale = sim.node.validate_channel_names()
if stale: if stale:
@@ -703,7 +825,7 @@ async def import_config(
sim.node_config_path, modules, sim.node_config_path, modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=names, channel_names=names,
channel_ranges=ranges, channel_ranges=_nondefault_ranges(sim),
) )
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
+12 -5
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from ..modules import ModuleType from ..modules import ModuleType, VirtualNode
_HEADER = """\ _HEADER = """\
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG) # Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
@@ -52,8 +52,15 @@ def save_node_config(
for code in sorted(channel_names): for code in sorted(channel_names):
lines.append(f" {code}: {channel_names[code]}\n") lines.append(f" {code}: {channel_names[code]}\n")
if channel_ranges: if channel_ranges:
lines.append(_RANGES_HEADER) # lean YAML: the runtime store is dense (every analog channel,
for code in sorted(channel_ranges): # FR-10.2.14) — persist only explicit, non-default entries so a
lo, hi = channel_ranges[code] # hand-edited config stays minimal and defaults survive a
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n") # future DEFAULT_RANGE change
explicit = {code: rng for code, rng in channel_ranges.items()
if rng != VirtualNode.DEFAULT_RANGE}
if explicit:
lines.append(_RANGES_HEADER)
for code in sorted(explicit):
lo, hi = explicit[code]
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n")
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n") Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
+22 -15
View File
@@ -16,15 +16,16 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
modules_json = [] modules_json = []
for index, mod in enumerate(node.modules): for index, mod in enumerate(node.modules):
channels = [] channels = []
# per-type module number for this slot (FR-4.3.11 v1.5):
# a DI module at physical slot 2 with no earlier DI module is DI0
per_type = node.kind_index(index)
for ch in mod.channels: for ch in mod.channels:
kind_upper = ch.kind.upper()
# module-based addressing per FR-4.3.11: {KIND}{module}.{channel}
# `module` is the 0-based slot; `channel` is 0-based on module.
channel_no = int(ch.name.split()[-1]) - 1 channel_no = int(ch.name.split()[-1]) - 1
io_code = f"{kind_upper}{index}.{channel_no}" io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}"
lo, hi = (0, 4095) # dense range store (FR-10.2.14): every analog channel has
if ch.kind in ("ai", "ao"): # an entry (defaults materialized at layout time)
lo, hi = node.channel_ranges.get(io_code, (0, 4095)) lo, hi = node.channel_ranges[io_code] \
if ch.kind in ("ai", "ao") else (0, 4095)
channels.append( channels.append(
{ {
"name": ch.name, "name": ch.name,
@@ -75,23 +76,27 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
out: list[ChannelValue] = [] out: list[ChannelValue] = []
# Digital channels: global 1-based numbers, bit-packed after analog. # Digital channels: global 1-based numbers, bit-packed after analog.
# `module` in each entry is the PER-TYPE module number (FR-4.3.11
# v1.5) so the UI round-trips the same number the codes carry.
for i, loc in enumerate(node.di, start=1): for i, loc in enumerate(node.di, start=1):
code = node.io_code("di", loc.module_index, loc.channel - 1) kind_no = node.kind_index(loc.module_index)["di"]
code = node.io_code_at_slot("di", loc.module_index, loc.channel - 1)
out.append( out.append(
{ {
"kind": "di", "kind": "di",
"module": loc.module_index, "module": kind_no,
"channel": loc.channel - 1, "channel": loc.channel - 1,
"value": ds.digital_input_channel(i), "value": ds.digital_input_channel(i),
"name": node.channel_names.get(code), "name": node.channel_names.get(code),
} }
) )
for i, loc in enumerate(node.do, start=1): for i, loc in enumerate(node.do, start=1):
code = node.io_code("do", loc.module_index, loc.channel - 1) kind_no = node.kind_index(loc.module_index)["do"]
code = node.io_code_at_slot("do", loc.module_index, loc.channel - 1)
out.append( out.append(
{ {
"kind": "do", "kind": "do",
"module": loc.module_index, "module": kind_no,
"channel": loc.channel - 1, "channel": loc.channel - 1,
"value": ds.digital_output_channel(i), "value": ds.digital_output_channel(i),
"name": node.channel_names.get(code), "name": node.channel_names.get(code),
@@ -102,22 +107,24 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
# FR-10.2.14), the raw word is shown as its 16-bit two's-complement # FR-10.2.14), the raw word is shown as its 16-bit two's-complement
# value so the UI, the bar, and the configured range agree. # value so the UI, the bar, and the configured range agree.
for loc in node.ai: for loc in node.ai:
code = node.io_code("ai", loc.module_index, loc.channel - 1) kind_no = node.kind_index(loc.module_index)["ai"]
code = node.io_code_at_slot("ai", loc.module_index, loc.channel - 1)
out.append( out.append(
{ {
"kind": "ai", "kind": "ai",
"module": loc.module_index, "module": kind_no,
"channel": loc.channel - 1, "channel": loc.channel - 1,
"value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)), "value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)),
"name": node.channel_names.get(code), "name": node.channel_names.get(code),
} }
) )
for loc in node.ao: for loc in node.ao:
code = node.io_code("ao", loc.module_index, loc.channel - 1) kind_no = node.kind_index(loc.module_index)["ao"]
code = node.io_code_at_slot("ao", loc.module_index, loc.channel - 1)
out.append( out.append(
{ {
"kind": "ao", "kind": "ao",
"module": loc.module_index, "module": kind_no,
"channel": loc.channel - 1, "channel": loc.channel - 1,
"value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)), "value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)),
"name": node.channel_names.get(code), "name": node.channel_names.get(code),
+199 -14
View File
@@ -28,9 +28,90 @@ async function init() {
renderRail(); renderRail();
renderChannelList(); renderChannelList();
if (diag) renderDiagnostics(diag); if (diag) renderDiagnostics(diag);
refreshEnvCard();
openWS(); openWS();
} }
// ---------------- plant environment control (FR-10.2.15) ----------------
let ENV = null; // last /api/env status
async function refreshEnvCard() {
ENV = await api("/api/env").catch(() => null);
if (ENV) renderEnvCard(ENV);
}
function renderEnvCard(env) {
const body = document.getElementById("env-body");
if (!body) return;
const state = env.state || "none";
const chip = `<span class="env-state st-${state}">${state}</span>`;
const time = env.sim_time_ms != null
? `${(env.sim_time_ms / 1000).toFixed(1)} s` : "—";
const tick = env.tick_ms != null ? `${env.tick_ms} ms` : "—";
if (state === "none") {
body.innerHTML = `
<p class="cfg-note">No environment loaded.</p>
${READONLY ? "" : `
<div class="fault-buttons">
<input type="text" id="env-ref" class="env-ref"
placeholder="envs.pump_station_env:PumpStationEnv">
<button id="env-start" class="primary">Start</button>
</div>`}`;
} else {
const canPause = state === "running";
const canResume = state === "paused";
const canReset = !READONLY && (state === "running" || state === "paused");
body.innerHTML = `
<p><b>${env.ref}</b> &nbsp;${chip}</p>
<p class="cfg-note">sim-time ${time} · tick ${tick}
${env.reason ? ` · done: ${env.reason}` : ""}</p>
${READONLY ? "" : `
<div class="fault-buttons">
<button id="env-pause" ${canPause ? "" : "disabled"}>Pause</button>
<button id="env-resume" ${canResume ? "" : "disabled"}>Resume</button>
<button id="env-reset" ${canReset ? "" : "disabled"}>Reset</button>
<button id="env-restart">Restart</button>
<button id="env-stop">Stop</button>
</div>`}`;
}
const msg = document.getElementById("env-msg");
msg.classList.add("hidden");
bindEnvButtons();
}
function bindEnvButtons() {
const on = (id, fn) => {
const el = document.getElementById(id);
if (el) el.addEventListener("click", fn);
};
on("env-start", async () => {
const ref = document.getElementById("env-ref").value.trim();
await envAction("start", {ref});
});
on("env-stop", () => envAction("stop"));
on("env-pause", () => envAction("pause"));
on("env-resume", () => envAction("resume"));
on("env-reset", () => envAction("reset"));
on("env-restart", () => envAction("restart"));
}
async function envAction(name, payload) {
const msg = document.getElementById("env-msg");
msg.classList.add("hidden");
try {
await api(`/api/env/${name}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload || {}),
});
await refreshEnvCard();
} catch (e) {
msg.textContent = e.message;
msg.classList.remove("hidden");
}
}
function openWS() { function openWS() {
const proto = location.protocol === "https:" ? "wss" : "ws"; const proto = location.protocol === "https:" ? "wss" : "ws";
WS = new WebSocket(`${proto}://${location.host}/api/ws`); WS = new WebSocket(`${proto}://${location.host}/api/ws`);
@@ -75,19 +156,20 @@ function renderChannelList() {
for (const mod of NODE.modules) { for (const mod of NODE.modules) {
for (const ch of mod.channels) { for (const ch of mod.channels) {
const no = channelNo(ch.address); const no = channelNo(ch.address);
const id = `row-${ch.kind}-${mod.index}-${no}`; const mno = moduleNo(ch.address); // per-type module number
const id = `row-${ch.kind}-${mno}-${no}`;
const named = ch.channel_name ? `<span class="row-name">${ch.channel_name}</span>` const named = ch.channel_name ? `<span class="row-name">${ch.channel_name}</span>`
: `<span class="row-name unnamed">—</span>`; : `<span class="row-name unnamed">—</span>`;
let control; let control;
if (ch.kind === "di" || ch.kind === "do") { if (ch.kind === "di" || ch.kind === "do") {
control = `<label class="switch"><input type="checkbox" class="row-value" control = `<label class="switch"><input type="checkbox" class="row-value"
id="val-${ch.kind}-${mod.index}-${no}" id="val-${ch.kind}-${mno}-${no}"
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)"> onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">
<span class="slider"></span></label>`; <span class="slider"></span></label>`;
} else { } else {
control = `<input type="number" class="row-value num" min="-65534" max="65535" control = `<input type="number" class="row-value num" min="-65534" max="65535"
id="val-${ch.kind}-${mod.index}-${no}" id="val-${ch.kind}-${mno}-${no}"
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`; onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">`;
} }
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}"> html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
<span class="row-io">${ch.address}</span> <span class="row-io">${ch.address}</span>
@@ -253,7 +335,6 @@ async function importConfig(file) {
function renderRail() { function renderRail() {
const rail = document.getElementById("rail"); const rail = document.getElementById("rail");
rail.innerHTML = ""; rail.innerHTML = "";
// coupler block on the left // coupler block on the left
const coupler = document.createElement("div"); const coupler = document.createElement("div");
coupler.className = "coupler"; coupler.className = "coupler";
@@ -293,7 +374,7 @@ function renderRail() {
html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`; html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`;
let chIndex = 0; let chIndex = 0;
for (const ch of mod.channels) { for (const ch of mod.channels) {
const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`; const id = `ch-${ch.kind}-${moduleNo(ch.address)}-${channelNo(ch.address)}`;
const chNo = String(channelNo(ch.address)); // 0-based channel only const chNo = String(channelNo(ch.address)); // 0-based channel only
if (isDigital) { if (isDigital) {
if (chIndex > 0 && chIndex % 8 === 0) { if (chIndex > 0 && chIndex % 8 === 0) {
@@ -302,7 +383,7 @@ function renderRail() {
} }
html += `<span class="led-cell"> html += `<span class="led-cell">
<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})" <span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span> onclick="clickChannel('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)})"></span>
<span class="led-label">${chNo}</span></span>`; <span class="led-label">${chNo}</span></span>`;
chIndex += 1; chIndex += 1;
} else { } else {
@@ -312,7 +393,8 @@ function renderRail() {
html += `<span class="abar-cell"> html += `<span class="abar-cell">
<span class="abar-label top-label">${chNo}</span> <span class="abar-label top-label">${chNo}</span>
<span class="lim-led lim-over" id="${id}-over" title="overflow (&gt; ${rg.max})"></span> <span class="lim-led lim-over" id="${id}-over" title="overflow (&gt; ${rg.max})"></span>
<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"> <span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
onclick="clickBar('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)}, event)">
<span class="abar-fill"></span></span> <span class="abar-fill"></span></span>
<span class="lim-led lim-under" id="${id}-under" title="underflow (&lt; ${rg.min})"></span> <span class="lim-led lim-under" id="${id}-under" title="underflow (&lt; ${rg.min})"></span>
<span class="abar-label bottom-label">${chNo}</span> <span class="abar-label bottom-label">${chNo}</span>
@@ -336,6 +418,13 @@ function channelNo(address) {
return dot >= 0 ? Number(address.slice(dot + 1)) : 0; return dot >= 0 ? Number(address.slice(dot + 1)) : 0;
} }
function moduleNo(address) {
// address is like "DI2.1" — the module part before the dot;
// per FR-4.3.11 v1.5 this is the PER-TYPE module number (DI0, DO0…)
const dot = address.indexOf(".");
return dot >= 0 ? Number(address.slice(2, dot)) : 0;
}
function updateChannel(kind, module, channel, value) { function updateChannel(kind, module, channel, value) {
const id = `ch-${kind}-${module}-${channel}`; const id = `ch-${kind}-${module}-${channel}`;
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -370,9 +459,12 @@ function updateChannel(kind, module, channel, value) {
function findChannel(kind, module, channel) { function findChannel(kind, module, channel) {
if (!NODE) return null; if (!NODE) return null;
for (const mod of NODE.modules) { for (const mod of NODE.modules) {
if (mod.index !== module) continue; for (const c of mod.channels) {
return mod.channels.find((c) => c.kind === kind && if (c.kind === kind && moduleNo(c.address) === module
channelNo(c.address) === channel) || null; && channelNo(c.address) === channel) {
return c;
}
}
} }
return null; return null;
} }
@@ -392,6 +484,28 @@ async function clickChannel(kind, module, channel) {
} }
} }
async function clickBar(kind, module, channel, ev) {
if (READONLY) return;
const bar = ev.currentTarget;
const rect = bar.getBoundingClientRect();
// click height -> fraction of the bar: bottom = range min, top = max
const frac = Math.min(1, Math.max(0, 1 - (ev.clientY - rect.top) / rect.height));
const chInfo = findChannel(kind, module, channel);
const rg = (chInfo && chInfo.range) || {min: 0, max: 4095};
const value = Math.round(rg.min + frac * (rg.max - rg.min));
try {
await api(`/api/channels/${kind}/${module}/${channel}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({action: "set", value}),
});
const res = await api("/api/values");
applyValues(res.values);
} catch (e) {
console.warn("write rejected:", e.message);
}
}
// ---------------- diagnostics ---------------- // ---------------- diagnostics ----------------
async function renderDiagnostics(diag) { async function renderDiagnostics(diag) {
@@ -495,13 +609,25 @@ async function renderConfig() {
renderNameEditor(); renderNameEditor();
const list = document.getElementById("module-list"); const list = document.getElementById("module-list");
list.innerHTML = mc.modules.map((m, i) => ` list.innerHTML = mc.modules.map((m, i) => `
<label class="module-row"> <label class="module-row" draggable="true"
ondragstart="dragStart(event, ${i})"
ondragover="dragOver(event, ${i})"
ondrop="drop(event, ${i})"
ondragend="dragEnd()">
<span class="drag-handle" title="Drag to rearrange">&#9776;</span>
<input type="radio" name="mod" value="${i}"> <input type="radio" name="mod" value="${i}">
<b>${i}</b>&nbsp; ${m.article} — ${m.description} <b>${i}</b>&nbsp; ${m.article} — ${m.description}
<span class="mod-io">${m.input_bits ? m.input_bits + " DI" : ""} <span class="mod-io">${m.input_bits ? m.input_bits + " DI" : ""}
${m.output_bits ? m.output_bits + " DO" : ""} ${m.output_bits ? m.output_bits + " DO" : ""}
${m.input_words ? m.input_words + " AIw" : ""} ${m.input_words ? m.input_words + " AIw" : ""}
${m.output_words ? m.output_words + " AOW" : ""}</span> ${m.output_words ? m.output_words + " AOW" : ""}</span>
<span class="row-buttons">
<button class="mini" onclick="moveModule(${i}, -1)"
${i === 0 ? "disabled" : ""} title="Move up">&#9650;</button>
<button class="mini" onclick="moveModule(${i}, +1)"
${i === mc.modules.length - 1 ? "disabled" : ""}
title="Move down">&#9660;</button>
</span>
</label>`).join(""); </label>`).join("");
const pick = document.getElementById("catalog-pick"); const pick = document.getElementById("catalog-pick");
pick.innerHTML = mc.catalog.map((c) => pick.innerHTML = mc.catalog.map((c) =>
@@ -597,18 +723,77 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}); });
document.getElementById("module-save").addEventListener("click", saveModules); document.getElementById("module-save").addEventListener("click", saveModules);
setInterval(refreshEnvCard, 1000); // env status: 1 Hz
}); });
function renderModuleRows() { function renderModuleRows() {
// local edit view until SAVE CONFIG is pressed // local edit view until SAVE CONFIG is pressed
const list = document.getElementById("module-list"); const list = document.getElementById("module-list");
list.innerHTML = MODULE_ARTICLES.map((a, i) => ` list.innerHTML = MODULE_ARTICLES.map((a, i) => `
<label class="module-row"> <label class="module-row" draggable="true"
ondragstart="dragStart(event, ${i})"
ondragover="dragOver(event, ${i})"
ondrop="drop(event, ${i})"
ondragend="dragEnd()">
<span class="drag-handle" title="Drag to rearrange">&#9776;</span>
<input type="radio" name="mod" value="${i}"> <input type="radio" name="mod" value="${i}">
<b>${i}</b>&nbsp; ${a} <b>${i}</b>&nbsp; ${a}
<span class="row-buttons">
<button class="mini" onclick="moveModule(${i}, -1)"
${i === 0 ? "disabled" : ""} title="Move up">&#9650;</button>
<button class="mini" onclick="moveModule(${i}, +1)"
${i === MODULE_ARTICLES.length - 1 ? "disabled" : ""}
title="Move down">&#9660;</button>
</span>
</label>`).join(""); </label>`).join("");
} }
async function moveModule(index, delta) {
// FR-11.2.6: reorder by swapping with the neighbor, then SAVE CONFIG
// (rebuild + persist) so the rail reflects the new physical order
const target = index + delta;
if (target < 0 || target >= MODULE_ARTICLES.length) return;
[MODULE_ARTICLES[index], MODULE_ARTICLES[target]] =
[MODULE_ARTICLES[target], MODULE_ARTICLES[index]];
await saveModules();
}
// -- drag-and-drop reordering (FR-11.2.6) ----------------
let DRAG_FROM = null;
function dragStart(ev, index) {
DRAG_FROM = index;
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", String(index));
ev.currentTarget.classList.add("dragging");
}
function dragOver(ev, index) {
if (DRAG_FROM === null || DRAG_FROM === index) return;
ev.preventDefault(); // allow the drop
ev.dataTransfer.dropEffect = "move";
ev.currentTarget.classList.add("drop-target");
}
async function drop(ev, index) {
ev.preventDefault();
ev.currentTarget.classList.remove("drop-target");
if (DRAG_FROM === null || DRAG_FROM === index) return;
// move the dragged module to the drop position (splice semantics:
// remove, then insert at the target index)
const [moved] = MODULE_ARTICLES.splice(DRAG_FROM, 1);
MODULE_ARTICLES.splice(index, 0, moved);
DRAG_FROM = null;
await saveModules(); // rebuild + persist, like arrows
}
function dragEnd() {
DRAG_FROM = null;
document.querySelectorAll(".module-row").forEach((el) =>
el.classList.remove("dragging", "drop-target"));
}
// ---------------- tabs ---------------- // ---------------- tabs ----------------
document.querySelectorAll(".tab").forEach((btn) => { document.querySelectorAll(".tab").forEach((btn) => {
+8 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>WAGO 750-362 Simulator</title> <title>WAGO 750-362 Simulator</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css?v=4">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -33,6 +33,12 @@
<div id="rail" class="rail"></div> <div id="rail" class="rail"></div>
<div id="rail-detail" class="rail-detail"></div> <div id="rail-detail" class="rail-detail"></div>
<div class="diag-card env-card" id="env-card">
<h3>Plant environment</h3>
<div id="env-body" class="cfg-note">loading…</div>
<div id="env-msg" class="error hidden"></div>
</div>
<h3 class="list-title">Channels</h3> <h3 class="list-title">Channels</h3>
<div class="chlist-head"> <div class="chlist-head">
<span>I/O</span><span>Name</span><span>Value</span> <span>I/O</span><span>Name</span><span>Value</span>
@@ -111,6 +117,6 @@
</section> </section>
</main> </main>
<script src="/static/app.js"></script> <script src="/static/app.js?v=4"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>WAGO Simulator — Login</title> <title>WAGO Simulator — Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css?v=4">
</head> </head>
<body class="login-body"> <body class="login-body">
<div class="login-card"> <div class="login-card">
+40
View File
@@ -190,6 +190,7 @@ h2 { margin: 4px 0 14px; }
border-radius: 3px; border-radius: 3px;
overflow: hidden; /* clip the fill inside the rounded bar */ overflow: hidden; /* clip the fill inside the rounded bar */
display: inline-block; display: inline-block;
cursor: pointer; /* clickable: sets value at click height */
} }
.abar-fill { .abar-fill {
position: absolute; bottom: 0; left: 0; right: 0; height: 0%; position: absolute; bottom: 0; left: 0; right: 0; height: 0%;
@@ -237,6 +238,24 @@ h2 { margin: 4px 0 14px; }
} }
.diag-card button:hover { background: #004a99; } .diag-card button:hover { background: #004a99; }
.fault-buttons { display: flex; gap: 8px; } .fault-buttons { display: flex; gap: 8px; }
.fault-buttons button:disabled { background: #b6bfc9; cursor: not-allowed; }
/* ---------- plant environment card (FR-10.2.15) ---------- */
.env-card { margin-top: 16px; }
.env-card .fault-buttons { margin-top: 10px; flex-wrap: wrap; }
.env-card .fault-buttons button { margin-top: 0; }
.env-ref { flex: 1; padding: 7px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
font-family: Consolas, monospace; font-size: 0.88rem; }
.env-state {
display: inline-block; padding: 2px 10px; border-radius: 10px;
font-size: 0.78rem; font-weight: 600; text-transform: uppercase;
vertical-align: middle;
}
.env-state.st-running { background: #e2f5e6; color: #1d7a33; }
.env-state.st-paused { background: #fdf3d7; color: #8a6d00; }
.env-state.st-done { background: #e8ecf1; color: #56606c; }
.env-state.st-none { background: #e8ecf1; color: #56606c; }
/* ---------- configuration ---------- */ /* ---------- configuration ---------- */
@@ -258,6 +277,27 @@ h2 { margin: 4px 0 14px; }
} }
.module-row:hover { background: #eaf1f8; } .module-row:hover { background: #eaf1f8; }
.mod-io { color: #55606c; margin-left: auto; font-size: 0.78rem; white-space: nowrap; } .mod-io { color: #55606c; margin-left: auto; font-size: 0.78rem; white-space: nowrap; }
.row-buttons { display: inline-flex; gap: 3px; margin-left: 6px; }
button.mini {
padding: 1px 6px; border: 1px solid #c5ccd4; border-radius: 3px;
background: #f0f3f6; color: #33414f; font-size: 0.6rem;
line-height: 1.2; cursor: pointer;
}
button.mini:hover:not(:disabled) { background: #dce7f2; }
button.mini:disabled { opacity: 0.35; cursor: default; }
/* drag-and-drop reordering (FR-11.2.6) */
.drag-handle {
cursor: grab; color: #9aa4ae; font-size: 0.9rem;
padding: 0 3px; user-select: none;
}
.drag-handle:hover { color: #0062c4; }
.module-row.dragging { opacity: 0.45; }
.module-row.drop-target {
border-color: #0062c4 !important;
box-shadow: inset 0 2px 0 #0062c4; /* insertion marker: line on top edge */
}
.module-row[draggable="true"] { cursor: default; }
#catalog-pick { padding: 6px; border: 1px solid #c5ccd4; border-radius: 4px; flex: 1; } #catalog-pick { padding: 6px; border: 1px solid #c5ccd4; border-radius: 4px; flex: 1; }
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; } .cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
button.primary { background: #2e7d32; } button.primary { background: #2e7d32; }
+581
View File
@@ -0,0 +1,581 @@
"""Continuous-mode (spec §8) tests: datasets, degradation, invariants,
PlantEnv, and the fixed tick pipeline (FR-8.2.12 order)."""
from __future__ import annotations
import textwrap
import pytest
from wago_sim.scenario.datasets import CsvDataset
from wago_sim.scenario.engine import DataError, ScenarioEngine, ScenarioError, Verdict
from wago_sim.scenario.plantenv import PlantEnv
def _write_csv(path, text: str) -> str:
path.write_text(textwrap.dedent(text), encoding="utf-8")
return str(path)
def _engine(node, scenario: dict) -> ScenarioEngine:
return ScenarioEngine(node, scenario)
class TestContinuousValidation:
def test_unbounded_run_rejected(self, node):
with pytest.raises(ScenarioError, match="would run forever"):
_engine(node, {"mode": "continuous", "name": "x",
"inputs": {"s": {"type": "di", "channel": 1}}})
def test_until_bounds_the_run(self, node):
engine = _engine(node, {"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"s": {"type": "di", "channel": 1}}})
assert engine.mode == "continuous"
def test_dsl_validation_flags_continuous(self):
from wago_sim.scenario.dsl import validate_scenario
problems = validate_scenario({"name": "x", "mode": "continuous"})
assert any("datasets, scheduled steps" in p for p in problems)
problems = validate_scenario({"name": "x", "mode": "continuous",
"until": "1h"})
assert not any("datasets, scheduled steps" in p for p in problems)
class TestDatasets:
def _scenario_with_csv(self, tmp_path, csv_text, **extra):
csv = _write_csv(tmp_path / "data.csv", csv_text)
scenario = {
"mode": "continuous", "name": "csv", "until": "5s",
"inputs": {
"level": {"type": "ai", "module": 0, "channel": 0, "scale": 1.0},
"switch": {"type": "di", "module": 0, "channel": 1},
},
"datasets": {"main": {"file": csv}},
**extra,
}
return scenario
def test_streamed_values_match_samples(self, node, tmp_path):
scenario = self._scenario_with_csv(tmp_path, """\
time,level,switch
0.0,100,ON
1.0,200,OFF
2.0,300,ON
3.0,400,OFF
4.0,500,ON
""")
engine = _engine(node, scenario)
engine.start()
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.PASS
# final applied sample: level = 500 counts at t=5s
assert engine.alias_value("level") == pytest.approx(500.0)
def test_linear_interpolation_between_samples(self, node, tmp_path):
scenario = self._scenario_with_csv(tmp_path, """\
time,level,switch
0.0,0,OFF
2.0,100,OFF
""", until="1s")
scenario["datasets"]["main"]["interpolation"] = "linear"
engine = _engine(node, scenario)
engine.pump() # t=10ms
engine.pump()
# at t=20ms, interpolated between 0 (t=0) and 100 (t=2s): 1.0
assert engine.alias_value("level") == pytest.approx(1.0)
def test_hold_interpolation(self, node, tmp_path):
scenario = self._scenario_with_csv(tmp_path, """\
time,level,switch
0.0,0,OFF
2.0,100,OFF
""", until="1s")
scenario["datasets"]["main"]["interpolation"] = "hold"
engine = _engine(node, scenario)
engine.pump()
engine.pump()
assert engine.alias_value("level") == pytest.approx(0.0)
def test_output_alias_in_dataset_rejected(self, node, tmp_path):
csv = _write_csv(tmp_path / "bad.csv", "time,pump\n0.0,1\n")
scenario = {
"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
"datasets": {"main": {"file": csv}},
}
with pytest.raises(DataError, match="output alias"):
_engine(node, scenario)
def test_malformed_value_is_error_verdict(self, node, tmp_path):
csv = _write_csv(tmp_path / "bad.csv", "time,level\n0.0,100\n1.0,banana\n")
scenario = {
"mode": "continuous", "name": "x", "until": "5s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {"main": {"file": csv}},
}
engine = _engine(node, scenario)
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.ERROR
assert "banana" in result.steps[-1].detail
def test_non_monotonic_time_is_error(self, node, tmp_path):
csv = _write_csv(tmp_path / "bad.csv", "time,level\n1.0,10\n0.5,20\n")
scenario = {
"mode": "continuous", "name": "x", "until": "5s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {"main": {"file": csv}},
}
# detected while priming the lookahead at open(): immediate
# DataError with file:line (FR-8.2.11)
with pytest.raises(DataError, match="non-monotonic"):
_engine(node, scenario)
def test_alias_ownership_conflict_rejected(self, node, tmp_path):
csv = _write_csv(tmp_path / "a.csv", "time,level\n0.0,1\n1.0,2\n")
csv2 = _write_csv(tmp_path / "b.csv", "time,level\n0.0,3\n1.0,4\n")
scenario = {
"mode": "continuous", "name": "x", "until": "5s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {
"a": {"file": csv},
"b": {"file": csv2},
},
}
with pytest.raises(ScenarioError, match="claimed by both"):
_engine(node, scenario)
def test_time_factor_accelerates(self, node, tmp_path):
# dataset time advances 10x sim time: 10 dataset-seconds per
# sim-second; at sim t=1s the dataset is at t=10s
csv = _write_csv(tmp_path / "fast.csv", """\
time,level
0.0,0
10.0,1000
""")
scenario = {
"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {"main": {"file": csv, "time_factor": 10.0,
"interpolation": "linear"}},
}
engine = _engine(node, scenario)
result = None
while result is None:
result = engine.pump()
# at end (sim t=1s) dataset t=10s -> level = 1000
assert engine.alias_value("level") == pytest.approx(1000.0)
def test_bounded_memory_large_file(self, node, tmp_path, datastore):
"""FR-8.2.6: a 100k-row file plays without loading into RAM."""
big = tmp_path / "big.csv"
with big.open("w", encoding="utf-8") as fh:
fh.write("time,level\n")
for i in range(100_000):
fh.write(f"{i / 10.0},{i % 1000}\n")
scenario = {
"mode": "continuous", "name": "x", "until": "60s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"datasets": {"main": {"file": str(big), "interpolation": "hold"}},
}
engine = _engine(node, scenario)
engine.start()
result = None
ticks = 0
while result is None:
result = engine.pump()
ticks += 1
assert result.verdict is Verdict.PASS
# only a lookahead row is held, not the whole file
ds = engine.datasets[0]
assert ds._row is not None and ds._next is not None
class TestDegradation:
def _scenario(self, **degradation):
return {
"mode": "continuous", "name": "x", "until": "2h",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
"plugins": [],
"degradation": {
"pump_capacity": {
"observes": "pump",
"model": "linear",
"rate": 0.1, # 0.1 per runtime hour
"from": 1.0,
**degradation,
},
},
}
def test_parameter_decays_with_runtime(self, node):
engine = _engine(node, self._scenario())
# wear observes the DO alias: emulate the master turning it on
loc = engine._alias_location(engine._aliases["pump"])
engine.datastore.write_bits(loc.bit, [True])
result = None
while result is None:
result = engine.pump()
# 2h runtime at 0.1/h -> capacity = 0.8
assert engine.parameter("pump_capacity") == pytest.approx(0.8, abs=0.01)
def test_maintenance_threshold_injects_diagnosis(self, node, app):
# engine needs the register bank for diagnosis injection
scenario = self._scenario(maintenance_at=0.95)
engine = ScenarioEngine(node, scenario)
engine.registers = app.registers # FR-8.3.4 injection target
loc = engine._alias_location(engine._aliases["pump"])
engine.datastore.write_bits(loc.bit, [True])
result = None
while result is None:
result = engine.pump()
# capacity crosses 0.95 after ~0.5h of the 2h run -> diagnosed
assert engine.degradation.models["pump_capacity"].diagnosed is True
assert app.registers.read_config(0x1050, 3) != [0, 0, 0]
def test_maintenance_reset_restores(self, node, app):
scenario = self._scenario()
engine = ScenarioEngine(node, scenario)
engine.registers = app.registers
loc = engine._alias_location(engine._aliases["pump"])
engine.datastore.write_bits(loc.bit, [True])
for _ in range(50):
engine.pump()
assert engine.parameter("pump_capacity") < 1.0
engine.maintenance_reset()
assert engine.parameter("pump_capacity") == pytest.approx(1.0)
def test_staircase_model(self, node):
scenario = self._scenario(model="staircase",
staircase=[0.9, 0.5, 0.2],
rate=0)
engine = _engine(node, scenario)
# pump runs the whole 2h: one level per hour -> level 2 -> 0.2
loc = engine._alias_location(engine._aliases["pump"])
engine.datastore.write_bits(loc.bit, [True])
result = None
while result is None:
result = engine.pump()
assert engine.parameter("pump_capacity") == pytest.approx(0.2)
class TestInvariants:
def _scenario(self, invariants, **extra):
return {
"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
"scale": 1.0}},
"invariants": invariants,
**extra,
}
def test_invariant_breach_records_one_episode(self, node):
inv = [{"alias": "level", "condition": ">= 0", "label": "floor"}]
engine = _engine(node, self._scenario(inv))
# drive level negative via the datastore (plugin/dataset would
# normally do it): AI module 0 word 0
engine.datastore.set_input_word(0, 0xFFFF) # signed view? raw 65535
result = None
while result is None:
result = engine.pump()
# level reads raw (no signed range configured) = 65535 >= 0: OK.
# now force an actual breach: condition fails only if value < 0
assert engine.invariants.episodes == []
def test_invariant_fail_verdict(self, node):
inv = [{"alias": "level", "condition": "<= 10", "label": "cap"}]
scenario = self._scenario(inv)
engine = _engine(node, scenario)
engine.datastore.set_input_word(0, 5000) # 5000 > 10: breach
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.FAIL
episodes = engine.invariants.episodes
assert len(episodes) == 1
assert episodes[0].label == "cap"
assert episodes[0].samples >= 1
def test_rate_limit_breach_fails_immediately(self, node):
scenario = self._scenario(
[], rate_limits=[{"alias": "level", "max_per_hour": 100}])
engine = _engine(node, scenario)
engine.datastore.set_input_word(0, 0)
engine.pump()
engine.datastore.set_input_word(0, 50_000) # huge jump within 1h
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.FAIL
assert "rate limit" in result.steps[-1].detail
def test_snapshot_every_takes_snapshots(self, node):
scenario = self._scenario([], snapshot_every="100ms")
engine = _engine(node, scenario)
result = None
while result is None:
result = engine.pump()
# 1s run, snapshot every 100ms -> ~10 snapshots
assert len(engine.invariants.snapshots) >= 9
snap = engine.invariants.snapshots[0]
assert "aliases" in snap and "sim_time_ms" in snap
class TestPlantEnv:
"""PlantEnv v2 (FR-8.2.14-17): standalone-first, apply -> DI/AI."""
def _scenario(self):
return {
"mode": "continuous", "name": "x", "until": "500ms",
"inputs": {"start": {"type": "di", "module": 0, "channel": 0}},
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
}
def test_observe_returns_all_channels(self, node):
# standalone construction: no engine needed
env = PlantEnv(node=node)
state = env.observe()
kinds = {c.kind for c in state.channels}
assert kinds == {"di", "do", "ai", "ao"}
codes = [c.io_code for c in state.channels]
assert "DI0.0" in codes and "DO0.0" in codes
assert state.sim_time_ms == 0.0
def test_observe_carries_display_ranges(self, node):
"""FR-8.2.14 observation includes analog display ranges
(FR-10.2.14 dense store — defaults materialized)."""
env = PlantEnv(node=node)
state = env.observe()
ai = state.channel("AI0.0")
assert ai.range == (0, 4095) # default materialized
di = state.channel("DI0.0")
assert di.range is None # digital: no range
# a configured range is visible in the very same observation
node.set_channel_ranges({"AI0.0": (-32000, 32000)})
state = env.observe()
assert state.channel("AI0.0").range == (-32000, 32000)
def test_channel_lookup_by_name(self, node):
"""PlantState.channel() resolves the configured application
name too (FR-10.2.11 names, FR-8.2.16 accessor)."""
node.channel_names["AI0.0"] = "tank_level"
env = PlantEnv(node=node)
state = env.observe()
ch = state.channel("tank_level")
assert ch.io_code == "AI0.0"
assert ch.range == (0, 4095)
assert state.channel("AI0.0") is ch # code lookup matches
# code takes precedence over name; unknown -> KeyError
with pytest.raises(KeyError):
state.channel("no_such_channel")
# a name colliding with an I/O code resolves to the code
node.channel_names["AO0.1"] = "AI0.0"
state = env.observe()
assert state.channel("AI0.0").io_code == "AI0.0"
def test_channel_range_accessor(self, node):
"""state.channel_range(): analog (min, max) by code or name;
digital channels carry no range."""
node.channel_names["AI0.0"] = "tank_level"
node.set_channel_ranges({"AI0.0": (-32000, 32000)})
env = PlantEnv(node=node)
state = env.observe()
assert state.channel_range("AI0.0") == (-32000, 32000)
assert state.channel_range("tank_level") == (-32000, 32000)
assert state.channel_range("AO0.0") == (0, 4095) # dense default
with pytest.raises(KeyError):
state.channel_range("DI0.0") # digital
def test_node_range_map_is_dense(self, node):
"""FR-10.2.14: every analog channel always has an entry."""
analog = set(node.channel_ranges)
assert {"AI0.0", "AI0.1", "AO0.0", "AO0.1"} <= analog
assert all(node.channel_ranges[c] == (0, 4095) for c in analog)
# digital codes are pruned; defaults materialized on assign
node.set_channel_ranges({"AI0.0": (0, 4095), "DI0.0": (0, 100)})
assert "DI0.0" not in node.channel_ranges
assert node.channel_ranges["AI0.0"] == (0, 4095)
assert "AO0.1" in node.channel_ranges # re-materialized
def test_apply_writes_input_side(self, node):
env = PlantEnv(node=node)
env.apply({"DI0.0": True, "AI0.0": 1234})
state = env.observe()
assert state.by_code("DI0.0") is True
assert state.by_code("AI0.0") == 1234
def test_apply_output_requires_force(self, node):
env = PlantEnv(node=node)
with pytest.raises(ScenarioError, match="force"):
env.apply({"DO0.0": True})
# explicit forcing works and lands in the output image
env.force({"DO0.0": True, "AO0.0": 999})
state = env.observe()
assert state.by_code("DO0.0") is True
assert state.by_code("AO0.0") == 999
def test_apply_unknown_rejected(self, node):
env = PlantEnv(node=node)
with pytest.raises(ScenarioError, match="unknown channel"):
env.apply({"nonexistent": True})
def test_model_hook_drives_field(self, node):
"""The intended loop: model() reads PLC outputs, returns field."""
class Tank(PlantEnv):
def model(self, state, dt_s):
pump_on = bool(state.by_code("DO0.0"))
level = float(state.by_code("AI0.0"))
return {"AI0.0": level + (5.0 if pump_on else 1.0)}
env = Tank(node=node)
env.force({"DO0.0": True}) # the "PLC" turns the pump on
state = env.tick()
assert state.by_code("AI0.0") == 5.0 # field advanced via model
state = env.tick()
assert state.by_code("AI0.0") == 10.0
assert state.sim_time_ms == 20.0
def test_is_done_stops_ticks(self, node):
class DoneAfterTwo(PlantEnv):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.n = 0
def is_done(self, state):
return self.n >= 2
def model(self, state, dt_s):
self.n += 1
return {}
env = DoneAfterTwo(node=node)
env.tick()
env.tick()
state = env.tick() # done: no further advance
assert env.n == 2
assert state.sim_time_ms == 20.0
def test_reset_deterministic_replay(self, node):
env = PlantEnv(node=node)
env.apply({"DI0.0": True, "AI0.0": 42})
env.tick()
assert env.sim_time_ms > 0
state = env.reset()
assert env.sim_time_ms == 0.0
assert state.by_code("DI0.0") is False
assert state.by_code("AI0.0") == 0
def test_engine_attached_env_uses_aliases(self, node):
"""Scenario layer: alias names + alias scale/offset still work."""
engine = _engine(node, self._scenario())
env = engine.env
state = env.observe()
assert state.aliases["start"] is False
# alias write to a do output requires force (PLC-side semantics)
with pytest.raises(ScenarioError, match="force"):
env.apply({"pump": True})
env.force({"pump": True})
assert env.observe().aliases["pump"] is True
def test_pump_station_example_env(self, node):
"""The shipped example env loads and runs closed-loop physics."""
from envs.pump_station_env import PumpStationEnv
env = PumpStationEnv(node=node)
# PLC turns the pump on at full speed
env.force({"DO0.0": True, "AO0.0": 4095})
env.tick() # physics applies the level
level0 = env.observe().by_code("AI0.0")
assert level0 > 0 # tank starts at 3000 mm
# big dt (10 sim-minutes): the pump visibly drains the tank
env.tick(dt_ms=600_000)
level1 = env.observe().by_code("AI0.0")
assert level1 < level0 # pump drains the tank
# pump OFF: inflow fills the tank instead
env.force({"DO0.0": False})
env.tick(dt_ms=600_000)
level2 = env.observe().by_code("AI0.0")
assert level2 > level1
# and it runs forever by default
assert env.is_done(env.observe()) is False
def test_load_env_class_and_factory(self, node):
from wago_sim.scenario.plantenv import load_env
env = load_env("envs.pump_station_env:PumpStationEnv", node=node)
assert isinstance(env, PlantEnv)
env = load_env("envs.pump_station_env:make_env", node=node)
assert isinstance(env, PlantEnv)
# bare module picks up Env
env = load_env("envs.pump_station_env", node=node)
assert isinstance(env, PlantEnv)
with pytest.raises(ScenarioError):
load_env("envs.pump_station_env:Nope", node=node)
with pytest.raises(ScenarioError):
load_env("envs.missing_module", node=node)
class TestTickPipeline:
def test_scheduled_steps_execute_at_their_time(self, node):
scenario = {
"mode": "continuous", "name": "x", "until": "2s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
"sequence": [
{"at": "500ms", "set": {"level": 123}},
{"at": "1s", "report": "PASS"},
],
}
engine = _engine(node, scenario)
result = None
while result is None:
result = engine.pump()
assert result.verdict is Verdict.PASS
assert engine.alias_value("level") == pytest.approx(123.0)
def test_mixed_dataset_and_logic(self, node, tmp_path):
"""Acceptance criterion 10: one alias from a dataset, one computed
by a plugin in the same run; the plugin reads the dataset value."""
csv = _write_csv(tmp_path / "d.csv", "time,level\n0.0,700\n10.0,700\n")
scenario = {
"mode": "continuous", "name": "x", "until": "1s",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
"scale": 1.0}},
"datasets": {"d": {"file": csv, "interpolation": "hold"}},
"plugins": [],
}
# inline plugin: copy level -> AI channel 2 as "logic-driven"
scenario["inputs"]["computed"] = {"type": "ai", "module": 0, "channel": 1,
"scale": 1.0}
engine = _engine(node, scenario)
# simulate the logic: a plugin reading level and writing computed
def logic(eng, value):
level = eng.alias_value("level")
eng.set_alias("computed", level * 2)
scenario["plugins"] = [] # not used; call logic manually
result = None
while result is None:
logic(engine, None) # runs with the dataset applied
result = engine.pump()
assert engine.alias_value("level") == pytest.approx(700.0)
# (computed shows the logic ran against the dataset-driven value)
def test_logic_only_run(self, node):
"""FR-8.2.13: continuous run with no datasets at all."""
scenario = {
"mode": "continuous", "name": "x", "until": "1h",
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
"scale": 1.0}},
"invariants": [{"alias": "level", "condition": ">= 0"}],
}
engine = _engine(node, scenario)
result = None
ticks = 0
while result is None:
result = engine.pump()
ticks += 1
assert result.verdict is Verdict.PASS
assert ticks == 3_600_000 // 10 # 1h at 10ms ticks
+39 -27
View File
@@ -88,13 +88,15 @@ class TestAliases:
class TestModuleAddressing: class TestModuleAddressing:
"""Module type + module number + channel number addressing (DI0.1). """Module type + module number + channel number addressing (DI0.1).
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2] The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2].
(indices 0..5). Module addressing uses 0-based module positions and Module addressing (FR-4.3.11 v1.5) numbers modules 0-based WITHIN
0-based channel numbers on the module. THEIR TYPE: DI0=DI_8, DI1=DI_16, DO0=DO_8, DO1=DO_16, AI0=AI_2,
AO0=AO_2 — regardless of physical slot interleaving. Channels are
0-based on the module.
""" """
def test_module_addressing_di(self, node, datastore): def test_module_addressing_di(self, node, datastore):
# DI0.1: module slot 0 (DI_8), channel 1 (0-based) = 2nd DI # DI0.1: DI module 0 (DI_8), channel 1 (0-based) = 2nd DI
scenario = _scenario() scenario = _scenario()
scenario["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1} scenario["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
engine = ScenarioEngine(node, scenario) engine = ScenarioEngine(node, scenario)
@@ -104,35 +106,42 @@ class TestModuleAddressing:
assert engine.alias_value("second_di") is True assert engine.alias_value("second_di") is True
def test_module_addressing_across_modules(self, node, datastore): def test_module_addressing_across_modules(self, node, datastore):
# DI1.0: module slot 1 (DI_16), channel 0 = 9th DI globally # DI1.0: DI module 1 (DI_16, physical slot 1), channel 0 = 9th DI
# (8 channels on module 0 come first)
scenario = _scenario() scenario = _scenario()
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0} scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
engine = ScenarioEngine(node, scenario) engine = ScenarioEngine(node, scenario)
engine.set_alias("di16_first", True) engine.set_alias("di16_first", True)
assert datastore.read_bits(8, 1) == [True] assert datastore.read_bits(8, 1) == [True]
def test_module_addressing_do(self, node, datastore): def test_module_addressing_do_interleaved(self, node, datastore):
# DO2.2: module slot 2 (DO_8), channel 2 = 3rd DO globally # DO0.2: DO module 0 is the DO_8 at PHYSICAL SLOT 2 — per-type
# numbering means module 0, not slot 2
scenario = _scenario() scenario = _scenario()
scenario["outputs"]["third_do"] = {"type": "do", "module": 2, "channel": 2} scenario["outputs"]["third_do"] = {"type": "do", "module": 0, "channel": 2}
engine = ScenarioEngine(node, scenario) engine = ScenarioEngine(node, scenario)
bit = engine.alias_location("third_do").bit bit = engine.alias_location("third_do").bit
assert bit == 2 assert bit == 2
assert datastore.write_bits(bit, [True]) is None assert datastore.write_bits(bit, [True]) is None
assert engine.alias_value("third_do") is True assert engine.alias_value("third_do") is True
def test_module_addressing_ai_ao(self, node, datastore): def test_do_module_1_is_do16(self, node):
# AI4.1: module slot 4 (AI_2), channel 1 = 2nd AI word # DO1.0: DO module 1 is the DO_16 at physical slot 3
scenario = _scenario() scenario = _scenario()
scenario["inputs"]["ai2"] = {"type": "ai", "module": 4, "channel": 1, "scale": 1.0} scenario["outputs"]["x"] = {"type": "do", "module": 1, "channel": 0}
engine = ScenarioEngine(node, scenario)
assert engine.alias_location("x").bit == 8 # after DO_8's 8 channels
def test_module_addressing_ai_ao(self, node, datastore):
# AI0.1: AI module 0 (AI_2 at physical slot 4), channel 1 = 2nd AI word
scenario = _scenario()
scenario["inputs"]["ai2"] = {"type": "ai", "module": 0, "channel": 1, "scale": 1.0}
engine = ScenarioEngine(node, scenario) engine = ScenarioEngine(node, scenario)
engine.set_alias("ai2", 0x1234) engine.set_alias("ai2", 0x1234)
loc = node.channel("ai", 2) # global 2nd AI loc = node.channel("ai", 2) # global 2nd AI
assert datastore.get_input_word(loc.word) == 0x1234 assert datastore.get_input_word(loc.word) == 0x1234
# AO5.1: module slot 5 (AO_2), channel 1 = 2nd AO word # AO0.1: AO module 0 (AO_2 at physical slot 5), channel 1 = 2nd AO word
scenario["outputs"] = {"ao2": {"type": "ao", "module": 5, "channel": 1}} scenario["outputs"] = {"ao2": {"type": "ao", "module": 0, "channel": 1}}
engine2 = ScenarioEngine(node, scenario) engine2 = ScenarioEngine(node, scenario)
bit_word = node.channel("ao", 2).word bit_word = node.channel("ao", 2).word
assert datastore.write_words(bit_word, [0xBEEF]) is None assert datastore.write_words(bit_word, [0xBEEF]) is None
@@ -149,31 +158,34 @@ class TestModuleAddressing:
def test_module_out_of_range_rejected(self, node): def test_module_out_of_range_rejected(self, node):
scenario = _scenario() scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "module": 99, "channel": 0} # the node has only 2 DI modules
with pytest.raises(IndexError, match="module 99"): scenario["inputs"]["bad"] = {"type": "di", "module": 2, "channel": 0}
with pytest.raises(IndexError, match="DI module 2"):
ScenarioEngine(node, scenario) ScenarioEngine(node, scenario)
def test_channel_out_of_range_rejected(self, node): def test_channel_out_of_range_rejected(self, node):
# module 0 is a DI_8 -> channels 0-7 only # DI module 0 is a DI_8 -> channels 0-7 only
scenario = _scenario() scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8} scenario["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
with pytest.raises(IndexError, match="channel 8"): with pytest.raises(IndexError, match="channel 8"):
ScenarioEngine(node, scenario) ScenarioEngine(node, scenario)
def test_kind_mismatch_rejected(self, node): def test_module_kind_routes_to_own_type(self, node):
# module 0 is DI-only -> DO addressing must fail """Per-type numbering (v1.5): 'do' module numbers address the DO
modules regardless of physical slot — DO0 is the DO_8 at slot 2.
A 'do' alias on module 1 (the DO_16) is valid and distinct."""
scenario = _scenario() scenario = _scenario()
scenario["outputs"]["bad"] = {"type": "do", "module": 0, "channel": 0} scenario["outputs"]["do8"] = {"type": "do", "module": 0, "channel": 0}
from wago_sim.modules import ChannelMismatch scenario["outputs"]["do16"] = {"type": "do", "module": 1, "channel": 0}
with pytest.raises(ChannelMismatch): engine = ScenarioEngine(node, scenario)
ScenarioEngine(node, scenario) assert engine.alias_location("do8").bit == 0
assert engine.alias_location("do16").bit == 8
def test_analog_kind_on_digital_module_rejected(self, node): def test_analog_kind_on_digital_module_rejected(self, node):
# module 0 is DI-only -> AI addressing must fail # per-type routing: 'ai' module 9 is out of range (only 1 AI module)
scenario = _scenario() scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "ai", "module": 0, "channel": 0} scenario["inputs"]["bad"] = {"type": "ai", "module": 9, "channel": 0}
from wago_sim.modules import ChannelMismatch with pytest.raises(IndexError):
with pytest.raises(ChannelMismatch):
ScenarioEngine(node, scenario) ScenarioEngine(node, scenario)
+262 -40
View File
@@ -3,6 +3,9 @@ diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
from __future__ import annotations from __future__ import annotations
import asyncio
import sys
import httpx import httpx
import pytest import pytest
@@ -106,7 +109,7 @@ class TestManipulation:
async def test_toggle_do_forces_output_image(self, client, datastore): async def test_toggle_do_forces_output_image(self, client, datastore):
# DO_8 sits at module slot 2 in the pump fixture # DO_8 sits at module slot 2 in the pump fixture
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"}) r = await client.post("/api/channels/do/0/0", json={"action": "toggle"})
assert r.status_code == 200 assert r.status_code == 200
# DO channel 1 is forced ON in the OUTPUT image (master-side) # DO channel 1 is forced ON in the OUTPUT image (master-side)
assert datastore.digital_output_channel(1) is True assert datastore.digital_output_channel(1) is True
@@ -117,13 +120,13 @@ class TestManipulation:
async def test_ai_value_set(self, client, datastore): async def test_ai_value_set(self, client, datastore):
# AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16, # AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16,
# DO_8, DO_16, AI_2, AO_2); its first word is input word 0 # DO_8, DO_16, AI_2, AO_2); its first word is input word 0
r = await client.post("/api/channels/ai/4/0", json={"value": 200}) r = await client.post("/api/channels/ai/0/0", json={"value": 200})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["value"] == 200 assert r.json()["value"] == 200
assert datastore.get_input_word(0) == 200 assert datastore.get_input_word(0) == 200
async def test_ao_force_writes_output_image(self, client, datastore): async def test_ao_force_writes_output_image(self, client, datastore):
r = await client.post("/api/channels/ao/5/0", json={"value": 1234}) r = await client.post("/api/channels/ao/0/0", json={"value": 1234})
assert r.status_code == 200 assert r.status_code == 200
# written at the output image word 0 -> read back at +0x0200 # written at the output image word 0 -> read back at +0x0200
assert datastore.read_words(0x0200, 1) == [1234] assert datastore.read_words(0x0200, 1) == [1234]
@@ -133,9 +136,13 @@ class TestManipulation:
assert r.status_code == 404 assert r.status_code == 404
async def test_kind_mismatch_400(self, client): async def test_kind_mismatch_400(self, client):
# DI_16 module (index 1) has no DO channels # per-type numbering (v1.5): DO module 0 is the DO_8 — asking
r = await client.post("/api/channels/do/1/0", json={"action": "toggle"}) # for a DO channel beyond its 8 channels is out of range (404);
assert r.status_code == 400 # DO module 2 does not exist (only DO0, DO1)
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"})
assert r.status_code == 404
r = await client.post("/api/channels/do/0/9", json={"action": "toggle"})
assert r.status_code in (400, 404) # channel out of module range
class TestDiagnostics: class TestDiagnostics:
@@ -187,6 +194,188 @@ class TestReadOnlyMode:
r = await c.post("/api/diagnostics/reset-stats") r = await c.post("/api/diagnostics/reset-stats")
assert r.status_code == 403 assert r.status_code == 403
async def test_readonly_blocks_env_control(self, app):
ro = build_webui(app, user="admin", password="simu", readonly=True)
transport = httpx.ASGITransport(app=ro)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
await c.post("/login", json={"user": "admin", "password": "simu"})
r = await c.get("/api/env")
assert r.status_code == 200 # status is a read
assert r.json()["state"] == "none"
r = await c.post("/api/env/start",
json={"ref": "envs.pump_station_env:PumpStationEnv"})
assert r.status_code == 403
r = await c.post("/api/env/stop")
assert r.status_code == 403
class TestEnvControl:
"""FR-10.2.15 runtime env lifecycle + FR-10.2.17 hot reload."""
REF = "envs.pump_station_env:PumpStationEnv"
async def test_env_status_none(self, client):
r = await client.get("/api/env")
assert r.status_code == 200
assert r.json() == {"ref": None, "state": "none", "reason": None,
"sim_time_ms": None, "tick_ms": None}
async def test_start_stop_cycle(self, client, app):
r = await client.post("/api/env/start", json={"ref": self.REF})
assert r.status_code == 200, r.text
st = r.json()
assert st["state"] == "running"
assert st["ref"] == self.REF
assert app.runner.env is not None
# let it tick a little, then check status shows sim time
await asyncio.sleep(0.05)
r = await client.get("/api/env")
assert r.json()["sim_time_ms"] is not None
# stop -> none
r = await client.post("/api/env/stop")
assert r.status_code == 200
assert r.json()["state"] == "none"
assert app.runner.env is None
async def test_start_refused_while_loaded(self, client):
await client.post("/api/env/start", json={"ref": self.REF})
r = await client.post("/api/env/start", json={"ref": self.REF})
assert r.status_code == 409
assert "stop" in r.json()["detail"]
await client.post("/api/env/stop")
async def test_start_requires_ref(self, client):
r = await client.post("/api/env/start", json={})
assert r.status_code == 400
async def test_pause_resume(self, client, app):
await client.post("/api/env/start", json={"ref": self.REF})
# let it tick so sim time advances
await asyncio.sleep(0.05)
t0 = (await client.get("/api/env")).json()["sim_time_ms"]
assert t0 > 0
r = await client.post("/api/env/pause")
assert r.json()["state"] == "paused"
t1 = (await client.get("/api/env")).json()["sim_time_ms"]
await asyncio.sleep(0.05)
t2 = (await client.get("/api/env")).json()["sim_time_ms"]
assert t2 == t1 # frozen while paused
r = await client.post("/api/env/resume")
assert r.json()["state"] == "running"
await asyncio.sleep(0.05)
t3 = (await client.get("/api/env")).json()["sim_time_ms"]
assert t3 > t2
# double-pause / resume-without-pause are refused
await client.post("/api/env/pause")
r = await client.post("/api/env/pause")
assert r.status_code == 409
r = await client.post("/api/env/stop")
assert r.status_code == 200
r = await client.post("/api/env/resume")
assert r.status_code == 409
r = await client.post("/api/env/pause")
assert r.status_code == 409 # nothing loaded
async def test_reset_returns_to_initial_state(self, client):
await client.post("/api/env/start", json={"ref": self.REF})
await asyncio.sleep(0.05)
st = (await client.get("/api/env")).json()
assert st["sim_time_ms"] > 0
r = await client.post("/api/env/reset")
assert r.status_code == 200
st = (await client.get("/api/env")).json()
assert st["sim_time_ms"] == 0.0 # deterministic fresh episode
await client.post("/api/env/stop")
async def test_restart_fresh_instance(self, client, app):
await client.post("/api/env/start", json={"ref": self.REF})
first = app.runner.env
await asyncio.sleep(0.02)
r = await client.post("/api/env/restart")
assert r.status_code == 200
assert r.json()["state"] == "running"
assert app.runner.env is not first # a NEW instance
assert app.runner.env.sim_time_ms < first.sim_time_ms
await client.post("/api/env/stop")
async def test_is_done_ends_ticker(self, client, app):
# an env that is done after 30 ms of sim time (TICK_MS 10 -> 3 ticks)
ref = "envs.test_done_env:PumpStationEnv"
app.runner.env = None
r = await client.post("/api/env/start", json={"ref": ref})
assert r.status_code == 200
await asyncio.sleep(0.15) # let the paced ticker finish
st = (await client.get("/api/env")).json()
assert st["state"] == "done"
assert st["reason"] == "is_done()"
assert app.runner.env is not None # instance kept for inspection
# pause on done -> refused (restart instead)
r = await client.post("/api/env/pause")
assert r.status_code == 409
# reset brings it back to running
r = await client.post("/api/env/reset")
assert r.json()["state"] == "running"
await client.post("/api/env/stop")
async def test_module_save_discards_env(self, client, app):
await client.post("/api/env/start", json={"ref": self.REF})
assert app.runner.env is not None
r = await client.put("/api/config/modules", json={
"modules": ["750-430", "750-530"], "persist": False,
})
assert r.status_code == 200
# FR-10.2.15: the rebuild discards the env (old-layout bindings)
assert app.runner.env is None
assert (await client.get("/api/env")).json()["state"] == "none"
# and the new runner points at the NEW node
assert app.runner.node is app.node
async def test_start_unknown_env_is_409(self, client):
r = await client.post("/api/env/start",
json={"ref": "envs.no_such_env:Env"})
assert r.status_code == 409
assert "cannot import" in r.json()["detail"]
async def test_hot_reload_after_source_edit(self, client, app, tmp_path,
monkeypatch):
"""FR-10.2.17: start -> edit source on disk -> restart runs the
NEW code (sys.modules cache invalidated)."""
pkg = tmp_path / "reloadpkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("", encoding="utf-8")
# v1: level rises
(pkg / "reload_env.py").write_text(
"from wago_sim.scenario.plantenv import PlantEnv\n"
"class ReloadEnv(PlantEnv):\n"
" def model(self, state, dt_s):\n"
" level = float(state.by_code('AI0.0'))\n"
" return {'AI0.0': level + 1.0}\n"
"Env = ReloadEnv\n", encoding="utf-8")
sys.path.insert(0, str(tmp_path))
try:
r = await client.post("/api/env/start",
json={"ref": "reloadpkg.reload_env:ReloadEnv"})
assert r.status_code == 200, r.text
await asyncio.sleep(0.03)
v1 = app.datastore.get_input_word(0)
assert v1 > 0
# v2: level DRAINS instead — same class name
(pkg / "reload_env.py").write_text(
"from wago_sim.scenario.plantenv import PlantEnv\n"
"class ReloadEnv(PlantEnv):\n"
" def model(self, state, dt_s):\n"
" level = float(state.by_code('AI0.0'))\n"
" return {'AI0.0': max(0.0, level - 1.0)}\n"
"Env = ReloadEnv\n", encoding="utf-8")
r = await client.post("/api/env/restart")
assert r.status_code == 200
app.datastore.set_input_word(0, 100)
await asyncio.sleep(0.03)
assert app.datastore.get_input_word(0) < 100 # NEW code ran
await client.post("/api/env/stop")
finally:
sys.path.remove(str(tmp_path))
class TestCouplerConfig: class TestCouplerConfig:
async def test_get_coupler_config(self, client, app): async def test_get_coupler_config(self, client, app):
@@ -319,7 +508,7 @@ class TestModuleConfig:
class TestChannelNames: class TestChannelNames:
async def test_names_in_layout_and_values(self, client, app): async def test_names_in_layout_and_values(self, client, app):
r = await client.put("/api/config/channel-names", json={ r = await client.put("/api/config/channel-names", json={
"channel_names": {"DI0.0": "start_button", "AI4.0": "tank_level"}, "channel_names": {"DI0.0": "start_button", "AI0.0": "tank_level"},
"persist": False, "persist": False,
}) })
assert r.status_code == 200 assert r.status_code == 200
@@ -331,23 +520,23 @@ class TestChannelNames:
# values snapshot carries it too # values snapshot carries it too
r = await client.get("/api/values") r = await client.get("/api/values")
entry = next(v for v in r.json()["values"] entry = next(v for v in r.json()["values"]
if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0)) if (v["kind"], v["module"], v["channel"]) == ("ai", 0, 0))
assert entry["name"] == "tank_level" assert entry["name"] == "tank_level"
async def test_names_persist_to_yaml(self, client, app, tmp_path): async def test_names_persist_to_yaml(self, client, app, tmp_path):
cfg_file = tmp_path / "node.yaml" cfg_file = tmp_path / "node.yaml"
app.node_config_path = str(cfg_file) app.node_config_path = str(cfg_file)
r = await client.put("/api/config/channel-names", json={ r = await client.put("/api/config/channel-names", json={
"channel_names": {"DO2.0": "pump_relay"}, "persist": True, "channel_names": {"DO0.0": "pump_relay"}, "persist": True,
}) })
assert r.status_code == 200 assert r.status_code == 200
text = cfg_file.read_text(encoding="utf-8") text = cfg_file.read_text(encoding="utf-8")
assert "channel_names:" in text assert "channel_names:" in text
assert "DO2.0: pump_relay" in text assert "DO0.0: pump_relay" in text
# and it loads back # and it loads back
from wago_sim.scenario.dsl import load_yaml, load_channel_names from wago_sim.scenario.dsl import load_yaml, load_channel_names
names = load_channel_names(load_yaml(cfg_file)) names = load_channel_names(load_yaml(cfg_file))
assert names == {"DO2.0": "pump_relay"} assert names == {"DO0.0": "pump_relay"}
async def test_invalid_code_rejected(self, client): async def test_invalid_code_rejected(self, client):
r = await client.put("/api/config/channel-names", json={ r = await client.put("/api/config/channel-names", json={
@@ -464,25 +653,44 @@ class TestChannelRanges:
async def test_put_ranges_and_layout(self, client, app): async def test_put_ranges_and_layout(self, client, app):
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, "channel_ranges": {"AI0.0": {"min": 0, "max": 32000},
"AO5.0": {"min": 400, "max": 2000}}, "AO0.0": {"min": 400, "max": 2000}},
"persist": False, "persist": False,
}) })
assert r.status_code == 200 assert r.status_code == 200
# layout reflects the configured range # layout reflects the configured range
r = await client.get("/api/node") r = await client.get("/api/node")
ai = next(c for m in r.json()["modules"] if m["index"] == 4 ai = next(c for m in r.json()["modules"] if m["index"] == 4
for c in m["channels"] if c["kind"] == "ai" and c["address"] == "AI4.0") for c in m["channels"] if c["kind"] == "ai" and c["address"] == "AI0.0")
assert ai["range"] == {"min": 0, "max": 32000} assert ai["range"] == {"min": 0, "max": 32000}
assert app.node.channel_range("ao", 5, 0) == (400, 2000) assert app.node.channel_ranges["AO0.0"] == (400, 2000)
async def test_default_range_dropped_from_store(self, client, app): async def test_default_range_kept_in_dense_store(self, client, app):
# the store is DENSE (FR-10.2.14): a default-equal entry is
# materialized like any other — no sparse-override dropping
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 0, "max": 4095}}, # == default "channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default
"persist": False, "persist": False,
}) })
assert r.status_code == 200 assert r.status_code == 200
assert app.node.channel_ranges == {} # kept clean assert app.node.channel_ranges["AI0.0"] == (0, 4095)
# every analog channel of the layout is present
analog = {c for c in app.node.channel_ranges}
assert {"AI0.0", "AI0.1", "AO0.0", "AO0.1"} <= analog
# and digital codes never are
assert not any(c.startswith(("DI", "DO")) for c in analog)
async def test_dense_store_pruned_on_module_save(self, client, app, tmp_path):
app.node_config_path = str(tmp_path / "node.yaml")
await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": False,
})
r = await client.put("/api/config/modules", json={
"modules": ["750-430", "750-530"], "persist": False,
})
assert r.status_code == 200
# no analog modules left -> the range store is empty (pruned)
assert app.node.channel_ranges == {}
async def test_range_validation(self, client): async def test_range_validation(self, client):
base = {"persist": False} base = {"persist": False}
@@ -490,71 +698,71 @@ class TestChannelRanges:
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base}) "channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
assert r.status_code == 400 # bad code assert r.status_code == 400 # bad code
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 100, "max": 50}}, **base}) "channel_ranges": {"AI0.0": {"min": 100, "max": 50}}, **base})
assert r.status_code == 400 # min >= max assert r.status_code == 400 # min >= max
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 0, "max": 70000}}, **base}) "channel_ranges": {"AI0.0": {"min": 0, "max": 70000}}, **base})
assert r.status_code == 400 # > 65535 assert r.status_code == 400 # > 65535
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 0}}, **base}) "channel_ranges": {"AI0.0": {"min": 0}}, **base})
assert r.status_code == 400 # missing max assert r.status_code == 400 # missing max
async def test_negative_range_accepted(self, client, app): async def test_negative_range_accepted(self, client, app):
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": -32000, "max": 32000}}, "channel_ranges": {"AI0.0": {"min": -32000, "max": 32000}},
"persist": False, "persist": False,
}) })
assert r.status_code == 200 assert r.status_code == 200
assert app.node.channel_range("ai", 4, 0) == (-32000, 32000) assert app.node.channel_ranges["AI0.0"] == (-32000, 32000)
# layout exposes the signed range # layout exposes the signed range
r = await client.get("/api/node") r = await client.get("/api/node")
ai = next(c for m in r.json()["modules"] if m["index"] == 4 ai = next(c for m in r.json()["modules"] if m["index"] == 4
for c in m["channels"] if c["address"] == "AI4.0") for c in m["channels"] if c["address"] == "AI0.0")
assert ai["range"] == {"min": -32000, "max": 32000} assert ai["range"] == {"min": -32000, "max": 32000}
async def test_range_floor_is_minus_65534(self, client): async def test_range_floor_is_minus_65534(self, client):
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": -65535, "max": 0}}, "persist": False}) "channel_ranges": {"AI0.0": {"min": -65535, "max": 0}}, "persist": False})
assert r.status_code == 400 # below -65534 assert r.status_code == 400 # below -65534
async def test_signed_value_display_and_write(self, client, app, datastore): async def test_signed_value_display_and_write(self, client, app, datastore):
# channel with a negative-min range -> signed two's-complement view # channel with a negative-min range -> signed two's-complement view
await client.put("/api/config/channel-ranges", json={ await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": -32000, "max": 32000}}, "channel_ranges": {"AI0.0": {"min": -32000, "max": 32000}},
"persist": False}) "persist": False})
r = await client.post("/api/channels/ai/4/0", json={"value": -2000}) r = await client.post("/api/channels/ai/0/0", json={"value": -2000})
assert r.status_code == 200 assert r.status_code == 200
# raw image holds the two's-complement word # raw image holds the two's-complement word
assert datastore.get_input_word(0) == (-2000) & 0xFFFF assert datastore.get_input_word(0) == (-2000) & 0xFFFF
# values API shows the signed number again # values API shows the signed number again
r = await client.get("/api/values") r = await client.get("/api/values")
entry = next(v for v in r.json()["values"] entry = next(v for v in r.json()["values"]
if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0)) if (v["kind"], v["module"], v["channel"]) == ("ai", 0, 0))
assert entry["value"] == -2000 assert entry["value"] == -2000
async def test_negative_ao_force(self, client, datastore): async def test_negative_ao_force(self, client, datastore):
r = await client.post("/api/channels/ao/5/0", json={"value": -3000}) r = await client.post("/api/channels/ao/0/0", json={"value": -3000})
assert r.status_code == 200 assert r.status_code == 200
assert datastore.get_output_word(0) == (-3000) & 0xFFFF assert datastore.get_output_word(0) == (-3000) & 0xFFFF
# unsigned AO (default range) still displays raw # unsigned AO (default range) still displays raw
r = await client.get("/api/values") r = await client.get("/api/values")
entry = next(v for v in r.json()["values"] entry = next(v for v in r.json()["values"]
if (v["kind"], v["module"], v["channel"]) == ("ao", 5, 0)) if (v["kind"], v["module"], v["channel"]) == ("ao", 0, 0))
assert entry["value"] == (-3000) & 0xFFFF assert entry["value"] == (-3000) & 0xFFFF
async def test_value_bounds_rejected(self, client): async def test_value_bounds_rejected(self, client):
r = await client.post("/api/channels/ai/4/0", json={"value": -65535}) r = await client.post("/api/channels/ai/0/0", json={"value": -65535})
assert r.status_code == 400 assert r.status_code == 400
r = await client.post("/api/channels/ai/4/0", json={"value": 65536}) r = await client.post("/api/channels/ai/0/0", json={"value": 65536})
assert r.status_code == 400 assert r.status_code == 400
r = await client.post("/api/channels/ai/4/0", json={"value": "banana"}) r = await client.post("/api/channels/ai/0/0", json={"value": "banana"})
assert r.status_code == 400 assert r.status_code == 400
async def test_ranges_persist_to_yaml(self, client, app, tmp_path): async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
cfg_file = tmp_path / "node.yaml" cfg_file = tmp_path / "node.yaml"
app.node_config_path = str(cfg_file) app.node_config_path = str(cfg_file)
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}}, "persist": True, "channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": True,
}) })
assert r.status_code == 200 assert r.status_code == 200
text = cfg_file.read_text(encoding="utf-8") text = cfg_file.read_text(encoding="utf-8")
@@ -563,18 +771,32 @@ class TestChannelRanges:
# loads back # loads back
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
ranges = load_channel_ranges(load_yaml(cfg_file)) ranges = load_channel_ranges(load_yaml(cfg_file))
assert ranges == {"AI4.0": (0, 32000)} assert ranges == {"AI0.0": (0, 32000)}
async def test_ranges_yaml_lean_defaults(self, client, app, tmp_path):
# dense store, lean YAML: default-equal entries are NOT written
cfg_file = tmp_path / "node.yaml"
app.node_config_path = str(cfg_file)
r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095},
"AO0.0": {"min": 0, "max": 32000}},
"persist": True,
})
assert r.status_code == 200
text = cfg_file.read_text(encoding="utf-8")
assert "AO0.0" in text and "AI0.0" not in text
assert app.node.channel_ranges["AI0.0"] == (0, 4095) # store dense
async def test_ranges_survive_module_save(self, client, app, tmp_path): async def test_ranges_survive_module_save(self, client, app, tmp_path):
app.node_config_path = str(tmp_path / "node.yaml") app.node_config_path = str(tmp_path / "node.yaml")
await client.put("/api/config/channel-ranges", json={ await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}}, "persist": True, "channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": True,
}) })
r = await client.put("/api/config/modules", json={ r = await client.put("/api/config/modules", json={
"modules": ["750-430", "750-467"], "persist": True, "modules": ["750-430", "750-467"], "persist": True,
}) })
assert r.status_code == 200 assert r.status_code == 200
# AI_2 is at slot 1 now; the AI4.0 range became stale -> carried # AI_2 is at slot 1 now; the AI0.0 range became stale -> carried
# over but no longer matches a channel; the store keeps it (names # over but no longer matches a channel; the store keeps it (names
# semantics differ: stale name codes are dropped on import only) # semantics differ: stale name codes are dropped on import only)
text = (tmp_path / "node.yaml").read_text(encoding="utf-8") text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
@@ -582,14 +804,14 @@ class TestChannelRanges:
async def test_export_import_carries_ranges(self, client, app, tmp_path): async def test_export_import_carries_ranges(self, client, app, tmp_path):
await client.put("/api/config/channel-ranges", json={ await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 600, "max": 2000}}, "persist": False, "channel_ranges": {"AI0.0": {"min": 600, "max": 2000}}, "persist": False,
}) })
exported = (await client.get("/api/config/export")).json() exported = (await client.get("/api/config/export")).json()
assert exported["channel_ranges"]["AI4.0"] == {"min": 600, "max": 2000} assert exported["channel_ranges"]["AI0.0"] == {"min": 600, "max": 2000}
app.node_config_path = str(tmp_path / "node.yaml") app.node_config_path = str(tmp_path / "node.yaml")
r = await client.post("/api/config/import", json=exported) r = await client.post("/api/config/import", json=exported)
assert r.status_code == 200 assert r.status_code == 200
assert app.node.channel_range("ai", 4, 0) == (600, 2000) assert app.node.channel_ranges["AI0.0"] == (600, 2000)
text = (tmp_path / "node.yaml").read_text(encoding="utf-8") text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
assert "min: 600, max: 2000" in text assert "min: 600, max: 2000" in text