🚧 Add environment maneuverability

This commit is contained in:
Jonas Bengtsson
2026-10-04 22:48:10 +02:00
parent 68d3c42d79
commit 647c209e89
15 changed files with 850 additions and 46 deletions
+101
View File
@@ -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
} }
] ]
} }
+20
View File
@@ -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
+60 -5
View File
@@ -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.
--- ---
+2 -2
View File
@@ -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")
+28
View File
@@ -0,0 +1,28 @@
"""Test-only environment: is_done() after 30 ms of sim time.
Used by the web-UI env-control tests (FR-10.2.15): the paced ticker
must notice is_done(), set state "done" (no auto-restart), and keep
the instance for inspection.
"""
from __future__ import annotations
from wago_sim.scenario.plantenv import PlantEnv, PlantState
class PumpStationEnv(PlantEnv):
"""Minimal env: fills AI0.0, done after 30 ms."""
def __init__(self, *args, stop_after_ms: float | None = 30.0, **kw):
super().__init__(*args, **kw)
self.stop_after_ms = stop_after_ms
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
level = float(state.by_code("AI0.0"))
return {"AI0.0": level + 1.0}
def is_done(self, state: PlantState) -> bool:
return state.sim_time_ms >= (self.stop_after_ms or 0.0)
Env = PumpStationEnv
+13 -2
View File
@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass, field
from .datastore import WagoDatastore from .datastore import WagoDatastore
from .envrunner import EnvRunner
from .modules import ModuleType, VirtualNode from .modules import ModuleType, VirtualNode
from .registers import ConfigRegisterBank from .registers import ConfigRegisterBank
from .server import WagoSimServer from .server import WagoSimServer
@@ -36,6 +37,8 @@ class SimulatorApp:
server: WagoSimServer server: WagoSimServer
web: WebUIConfig | None = None web: WebUIConfig | None = None
node_config_path: str | None = None # where the module list was loaded from node_config_path: str | None = None # where the module list was loaded from
runner: EnvRunner = field(init=False) # env lifecycle owner (FR-10.2.15);
# holds the loaded env + its ref
@property @property
def modules(self) -> list[ModuleType]: def modules(self) -> list[ModuleType]:
@@ -60,9 +63,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
datastore = app.datastore datastore = app.datastore
names = channel_names if channel_names is not None else app.node.channel_names names = channel_names if channel_names is not None else app.node.channel_names
ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges
# a loaded env binds to the OLD layout (its model() reads channels by
# code); the rebuild renumbers everything — discard it like the values
# are lost on a KBUS reset (FR-10.2.15 discard rule)
app.runner.dispose()
datastore.reset_images() datastore.reset_images()
app.node = VirtualNode(datastore, modules, app.node = VirtualNode(datastore, modules,
channel_names=names, channel_ranges=ranges) channel_names=names, channel_ranges=ranges)
# re-point the runner at the new layout so the next start binds to it
app.runner = EnvRunner(app.node)
app.registers.set_process_image_bits( app.registers.set_process_image_bits(
analog_in=app.node.analog_in_bits, analog_in=app.node.analog_in_bits,
analog_out=app.node.analog_out_bits, analog_out=app.node.analog_out_bits,
@@ -145,7 +154,7 @@ def build_app(
idle_timeout_provider=lambda: registers.idle_timeout_ms, idle_timeout_provider=lambda: registers.idle_timeout_ms,
) )
server_ref: dict[str, WagoSimServer] = {"server": server} server_ref: dict[str, WagoSimServer] = {"server": server}
return SimulatorApp( app = SimulatorApp(
datastore=datastore, datastore=datastore,
node=node, node=node,
stats=stats, stats=stats,
@@ -155,3 +164,5 @@ def build_app(
web=web, web=web,
node_config_path=node_config_path, node_config_path=node_config_path,
) )
app.runner = EnvRunner(node)
return app
+13 -32
View File
@@ -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
+184
View File
@@ -0,0 +1,184 @@
"""EnvRunner: web-UI-controllable ticker for a plant environment.
Owns the wall-clock-paced tick task that used to live as a closure in
the CLI's ``_serve`` (FR-8.2.17 runner (a)). Moved here so the web UI
can control the lifecycle at runtime (spec FR-10.2.15):
start(ref) load (+ hot reload, FR-10.2.17) + tick
stop() cancel the ticker; the field freezes
pause() cancel the ticker, keep the instance
resume() reschedule the ticker
reset() env.reset() — deterministic fresh episode
restart() stop + start of the same ref, fresh instance
State machine: none -> running <-> paused -> done; stop/discard -> none.
Exactly one env at a time (one decision source, FR-8.2.15): start is
refused while one is loaded.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from .scenario.engine import ScenarioError
from .scenario.plantenv import PlantEnv, load_env
if TYPE_CHECKING:
from .modules import VirtualNode
log = logging.getLogger(__name__)
class EnvRunner:
"""Paced ticker + lifecycle for one PlantEnv on a virtual node."""
def __init__(self, node: VirtualNode) -> None:
self.node = node
self.env: PlantEnv | None = None
self.env_ref: str | None = None
self._task: asyncio.Task | None = None
self._state: str = "none" # none | running | paused | done
self._done_reason: str | None = None
# -- introspection ---------------------------------------------------------
@property
def state(self) -> str:
return self._state
def status(self) -> dict[str, object]:
"""Status snapshot for the web UI (FR-10.2.15)."""
return {
"ref": self.env_ref,
"state": self._state,
"reason": self._done_reason,
"sim_time_ms": self.env.sim_time_ms if self.env else None,
"tick_ms": self.env.TICK_MS if self.env else None,
}
# -- lifecycle --------------------------------------------------------------
def start(self, ref: str) -> None:
"""Load + start an environment (refused while one is loaded).
``reload=True`` passes through to ``load_env`` (FR-10.2.17):
a cached module is invalidated first, so starting after an
edit of the source file runs the NEW code.
"""
if self.env is not None:
raise ScenarioError(
"an environment is already loaded "
f"({self.env_ref!r}); stop it first")
env = load_env(ref, node=self.node, reload=True)
self.env = env
self.env_ref = ref
self._state = "running"
self._done_reason = None
self._task = asyncio.create_task(self._run())
log.info("env started: %s", ref)
async def stop(self) -> None:
"""Cancel the ticker and drop the env (field freezes)."""
await self._cancel_task()
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
log.info("env stopped")
async def pause(self) -> None:
"""Cancel the ticker, keep the instance (plant stuck fault)."""
if self.env is None or self._state not in ("running", "done"):
raise ScenarioError(f"cannot pause: state is {self._state!r}")
if self._state == "done":
raise ScenarioError("environment already done; restart it")
await self._cancel_task()
self._state = "paused"
log.info("env paused (sim %.0f ms)", self.env.sim_time_ms)
async def resume(self) -> None:
"""Reschedule the ticker after a pause."""
if self.env is None or self._state != "paused":
raise ScenarioError(f"cannot resume: state is {self._state!r}")
self._state = "running"
self._task = asyncio.create_task(self._run())
log.info("env resumed (sim %.0f ms)", self.env.sim_time_ms)
def reset(self) -> None:
"""Deterministic fresh episode (NFR-8.7.3); ticker keeps running."""
if self.env is None:
raise ScenarioError("no environment loaded")
self.env.reset()
self._done_reason = None
if self._state == "done":
# the criterion may hold again on the fresh episode — but
# is_done() is re-checked every tick anyway, so just resume
self._state = "running"
self._task = asyncio.create_task(self._run())
log.info("env reset")
async def restart(self) -> None:
"""Fresh instance of the same ref (picks up edited source)."""
if self.env_ref is None:
raise ScenarioError("no environment to restart")
ref = self.env_ref
await self.stop()
self.start(ref)
async def discard(self) -> None:
"""Silent unload (module-list rebuild, shutdown path)."""
await self._cancel_task()
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
def dispose(self) -> None:
"""Synchronous unload without awaiting (rebuild path).
The API handlers ``await discard()`` BEFORE rebuilding; this
is the belt-and-braces guarantee for sync callers
(``rebuild_node``) that the ticker can never tick the new
image: ``cancel()`` takes effect before the loop resumes, and
a fully synchronous function yields no loop slots.
"""
if self._task is not None:
self._task.cancel()
self._task = None
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
# -- ticker -------------------------------------------------------------------
async def _run(self) -> None:
"""Monotonic-scheduled paced ticker (no drift), as in _serve."""
assert self.env is not None
period = self.env.TICK_MS / 1000.0
next_at = time.monotonic()
while True:
self.env.tick()
if self.env.is_done(self.env.observe()):
self._state = "done"
self._done_reason = "is_done()"
log.info("env is_done() satisfied; ticker stopped")
return
next_at += period
delay = next_at - time.monotonic()
if delay > 0:
await asyncio.sleep(delay)
else:
next_at = time.monotonic() # catch up, no drift burst
async def _cancel_task(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
+23 -1
View File
@@ -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)
+111
View File
@@ -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:
+82
View File
@@ -28,9 +28,90 @@ async function init() {
renderRail(); renderRail();
renderChannelList(); renderChannelList();
if (diag) renderDiagnostics(diag); if (diag) renderDiagnostics(diag);
refreshEnvCard();
openWS(); openWS();
} }
// ---------------- plant environment control (FR-10.2.15) ----------------
let ENV = null; // last /api/env status
async function refreshEnvCard() {
ENV = await api("/api/env").catch(() => null);
if (ENV) renderEnvCard(ENV);
}
function renderEnvCard(env) {
const body = document.getElementById("env-body");
if (!body) return;
const state = env.state || "none";
const chip = `<span class="env-state st-${state}">${state}</span>`;
const time = env.sim_time_ms != null
? `${(env.sim_time_ms / 1000).toFixed(1)} s` : "—";
const tick = env.tick_ms != null ? `${env.tick_ms} ms` : "—";
if (state === "none") {
body.innerHTML = `
<p class="cfg-note">No environment loaded.</p>
${READONLY ? "" : `
<div class="fault-buttons">
<input type="text" id="env-ref" class="env-ref"
placeholder="envs.pump_station_env:PumpStationEnv">
<button id="env-start" class="primary">Start</button>
</div>`}`;
} else {
const canPause = state === "running";
const canResume = state === "paused";
const canReset = !READONLY && (state === "running" || state === "paused");
body.innerHTML = `
<p><b>${env.ref}</b> &nbsp;${chip}</p>
<p class="cfg-note">sim-time ${time} · tick ${tick}
${env.reason ? ` · done: ${env.reason}` : ""}</p>
${READONLY ? "" : `
<div class="fault-buttons">
<button id="env-pause" ${canPause ? "" : "disabled"}>Pause</button>
<button id="env-resume" ${canResume ? "" : "disabled"}>Resume</button>
<button id="env-reset" ${canReset ? "" : "disabled"}>Reset</button>
<button id="env-restart">Restart</button>
<button id="env-stop">Stop</button>
</div>`}`;
}
const msg = document.getElementById("env-msg");
msg.classList.add("hidden");
bindEnvButtons();
}
function bindEnvButtons() {
const on = (id, fn) => {
const el = document.getElementById(id);
if (el) el.addEventListener("click", fn);
};
on("env-start", async () => {
const ref = document.getElementById("env-ref").value.trim();
await envAction("start", {ref});
});
on("env-stop", () => envAction("stop"));
on("env-pause", () => envAction("pause"));
on("env-resume", () => envAction("resume"));
on("env-reset", () => envAction("reset"));
on("env-restart", () => envAction("restart"));
}
async function envAction(name, payload) {
const msg = document.getElementById("env-msg");
msg.classList.add("hidden");
try {
await api(`/api/env/${name}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload || {}),
});
await refreshEnvCard();
} catch (e) {
msg.textContent = e.message;
msg.classList.remove("hidden");
}
}
function openWS() { function openWS() {
const proto = location.protocol === "https:" ? "wss" : "ws"; const proto = location.protocol === "https:" ? "wss" : "ws";
WS = new WebSocket(`${proto}://${location.host}/api/ws`); WS = new WebSocket(`${proto}://${location.host}/api/ws`);
@@ -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() {
+8 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>WAGO 750-362 Simulator</title> <title>WAGO 750-362 Simulator</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css?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>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>WAGO Simulator — Login</title> <title>WAGO Simulator — Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css?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">
+18
View File
@@ -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 ---------- */
+185
View File
@@ -3,6 +3,9 @@ diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
from __future__ import annotations from __future__ import annotations
import asyncio
import sys
import httpx import httpx
import pytest import pytest
@@ -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):