🚧 WIP
This commit is contained in:
+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:
|
||||
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")
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user