diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9140725..dca2e25 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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 } ] } \ No newline at end of file diff --git a/README.md b/README.md index ddaf1c3..3707947 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/pump_node.yaml b/config/pump_node.yaml index 7231ee0..7b27852 100644 --- a/config/pump_node.yaml +++ b/config/pump_node.yaml @@ -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 diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index fa50280..fbd03c7 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -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) diff --git a/envs/pump_station_env.py b/envs/pump_station_env.py index 4b05e99..8b86b42 100644 --- a/envs/pump_station_env.py +++ b/envs/pump_station_env.py @@ -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: diff --git a/src/wago_sim/modules.py b/src/wago_sim/modules.py index 7331c88..e28e6de 100644 --- a/src/wago_sim/modules.py +++ b/src/wago_sim/modules.py @@ -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 diff --git a/src/wago_sim/scenario/plantenv.py b/src/wago_sim/scenario/plantenv.py index 45c7344..8e42850 100644 --- a/src/wago_sim/scenario/plantenv.py +++ b/src/wago_sim/scenario/plantenv.py @@ -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}") diff --git a/src/wago_sim/webui/api.py b/src/wago_sim/webui/api.py index e270ab0..19b61d9 100644 --- a/src/wago_sim/webui/api.py +++ b/src/wago_sim/webui/api.py @@ -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( diff --git a/src/wago_sim/webui/persist.py b/src/wago_sim/webui/persist.py index f463409..569a7b4 100644 --- a/src/wago_sim/webui/persist.py +++ b/src/wago_sim/webui/persist.py @@ -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: - lines.append(_RANGES_HEADER) - for code in sorted(channel_ranges): - lo, hi = channel_ranges[code] - lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n") + # 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(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") \ No newline at end of file diff --git a/src/wago_sim/webui/state.py b/src/wago_sim/webui/state.py index a225e8e..2767822 100644 --- a/src/wago_sim/webui/state.py +++ b/src/wago_sim/webui/state.py @@ -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, diff --git a/tests/test_continuous.py b/tests/test_continuous.py index 02ec47a..f90fff6 100644 --- a/tests/test_continuous.py +++ b/tests/test_continuous.py @@ -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}) diff --git a/tests/test_webui.py b/tests/test_webui.py index 16c5fb0..142f55c 100644 --- a/tests/test_webui.py +++ b/tests/test_webui.py @@ -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={