kilonova 0.1.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.
- kilonova/__init__.py +12 -0
- kilonova/address_space.py +413 -0
- kilonova/calculated.py +234 -0
- kilonova/cli.py +58 -0
- kilonova/config.py +222 -0
- kilonova/design.py +390 -0
- kilonova/dump.py +192 -0
- kilonova/errors.py +13 -0
- kilonova/meta.py +135 -0
- kilonova/objects.py +89 -0
- kilonova/oracle.py +162 -0
- kilonova/server.py +389 -0
- kilonova-0.1.0.dist-info/METADATA +111 -0
- kilonova-0.1.0.dist-info/RECORD +17 -0
- kilonova-0.1.0.dist-info/WHEEL +4 -0
- kilonova-0.1.0.dist-info/entry_points.txt +2 -0
- kilonova-0.1.0.dist-info/licenses/LICENSE +25 -0
kilonova/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""kilonova — pure-Python OPC UA servers from quasar Design files.
|
|
2
|
+
|
|
3
|
+
A quasar in miniature: loads a quasar Design.xml + config.xml and serves the
|
|
4
|
+
identical address space the C++ quasar framework would, with no code generation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from kilonova.design import Design
|
|
8
|
+
from kilonova.server import Server
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["Design", "Server", "__version__"]
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Building the quasar address space inside an asyncua server.
|
|
2
|
+
|
|
3
|
+
Address rules match C++ quasar exactly: instances live in namespace 2 with
|
|
4
|
+
string NodeIds of dotted paths (``sca1.online``), object types get numeric ids
|
|
5
|
+
from 1000 upward.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import asyncua
|
|
13
|
+
from asyncua import ua
|
|
14
|
+
from asyncua.common.node import Node
|
|
15
|
+
|
|
16
|
+
from kilonova import oracle
|
|
17
|
+
from kilonova.config import Instance
|
|
18
|
+
from kilonova.design import (
|
|
19
|
+
CacheVariable,
|
|
20
|
+
ConfigEntry,
|
|
21
|
+
Design,
|
|
22
|
+
Method,
|
|
23
|
+
MethodArgument,
|
|
24
|
+
QuasarClass,
|
|
25
|
+
SourceVariable,
|
|
26
|
+
)
|
|
27
|
+
from kilonova.errors import ConfigurationError, DesignError
|
|
28
|
+
from kilonova.objects import QuasarObject
|
|
29
|
+
|
|
30
|
+
_log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
FIRST_TYPE_ID = 1000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _not_implemented_method(parent, *args):
|
|
36
|
+
return ua.StatusCode(ua.StatusCodes.BadNotImplemented)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check_cardinality(relations, child_class_names: list[str], where: str) -> None:
|
|
40
|
+
"""Enforce hasobjects minOccurs/maxOccurs, as quasar's Configuration.xsd does."""
|
|
41
|
+
for rel in relations:
|
|
42
|
+
if rel.instantiate_using != "configuration":
|
|
43
|
+
continue
|
|
44
|
+
count = child_class_names.count(rel.class_name)
|
|
45
|
+
if count < rel.min_occurs:
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
f"{where}: needs at least {rel.min_occurs} <{rel.class_name}> "
|
|
48
|
+
f"instance(s), got {count}"
|
|
49
|
+
)
|
|
50
|
+
if rel.max_occurs is not None and count > rel.max_occurs:
|
|
51
|
+
raise ConfigurationError(
|
|
52
|
+
f"{where}: allows at most {rel.max_occurs} <{rel.class_name}> "
|
|
53
|
+
f"instance(s), got {count}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AddressSpaceBuilder:
|
|
58
|
+
"""Creates types and instances for one Design inside one asyncua server."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
ua_server: asyncua.Server,
|
|
63
|
+
design: Design,
|
|
64
|
+
namespace_index: int,
|
|
65
|
+
method_dispatcher_factory=None,
|
|
66
|
+
calculated_engine=None,
|
|
67
|
+
):
|
|
68
|
+
self._server = ua_server
|
|
69
|
+
self._design = design
|
|
70
|
+
self._ns = namespace_index
|
|
71
|
+
self._make_dispatcher = method_dispatcher_factory
|
|
72
|
+
self._calculated = calculated_engine
|
|
73
|
+
self._type_nodes: dict[str, Node] = {}
|
|
74
|
+
#: All instantiated objects keyed by their dotted string address.
|
|
75
|
+
self.objects: dict[str, QuasarObject] = {}
|
|
76
|
+
#: Source variable specs keyed by full dotted address.
|
|
77
|
+
self.source_variables: dict[str, SourceVariable] = {}
|
|
78
|
+
#: Full addresses of cache variables with addressSpaceWrite="delegated".
|
|
79
|
+
self.delegated_cache_variables: set[str] = set()
|
|
80
|
+
|
|
81
|
+
# -- types ---------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
async def build_types(self) -> None:
|
|
84
|
+
"""One bare ObjectType per Design class, numeric ids from 1000 up."""
|
|
85
|
+
base = self._server.get_node(ua.NodeId(ua.ObjectIds.BaseObjectType))
|
|
86
|
+
for offset, klass in enumerate(self._design.classes.values()):
|
|
87
|
+
type_node = await base.add_object_type(
|
|
88
|
+
ua.NodeId(FIRST_TYPE_ID + offset, self._ns),
|
|
89
|
+
ua.QualifiedName(klass.name, self._ns),
|
|
90
|
+
)
|
|
91
|
+
self._type_nodes[klass.name] = type_node
|
|
92
|
+
|
|
93
|
+
# -- instances -----------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
async def instantiate_root_design_objects(self) -> None:
|
|
96
|
+
"""Objects the Design itself mandates at the root (instantiateUsing="design")."""
|
|
97
|
+
objects_folder = self._server.nodes.objects
|
|
98
|
+
for rel in self._design.root_has_objects:
|
|
99
|
+
if rel.instantiate_using != "design":
|
|
100
|
+
continue
|
|
101
|
+
klass = self._design.classes[rel.class_name]
|
|
102
|
+
for instance_name in rel.design_instance_names:
|
|
103
|
+
await self._instantiate(
|
|
104
|
+
Instance(class_name=klass.name, name=instance_name),
|
|
105
|
+
objects_folder,
|
|
106
|
+
parent_address=None,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def instantiate_from_config(self, instances: list[Instance]) -> None:
|
|
110
|
+
objects_folder = self._server.nodes.objects
|
|
111
|
+
_check_cardinality(
|
|
112
|
+
self._design.root_has_objects,
|
|
113
|
+
[instance.class_name for instance in instances],
|
|
114
|
+
where="configuration root",
|
|
115
|
+
)
|
|
116
|
+
for instance in instances:
|
|
117
|
+
await self._instantiate(instance, objects_folder, parent_address=None)
|
|
118
|
+
|
|
119
|
+
async def _instantiate(
|
|
120
|
+
self, instance: Instance, parent_node: Node, parent_address: str | None
|
|
121
|
+
) -> QuasarObject:
|
|
122
|
+
klass = self._design.classes[instance.class_name]
|
|
123
|
+
if klass.calculated_variables:
|
|
124
|
+
raise DesignError(
|
|
125
|
+
f"class {klass.name}: Design-level calculated variables are not supported"
|
|
126
|
+
" yet — declare them in the configuration instead"
|
|
127
|
+
)
|
|
128
|
+
address = instance.name if parent_address is None else f"{parent_address}.{instance.name}"
|
|
129
|
+
node_id = ua.NodeId(address, self._ns)
|
|
130
|
+
browse_name = ua.QualifiedName(instance.name, self._ns)
|
|
131
|
+
_log.debug("instantiating %s %r", klass.name, address)
|
|
132
|
+
|
|
133
|
+
if klass.single_variable_node and klass.source_variables:
|
|
134
|
+
sv = klass.source_variables[0]
|
|
135
|
+
node = await self._add_source_variable_node(
|
|
136
|
+
parent_node, node_id, browse_name, address, sv
|
|
137
|
+
)
|
|
138
|
+
quasar_object = QuasarObject(self._server, klass, node, address)
|
|
139
|
+
quasar_object.source_variables[sv.name] = node
|
|
140
|
+
elif klass.single_variable_node:
|
|
141
|
+
node = await self._add_single_variable_node(
|
|
142
|
+
parent_node, node_id, browse_name, klass, instance
|
|
143
|
+
)
|
|
144
|
+
quasar_object = QuasarObject(self._server, klass, node, address)
|
|
145
|
+
quasar_object.cache_variables[klass.the_single_variable.name] = node
|
|
146
|
+
else:
|
|
147
|
+
node = await parent_node.add_object(
|
|
148
|
+
node_id, browse_name, objecttype=self._type_nodes[klass.name].nodeid
|
|
149
|
+
)
|
|
150
|
+
quasar_object = QuasarObject(self._server, klass, node, address)
|
|
151
|
+
for cv in klass.cache_variables:
|
|
152
|
+
var_node = await self._add_cache_variable(node, address, cv, instance)
|
|
153
|
+
quasar_object.cache_variables[cv.name] = var_node
|
|
154
|
+
if cv.address_space_write == "delegated":
|
|
155
|
+
self.delegated_cache_variables.add(f"{address}.{cv.name}")
|
|
156
|
+
for sv in klass.source_variables:
|
|
157
|
+
sv_node = await self._add_source_variable(node, address, sv)
|
|
158
|
+
quasar_object.source_variables[sv.name] = sv_node
|
|
159
|
+
for entry in klass.config_entries:
|
|
160
|
+
await self._add_config_entry(node, address, entry, instance)
|
|
161
|
+
for method in klass.methods:
|
|
162
|
+
await self._add_method(node, address, method)
|
|
163
|
+
|
|
164
|
+
self.objects[address] = quasar_object
|
|
165
|
+
_check_cardinality(
|
|
166
|
+
klass.has_objects,
|
|
167
|
+
[child.class_name for child in instance.children],
|
|
168
|
+
where=f"<{klass.name} name={instance.name!r}>",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if self._calculated is not None:
|
|
172
|
+
for fv in instance.free_variables:
|
|
173
|
+
await self._calculated.add_free_variable(
|
|
174
|
+
node, address, fv.name, fv.data_type, fv.initial_value
|
|
175
|
+
)
|
|
176
|
+
for calc in instance.calculated_variables:
|
|
177
|
+
await self._calculated.add_calculated_variable(
|
|
178
|
+
node, address, calc.name, calc.formula
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# children from the configuration...
|
|
182
|
+
for child in instance.children:
|
|
183
|
+
await self._instantiate(child, node, address)
|
|
184
|
+
# ...and children the Design mandates on every instance of this class
|
|
185
|
+
for rel in klass.has_objects:
|
|
186
|
+
if rel.instantiate_using != "design":
|
|
187
|
+
continue
|
|
188
|
+
for child_name in rel.design_instance_names:
|
|
189
|
+
await self._instantiate(
|
|
190
|
+
Instance(class_name=rel.class_name, name=child_name), node, address
|
|
191
|
+
)
|
|
192
|
+
return quasar_object
|
|
193
|
+
|
|
194
|
+
# -- variables -----------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
async def _add_single_variable_node(
|
|
197
|
+
self, parent_node: Node, node_id: ua.NodeId, browse_name: ua.QualifiedName,
|
|
198
|
+
klass: QuasarClass, instance: Instance,
|
|
199
|
+
) -> Node:
|
|
200
|
+
"""singleVariableNode classes collapse to one variable named as the instance."""
|
|
201
|
+
cv = klass.the_single_variable
|
|
202
|
+
data_value = self._initial_data_value(cv, instance)
|
|
203
|
+
node = await parent_node.add_variable(
|
|
204
|
+
node_id, browse_name, data_value.Value, datatype=self._data_type_attribute(cv)
|
|
205
|
+
)
|
|
206
|
+
await self._finalize_variable(node, cv, data_value)
|
|
207
|
+
return node
|
|
208
|
+
|
|
209
|
+
async def _add_cache_variable(
|
|
210
|
+
self, object_node: Node, parent_address: str, cv: CacheVariable, instance: Instance
|
|
211
|
+
) -> Node:
|
|
212
|
+
node_id = ua.NodeId(f"{parent_address}.{cv.name}", self._ns)
|
|
213
|
+
data_value = self._initial_data_value(cv, instance)
|
|
214
|
+
node = await object_node.add_variable(
|
|
215
|
+
node_id,
|
|
216
|
+
ua.QualifiedName(cv.name, self._ns),
|
|
217
|
+
data_value.Value,
|
|
218
|
+
datatype=self._data_type_attribute(cv),
|
|
219
|
+
)
|
|
220
|
+
await self._finalize_variable(node, cv, data_value)
|
|
221
|
+
return node
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _data_type_attribute(cv: CacheVariable) -> ua.NodeId:
|
|
225
|
+
"""C++ quasar sets the concrete DataType only for nullForbidden variables;
|
|
226
|
+
nullAllowed ones keep BaseDataType so null writes stay legal."""
|
|
227
|
+
if cv.null_policy == "nullForbidden":
|
|
228
|
+
return oracle.data_type_node_id(cv.data_type)
|
|
229
|
+
return oracle.BASE_DATA_TYPE
|
|
230
|
+
|
|
231
|
+
async def _add_source_variable(
|
|
232
|
+
self, object_node: Node, parent_address: str, sv: SourceVariable
|
|
233
|
+
) -> Node:
|
|
234
|
+
address = f"{parent_address}.{sv.name}"
|
|
235
|
+
return await self._add_source_variable_node(
|
|
236
|
+
object_node, ua.NodeId(address, self._ns),
|
|
237
|
+
ua.QualifiedName(sv.name, self._ns), address, sv,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def _add_source_variable_node(
|
|
241
|
+
self, parent_node: Node, node_id: ua.NodeId, browse_name: ua.QualifiedName,
|
|
242
|
+
address: str, sv: SourceVariable,
|
|
243
|
+
) -> Node:
|
|
244
|
+
"""Source variables delegate reads/writes to device logic; until the first
|
|
245
|
+
interaction they hold a null value with BadWaitingForInitialData."""
|
|
246
|
+
initial = ua.DataValue(
|
|
247
|
+
ua.Variant(None, ua.VariantType.Null),
|
|
248
|
+
ua.StatusCode(ua.StatusCodes.BadWaitingForInitialData),
|
|
249
|
+
)
|
|
250
|
+
node = await parent_node.add_variable(
|
|
251
|
+
node_id,
|
|
252
|
+
browse_name,
|
|
253
|
+
initial.Value,
|
|
254
|
+
# source variables always expose the concrete design type (per oracle)
|
|
255
|
+
datatype=oracle.data_type_node_id(sv.data_type),
|
|
256
|
+
)
|
|
257
|
+
await self._write_value_rank(node, self._value_rank(sv.is_array, sv.data_type))
|
|
258
|
+
access = (1 if sv.is_readable else 0) | (2 if sv.is_writable else 0)
|
|
259
|
+
for attribute in (ua.AttributeIds.AccessLevel, ua.AttributeIds.UserAccessLevel):
|
|
260
|
+
await self._server.write_attribute_value(
|
|
261
|
+
node.nodeid,
|
|
262
|
+
ua.DataValue(ua.Variant(access, ua.VariantType.Byte)),
|
|
263
|
+
attribute,
|
|
264
|
+
)
|
|
265
|
+
await self._server.write_attribute_value(node.nodeid, initial)
|
|
266
|
+
self.source_variables[address] = sv
|
|
267
|
+
return node
|
|
268
|
+
|
|
269
|
+
async def _add_config_entry(
|
|
270
|
+
self, object_node: Node, parent_address: str, entry: ConfigEntry, instance: Instance
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Scalar config entries surface as read-only properties, like C++ quasar
|
|
273
|
+
exposes them; array entries stay config-only (C++ parity)."""
|
|
274
|
+
if entry.is_array:
|
|
275
|
+
return
|
|
276
|
+
raw = instance.attributes.get(entry.name)
|
|
277
|
+
try:
|
|
278
|
+
if raw is not None:
|
|
279
|
+
oracle.check_restrictions(raw, entry.restrictions)
|
|
280
|
+
value = oracle.parse_design_value(raw, entry.data_type) if raw is not None else None
|
|
281
|
+
except ValueError as exc:
|
|
282
|
+
raise ConfigurationError(f"{instance.name}.{entry.name}: {exc}") from exc
|
|
283
|
+
variant = oracle.make_variant(value, entry.data_type, is_array=False)
|
|
284
|
+
node = await object_node.add_property(
|
|
285
|
+
ua.NodeId(f"{parent_address}.{entry.name}", self._ns),
|
|
286
|
+
ua.QualifiedName(entry.name, self._ns),
|
|
287
|
+
variant,
|
|
288
|
+
datatype=oracle.data_type_node_id(entry.data_type),
|
|
289
|
+
)
|
|
290
|
+
await self._write_value_rank(node, self._value_rank(False, entry.data_type))
|
|
291
|
+
|
|
292
|
+
async def _add_method(self, object_node: Node, parent_address: str, method: Method) -> None:
|
|
293
|
+
address = f"{parent_address}.{method.name}"
|
|
294
|
+
callback = (
|
|
295
|
+
self._make_dispatcher(parent_address, method)
|
|
296
|
+
if self._make_dispatcher is not None
|
|
297
|
+
else _not_implemented_method
|
|
298
|
+
)
|
|
299
|
+
method_node = await object_node.add_method(
|
|
300
|
+
ua.NodeId(address, self._ns),
|
|
301
|
+
ua.QualifiedName(method.name, self._ns),
|
|
302
|
+
callback,
|
|
303
|
+
)
|
|
304
|
+
# quasar publishes argument properties at <method>.args / <method>.return_values
|
|
305
|
+
if method.arguments:
|
|
306
|
+
await self._add_argument_property(
|
|
307
|
+
method_node, f"{address}.args", "InputArguments", method.arguments
|
|
308
|
+
)
|
|
309
|
+
if method.return_values:
|
|
310
|
+
await self._add_argument_property(
|
|
311
|
+
method_node, f"{address}.return_values", "OutputArguments", method.return_values
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def _add_argument_property(
|
|
315
|
+
self, method_node: Node, address: str, browse_name: str,
|
|
316
|
+
arguments: tuple[MethodArgument, ...],
|
|
317
|
+
) -> None:
|
|
318
|
+
value = [
|
|
319
|
+
ua.Argument(
|
|
320
|
+
Name=arg.name,
|
|
321
|
+
DataType=oracle.data_type_node_id(arg.data_type),
|
|
322
|
+
ValueRank=self._value_rank(arg.is_array, arg.data_type),
|
|
323
|
+
ArrayDimensions=[],
|
|
324
|
+
Description=ua.LocalizedText(""),
|
|
325
|
+
)
|
|
326
|
+
for arg in arguments
|
|
327
|
+
]
|
|
328
|
+
node = await method_node.add_property(
|
|
329
|
+
ua.NodeId(address, self._ns),
|
|
330
|
+
ua.QualifiedName(browse_name, 0),
|
|
331
|
+
value,
|
|
332
|
+
datatype=ua.NodeId(ua.ObjectIds.Argument),
|
|
333
|
+
)
|
|
334
|
+
await self._write_value_rank(node, 1)
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _value_rank(is_array: bool, data_type: str) -> int:
|
|
338
|
+
"""quasar semantics: arrays are one-dimensional; UaVariant is ScalarOrOneDimension."""
|
|
339
|
+
if is_array:
|
|
340
|
+
return 1
|
|
341
|
+
if data_type == "UaVariant":
|
|
342
|
+
return -3
|
|
343
|
+
return -1
|
|
344
|
+
|
|
345
|
+
async def _write_value_rank(self, node: Node, rank: int) -> None:
|
|
346
|
+
await self._server.write_attribute_value(
|
|
347
|
+
node.nodeid,
|
|
348
|
+
ua.DataValue(ua.Variant(rank, ua.VariantType.Int32)),
|
|
349
|
+
ua.AttributeIds.ValueRank,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def _finalize_variable(self, node: Node, cv: CacheVariable, dv: ua.DataValue) -> None:
|
|
353
|
+
rank = self._value_rank(cv.is_array, cv.data_type)
|
|
354
|
+
await self._server.write_attribute_value(
|
|
355
|
+
node.nodeid,
|
|
356
|
+
ua.DataValue(ua.Variant(rank, ua.VariantType.Int32)),
|
|
357
|
+
ua.AttributeIds.ValueRank,
|
|
358
|
+
)
|
|
359
|
+
if cv.is_writable:
|
|
360
|
+
await node.set_writable(True)
|
|
361
|
+
# write the full DataValue (value + status) after creation: add_variable
|
|
362
|
+
# alone would leave status Good even for BadWaitingForInitialData designs
|
|
363
|
+
await self._server.write_attribute_value(node.nodeid, dv)
|
|
364
|
+
|
|
365
|
+
def _initial_data_value(self, cv: CacheVariable, instance: Instance | None) -> ua.DataValue:
|
|
366
|
+
if cv.initialize_with == "valueAndStatus":
|
|
367
|
+
if cv.initial_value is not None:
|
|
368
|
+
value = oracle.parse_design_value(cv.initial_value, cv.data_type)
|
|
369
|
+
variant = oracle.make_variant(value, cv.data_type, cv.is_array)
|
|
370
|
+
else:
|
|
371
|
+
variant = ua.Variant(None, ua.VariantType.Null)
|
|
372
|
+
if cv.initial_status is None:
|
|
373
|
+
raise DesignError(f"cache variable {cv.name}: valueAndStatus needs initialStatus")
|
|
374
|
+
status = oracle.initial_status(cv.initial_status)
|
|
375
|
+
return ua.DataValue(variant, ua.StatusCode(status))
|
|
376
|
+
|
|
377
|
+
if cv.initialize_with == "configuration":
|
|
378
|
+
where = instance.name if instance is not None else "<design>"
|
|
379
|
+
try:
|
|
380
|
+
if cv.is_array:
|
|
381
|
+
if instance is not None and cv.name in instance.array_values:
|
|
382
|
+
raw_values = instance.array_values[cv.name]
|
|
383
|
+
for raw_element in raw_values:
|
|
384
|
+
oracle.check_restrictions(raw_element, cv.restrictions)
|
|
385
|
+
values = oracle.parse_design_array(raw_values, cv.data_type)
|
|
386
|
+
variant = oracle.make_variant(values, cv.data_type, is_array=True)
|
|
387
|
+
return ua.DataValue(variant, ua.StatusCode(ua.StatusCodes.Good))
|
|
388
|
+
raw = None
|
|
389
|
+
else:
|
|
390
|
+
raw = instance.attributes.get(cv.name) if instance is not None else None
|
|
391
|
+
if raw is None:
|
|
392
|
+
raw = cv.default_config_initializer_value
|
|
393
|
+
if raw is not None:
|
|
394
|
+
oracle.check_restrictions(raw, cv.restrictions)
|
|
395
|
+
|
|
396
|
+
if raw is None:
|
|
397
|
+
if cv.null_policy == "nullForbidden":
|
|
398
|
+
raise ConfigurationError(
|
|
399
|
+
f"{where}: cache variable {cv.name} is nullForbidden but the "
|
|
400
|
+
"configuration provides no value"
|
|
401
|
+
)
|
|
402
|
+
return ua.DataValue(
|
|
403
|
+
ua.Variant(None, ua.VariantType.Null),
|
|
404
|
+
ua.StatusCode(ua.StatusCodes.Good),
|
|
405
|
+
)
|
|
406
|
+
value = oracle.parse_design_value(raw, cv.data_type)
|
|
407
|
+
variant = oracle.make_variant(value, cv.data_type, cv.is_array)
|
|
408
|
+
except ValueError as exc:
|
|
409
|
+
raise ConfigurationError(f"{where}.{cv.name}: {exc}") from exc
|
|
410
|
+
return ua.DataValue(variant, ua.StatusCode(ua.StatusCodes.Good))
|
|
411
|
+
|
|
412
|
+
raise DesignError(f"cache variable {cv.name}: unsupported initializeWith "
|
|
413
|
+
f"{cv.initialize_with!r}")
|
kilonova/calculated.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""quasar CalculatedVariables, pure Python.
|
|
2
|
+
|
|
3
|
+
Config-level ``<FreeVariable>`` and ``<CalculatedVariable>`` elements become
|
|
4
|
+
address-space variables; formulas (C++ quasar uses muParser) are compiled here
|
|
5
|
+
to a whitelisted Python AST — no ``eval`` — whose inputs are dotted quasar
|
|
6
|
+
addresses (``tc.fv``). Recalculation is wired through asyncua's server-side
|
|
7
|
+
datachange callbacks, so a dependent value is recomputed *inside* the write
|
|
8
|
+
that changed its input: the very next read is already fresh.
|
|
9
|
+
|
|
10
|
+
Supported formula syntax: numbers, dotted addresses, ``+ - * / % **`` and
|
|
11
|
+
unary minus (muParser's ``^`` is accepted and treated as power),
|
|
12
|
+
``$thisObjectAddress`` substitution and ``$applyGenericFormula(Name)``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from asyncua import ua
|
|
23
|
+
|
|
24
|
+
from kilonova.errors import ConfigurationError
|
|
25
|
+
|
|
26
|
+
_log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_BINOPS = {
|
|
29
|
+
ast.Add: lambda a, b: a + b,
|
|
30
|
+
ast.Sub: lambda a, b: a - b,
|
|
31
|
+
ast.Mult: lambda a, b: a * b,
|
|
32
|
+
ast.Div: lambda a, b: a / b,
|
|
33
|
+
ast.Mod: lambda a, b: a % b,
|
|
34
|
+
ast.Pow: lambda a, b: a**b,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_GENERIC_CALL = re.compile(r"\$applyGenericFormula\((\w+)\)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CompiledFormula:
|
|
42
|
+
text: str
|
|
43
|
+
tree: ast.Expression
|
|
44
|
+
inputs: tuple[str, ...] = field(default_factory=tuple)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _address_of(node: ast.expr) -> str | None:
|
|
48
|
+
"""Reconstruct a dotted quasar address from a Name/Attribute chain."""
|
|
49
|
+
parts = []
|
|
50
|
+
while isinstance(node, ast.Attribute):
|
|
51
|
+
parts.append(node.attr)
|
|
52
|
+
node = node.value
|
|
53
|
+
if isinstance(node, ast.Name):
|
|
54
|
+
parts.append(node.id)
|
|
55
|
+
return ".".join(reversed(parts))
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compile_formula(text: str) -> CompiledFormula:
|
|
60
|
+
"""Parse and validate a formula; muParser's ``^`` is mapped to python ``**``."""
|
|
61
|
+
normalized = text.strip().replace("^", "**")
|
|
62
|
+
try:
|
|
63
|
+
tree = ast.parse(normalized, mode="eval")
|
|
64
|
+
except SyntaxError as exc:
|
|
65
|
+
raise ConfigurationError(f"cannot parse formula {text!r}: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
inputs: list[str] = []
|
|
68
|
+
|
|
69
|
+
def validate(node: ast.expr) -> None:
|
|
70
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS:
|
|
71
|
+
validate(node.left)
|
|
72
|
+
validate(node.right)
|
|
73
|
+
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
|
|
74
|
+
validate(node.operand)
|
|
75
|
+
elif isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
|
76
|
+
pass
|
|
77
|
+
elif (address := _address_of(node)) is not None:
|
|
78
|
+
if address not in inputs:
|
|
79
|
+
inputs.append(address)
|
|
80
|
+
else:
|
|
81
|
+
raise ConfigurationError(
|
|
82
|
+
f"formula {text!r}: unsupported construct {ast.dump(node)[:60]}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
validate(tree.body)
|
|
86
|
+
return CompiledFormula(text=text, tree=tree, inputs=tuple(inputs))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def evaluate(compiled: CompiledFormula, env: dict[str, float]) -> float:
|
|
90
|
+
def walk(node: ast.expr) -> float:
|
|
91
|
+
if isinstance(node, ast.BinOp):
|
|
92
|
+
return _BINOPS[type(node.op)](walk(node.left), walk(node.right))
|
|
93
|
+
if isinstance(node, ast.UnaryOp):
|
|
94
|
+
operand = walk(node.operand)
|
|
95
|
+
return -operand if isinstance(node.op, ast.USub) else operand
|
|
96
|
+
if isinstance(node, ast.Constant):
|
|
97
|
+
return float(node.value)
|
|
98
|
+
return env[_address_of(node)]
|
|
99
|
+
|
|
100
|
+
return walk(compiled.tree.body)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_FREE_VARIABLE_PARSERS = {
|
|
104
|
+
"Boolean": lambda text: text in ("true", "1", "OpcUa_True"),
|
|
105
|
+
"Float": float,
|
|
106
|
+
"Double": float,
|
|
107
|
+
"String": str,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CalculatedVariablesEngine:
|
|
112
|
+
"""Owns free variables, formulas, the dependency graph, and recomputation."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, ua_server, namespace_index: int):
|
|
115
|
+
self._server = ua_server
|
|
116
|
+
self._ns = namespace_index
|
|
117
|
+
self._formulas: dict[str, CompiledFormula] = {}
|
|
118
|
+
self._dependents: dict[str, list[str]] = {}
|
|
119
|
+
self._generic_formulas: dict[str, str] = {}
|
|
120
|
+
self._last: dict[str, object] = {}
|
|
121
|
+
|
|
122
|
+
def register_generic_formula(self, name: str, formula: str) -> None:
|
|
123
|
+
self._generic_formulas[name] = formula
|
|
124
|
+
|
|
125
|
+
def resolve_formula_text(self, text: str, this_object_address: str) -> str:
|
|
126
|
+
def expand_generic(match: re.Match) -> str:
|
|
127
|
+
try:
|
|
128
|
+
return f"({self._generic_formulas[match.group(1)]})"
|
|
129
|
+
except KeyError:
|
|
130
|
+
raise ConfigurationError(
|
|
131
|
+
f"unknown generic formula {match.group(1)!r}"
|
|
132
|
+
) from None
|
|
133
|
+
|
|
134
|
+
text = _GENERIC_CALL.sub(expand_generic, text)
|
|
135
|
+
return text.replace("$thisObjectAddress.", f"{this_object_address}." if
|
|
136
|
+
this_object_address else "")
|
|
137
|
+
|
|
138
|
+
async def add_free_variable(
|
|
139
|
+
self, parent_node, parent_address: str, name: str, data_type: str,
|
|
140
|
+
initial_value: str | None,
|
|
141
|
+
) -> None:
|
|
142
|
+
address = f"{parent_address}.{name}" if parent_address else name
|
|
143
|
+
try:
|
|
144
|
+
variant_type = ua.VariantType[data_type]
|
|
145
|
+
except KeyError:
|
|
146
|
+
raise ConfigurationError(
|
|
147
|
+
f"free variable {address}: unknown type {data_type!r}"
|
|
148
|
+
) from None
|
|
149
|
+
parser = _FREE_VARIABLE_PARSERS.get(data_type, int)
|
|
150
|
+
value = parser(initial_value) if initial_value is not None else None
|
|
151
|
+
node = await parent_node.add_variable(
|
|
152
|
+
ua.NodeId(address, self._ns),
|
|
153
|
+
ua.QualifiedName(name, self._ns),
|
|
154
|
+
ua.Variant(value, variant_type if value is not None else ua.VariantType.Null),
|
|
155
|
+
datatype=ua.NodeId(variant_type.value),
|
|
156
|
+
)
|
|
157
|
+
await node.set_writable(True)
|
|
158
|
+
|
|
159
|
+
async def add_calculated_variable(
|
|
160
|
+
self, parent_node, parent_address: str, name: str, formula_text: str
|
|
161
|
+
) -> None:
|
|
162
|
+
address = f"{parent_address}.{name}" if parent_address else name
|
|
163
|
+
resolved = self.resolve_formula_text(formula_text, parent_address)
|
|
164
|
+
compiled = compile_formula(resolved)
|
|
165
|
+
# like the C++ oracle: calculated variables expose BaseDataType, read-only
|
|
166
|
+
await parent_node.add_variable(
|
|
167
|
+
ua.NodeId(address, self._ns),
|
|
168
|
+
ua.QualifiedName(name, self._ns),
|
|
169
|
+
ua.Variant(None, ua.VariantType.Null),
|
|
170
|
+
datatype=ua.NodeId(ua.ObjectIds.BaseDataType),
|
|
171
|
+
)
|
|
172
|
+
self._formulas[address] = compiled
|
|
173
|
+
for input_address in compiled.inputs:
|
|
174
|
+
self._dependents.setdefault(input_address, []).append(address)
|
|
175
|
+
|
|
176
|
+
async def wire_and_evaluate(self) -> None:
|
|
177
|
+
"""Subscribe to every input and compute initial values (document order)."""
|
|
178
|
+
aspace = self._server.iserver.aspace
|
|
179
|
+
for input_address in self._dependents:
|
|
180
|
+
status, _handle = aspace.add_datachange_callback(
|
|
181
|
+
ua.NodeId(input_address, self._ns),
|
|
182
|
+
ua.AttributeIds.Value,
|
|
183
|
+
self._make_input_listener(input_address),
|
|
184
|
+
)
|
|
185
|
+
if not status.is_good():
|
|
186
|
+
raise ConfigurationError(
|
|
187
|
+
f"calculated variables: input {input_address!r} does not exist"
|
|
188
|
+
)
|
|
189
|
+
for address in self._formulas:
|
|
190
|
+
await self._recompute(address)
|
|
191
|
+
|
|
192
|
+
def _make_input_listener(self, input_address: str):
|
|
193
|
+
async def on_change(_handle, _data_value) -> None:
|
|
194
|
+
for dependent in self._dependents.get(input_address, ()):
|
|
195
|
+
await self._recompute(dependent)
|
|
196
|
+
|
|
197
|
+
return on_change
|
|
198
|
+
|
|
199
|
+
async def _recompute(self, address: str) -> None:
|
|
200
|
+
aspace = self._server.iserver.aspace
|
|
201
|
+
compiled = self._formulas[address]
|
|
202
|
+
env: dict[str, float] = {}
|
|
203
|
+
good = True
|
|
204
|
+
for input_address in compiled.inputs:
|
|
205
|
+
data_value = aspace.read_attribute_value(
|
|
206
|
+
ua.NodeId(input_address, self._ns), ua.AttributeIds.Value
|
|
207
|
+
)
|
|
208
|
+
value = data_value.Value.Value if data_value.Value is not None else None
|
|
209
|
+
in_status = data_value.StatusCode
|
|
210
|
+
if value is None or (in_status is not None and not in_status.is_good()):
|
|
211
|
+
good = False
|
|
212
|
+
break
|
|
213
|
+
env[input_address] = float(value)
|
|
214
|
+
|
|
215
|
+
if good:
|
|
216
|
+
try:
|
|
217
|
+
result = evaluate(compiled, env)
|
|
218
|
+
except ArithmeticError:
|
|
219
|
+
good = False
|
|
220
|
+
if good:
|
|
221
|
+
new = (result, ua.StatusCodes.Good)
|
|
222
|
+
else:
|
|
223
|
+
new = (None, ua.StatusCodes.BadWaitingForInitialData)
|
|
224
|
+
if self._last.get(address) == new:
|
|
225
|
+
return # unchanged: stop propagation (also breaks accidental cycles)
|
|
226
|
+
self._last[address] = new
|
|
227
|
+
data_value = (
|
|
228
|
+
ua.DataValue(ua.Variant(new[0], ua.VariantType.Double), ua.StatusCode(new[1]))
|
|
229
|
+
if good
|
|
230
|
+
else ua.DataValue(ua.Variant(None, ua.VariantType.Null), ua.StatusCode(new[1]))
|
|
231
|
+
)
|
|
232
|
+
await aspace.write_attribute_value(
|
|
233
|
+
ua.NodeId(address, self._ns), ua.AttributeIds.Value, data_value
|
|
234
|
+
)
|