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.
@@ -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}>"
@@ -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")
@@ -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)