🚧 Implement continous simulation

This commit is contained in:
Jonas Bengtsson
2026-10-04 14:50:57 +02:00
parent eb36a2d947
commit 263afc9c1e
13 changed files with 1914 additions and 42 deletions
+13 -3
View File
@@ -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)
+260
View File
@@ -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
+195
View File
@@ -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()},
}
+45 -1
View File
@@ -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
+239
View File
@@ -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)):
+210
View File
@@ -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)
+216
View File
@@ -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)
+6 -1
View File
@@ -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: