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