🚧 Add environment maneuverability
This commit is contained in:
Vendored
+101
@@ -1792,6 +1792,107 @@
|
|||||||
"--no-header"
|
"--no-header"
|
||||||
],
|
],
|
||||||
"isBackground": false
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -411,6 +411,26 @@ requirement ID for each item in parentheses.
|
|||||||
config export stay lean (non-default entries only).
|
config export stay lean (non-default entries only).
|
||||||
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
|
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
|
||||||
FR-10.2.9) — deferred.
|
FR-10.2.9) — deferred.
|
||||||
|
- [x] M7: plant environment control (FR-10.2.15, FR-10.2.17) — the
|
||||||
|
web UI loads/unloads environments at runtime (`--env` becomes the
|
||||||
|
startup default, not the only option): an environment card shows
|
||||||
|
ref, state (running/paused/done/none), sim-time and tick ms with
|
||||||
|
start (with hot reload of edited source, `sys.modules`
|
||||||
|
invalidation), stop (ticker cancelled, field freezes),
|
||||||
|
pause/resume (instance kept — pause is a deliberate FAT fault:
|
||||||
|
"plant stuck"), reset (deterministic fresh episode) and restart.
|
||||||
|
Exactly one env at a time; `is_done()` ends the ticker (state
|
||||||
|
`done`); SAVE CONFIG (module-list rebuild) discards the env like
|
||||||
|
a KBUS reset discards values; starting while loaded is refused;
|
||||||
|
readonly mode blocks every env mutation. API: `GET /api/env` +
|
||||||
|
`POST /api/env/{start,stop,pause,resume,reset,restart}`.
|
||||||
|
- [ ] M8: environment file authoring (FR-10.2.16, planned) —
|
||||||
|
create/modify/delete environment source files (e.g.
|
||||||
|
`envs/*.py`) through the web UI (editor pane + upload/download).
|
||||||
|
Security note: UI-written Python is RCE by design — same trust
|
||||||
|
boundary as the bench network; disabled in readonly mode; delete
|
||||||
|
refused while the env is loaded; the on-disk directory must be
|
||||||
|
explicit in configuration.
|
||||||
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx
|
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx
|
||||||
`ASGITransport` (in-process, no socket) + live verification:
|
`ASGITransport` (in-process, no socket) + live verification:
|
||||||
browser SAVE CONFIG rebuilds the node and persists YAML; Modbus
|
browser SAVE CONFIG rebuilds the node and persists YAML; Modbus
|
||||||
|
|||||||
@@ -620,14 +620,17 @@ unchanged whether or not CSV streaming is used.
|
|||||||
to create and call — a subclass of `PlantEnv` overriding `model()`
|
to create and call — a subclass of `PlantEnv` overriding `model()`
|
||||||
(and optionally `is_done()`) in any importable module
|
(and optionally `is_done()`) in any importable module
|
||||||
(`--env myenvs.pump_station.PumpStationEnv`), or a module-level
|
(`--env myenvs.pump_station.PumpStationEnv`), or a module-level
|
||||||
factory function. Two runners: (a) `wago-sim serve --env ...` ticks
|
factory function. Three runners: (a) `wago-sim serve --env ...` ticks
|
||||||
the environment on a **wall-clock-paced** periodic task (10 ms tick
|
the environment on a **wall-clock-paced** periodic task (10 ms tick
|
||||||
alongside the Modbus server, real-time behavior for a live PLC) and
|
alongside the Modbus server, real-time behavior for a live PLC) and
|
||||||
runs **until manually stopped** (SIGINT/SIGTERM — the existing
|
runs **until manually stopped** (SIGINT/SIGTERM — the existing
|
||||||
graceful-shutdown path); (b) `wago-sim sim --node ... --env ...`
|
graceful-shutdown path); the ticker itself is owned by an
|
||||||
runs headless unpaced (as fast as determinism allows) until
|
`EnvRunner` on the app so the web UI can stop/pause/resume/reset/
|
||||||
`is_done()` or SIGINT. The scenario-continuous mode (FR-8.2.1-13)
|
restart it at runtime (FR-10.2.15); (b) `wago-sim sim --node ...
|
||||||
remains a scripted layer on top of the same engine ticks.
|
--env ...` runs headless unpaced (as fast as determinism allows)
|
||||||
|
until `is_done()` or SIGINT. The scenario-continuous mode
|
||||||
|
(FR-8.2.1-13) remains a scripted layer on top of the same engine
|
||||||
|
ticks.
|
||||||
|
|
||||||
### 8.3 Requirements — degradation models (plant logic over time)
|
### 8.3 Requirements — degradation models (plant logic over time)
|
||||||
|
|
||||||
@@ -1069,6 +1072,44 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
|||||||
default-fallback guesswork. Persistence (node YAML, config
|
default-fallback guesswork. Persistence (node YAML, config
|
||||||
export) stays **lean**: only explicit non-default entries are
|
export) stays **lean**: only explicit non-default entries are
|
||||||
written, so defaults survive a future DEFAULT_RANGE change.
|
written, so defaults survive a future DEFAULT_RANGE change.
|
||||||
|
- FR-10.2.15: **Plant environment control** (requested 2026-10-04):
|
||||||
|
the web UI can load/unload environments at runtime — the CLI's
|
||||||
|
`--env` becomes the startup default, not the only option. An
|
||||||
|
**environment card** shows the loaded ref, its state (running /
|
||||||
|
paused / done / none), sim-time, and tick ms; controls: **start**
|
||||||
|
(load + tick), **stop** (cancel the ticker, field freezes),
|
||||||
|
**pause/resume** (ticker cancelled or rescheduled, instance
|
||||||
|
kept — pause is a deliberate FAT fault: "plant stuck"),
|
||||||
|
**reset** (`env.reset()`, deterministic fresh episode,
|
||||||
|
NFR-8.7.3), **restart** (stop + start of the same ref, fresh
|
||||||
|
instance). Rules: exactly one environment at a time (one decision
|
||||||
|
source, FR-8.2.15); `is_done()` ends the ticker (state `done`, no
|
||||||
|
auto-restart); a module-list rebuild (SAVE CONFIG, FR-11.2.1)
|
||||||
|
**discards** the env — its bindings (channel bindings in the
|
||||||
|
model, sim state) belong to the old layout, exactly like values
|
||||||
|
are lost on a KBUS reset; starting a new env is refused while
|
||||||
|
one is loaded (stop first — explicit, no silent replacement);
|
||||||
|
readonly mode (FR-10.2.8) disables every mutating control.
|
||||||
|
- FR-10.2.16: **Environment file authoring** (requested 2026-10-04,
|
||||||
|
planned): create/modify/delete environment source files (e.g.
|
||||||
|
`envs/*.py`) through the web UI — an editor pane with the plain
|
||||||
|
PlantEnv subclass pattern (FR-8.2.17), plus upload/download.
|
||||||
|
Security note: the UI writing importable Python is remote code
|
||||||
|
execution **by design**; it requires the same trust boundary as
|
||||||
|
the bench network (auth, FR-10.2.8) and SHALL be disabled in
|
||||||
|
readonly mode; delete is refused while the env is loaded; the
|
||||||
|
on-disk directory must be explicit in configuration (today
|
||||||
|
`envs/` resolves from the working directory, not the installed
|
||||||
|
package). Status: planned, roadmap phase 5.
|
||||||
|
- FR-10.2.17: **Hot reload of environment code** (requested
|
||||||
|
2026-10-04, implemented same day as part of FR-10.2.15): Python
|
||||||
|
caches modules in `sys.modules`; a restart of an env whose
|
||||||
|
source changed on disk would silently reuse the old class.
|
||||||
|
`load_env(ref, reload=True)` — used by the UI's start/restart —
|
||||||
|
invalidates the cached module before import so a fresh instance
|
||||||
|
always picks up the edited code. The CLI's startup `--env` and
|
||||||
|
the headless `sim` runner do not reload (the process is new
|
||||||
|
anyway).
|
||||||
|
|
||||||
### 10.3 Implementation decisions (binding, 2026-10-03)
|
### 10.3 Implementation decisions (binding, 2026-10-03)
|
||||||
|
|
||||||
@@ -1131,6 +1172,10 @@ src/wago_sim/
|
|||||||
| `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 |
|
| `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 |
|
||||||
| `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 |
|
| `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 |
|
||||||
| `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 |
|
| `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 |
|
||||||
|
| `GET/PUT /api/config/channel-ranges` | read/write analog display ranges | 10.2.14 |
|
||||||
|
| `GET /api/env` | environment status: ref, state, sim-time, tick ms | 10.2.15 |
|
||||||
|
| `POST /api/env/start` | load + start an env by ref (refuses while one is loaded) | 10.2.15 |
|
||||||
|
| `POST /api/env/stop` · `pause` · `resume` · `reset` · `restart` | ticker lifecycle; reset = deterministic fresh episode | 10.2.15 |
|
||||||
|
|
||||||
### 10.6 Milestones
|
### 10.6 Milestones
|
||||||
|
|
||||||
@@ -1142,7 +1187,10 @@ src/wago_sim/
|
|||||||
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
|
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
|
||||||
| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence |
|
| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence |
|
||||||
| M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) |
|
| M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) |
|
||||||
|
| M5b | Analog display ranges (FR-10.2.14) |
|
||||||
| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) |
|
| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) |
|
||||||
|
| M7 | Plant environment control (FR-10.2.15 + FR-10.2.17): runtime load/stop/pause/resume/reset/restart + hot reload |
|
||||||
|
| M8 | (planned) Environment file authoring (FR-10.2.16) |
|
||||||
|
|
||||||
### 10.7 Acceptance criteria
|
### 10.7 Acceptance criteria
|
||||||
|
|
||||||
@@ -1158,6 +1206,13 @@ src/wago_sim/
|
|||||||
with a visible message; the scenario continues unaffected.
|
with a visible message; the scenario continues unaffected.
|
||||||
6. Two simultaneous browsers stay consistent with each other and with
|
6. Two simultaneous browsers stay consistent with each other and with
|
||||||
the Modbus image.
|
the Modbus image.
|
||||||
|
7. **Environment control** (FR-10.2.15): an env started from the UI ticks
|
||||||
|
paced (its field changes visible to a Modbus master and the overview);
|
||||||
|
pause freezes the field; reset returns to the initial state; a
|
||||||
|
module-list SAVE CONFIG discards the loaded env (status none); an
|
||||||
|
edit to the env source on disk + restart runs the new code (hot
|
||||||
|
reload); starting while loaded is refused; readonly blocks all
|
||||||
|
env mutations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ LEVEL_HIGH = 2.7 # m — high-level switch threshold
|
|||||||
class PumpStationEnv(PlantEnv):
|
class PumpStationEnv(PlantEnv):
|
||||||
"""Closed-loop tank + pump physics over the pump_node layout."""
|
"""Closed-loop tank + pump physics over the pump_node layout."""
|
||||||
|
|
||||||
TICK_MS = 9.0 # overrides PlantEnv TICK_MS
|
TICK_MS = 10.0 # overrides PlantEnv TICK_MS
|
||||||
|
|
||||||
def __init__(self, *args, stop_after_ms: float | None = None, **kw):
|
def __init__(self, *args, stop_after_ms: float | None = None, **kw):
|
||||||
super().__init__(*args, **kw)
|
super().__init__(*args, **kw)
|
||||||
@@ -49,7 +49,7 @@ class PumpStationEnv(PlantEnv):
|
|||||||
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
||||||
dt_h = dt_s / 3600.0
|
dt_h = dt_s / 3600.0
|
||||||
# what the PLC currently commands (master side: DO/AO)
|
# what the PLC currently commands (master side: DO/AO)
|
||||||
pump_running = bool(state.by_code("DO0.0"))
|
pump_running = bool(state.by_code("DI0.0"))
|
||||||
|
|
||||||
# speed setpoint from AO0.0, normalized over its display range
|
# speed setpoint from AO0.0, normalized over its display range
|
||||||
ao_lo, ao_hi = state.channel_range("AO0.0")
|
ao_lo, ao_hi = state.channel_range("AO0.0")
|
||||||
|
|||||||
@@ -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
|
||||||
+13
-2
@@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from .datastore import WagoDatastore
|
from .datastore import WagoDatastore
|
||||||
|
from .envrunner import EnvRunner
|
||||||
from .modules import ModuleType, VirtualNode
|
from .modules import ModuleType, VirtualNode
|
||||||
from .registers import ConfigRegisterBank
|
from .registers import ConfigRegisterBank
|
||||||
from .server import WagoSimServer
|
from .server import WagoSimServer
|
||||||
@@ -36,6 +37,8 @@ class SimulatorApp:
|
|||||||
server: WagoSimServer
|
server: WagoSimServer
|
||||||
web: WebUIConfig | None = None
|
web: WebUIConfig | None = None
|
||||||
node_config_path: str | None = None # where the module list was loaded from
|
node_config_path: str | None = None # where the module list was loaded from
|
||||||
|
runner: EnvRunner = field(init=False) # env lifecycle owner (FR-10.2.15);
|
||||||
|
# holds the loaded env + its ref
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def modules(self) -> list[ModuleType]:
|
def modules(self) -> list[ModuleType]:
|
||||||
@@ -60,9 +63,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
|
|||||||
datastore = app.datastore
|
datastore = app.datastore
|
||||||
names = channel_names if channel_names is not None else app.node.channel_names
|
names = channel_names if channel_names is not None else app.node.channel_names
|
||||||
ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges
|
ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges
|
||||||
|
# a loaded env binds to the OLD layout (its model() reads channels by
|
||||||
|
# code); the rebuild renumbers everything — discard it like the values
|
||||||
|
# are lost on a KBUS reset (FR-10.2.15 discard rule)
|
||||||
|
app.runner.dispose()
|
||||||
datastore.reset_images()
|
datastore.reset_images()
|
||||||
app.node = VirtualNode(datastore, modules,
|
app.node = VirtualNode(datastore, modules,
|
||||||
channel_names=names, channel_ranges=ranges)
|
channel_names=names, channel_ranges=ranges)
|
||||||
|
# re-point the runner at the new layout so the next start binds to it
|
||||||
|
app.runner = EnvRunner(app.node)
|
||||||
app.registers.set_process_image_bits(
|
app.registers.set_process_image_bits(
|
||||||
analog_in=app.node.analog_in_bits,
|
analog_in=app.node.analog_in_bits,
|
||||||
analog_out=app.node.analog_out_bits,
|
analog_out=app.node.analog_out_bits,
|
||||||
@@ -145,7 +154,7 @@ def build_app(
|
|||||||
idle_timeout_provider=lambda: registers.idle_timeout_ms,
|
idle_timeout_provider=lambda: registers.idle_timeout_ms,
|
||||||
)
|
)
|
||||||
server_ref: dict[str, WagoSimServer] = {"server": server}
|
server_ref: dict[str, WagoSimServer] = {"server": server}
|
||||||
return SimulatorApp(
|
app = SimulatorApp(
|
||||||
datastore=datastore,
|
datastore=datastore,
|
||||||
node=node,
|
node=node,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
@@ -155,3 +164,5 @@ def build_app(
|
|||||||
web=web,
|
web=web,
|
||||||
node_config_path=node_config_path,
|
node_config_path=node_config_path,
|
||||||
)
|
)
|
||||||
|
app.runner = EnvRunner(node)
|
||||||
|
return app
|
||||||
+13
-32
@@ -196,12 +196,14 @@ async def _serve(args: argparse.Namespace) -> int:
|
|||||||
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
|
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
|
||||||
node_config_path=args.node, channel_names=channel_names,
|
node_config_path=args.node, channel_names=channel_names,
|
||||||
channel_ranges=channel_ranges)
|
channel_ranges=channel_ranges)
|
||||||
# optional plant environment (FR-8.2.17): ticked wall-clock-paced
|
# startup default (FR-10.2.15): load BEFORE the servers start so a
|
||||||
# alongside the Modbus server, runs until manually stopped
|
# bad --env fails cleanly with nothing half-started; the UI can
|
||||||
env = None
|
# stop/restart it at runtime
|
||||||
if getattr(args, "env", None):
|
if getattr(args, "env", None):
|
||||||
from .scenario.plantenv import load_env
|
try:
|
||||||
env = load_env(args.env, node=app.node)
|
app.runner.start(args.env)
|
||||||
|
except ScenarioError as exc:
|
||||||
|
_fail(f"--env {args.env}: {exc}")
|
||||||
await app.server.start()
|
await app.server.start()
|
||||||
web_server = None
|
web_server = None
|
||||||
if web_cfg is not None:
|
if web_cfg is not None:
|
||||||
@@ -210,27 +212,10 @@ async def _serve(args: argparse.Namespace) -> int:
|
|||||||
f":{web_cfg.port} (user: {web_cfg.user})")
|
f":{web_cfg.port} (user: {web_cfg.user})")
|
||||||
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
|
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
|
||||||
f"on {args.host}:{args.port} (Ctrl+C to stop)")
|
f"on {args.host}:{args.port} (Ctrl+C to stop)")
|
||||||
env_task = None
|
if app.runner.env is not None:
|
||||||
if env is not None:
|
env = app.runner.env
|
||||||
async def run_env():
|
print(f"Plant environment: {app.runner.env_ref} "
|
||||||
"""Paced ticker (FR-8.2.17): wall-clock-aligned ticks for a
|
f"({env.TICK_MS:.0f} ms paced tick, Ctrl+C to stop)")
|
||||||
live PLC; runs until cancelled by the shutdown path."""
|
|
||||||
import time as _time
|
|
||||||
period = env.TICK_MS / 1000.0
|
|
||||||
next_at = _time.monotonic()
|
|
||||||
while True:
|
|
||||||
env.tick()
|
|
||||||
if env.is_done(env.observe()):
|
|
||||||
log.info("environment is_done() satisfied; env 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
|
|
||||||
env_task = asyncio.create_task(run_env())
|
|
||||||
print(f"Plant environment: {args.env} (10 ms paced tick, Ctrl+C to stop)")
|
|
||||||
|
|
||||||
# Graceful shutdown on SIGINT/SIGTERM (docker stop, systemctl stop).
|
# Graceful shutdown on SIGINT/SIGTERM (docker stop, systemctl stop).
|
||||||
# asyncio.run installs a SIGINT handler; SIGTERM arrives as a raw
|
# asyncio.run installs a SIGINT handler; SIGTERM arrives as a raw
|
||||||
@@ -247,12 +232,8 @@ async def _serve(args: argparse.Namespace) -> int:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass # SIGINT via asyncio.run's handler
|
pass # SIGINT via asyncio.run's handler
|
||||||
finally:
|
finally:
|
||||||
if env_task is not None:
|
if app.runner.env is not None:
|
||||||
env_task.cancel()
|
await app.runner.stop()
|
||||||
try:
|
|
||||||
await env_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
if web_server is not None:
|
if web_server is not None:
|
||||||
task = getattr(web_server, "_serve_task", None)
|
task = getattr(web_server, "_serve_task", None)
|
||||||
web_server.should_exit = True
|
web_server.should_exit = True
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,6 +23,7 @@ constructed on an engine.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -312,14 +313,29 @@ class PlantEnv:
|
|||||||
|
|
||||||
|
|
||||||
def load_env(ref: str, node: VirtualNode | None = None, *,
|
def load_env(ref: str, node: VirtualNode | None = None, *,
|
||||||
engine=None) -> PlantEnv:
|
engine=None, reload: bool = False) -> PlantEnv:
|
||||||
"""Load an environment from a module reference (FR-8.2.17).
|
"""Load an environment from a module reference (FR-8.2.17).
|
||||||
|
|
||||||
Accepts ``pkg.mod.ClassName``, ``pkg.mod.factory_fn`` (returns a
|
Accepts ``pkg.mod.ClassName``, ``pkg.mod.factory_fn`` (returns a
|
||||||
PlantEnv), or a bare ``pkg.mod`` whose module defines either a
|
PlantEnv), or a bare ``pkg.mod`` whose module defines either a
|
||||||
class named ``Env`` or a function ``make_env(...)``.
|
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 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(":")
|
parts = ref.split(":")
|
||||||
attr_path = None
|
attr_path = None
|
||||||
@@ -341,6 +357,12 @@ def load_env(ref: str, node: VirtualNode | None = None, *,
|
|||||||
and leaf is None):
|
and leaf is None):
|
||||||
raise ScenarioError(f"--env {ref!r}: reference must include a module path")
|
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:
|
if mod is None:
|
||||||
try:
|
try:
|
||||||
mod = importlib.import_module(mod_name)
|
mod = importlib.import_module(mod_name)
|
||||||
|
|||||||
@@ -578,6 +578,114 @@ async def put_channel_ranges(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- plant environment control (FR-10.2.15 / FR-10.2.17) -----------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/env")
|
||||||
|
async def get_env(
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
_: None = Depends(require_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Environment status: ref, state, sim-time, tick ms."""
|
||||||
|
return sim.runner.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/start")
|
||||||
|
async def env_start(
|
||||||
|
payload: dict,
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Load + start an environment by module ref (FR-10.2.15).
|
||||||
|
|
||||||
|
Payload: {"ref": "envs.pump_station_env:PumpStationEnv"}.
|
||||||
|
Refused while an env is loaded (stop first); the module is
|
||||||
|
hot-reloaded (FR-10.2.17) so edited source runs.
|
||||||
|
"""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
ref = str(payload.get("ref", "")).strip()
|
||||||
|
if not ref:
|
||||||
|
raise HTTPException(status_code=400, detail="'ref' is required")
|
||||||
|
try:
|
||||||
|
sim.runner.start(ref)
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/stop")
|
||||||
|
async def env_stop(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Cancel the ticker and unload the env (field freezes)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
await sim.runner.stop()
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/pause")
|
||||||
|
async def env_pause(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Pause: ticker cancelled, instance kept (plant-stuck fault)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
try:
|
||||||
|
await sim.runner.pause()
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/resume")
|
||||||
|
async def env_resume(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Resume a paused env (reschedules the ticker)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
try:
|
||||||
|
await sim.runner.resume()
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/reset")
|
||||||
|
async def env_reset(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Deterministic fresh episode (NFR-8.7.3); ticker keeps running."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
try:
|
||||||
|
sim.runner.reset()
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/restart")
|
||||||
|
async def env_restart(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Fresh instance of the same ref (picks up edited source)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
try:
|
||||||
|
await sim.runner.restart()
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
# -- configuration import/export (FR-10.2.12) ---------------------------------
|
# -- configuration import/export (FR-10.2.12) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -679,6 +787,9 @@ async def import_config(
|
|||||||
coupler = payload.get("coupler_settings") or {}
|
coupler = payload.get("coupler_settings") or {}
|
||||||
|
|
||||||
# -- everything validated: apply atomically -------------------------
|
# -- everything validated: apply atomically -------------------------
|
||||||
|
# a loaded env binds to the old layout — discard before rebuilding
|
||||||
|
# (FR-10.2.15; same rule as SAVE CONFIG)
|
||||||
|
await sim.runner.discard()
|
||||||
rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
|
rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
|
||||||
stale = sim.node.validate_channel_names()
|
stale = sim.node.validate_channel_names()
|
||||||
if stale:
|
if stale:
|
||||||
|
|||||||
@@ -28,9 +28,90 @@ async function init() {
|
|||||||
renderRail();
|
renderRail();
|
||||||
renderChannelList();
|
renderChannelList();
|
||||||
if (diag) renderDiagnostics(diag);
|
if (diag) renderDiagnostics(diag);
|
||||||
|
refreshEnvCard();
|
||||||
openWS();
|
openWS();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- plant environment control (FR-10.2.15) ----------------
|
||||||
|
|
||||||
|
let ENV = null; // last /api/env status
|
||||||
|
|
||||||
|
async function refreshEnvCard() {
|
||||||
|
ENV = await api("/api/env").catch(() => null);
|
||||||
|
if (ENV) renderEnvCard(ENV);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnvCard(env) {
|
||||||
|
const body = document.getElementById("env-body");
|
||||||
|
if (!body) return;
|
||||||
|
const state = env.state || "none";
|
||||||
|
const chip = `<span class="env-state st-${state}">${state}</span>`;
|
||||||
|
const time = env.sim_time_ms != null
|
||||||
|
? `${(env.sim_time_ms / 1000).toFixed(1)} s` : "—";
|
||||||
|
const tick = env.tick_ms != null ? `${env.tick_ms} ms` : "—";
|
||||||
|
if (state === "none") {
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="cfg-note">No environment loaded.</p>
|
||||||
|
${READONLY ? "" : `
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<input type="text" id="env-ref" class="env-ref"
|
||||||
|
placeholder="envs.pump_station_env:PumpStationEnv">
|
||||||
|
<button id="env-start" class="primary">Start</button>
|
||||||
|
</div>`}`;
|
||||||
|
} else {
|
||||||
|
const canPause = state === "running";
|
||||||
|
const canResume = state === "paused";
|
||||||
|
const canReset = !READONLY && (state === "running" || state === "paused");
|
||||||
|
body.innerHTML = `
|
||||||
|
<p><b>${env.ref}</b> ${chip}</p>
|
||||||
|
<p class="cfg-note">sim-time ${time} · tick ${tick}
|
||||||
|
${env.reason ? ` · done: ${env.reason}` : ""}</p>
|
||||||
|
${READONLY ? "" : `
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<button id="env-pause" ${canPause ? "" : "disabled"}>Pause</button>
|
||||||
|
<button id="env-resume" ${canResume ? "" : "disabled"}>Resume</button>
|
||||||
|
<button id="env-reset" ${canReset ? "" : "disabled"}>Reset</button>
|
||||||
|
<button id="env-restart">Restart</button>
|
||||||
|
<button id="env-stop">Stop</button>
|
||||||
|
</div>`}`;
|
||||||
|
}
|
||||||
|
const msg = document.getElementById("env-msg");
|
||||||
|
msg.classList.add("hidden");
|
||||||
|
bindEnvButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEnvButtons() {
|
||||||
|
const on = (id, fn) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener("click", fn);
|
||||||
|
};
|
||||||
|
on("env-start", async () => {
|
||||||
|
const ref = document.getElementById("env-ref").value.trim();
|
||||||
|
await envAction("start", {ref});
|
||||||
|
});
|
||||||
|
on("env-stop", () => envAction("stop"));
|
||||||
|
on("env-pause", () => envAction("pause"));
|
||||||
|
on("env-resume", () => envAction("resume"));
|
||||||
|
on("env-reset", () => envAction("reset"));
|
||||||
|
on("env-restart", () => envAction("restart"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function envAction(name, payload) {
|
||||||
|
const msg = document.getElementById("env-msg");
|
||||||
|
msg.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api(`/api/env/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
});
|
||||||
|
await refreshEnvCard();
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = e.message;
|
||||||
|
msg.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openWS() {
|
function openWS() {
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
WS = new WebSocket(`${proto}://${location.host}/api/ws`);
|
WS = new WebSocket(`${proto}://${location.host}/api/ws`);
|
||||||
@@ -642,6 +723,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById("module-save").addEventListener("click", saveModules);
|
document.getElementById("module-save").addEventListener("click", saveModules);
|
||||||
|
setInterval(refreshEnvCard, 1000); // env status: 1 Hz
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderModuleRows() {
|
function renderModuleRows() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>WAGO 750-362 Simulator</title>
|
<title>WAGO 750-362 Simulator</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=3">
|
<link rel="stylesheet" href="/static/style.css?v=4">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -33,6 +33,12 @@
|
|||||||
<div id="rail" class="rail"></div>
|
<div id="rail" class="rail"></div>
|
||||||
<div id="rail-detail" class="rail-detail"></div>
|
<div id="rail-detail" class="rail-detail"></div>
|
||||||
|
|
||||||
|
<div class="diag-card env-card" id="env-card">
|
||||||
|
<h3>Plant environment</h3>
|
||||||
|
<div id="env-body" class="cfg-note">loading…</div>
|
||||||
|
<div id="env-msg" class="error hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="list-title">Channels</h3>
|
<h3 class="list-title">Channels</h3>
|
||||||
<div class="chlist-head">
|
<div class="chlist-head">
|
||||||
<span>I/O</span><span>Name</span><span>Value</span>
|
<span>I/O</span><span>Name</span><span>Value</span>
|
||||||
@@ -111,6 +117,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/app.js?v=3"></script>
|
<script src="/static/app.js?v=4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>WAGO Simulator — Login</title>
|
<title>WAGO Simulator — Login</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=3">
|
<link rel="stylesheet" href="/static/style.css?v=4">
|
||||||
</head>
|
</head>
|
||||||
<body class="login-body">
|
<body class="login-body">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
|
|||||||
@@ -238,6 +238,24 @@ h2 { margin: 4px 0 14px; }
|
|||||||
}
|
}
|
||||||
.diag-card button:hover { background: #004a99; }
|
.diag-card button:hover { background: #004a99; }
|
||||||
.fault-buttons { display: flex; gap: 8px; }
|
.fault-buttons { display: flex; gap: 8px; }
|
||||||
|
.fault-buttons button:disabled { background: #b6bfc9; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ---------- plant environment card (FR-10.2.15) ---------- */
|
||||||
|
|
||||||
|
.env-card { margin-top: 16px; }
|
||||||
|
.env-card .fault-buttons { margin-top: 10px; flex-wrap: wrap; }
|
||||||
|
.env-card .fault-buttons button { margin-top: 0; }
|
||||||
|
.env-ref { flex: 1; padding: 7px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
|
||||||
|
font-family: Consolas, monospace; font-size: 0.88rem; }
|
||||||
|
.env-state {
|
||||||
|
display: inline-block; padding: 2px 10px; border-radius: 10px;
|
||||||
|
font-size: 0.78rem; font-weight: 600; text-transform: uppercase;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.env-state.st-running { background: #e2f5e6; color: #1d7a33; }
|
||||||
|
.env-state.st-paused { background: #fdf3d7; color: #8a6d00; }
|
||||||
|
.env-state.st-done { background: #e8ecf1; color: #56606c; }
|
||||||
|
.env-state.st-none { background: #e8ecf1; color: #56606c; }
|
||||||
|
|
||||||
/* ---------- configuration ---------- */
|
/* ---------- configuration ---------- */
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -191,6 +194,188 @@ class TestReadOnlyMode:
|
|||||||
r = await c.post("/api/diagnostics/reset-stats")
|
r = await c.post("/api/diagnostics/reset-stats")
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
async def test_readonly_blocks_env_control(self, app):
|
||||||
|
ro = build_webui(app, user="admin", password="simu", readonly=True)
|
||||||
|
transport = httpx.ASGITransport(app=ro)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
||||||
|
r = await c.get("/api/env")
|
||||||
|
assert r.status_code == 200 # status is a read
|
||||||
|
assert r.json()["state"] == "none"
|
||||||
|
r = await c.post("/api/env/start",
|
||||||
|
json={"ref": "envs.pump_station_env:PumpStationEnv"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = await c.post("/api/env/stop")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvControl:
|
||||||
|
"""FR-10.2.15 runtime env lifecycle + FR-10.2.17 hot reload."""
|
||||||
|
|
||||||
|
REF = "envs.pump_station_env:PumpStationEnv"
|
||||||
|
|
||||||
|
async def test_env_status_none(self, client):
|
||||||
|
r = await client.get("/api/env")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ref": None, "state": "none", "reason": None,
|
||||||
|
"sim_time_ms": None, "tick_ms": None}
|
||||||
|
|
||||||
|
async def test_start_stop_cycle(self, client, app):
|
||||||
|
r = await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
st = r.json()
|
||||||
|
assert st["state"] == "running"
|
||||||
|
assert st["ref"] == self.REF
|
||||||
|
assert app.runner.env is not None
|
||||||
|
# let it tick a little, then check status shows sim time
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
r = await client.get("/api/env")
|
||||||
|
assert r.json()["sim_time_ms"] is not None
|
||||||
|
# stop -> none
|
||||||
|
r = await client.post("/api/env/stop")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["state"] == "none"
|
||||||
|
assert app.runner.env is None
|
||||||
|
|
||||||
|
async def test_start_refused_while_loaded(self, client):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
r = await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert "stop" in r.json()["detail"]
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
|
async def test_start_requires_ref(self, client):
|
||||||
|
r = await client.post("/api/env/start", json={})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
async def test_pause_resume(self, client, app):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
# let it tick so sim time advances
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
t0 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||||
|
assert t0 > 0
|
||||||
|
r = await client.post("/api/env/pause")
|
||||||
|
assert r.json()["state"] == "paused"
|
||||||
|
t1 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
t2 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||||
|
assert t2 == t1 # frozen while paused
|
||||||
|
r = await client.post("/api/env/resume")
|
||||||
|
assert r.json()["state"] == "running"
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
t3 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||||
|
assert t3 > t2
|
||||||
|
# double-pause / resume-without-pause are refused
|
||||||
|
await client.post("/api/env/pause")
|
||||||
|
r = await client.post("/api/env/pause")
|
||||||
|
assert r.status_code == 409
|
||||||
|
r = await client.post("/api/env/stop")
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await client.post("/api/env/resume")
|
||||||
|
assert r.status_code == 409
|
||||||
|
r = await client.post("/api/env/pause")
|
||||||
|
assert r.status_code == 409 # nothing loaded
|
||||||
|
|
||||||
|
async def test_reset_returns_to_initial_state(self, client):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
st = (await client.get("/api/env")).json()
|
||||||
|
assert st["sim_time_ms"] > 0
|
||||||
|
r = await client.post("/api/env/reset")
|
||||||
|
assert r.status_code == 200
|
||||||
|
st = (await client.get("/api/env")).json()
|
||||||
|
assert st["sim_time_ms"] == 0.0 # deterministic fresh episode
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
|
async def test_restart_fresh_instance(self, client, app):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
first = app.runner.env
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
r = await client.post("/api/env/restart")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["state"] == "running"
|
||||||
|
assert app.runner.env is not first # a NEW instance
|
||||||
|
assert app.runner.env.sim_time_ms < first.sim_time_ms
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
|
async def test_is_done_ends_ticker(self, client, app):
|
||||||
|
# an env that is done after 30 ms of sim time (TICK_MS 10 -> 3 ticks)
|
||||||
|
ref = "envs.test_done_env:PumpStationEnv"
|
||||||
|
app.runner.env = None
|
||||||
|
r = await client.post("/api/env/start", json={"ref": ref})
|
||||||
|
assert r.status_code == 200
|
||||||
|
await asyncio.sleep(0.15) # let the paced ticker finish
|
||||||
|
st = (await client.get("/api/env")).json()
|
||||||
|
assert st["state"] == "done"
|
||||||
|
assert st["reason"] == "is_done()"
|
||||||
|
assert app.runner.env is not None # instance kept for inspection
|
||||||
|
# pause on done -> refused (restart instead)
|
||||||
|
r = await client.post("/api/env/pause")
|
||||||
|
assert r.status_code == 409
|
||||||
|
# reset brings it back to running
|
||||||
|
r = await client.post("/api/env/reset")
|
||||||
|
assert r.json()["state"] == "running"
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
|
async def test_module_save_discards_env(self, client, app):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
assert app.runner.env is not None
|
||||||
|
r = await client.put("/api/config/modules", json={
|
||||||
|
"modules": ["750-430", "750-530"], "persist": False,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# FR-10.2.15: the rebuild discards the env (old-layout bindings)
|
||||||
|
assert app.runner.env is None
|
||||||
|
assert (await client.get("/api/env")).json()["state"] == "none"
|
||||||
|
# and the new runner points at the NEW node
|
||||||
|
assert app.runner.node is app.node
|
||||||
|
|
||||||
|
async def test_start_unknown_env_is_409(self, client):
|
||||||
|
r = await client.post("/api/env/start",
|
||||||
|
json={"ref": "envs.no_such_env:Env"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert "cannot import" in r.json()["detail"]
|
||||||
|
|
||||||
|
async def test_hot_reload_after_source_edit(self, client, app, tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
"""FR-10.2.17: start -> edit source on disk -> restart runs the
|
||||||
|
NEW code (sys.modules cache invalidated)."""
|
||||||
|
pkg = tmp_path / "reloadpkg"
|
||||||
|
pkg.mkdir()
|
||||||
|
(pkg / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
# v1: level rises
|
||||||
|
(pkg / "reload_env.py").write_text(
|
||||||
|
"from wago_sim.scenario.plantenv import PlantEnv\n"
|
||||||
|
"class ReloadEnv(PlantEnv):\n"
|
||||||
|
" def model(self, state, dt_s):\n"
|
||||||
|
" level = float(state.by_code('AI0.0'))\n"
|
||||||
|
" return {'AI0.0': level + 1.0}\n"
|
||||||
|
"Env = ReloadEnv\n", encoding="utf-8")
|
||||||
|
sys.path.insert(0, str(tmp_path))
|
||||||
|
try:
|
||||||
|
r = await client.post("/api/env/start",
|
||||||
|
json={"ref": "reloadpkg.reload_env:ReloadEnv"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
v1 = app.datastore.get_input_word(0)
|
||||||
|
assert v1 > 0
|
||||||
|
# v2: level DRAINS instead — same class name
|
||||||
|
(pkg / "reload_env.py").write_text(
|
||||||
|
"from wago_sim.scenario.plantenv import PlantEnv\n"
|
||||||
|
"class ReloadEnv(PlantEnv):\n"
|
||||||
|
" def model(self, state, dt_s):\n"
|
||||||
|
" level = float(state.by_code('AI0.0'))\n"
|
||||||
|
" return {'AI0.0': max(0.0, level - 1.0)}\n"
|
||||||
|
"Env = ReloadEnv\n", encoding="utf-8")
|
||||||
|
r = await client.post("/api/env/restart")
|
||||||
|
assert r.status_code == 200
|
||||||
|
app.datastore.set_input_word(0, 100)
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
assert app.datastore.get_input_word(0) < 100 # NEW code ran
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
finally:
|
||||||
|
sys.path.remove(str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
class TestCouplerConfig:
|
class TestCouplerConfig:
|
||||||
async def test_get_coupler_config(self, client, app):
|
async def test_get_coupler_config(self, client, app):
|
||||||
|
|||||||
Reference in New Issue
Block a user