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 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
+ )