Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647c209e89 | ||
|
|
68d3c42d79 | ||
|
|
f82e8d4be9 | ||
|
|
66b7e12f0f | ||
|
|
263afc9c1e | ||
|
|
eb36a2d947 | ||
|
|
c30b444ef1 | ||
|
|
59679b96ee | ||
|
|
e5df0b04ed | ||
|
|
7948b794f5 |
Vendored
+22
@@ -22,6 +22,28 @@
|
||||
],
|
||||
"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.
|
||||
"name": "wago-sim: run (pump_station_fat)",
|
||||
|
||||
Vendored
+980
@@ -913,6 +913,986 @@
|
||||
],
|
||||
"isBackground": false,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
and Python action plugins for complex plant models.
|
||||
- **Two I/O addressing schemes** for aliases: *module-based* —
|
||||
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module slot and
|
||||
channel both 0-based) — and *pool-based* — `{ type: di, channel: 3 }`
|
||||
(1-based index across the node's DI pool). Both may be mixed.
|
||||
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module **numbered
|
||||
0-based within its type** — `DI0`, `DO0`, `AI0`, `AO0` start each
|
||||
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
|
||||
without a scenario (`wago-sim serve --node ...` already works);
|
||||
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
|
||||
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
|
||||
|
||||
- `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
|
||||
(0x1020/0x1021) and module-diagnosis (0x1050) fault injection
|
||||
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) —
|
||||
node-config persisted (`channel_names:` mapping, I/O code →
|
||||
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
|
||||
complement), for signed plant quantities. DI/DO channels render
|
||||
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,
|
||||
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
|
||||
`ASGITransport` (in-process, no socket) + live verification:
|
||||
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 —
|
||||
see Completed above.)*
|
||||
|
||||
### Phase 3 — Long-duration (continuous) simulation (spec §8)
|
||||
The soak-testing feature requested 2026-10-03 — specified in §8,
|
||||
not yet implemented. Datasets are **optional**: signal logic
|
||||
(plugins, scheduled steps, degradation) runs in parallel with CSV
|
||||
### Phase 3 — Long-duration (continuous) simulation (spec §8) — IMPLEMENTED 2026-10-04
|
||||
The soak-testing feature specified in §8 is implemented: continuous
|
||||
mode with the fixed tick pipeline, optional streaming CSV datasets,
|
||||
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.
|
||||
|
||||
- [ ] `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,
|
||||
`interpolation: linear|hold`, `repeat` with seamless seams,
|
||||
`time_factor` acceleration, `until` limits, bounded memory, ERROR
|
||||
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
|
||||
limits → scheduled steps → snapshots; plugins may read
|
||||
dataset-driven and logic-driven aliases in the same tick
|
||||
(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
|
||||
run; degradation/invariants/rate limits/snapshots apply
|
||||
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
|
||||
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`,
|
||||
`exponential`, `staircase`, plugin shapes) computing plant
|
||||
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
|
||||
(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`
|
||||
action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5,
|
||||
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
|
||||
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).
|
||||
- [ ] 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).
|
||||
- [ ] 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).
|
||||
- [ ] 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,
|
||||
alias-ownership validation, degradation/recovery round-trip,
|
||||
episode recording, full 28-day soak, logic-only run, mixed
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -14,5 +14,9 @@ modules:
|
||||
|
||||
# Application names for module channels (I/O code -> name)
|
||||
channel_names:
|
||||
AO5.0: speed_setpoint
|
||||
DI0.0: start_button
|
||||
AI0.0: NG1
|
||||
AO0.0: P!_BORV
|
||||
DI0.0: P1_DI
|
||||
DI0.1: P1_AUTO
|
||||
DI0.2: P1_FEL
|
||||
DO0.0: P!_START
|
||||
|
||||
+188
-29
@@ -7,7 +7,7 @@
|
||||
| Project | `wago-modbustcp-simulator-py` |
|
||||
| Version | 1.4 (Draft for review) |
|
||||
| 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 |
|
||||
| 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
|
||||
as *module type + module number + channel number*, all components
|
||||
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
|
||||
digital input channel 1 (0-based) on module slot 2 (0-based position in
|
||||
the node's module list), i.e. `DI2.1`. Both numbers are 0-based to match
|
||||
physical slot/channel labeling. The channel kind must match the module
|
||||
(`DO` on a DI-only module is a validation error). This is an alternative
|
||||
to pool addressing (`{ type: di, channel: 3 }` = 3rd DI channel across
|
||||
the whole node); both schemes are supported and may be mixed, but an
|
||||
alias uses exactly one. **Implemented** in v1.1 (`channel_by_module`,
|
||||
`Alias.module`, `alias_location`).
|
||||
digital input channel 1 (0-based) on DI-module 2 (0-based **number
|
||||
among the modules of the same type**, i.e. the third DI module in the
|
||||
node), i.e. `DI2.1`. Channel numbers are 0-based. The channel kind must
|
||||
match the module (`DO` on a DI-only module is a validation error).
|
||||
This is an alternative to addressing by physical slot; both pool
|
||||
addressing (`{ type: di, channel: 3 }` = 3rd DI channel across the
|
||||
whole node) and module addressing are supported and may be mixed,
|
||||
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
|
||||
|
||||
@@ -469,8 +478,8 @@ stateDiagram-v2
|
||||
|
||||
## 8. Feature Request — Long-Duration (Continuous) Simulation
|
||||
|
||||
> **Status: requested 2026-10-03, planned (roadmap phase 3), not yet implemented.**
|
||||
> Extends the scenario engine (§4) with a `continuous` mode for endurance
|
||||
> **Status: implemented 2026-10-04** (roadmap phase 3 complete). Extends
|
||||
> the scenario engine (§4) with a `continuous` mode for endurance
|
||||
> (“soak”) testing: the facility runs for hours, days or weeks of simulated
|
||||
> 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,
|
||||
(7) snapshots are taken if due.
|
||||
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
|
||||
all, continuous mode shall work unchanged — demand profiles and other
|
||||
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,
|
||||
invariants, rate limits, snapshots) applies identically, whether or not
|
||||
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)
|
||||
|
||||
@@ -573,8 +639,11 @@ unchanged whether or not CSV streaming is used.
|
||||
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.
|
||||
`pump_capacity`, range 0…1) from wear counters and sim-time. Built-in
|
||||
shapes: `linear` (per runtime hour, per start cycle), `exponential`,
|
||||
`staircase` (threshold table); custom shapes via Python plugin.
|
||||
shapes: `linear` (per runtime hour via `rate:`, or per start cycle with
|
||||
`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
|
||||
`engine.parameter("pump_capacity")` and shall use them in the loop physics
|
||||
(e.g., actual pump flow = nominal flow × capacity), so the PLC must
|
||||
@@ -649,31 +718,30 @@ datasets:
|
||||
repeat: 4
|
||||
|
||||
degradation:
|
||||
pump_wear:
|
||||
pump_capacity:
|
||||
observes: pump_relay # wear counts from this alias
|
||||
model: linear
|
||||
parameter: pump_capacity # consumed by the plant model
|
||||
per_runtime_hour: 0.0004 # 0.04 %/h → ~10 % after ~10.4 sim-days
|
||||
per_start_cycle: 0.0002 # 0.02 % per start
|
||||
floor: 0.80
|
||||
maintenance_at: 0.90
|
||||
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 }
|
||||
rate: 0.0004 # -0.04 %/h runtime → ~10 % after ~10.4 sim-days
|
||||
from: 1.0
|
||||
maintenance_at: 0.90 # threshold crossing injects diagnosis
|
||||
diagnosis: { module: 4, channel: 1, value: 0x0001 }
|
||||
# the PLC raises maintenance_alarm; the scenario asserts it:
|
||||
|
||||
sequence: # scheduled events (sim-time)
|
||||
- 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)
|
||||
|
||||
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:
|
||||
- { alias: pump_relay, edge: rising, max_per_hour: 6, label: "pump start cycles" }
|
||||
- { alias: speed_setpoint, max_per_hour: 3000 } # ramp-rate bound
|
||||
|
||||
report:
|
||||
snapshot_every: 1h
|
||||
@@ -735,15 +803,20 @@ src/wago_sim/scenario/
|
||||
│ # time_factor — OPTIONAL driver in continuous mode
|
||||
├── degradation.py # WearCounters, DegradationModel registry, maintenance actions
|
||||
├── 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
|
||||
# scheduler; fixed tick pipeline per FR-8.2.12:
|
||||
# datasets -> edges -> plugins -> degradation ->
|
||||
# datasets -> edges -> plugins/env -> degradation ->
|
||||
# invariants -> scheduled steps -> snapshots
|
||||
```
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ContinuousScenario ||--o{ Dataset : "datasets (optional)"
|
||||
ContinuousScenario ||--|| PlantEnv : "environment (FR-8.2.14)"
|
||||
PlantEnv ||--o{ DecisionSource : "plugins / external agents"
|
||||
Dataset ||--|{ ColumnMapping : "alias columns"
|
||||
ContinuousScenario ||--|{ PlantPlugin : "signal logic (always runs)"
|
||||
ContinuousScenario ||--|{ DegradationModel : degradation
|
||||
@@ -755,6 +828,7 @@ erDiagram
|
||||
ContinuousScenario ||--o{ Snapshot : "periodic dumps"
|
||||
PlantPlugin }o--|| DegradationModel : "reads parameters"
|
||||
PlantPlugin }o--o{ Alias : "reads dataset-driven + computes logic-driven"
|
||||
PlantEnv }o--|| ProcessImage : "observe/apply via datastore"
|
||||
```
|
||||
|
||||
### 8.7 Non-functional requirements (long-duration)
|
||||
@@ -800,6 +874,18 @@ erDiagram
|
||||
tick-order test.
|
||||
11. A continuous scenario with no datasets, no scheduled steps, and no
|
||||
`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
|
||||
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.
|
||||
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
|
||||
block (0x1029, with reset button), module diagnosis (0x1050), LED error
|
||||
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
|
||||
numbers within the same window; values are stored in the image
|
||||
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)
|
||||
|
||||
@@ -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 /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-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
|
||||
|
||||
@@ -1047,7 +1187,10 @@ src/wago_sim/
|
||||
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
|
||||
| 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) |
|
||||
| M5b | Analog display ranges (FR-10.2.14) |
|
||||
| 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
|
||||
|
||||
@@ -1063,6 +1206,13 @@ src/wago_sim/
|
||||
with a visible message; the scenario continues unaffected.
|
||||
6. Two simultaneous browsers stay consistent with each other and with
|
||||
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
|
||||
description, new image sizes) and visible in the web UI's I/O Config
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
inputs: # simulated field signals (what the PLC reads)
|
||||
# module-based addressing: module slot and
|
||||
# channel number on the module, both 0-based
|
||||
# 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: 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
|
||||
pump_relay: { type: do, module: 2, channel: 0 } # DO2.0
|
||||
valve_open: { type: do, module: 2, channel: 1 } # DO2.1
|
||||
speed_setpoint: { type: ao, module: 5, channel: 0, scale: 0.1 } # AO5.0
|
||||
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:
|
||||
|
||||
+13
-2
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .datastore import WagoDatastore
|
||||
from .envrunner import EnvRunner
|
||||
from .modules import ModuleType, VirtualNode
|
||||
from .registers import ConfigRegisterBank
|
||||
from .server import WagoSimServer
|
||||
@@ -36,6 +37,8 @@ class SimulatorApp:
|
||||
server: WagoSimServer
|
||||
web: WebUIConfig | None = None
|
||||
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
|
||||
def modules(self) -> list[ModuleType]:
|
||||
@@ -60,9 +63,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
|
||||
datastore = app.datastore
|
||||
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
|
||||
# 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()
|
||||
app.node = VirtualNode(datastore, modules,
|
||||
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(
|
||||
analog_in=app.node.analog_in_bits,
|
||||
analog_out=app.node.analog_out_bits,
|
||||
@@ -145,7 +154,7 @@ def build_app(
|
||||
idle_timeout_provider=lambda: registers.idle_timeout_ms,
|
||||
)
|
||||
server_ref: dict[str, WagoSimServer] = {"server": server}
|
||||
return SimulatorApp(
|
||||
app = SimulatorApp(
|
||||
datastore=datastore,
|
||||
node=node,
|
||||
stats=stats,
|
||||
@@ -155,3 +164,5 @@ def build_app(
|
||||
web=web,
|
||||
node_config_path=node_config_path,
|
||||
)
|
||||
app.runner = EnvRunner(node)
|
||||
return app
|
||||
+68
-3
@@ -35,6 +35,7 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
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.
|
||||
|
||||
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,
|
||||
node_config_path=args.node, channel_names=channel_names,
|
||||
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()
|
||||
web_server = 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})")
|
||||
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
|
||||
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).
|
||||
# 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:
|
||||
pass # SIGINT via asyncio.run's handler
|
||||
finally:
|
||||
if app.runner.env is not None:
|
||||
await app.runner.stop()
|
||||
if web_server is not None:
|
||||
task = getattr(web_server, "_serve_task", None)
|
||||
web_server.should_exit = True
|
||||
@@ -231,6 +246,34 @@ async def _serve(args: argparse.Namespace) -> int:
|
||||
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:
|
||||
node_config = _load_yaml_file(args.node, "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}")
|
||||
engine.start()
|
||||
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
|
||||
while result is None and ticks < max_ticks:
|
||||
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)
|
||||
return 3
|
||||
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)
|
||||
if 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),
|
||||
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)
|
||||
|
||||
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.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)
|
||||
_build_logger(args.verbose)
|
||||
# `--node` may come from the environment when omitted (service mode);
|
||||
|
||||
@@ -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
@@ -196,28 +196,77 @@ class VirtualNode:
|
||||
self.digital_in_bits = digital_in_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:
|
||||
"""Text for firmware-info register 0x2030 (module description)."""
|
||||
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
|
||||
|
||||
# -- channel naming (spec FR-10.2.11) ----------------------------------
|
||||
|
||||
def io_code(self, kind: str, module: int, channel: int) -> str:
|
||||
"""I/O code for a module channel, e.g. ('di', 0, 0) -> 'DI0.0'."""
|
||||
return f"{kind.upper()}{module}.{channel}"
|
||||
def io_code_at_slot(self, kind: str, module_slot: int, channel: int) -> str:
|
||||
"""I/O code for a channel identified by PHYSICAL slot, e.g.
|
||||
('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."""
|
||||
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.
|
||||
|
||||
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(
|
||||
self.io_code(kind, module, channel), self.DEFAULT_RANGE
|
||||
)
|
||||
if kind not in ("ai", "ao"):
|
||||
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]:
|
||||
"""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),
|
||||
("ai", self._ai_channels), ("ao", self._ao_channels)):
|
||||
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)
|
||||
|
||||
# -- named channel access (scenario engine API) -----------------------------
|
||||
@@ -256,24 +305,57 @@ class VirtualNode:
|
||||
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
|
||||
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:
|
||||
"""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
|
||||
number*, e.g. DI0.1 = digital input, module slot 0, channel 1.
|
||||
The module number is the position in the configured module list;
|
||||
the channel number is the channel on that module (0-based), i.e.
|
||||
DI0.1 selects the module's 2nd digital input.
|
||||
number*, e.g. DI1.0 = digital inputs, the 2nd DI module, channel 0
|
||||
(0-based) — **the module number counts within the module's type**
|
||||
(FR-4.3.11 v1.5): DI modules are numbered DI0, DI1, … regardless
|
||||
of their physical slots among DO/AI/AO modules.
|
||||
|
||||
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(
|
||||
f"no module {module} (node has {len(self.modules)} modules, "
|
||||
f"numbered 0-{len(self.modules) - 1})"
|
||||
f"no {kind.upper()} module {module} "
|
||||
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 = {
|
||||
"di": (self._di_channels, mod.input_bits),
|
||||
"do": (self._do_channels, mod.output_bits),
|
||||
@@ -285,24 +367,41 @@ class VirtualNode:
|
||||
pool, module_channel_count = pool_map[kind]
|
||||
if module_channel_count == 0:
|
||||
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:
|
||||
raise IndexError(
|
||||
f"module {module} ({mod.article}) has no {kind.upper()} "
|
||||
f"channel {channel} (has {module_channel_count}: 0-"
|
||||
f"{module_channel_count - 1})"
|
||||
f"{kind.upper()} module {module} ({mod.article}) has no "
|
||||
f"{kind.upper()} channel {channel} (has {module_channel_count}: "
|
||||
f"0-{module_channel_count - 1})"
|
||||
)
|
||||
# find the location: channels of one module are contiguous in the
|
||||
# kind pool; locate the first channel of this module in the pool
|
||||
# channels of one module are contiguous in the kind pool; find the
|
||||
# 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:
|
||||
if loc.module_index == module and loc.channel == channel + 1:
|
||||
if (loc.module_index == physical_slot
|
||||
and loc.channel == channel + 1):
|
||||
return loc
|
||||
raise ChannelMismatch(
|
||||
f"module {module} ({mod.article}) channel {channel} is not a "
|
||||
f"{kind.upper()} channel"
|
||||
f"{kind.upper()} module {module} ({mod.article}) 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) -------------------------------------
|
||||
|
||||
def set_di(self, channel: int, value: bool) -> None:
|
||||
|
||||
@@ -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
|
||||
@@ -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()},
|
||||
}
|
||||
@@ -136,6 +136,50 @@ def validate_scenario(scenario: dict) -> list[str]:
|
||||
f"'module' is given"
|
||||
)
|
||||
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")
|
||||
# -- 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
|
||||
@@ -155,6 +155,122 @@ class ScenarioEngine:
|
||||
}
|
||||
self._macro_return_wait: float | None = None
|
||||
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 ---------------------------------------------------------------
|
||||
|
||||
@@ -328,6 +444,8 @@ class ScenarioEngine:
|
||||
return self._result
|
||||
dt = dt_ms if dt_ms is not None else float(self.TICK_MS)
|
||||
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
|
||||
# written by the master/plant)
|
||||
self._detect_edges()
|
||||
@@ -377,6 +495,118 @@ class ScenarioEngine:
|
||||
if fn is not 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:
|
||||
"""Execute one queued step.
|
||||
|
||||
@@ -531,6 +761,15 @@ class ScenarioError(ValueError):
|
||||
"""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:
|
||||
"""Parse '250ms', '30s', '2m', or a raw number (milliseconds)."""
|
||||
if isinstance(value, (int, float)):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -22,7 +22,8 @@ def result_summary(result: ScenarioResult) -> str:
|
||||
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)
|
||||
with path.open("w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({
|
||||
@@ -39,6 +40,10 @@ def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
|
||||
"detail": step.detail,
|
||||
"sim_time_ms": step.sim_time_ms,
|
||||
}) + "\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:
|
||||
|
||||
+136
-14
@@ -14,7 +14,8 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
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 ..watchdog import RegisterWriteRefused
|
||||
from . import state
|
||||
@@ -417,7 +418,7 @@ async def put_module_config(
|
||||
save_node_config(sim.node_config_path, modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=sim.node.channel_names,
|
||||
channel_ranges=sim.node.channel_ranges)
|
||||
channel_ranges=_nondefault_ranges(sim))
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
@@ -485,6 +486,7 @@ async def put_channel_names(
|
||||
sim.node_config_path, sim.node.modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=names,
|
||||
channel_ranges=sim.node.channel_ranges,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
@@ -520,8 +522,10 @@ async def put_channel_ranges(
|
||||
"""Set per-channel analog display ranges (FR-10.2.14).
|
||||
|
||||
Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}.
|
||||
Codes are regex-validated; 0 <= min < max <= 65535 required; an
|
||||
entry with min=0/max=4095 (the default) is dropped from the store.
|
||||
Codes are regex-validated; -65534 <= min < max <= 65535 required.
|
||||
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)
|
||||
raw = payload.get("channel_ranges")
|
||||
@@ -550,10 +554,9 @@ async def put_channel_ranges(
|
||||
status_code=400,
|
||||
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535",
|
||||
)
|
||||
if (lo, hi) == (0, 4095):
|
||||
continue # default — keep the store clean
|
||||
ranges[code_up] = (lo, hi)
|
||||
sim.node.channel_ranges = ranges
|
||||
ranges[code_up] = (lo, hi) # dense store keeps defaults
|
||||
sim.node.set_channel_ranges(ranges)
|
||||
persisted = _nondefault_ranges(sim)
|
||||
if payload.get("persist", True) and sim.node_config_path:
|
||||
from .persist import save_node_config
|
||||
try:
|
||||
@@ -561,7 +564,7 @@ async def put_channel_ranges(
|
||||
sim.node_config_path, sim.node.modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=sim.node.channel_names,
|
||||
channel_ranges=ranges,
|
||||
channel_ranges=persisted,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
@@ -570,10 +573,119 @@ async def put_channel_ranges(
|
||||
) from None
|
||||
return {
|
||||
"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) ---------------------------------
|
||||
|
||||
|
||||
@@ -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")
|
||||
async def export_config(
|
||||
sim: SimulatorApp = Depends(get_sim),
|
||||
@@ -606,7 +726,7 @@ async def export_config(
|
||||
"modules": [m.article for m in sim.node.modules],
|
||||
"channel_names": dict(sim.node.channel_names),
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -663,11 +783,13 @@ async def import_config(
|
||||
if not -65534 <= lo < hi <= 65535:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535")
|
||||
if (lo, hi) != (0, 4095):
|
||||
ranges[code_up] = (lo, hi)
|
||||
ranges[code_up] = (lo, hi) # defaults kept; the store is dense
|
||||
coupler = payload.get("coupler_settings") or {}
|
||||
|
||||
# -- 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)
|
||||
stale = sim.node.validate_channel_names()
|
||||
if stale:
|
||||
@@ -703,7 +825,7 @@ async def import_config(
|
||||
sim.node_config_path, modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=names,
|
||||
channel_ranges=ranges,
|
||||
channel_ranges=_nondefault_ranges(sim),
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..modules import ModuleType
|
||||
from ..modules import ModuleType, VirtualNode
|
||||
|
||||
_HEADER = """\
|
||||
# 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):
|
||||
lines.append(f" {code}: {channel_names[code]}\n")
|
||||
if channel_ranges:
|
||||
lines.append(_RANGES_HEADER)
|
||||
for code in sorted(channel_ranges):
|
||||
lo, hi = channel_ranges[code]
|
||||
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n")
|
||||
# lean YAML: the runtime store is dense (every analog channel,
|
||||
# FR-10.2.14) — persist only explicit, non-default entries so a
|
||||
# hand-edited config stays minimal and defaults survive a
|
||||
# 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")
|
||||
+22
-15
@@ -16,15 +16,16 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
|
||||
modules_json = []
|
||||
for index, mod in enumerate(node.modules):
|
||||
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:
|
||||
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
|
||||
io_code = f"{kind_upper}{index}.{channel_no}"
|
||||
lo, hi = (0, 4095)
|
||||
if ch.kind in ("ai", "ao"):
|
||||
lo, hi = node.channel_ranges.get(io_code, (0, 4095))
|
||||
io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}"
|
||||
# dense range store (FR-10.2.14): every analog channel has
|
||||
# an entry (defaults materialized at layout time)
|
||||
lo, hi = node.channel_ranges[io_code] \
|
||||
if ch.kind in ("ai", "ao") else (0, 4095)
|
||||
channels.append(
|
||||
{
|
||||
"name": ch.name,
|
||||
@@ -75,23 +76,27 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
|
||||
out: list[ChannelValue] = []
|
||||
|
||||
# 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):
|
||||
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(
|
||||
{
|
||||
"kind": "di",
|
||||
"module": loc.module_index,
|
||||
"module": kind_no,
|
||||
"channel": loc.channel - 1,
|
||||
"value": ds.digital_input_channel(i),
|
||||
"name": node.channel_names.get(code),
|
||||
}
|
||||
)
|
||||
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(
|
||||
{
|
||||
"kind": "do",
|
||||
"module": loc.module_index,
|
||||
"module": kind_no,
|
||||
"channel": loc.channel - 1,
|
||||
"value": ds.digital_output_channel(i),
|
||||
"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
|
||||
# value so the UI, the bar, and the configured range agree.
|
||||
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(
|
||||
{
|
||||
"kind": "ai",
|
||||
"module": loc.module_index,
|
||||
"module": kind_no,
|
||||
"channel": loc.channel - 1,
|
||||
"value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)),
|
||||
"name": node.channel_names.get(code),
|
||||
}
|
||||
)
|
||||
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(
|
||||
{
|
||||
"kind": "ao",
|
||||
"module": loc.module_index,
|
||||
"module": kind_no,
|
||||
"channel": loc.channel - 1,
|
||||
"value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)),
|
||||
"name": node.channel_names.get(code),
|
||||
|
||||
@@ -28,9 +28,90 @@ async function init() {
|
||||
renderRail();
|
||||
renderChannelList();
|
||||
if (diag) renderDiagnostics(diag);
|
||||
refreshEnvCard();
|
||||
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> ${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() {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
WS = new WebSocket(`${proto}://${location.host}/api/ws`);
|
||||
@@ -75,19 +156,20 @@ function renderChannelList() {
|
||||
for (const mod of NODE.modules) {
|
||||
for (const ch of mod.channels) {
|
||||
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>`
|
||||
: `<span class="row-name unnamed">—</span>`;
|
||||
let control;
|
||||
if (ch.kind === "di" || ch.kind === "do") {
|
||||
control = `<label class="switch"><input type="checkbox" class="row-value"
|
||||
id="val-${ch.kind}-${mod.index}-${no}"
|
||||
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">
|
||||
id="val-${ch.kind}-${mno}-${no}"
|
||||
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">
|
||||
<span class="slider"></span></label>`;
|
||||
} else {
|
||||
control = `<input type="number" class="row-value num" min="-65534" max="65535"
|
||||
id="val-${ch.kind}-${mod.index}-${no}"
|
||||
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`;
|
||||
id="val-${ch.kind}-${mno}-${no}"
|
||||
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">`;
|
||||
}
|
||||
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
|
||||
<span class="row-io">${ch.address}</span>
|
||||
@@ -253,7 +335,6 @@ async function importConfig(file) {
|
||||
function renderRail() {
|
||||
const rail = document.getElementById("rail");
|
||||
rail.innerHTML = "";
|
||||
|
||||
// coupler block on the left
|
||||
const coupler = document.createElement("div");
|
||||
coupler.className = "coupler";
|
||||
@@ -293,7 +374,7 @@ function renderRail() {
|
||||
html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`;
|
||||
let chIndex = 0;
|
||||
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
|
||||
if (isDigital) {
|
||||
if (chIndex > 0 && chIndex % 8 === 0) {
|
||||
@@ -302,7 +383,7 @@ function renderRail() {
|
||||
}
|
||||
html += `<span class="led-cell">
|
||||
<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>`;
|
||||
chIndex += 1;
|
||||
} else {
|
||||
@@ -312,7 +393,8 @@ function renderRail() {
|
||||
html += `<span class="abar-cell">
|
||||
<span class="abar-label top-label">${chNo}</span>
|
||||
<span class="lim-led lim-over" id="${id}-over" title="overflow (> ${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="lim-led lim-under" id="${id}-under" title="underflow (< ${rg.min})"></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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const id = `ch-${kind}-${module}-${channel}`;
|
||||
const el = document.getElementById(id);
|
||||
@@ -370,9 +459,12 @@ function updateChannel(kind, module, channel, value) {
|
||||
function findChannel(kind, module, channel) {
|
||||
if (!NODE) return null;
|
||||
for (const mod of NODE.modules) {
|
||||
if (mod.index !== module) continue;
|
||||
return mod.channels.find((c) => c.kind === kind &&
|
||||
channelNo(c.address) === channel) || null;
|
||||
for (const c of mod.channels) {
|
||||
if (c.kind === kind && moduleNo(c.address) === module
|
||||
&& channelNo(c.address) === channel) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 ----------------
|
||||
|
||||
async function renderDiagnostics(diag) {
|
||||
@@ -495,13 +609,25 @@ async function renderConfig() {
|
||||
renderNameEditor();
|
||||
const list = document.getElementById("module-list");
|
||||
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">☰</span>
|
||||
<input type="radio" name="mod" value="${i}">
|
||||
<b>${i}</b> ${m.article} — ${m.description}
|
||||
<span class="mod-io">${m.input_bits ? m.input_bits + " DI" : ""}
|
||||
${m.output_bits ? m.output_bits + " DO" : ""}
|
||||
${m.input_words ? m.input_words + " AIw" : ""}
|
||||
${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">▲</button>
|
||||
<button class="mini" onclick="moveModule(${i}, +1)"
|
||||
${i === mc.modules.length - 1 ? "disabled" : ""}
|
||||
title="Move down">▼</button>
|
||||
</span>
|
||||
</label>`).join("");
|
||||
const pick = document.getElementById("catalog-pick");
|
||||
pick.innerHTML = mc.catalog.map((c) =>
|
||||
@@ -597,18 +723,77 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
});
|
||||
document.getElementById("module-save").addEventListener("click", saveModules);
|
||||
setInterval(refreshEnvCard, 1000); // env status: 1 Hz
|
||||
});
|
||||
|
||||
function renderModuleRows() {
|
||||
// local edit view until SAVE CONFIG is pressed
|
||||
const list = document.getElementById("module-list");
|
||||
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">☰</span>
|
||||
<input type="radio" name="mod" value="${i}">
|
||||
<b>${i}</b> ${a}
|
||||
<span class="row-buttons">
|
||||
<button class="mini" onclick="moveModule(${i}, -1)"
|
||||
${i === 0 ? "disabled" : ""} title="Move up">▲</button>
|
||||
<button class="mini" onclick="moveModule(${i}, +1)"
|
||||
${i === MODULE_ARTICLES.length - 1 ? "disabled" : ""}
|
||||
title="Move down">▼</button>
|
||||
</span>
|
||||
</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 ----------------
|
||||
|
||||
document.querySelectorAll(".tab").forEach((btn) => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>WAGO 750-362 Simulator</title>
|
||||
<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>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -33,6 +33,12 @@
|
||||
<div id="rail" class="rail"></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>
|
||||
<div class="chlist-head">
|
||||
<span>I/O</span><span>Name</span><span>Value</span>
|
||||
@@ -111,6 +117,6 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/app.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>WAGO Simulator — Login</title>
|
||||
<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>
|
||||
<body class="login-body">
|
||||
<div class="login-card">
|
||||
|
||||
@@ -190,6 +190,7 @@ h2 { margin: 4px 0 14px; }
|
||||
border-radius: 3px;
|
||||
overflow: hidden; /* clip the fill inside the rounded bar */
|
||||
display: inline-block;
|
||||
cursor: pointer; /* clickable: sets value at click height */
|
||||
}
|
||||
.abar-fill {
|
||||
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; }
|
||||
.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 ---------- */
|
||||
|
||||
@@ -258,6 +277,27 @@ h2 { margin: 4px 0 14px; }
|
||||
}
|
||||
.module-row:hover { background: #eaf1f8; }
|
||||
.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; }
|
||||
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
|
||||
button.primary { background: #2e7d32; }
|
||||
|
||||
@@ -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
|
||||
@@ -88,13 +88,15 @@ class TestAliases:
|
||||
class TestModuleAddressing:
|
||||
"""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]
|
||||
(indices 0..5). Module addressing uses 0-based module positions and
|
||||
0-based channel numbers on the module.
|
||||
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2].
|
||||
Module addressing (FR-4.3.11 v1.5) numbers modules 0-based WITHIN
|
||||
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):
|
||||
# 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["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
|
||||
engine = ScenarioEngine(node, scenario)
|
||||
@@ -104,35 +106,42 @@ class TestModuleAddressing:
|
||||
assert engine.alias_value("second_di") is True
|
||||
|
||||
def test_module_addressing_across_modules(self, node, datastore):
|
||||
# DI1.0: module slot 1 (DI_16), channel 0 = 9th DI globally
|
||||
# (8 channels on module 0 come first)
|
||||
# DI1.0: DI module 1 (DI_16, physical slot 1), channel 0 = 9th DI
|
||||
scenario = _scenario()
|
||||
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
|
||||
engine = ScenarioEngine(node, scenario)
|
||||
engine.set_alias("di16_first", True)
|
||||
assert datastore.read_bits(8, 1) == [True]
|
||||
|
||||
def test_module_addressing_do(self, node, datastore):
|
||||
# DO2.2: module slot 2 (DO_8), channel 2 = 3rd DO globally
|
||||
def test_module_addressing_do_interleaved(self, node, datastore):
|
||||
# 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["outputs"]["third_do"] = {"type": "do", "module": 2, "channel": 2}
|
||||
scenario["outputs"]["third_do"] = {"type": "do", "module": 0, "channel": 2}
|
||||
engine = ScenarioEngine(node, scenario)
|
||||
bit = engine.alias_location("third_do").bit
|
||||
assert bit == 2
|
||||
assert datastore.write_bits(bit, [True]) is None
|
||||
assert engine.alias_value("third_do") is True
|
||||
|
||||
def test_module_addressing_ai_ao(self, node, datastore):
|
||||
# AI4.1: module slot 4 (AI_2), channel 1 = 2nd AI word
|
||||
def test_do_module_1_is_do16(self, node):
|
||||
# DO1.0: DO module 1 is the DO_16 at physical slot 3
|
||||
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.set_alias("ai2", 0x1234)
|
||||
loc = node.channel("ai", 2) # global 2nd AI
|
||||
assert datastore.get_input_word(loc.word) == 0x1234
|
||||
|
||||
# AO5.1: module slot 5 (AO_2), channel 1 = 2nd AO word
|
||||
scenario["outputs"] = {"ao2": {"type": "ao", "module": 5, "channel": 1}}
|
||||
# AO0.1: AO module 0 (AO_2 at physical slot 5), channel 1 = 2nd AO word
|
||||
scenario["outputs"] = {"ao2": {"type": "ao", "module": 0, "channel": 1}}
|
||||
engine2 = ScenarioEngine(node, scenario)
|
||||
bit_word = node.channel("ao", 2).word
|
||||
assert datastore.write_words(bit_word, [0xBEEF]) is None
|
||||
@@ -149,31 +158,34 @@ class TestModuleAddressing:
|
||||
|
||||
def test_module_out_of_range_rejected(self, node):
|
||||
scenario = _scenario()
|
||||
scenario["inputs"]["bad"] = {"type": "di", "module": 99, "channel": 0}
|
||||
with pytest.raises(IndexError, match="module 99"):
|
||||
# the node has only 2 DI modules
|
||||
scenario["inputs"]["bad"] = {"type": "di", "module": 2, "channel": 0}
|
||||
with pytest.raises(IndexError, match="DI module 2"):
|
||||
ScenarioEngine(node, scenario)
|
||||
|
||||
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["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
|
||||
with pytest.raises(IndexError, match="channel 8"):
|
||||
ScenarioEngine(node, scenario)
|
||||
|
||||
def test_kind_mismatch_rejected(self, node):
|
||||
# module 0 is DI-only -> DO addressing must fail
|
||||
def test_module_kind_routes_to_own_type(self, node):
|
||||
"""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["outputs"]["bad"] = {"type": "do", "module": 0, "channel": 0}
|
||||
from wago_sim.modules import ChannelMismatch
|
||||
with pytest.raises(ChannelMismatch):
|
||||
ScenarioEngine(node, scenario)
|
||||
scenario["outputs"]["do8"] = {"type": "do", "module": 0, "channel": 0}
|
||||
scenario["outputs"]["do16"] = {"type": "do", "module": 1, "channel": 0}
|
||||
engine = 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):
|
||||
# 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["inputs"]["bad"] = {"type": "ai", "module": 0, "channel": 0}
|
||||
from wago_sim.modules import ChannelMismatch
|
||||
with pytest.raises(ChannelMismatch):
|
||||
scenario["inputs"]["bad"] = {"type": "ai", "module": 9, "channel": 0}
|
||||
with pytest.raises(IndexError):
|
||||
ScenarioEngine(node, scenario)
|
||||
|
||||
|
||||
|
||||
+262
-40
@@ -3,6 +3,9 @@ diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -106,7 +109,7 @@ class TestManipulation:
|
||||
|
||||
async def test_toggle_do_forces_output_image(self, client, datastore):
|
||||
# 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
|
||||
# DO channel 1 is forced ON in the OUTPUT image (master-side)
|
||||
assert datastore.digital_output_channel(1) is True
|
||||
@@ -117,13 +120,13 @@ class TestManipulation:
|
||||
async def test_ai_value_set(self, client, datastore):
|
||||
# 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
|
||||
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.json()["value"] == 200
|
||||
assert datastore.get_input_word(0) == 200
|
||||
|
||||
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
|
||||
# written at the output image word 0 -> read back at +0x0200
|
||||
assert datastore.read_words(0x0200, 1) == [1234]
|
||||
@@ -133,9 +136,13 @@ class TestManipulation:
|
||||
assert r.status_code == 404
|
||||
|
||||
async def test_kind_mismatch_400(self, client):
|
||||
# DI_16 module (index 1) has no DO channels
|
||||
r = await client.post("/api/channels/do/1/0", json={"action": "toggle"})
|
||||
assert r.status_code == 400
|
||||
# per-type numbering (v1.5): DO module 0 is the DO_8 — asking
|
||||
# for a DO channel beyond its 8 channels is out of range (404);
|
||||
# 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:
|
||||
@@ -187,6 +194,188 @@ class TestReadOnlyMode:
|
||||
r = await c.post("/api/diagnostics/reset-stats")
|
||||
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:
|
||||
async def test_get_coupler_config(self, client, app):
|
||||
@@ -319,7 +508,7 @@ class TestModuleConfig:
|
||||
class TestChannelNames:
|
||||
async def test_names_in_layout_and_values(self, client, app):
|
||||
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,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
@@ -331,23 +520,23 @@ class TestChannelNames:
|
||||
# values snapshot carries it too
|
||||
r = await client.get("/api/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"
|
||||
|
||||
async def test_names_persist_to_yaml(self, client, app, tmp_path):
|
||||
cfg_file = tmp_path / "node.yaml"
|
||||
app.node_config_path = str(cfg_file)
|
||||
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
|
||||
text = cfg_file.read_text(encoding="utf-8")
|
||||
assert "channel_names:" in text
|
||||
assert "DO2.0: pump_relay" in text
|
||||
assert "DO0.0: pump_relay" in text
|
||||
# and it loads back
|
||||
from wago_sim.scenario.dsl import load_yaml, load_channel_names
|
||||
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):
|
||||
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):
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000},
|
||||
"AO5.0": {"min": 400, "max": 2000}},
|
||||
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000},
|
||||
"AO0.0": {"min": 400, "max": 2000}},
|
||||
"persist": False,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
# layout reflects the configured range
|
||||
r = await client.get("/api/node")
|
||||
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 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={
|
||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 4095}}, # == default
|
||||
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default
|
||||
"persist": False,
|
||||
})
|
||||
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):
|
||||
base = {"persist": False}
|
||||
@@ -490,71 +698,71 @@ class TestChannelRanges:
|
||||
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
|
||||
assert r.status_code == 400 # bad code
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
async def test_negative_range_accepted(self, client, app):
|
||||
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,
|
||||
})
|
||||
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
|
||||
r = await client.get("/api/node")
|
||||
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}
|
||||
|
||||
async def test_range_floor_is_minus_65534(self, client):
|
||||
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
|
||||
|
||||
async def test_signed_value_display_and_write(self, client, app, datastore):
|
||||
# channel with a negative-min range -> signed two's-complement view
|
||||
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})
|
||||
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
|
||||
# raw image holds the two's-complement word
|
||||
assert datastore.get_input_word(0) == (-2000) & 0xFFFF
|
||||
# values API shows the signed number again
|
||||
r = await client.get("/api/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
|
||||
|
||||
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 datastore.get_output_word(0) == (-3000) & 0xFFFF
|
||||
# unsigned AO (default range) still displays raw
|
||||
r = await client.get("/api/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
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
|
||||
cfg_file = tmp_path / "node.yaml"
|
||||
app.node_config_path = str(cfg_file)
|
||||
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
|
||||
text = cfg_file.read_text(encoding="utf-8")
|
||||
@@ -563,18 +771,32 @@ class TestChannelRanges:
|
||||
# loads back
|
||||
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
|
||||
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):
|
||||
app.node_config_path = str(tmp_path / "node.yaml")
|
||||
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={
|
||||
"modules": ["750-430", "750-467"], "persist": True,
|
||||
})
|
||||
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
|
||||
# semantics differ: stale name codes are dropped on import only)
|
||||
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):
|
||||
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()
|
||||
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")
|
||||
r = await client.post("/api/config/import", json=exported)
|
||||
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")
|
||||
assert "min: 600, max: 2000" in text
|
||||
|
||||
|
||||
Reference in New Issue
Block a user