redzed 25.12.30__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.
- redzed/__init__.py +44 -0
- redzed/base_block.py +132 -0
- redzed/block.py +290 -0
- redzed/blocklib/__init__.py +26 -0
- redzed/blocklib/counter.py +45 -0
- redzed/blocklib/fsm.py +554 -0
- redzed/blocklib/inputs.py +210 -0
- redzed/blocklib/outputs.py +361 -0
- redzed/blocklib/repeat.py +72 -0
- redzed/blocklib/timedate.py +150 -0
- redzed/blocklib/timeinterval.py +192 -0
- redzed/blocklib/timer.py +50 -0
- redzed/circuit.py +756 -0
- redzed/cron_service.py +243 -0
- redzed/debug.py +86 -0
- redzed/formula_trigger.py +205 -0
- redzed/initializers.py +249 -0
- redzed/signal_shutdown.py +64 -0
- redzed/undef.py +38 -0
- redzed/utils/__init__.py +14 -0
- redzed/utils/async_utils.py +145 -0
- redzed/utils/data_utils.py +116 -0
- redzed/utils/time_utils.py +262 -0
- redzed-25.12.30.dist-info/METADATA +52 -0
- redzed-25.12.30.dist-info/RECORD +28 -0
- redzed-25.12.30.dist-info/WHEEL +5 -0
- redzed-25.12.30.dist-info/licenses/LICENSE.txt +21 -0
- redzed-25.12.30.dist-info/top_level.txt +1 -0
redzed/cron_service.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Call the .rz_cron_event() method of all registered blocks at given times of day.
|
|
3
|
+
|
|
4
|
+
Blocks acting on given time, date and weekdays are implemented
|
|
5
|
+
on top of this low-level service.
|
|
6
|
+
|
|
7
|
+
- - - - - -
|
|
8
|
+
Docs: https://redzed.readthedocs.io/en/latest/
|
|
9
|
+
Home: https://github.com/xitop/redzed/
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import bisect
|
|
15
|
+
from collections.abc import Collection
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import time
|
|
18
|
+
import typing as t
|
|
19
|
+
|
|
20
|
+
from .block import Block, EventData
|
|
21
|
+
from .debug import get_debug_level
|
|
22
|
+
from .utils import SEC_PER_HOUR, SEC_PER_MIN, SEC_PER_DAY
|
|
23
|
+
|
|
24
|
+
# time tracking accuracy (in seconds)
|
|
25
|
+
_TT_OK = 0.001 # desired accuracy
|
|
26
|
+
_TT_WARNING = 0.1 # log a warning when exceeded
|
|
27
|
+
_TT_ERROR = 2.5 # do a reset when exceeded
|
|
28
|
+
_SYNC_SLEEP = 0.000_2 # sleeps <= 200 µs can be blocking for sake of accuracy
|
|
29
|
+
|
|
30
|
+
# hourly wake-ups for precise time tracking and early detection of DST changes
|
|
31
|
+
_SET24 = frozenset(dt.time(hour, 0, 0) for hour in range(24))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Cron(Block):
|
|
35
|
+
"""
|
|
36
|
+
Simple cron service.
|
|
37
|
+
|
|
38
|
+
Do not use directly in circuits. It has a form of a logic block
|
|
39
|
+
only to allow monitoring through the event interface.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
RZ_RESERVED = True
|
|
43
|
+
|
|
44
|
+
def __init__(self, *args, utc: bool, **kwargs) -> None:
|
|
45
|
+
super().__init__(*args, **kwargs)
|
|
46
|
+
self._utc = bool(utc)
|
|
47
|
+
self._alarms: dict[dt.time, set[Block]] = {}
|
|
48
|
+
self._reversed: dict[Block, set[dt.time]] = {}
|
|
49
|
+
self._do_reload = asyncio.Event()
|
|
50
|
+
|
|
51
|
+
def rz_start(self) -> None:
|
|
52
|
+
tz = 'UTC' if self._utc else 'local time'
|
|
53
|
+
self.circuit.create_service(self._cron_daemon(), name=f"cron daemon ({tz})")
|
|
54
|
+
|
|
55
|
+
def dtnow(self) -> dt.datetime:
|
|
56
|
+
"""Return the current date/time."""
|
|
57
|
+
if self._utc:
|
|
58
|
+
# but we need to keep all date/time object timezone naive
|
|
59
|
+
# in order to make them mutually comparable
|
|
60
|
+
return dt.datetime.now(dt.UTC).replace(tzinfo=None)
|
|
61
|
+
return dt.datetime.now()
|
|
62
|
+
|
|
63
|
+
def _check_tz(self, time_of_day: dt.time) -> None:
|
|
64
|
+
"""Check if the time zone is left unspecified (i.e. object is "naive")."""
|
|
65
|
+
if not isinstance(time_of_day, dt.time):
|
|
66
|
+
raise TypeError(
|
|
67
|
+
f"time_of_day should be a datetime.time object, but got {time_of_day!r}")
|
|
68
|
+
if time_of_day.tzinfo is not None:
|
|
69
|
+
raise ValueError("time_of_day must not contain timezone data")
|
|
70
|
+
|
|
71
|
+
def set_schedule(self, blk: Block, times_of_day: Collection[dt.time]) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Add a block to be activated at given times or update its schedule.
|
|
74
|
+
|
|
75
|
+
The block's 'rz_cron_event' method will be called at given time
|
|
76
|
+
and also when this service is started, reset or reloaded.
|
|
77
|
+
|
|
78
|
+
A datetime.datetime object will be passed to the blk as its
|
|
79
|
+
only argument.
|
|
80
|
+
"""
|
|
81
|
+
if not hasattr(blk, 'rz_cron_event'):
|
|
82
|
+
raise TypeError(f"{blk} is not compatible with the cron service")
|
|
83
|
+
|
|
84
|
+
times_of_day = set(times_of_day) # make a set, make a copy
|
|
85
|
+
# remove old times of day
|
|
86
|
+
old_times = self._reversed.get(blk, set())
|
|
87
|
+
for tod in old_times - times_of_day:
|
|
88
|
+
self._alarms[tod].discard(blk)
|
|
89
|
+
|
|
90
|
+
do_reload = False
|
|
91
|
+
# add new times of day
|
|
92
|
+
for tod in times_of_day - old_times:
|
|
93
|
+
self._check_tz(tod)
|
|
94
|
+
if tod in self._alarms:
|
|
95
|
+
self._alarms[tod].add(blk)
|
|
96
|
+
else:
|
|
97
|
+
self._alarms[tod] = {blk}
|
|
98
|
+
if tod not in _SET24:
|
|
99
|
+
do_reload = True # new entry added to timetable
|
|
100
|
+
self._reversed[blk] = times_of_day
|
|
101
|
+
|
|
102
|
+
# cleanup
|
|
103
|
+
unused = [tod for tod, blkset in self._alarms.items() if not blkset]
|
|
104
|
+
for tod in unused:
|
|
105
|
+
del self._alarms[tod]
|
|
106
|
+
if tod not in _SET24:
|
|
107
|
+
do_reload = True # empty entry removed from the timetable
|
|
108
|
+
if do_reload:
|
|
109
|
+
self._do_reload.set()
|
|
110
|
+
|
|
111
|
+
async def _cron_daemon(self) -> t.NoReturn:
|
|
112
|
+
"""Recalculate registered blocks according to the schedule."""
|
|
113
|
+
overhead = _TT_OK # initial value, will be adjusted
|
|
114
|
+
# the sleeptime is reduced by this value
|
|
115
|
+
reset_flag = False
|
|
116
|
+
reload_flag = True # reload will also initialize the index
|
|
117
|
+
short_sleep = False # alternative sleep function used => do not compute overhead
|
|
118
|
+
while True:
|
|
119
|
+
if reload_flag:
|
|
120
|
+
timetable = sorted(_SET24.union(self._alarms))
|
|
121
|
+
tlen = len(timetable)
|
|
122
|
+
self.log_debug1("time schedule reloaded")
|
|
123
|
+
index = None
|
|
124
|
+
reload_flag = False
|
|
125
|
+
|
|
126
|
+
nowdt = self.dtnow()
|
|
127
|
+
nowt = nowdt.time()
|
|
128
|
+
if index is None:
|
|
129
|
+
# reload is set before entering the loop -> "tlen" gets initialized
|
|
130
|
+
# pylint: disable-next=possibly-used-before-assignment
|
|
131
|
+
index = bisect.bisect_left(timetable, nowt) % tlen
|
|
132
|
+
wakeup = timetable[index]
|
|
133
|
+
self.log_debug1("wakeup time: %s", wakeup)
|
|
134
|
+
|
|
135
|
+
# sleep until the wakeup time:
|
|
136
|
+
# step 0 - compute the delay until wakeup time
|
|
137
|
+
# - sleep
|
|
138
|
+
# step 1 - check the current time, adjust overhead estimate,
|
|
139
|
+
# A: finish if the time is correct, or
|
|
140
|
+
# B: add a tiny sleep if woken up too early, because
|
|
141
|
+
# continuing before wakeup time is not acceptable
|
|
142
|
+
# C: do a reset if the time is way off
|
|
143
|
+
# step 2 - check time after 1B,
|
|
144
|
+
# A: finish if the time is correct
|
|
145
|
+
# B: do a reset otherwise
|
|
146
|
+
for step in range(3):
|
|
147
|
+
# datetime.time does not support time arithmetic
|
|
148
|
+
sleeptime = (SEC_PER_HOUR*(wakeup.hour - nowt.hour)
|
|
149
|
+
+ SEC_PER_MIN*(wakeup.minute - nowt.minute)
|
|
150
|
+
+ (wakeup.second - nowt.second)
|
|
151
|
+
+ (wakeup.microsecond - nowt.microsecond)/ 1_000_000.0)
|
|
152
|
+
if nowt.hour == 23 and wakeup.hour == 0:
|
|
153
|
+
# wrap around midnight (relying on hourly wakeups in SET24)
|
|
154
|
+
sleeptime += SEC_PER_DAY
|
|
155
|
+
# sleeptime: negative = after the alarm time; positive = before the alarm time
|
|
156
|
+
if step == 0:
|
|
157
|
+
self.log_debug2("sleep until wakeup: %.3f sec", sleeptime)
|
|
158
|
+
if step >= 1 or sleeptime < 0:
|
|
159
|
+
diff = abs(sleeptime)
|
|
160
|
+
if get_debug_level() >= 2:
|
|
161
|
+
msg = 'BEFORE' if sleeptime > 0 else 'after'
|
|
162
|
+
self.log_debug(
|
|
163
|
+
"step %d, diff %.2f ms %s, estimated overhead: %.2f ms",
|
|
164
|
+
step, 1000*diff, msg, 1000*overhead)
|
|
165
|
+
if diff > _TT_WARNING:
|
|
166
|
+
self.log_warning(
|
|
167
|
+
"expected time: %s, current time: %s, diff: %.2f ms.",
|
|
168
|
+
wakeup, nowt, 1000*diff)
|
|
169
|
+
if (step == 2 and sleeptime > 0) or diff > _TT_ERROR:
|
|
170
|
+
reset_flag = True
|
|
171
|
+
if reset_flag:
|
|
172
|
+
break
|
|
173
|
+
if step == 1 and not short_sleep and not -_TT_OK <= sleeptime <= 0:
|
|
174
|
+
overhead -= (sleeptime + _TT_OK/2) / 2 # average of new and old
|
|
175
|
+
if sleeptime <= 0:
|
|
176
|
+
break
|
|
177
|
+
if get_debug_level() >= 2:
|
|
178
|
+
self.log_debug("additional sleep %.2f ms", 1000*sleeptime)
|
|
179
|
+
|
|
180
|
+
if sleeptime == 0.0:
|
|
181
|
+
pass # how likely is this?
|
|
182
|
+
elif sleeptime <= _SYNC_SLEEP:
|
|
183
|
+
short_sleep = True
|
|
184
|
+
# breaking the asyncio rules for max time tracking accuracy:
|
|
185
|
+
# doing a blocking sleep, but only for a fraction of a millisecond
|
|
186
|
+
time.sleep(sleeptime)
|
|
187
|
+
elif sleeptime <= overhead:
|
|
188
|
+
short_sleep = True
|
|
189
|
+
await asyncio.sleep(sleeptime)
|
|
190
|
+
else:
|
|
191
|
+
short_sleep = False
|
|
192
|
+
try:
|
|
193
|
+
async with asyncio.timeout(sleeptime - overhead):
|
|
194
|
+
await self._do_reload.wait()
|
|
195
|
+
except TimeoutError:
|
|
196
|
+
pass
|
|
197
|
+
else:
|
|
198
|
+
self._do_reload.clear()
|
|
199
|
+
reload_flag = True
|
|
200
|
+
break
|
|
201
|
+
nowdt = self.dtnow()
|
|
202
|
+
nowt = nowdt.time()
|
|
203
|
+
|
|
204
|
+
if reset_flag:
|
|
205
|
+
# DST begin/end or other computer clock related reason
|
|
206
|
+
if (not self._utc
|
|
207
|
+
and nowdt.isoweekday() >= 6
|
|
208
|
+
and abs(diff - SEC_PER_HOUR) <= _TT_ERROR
|
|
209
|
+
):
|
|
210
|
+
self.log_warning("Apparently a DST (summer time) clock change has occured.")
|
|
211
|
+
self.log_warning(
|
|
212
|
+
"Resetting due to a time tracking problem. "
|
|
213
|
+
+ "Notifying all registered blocks.")
|
|
214
|
+
# .rz_cron_event() may alter the dict we are iterating over
|
|
215
|
+
for blk in list(self._reversed): # all blocks
|
|
216
|
+
assert hasattr(blk, 'rz_cron_event')
|
|
217
|
+
blk.rz_cron_event(nowdt)
|
|
218
|
+
index = None
|
|
219
|
+
reset_flag = False
|
|
220
|
+
continue
|
|
221
|
+
if reload_flag:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
if wakeup in self._alarms:
|
|
225
|
+
# .rz_cron_event() may alter the set we are iterating over
|
|
226
|
+
block_list = list(self._alarms[wakeup])
|
|
227
|
+
if get_debug_level() >= 1:
|
|
228
|
+
self.log_debug(
|
|
229
|
+
"Notifying blocks: %s", ", ".join(blk.name for blk in block_list))
|
|
230
|
+
for blk in block_list:
|
|
231
|
+
assert hasattr(blk, 'rz_cron_event')
|
|
232
|
+
blk.rz_cron_event(nowdt)
|
|
233
|
+
index = (index + 1) % tlen
|
|
234
|
+
|
|
235
|
+
def _event__get_config(self, _edata: EventData) -> dict[str, dict[str, list[str]]]:
|
|
236
|
+
"""Return the internal scheduling data for debugging or monitoring."""
|
|
237
|
+
return {
|
|
238
|
+
'alarms': {
|
|
239
|
+
str(recalc_time): sorted(blk.name for blk in blk_set)
|
|
240
|
+
for recalc_time, blk_set in self._alarms.items()},
|
|
241
|
+
'blocks': {blk.name: sorted(str(recalc_time) for recalc_time in times_set)
|
|
242
|
+
for blk, times_set in self._reversed.items()},
|
|
243
|
+
}
|
redzed/debug.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Debug level.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
__all__ = ['get_debug_level', 'set_debug_level']
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from . import circuit
|
|
12
|
+
|
|
13
|
+
_logger = logging.getLogger(__package__)
|
|
14
|
+
_debug_handler: logging.StreamHandler|None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _CircuitTimeFormatter(logging.Formatter):
|
|
18
|
+
"""Formatter for debug level 3."""
|
|
19
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
20
|
+
msg = super().format(record)
|
|
21
|
+
if get_debug_level() >= 3:
|
|
22
|
+
msg = f"{circuit.get_circuit().runtime():.03f} {msg}"
|
|
23
|
+
return msg
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _DebugLevel:
|
|
27
|
+
"""
|
|
28
|
+
Global debug level.
|
|
29
|
+
|
|
30
|
+
The initial level is set according to the environment variable REDZED_DEBUG.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._level = -1 # make sure the set_level's core will run
|
|
35
|
+
self.set_level(self._get_level_from_env())
|
|
36
|
+
|
|
37
|
+
def _get_level_from_env(self) -> int:
|
|
38
|
+
try:
|
|
39
|
+
level = os.environ['REDZED_DEBUG']
|
|
40
|
+
except KeyError:
|
|
41
|
+
return 0
|
|
42
|
+
level = level.strip()
|
|
43
|
+
if not level:
|
|
44
|
+
return 0
|
|
45
|
+
if level in {'0', '1', '2', '3'}:
|
|
46
|
+
return int(level)
|
|
47
|
+
_logger.warning(
|
|
48
|
+
"Envvar 'REDZED_DEBUG' should be: 0 (disabled), 1 (normal), "
|
|
49
|
+
+ "2 (verbose) or 3 (verbose with circuit timestamps)")
|
|
50
|
+
_logger.warning("Ignoring REDZED_DEBUG='%s'. Please use a correct value.", level)
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
def get_level(self) -> int:
|
|
54
|
+
return self._level
|
|
55
|
+
|
|
56
|
+
def set_level(self, level: int) -> None:
|
|
57
|
+
"""Set debug level."""
|
|
58
|
+
global _debug_handler # pylint: disable=global-statement
|
|
59
|
+
|
|
60
|
+
if not isinstance(level, int):
|
|
61
|
+
raise TypeError(f"Expected an integer, got {level!r}")
|
|
62
|
+
if level == self._level:
|
|
63
|
+
return
|
|
64
|
+
if not 0 <= level <= 3:
|
|
65
|
+
raise ValueError(f"Debug level must be an integer 0 to 3, but got {level}")
|
|
66
|
+
# self._level is -1 on initial call
|
|
67
|
+
if self._level >= 0:
|
|
68
|
+
_logger.debug("Debug level: %d -> %d", self._level, level)
|
|
69
|
+
elif level > 0:
|
|
70
|
+
_logger.debug("Debug level: %d", level)
|
|
71
|
+
self._level = level
|
|
72
|
+
if level == 0:
|
|
73
|
+
if _debug_handler is not None:
|
|
74
|
+
_logger.removeHandler(_debug_handler)
|
|
75
|
+
_debug_handler = None
|
|
76
|
+
_logger.setLevel(logging.WARNING)
|
|
77
|
+
elif not _logger.hasHandlers() and _debug_handler is None:
|
|
78
|
+
_logger.addHandler(_debug_handler := logging.StreamHandler())
|
|
79
|
+
_debug_handler.setFormatter(_CircuitTimeFormatter("%(levelname)s - %(message)s"))
|
|
80
|
+
_logger.setLevel(logging.DEBUG)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_global_debug_level = _DebugLevel()
|
|
84
|
+
|
|
85
|
+
get_debug_level = _global_debug_level.get_level
|
|
86
|
+
set_debug_level = _global_debug_level.set_level
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Formulas and Triggers.
|
|
3
|
+
- - - - - -
|
|
4
|
+
Part of the redzed package.
|
|
5
|
+
Docs: https://redzed.readthedocs.io/en/latest/
|
|
6
|
+
Project home: https://github.com/xitop/redzed/
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ['Formula', 'formula', 'Trigger', 'triggered']
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
import inspect
|
|
14
|
+
import logging
|
|
15
|
+
import typing as t
|
|
16
|
+
|
|
17
|
+
from . import base_block
|
|
18
|
+
from . import block
|
|
19
|
+
from . import circuit
|
|
20
|
+
from .debug import get_debug_level
|
|
21
|
+
from .undef import UNDEF
|
|
22
|
+
from .utils import func_call_string
|
|
23
|
+
|
|
24
|
+
_logger = logging.getLogger(__package__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ExtFunction:
|
|
28
|
+
"""
|
|
29
|
+
Manage calls to a function associated with a Trigger or a Formula.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, func: Callable[..., t.Any], owner: Formula|Trigger) -> None:
|
|
33
|
+
"""Check if the function's signature is compatible."""
|
|
34
|
+
self._owner = owner
|
|
35
|
+
self._func = func
|
|
36
|
+
self._parameters: list[str] = []
|
|
37
|
+
self._inputs: list[str|block.Block|Formula] = []
|
|
38
|
+
# names (strings) must be resolved to objects before start
|
|
39
|
+
arglist = []
|
|
40
|
+
Param = inspect.Parameter
|
|
41
|
+
try:
|
|
42
|
+
for name, param in inspect.signature(func).parameters.items():
|
|
43
|
+
# support of positional-only arguments is possible,
|
|
44
|
+
# but the benefit would be minimal
|
|
45
|
+
if param.kind not in [Param.POSITIONAL_OR_KEYWORD, Param.KEYWORD_ONLY]:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"Function takes *args or **kwargs or positional-only arguments")
|
|
48
|
+
self._parameters.append(name)
|
|
49
|
+
if (default := param.default) is Param.empty:
|
|
50
|
+
self._inputs.append(name)
|
|
51
|
+
arglist.append(name)
|
|
52
|
+
else:
|
|
53
|
+
self._inputs.append(default)
|
|
54
|
+
if isinstance(default, str):
|
|
55
|
+
arglist.append(f"{name}='{default}'")
|
|
56
|
+
elif isinstance(default, (block.Block, Formula)):
|
|
57
|
+
arglist.append(f"{name}='{default.name}'")
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"The default value in '{name}={default!r}' "
|
|
61
|
+
+ "does not specify a circuit block")
|
|
62
|
+
if not arglist:
|
|
63
|
+
raise ValueError("Function does take any arguments")
|
|
64
|
+
except ValueError as inspect_error:
|
|
65
|
+
# Re-raise with more descriptive message and with the original message as a note.
|
|
66
|
+
# The owner is not initialized yet, so do not try to print e.g. its name.
|
|
67
|
+
exc = ValueError(f"{owner.type_name} cannot accept function {func.__qualname__}()")
|
|
68
|
+
exc.add_note(inspect_error.args[0])
|
|
69
|
+
raise exc from None
|
|
70
|
+
self.signature = f"{self._func.__name__}({', '.join(arglist)})"
|
|
71
|
+
|
|
72
|
+
def resolve_names(self) -> list[block.Block|Formula]:
|
|
73
|
+
"""Resolve names to block or formula objects."""
|
|
74
|
+
resolve_name = self._owner.circuit.resolve_name
|
|
75
|
+
self._inputs = [resolve_name(ref) for ref in self._inputs]
|
|
76
|
+
return t.cast(list[block.Block|Formula], self._inputs)
|
|
77
|
+
|
|
78
|
+
def run_function(self) -> t.Any:
|
|
79
|
+
"""Run with output values of referenced blocks."""
|
|
80
|
+
# union-attr: after pre-init the _inputs does not contain strings
|
|
81
|
+
kwargs = dict(zip(
|
|
82
|
+
self._parameters,
|
|
83
|
+
(blk.get() for blk in self._inputs), # type: ignore[union-attr]
|
|
84
|
+
strict=True))
|
|
85
|
+
if UNDEF in kwargs.values():
|
|
86
|
+
assert self._owner.circuit.get_state() < circuit.CircuitState.RUNNING
|
|
87
|
+
if get_debug_level() >= 2:
|
|
88
|
+
undef = next(iter(param for param, value in kwargs.items() if value is UNDEF))
|
|
89
|
+
_logger.debug(
|
|
90
|
+
"%s: NOT calling the function, because '%s' is UNDEF", self._owner, undef)
|
|
91
|
+
return UNDEF
|
|
92
|
+
if get_debug_level() >= 1:
|
|
93
|
+
_logger.debug(
|
|
94
|
+
"%s: Calling %s", self._owner, func_call_string(self._func, (), kwargs))
|
|
95
|
+
try:
|
|
96
|
+
return self._func(**kwargs)
|
|
97
|
+
except Exception as err:
|
|
98
|
+
err.add_note(f"Failed function call originated from {self._owner}")
|
|
99
|
+
self._owner.circuit.abort(err)
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Triggers do not have a name nor an output.
|
|
104
|
+
@t.final
|
|
105
|
+
class Trigger:
|
|
106
|
+
"""
|
|
107
|
+
A circuit item monitoring output changes of selected blocks.
|
|
108
|
+
"""
|
|
109
|
+
def __init__(self, func: Callable[..., t.Any]) -> None:
|
|
110
|
+
self._ext_func = _ExtFunction(func, owner=self)
|
|
111
|
+
self._str = f"<{self.type_name} for {self._ext_func.signature}>"
|
|
112
|
+
self.circuit = circuit.get_circuit()
|
|
113
|
+
self.circuit.rz_add_item(self)
|
|
114
|
+
self._enabled = False
|
|
115
|
+
|
|
116
|
+
def __str__(self) -> str:
|
|
117
|
+
return self._str
|
|
118
|
+
|
|
119
|
+
# for compatibility with Blocks and Formulas
|
|
120
|
+
@property
|
|
121
|
+
def type_name(self) -> str:
|
|
122
|
+
return type(self).__name__
|
|
123
|
+
|
|
124
|
+
def rz_pre_init(self) -> None:
|
|
125
|
+
for inp in self._ext_func.resolve_names():
|
|
126
|
+
inp.rz_add_trigger(self)
|
|
127
|
+
|
|
128
|
+
def run(self) -> None:
|
|
129
|
+
if self._enabled:
|
|
130
|
+
self._ext_func.run_function()
|
|
131
|
+
|
|
132
|
+
def rz_start(self) -> None:
|
|
133
|
+
self._enabled = True
|
|
134
|
+
self.run()
|
|
135
|
+
|
|
136
|
+
def rz_stop(self) -> None:
|
|
137
|
+
self._enabled = False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_FUNC = t.TypeVar("_FUNC", bound=Callable[..., t.Any])
|
|
141
|
+
def triggered(func: _FUNC) -> _FUNC:
|
|
142
|
+
"""
|
|
143
|
+
@triggered adds a trigger to a function.
|
|
144
|
+
|
|
145
|
+
The function *func* itself it not changed.
|
|
146
|
+
|
|
147
|
+
All parameters of the decorated function refer to blocks with
|
|
148
|
+
the same name. When the output of any of those block changes
|
|
149
|
+
and none of them is UNDEF, the function will be called
|
|
150
|
+
with corresponding output values passed as arguments.
|
|
151
|
+
"""
|
|
152
|
+
Trigger(func)
|
|
153
|
+
return func
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@t.final
|
|
157
|
+
class Formula(base_block.BlockOrFormula):
|
|
158
|
+
"""
|
|
159
|
+
A block with its output computed from other blocks` outputs.
|
|
160
|
+
|
|
161
|
+
The output is computed on demand with a provided function.
|
|
162
|
+
The function should be a pure function, i.e. without side effects.
|
|
163
|
+
|
|
164
|
+
The most convenient way to create a Formula block is the
|
|
165
|
+
@formula decorator.
|
|
166
|
+
"""
|
|
167
|
+
def __init__(self, *args, func: Callable[..., t.Any], **kwargs) -> None:
|
|
168
|
+
super().__init__(*args, **kwargs)
|
|
169
|
+
self._ext_func = _ExtFunction(func, self)
|
|
170
|
+
self._evaluate_active = False
|
|
171
|
+
|
|
172
|
+
def rz_pre_init(self) -> None:
|
|
173
|
+
for inp in self._ext_func.resolve_names():
|
|
174
|
+
inp.rz_add_formula(self)
|
|
175
|
+
|
|
176
|
+
def rz_start(self) -> None:
|
|
177
|
+
self.evaluate()
|
|
178
|
+
|
|
179
|
+
def evaluate(self) -> set[Trigger]:
|
|
180
|
+
"""
|
|
181
|
+
Evaluate this formula and dependent formulas.
|
|
182
|
+
|
|
183
|
+
Return a set of affected triggers.
|
|
184
|
+
"""
|
|
185
|
+
if self._evaluate_active:
|
|
186
|
+
raise RuntimeError(f"{self}: detected a dependency loop")
|
|
187
|
+
result = self._ext_func.run_function()
|
|
188
|
+
if result is UNDEF or not self._set_output(result):
|
|
189
|
+
return set()
|
|
190
|
+
triggers = self._dependent_triggers.copy()
|
|
191
|
+
self._evaluate_active = True
|
|
192
|
+
for frm in self._dependent_formulas:
|
|
193
|
+
triggers |= frm.evaluate()
|
|
194
|
+
self._evaluate_active = False
|
|
195
|
+
return triggers
|
|
196
|
+
|
|
197
|
+
def formula(name: str, *args, **kwargs) -> Callable[[_FUNC], _FUNC]:
|
|
198
|
+
"""@formula() creates a Formula block with the decorated function."""
|
|
199
|
+
if 'func' in kwargs:
|
|
200
|
+
# Argument func=... will be supplied by us
|
|
201
|
+
raise TypeError("@formula() got an unexpected keyword argument 'func='")
|
|
202
|
+
def decorator(func: _FUNC) -> _FUNC:
|
|
203
|
+
Formula(name, *args, func=func, **kwargs)
|
|
204
|
+
return func
|
|
205
|
+
return decorator
|