This commit is contained in:
Jonas Bengtsson
2026-10-04 20:59:22 +02:00
parent f82e8d4be9
commit 68d3c42d79
12 changed files with 394 additions and 73 deletions
+89
View File
@@ -1703,6 +1703,95 @@
], ],
"isBackground": false, "isBackground": false,
"problemMatcher": [] "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
} }
] ]
} }
+12 -4
View File
@@ -224,10 +224,13 @@ Env = TankEnv # `--env myenvs.tank` picks this up
``` ```
API surface (spec FR-8.2.14–17): `observe()` returns every API surface (spec FR-8.2.14–17): `observe()` returns every
DI/DO/AI/AO channel (I/O codes, names, values) plus aliases and DI/DO/AI/AO channel (I/O codes, names, values, analog display
degradation parameters; `apply()` writes DI/AI (the field); ranges) plus aliases and degradation parameters; `apply()` writes DI/AI (the field);
`force()` writes DO/AO master-side (headless runs, fault setup); `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 forms: `pkg.mod:ClassName`, `pkg.mod:factory`, or `pkg.mod` with
`Env`/`make_env`. The shipped example is `Env`/`make_env`. The shipped example is
`envs/pump_station_env.py` (tank + pump physics with a low-level `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 may be negative (-65534–65535; stored as 16-bit two's
complement), for signed plant quantities. DI/DO channels render complement), for signed plant quantities. DI/DO channels render
in vertical columns of eight (0–7 left, 8–15 next) on thin, 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, - [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
FR-10.2.9) — deferred. FR-10.2.9) — deferred.
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx - [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx
+15 -11
View File
@@ -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 # 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) # Edit by hand or via the UI; comments are not preserved on UI saves.
name: pump_station_node name: pump_node
coupler: 750-362 coupler: 750-362
modules: modules:
- "750-430" # 8-channel digital input 24VDC - "750-430" # 8-channel DI 24VDC
- "750-1405" # 16-channel digital input 24VDC - "750-1405" # 16-channel DI 24VDC
- "750-530" # 8-channel digital output 24VDC - "750-530" # 8-channel DO 24VDC
- "750-1516" # 16-channel digital output 24VDC - "750-1516" # 16-channel DO 24VDC
- "750-467" # 2-channel analog input 4-20mA - "750-467" # 2-channel AI 4-20mA
- "750-554" # 2-channel analog output 4-20mA - "750-554" # 2-channel AO 4-20mA
# Application names for module channels (I/O code -> name) # Application names for module channels (I/O code -> name)
channel_names: channel_names:
AO0.0: speed_setpoint AI0.0: NG1
DI0.0: start_button AO0.0: P!_BORV
DI0.0: P1_DI
DI0.1: P1_AUTO
DI0.2: P1_FEL
DO0.0: P!_START
+17 -4
View File
@@ -588,7 +588,8 @@ unchanged whether or not CSV streaming is used.
deterministic tick (the engine or the runner advances the tick clock, deterministic tick (the engine or the runner advances the tick clock,
never wall-clock arithmetic): **`state = env.observe()`** returns the never wall-clock arithmetic): **`state = env.observe()`** returns the
state of ALL modules — DI/DO/AI/AO channels, engineering units, per-type 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()` `env.model(state, dt)` (the caller-supplied plant physics) → **`apply()`
writes the new field state**. The environment owns no policy; plant writes the new field state**. The environment owns no policy; plant
models are supplied by subclassing (FR-8.2.17). 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 Exactly one decision source may own an alias per tick (ownership
conflicts are validation errors, extending FR-8.2.2). conflicts are validation errors, extending FR-8.2.2).
- FR-8.2.16: **Environment API surface** (concrete): - FR-8.2.16: **Environment API surface** (concrete):
`env.observe() -> PlantState` (all channels, aliases, parameters, `env.observe() -> PlantState` (all channels with names and analog
sim-time); `env.apply(values, force=False)` (keys: I/O code or alias 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); name; DI/AI default, DO/AO require force; unknown keys are errors);
`env.model(state, dt_s)` — override point for plant physics, returning `env.model(state, dt_s)` — override point for plant physics, returning
new field values; `env.is_done(state) -> bool` — optional end new field values; `env.is_done(state) -> bool` — optional end
criterion, default False (runs until manually stopped); criterion, default False (runs until manually stopped);
`env.reset()` — deterministic fresh episode; `env.tick_ms` / `env.reset()` — deterministic fresh episode; `env.tick_ms` /
`env.sim_time_ms` (read-only clock); `env.parameters` / `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 - FR-8.2.17: **Environment authoring & runners**: environments are easy
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
@@ -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 onto the bar). Simulated AI values and AO forces accept negative
numbers within the same window; values are stored in the image numbers within the same window; values are stored in the image
as their 16-bit two's-complement representation. 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) ### 10.3 Implementation decisions (binding, 2026-10-03)
+42 -22
View File
@@ -13,50 +13,70 @@ or headless for model development:
--env envs.pump_station_env:PumpStationEnv --env envs.pump_station_env:PumpStationEnv
Physics (deliberately simple, for demonstration): one tank. Physics (deliberately simple, for demonstration): one tank.
- inflow is constant (or a small sinusoidal demand profile) - inflow is constant
- the pump drains at `flow_max * capacity * speed` while DO0.0 is ON - 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 the speed fraction is AO0.0 normalized over its display range
- a low-level switch DI0.1 opens when the tank drains below 500 mm (FR-10.2.14)
- optional is_done: stop after a sim hour (default: run forever) - 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 __future__ import annotations
from wago_sim.scenario.plantenv import PlantEnv, PlantState 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 FLOW_MAX = 30.0 # m^3/h the pump moves at full speed
INFLOW = 12.0 # m^3/h constant inflow INFLOW = 12.0 # m^3/h constant inflow
LEVEL_MAX = 9000.0 # mm — tank brim LEVEL_SENSOR_MAX = 5.0 # m — tank level meter full-scale
SWITCH_AT = 500.0 # mm — low-level switch threshold LEVEL_BRIM = 2.9 # m — tank brim (physical clamp)
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
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)
self._level_mm = 3000.0 # initial tank level self._level_m = 1.2 # initial tank level
self.stop_after_ms = stop_after_ms self.stop_after_ms = stop_after_ms
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 # what the PLC currently commands (master side: DO/AO)
pump_on = bool(state.by_code("DO0.0")) pump_running = 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 setpoint from AO0.0, normalized over its display range
speed = min(1.0, max(0.0, speed_raw / 4095.0)) 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) # physics: water balance with pump wear (parameters attachable)
capacity = state.parameters.get("pump_capacity", 1.0) capacity = state.parameters.get("pump_capacity", 1.0)
outflow = FLOW_MAX * capacity * speed if pump_on else 0.0 outflow = FLOW_MAX * capacity * speed if pump_running else 0.0
d_level_mm = (INFLOW - outflow) / TANK_AREA * 1000.0 * dt_h d_level_m = (INFLOW - outflow) / TANK_AREA * dt_h
self._level_mm = min(LEVEL_MAX, max(0.0, self._level_mm + d_level_mm)) self._level_m = min(LEVEL_BRIM, max(0.0, self._level_m + d_level_m))
# the field the PLC reads back
level_counts = int(self._level_mm / 0.1) # AI0.0: 0.1 mm/count # the field the PLC reads back: level over the AI display range
low_switch = self._level_mm < SWITCH_AT # DI0.1 # (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 { return {
"AI0.0": level_counts, "AI0.0": level_raw,
"DI0.1": low_switch, "DI0.1": high_switch,
} }
def is_done(self, state: PlantState) -> bool: def is_done(self, state: PlantState) -> bool:
+42 -4
View File
@@ -196,6 +196,39 @@ class VirtualNode:
self.digital_in_bits = digital_in_bits self.digital_in_bits = digital_in_bits
self.digital_out_bits = digital_out_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: def description(self) -> str:
"""Text for firmware-info register 0x2030 (module description).""" """Text for firmware-info register 0x2030 (module description)."""
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules)) 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]: def channel_range(self, kind: str, module_slot: int, channel: int) -> tuple[int, int]:
"""Display range (min, max) raw counts for an analog channel. """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( if kind not in ("ai", "ao"):
self.io_code_at_slot(kind, module_slot, channel), self.DEFAULT_RANGE 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]: def validate_channel_names(self) -> list[str]:
"""Names whose I/O code does not exist on this node (after a """Names whose I/O code does not exist on this node (after a
+45 -4
View File
@@ -25,10 +25,14 @@ from __future__ import annotations
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 ..modules import VirtualNode from ..modules import VirtualNode
from .engine import ScenarioError from .engine import ScenarioError
if TYPE_CHECKING:
from .engine import ScenarioEngine
@dataclass @dataclass
class ChannelState: class ChannelState:
@@ -40,6 +44,7 @@ class ChannelState:
io_code: str # e.g. "DI0.0" io_code: str # e.g. "DI0.0"
name: str | None # configured application name, if any name: str | None # configured application name, if any
value: object # bool for di/do, int/float for ai/ao value: object # bool for di/do, int/float for ai/ao
range: tuple[int, int] | None = None # (min, max) raw counts — analog only
@dataclass @dataclass
@@ -62,6 +67,35 @@ class PlantState:
"""{io_code: value} for all channels of one kind.""" """{io_code: value} for all channels of one kind."""
return {c.io_code: c.value for c in self.channels if c.kind == 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: class PlantEnv:
"""gym-style plant environment (FR-8.2.14-17). """gym-style plant environment (FR-8.2.14-17).
@@ -75,9 +109,14 @@ class PlantEnv:
TICK_MS = 10.0 TICK_MS = 10.0
def __init__(self, node: VirtualNode | None = None, *, 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.engine = engine
self.node: VirtualNode = node if node is not None else engine.node
self.datastore = self.node.datastore self.datastore = self.node.datastore
self.sim_time_ms = 0.0 self.sim_time_ms = 0.0
# attachments (optional; the scenario layer wires these) # attachments (optional; the scenario layer wires these)
@@ -114,6 +153,8 @@ class PlantEnv:
io_code=code, io_code=code,
name=node.channel_names.get(code), name=node.channel_names.get(code),
value=value, value=value,
range=node.channel_ranges[code]
if kind in ("ai", "ao") else None,
)) ))
aliases = ({name: self._alias_value(name) aliases = ({name: self._alias_value(name)
for name in (self._aliases or {})} for name in (self._aliases or {})}
@@ -198,14 +239,14 @@ class PlantEnv:
if kind == "di": if kind == "di":
ds.set_digital_input_channel(loc.bit + 1, bool(value)) ds.set_digital_input_channel(loc.bit + 1, bool(value))
elif kind == "ai": 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) ds.set_input_word(loc.word, raw)
elif kind == "do": elif kind == "do":
result = ds.write_bits(loc.bit, [bool(value)]) result = ds.write_bits(loc.bit, [bool(value)])
if isinstance(result, Exception): if isinstance(result, Exception):
raise ScenarioError(f"force() write to {key!r}: {result}") raise ScenarioError(f"force() write to {key!r}: {result}")
else: # ao 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]) result = ds.write_words(loc.word, [raw])
if isinstance(result, Exception): if isinstance(result, Exception):
raise ScenarioError(f"force() write to {key!r}: {result}") raise ScenarioError(f"force() write to {key!r}: {result}")
+25 -14
View File
@@ -14,7 +14,8 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from ..app import SimulatorApp, rebuild_node 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 ..scenario.engine import ScenarioError
from ..watchdog import RegisterWriteRefused from ..watchdog import RegisterWriteRefused
from . import state from . import state
@@ -417,7 +418,7 @@ async def put_module_config(
save_node_config(sim.node_config_path, modules, save_node_config(sim.node_config_path, modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=sim.node.channel_names, channel_names=sim.node.channel_names,
channel_ranges=sim.node.channel_ranges) channel_ranges=_nondefault_ranges(sim))
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
@@ -485,6 +486,7 @@ async def put_channel_names(
sim.node_config_path, sim.node.modules, sim.node_config_path, sim.node.modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=names, channel_names=names,
channel_ranges=sim.node.channel_ranges,
) )
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
@@ -520,8 +522,10 @@ async def put_channel_ranges(
"""Set per-channel analog display ranges (FR-10.2.14). """Set per-channel analog display ranges (FR-10.2.14).
Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}. Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}.
Codes are regex-validated; 0 <= min < max <= 65535 required; an Codes are regex-validated; -65534 <= min < max <= 65535 required.
entry with min=0/max=4095 (the default) is dropped from the store. 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) _require_write(request, sessions)
raw = payload.get("channel_ranges") raw = payload.get("channel_ranges")
@@ -550,10 +554,9 @@ async def put_channel_ranges(
status_code=400, status_code=400,
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535", detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535",
) )
if (lo, hi) == (0, 4095): ranges[code_up] = (lo, hi) # dense store keeps defaults
continue # default — keep the store clean sim.node.set_channel_ranges(ranges)
ranges[code_up] = (lo, hi) persisted = _nondefault_ranges(sim)
sim.node.channel_ranges = ranges
if payload.get("persist", True) and sim.node_config_path: if payload.get("persist", True) and sim.node_config_path:
from .persist import save_node_config from .persist import save_node_config
try: try:
@@ -561,7 +564,7 @@ async def put_channel_ranges(
sim.node_config_path, sim.node.modules, sim.node_config_path, sim.node.modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=sim.node.channel_names, channel_names=sim.node.channel_names,
channel_ranges=ranges, channel_ranges=persisted,
) )
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
@@ -570,7 +573,8 @@ async def put_channel_ranges(
) from None ) from None
return { return {
"ok": True, "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") @router.get("/api/config/export")
async def export_config( async def export_config(
sim: SimulatorApp = Depends(get_sim), sim: SimulatorApp = Depends(get_sim),
@@ -606,7 +618,7 @@ async def export_config(
"modules": [m.article for m in sim.node.modules], "modules": [m.article for m in sim.node.modules],
"channel_names": dict(sim.node.channel_names), "channel_names": dict(sim.node.channel_names),
"channel_ranges": {c: {"min": lo, "max": hi} "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), "coupler_settings": _coupler_snapshot(sim),
} }
@@ -663,8 +675,7 @@ async def import_config(
if not -65534 <= lo < hi <= 65535: if not -65534 <= lo < hi <= 65535:
raise HTTPException(status_code=400, raise HTTPException(status_code=400,
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535") detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535")
if (lo, hi) != (0, 4095): ranges[code_up] = (lo, hi) # defaults kept; the store is dense
ranges[code_up] = (lo, hi)
coupler = payload.get("coupler_settings") or {} coupler = payload.get("coupler_settings") or {}
# -- everything validated: apply atomically ------------------------- # -- everything validated: apply atomically -------------------------
@@ -703,7 +714,7 @@ async def import_config(
sim.node_config_path, modules, sim.node_config_path, modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=names, channel_names=names,
channel_ranges=ranges, channel_ranges=_nondefault_ranges(sim),
) )
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
+12 -5
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from ..modules import ModuleType from ..modules import ModuleType, VirtualNode
_HEADER = """\ _HEADER = """\
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG) # 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): for code in sorted(channel_names):
lines.append(f" {code}: {channel_names[code]}\n") lines.append(f" {code}: {channel_names[code]}\n")
if channel_ranges: if channel_ranges:
lines.append(_RANGES_HEADER) # lean YAML: the runtime store is dense (every analog channel,
for code in sorted(channel_ranges): # FR-10.2.14) — persist only explicit, non-default entries so a
lo, hi = channel_ranges[code] # hand-edited config stays minimal and defaults survive a
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n") # future DEFAULT_RANGE change
explicit = {code: rng for code, rng in channel_ranges.items()
if rng != VirtualNode.DEFAULT_RANGE}
if explicit:
lines.append(_RANGES_HEADER)
for code in sorted(explicit):
lo, hi = explicit[code]
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n")
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n") Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
+4 -3
View File
@@ -22,9 +22,10 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
for ch in mod.channels: for ch in mod.channels:
channel_no = int(ch.name.split()[-1]) - 1 channel_no = int(ch.name.split()[-1]) - 1
io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}" io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}"
lo, hi = (0, 4095) # dense range store (FR-10.2.14): every analog channel has
if ch.kind in ("ai", "ao"): # an entry (defaults materialized at layout time)
lo, hi = node.channel_ranges.get(io_code, (0, 4095)) lo, hi = node.channel_ranges[io_code] \
if ch.kind in ("ai", "ao") else (0, 4095)
channels.append( channels.append(
{ {
"name": ch.name, "name": ch.name,
+56
View File
@@ -346,6 +346,62 @@ class TestPlantEnv:
assert "DI0.0" in codes and "DO0.0" in codes assert "DI0.0" in codes and "DO0.0" in codes
assert state.sim_time_ms == 0.0 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): def test_apply_writes_input_side(self, node):
env = PlantEnv(node=node) env = PlantEnv(node=node)
env.apply({"DI0.0": True, "AI0.0": 1234}) env.apply({"DI0.0": True, "AI0.0": 1234})
+35 -2
View File
@@ -480,13 +480,32 @@ class TestChannelRanges:
assert ai["range"] == {"min": 0, "max": 32000} assert ai["range"] == {"min": 0, "max": 32000}
assert app.node.channel_ranges["AO0.0"] == (400, 2000) assert app.node.channel_ranges["AO0.0"] == (400, 2000)
async def test_default_range_dropped_from_store(self, client, app): async def test_default_range_kept_in_dense_store(self, client, app):
# the store is DENSE (FR-10.2.14): a default-equal entry is
# materialized like any other — no sparse-override dropping
r = await client.put("/api/config/channel-ranges", json={ r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default "channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default
"persist": False, "persist": False,
}) })
assert r.status_code == 200 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): async def test_range_validation(self, client):
base = {"persist": False} base = {"persist": False}
@@ -569,6 +588,20 @@ class TestChannelRanges:
ranges = load_channel_ranges(load_yaml(cfg_file)) ranges = load_channel_ranges(load_yaml(cfg_file))
assert ranges == {"AI0.0": (0, 32000)} assert ranges == {"AI0.0": (0, 32000)}
async def test_ranges_yaml_lean_defaults(self, client, app, tmp_path):
# dense store, lean YAML: default-equal entries are NOT written
cfg_file = tmp_path / "node.yaml"
app.node_config_path = str(cfg_file)
r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095},
"AO0.0": {"min": 0, "max": 32000}},
"persist": True,
})
assert r.status_code == 200
text = cfg_file.read_text(encoding="utf-8")
assert "AO0.0" in text and "AI0.0" not in text
assert app.node.channel_ranges["AI0.0"] == (0, 4095) # store dense
async def test_ranges_survive_module_save(self, client, app, tmp_path): async def test_ranges_survive_module_save(self, client, app, tmp_path):
app.node_config_path = str(tmp_path / "node.yaml") app.node_config_path = str(tmp_path / "node.yaml")
await client.put("/api/config/channel-ranges", json={ await client.put("/api/config/channel-ranges", json={