🚧 Implement continous simulation
This commit is contained in:
+13
-3
@@ -35,6 +35,7 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -101,7 +102,7 @@ def _build_logger(verbose: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _fail(message: str, code: int = 2):
|
||||
def _fail(message: str, code: int = 2) -> NoReturn:
|
||||
"""Report a bad-argument / bad-configuration error cleanly and exit.
|
||||
|
||||
Prints a one-line ``wago-sim: error: ...`` to stderr (never a
|
||||
@@ -257,7 +258,13 @@ def _run_scenario(args: argparse.Namespace) -> int:
|
||||
_fail(f"scenario {args.scenario}: {exc}")
|
||||
engine.start()
|
||||
result = None
|
||||
max_ticks = 2_000_000
|
||||
# sequence runs cap at 2M ticks; continuous runs scale with `until`
|
||||
# (spec §8): a 28-day soak needs ~242M ticks, so the limit is
|
||||
# derived from the configured run length plus headroom.
|
||||
if engine.mode == "continuous":
|
||||
max_ticks = int((engine._until_ms or 0) / engine.TICK_MS) + 10_000_000
|
||||
else:
|
||||
max_ticks = 2_000_000
|
||||
ticks = 0
|
||||
while result is None and ticks < max_ticks:
|
||||
result = engine.pump()
|
||||
@@ -266,7 +273,10 @@ def _run_scenario(args: argparse.Namespace) -> int:
|
||||
print("Scenario did not terminate (tick limit reached)", file=sys.stderr)
|
||||
return 3
|
||||
print(result_summary(result))
|
||||
if report_jsonl:
|
||||
# continuous runs append episodes/snapshots to the JSONL report
|
||||
if report_jsonl and engine.invariants is not None:
|
||||
write_jsonl(result, report_jsonl, extra=engine.invariants.as_dict())
|
||||
elif report_jsonl:
|
||||
write_jsonl(result, report_jsonl)
|
||||
if report_csv:
|
||||
write_csv(result, report_csv)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Streaming CSV dataset playback for continuous scenarios (spec §8, FR-8.2.2-11).
|
||||
|
||||
A dataset drives designated input aliases from a time-series CSV:
|
||||
|
||||
datasets:
|
||||
inflow:
|
||||
file: data/inflow.csv # time column + one column per alias
|
||||
interpolation: linear # analog only; digital always holds
|
||||
repeat: 4 # optional seamless loop
|
||||
time_factor: 10 # dataset seconds per sim-second
|
||||
offset: 1h # optional start shift on sim-time
|
||||
time_column: time # default 'time'
|
||||
time_format: elapsed # 'elapsed' (seconds) | 'iso8601'
|
||||
|
||||
Design points (FR-8.2.6): files are STREAMED with one-row lookahead —
|
||||
a months-long CSV never loads into RAM. Malformed rows raise a
|
||||
DataError carrying file+line so the engine can end the run with
|
||||
verdict ERROR (FR-8.2.11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .engine import DataError, ScenarioError
|
||||
|
||||
|
||||
def _parse_digital(text: str) -> bool:
|
||||
v = text.strip().upper()
|
||||
if v in ("ON", "1", "TRUE", "YES"):
|
||||
return True
|
||||
if v in ("OFF", "0", "FALSE", "NO", ""):
|
||||
return False
|
||||
raise ValueError(f"cannot parse digital value {text!r}")
|
||||
|
||||
|
||||
def _parse_time(text: str, fmt: str) -> float:
|
||||
"""Elapsed seconds or ISO-8601 -> elapsed seconds since the epoch."""
|
||||
if fmt == "iso8601":
|
||||
dt = datetime.fromisoformat(text.strip())
|
||||
return dt.timestamp()
|
||||
return float(text)
|
||||
|
||||
|
||||
class CsvDataset:
|
||||
"""One streaming dataset (FR-8.2.2-11).
|
||||
|
||||
The engine calls :meth:`apply_at` every tick with the current
|
||||
sim-time; the dataset sets its aliases via the engine's set_alias.
|
||||
Bounded memory: exactly one lookahead row is held.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine,
|
||||
name: str,
|
||||
spec: dict,
|
||||
*,
|
||||
base_path: Path | None = None,
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.name = name
|
||||
self.path = Path(spec["file"])
|
||||
if not self.path.is_absolute() and base_path is not None:
|
||||
self.path = base_path / self.path
|
||||
self.time_column = str(spec.get("time_column", "time"))
|
||||
self.time_format = str(spec.get("time_format", "elapsed"))
|
||||
self.interpolation = str(spec.get("interpolation", "linear"))
|
||||
self.repeat = int(spec.get("repeat", 1))
|
||||
self.time_factor = float(spec.get("time_factor", 1.0))
|
||||
self.offset_ms = _parse_duration_ms(spec.get("offset", 0))
|
||||
self.aliases: list[str] = []
|
||||
self._fh = None
|
||||
self._reader = None
|
||||
self._header: list[str] | None = None
|
||||
self._row: list[str] | None = None # current row
|
||||
self._next: list[str] | None = None # one-row lookahead
|
||||
self._row_time = 0.0 # dataset time of _row
|
||||
self._loop = 1 # current repeat loop
|
||||
self._start_epoch: float | None = None # iso8601 anchor
|
||||
self._finished = False
|
||||
self._line_no = 0
|
||||
|
||||
# -- lifecycle ------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
"""Validate the header and alias columns; prime the lookahead."""
|
||||
if not self.path.is_file():
|
||||
raise ScenarioError(f"dataset file not found: {self.path}")
|
||||
self._fh = self.path.open("r", encoding="utf-8", newline="")
|
||||
self._reader = csv.reader(self._fh)
|
||||
try:
|
||||
header = next(self._reader)
|
||||
except StopIteration:
|
||||
raise ScenarioError(f"dataset {self.path} is empty") from None
|
||||
self._line_no = 1
|
||||
header = [h.strip() for h in header]
|
||||
if self.time_column not in header:
|
||||
raise DataError(
|
||||
str(self.path), 1, f"time column {self.time_column!r} not in header")
|
||||
cols = [c for c in header if c != self.time_column]
|
||||
if not cols:
|
||||
raise DataError(str(self.path), 1, "no alias columns")
|
||||
for col in cols:
|
||||
if col not in self.engine._aliases:
|
||||
raise DataError(
|
||||
str(self.path), 1,
|
||||
f"column {col!r} is not a known alias")
|
||||
alias = self.engine._aliases[col]
|
||||
if alias.kind in ("do", "ao"):
|
||||
raise DataError(
|
||||
str(self.path), 1,
|
||||
f"output alias {col!r} cannot be dataset-driven")
|
||||
self.aliases = cols
|
||||
self._header = header
|
||||
self._advance_row() # prime _row / _row_time
|
||||
# iso8601 datasets: anchor elapsed time at the first sample so
|
||||
# `offset` positions the series on the sim-time axis
|
||||
if self.time_format == "iso8601" and self._row is not None:
|
||||
self._start_epoch = self._row_time
|
||||
self._row_time = 0.0
|
||||
|
||||
def close(self) -> None:
|
||||
if self._fh is not None:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self._finished
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
def _read_next(self) -> list[str] | None:
|
||||
"""Read the next data row, skipping comment lines (#)."""
|
||||
while True:
|
||||
try:
|
||||
raw = next(self._reader)
|
||||
except StopIteration:
|
||||
return None
|
||||
self._line_no += 1
|
||||
if raw and raw[0].lstrip().startswith("#"):
|
||||
continue
|
||||
return raw
|
||||
|
||||
def _advance_row(self) -> None:
|
||||
"""Move the lookahead into the current row slot."""
|
||||
if self._next is None:
|
||||
self._next = self._read_next()
|
||||
if self._next is not None:
|
||||
self._next_t = self._parse_time_of(self._next)
|
||||
if self._next is None:
|
||||
# end of file: loop if repeats remain
|
||||
if self._loop < self.repeat:
|
||||
self._loop += 1
|
||||
self._fh.seek(0)
|
||||
next(self._reader) # skip header
|
||||
self._line_no = 1
|
||||
self._next = self._read_next()
|
||||
if self._next is None:
|
||||
self._finished = True
|
||||
return
|
||||
self._next_t = self._parse_time_of(self._next)
|
||||
# seamless seam (FR-8.2.7): continue the time axis from
|
||||
# the last sample of the previous loop
|
||||
self._next_t = self._row_time + (
|
||||
self._next_t - self._loop_anchor)
|
||||
else:
|
||||
self._finished = True
|
||||
return
|
||||
self._row = self._next
|
||||
self._row_time = self._next_t if self._loop == 1 else self._row_time + (
|
||||
self._next_t - self._row_time)
|
||||
self._next = self._read_next()
|
||||
if self._next is not None:
|
||||
self._next_t = self._parse_time_of(self._next)
|
||||
if self._next_t <= self._row_time:
|
||||
raise DataError(
|
||||
str(self.path), self._line_no,
|
||||
f"non-monotonic time {self._next_t}")
|
||||
|
||||
_loop_anchor = 0.0
|
||||
|
||||
def _parse_time_of(self, row: list[str]) -> float:
|
||||
try:
|
||||
idx = self._header.index(self.time_column)
|
||||
return _parse_time(row[idx], self.time_format)
|
||||
except (ValueError, IndexError) as exc:
|
||||
raise DataError(
|
||||
str(self.path), self._line_no,
|
||||
f"cannot parse time: {exc}") from None
|
||||
|
||||
def _value_of(self, row: list[str], alias: str) -> object:
|
||||
idx = self._header.index(alias)
|
||||
if idx >= len(row):
|
||||
raise DataError(str(self.path), self._line_no,
|
||||
f"missing column {alias!r}")
|
||||
alias_spec = self.engine._aliases[alias]
|
||||
text = row[idx]
|
||||
try:
|
||||
if alias_spec.kind == "di":
|
||||
return _parse_digital(text)
|
||||
return float(text)
|
||||
except ValueError:
|
||||
raise DataError(
|
||||
str(self.path), self._line_no,
|
||||
f"cannot parse value for {alias!r}: {text!r}") from None
|
||||
|
||||
# -- per-tick playback ---------------------------------------------------
|
||||
|
||||
def apply_at(self, sim_time_ms: float) -> None:
|
||||
"""Set this dataset's aliases to their values at sim_time_ms."""
|
||||
if self._finished:
|
||||
return
|
||||
# dataset-time position on the sim-time axis (time_factor applied)
|
||||
effective_ms = (sim_time_ms - self.offset_ms) * self.time_factor
|
||||
if effective_ms < 0:
|
||||
return # not started yet (offset)
|
||||
effective_s = effective_ms / 1000.0
|
||||
# advance the stream until the row pair brackets the time
|
||||
while (self._next is not None
|
||||
and self._next_t <= effective_s):
|
||||
self._advance_row()
|
||||
if self._row is None:
|
||||
return
|
||||
# interpolate/hold between _row and _next (FR-8.2.5)
|
||||
for alias in self.aliases:
|
||||
kind = self.engine._aliases[alias].kind
|
||||
if kind == "di":
|
||||
self.engine.set_alias(alias, self._value_of(self._row, alias))
|
||||
continue
|
||||
cur = self._value_of(self._row, alias)
|
||||
if (self.interpolation == "linear" and self._next is not None):
|
||||
nxt = self._value_of(self._next, alias)
|
||||
span = self._next_t - self._row_time
|
||||
if span > 0:
|
||||
frac = (effective_s - self._row_time) / span
|
||||
frac = min(1.0, max(0.0, frac))
|
||||
cur = cur + (nxt - cur) * frac
|
||||
self.engine.set_alias(alias, cur)
|
||||
|
||||
|
||||
def _parse_duration_ms(value: str | int | float) -> float:
|
||||
"""'1h30m' style offsets -> milliseconds (reuses engine parser)."""
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
text = str(value).strip().lower()
|
||||
multipliers = {"ms": 1.0, "s": 1000.0, "m": 60000.0, "h": 3600000.0}
|
||||
for suffix in ("ms", "s", "m", "h"):
|
||||
if text.endswith(suffix):
|
||||
try:
|
||||
return float(text[: -len(suffix)]) * multipliers[suffix]
|
||||
except ValueError:
|
||||
break
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
raise ScenarioError(f"cannot parse offset {value!r}") from None
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Wear counters and degradation models (spec §8.3, FR-8.3.1-5).
|
||||
|
||||
Plant parameters (e.g. ``pump_capacity``) age from wear instead of
|
||||
staying ideal forever, exercising the PLC's compensation and
|
||||
maintenance logic:
|
||||
|
||||
degradation:
|
||||
pump_capacity: # parameter name (float 0..1)
|
||||
from: 1.0 # fresh value (also the reset target)
|
||||
model: linear # linear | exponential | staircase
|
||||
rate: 0.0002 # per runtime hour (linear/exp)
|
||||
per: runtime_hours # wear basis
|
||||
on: pump_relay # alias observed for wear (any kind)
|
||||
staircase: [0.95, 0.85, 0.7] # staircase model: levels per step
|
||||
maintenance_at: 0.75 # parameter threshold -> diagnosis
|
||||
diagnosis: # injected when threshold crossed
|
||||
module: 2 # per-type DI/DO... module number
|
||||
channel: 0
|
||||
value: 0x0001
|
||||
|
||||
Counters update once per tick from the observed alias truth (runtime
|
||||
hours, rising/falling edges). The engine reads parameters via
|
||||
``engine.parameter(name)`` (FR-8.3.3); the maintenance alarm is the
|
||||
PLC's to raise — the scenario asserts it (FR-8.3.4); the built-in
|
||||
``maintenance_reset`` action clears diagnosis and restores ``from``
|
||||
(FR-8.3.5).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class WearCounters:
|
||||
"""Per-alias wear (FR-8.3.1)."""
|
||||
|
||||
runtime_hours: float = 0.0
|
||||
rising_edges: int = 0
|
||||
falling_edges: int = 0
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"runtime_hours": self.runtime_hours,
|
||||
"rising_edges": self.rising_edges,
|
||||
"falling_edges": self.falling_edges,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DegradationModel:
|
||||
"""One aging parameter (FR-8.3.2-5)."""
|
||||
|
||||
name: str
|
||||
parameter: str # parameter key, e.g. 'pump_capacity'
|
||||
observes: str # alias name the wear counts come from
|
||||
model: str = "linear" # linear | exponential | staircase
|
||||
start: float = 1.0 # fresh value; maintenance_reset target
|
||||
rate: float = 0.0 # per hour (linear/exponential) or per cycle
|
||||
basis: str = "runtime_hours" # runtime_hours | cycles
|
||||
staircase: list[float] = field(default_factory=list)
|
||||
maintenance_at: float | None = None
|
||||
diagnosis: dict | None = None
|
||||
# runtime state
|
||||
value: float = 1.0
|
||||
cycles: int = 0
|
||||
diagnosed: bool = False
|
||||
|
||||
def reset(self) -> None:
|
||||
"""FR-8.3.5: restore to `start`; diagnosis cleared by the engine."""
|
||||
self.value = self.start
|
||||
self.cycles = 0
|
||||
|
||||
def update(self, dt_hours: float, wear: WearCounters) -> None:
|
||||
"""Advance the parameter one tick (FR-8.3.2)."""
|
||||
if self.basis == "cycles":
|
||||
if self.model == "staircase":
|
||||
level = min(self.cycles, len(self.staircase) - 1)
|
||||
self.value = self.staircase[level]
|
||||
elif self.model == "linear":
|
||||
self.value = self.start - self.rate * self.cycles
|
||||
elif self.model == "exponential":
|
||||
self.value = self.start * (self.rate ** self.cycles)
|
||||
else:
|
||||
if self.model == "staircase":
|
||||
# `rate` = levels per runtime hour; rate<=0 = one level/hour
|
||||
per_level_hours = (1.0 / self.rate) if self.rate > 0 else 1.0
|
||||
level = min(int(wear.runtime_hours / per_level_hours),
|
||||
len(self.staircase) - 1)
|
||||
self.value = self.staircase[level]
|
||||
elif self.model == "exponential":
|
||||
self.value = self.start * (1.0 - self.rate) ** wear.runtime_hours
|
||||
else: # linear
|
||||
self.value = self.start - self.rate * wear.runtime_hours
|
||||
self.value = max(0.0, min(1.0, self.value))
|
||||
|
||||
|
||||
class DegradationManager:
|
||||
"""All aging parameters of one continuous run (spec §8.3)."""
|
||||
|
||||
def __init__(self, engine, spec: dict | None) -> None:
|
||||
self.engine = engine
|
||||
self.models: dict[str, DegradationModel] = {}
|
||||
self.wear: dict[str, WearCounters] = {}
|
||||
self._last_truth: dict[str, bool] = {}
|
||||
for param_name, cfg in (spec or {}).items():
|
||||
if not isinstance(cfg, dict):
|
||||
raise ValueError(f"degradation {param_name!r} must be a mapping")
|
||||
# NB: the key is 'observes', NOT 'on' — YAML 1.1 parses the
|
||||
# bare key `on` as boolean True (a classic trap)
|
||||
observes = str(cfg.get("observes", ""))
|
||||
if observes not in engine._aliases:
|
||||
raise ValueError(
|
||||
f"degradation {param_name!r}: unknown alias {observes!r}")
|
||||
model = DegradationModel(
|
||||
name=param_name,
|
||||
parameter=str(cfg.get("parameter", param_name)),
|
||||
observes=observes,
|
||||
model=str(cfg.get("model", "linear")),
|
||||
start=float(cfg.get("from", 1.0)),
|
||||
rate=float(cfg.get("rate", 0.0)),
|
||||
basis=str(cfg.get("per", "runtime_hours")),
|
||||
staircase=[float(v) for v in cfg.get("staircase", [])],
|
||||
maintenance_at=(float(cfg["maintenance_at"])
|
||||
if "maintenance_at" in cfg else None),
|
||||
diagnosis=cfg.get("diagnosis"),
|
||||
)
|
||||
if model.model == "staircase" and not model.staircase:
|
||||
raise ValueError(
|
||||
f"degradation {param_name!r}: staircase model needs 'staircase'")
|
||||
model.value = model.start
|
||||
self.models[param_name] = model
|
||||
self.wear.setdefault(observes, WearCounters())
|
||||
|
||||
# -- per-tick update ------------------------------------------------------
|
||||
|
||||
def tick(self, dt_ms: float) -> None:
|
||||
"""Update wear counters and parameters for one tick (FR-8.2.12 step 4)."""
|
||||
dt_hours = dt_ms / 3_600_000.0
|
||||
for alias_name, wear in self.wear.items():
|
||||
truth = bool(self.engine.alias_value(alias_name))
|
||||
before = self._last_truth.get(alias_name, False)
|
||||
if truth:
|
||||
wear.runtime_hours += dt_hours
|
||||
if truth and not before:
|
||||
wear.rising_edges += 1
|
||||
# count a cycle per completed run: rising edge closes it
|
||||
for model in self.models.values():
|
||||
if model.observes == alias_name and model.basis == "cycles":
|
||||
model.cycles += 1
|
||||
if (not truth) and before:
|
||||
wear.falling_edges += 1
|
||||
self._last_truth[alias_name] = truth
|
||||
for model in self.models.values():
|
||||
model.update(dt_hours, self.wear[model.observes])
|
||||
if (model.maintenance_at is not None and not model.diagnosed
|
||||
and model.value <= model.maintenance_at):
|
||||
model.diagnosed = True
|
||||
self._inject_diagnosis(model)
|
||||
|
||||
def _inject_diagnosis(self, model: DegradationModel) -> None:
|
||||
"""FR-8.3.4: threshold crossed -> module diagnosis injected."""
|
||||
diag = model.diagnosis or {}
|
||||
module = int(diag.get("module", 0))
|
||||
channel = int(diag.get("channel", 0))
|
||||
value = int(diag.get("value", 0x0001))
|
||||
registers = getattr(self.engine, "registers", None)
|
||||
if registers is not None:
|
||||
registers.set_module_diagnosis(module, channel, value)
|
||||
|
||||
# -- engine interface (FR-8.3.3) -------------------------------------------
|
||||
|
||||
def parameter(self, name: str) -> float:
|
||||
"""Parameter value for plant physics (pump models etc.)."""
|
||||
model = self.models.get(name)
|
||||
if model is None:
|
||||
raise KeyError(f"unknown parameter {name!r}")
|
||||
return model.value
|
||||
|
||||
def maintenance_reset(self) -> None:
|
||||
"""FR-8.3.5 action: restore parameters, clear diagnosis."""
|
||||
for model in self.models.values():
|
||||
model.reset()
|
||||
model.diagnosed = False
|
||||
registers = getattr(self.engine, "registers", None)
|
||||
if registers is not None:
|
||||
registers.set_module_diagnosis(0, 0, 0)
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""Wear + parameter values for report snapshots (FR-8.4.3)."""
|
||||
return {
|
||||
"parameters": {n: m.value for n, m in self.models.items()},
|
||||
"wear": {a: w.as_dict() for a, w in self.wear.items()},
|
||||
}
|
||||
@@ -136,6 +136,50 @@ def validate_scenario(scenario: dict) -> list[str]:
|
||||
f"'module' is given"
|
||||
)
|
||||
seq = scenario.get("sequence") or []
|
||||
if not seq and not scenario.get("states"):
|
||||
mode = str(scenario.get("mode", "sequence"))
|
||||
if not seq and not scenario.get("states") and mode != "continuous":
|
||||
problems.append("neither 'sequence' nor 'states' defined")
|
||||
# -- continuous mode (spec §8) -------------------------------------------
|
||||
mode = str(scenario.get("mode", "sequence"))
|
||||
if mode not in ("sequence", "continuous"):
|
||||
problems.append(f"unknown mode {mode!r} (sequence | continuous)")
|
||||
if mode == "continuous":
|
||||
# sequence optional (FR-8.2.1); run-length drivers required
|
||||
datasets = scenario.get("datasets") or {}
|
||||
scheduled = [s for s in seq if isinstance(s, dict) and "at" in s]
|
||||
until = scenario.get("until")
|
||||
if not datasets and not scheduled and until is None and not seq:
|
||||
problems.append(
|
||||
"continuous scenario needs datasets, scheduled steps "
|
||||
"(at:), or until: to bound the run (FR-8.2.1)"
|
||||
)
|
||||
if not isinstance(datasets, dict):
|
||||
problems.append("'datasets' must be a mapping")
|
||||
else:
|
||||
for ds_name, spec in datasets.items():
|
||||
if not isinstance(spec, dict) or "file" not in spec:
|
||||
problems.append(f"datasets.{ds_name}: needs 'file'")
|
||||
else:
|
||||
for key in ("interpolation",):
|
||||
if key in spec and spec[key] not in ("linear", "hold"):
|
||||
problems.append(
|
||||
f"datasets.{ds_name}: interpolation must be "
|
||||
f"linear | hold (FR-8.2.5)")
|
||||
# invariants / rate limits reference aliases (FR-8.4)
|
||||
for inv in scenario.get("invariants") or []:
|
||||
if not isinstance(inv, dict) or "alias" not in inv or "condition" not in inv:
|
||||
problems.append("invariants need 'alias' and 'condition'")
|
||||
for rl in scenario.get("rate_limits") or []:
|
||||
if not isinstance(rl, dict) or "alias" not in rl or "max_per_hour" not in rl:
|
||||
problems.append("rate_limits need 'alias' and 'max_per_hour'")
|
||||
deg = scenario.get("degradation") or {}
|
||||
if not isinstance(deg, dict):
|
||||
problems.append("'degradation' must be a mapping")
|
||||
else:
|
||||
for pname, cfg in deg.items():
|
||||
if not isinstance(cfg, dict) or "observes" not in cfg:
|
||||
problems.append(f"degradation.{pname}: needs 'observes' (alias)")
|
||||
elif cfg["observes"] not in {**inputs, **outputs}:
|
||||
problems.append(
|
||||
f"degradation.{pname}: unknown alias {cfg['observes']!r}")
|
||||
return problems
|
||||
@@ -155,6 +155,122 @@ class ScenarioEngine:
|
||||
}
|
||||
self._macro_return_wait: float | None = None
|
||||
self._plugin_actions = self._load_plugins()
|
||||
# -- continuous mode (spec §8) ---------------------------------------
|
||||
self.mode = str(scenario.get("mode", "sequence"))
|
||||
if self.mode not in ("sequence", "continuous"):
|
||||
raise ScenarioError(f"unknown mode {self.mode!r}")
|
||||
self.env = None # PlantEnv, attached in continuous mode
|
||||
self.datasets = [] # CsvDataset instances
|
||||
self.degradation = None # DegradationManager
|
||||
self.invariants = None # InvariantMonitor
|
||||
self._until_ms: float | None = None
|
||||
self._scheduled: list[tuple[float, dict]] = [] # (due_ms, step)
|
||||
self._ownership: dict[str, str] = {} # alias -> driver name
|
||||
if self.mode == "continuous":
|
||||
self._setup_continuous()
|
||||
|
||||
# -- continuous-mode setup (spec §8.2) --------------------------------------
|
||||
|
||||
def _setup_continuous(self) -> None:
|
||||
from .datasets import CsvDataset
|
||||
from .degradation import DegradationManager
|
||||
from .invariants import InvariantMonitor
|
||||
from .plantenv import PlantEnv
|
||||
|
||||
# run-length control (FR-8.2.1): until, scheduled steps, datasets
|
||||
until = self.scenario.get("until")
|
||||
if until is not None:
|
||||
self._until_ms = _parse_duration(until)
|
||||
|
||||
# datasets (optional) — one driver per alias (FR-8.2.2)
|
||||
base = self.scenario.get("_base_path")
|
||||
for ds_name, spec in (self.scenario.get("datasets") or {}).items():
|
||||
ds = CsvDataset(self, ds_name, spec, base_path=base)
|
||||
ds.open()
|
||||
for alias in ds.aliases:
|
||||
self._claim_alias(alias, f"dataset:{ds_name}")
|
||||
self.datasets.append(ds)
|
||||
|
||||
# scheduled steps: `at: <duration>` entries (FR-8.4.6); the rest of
|
||||
# the sequence runs as a normal step queue when present
|
||||
for step in self.scenario.get("sequence", []) or []:
|
||||
if isinstance(step, dict) and "at" in step:
|
||||
due = _parse_duration(step["at"])
|
||||
inner = {k: v for k, v in step.items() if k != "at"}
|
||||
self._scheduled.append((due, inner))
|
||||
self._scheduled.sort(key=lambda s: s[0])
|
||||
|
||||
self.degradation = DegradationManager(self, self.scenario.get("degradation"))
|
||||
for model in self.degradation.models.values():
|
||||
self._claim_alias(model.observes, f"degradation:{model.name}")
|
||||
self.invariants = InvariantMonitor(self, self.scenario)
|
||||
self.env = PlantEnv(self)
|
||||
|
||||
# FR-8.2.1: a continuous run needs at least one run-length driver
|
||||
if (not self.datasets and not self._scheduled
|
||||
and self._until_ms is None
|
||||
and not self.scenario.get("sequence")):
|
||||
raise ScenarioError(
|
||||
"continuous scenario needs datasets, scheduled steps, or "
|
||||
"until: to bound the run (it would run forever)"
|
||||
)
|
||||
|
||||
def _claim_alias(self, alias: str, driver: str) -> None:
|
||||
"""FR-8.2.2: exactly one driver may own an alias."""
|
||||
if alias in self._ownership and self._ownership[alias] != driver:
|
||||
raise ScenarioError(
|
||||
f"alias {alias!r} claimed by both "
|
||||
f"{self._ownership[alias]!r} and {driver!r}"
|
||||
)
|
||||
self._ownership[alias] = driver
|
||||
|
||||
def parameter(self, name: str) -> float:
|
||||
"""Plant parameter accessor (FR-8.3.3): e.g. pump_capacity."""
|
||||
if self.degradation is None:
|
||||
raise ScenarioError("no degradation configured; parameter() unavailable")
|
||||
return self.degradation.parameter(name)
|
||||
|
||||
def maintenance_reset(self) -> None:
|
||||
"""Scenario action (FR-8.3.5): restore parameters, clear diagnosis."""
|
||||
if self.degradation is not None:
|
||||
self.degradation.maintenance_reset()
|
||||
|
||||
def reset_episode(self) -> None:
|
||||
"""PlantEnv.reset (FR-8.2.16): deterministic fresh episode."""
|
||||
self.datastore.reset_images()
|
||||
self.sim_time_ms = 0.0
|
||||
self._result = ScenarioResult(name=self.name)
|
||||
self._edges = {}
|
||||
self._ramps = []
|
||||
self._last_snapshot = self._snapshot_aliases()
|
||||
self._finished = False
|
||||
self._step_queue = deque(self.scenario.get("sequence", []) or [])
|
||||
self._wait_until_ms = None
|
||||
self._pending_assert = None
|
||||
self._assert_deadline_ms = None
|
||||
for ds in self.datasets:
|
||||
ds.close()
|
||||
ds._loop = 1
|
||||
ds._finished = False
|
||||
ds._row = ds._next = None
|
||||
ds._row_time = 0.0
|
||||
ds._line_no = 0
|
||||
ds.open()
|
||||
if self.degradation is not None:
|
||||
for model in self.degradation.models.values():
|
||||
model.reset()
|
||||
model.diagnosed = False
|
||||
self.degradation._last_truth = {}
|
||||
if self.invariants is not None:
|
||||
self.invariants.episodes = []
|
||||
for inv in self.invariants.invariants:
|
||||
inv.active = None
|
||||
for rl in self.invariants.rate_limits:
|
||||
rl.samples = []
|
||||
rl.breached = False
|
||||
rl.peak_rate = 0.0
|
||||
self.invariants.snapshots = []
|
||||
self.invariants._last_snapshot_ms = 0.0
|
||||
|
||||
# -- setup ---------------------------------------------------------------
|
||||
|
||||
@@ -328,6 +444,8 @@ class ScenarioEngine:
|
||||
return self._result
|
||||
dt = dt_ms if dt_ms is not None else float(self.TICK_MS)
|
||||
self.sim_time_ms += dt
|
||||
if self.mode == "continuous":
|
||||
return self._pump_continuous(dt)
|
||||
# 1) detect alias edges (inputs set in the previous tick, outputs
|
||||
# written by the master/plant)
|
||||
self._detect_edges()
|
||||
@@ -377,6 +495,118 @@ class ScenarioEngine:
|
||||
if fn is not None:
|
||||
fn(self, None)
|
||||
|
||||
# -- continuous tick pipeline (spec FR-8.2.12) -------------------------------
|
||||
|
||||
def _pump_continuous(self, dt: float) -> ScenarioResult | None:
|
||||
"""One continuous-mode tick, fixed pipeline order (FR-8.2.12):
|
||||
|
||||
(1) datasets apply sampled/interpolated values,
|
||||
(2) alias edges are detected,
|
||||
(3) plant models run (plugins + env decisions flush),
|
||||
(4) degradation updates,
|
||||
(5) invariants and rate limits evaluate,
|
||||
(6) scheduled steps due this tick execute,
|
||||
(7) snapshots are taken if due.
|
||||
Run end: until elapsed + all drivers exhausted (FR-8.2.1).
|
||||
"""
|
||||
# (1) datasets
|
||||
for ds in self.datasets:
|
||||
try:
|
||||
ds.apply_at(self.sim_time_ms)
|
||||
except DataError as exc:
|
||||
self._record("dataset", Verdict.ERROR, str(exc))
|
||||
self._finish(Verdict.ERROR)
|
||||
return self._result
|
||||
# (2) edges
|
||||
self._detect_edges()
|
||||
# (3) plant logic: plugins first, then queued env actions flush
|
||||
self._run_tick_plugins()
|
||||
if self.env is not None:
|
||||
self.env.flush()
|
||||
self._advance_ramps(dt)
|
||||
# (4) degradation
|
||||
if self.degradation is not None:
|
||||
self.degradation.tick(dt)
|
||||
# (5) invariants + rate limits
|
||||
if self.invariants is not None:
|
||||
self.invariants.tick()
|
||||
failure = self.invariants.rate_failure
|
||||
if failure:
|
||||
self._record("rate_limit", Verdict.FAIL, failure)
|
||||
self._finish(Verdict.FAIL)
|
||||
return self._result
|
||||
# (6) scheduled steps due now (FR-8.4.6)
|
||||
while self._scheduled and self._scheduled[0][0] <= self.sim_time_ms:
|
||||
_, step = self._scheduled.pop(0)
|
||||
for key, value in step.items():
|
||||
self._execute_step_key(key, value)
|
||||
# non-scheduled step queue (mixed runs): drain non-blocking steps
|
||||
self._drain_sequence_in_continuous()
|
||||
# (7) snapshots handled inside invariants.tick()
|
||||
|
||||
# -- run-end check (FR-8.2.1) -----------------------------------------
|
||||
# The run ends when ALL of the following hold:
|
||||
# - every dataset finished (or looped to its repeat limit)
|
||||
# - no scheduled steps remain and the step queue is empty
|
||||
# - no blocking step is pending (wait/assert)
|
||||
# - the `until` duration elapsed (until ALWAYS bounds the run)
|
||||
drivers_left = (any(not ds.finished for ds in self.datasets)
|
||||
or bool(self._scheduled)
|
||||
or bool(self._step_queue)
|
||||
or self._wait_until_ms is not None
|
||||
or self._pending_assert is not None)
|
||||
until_elapsed = (self._until_ms is not None
|
||||
and self.sim_time_ms >= self._until_ms)
|
||||
if until_elapsed or (not drivers_left and self._until_ms is None):
|
||||
self._end_continuous_run()
|
||||
return None if not self._finished else self._result
|
||||
|
||||
def _end_continuous_run(self) -> None:
|
||||
"""Continuous run-end verdict (FR-8.4.4): invariants/rate-limits fail."""
|
||||
verdict = Verdict.PASS
|
||||
if self.invariants is not None:
|
||||
if self.invariants.episodes:
|
||||
verdict = Verdict.FAIL
|
||||
elif self.invariants.rate_failure:
|
||||
verdict = Verdict.FAIL
|
||||
for step in self._result.steps:
|
||||
if step.verdict is Verdict.FAIL:
|
||||
verdict = Verdict.FAIL
|
||||
break
|
||||
self._finish(verdict)
|
||||
|
||||
def _drain_sequence_in_continuous(self) -> None:
|
||||
"""Run queued sequence steps that don't block (mixed continuous runs)."""
|
||||
while self._step_queue:
|
||||
step = self._step_queue[0]
|
||||
if isinstance(step, dict) and "at" in step:
|
||||
self._step_queue.popleft() # scheduled steps handled above
|
||||
continue
|
||||
blocked = self._run_next_step()
|
||||
if blocked:
|
||||
break
|
||||
|
||||
def _execute_step_key(self, key: str, value) -> None:
|
||||
"""One key of a scheduled step (subset of sequence semantics)."""
|
||||
if key == "set":
|
||||
for alias_name, v in value.items():
|
||||
self.set_alias(alias_name, v)
|
||||
elif key == "maintenance_reset":
|
||||
self.maintenance_reset()
|
||||
elif key == "report":
|
||||
self._record("scheduled", Verdict.PASS if str(value).upper() == "PASS"
|
||||
else Verdict.FAIL, str(value))
|
||||
elif key in self._plugin_actions:
|
||||
self._plugin_actions[key](self, value)
|
||||
elif key == "assert":
|
||||
# immediate one-tick check; failures recorded as episodes
|
||||
label = str(value.get("label", "assert")) if isinstance(value, dict) else "assert"
|
||||
conditions = value.get("conditions", {}) if isinstance(value, dict) else value
|
||||
verdict = Verdict.PASS if self._matcher.evaluate(conditions) else Verdict.FAIL
|
||||
self._record(label, verdict, f"at {self.sim_time_ms:.0f} ms")
|
||||
else:
|
||||
raise ScenarioError(f"unknown scheduled step key: {key!r}")
|
||||
|
||||
def _run_next_step(self) -> bool:
|
||||
"""Execute one queued step.
|
||||
|
||||
@@ -531,6 +761,15 @@ class ScenarioError(ValueError):
|
||||
"""Invalid scenario definition or runtime misuse."""
|
||||
|
||||
|
||||
class DataError(ScenarioError):
|
||||
"""Malformed dataset content (FR-8.2.11); carries file and line."""
|
||||
|
||||
def __init__(self, path: str, line_no: int, message: str) -> None:
|
||||
self.path = path
|
||||
self.line_no = line_no
|
||||
super().__init__(f"{path}:{line_no}: {message}")
|
||||
|
||||
|
||||
def _parse_duration(value: str | int | float) -> float:
|
||||
"""Parse '250ms', '30s', '2m', or a raw number (milliseconds)."""
|
||||
if isinstance(value, (int, float)):
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Invariants, rate limits, and snapshots for continuous runs (spec §8.4).
|
||||
|
||||
An invariant is a condition that must hold EVERY tick of the run
|
||||
(e.g. tank level within bounds). A breach is recorded as one bounded
|
||||
*episode* (start, end, peak violation) instead of per-tick spam
|
||||
(FR-8.4.1); any episode fails the run verdict. Rate limits bound how
|
||||
fast an alias may change over a sliding one-hour window (FR-8.4.2).
|
||||
Snapshots periodically dump alias values, wear counters, and
|
||||
degradation parameters (FR-8.4.3).
|
||||
|
||||
YAML::
|
||||
|
||||
invariants:
|
||||
- { alias: tank_level, condition: '>= 0', label: level_bottom }
|
||||
- { alias: tank_level, condition: '<= 8500', label: level_brim }
|
||||
rate_limits:
|
||||
- { alias: pump_speed, max_per_hour: 3000 }
|
||||
snapshot_every: 1h
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
def _compare(value: float, condition: str) -> bool:
|
||||
"""Evaluate 'value <condition>' e.g. 3.2 >= 0. Returns True when OK."""
|
||||
text = condition.strip()
|
||||
for op in ("<=", ">=", "!=", "==", "<", ">"):
|
||||
if text.startswith(op):
|
||||
ref = float(text[len(op):])
|
||||
return {
|
||||
"<=": value <= ref, ">=": value >= ref,
|
||||
"!=": value != ref, "==": value == ref,
|
||||
"<": value < ref, ">": value > ref,
|
||||
}[op]
|
||||
raise ValueError(f"cannot parse invariant condition {condition!r}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Episode:
|
||||
"""One aggregated invariant breach (FR-8.4.1)."""
|
||||
|
||||
label: str
|
||||
started_ms: float
|
||||
ended_ms: float
|
||||
peak_violation: float = 0.0
|
||||
samples: int = 0
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"label": self.label,
|
||||
"started_ms": self.started_ms,
|
||||
"ended_ms": self.ended_ms,
|
||||
"peak_violation": self.peak_violation,
|
||||
"samples": self.samples,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Invariant:
|
||||
label: str
|
||||
alias: str
|
||||
condition: str
|
||||
# episode runtime state
|
||||
active: Episode | None = None
|
||||
|
||||
def evaluate(self, value: float) -> bool:
|
||||
return _compare(float(value), self.condition)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimit:
|
||||
"""Sliding-window rate bound (FR-8.4.2).
|
||||
|
||||
The window is one hour of SIM-TIME (not wall-clock): samples are
|
||||
(sim_time, value) pairs; older ones age out of the window.
|
||||
"""
|
||||
|
||||
alias: str
|
||||
max_per_hour: float
|
||||
window_ms: float = 3_600_000.0
|
||||
samples: list[tuple[float, float]] = field(default_factory=list)
|
||||
breached: bool = False
|
||||
peak_rate: float = 0.0
|
||||
|
||||
def record(self, sim_ms: float, value: float) -> None:
|
||||
self.samples.append((sim_ms, float(value)))
|
||||
horizon = sim_ms - self.window_ms
|
||||
if self.samples and self.samples[0][0] < horizon:
|
||||
self.samples = [s for s in self.samples if s[0] >= horizon]
|
||||
if len(self.samples) >= 2:
|
||||
t0, v0 = self.samples[0]
|
||||
t1, v1 = self.samples[-1]
|
||||
hours = (t1 - t0) / 3_600_000.0
|
||||
if hours > 0:
|
||||
rate = abs(v1 - v0) / hours
|
||||
self.peak_rate = max(self.peak_rate, rate)
|
||||
if rate > self.max_per_hour:
|
||||
self.breached = True
|
||||
|
||||
|
||||
class InvariantMonitor:
|
||||
"""Per-tick invariant + rate-limit evaluation (FR-8.2.12 step 5)."""
|
||||
|
||||
def __init__(self, engine, scenario: dict) -> None:
|
||||
self.engine = engine
|
||||
self.invariants: list[Invariant] = []
|
||||
self.rate_limits: list[RateLimit] = []
|
||||
for spec in scenario.get("invariants", []) or []:
|
||||
alias = str(spec["alias"])
|
||||
if alias not in engine._aliases:
|
||||
raise ValueError(f"invariant: unknown alias {alias!r}")
|
||||
self.invariants.append(Invariant(
|
||||
label=str(spec.get("label", f"{alias} {spec.get('condition')}")),
|
||||
alias=alias,
|
||||
condition=str(spec["condition"]),
|
||||
))
|
||||
for spec in scenario.get("rate_limits", []) or []:
|
||||
alias = str(spec["alias"])
|
||||
if alias not in engine._aliases:
|
||||
raise ValueError(f"rate_limit: unknown alias {alias!r}")
|
||||
self.rate_limits.append(RateLimit(
|
||||
alias=alias,
|
||||
max_per_hour=float(spec["max_per_hour"]),
|
||||
))
|
||||
self.episodes: list[Episode] = []
|
||||
self.snapshot_every_ms = _parse_ms(scenario.get("snapshot_every", 0))
|
||||
self.snapshots: list[dict] = []
|
||||
self._last_snapshot_ms = 0.0
|
||||
|
||||
# -- per-tick ------------------------------------------------------------
|
||||
|
||||
def tick(self) -> None:
|
||||
"""Evaluate invariants + rate limits; take snapshots if due.
|
||||
|
||||
Returns a failure detail string when a rate limit is breached
|
||||
(the engine turns that into a FAIL); invariant episodes are
|
||||
recorded and reported (they fail the run at the end).
|
||||
"""
|
||||
sim = self.engine.sim_time_ms
|
||||
for inv in self.invariants:
|
||||
value = self.engine.alias_value(inv.alias)
|
||||
ok = inv.evaluate(value if not isinstance(value, bool) else int(value))
|
||||
if not ok:
|
||||
if inv.active is None:
|
||||
inv.active = Episode(inv.label, sim, sim)
|
||||
self.episodes.append(inv.active)
|
||||
inv.active.ended_ms = sim
|
||||
inv.active.samples += 1
|
||||
# peak violation: magnitude from the nearest bound
|
||||
inv.active.peak_violation = max(
|
||||
inv.active.peak_violation, _violation(value, inv.condition))
|
||||
elif inv.active is not None:
|
||||
inv.active = None # episode closed; bounded record kept
|
||||
for rl in self.rate_limits:
|
||||
rl.record(sim, float(self.engine.alias_value(rl.alias)))
|
||||
if self.snapshot_every_ms and sim - self._last_snapshot_ms >= self.snapshot_every_ms:
|
||||
self._take_snapshot()
|
||||
|
||||
def _take_snapshot(self) -> None:
|
||||
self._last_snapshot_ms = self.engine.sim_time_ms
|
||||
self.snapshots.append({
|
||||
"sim_time_ms": self.engine.sim_time_ms,
|
||||
"wall_time": time.time(),
|
||||
"aliases": self.engine._snapshot_aliases(),
|
||||
"degradation": (self.engine.degradation.snapshot()
|
||||
if getattr(self.engine, "degradation", None) else None),
|
||||
})
|
||||
|
||||
@property
|
||||
def rate_failure(self) -> str | None:
|
||||
for rl in self.rate_limits:
|
||||
if rl.breached:
|
||||
return (f"rate limit: {rl.alias} peak {rl.peak_rate:.1f}/h "
|
||||
f"exceeds {rl.max_per_hour}/h")
|
||||
return None
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"episodes": [e.as_dict() for e in self.episodes],
|
||||
"rate_limits": [
|
||||
{"alias": r.alias, "max_per_hour": r.max_per_hour,
|
||||
"peak_rate": r.peak_rate, "breached": r.breached}
|
||||
for r in self.rate_limits
|
||||
],
|
||||
"snapshots": self.snapshots,
|
||||
}
|
||||
|
||||
|
||||
def _violation(value: float, condition: str) -> float:
|
||||
"""Distance beyond the violated bound (for the episode peak)."""
|
||||
text = condition.strip()
|
||||
for op, sign in (("<=", 1), (">=", -1)):
|
||||
if text.startswith(op):
|
||||
ref = float(text[len(op):])
|
||||
return abs(value - ref)
|
||||
return abs(float(value))
|
||||
|
||||
|
||||
def _parse_ms(value: str | int | float) -> float:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
text = str(value).strip().lower()
|
||||
multipliers = {"ms": 1.0, "s": 1000.0, "m": 60000.0, "h": 3600000.0}
|
||||
for suffix in ("ms", "s", "m", "h"):
|
||||
if text.endswith(suffix):
|
||||
return float(text[: -len(suffix)]) * multipliers[suffix]
|
||||
return float(text)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""PlantEnv: gymnasium-style environment over the simulated plant (spec §8, FR-8.2.14-16).
|
||||
|
||||
The environment is the decision-source interface to the distributed
|
||||
I/O: per deterministic engine tick —
|
||||
|
||||
state = env.observe() # WAGO input image in engineering units
|
||||
decision = policy(state) # caller's plant model / agent (no policy here)
|
||||
env.apply(actions) # decided outputs -> output image via
|
||||
# the datastore's INTERNAL write paths
|
||||
engine.pump() # engine advances exactly one tick
|
||||
|
||||
The engine always owns the tick clock (FR-4.3.9 deterministic sim
|
||||
time); the env never reads wall-clock. `apply()` writes through the
|
||||
datastore's Modbus-equivalent dispatch so mirrors, statistics, and
|
||||
watchdog behavior stay consistent (FR-9.2.6 semantics).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .engine import ScenarioError
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelState:
|
||||
"""One channel of the observed plant state (engineering units)."""
|
||||
|
||||
kind: str # di | do | ai | ao
|
||||
module: int # per-type module number (FR-4.3.11 v1.5)
|
||||
channel: int # 0-based channel on the module
|
||||
io_code: str # e.g. "DI0.0"
|
||||
name: str | None # configured application name, if any
|
||||
value: object # bool for di/do, float for ai/ao
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlantState:
|
||||
"""Full observation: every channel + aliases + parameters + clock."""
|
||||
|
||||
sim_time_ms: float
|
||||
channels: list[ChannelState] = field(default_factory=list)
|
||||
aliases: dict[str, object] = field(default_factory=dict)
|
||||
parameters: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
class PlantEnv:
|
||||
"""gym-style observe/apply environment (FR-8.2.14-16).
|
||||
|
||||
Constructed by the continuous engine; the engine stays the clock
|
||||
owner — the env's `step()` convenience drives `engine.pump()` for
|
||||
exactly one tick (server mode may tick the engine externally and
|
||||
only use observe/apply).
|
||||
"""
|
||||
|
||||
def __init__(self, engine) -> None:
|
||||
self.engine = engine
|
||||
self._actions: dict[str, object] = {}
|
||||
|
||||
# -- observation (FR-8.2.14) -----------------------------------------------
|
||||
|
||||
def observe(self) -> PlantState:
|
||||
"""Snapshot of the generic inputs (WAGO input image + aliases).
|
||||
|
||||
DI/AI channels hold the simulated field state; DO/AO hold the
|
||||
PLC-written outputs as currently in the images — all values in
|
||||
engineering units (alias scale/offset applied where defined).
|
||||
"""
|
||||
node = self.engine.node
|
||||
ds = self.engine.datastore
|
||||
channels: list[ChannelState] = []
|
||||
for kind, pool in (("di", node.di), ("do", node.do),
|
||||
("ai", node.ai), ("ao", node.ao)):
|
||||
for i, loc in enumerate(pool, start=1):
|
||||
if kind == "di":
|
||||
value = ds.digital_input_channel(i)
|
||||
elif kind == "do":
|
||||
value = ds.digital_output_channel(i)
|
||||
elif kind == "ai":
|
||||
value = ds.get_input_word(loc.word)
|
||||
else:
|
||||
value = ds.get_output_word(loc.word)
|
||||
code = node.io_code_at_slot(kind, loc.module_index, loc.channel - 1)
|
||||
channels.append(ChannelState(
|
||||
kind=kind,
|
||||
module=node.kind_index(loc.module_index)[kind],
|
||||
channel=loc.channel - 1,
|
||||
io_code=code,
|
||||
name=node.channel_names.get(code),
|
||||
value=value,
|
||||
))
|
||||
aliases = {name: self.engine.alias_value(name)
|
||||
for name in self.engine._aliases}
|
||||
parameters: dict[str, float] = {}
|
||||
degradation = getattr(self.engine, "degradation", None)
|
||||
if degradation is not None:
|
||||
parameters = {n: m.value for n, m in degradation.models.items()}
|
||||
return PlantState(
|
||||
sim_time_ms=self.engine.sim_time_ms,
|
||||
channels=channels,
|
||||
aliases=aliases,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
# -- actions (FR-8.2.14) -----------------------------------------------------
|
||||
|
||||
def apply(self, actions: dict[str, object]) -> None:
|
||||
"""Queue decided outputs; they land when the engine ticks.
|
||||
|
||||
Keys: I/O code ("DO0.0") or alias name ("pump_relay"). Writing
|
||||
a DI/AI through the env is an error — inputs are the field side
|
||||
(datasets/scenario steps simulate them). Keys are validated
|
||||
EAGERLY so a bad decision surfaces immediately, not at flush.
|
||||
"""
|
||||
if not isinstance(actions, dict):
|
||||
raise ScenarioError("apply() actions must be a mapping")
|
||||
validated: dict[str, object] = {}
|
||||
for key, value in actions.items():
|
||||
alias = self.engine._aliases.get(key) or self._alias_by_code(key)
|
||||
if alias is None:
|
||||
raise ScenarioError(f"apply(): unknown channel {key!r}")
|
||||
if alias.kind not in ("do", "ao"):
|
||||
raise ScenarioError(
|
||||
f"apply(): {key!r} is an input alias ({alias.kind}); "
|
||||
"only outputs (do/ao) are decided")
|
||||
validated[str(key)] = value
|
||||
self._actions.update(validated)
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Write queued actions into the output image (engine-tick step).
|
||||
|
||||
Called by the engine after the observe/decide window and before
|
||||
the pipeline advances — so the next observe() sees them
|
||||
(FR-8.2.12 atomic-env-tick paragraph).
|
||||
"""
|
||||
for key, value in self._actions.items():
|
||||
self._write_one(key, value)
|
||||
self._actions = {}
|
||||
|
||||
def _write_one(self, key: str, value: object) -> None:
|
||||
# resolve by alias name first, then by I/O code
|
||||
alias = self.engine._aliases.get(key)
|
||||
if alias is None:
|
||||
alias = self._alias_by_code(key)
|
||||
if alias is None:
|
||||
raise ScenarioError(f"apply(): unknown channel {key!r}")
|
||||
if alias.kind not in ("do", "ao"):
|
||||
raise ScenarioError(
|
||||
f"apply(): {key!r} is an input alias ({alias.kind}); "
|
||||
"only outputs (do/ao) are decided")
|
||||
loc = self.engine._alias_location(alias)
|
||||
ds = self.engine.datastore
|
||||
if alias.kind == "do":
|
||||
result = ds.write_bits(loc.bit, [bool(value)])
|
||||
else:
|
||||
numeric = float(value) # type: ignore[arg-type]
|
||||
raw = int(numeric * alias.scale + alias.offset) & 0xFFFF
|
||||
result = ds.write_words(loc.word, [raw])
|
||||
if isinstance(result, Exception):
|
||||
raise ScenarioError(f"apply() write to {key!r} rejected: {result}")
|
||||
|
||||
def _alias_by_code(self, code: str):
|
||||
"""Find an alias whose I/O code matches (per-type codes)."""
|
||||
code_up = str(code).strip().upper()
|
||||
node = self.engine.node
|
||||
for name, alias in self.engine._aliases.items():
|
||||
if alias.kind not in ("do", "ao"):
|
||||
continue
|
||||
try:
|
||||
loc = self.engine._alias_location(alias)
|
||||
except Exception:
|
||||
continue
|
||||
if node.io_code_at_slot(alias.kind, loc.module_index,
|
||||
loc.channel - 1) == code_up:
|
||||
return alias
|
||||
return None
|
||||
|
||||
# -- clock (FR-8.2.16) ---------------------------------------------------------
|
||||
|
||||
@property
|
||||
def tick_ms(self) -> float:
|
||||
return float(self.engine.TICK_MS)
|
||||
|
||||
@property
|
||||
def sim_time_ms(self) -> float:
|
||||
return self.engine.sim_time_ms
|
||||
|
||||
# -- episode control (FR-8.2.16) ------------------------------------------------
|
||||
|
||||
def reset(self, config: dict | None = None) -> PlantState:
|
||||
"""Fresh episode: zero images, reset wear + parameters + clock
|
||||
(deterministic — identical runs replay identically, NFR-8.7.3)."""
|
||||
self.engine.reset_episode()
|
||||
return self.observe()
|
||||
|
||||
def step(self, actions: dict[str, object] | None = None):
|
||||
"""One full env tick: apply -> engine pump -> observe (FR-8.2.14).
|
||||
|
||||
Convenience for headless agents; server mode ticks the engine
|
||||
on its own scheduler and uses observe/apply separately.
|
||||
"""
|
||||
if actions:
|
||||
self.apply(actions)
|
||||
self.engine.pump()
|
||||
return self.observe()
|
||||
|
||||
# -- §8.3/§8.4 machinery (FR-8.2.16) ----------------------------------------------
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, float]:
|
||||
deg = getattr(self.engine, "degradation", None)
|
||||
return {n: m.value for n, m in deg.models.items()} if deg else {}
|
||||
|
||||
@property
|
||||
def invariants(self):
|
||||
return getattr(self.engine, "invariants", None)
|
||||
@@ -22,7 +22,8 @@ def result_summary(result: ScenarioResult) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
|
||||
def write_jsonl(result: ScenarioResult, path: str | Path,
|
||||
extra: dict | None = None) -> None:
|
||||
path = Path(path)
|
||||
with path.open("w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({
|
||||
@@ -39,6 +40,10 @@ def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
|
||||
"detail": step.detail,
|
||||
"sim_time_ms": step.sim_time_ms,
|
||||
}) + "\n")
|
||||
if extra:
|
||||
# continuous-run artifacts (spec §8.4): episodes, rate-limit
|
||||
# peaks, and snapshots appended as a summary record
|
||||
fh.write(json.dumps({"type": "continuous", **extra}) + "\n")
|
||||
|
||||
|
||||
def write_csv(result: ScenarioResult, path: str | Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user