fieldsim 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fieldsim/__init__.py +26 -0
- fieldsim/__main__.py +3 -0
- fieldsim/background.py +362 -0
- fieldsim/cli.py +137 -0
- fieldsim/config.py +560 -0
- fieldsim/core/__init__.py +0 -0
- fieldsim/core/clock.py +87 -0
- fieldsim/core/datastore.py +35 -0
- fieldsim/core/device.py +205 -0
- fieldsim/core/engine.py +88 -0
- fieldsim/core/faults.py +150 -0
- fieldsim/core/generators.py +109 -0
- fieldsim/core/scenario.py +140 -0
- fieldsim/core/types.py +133 -0
- fieldsim/protocols/__init__.py +0 -0
- fieldsim/protocols/modbus/__init__.py +18 -0
- fieldsim/protocols/modbus/constants.py +42 -0
- fieldsim/protocols/modbus/datastore.py +203 -0
- fieldsim/protocols/modbus/device.py +115 -0
- fieldsim/protocols/modbus/framer.py +196 -0
- fieldsim/protocols/modbus/pdu.py +157 -0
- fieldsim/protocols/modbus/server.py +135 -0
- fieldsim/py.typed +0 -0
- fieldsim/pytest_plugin.py +163 -0
- fieldsim/simulator.py +96 -0
- fieldsim-0.2.0.dist-info/METADATA +257 -0
- fieldsim-0.2.0.dist-info/RECORD +30 -0
- fieldsim-0.2.0.dist-info/WHEEL +4 -0
- fieldsim-0.2.0.dist-info/entry_points.txt +5 -0
- fieldsim-0.2.0.dist-info/licenses/LICENSE +21 -0
fieldsim/core/device.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Base class shared by every simulated device."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
from fieldsim.config import FaultSpec, ScenarioStep
|
|
12
|
+
from fieldsim.core.clock import Clock, RealClock
|
|
13
|
+
from fieldsim.core.datastore import PointStore, Value
|
|
14
|
+
from fieldsim.core.engine import GeneratorEngine
|
|
15
|
+
from fieldsim.core.faults import ActiveFault, FaultInjector, parse_fault
|
|
16
|
+
from fieldsim.core.generators import Generator
|
|
17
|
+
from fieldsim.core.scenario import ScenarioRunner
|
|
18
|
+
|
|
19
|
+
StoreT = TypeVar("StoreT", bound=PointStore)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Device(ABC, Generic[StoreT]):
|
|
23
|
+
"""A running (or startable) virtual device.
|
|
24
|
+
|
|
25
|
+
Subclasses only implement the network side (:meth:`_start_server`,
|
|
26
|
+
:meth:`_stop_server`, :attr:`host`, :attr:`port`). Values, generators,
|
|
27
|
+
faults, scenarios and outages are shared by every protocol.
|
|
28
|
+
|
|
29
|
+
All methods must be called from the event loop that runs the device. For
|
|
30
|
+
use from other threads (for example synchronous tests) see
|
|
31
|
+
:class:`fieldsim.BackgroundSimulator`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
protocol: str = ""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
name: str,
|
|
39
|
+
store: StoreT,
|
|
40
|
+
generators: Mapping[str, Generator],
|
|
41
|
+
*,
|
|
42
|
+
update_interval: float,
|
|
43
|
+
faults: Iterable[FaultSpec] = (),
|
|
44
|
+
scenario: Sequence[ScenarioStep] = (),
|
|
45
|
+
seed: int | None = None,
|
|
46
|
+
clock: Clock | None = None,
|
|
47
|
+
):
|
|
48
|
+
self.name = name
|
|
49
|
+
self.store = store
|
|
50
|
+
self.clock: Clock = clock or RealClock()
|
|
51
|
+
self.faults = FaultInjector(seed=seed)
|
|
52
|
+
for spec in faults:
|
|
53
|
+
self.add_fault(spec)
|
|
54
|
+
self._engine = GeneratorEngine(
|
|
55
|
+
store, generators, interval=update_interval, clock=self.clock
|
|
56
|
+
)
|
|
57
|
+
self.scenario = ScenarioRunner(self, scenario, self.clock)
|
|
58
|
+
self._running = False
|
|
59
|
+
self._online = False
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------ network
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def host(self) -> str: ...
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def port(self) -> int:
|
|
70
|
+
"""Listening port. After :meth:`start` this is the real port, even if 0 was configured."""
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def address(self) -> tuple[str, int]:
|
|
74
|
+
return self.host, self.port
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
async def _start_server(self) -> None: ...
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
async def _stop_server(self) -> None: ...
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------ lifecycle
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def running(self) -> bool:
|
|
86
|
+
return self._running
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def online(self) -> bool:
|
|
90
|
+
"""True while the device accepts connections (False during an outage)."""
|
|
91
|
+
return self._online
|
|
92
|
+
|
|
93
|
+
async def start(self) -> None:
|
|
94
|
+
if self._running:
|
|
95
|
+
return
|
|
96
|
+
await self._start_server()
|
|
97
|
+
self._online = True
|
|
98
|
+
self._running = True
|
|
99
|
+
self._engine.start()
|
|
100
|
+
self.scenario.start()
|
|
101
|
+
|
|
102
|
+
async def stop(self) -> None:
|
|
103
|
+
if not self._running:
|
|
104
|
+
return
|
|
105
|
+
self._running = False
|
|
106
|
+
await self.scenario.stop()
|
|
107
|
+
await self._engine.stop()
|
|
108
|
+
if self._online:
|
|
109
|
+
self._online = False
|
|
110
|
+
await self._stop_server()
|
|
111
|
+
|
|
112
|
+
async def go_offline(self) -> None:
|
|
113
|
+
"""Simulate a power or network loss: drop every client and refuse new ones.
|
|
114
|
+
|
|
115
|
+
Values and generators keep running, as they would on a device whose
|
|
116
|
+
network link went down.
|
|
117
|
+
"""
|
|
118
|
+
self._require_running()
|
|
119
|
+
if self._online:
|
|
120
|
+
self._online = False
|
|
121
|
+
await self._stop_server()
|
|
122
|
+
|
|
123
|
+
async def go_online(self) -> None:
|
|
124
|
+
"""End an outage; the device listens on the same port again."""
|
|
125
|
+
self._require_running()
|
|
126
|
+
if not self._online:
|
|
127
|
+
await self._start_server()
|
|
128
|
+
self._online = True
|
|
129
|
+
|
|
130
|
+
@asynccontextmanager
|
|
131
|
+
async def offline(self) -> AsyncIterator[None]:
|
|
132
|
+
"""``async with device.offline(): ...`` - outage for the duration of the block."""
|
|
133
|
+
await self.go_offline()
|
|
134
|
+
try:
|
|
135
|
+
yield
|
|
136
|
+
finally:
|
|
137
|
+
if self._running:
|
|
138
|
+
await self.go_online()
|
|
139
|
+
|
|
140
|
+
def _require_running(self) -> None:
|
|
141
|
+
if not self._running:
|
|
142
|
+
raise RuntimeError(f"device {self.name!r} is not running")
|
|
143
|
+
|
|
144
|
+
async def __aenter__(self) -> Device[StoreT]:
|
|
145
|
+
await self.start()
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
async def __aexit__(
|
|
149
|
+
self,
|
|
150
|
+
exc_type: type[BaseException] | None,
|
|
151
|
+
exc: BaseException | None,
|
|
152
|
+
tb: TracebackType | None,
|
|
153
|
+
) -> None:
|
|
154
|
+
await self.stop()
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------ faults
|
|
157
|
+
|
|
158
|
+
def validate_fault(self, spec: FaultSpec) -> None:
|
|
159
|
+
"""Reject faults that make no sense for this protocol. Overridden by subclasses."""
|
|
160
|
+
|
|
161
|
+
def add_fault(self, spec: FaultSpec) -> ActiveFault:
|
|
162
|
+
self.validate_fault(spec)
|
|
163
|
+
return self.faults.add(spec)
|
|
164
|
+
|
|
165
|
+
def fault(self, fault_type: str, **params: Any) -> ActiveFault:
|
|
166
|
+
"""Activate a fault, e.g. ``device.fault("latency", delay="200ms")``.
|
|
167
|
+
|
|
168
|
+
The returned object is a context manager that removes the fault again::
|
|
169
|
+
|
|
170
|
+
with device.fault("drop", count=2) as f:
|
|
171
|
+
...
|
|
172
|
+
assert f.hits == 2
|
|
173
|
+
"""
|
|
174
|
+
return self.add_fault(parse_fault(fault_type, **params))
|
|
175
|
+
|
|
176
|
+
def clear_faults(self) -> None:
|
|
177
|
+
self.faults.clear()
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------ values
|
|
180
|
+
|
|
181
|
+
def get(self, name: str) -> Value:
|
|
182
|
+
"""Current value of a point."""
|
|
183
|
+
return self.store.get(name)
|
|
184
|
+
|
|
185
|
+
def set(self, name: str, value: Value) -> None:
|
|
186
|
+
"""Set a point's value.
|
|
187
|
+
|
|
188
|
+
If the point is driven by a generator, the generator is paused until
|
|
189
|
+
:meth:`release` is called, so the value stays exactly as set.
|
|
190
|
+
"""
|
|
191
|
+
self.store.set(name, value)
|
|
192
|
+
self._engine.pin(name)
|
|
193
|
+
|
|
194
|
+
def release(self, name: str) -> None:
|
|
195
|
+
"""Hand a point set with :meth:`set` back to its generator."""
|
|
196
|
+
self.store.get(name) # validates the name
|
|
197
|
+
self._engine.release(name)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def generated_points(self) -> frozenset[str]:
|
|
201
|
+
return self._engine.generated
|
|
202
|
+
|
|
203
|
+
def __repr__(self) -> str:
|
|
204
|
+
state = "stopped" if not self._running else "running" if self._online else "offline"
|
|
205
|
+
return f"<{type(self).__name__} {self.name!r} {self.host}:{self.port} {state}>"
|
fieldsim/core/engine.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Periodically pushes generator output into a point store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
|
|
10
|
+
from fieldsim.core.clock import Clock, RealClock
|
|
11
|
+
from fieldsim.core.datastore import PointStore
|
|
12
|
+
from fieldsim.core.generators import Generator
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GeneratorEngine:
|
|
18
|
+
"""Runs every generator of a device on a fixed interval.
|
|
19
|
+
|
|
20
|
+
A point can be *pinned*: its generator is paused so a value set by a test
|
|
21
|
+
stays put instead of being overwritten on the next tick.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
store: PointStore,
|
|
27
|
+
generators: Mapping[str, Generator],
|
|
28
|
+
*,
|
|
29
|
+
interval: float,
|
|
30
|
+
clock: Clock | None = None,
|
|
31
|
+
):
|
|
32
|
+
if interval <= 0:
|
|
33
|
+
raise ValueError("interval must be > 0")
|
|
34
|
+
self._store = store
|
|
35
|
+
self._generators = dict(generators)
|
|
36
|
+
self._interval = interval
|
|
37
|
+
self._clock: Clock = clock or RealClock()
|
|
38
|
+
self._pinned: set[str] = set()
|
|
39
|
+
self._task: asyncio.Task[None] | None = None
|
|
40
|
+
self._t0 = 0.0
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def generated(self) -> frozenset[str]:
|
|
44
|
+
return frozenset(self._generators)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def pinned(self) -> frozenset[str]:
|
|
48
|
+
return frozenset(self._pinned)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def running(self) -> bool:
|
|
52
|
+
return self._task is not None and not self._task.done()
|
|
53
|
+
|
|
54
|
+
def pin(self, name: str) -> None:
|
|
55
|
+
if name in self._generators:
|
|
56
|
+
self._pinned.add(name)
|
|
57
|
+
|
|
58
|
+
def release(self, name: str) -> None:
|
|
59
|
+
self._pinned.discard(name)
|
|
60
|
+
|
|
61
|
+
def tick(self, elapsed: float) -> None:
|
|
62
|
+
"""Sample every unpinned generator at ``elapsed`` seconds and store the result."""
|
|
63
|
+
for name, generator in self._generators.items():
|
|
64
|
+
if name not in self._pinned:
|
|
65
|
+
self._store.set(name, generator.sample(elapsed), clamp=True)
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
if self.running or not self._generators:
|
|
69
|
+
return
|
|
70
|
+
self._t0 = self._clock.time()
|
|
71
|
+
self.tick(0.0)
|
|
72
|
+
self._task = asyncio.get_running_loop().create_task(self._run(), name="fieldsim-engine")
|
|
73
|
+
|
|
74
|
+
async def stop(self) -> None:
|
|
75
|
+
task, self._task = self._task, None
|
|
76
|
+
if task is None:
|
|
77
|
+
return
|
|
78
|
+
task.cancel()
|
|
79
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
80
|
+
await task
|
|
81
|
+
|
|
82
|
+
async def _run(self) -> None:
|
|
83
|
+
while True:
|
|
84
|
+
await self._clock.sleep(self._interval)
|
|
85
|
+
try:
|
|
86
|
+
self.tick(self._clock.time() - self._t0)
|
|
87
|
+
except Exception: # keep the device alive; one bad generator must not stop the rest
|
|
88
|
+
logger.exception("generator tick failed")
|
fieldsim/core/faults.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Fault injection: decides, per request, how a device should misbehave.
|
|
2
|
+
|
|
3
|
+
The injector is protocol independent. It produces a :class:`FaultPlan` and the
|
|
4
|
+
protocol server carries it out (how a "corrupt" response looks is a protocol
|
|
5
|
+
detail; *whether* to corrupt is not).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import TypeAdapter, ValidationError
|
|
17
|
+
|
|
18
|
+
from fieldsim.config import (
|
|
19
|
+
CorruptFault,
|
|
20
|
+
DisconnectFault,
|
|
21
|
+
DropFault,
|
|
22
|
+
ExceptionFault,
|
|
23
|
+
FaultSpec,
|
|
24
|
+
LatencyFault,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_FAULT_ADAPTER: TypeAdapter[FaultSpec] = TypeAdapter(FaultSpec)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_fault(fault_type: str, **params: Any) -> FaultSpec:
|
|
31
|
+
"""Build a validated fault spec, e.g. ``parse_fault("latency", delay="200ms")``."""
|
|
32
|
+
try:
|
|
33
|
+
return _FAULT_ADAPTER.validate_python({"type": fault_type, **params})
|
|
34
|
+
except ValidationError as exc:
|
|
35
|
+
details = "; ".join(
|
|
36
|
+
f"{'.'.join(str(p) for p in e['loc'][1:]) or 'type'}: {e['msg']}"
|
|
37
|
+
for e in exc.errors(include_url=False)
|
|
38
|
+
)
|
|
39
|
+
raise ValueError(f"invalid {fault_type!r} fault: {details}") from None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class FaultPlan:
|
|
44
|
+
"""What to do with one request."""
|
|
45
|
+
|
|
46
|
+
delay: float = 0.0
|
|
47
|
+
drop: bool = False
|
|
48
|
+
execute: bool = True
|
|
49
|
+
disconnect: bool = False
|
|
50
|
+
exception: int | None = None
|
|
51
|
+
corrupt: str | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def clean(self) -> bool:
|
|
55
|
+
return self == FaultPlan()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ActiveFault:
|
|
59
|
+
"""A fault currently attached to a device.
|
|
60
|
+
|
|
61
|
+
Usable as a context manager (``with device.fault("drop"): ...``) and keeps
|
|
62
|
+
count of how many requests it affected.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, injector: FaultInjector, spec: FaultSpec):
|
|
66
|
+
self.spec = spec
|
|
67
|
+
self.hits = 0
|
|
68
|
+
self._injector = injector
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def kind(self) -> str:
|
|
72
|
+
"""Fault type, e.g. ``"latency"``."""
|
|
73
|
+
return self.spec.type
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def active(self) -> bool:
|
|
77
|
+
return self in self._injector.active
|
|
78
|
+
|
|
79
|
+
def remove(self) -> None:
|
|
80
|
+
self._injector.remove(self)
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> ActiveFault:
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(
|
|
86
|
+
self,
|
|
87
|
+
exc_type: type[BaseException] | None,
|
|
88
|
+
exc: BaseException | None,
|
|
89
|
+
tb: TracebackType | None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.remove()
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
return f"<ActiveFault {self.spec.type} hits={self.hits} active={self.active}>"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FaultInjector:
|
|
98
|
+
def __init__(self, specs: Iterable[FaultSpec] = (), *, seed: int | None = None):
|
|
99
|
+
self.rng = random.Random(seed)
|
|
100
|
+
self._faults: list[ActiveFault] = []
|
|
101
|
+
for spec in specs:
|
|
102
|
+
self.add(spec)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def active(self) -> list[ActiveFault]:
|
|
106
|
+
return list(self._faults)
|
|
107
|
+
|
|
108
|
+
def add(self, spec: FaultSpec) -> ActiveFault:
|
|
109
|
+
fault = ActiveFault(self, spec)
|
|
110
|
+
self._faults.append(fault)
|
|
111
|
+
return fault
|
|
112
|
+
|
|
113
|
+
def remove(self, fault: ActiveFault) -> None:
|
|
114
|
+
if fault in self._faults:
|
|
115
|
+
self._faults.remove(fault)
|
|
116
|
+
|
|
117
|
+
def clear(self) -> None:
|
|
118
|
+
self._faults.clear()
|
|
119
|
+
|
|
120
|
+
def plan(self, function_code: int | None = None) -> FaultPlan:
|
|
121
|
+
"""Roll the dice for one request and combine every fault that applies."""
|
|
122
|
+
plan = FaultPlan()
|
|
123
|
+
for fault in list(self._faults):
|
|
124
|
+
spec = fault.spec
|
|
125
|
+
if (
|
|
126
|
+
spec.function_codes is not None
|
|
127
|
+
and function_code is not None
|
|
128
|
+
and function_code not in spec.function_codes
|
|
129
|
+
):
|
|
130
|
+
continue
|
|
131
|
+
if spec.probability < 1 and self.rng.random() >= spec.probability:
|
|
132
|
+
continue
|
|
133
|
+
fault.hits += 1
|
|
134
|
+
if spec.count is not None and fault.hits >= spec.count:
|
|
135
|
+
self.remove(fault)
|
|
136
|
+
self._apply(spec, plan)
|
|
137
|
+
return plan
|
|
138
|
+
|
|
139
|
+
def _apply(self, spec: FaultSpec, plan: FaultPlan) -> None:
|
|
140
|
+
if isinstance(spec, LatencyFault):
|
|
141
|
+
plan.delay += spec.delay + (self.rng.uniform(0, spec.jitter) if spec.jitter else 0.0)
|
|
142
|
+
elif isinstance(spec, DropFault):
|
|
143
|
+
plan.drop = True
|
|
144
|
+
plan.execute = plan.execute and spec.executed
|
|
145
|
+
elif isinstance(spec, DisconnectFault):
|
|
146
|
+
plan.disconnect = True
|
|
147
|
+
elif isinstance(spec, ExceptionFault):
|
|
148
|
+
plan.exception = spec.code
|
|
149
|
+
elif isinstance(spec, CorruptFault):
|
|
150
|
+
plan.corrupt = spec.mode
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Value generators: functions of elapsed time that drive simulated points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from fieldsim.config import (
|
|
10
|
+
ConstantGenerator,
|
|
11
|
+
GeneratorSpec,
|
|
12
|
+
RampGenerator,
|
|
13
|
+
RandomWalkGenerator,
|
|
14
|
+
SequenceGenerator,
|
|
15
|
+
SineGenerator,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Generator(ABC):
|
|
20
|
+
"""Produces a value for a point given the seconds elapsed since the device started."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def sample(self, t: float) -> float: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Constant(Generator):
|
|
27
|
+
def __init__(self, value: float):
|
|
28
|
+
self.value = value
|
|
29
|
+
|
|
30
|
+
def sample(self, t: float) -> float:
|
|
31
|
+
return self.value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Sine(Generator):
|
|
35
|
+
def __init__(self, minimum: float, maximum: float, period: float, phase: float = 0.0):
|
|
36
|
+
if period <= 0:
|
|
37
|
+
raise ValueError("period must be > 0")
|
|
38
|
+
self.minimum, self.maximum, self.period, self.phase = minimum, maximum, period, phase
|
|
39
|
+
|
|
40
|
+
def sample(self, t: float) -> float:
|
|
41
|
+
amplitude = (self.maximum - self.minimum) / 2
|
|
42
|
+
angle = 2 * math.pi * (t + self.phase) / self.period
|
|
43
|
+
return self.minimum + amplitude * (1 + math.sin(angle))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Ramp(Generator):
|
|
47
|
+
def __init__(self, start: float, end: float, period: float, mode: str = "sawtooth"):
|
|
48
|
+
if period <= 0:
|
|
49
|
+
raise ValueError("period must be > 0")
|
|
50
|
+
if mode not in ("sawtooth", "triangle", "once"):
|
|
51
|
+
raise ValueError(f"unknown ramp mode {mode!r}")
|
|
52
|
+
self.start, self.end, self.period, self.mode = start, end, period, mode
|
|
53
|
+
|
|
54
|
+
def sample(self, t: float) -> float:
|
|
55
|
+
if self.mode == "once":
|
|
56
|
+
fraction = min(max(t / self.period, 0.0), 1.0)
|
|
57
|
+
else:
|
|
58
|
+
fraction = (t % self.period) / self.period
|
|
59
|
+
if self.mode == "triangle":
|
|
60
|
+
fraction = 2 * fraction if fraction < 0.5 else 2 - 2 * fraction
|
|
61
|
+
return self.start + (self.end - self.start) * fraction
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RandomWalk(Generator):
|
|
65
|
+
"""Stateful: each call to :meth:`sample` takes one step, regardless of ``t``."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self, start: float, step: float, minimum: float, maximum: float, seed: int | None = None
|
|
69
|
+
):
|
|
70
|
+
self.value = start
|
|
71
|
+
self.step, self.minimum, self.maximum = step, minimum, maximum
|
|
72
|
+
self._rng = random.Random(seed)
|
|
73
|
+
self._started = False
|
|
74
|
+
|
|
75
|
+
def sample(self, t: float) -> float:
|
|
76
|
+
if self._started:
|
|
77
|
+
self.value += self._rng.uniform(-self.step, self.step)
|
|
78
|
+
self.value = min(max(self.value, self.minimum), self.maximum)
|
|
79
|
+
self._started = True
|
|
80
|
+
return self.value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Sequence(Generator):
|
|
84
|
+
def __init__(self, values: list[float], interval: float, repeat: bool = True):
|
|
85
|
+
if not values:
|
|
86
|
+
raise ValueError("values must not be empty")
|
|
87
|
+
if interval <= 0:
|
|
88
|
+
raise ValueError("interval must be > 0")
|
|
89
|
+
self.values, self.interval, self.repeat = list(values), interval, repeat
|
|
90
|
+
|
|
91
|
+
def sample(self, t: float) -> float:
|
|
92
|
+
index = max(int(t // self.interval), 0)
|
|
93
|
+
index = index % len(self.values) if self.repeat else min(index, len(self.values) - 1)
|
|
94
|
+
return self.values[index]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_generator(spec: GeneratorSpec) -> Generator:
|
|
98
|
+
"""Create a generator from its configuration model."""
|
|
99
|
+
if isinstance(spec, ConstantGenerator):
|
|
100
|
+
return Constant(spec.value)
|
|
101
|
+
if isinstance(spec, SineGenerator):
|
|
102
|
+
return Sine(spec.min, spec.max, spec.period, spec.phase)
|
|
103
|
+
if isinstance(spec, RampGenerator):
|
|
104
|
+
return Ramp(spec.start, spec.end, spec.period, spec.mode)
|
|
105
|
+
if isinstance(spec, RandomWalkGenerator):
|
|
106
|
+
return RandomWalk(spec.start, spec.step, spec.min, spec.max, spec.seed)
|
|
107
|
+
if isinstance(spec, SequenceGenerator):
|
|
108
|
+
return Sequence(spec.values, spec.interval, spec.repeat)
|
|
109
|
+
raise TypeError(f"unsupported generator spec: {type(spec).__name__}") # pragma: no cover
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Plays a device's scenario: a timeline of value changes, faults and outages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import inspect
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from fieldsim.config import FaultSpec, ScenarioStep
|
|
14
|
+
from fieldsim.core.clock import Clock
|
|
15
|
+
from fieldsim.core.datastore import Value
|
|
16
|
+
from fieldsim.core.faults import ActiveFault
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ScenarioTarget(Protocol):
|
|
22
|
+
def set(self, name: str, value: Value) -> None: ...
|
|
23
|
+
|
|
24
|
+
def release(self, name: str) -> None: ...
|
|
25
|
+
|
|
26
|
+
def add_fault(self, spec: FaultSpec) -> ActiveFault: ...
|
|
27
|
+
|
|
28
|
+
async def go_offline(self) -> None: ...
|
|
29
|
+
|
|
30
|
+
async def go_online(self) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
Action = Callable[[], Awaitable[None] | None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ScenarioEvent:
|
|
38
|
+
time: float
|
|
39
|
+
description: str
|
|
40
|
+
action: Action
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_events(target: ScenarioTarget, steps: Sequence[ScenarioStep]) -> list[ScenarioEvent]:
|
|
44
|
+
"""Expand steps into a sorted list of instantaneous events.
|
|
45
|
+
|
|
46
|
+
A step with a duration becomes two events (start and end). Events at the
|
|
47
|
+
same time keep the order in which they were written.
|
|
48
|
+
"""
|
|
49
|
+
events: list[tuple[float, int, ScenarioEvent]] = []
|
|
50
|
+
|
|
51
|
+
def add(time: float, description: str, action: Action) -> None:
|
|
52
|
+
events.append((time, len(events), ScenarioEvent(time, description, action)))
|
|
53
|
+
|
|
54
|
+
for step in steps:
|
|
55
|
+
if step.set is not None:
|
|
56
|
+
values = dict(step.set)
|
|
57
|
+
|
|
58
|
+
def do_set(values: dict[str, Value] = values) -> None:
|
|
59
|
+
for name, value in values.items():
|
|
60
|
+
target.set(name, value)
|
|
61
|
+
|
|
62
|
+
add(step.at, f"set {values}", do_set)
|
|
63
|
+
elif step.release is not None:
|
|
64
|
+
names = list(step.release)
|
|
65
|
+
|
|
66
|
+
def do_release(names: list[str] = names) -> None:
|
|
67
|
+
for name in names:
|
|
68
|
+
target.release(name)
|
|
69
|
+
|
|
70
|
+
add(step.at, f"release {', '.join(names)}", do_release)
|
|
71
|
+
elif step.fault is not None:
|
|
72
|
+
holder: list[ActiveFault] = []
|
|
73
|
+
spec = step.fault
|
|
74
|
+
|
|
75
|
+
def do_fault(spec: FaultSpec = spec, holder: list[ActiveFault] = holder) -> None:
|
|
76
|
+
holder.append(target.add_fault(spec))
|
|
77
|
+
|
|
78
|
+
add(step.at, f"fault {spec.type} on", do_fault)
|
|
79
|
+
if step.duration is not None:
|
|
80
|
+
|
|
81
|
+
def undo_fault(holder: list[ActiveFault] = holder) -> None:
|
|
82
|
+
for fault in holder:
|
|
83
|
+
fault.remove()
|
|
84
|
+
|
|
85
|
+
add(step.at + step.duration, f"fault {spec.type} off", undo_fault)
|
|
86
|
+
elif step.offline is not None:
|
|
87
|
+
add(step.at, "offline", target.go_offline)
|
|
88
|
+
add(step.at + step.offline, "online", target.go_online)
|
|
89
|
+
|
|
90
|
+
return [event for *_, event in sorted(events, key=lambda e: (e[0], e[1]))]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ScenarioRunner:
|
|
94
|
+
def __init__(self, target: ScenarioTarget, steps: Sequence[ScenarioStep], clock: Clock):
|
|
95
|
+
self.events = build_events(target, steps)
|
|
96
|
+
self.history: list[ScenarioEvent] = []
|
|
97
|
+
self._clock = clock
|
|
98
|
+
self._task: asyncio.Task[None] | None = None
|
|
99
|
+
self._t0 = 0.0
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def done(self) -> bool:
|
|
103
|
+
return len(self.history) == len(self.events)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def elapsed(self) -> float:
|
|
107
|
+
return self._clock.time() - self._t0
|
|
108
|
+
|
|
109
|
+
def start(self) -> None:
|
|
110
|
+
if self._task is not None or not self.events:
|
|
111
|
+
return
|
|
112
|
+
self._t0 = self._clock.time()
|
|
113
|
+
self._task = asyncio.get_running_loop().create_task(self._run(), name="fieldsim-scenario")
|
|
114
|
+
|
|
115
|
+
async def stop(self) -> None:
|
|
116
|
+
task, self._task = self._task, None
|
|
117
|
+
if task is None:
|
|
118
|
+
return
|
|
119
|
+
task.cancel()
|
|
120
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
121
|
+
await task
|
|
122
|
+
|
|
123
|
+
async def wait(self) -> None:
|
|
124
|
+
"""Wait until every event has run (returns at once if there are none)."""
|
|
125
|
+
if self._task is not None:
|
|
126
|
+
await asyncio.shield(self._task)
|
|
127
|
+
|
|
128
|
+
async def _run(self) -> None:
|
|
129
|
+
for event in self.events:
|
|
130
|
+
delay = self._t0 + event.time - self._clock.time()
|
|
131
|
+
if delay > 0:
|
|
132
|
+
await self._clock.sleep(delay)
|
|
133
|
+
logger.info("scenario t=%.3fs: %s", event.time, event.description)
|
|
134
|
+
try:
|
|
135
|
+
result = event.action()
|
|
136
|
+
if inspect.isawaitable(result):
|
|
137
|
+
await result
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.exception("scenario action failed: %s", event.description)
|
|
140
|
+
self.history.append(event)
|