🚧 WIP
This commit is contained in:
Vendored
+89
@@ -1703,6 +1703,95 @@
|
||||
],
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest dense ranges",
|
||||
"type": "shell",
|
||||
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/test_webui.py",
|
||||
"tests/test_continuous.py",
|
||||
"-q",
|
||||
"--no-header"
|
||||
],
|
||||
"isBackground": false
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest full dense",
|
||||
"type": "shell",
|
||||
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"-q",
|
||||
"--no-header"
|
||||
],
|
||||
"isBackground": false
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest channel-by-name",
|
||||
"type": "shell",
|
||||
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/test_continuous.py",
|
||||
"-q",
|
||||
"--no-header"
|
||||
],
|
||||
"isBackground": false
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest example env isolated",
|
||||
"type": "shell",
|
||||
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/test_continuous.py::TestPlantEnv::test_pump_station_example_env",
|
||||
"-q",
|
||||
"--no-header"
|
||||
],
|
||||
"isBackground": false
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest env fix",
|
||||
"type": "shell",
|
||||
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/test_continuous.py",
|
||||
"-q",
|
||||
"--no-header"
|
||||
],
|
||||
"isBackground": false
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest full by-name",
|
||||
"type": "shell",
|
||||
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"-q",
|
||||
"--no-header"
|
||||
],
|
||||
"isBackground": false
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest full final",
|
||||
"type": "shell",
|
||||
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"-q",
|
||||
"--no-header"
|
||||
],
|
||||
"isBackground": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -224,10 +224,13 @@ Env = TankEnv # `--env myenvs.tank` picks this up
|
||||
```
|
||||
|
||||
API surface (spec FR-8.2.14–17): `observe()` returns every
|
||||
DI/DO/AI/AO channel (I/O codes, names, values) plus aliases and
|
||||
degradation parameters; `apply()` writes DI/AI (the field);
|
||||
DI/DO/AI/AO channel (I/O codes, names, values, analog display
|
||||
ranges) plus aliases and degradation parameters; `apply()` writes DI/AI (the field);
|
||||
`force()` writes DO/AO master-side (headless runs, fault setup);
|
||||
`is_done()` optional end; `reset()` deterministic replay. Reference
|
||||
`is_done()` optional end; `reset()` deterministic replay.
|
||||
`state.channel(key)` returns the full channel record by I/O code
|
||||
or configured name; `state.channel_range(key)` gives an analog
|
||||
channel's (min, max) directly. Reference
|
||||
forms: `pkg.mod:ClassName`, `pkg.mod:factory`, or `pkg.mod` with
|
||||
`Env`/`make_env`. The shipped example is
|
||||
`envs/pump_station_env.py` (tank + pump physics with a low-level
|
||||
@@ -400,7 +403,12 @@ requirement ID for each item in parentheses.
|
||||
may be negative (-65534–65535; stored as 16-bit two's
|
||||
complement), for signed plant quantities. DI/DO channels render
|
||||
in vertical columns of eight (0–7 left, 8–15 next) on thin,
|
||||
tall module bezels mirroring real DIN-rail housings.
|
||||
tall module bezels mirroring real DIN-rail housings. Dense
|
||||
store (2026-10-04): `node.channel_ranges` always carries every
|
||||
analog channel of the current layout (unconfigured = the
|
||||
materialized default, stale codes pruned on rebuild), so
|
||||
environments read min/max without fallbacks; the YAML and the
|
||||
config export stay lean (non-default entries only).
|
||||
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
|
||||
FR-10.2.9) — deferred.
|
||||
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx
|
||||
|
||||
+15
-11
@@ -1,18 +1,22 @@
|
||||
# Virtual WAGO node configuration — pump station FAT bench
|
||||
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
|
||||
# Simulated device: WAGO 750-362 Modbus TCP coupler
|
||||
# I/O codes number modules per type (DI0, DO0, AI0, AO0 — FR-4.3.11 v1.5)
|
||||
name: pump_station_node
|
||||
# Edit by hand or via the UI; comments are not preserved on UI saves.
|
||||
name: pump_node
|
||||
coupler: 750-362
|
||||
|
||||
modules:
|
||||
- "750-430" # 8-channel digital input 24VDC
|
||||
- "750-1405" # 16-channel digital input 24VDC
|
||||
- "750-530" # 8-channel digital output 24VDC
|
||||
- "750-1516" # 16-channel digital output 24VDC
|
||||
- "750-467" # 2-channel analog input 4-20mA
|
||||
- "750-554" # 2-channel analog output 4-20mA
|
||||
- "750-430" # 8-channel DI 24VDC
|
||||
- "750-1405" # 16-channel DI 24VDC
|
||||
- "750-530" # 8-channel DO 24VDC
|
||||
- "750-1516" # 16-channel DO 24VDC
|
||||
- "750-467" # 2-channel AI 4-20mA
|
||||
- "750-554" # 2-channel AO 4-20mA
|
||||
|
||||
# Application names for module channels (I/O code -> name)
|
||||
channel_names:
|
||||
AO0.0: speed_setpoint
|
||||
DI0.0: start_button
|
||||
AI0.0: NG1
|
||||
AO0.0: P!_BORV
|
||||
DI0.0: P1_DI
|
||||
DI0.1: P1_AUTO
|
||||
DI0.2: P1_FEL
|
||||
DO0.0: P!_START
|
||||
|
||||
@@ -588,7 +588,8 @@ unchanged whether or not CSV streaming is used.
|
||||
deterministic tick (the engine or the runner advances the tick clock,
|
||||
never wall-clock arithmetic): **`state = env.observe()`** returns the
|
||||
state of ALL modules — DI/DO/AI/AO channels, engineering units, per-type
|
||||
I/O codes, configured names, plus active aliases and parameters — →
|
||||
I/O codes, configured names, analog display ranges (FR-10.2.14),
|
||||
plus active aliases and parameters — →
|
||||
`env.model(state, dt)` (the caller-supplied plant physics) → **`apply()`
|
||||
writes the new field state**. The environment owns no policy; plant
|
||||
models are supplied by subclassing (FR-8.2.17).
|
||||
@@ -602,15 +603,19 @@ unchanged whether or not CSV streaming is used.
|
||||
Exactly one decision source may own an alias per tick (ownership
|
||||
conflicts are validation errors, extending FR-8.2.2).
|
||||
- FR-8.2.16: **Environment API surface** (concrete):
|
||||
`env.observe() -> PlantState` (all channels, aliases, parameters,
|
||||
sim-time); `env.apply(values, force=False)` (keys: I/O code or alias
|
||||
`env.observe() -> PlantState` (all channels with names and analog
|
||||
display ranges, aliases, parameters, sim-time); `env.apply(values, force=False)` (keys: I/O code or alias
|
||||
name; DI/AI default, DO/AO require force; unknown keys are errors);
|
||||
`env.model(state, dt_s)` — override point for plant physics, returning
|
||||
new field values; `env.is_done(state) -> bool` — optional end
|
||||
criterion, default False (runs until manually stopped);
|
||||
`env.reset()` — deterministic fresh episode; `env.tick_ms` /
|
||||
`env.sim_time_ms` (read-only clock); `env.parameters` /
|
||||
`env.invariants` attachments.
|
||||
`env.invariants` attachments. `PlantState.channel(key)` returns
|
||||
the full channel record by I/O code or by configured application
|
||||
name (FR-10.2.11); `by_code()` returns values by I/O code;
|
||||
`channel_range(key)` returns an analog channel's display range
|
||||
(min, max) — always defined for AI/AO (dense store, FR-10.2.14).
|
||||
- FR-8.2.17: **Environment authoring & runners**: environments are easy
|
||||
to create and call — a subclass of `PlantEnv` overriding `model()`
|
||||
(and optionally `is_done()`) in any importable module
|
||||
@@ -1056,6 +1061,14 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
||||
onto the bar). Simulated AI values and AO forces accept negative
|
||||
numbers within the same window; values are stored in the image
|
||||
as their 16-bit two's-complement representation.
|
||||
**Dense runtime store** (2026-10-04): `node.channel_ranges` is
|
||||
always complete — every analog channel of the current layout has
|
||||
an entry (unconfigured channels carry the materialized default),
|
||||
codes that no longer match an analog channel are pruned on
|
||||
rebuild, so envs and API consumers can read ranges without
|
||||
default-fallback guesswork. Persistence (node YAML, config
|
||||
export) stays **lean**: only explicit non-default entries are
|
||||
written, so defaults survive a future DEFAULT_RANGE change.
|
||||
|
||||
### 10.3 Implementation decisions (binding, 2026-10-03)
|
||||
|
||||
|
||||
+42
-22
@@ -13,50 +13,70 @@ or headless for model development:
|
||||
--env envs.pump_station_env:PumpStationEnv
|
||||
|
||||
Physics (deliberately simple, for demonstration): one tank.
|
||||
- inflow is constant (or a small sinusoidal demand profile)
|
||||
- the pump drains at `flow_max * capacity * speed` while DO0.0 is ON
|
||||
- the level integrates the balance; AI0.0 reports it as 0.1 mm/count
|
||||
- a low-level switch DI0.1 opens when the tank drains below 500 mm
|
||||
- optional is_done: stop after a sim hour (default: run forever)
|
||||
- inflow is constant
|
||||
- the pump drains at `flow_max * capacity * speed` while DO0.0 is ON;
|
||||
the speed fraction is AO0.0 normalized over its display range
|
||||
(FR-10.2.14)
|
||||
- the level integrates the balance, clamped at the brim; AI0.0
|
||||
reports the level scaled over the channel's display range
|
||||
(default 0-4095 counts = 100 %)
|
||||
- a high-level switch DI0.1 closes when the level rises above 2.7 m
|
||||
- optional is_done: stop after a given sim time (default: run forever)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wago_sim.scenario.plantenv import PlantEnv, PlantState
|
||||
|
||||
TANK_AREA = 2.0 # m^2
|
||||
TANK_AREA = 5.0 # m^2
|
||||
FLOW_MAX = 30.0 # m^3/h the pump moves at full speed
|
||||
INFLOW = 12.0 # m^3/h constant inflow
|
||||
LEVEL_MAX = 9000.0 # mm — tank brim
|
||||
SWITCH_AT = 500.0 # mm — low-level switch threshold
|
||||
LEVEL_SENSOR_MAX = 5.0 # m — tank level meter full-scale
|
||||
LEVEL_BRIM = 2.9 # m — tank brim (physical clamp)
|
||||
LEVEL_HIGH = 2.7 # m — high-level switch threshold
|
||||
|
||||
|
||||
class PumpStationEnv(PlantEnv):
|
||||
"""Closed-loop tank + pump physics over the pump_node layout."""
|
||||
|
||||
TICK_MS = 9.0 # overrides PlantEnv TICK_MS
|
||||
|
||||
def __init__(self, *args, stop_after_ms: float | None = None, **kw):
|
||||
super().__init__(*args, **kw)
|
||||
self._level_mm = 3000.0 # initial tank level
|
||||
self._level_m = 1.2 # initial tank level
|
||||
self.stop_after_ms = stop_after_ms
|
||||
|
||||
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
||||
dt_h = dt_s / 3600.0
|
||||
# what the PLC currently commands
|
||||
pump_on = bool(state.by_code("DO0.0"))
|
||||
# optional speed setpoint from AO0.0 (counts -> 0..1 fraction)
|
||||
speed_raw = float(state.by_code("AO0.0") or 0)
|
||||
speed = min(1.0, max(0.0, speed_raw / 4095.0))
|
||||
# what the PLC currently commands (master side: DO/AO)
|
||||
pump_running = bool(state.by_code("DO0.0"))
|
||||
|
||||
# speed setpoint from AO0.0, normalized over its display range
|
||||
ao_lo, ao_hi = state.channel_range("AO0.0")
|
||||
ao_value = state.by_code("AO0.0")
|
||||
speed_raw = float(ao_value) if isinstance(ao_value, (int, float)) else 0.0
|
||||
try:
|
||||
speed = min(1.0, max(0.0, (speed_raw - ao_lo) / (ao_hi - ao_lo)))
|
||||
except ZeroDivisionError:
|
||||
speed = 0.0
|
||||
|
||||
# physics: water balance with pump wear (parameters attachable)
|
||||
capacity = state.parameters.get("pump_capacity", 1.0)
|
||||
outflow = FLOW_MAX * capacity * speed if pump_on else 0.0
|
||||
d_level_mm = (INFLOW - outflow) / TANK_AREA * 1000.0 * dt_h
|
||||
self._level_mm = min(LEVEL_MAX, max(0.0, self._level_mm + d_level_mm))
|
||||
# the field the PLC reads back
|
||||
level_counts = int(self._level_mm / 0.1) # AI0.0: 0.1 mm/count
|
||||
low_switch = self._level_mm < SWITCH_AT # DI0.1
|
||||
outflow = FLOW_MAX * capacity * speed if pump_running else 0.0
|
||||
d_level_m = (INFLOW - outflow) / TANK_AREA * dt_h
|
||||
self._level_m = min(LEVEL_BRIM, max(0.0, self._level_m + d_level_m))
|
||||
|
||||
# the field the PLC reads back: level over the AI display range
|
||||
# (dense store, FR-10.2.14 — defaults to 0-4095 counts = 100 %)
|
||||
ai_lo, ai_hi = state.channel_range("AI0.0")
|
||||
level_raw = int((self._level_m / LEVEL_SENSOR_MAX) * (ai_hi - ai_lo)
|
||||
+ ai_lo)
|
||||
level_raw = min(ai_hi, max(ai_lo, level_raw))
|
||||
high_switch = self._level_m > LEVEL_HIGH # DI0.1
|
||||
|
||||
return {
|
||||
"AI0.0": level_counts,
|
||||
"DI0.1": low_switch,
|
||||
"AI0.0": level_raw,
|
||||
"DI0.1": high_switch,
|
||||
}
|
||||
|
||||
def is_done(self, state: PlantState) -> bool:
|
||||
|
||||
+42
-4
@@ -196,6 +196,39 @@ class VirtualNode:
|
||||
self.digital_in_bits = digital_in_bits
|
||||
self.digital_out_bits = digital_out_bits
|
||||
|
||||
self._densify_ranges()
|
||||
|
||||
def _densify_ranges(self) -> None:
|
||||
"""Complete the analog range map (FR-10.2.14, dense store).
|
||||
|
||||
Every analog channel gets an entry — defaults materialized —
|
||||
and codes that no longer match an analog channel are pruned
|
||||
(module list changed, FR-11.2). Digital codes never belong in
|
||||
the range map (ranges apply to analog bars only).
|
||||
"""
|
||||
analog_codes = {self.io_code_at_slot("ai", loc.module_index, loc.channel - 1)
|
||||
for loc in self._ai_channels} | \
|
||||
{self.io_code_at_slot("ao", loc.module_index, loc.channel - 1)
|
||||
for loc in self._ao_channels}
|
||||
dense: dict[str, tuple[int, int]] = {}
|
||||
for code in sorted(analog_codes):
|
||||
spec = self.channel_ranges.get(code)
|
||||
if spec is None or spec == self.DEFAULT_RANGE:
|
||||
dense[code] = self.DEFAULT_RANGE
|
||||
else:
|
||||
dense[code] = spec
|
||||
self.channel_ranges = dense
|
||||
|
||||
def set_channel_ranges(self, ranges: dict[str, tuple[int, int]]) -> None:
|
||||
"""Replace all analog display ranges, then re-densify.
|
||||
|
||||
The store ends dense (FR-10.2.14): every analog channel of the
|
||||
CURRENT layout present (missing entries become the default),
|
||||
codes that no longer match an analog channel pruned.
|
||||
"""
|
||||
self.channel_ranges = dict(ranges)
|
||||
self._densify_ranges()
|
||||
|
||||
def description(self) -> str:
|
||||
"""Text for firmware-info register 0x2030 (module description)."""
|
||||
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
|
||||
@@ -224,11 +257,16 @@ class VirtualNode:
|
||||
def channel_range(self, kind: str, module_slot: int, channel: int) -> tuple[int, int]:
|
||||
"""Display range (min, max) raw counts for an analog channel.
|
||||
|
||||
Defaults to (0, 4095) when unconfigured (FR-10.2.14).
|
||||
Always defined for analog channels (dense store, FR-10.2.14):
|
||||
the node materializes the default (0, 4095) at layout time,
|
||||
so this returns the stored entry directly. Digital kinds are
|
||||
rejected — ranges apply to analog bars only.
|
||||
"""
|
||||
return self.channel_ranges.get(
|
||||
self.io_code_at_slot(kind, module_slot, channel), self.DEFAULT_RANGE
|
||||
)
|
||||
if kind not in ("ai", "ao"):
|
||||
raise ValueError(f"channel_range() is analog-only, got kind {kind!r}")
|
||||
return self.channel_ranges[
|
||||
self.io_code_at_slot(kind, module_slot, channel)
|
||||
]
|
||||
|
||||
def validate_channel_names(self) -> list[str]:
|
||||
"""Names whose I/O code does not exist on this node (after a
|
||||
|
||||
@@ -25,10 +25,14 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..modules import VirtualNode
|
||||
from .engine import ScenarioError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .engine import ScenarioEngine
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelState:
|
||||
@@ -40,6 +44,7 @@ class ChannelState:
|
||||
io_code: str # e.g. "DI0.0"
|
||||
name: str | None # configured application name, if any
|
||||
value: object # bool for di/do, int/float for ai/ao
|
||||
range: tuple[int, int] | None = None # (min, max) raw counts — analog only
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -62,6 +67,35 @@ class PlantState:
|
||||
"""{io_code: value} for all channels of one kind."""
|
||||
return {c.io_code: c.value for c in self.channels if c.kind == kind}
|
||||
|
||||
def channel(self, key: str) -> ChannelState:
|
||||
"""Full ChannelState (name, range, ...) by I/O code or name.
|
||||
|
||||
Matches the I/O code first ("AI0.0"), then the configured
|
||||
application name ("speed_setpoint", FR-10.2.11). KeyError
|
||||
when neither matches — including a `None` name (unconfigured
|
||||
channels match by code only).
|
||||
"""
|
||||
for ch in self.channels:
|
||||
if ch.io_code == key:
|
||||
return ch
|
||||
for ch in self.channels:
|
||||
if ch.name == key:
|
||||
return ch
|
||||
raise KeyError(key)
|
||||
|
||||
def channel_range(self, key: str) -> tuple[int, int]:
|
||||
"""Display range (min, max) of an analog channel, by I/O code
|
||||
or name (FR-10.2.14).
|
||||
|
||||
Always defined for AI/AO — the range store is dense — so
|
||||
env models can unpack it without None-handling; digital
|
||||
channels carry no range and raise KeyError.
|
||||
"""
|
||||
rng = self.channel(key).range
|
||||
if rng is None:
|
||||
raise KeyError(key)
|
||||
return rng
|
||||
|
||||
|
||||
class PlantEnv:
|
||||
"""gym-style plant environment (FR-8.2.14-17).
|
||||
@@ -75,9 +109,14 @@ class PlantEnv:
|
||||
TICK_MS = 10.0
|
||||
|
||||
def __init__(self, node: VirtualNode | None = None, *,
|
||||
engine=None) -> None:
|
||||
engine: ScenarioEngine | None = None) -> None:
|
||||
if node is None:
|
||||
if engine is None:
|
||||
raise ScenarioError("PlantEnv needs a node or an engine")
|
||||
self.node: VirtualNode = engine.node
|
||||
else:
|
||||
self.node = node
|
||||
self.engine = engine
|
||||
self.node: VirtualNode = node if node is not None else engine.node
|
||||
self.datastore = self.node.datastore
|
||||
self.sim_time_ms = 0.0
|
||||
# attachments (optional; the scenario layer wires these)
|
||||
@@ -114,6 +153,8 @@ class PlantEnv:
|
||||
io_code=code,
|
||||
name=node.channel_names.get(code),
|
||||
value=value,
|
||||
range=node.channel_ranges[code]
|
||||
if kind in ("ai", "ao") else None,
|
||||
))
|
||||
aliases = ({name: self._alias_value(name)
|
||||
for name in (self._aliases or {})}
|
||||
@@ -198,14 +239,14 @@ class PlantEnv:
|
||||
if kind == "di":
|
||||
ds.set_digital_input_channel(loc.bit + 1, bool(value))
|
||||
elif kind == "ai":
|
||||
raw = int(float(value) * scale + offset) & 0xFFFF
|
||||
raw = int(float(value) * scale + offset) & 0xFFFF # type: ignore[arg-type]
|
||||
ds.set_input_word(loc.word, raw)
|
||||
elif kind == "do":
|
||||
result = ds.write_bits(loc.bit, [bool(value)])
|
||||
if isinstance(result, Exception):
|
||||
raise ScenarioError(f"force() write to {key!r}: {result}")
|
||||
else: # ao
|
||||
raw = int(float(value) * scale + offset) & 0xFFFF
|
||||
raw = int(float(value) * scale + offset) & 0xFFFF # type: ignore[arg-type]
|
||||
result = ds.write_words(loc.word, [raw])
|
||||
if isinstance(result, Exception):
|
||||
raise ScenarioError(f"force() write to {key!r}: {result}")
|
||||
|
||||
+25
-14
@@ -14,7 +14,8 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ..app import SimulatorApp, rebuild_node
|
||||
from ..modules import CATALOG, ChannelMismatch, ModuleUnknown, resolve_module
|
||||
from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode,
|
||||
resolve_module)
|
||||
from ..scenario.engine import ScenarioError
|
||||
from ..watchdog import RegisterWriteRefused
|
||||
from . import state
|
||||
@@ -417,7 +418,7 @@ async def put_module_config(
|
||||
save_node_config(sim.node_config_path, modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=sim.node.channel_names,
|
||||
channel_ranges=sim.node.channel_ranges)
|
||||
channel_ranges=_nondefault_ranges(sim))
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
@@ -485,6 +486,7 @@ async def put_channel_names(
|
||||
sim.node_config_path, sim.node.modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=names,
|
||||
channel_ranges=sim.node.channel_ranges,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
@@ -520,8 +522,10 @@ async def put_channel_ranges(
|
||||
"""Set per-channel analog display ranges (FR-10.2.14).
|
||||
|
||||
Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}.
|
||||
Codes are regex-validated; 0 <= min < max <= 65535 required; an
|
||||
entry with min=0/max=4095 (the default) is dropped from the store.
|
||||
Codes are regex-validated; -65534 <= min < max <= 65535 required.
|
||||
The runtime store is DENSE (every analog channel present, default
|
||||
0-4095 materialized); default-equal entries are kept in the store
|
||||
but omitted from the persisted YAML (lean config).
|
||||
"""
|
||||
_require_write(request, sessions)
|
||||
raw = payload.get("channel_ranges")
|
||||
@@ -550,10 +554,9 @@ async def put_channel_ranges(
|
||||
status_code=400,
|
||||
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535",
|
||||
)
|
||||
if (lo, hi) == (0, 4095):
|
||||
continue # default — keep the store clean
|
||||
ranges[code_up] = (lo, hi)
|
||||
sim.node.channel_ranges = ranges
|
||||
ranges[code_up] = (lo, hi) # dense store keeps defaults
|
||||
sim.node.set_channel_ranges(ranges)
|
||||
persisted = _nondefault_ranges(sim)
|
||||
if payload.get("persist", True) and sim.node_config_path:
|
||||
from .persist import save_node_config
|
||||
try:
|
||||
@@ -561,7 +564,7 @@ async def put_channel_ranges(
|
||||
sim.node_config_path, sim.node.modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=sim.node.channel_names,
|
||||
channel_ranges=ranges,
|
||||
channel_ranges=persisted,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
@@ -570,7 +573,8 @@ async def put_channel_ranges(
|
||||
) from None
|
||||
return {
|
||||
"ok": True,
|
||||
"channel_ranges": {c: {"min": lo, "max": hi} for c, (lo, hi) in ranges.items()},
|
||||
"channel_ranges": {c: {"min": lo, "max": hi}
|
||||
for c, (lo, hi) in persisted.items()},
|
||||
}
|
||||
|
||||
|
||||
@@ -593,6 +597,14 @@ def _coupler_snapshot(sim: SimulatorApp) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _nondefault_ranges(sim: SimulatorApp) -> dict[str, tuple[int, int]]:
|
||||
"""Dense store minus default entries — for YAML persistence and
|
||||
the config export (lean config: only explicit overrides).
|
||||
"""
|
||||
return {code: rng for code, rng in sim.node.channel_ranges.items()
|
||||
if rng != VirtualNode.DEFAULT_RANGE}
|
||||
|
||||
|
||||
@router.get("/api/config/export")
|
||||
async def export_config(
|
||||
sim: SimulatorApp = Depends(get_sim),
|
||||
@@ -606,7 +618,7 @@ async def export_config(
|
||||
"modules": [m.article for m in sim.node.modules],
|
||||
"channel_names": dict(sim.node.channel_names),
|
||||
"channel_ranges": {c: {"min": lo, "max": hi}
|
||||
for c, (lo, hi) in sim.node.channel_ranges.items()},
|
||||
for c, (lo, hi) in _nondefault_ranges(sim).items()},
|
||||
"coupler_settings": _coupler_snapshot(sim),
|
||||
}
|
||||
|
||||
@@ -663,8 +675,7 @@ async def import_config(
|
||||
if not -65534 <= lo < hi <= 65535:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535")
|
||||
if (lo, hi) != (0, 4095):
|
||||
ranges[code_up] = (lo, hi)
|
||||
ranges[code_up] = (lo, hi) # defaults kept; the store is dense
|
||||
coupler = payload.get("coupler_settings") or {}
|
||||
|
||||
# -- everything validated: apply atomically -------------------------
|
||||
@@ -703,7 +714,7 @@ async def import_config(
|
||||
sim.node_config_path, modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=names,
|
||||
channel_ranges=ranges,
|
||||
channel_ranges=_nondefault_ranges(sim),
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..modules import ModuleType
|
||||
from ..modules import ModuleType, VirtualNode
|
||||
|
||||
_HEADER = """\
|
||||
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
|
||||
@@ -52,8 +52,15 @@ def save_node_config(
|
||||
for code in sorted(channel_names):
|
||||
lines.append(f" {code}: {channel_names[code]}\n")
|
||||
if channel_ranges:
|
||||
# lean YAML: the runtime store is dense (every analog channel,
|
||||
# FR-10.2.14) — persist only explicit, non-default entries so a
|
||||
# hand-edited config stays minimal and defaults survive a
|
||||
# future DEFAULT_RANGE change
|
||||
explicit = {code: rng for code, rng in channel_ranges.items()
|
||||
if rng != VirtualNode.DEFAULT_RANGE}
|
||||
if explicit:
|
||||
lines.append(_RANGES_HEADER)
|
||||
for code in sorted(channel_ranges):
|
||||
lo, hi = channel_ranges[code]
|
||||
for code in sorted(explicit):
|
||||
lo, hi = explicit[code]
|
||||
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n")
|
||||
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
|
||||
@@ -22,9 +22,10 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
|
||||
for ch in mod.channels:
|
||||
channel_no = int(ch.name.split()[-1]) - 1
|
||||
io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}"
|
||||
lo, hi = (0, 4095)
|
||||
if ch.kind in ("ai", "ao"):
|
||||
lo, hi = node.channel_ranges.get(io_code, (0, 4095))
|
||||
# dense range store (FR-10.2.14): every analog channel has
|
||||
# an entry (defaults materialized at layout time)
|
||||
lo, hi = node.channel_ranges[io_code] \
|
||||
if ch.kind in ("ai", "ao") else (0, 4095)
|
||||
channels.append(
|
||||
{
|
||||
"name": ch.name,
|
||||
|
||||
@@ -346,6 +346,62 @@ class TestPlantEnv:
|
||||
assert "DI0.0" in codes and "DO0.0" in codes
|
||||
assert state.sim_time_ms == 0.0
|
||||
|
||||
def test_observe_carries_display_ranges(self, node):
|
||||
"""FR-8.2.14 observation includes analog display ranges
|
||||
(FR-10.2.14 dense store — defaults materialized)."""
|
||||
env = PlantEnv(node=node)
|
||||
state = env.observe()
|
||||
ai = state.channel("AI0.0")
|
||||
assert ai.range == (0, 4095) # default materialized
|
||||
di = state.channel("DI0.0")
|
||||
assert di.range is None # digital: no range
|
||||
# a configured range is visible in the very same observation
|
||||
node.set_channel_ranges({"AI0.0": (-32000, 32000)})
|
||||
state = env.observe()
|
||||
assert state.channel("AI0.0").range == (-32000, 32000)
|
||||
|
||||
def test_channel_lookup_by_name(self, node):
|
||||
"""PlantState.channel() resolves the configured application
|
||||
name too (FR-10.2.11 names, FR-8.2.16 accessor)."""
|
||||
node.channel_names["AI0.0"] = "tank_level"
|
||||
env = PlantEnv(node=node)
|
||||
state = env.observe()
|
||||
ch = state.channel("tank_level")
|
||||
assert ch.io_code == "AI0.0"
|
||||
assert ch.range == (0, 4095)
|
||||
assert state.channel("AI0.0") is ch # code lookup matches
|
||||
# code takes precedence over name; unknown -> KeyError
|
||||
with pytest.raises(KeyError):
|
||||
state.channel("no_such_channel")
|
||||
# a name colliding with an I/O code resolves to the code
|
||||
node.channel_names["AO0.1"] = "AI0.0"
|
||||
state = env.observe()
|
||||
assert state.channel("AI0.0").io_code == "AI0.0"
|
||||
|
||||
def test_channel_range_accessor(self, node):
|
||||
"""state.channel_range(): analog (min, max) by code or name;
|
||||
digital channels carry no range."""
|
||||
node.channel_names["AI0.0"] = "tank_level"
|
||||
node.set_channel_ranges({"AI0.0": (-32000, 32000)})
|
||||
env = PlantEnv(node=node)
|
||||
state = env.observe()
|
||||
assert state.channel_range("AI0.0") == (-32000, 32000)
|
||||
assert state.channel_range("tank_level") == (-32000, 32000)
|
||||
assert state.channel_range("AO0.0") == (0, 4095) # dense default
|
||||
with pytest.raises(KeyError):
|
||||
state.channel_range("DI0.0") # digital
|
||||
|
||||
def test_node_range_map_is_dense(self, node):
|
||||
"""FR-10.2.14: every analog channel always has an entry."""
|
||||
analog = set(node.channel_ranges)
|
||||
assert {"AI0.0", "AI0.1", "AO0.0", "AO0.1"} <= analog
|
||||
assert all(node.channel_ranges[c] == (0, 4095) for c in analog)
|
||||
# digital codes are pruned; defaults materialized on assign
|
||||
node.set_channel_ranges({"AI0.0": (0, 4095), "DI0.0": (0, 100)})
|
||||
assert "DI0.0" not in node.channel_ranges
|
||||
assert node.channel_ranges["AI0.0"] == (0, 4095)
|
||||
assert "AO0.1" in node.channel_ranges # re-materialized
|
||||
|
||||
def test_apply_writes_input_side(self, node):
|
||||
env = PlantEnv(node=node)
|
||||
env.apply({"DI0.0": True, "AI0.0": 1234})
|
||||
|
||||
+35
-2
@@ -480,13 +480,32 @@ class TestChannelRanges:
|
||||
assert ai["range"] == {"min": 0, "max": 32000}
|
||||
assert app.node.channel_ranges["AO0.0"] == (400, 2000)
|
||||
|
||||
async def test_default_range_dropped_from_store(self, client, app):
|
||||
async def test_default_range_kept_in_dense_store(self, client, app):
|
||||
# the store is DENSE (FR-10.2.14): a default-equal entry is
|
||||
# materialized like any other — no sparse-override dropping
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default
|
||||
"persist": False,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert app.node.channel_ranges == {} # kept clean
|
||||
assert app.node.channel_ranges["AI0.0"] == (0, 4095)
|
||||
# every analog channel of the layout is present
|
||||
analog = {c for c in app.node.channel_ranges}
|
||||
assert {"AI0.0", "AI0.1", "AO0.0", "AO0.1"} <= analog
|
||||
# and digital codes never are
|
||||
assert not any(c.startswith(("DI", "DO")) for c in analog)
|
||||
|
||||
async def test_dense_store_pruned_on_module_save(self, client, app, tmp_path):
|
||||
app.node_config_path = str(tmp_path / "node.yaml")
|
||||
await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": False,
|
||||
})
|
||||
r = await client.put("/api/config/modules", json={
|
||||
"modules": ["750-430", "750-530"], "persist": False,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
# no analog modules left -> the range store is empty (pruned)
|
||||
assert app.node.channel_ranges == {}
|
||||
|
||||
async def test_range_validation(self, client):
|
||||
base = {"persist": False}
|
||||
@@ -569,6 +588,20 @@ class TestChannelRanges:
|
||||
ranges = load_channel_ranges(load_yaml(cfg_file))
|
||||
assert ranges == {"AI0.0": (0, 32000)}
|
||||
|
||||
async def test_ranges_yaml_lean_defaults(self, client, app, tmp_path):
|
||||
# dense store, lean YAML: default-equal entries are NOT written
|
||||
cfg_file = tmp_path / "node.yaml"
|
||||
app.node_config_path = str(cfg_file)
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095},
|
||||
"AO0.0": {"min": 0, "max": 32000}},
|
||||
"persist": True,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
text = cfg_file.read_text(encoding="utf-8")
|
||||
assert "AO0.0" in text and "AI0.0" not in text
|
||||
assert app.node.channel_ranges["AI0.0"] == (0, 4095) # store dense
|
||||
|
||||
async def test_ranges_survive_module_save(self, client, app, tmp_path):
|
||||
app.node_config_path = str(tmp_path / "node.yaml")
|
||||
await client.put("/api/config/channel-ranges", json={
|
||||
|
||||
Reference in New Issue
Block a user