process-bigraph 0.0.43__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.
- process_bigraph/__init__.py +53 -0
- process_bigraph/composite.py +1551 -0
- process_bigraph/emitter.py +326 -0
- process_bigraph/experiments/__init__.py +0 -0
- process_bigraph/experiments/minimal_gillespie.py +207 -0
- process_bigraph/process_types.py +473 -0
- process_bigraph/processes/__init__.py +26 -0
- process_bigraph/processes/growth_division.py +167 -0
- process_bigraph/processes/parameter_scan.py +350 -0
- process_bigraph/tests.py +1330 -0
- process_bigraph/units.py +25 -0
- process_bigraph-0.0.43.dist-info/METADATA +65 -0
- process_bigraph-0.0.43.dist-info/RECORD +17 -0
- process_bigraph-0.0.43.dist-info/WHEEL +5 -0
- process_bigraph-0.0.43.dist-info/licenses/AUTHORS.md +6 -0
- process_bigraph-0.0.43.dist-info/licenses/LICENSE +201 -0
- process_bigraph-0.0.43.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
"""
|
|
2
|
+
composite.py
|
|
3
|
+
|
|
4
|
+
This module defines the core execution logic for compositional simulation workflows using the
|
|
5
|
+
Process Bigraph Protocol (PBP). It includes:
|
|
6
|
+
|
|
7
|
+
- `Composite`: A process orchestrator supporting nested processes, steps, and synchronization.
|
|
8
|
+
- `Step`: A process steps triggered by dependency updates.
|
|
9
|
+
- `Process`: A time-driven process unit.
|
|
10
|
+
- Utility functions for dependency tracking, merging, scheduling, and update application.
|
|
11
|
+
|
|
12
|
+
Used as part of the Vivarium 2.0 ecosystem for modular biological modeling.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import copy
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
from typing import (
|
|
20
|
+
Any, Dict, List, Optional, Set, Tuple, Union,
|
|
21
|
+
Mapping, MutableMapping, Sequence,
|
|
22
|
+
Callable, Type
|
|
23
|
+
)
|
|
24
|
+
import collections
|
|
25
|
+
|
|
26
|
+
from bigraph_schema import (
|
|
27
|
+
Edge, Registry, TypeSystem, visit_method,
|
|
28
|
+
get_path, set_path, resolve_path, hierarchy_depth, deep_merge,
|
|
29
|
+
is_schema_key, strip_schema_keys)
|
|
30
|
+
|
|
31
|
+
from bigraph_schema.protocols import local_lookup_module
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# =========================
|
|
35
|
+
# Process Utility Functions
|
|
36
|
+
# =========================
|
|
37
|
+
|
|
38
|
+
def assert_interface(interface: Dict[str, Any]) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Ensure that the interface dictionary contains both 'inputs' and 'outputs' keys.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
interface: A dictionary describing a process interface.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
AssertionError: If required keys are missing or extra keys are present.
|
|
47
|
+
"""
|
|
48
|
+
required_keys = {'inputs', 'outputs'}
|
|
49
|
+
existing_keys = set(interface.keys())
|
|
50
|
+
assert existing_keys == required_keys, (
|
|
51
|
+
f"Every interface requires exactly the keys 'inputs' and 'outputs', "
|
|
52
|
+
f"but found: {existing_keys}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_instances(
|
|
57
|
+
state: Dict[str, Any],
|
|
58
|
+
instance_type: str = 'process_bigraph.composite.Process'
|
|
59
|
+
) -> Dict[str, Any]:
|
|
60
|
+
"""
|
|
61
|
+
Recursively find all dictionary entries that contain an 'instance' of the given type.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
state: Nested state dictionary.
|
|
65
|
+
instance_type: Fully qualified path to the target class (e.g., 'module.Class').
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A dictionary of matching subtrees keyed by their path segment.
|
|
69
|
+
"""
|
|
70
|
+
process_class = local_lookup_module(instance_type)
|
|
71
|
+
found: Dict[str, Any] = {}
|
|
72
|
+
|
|
73
|
+
for key, inner in state.items():
|
|
74
|
+
if isinstance(inner, dict):
|
|
75
|
+
if isinstance(inner.get('instance'), process_class):
|
|
76
|
+
found[key] = inner
|
|
77
|
+
elif not is_schema_key(key):
|
|
78
|
+
sub_instances = find_instances(inner, instance_type)
|
|
79
|
+
if sub_instances:
|
|
80
|
+
found[key] = sub_instances
|
|
81
|
+
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def find_instance_paths(
|
|
86
|
+
state: Dict[str, Any],
|
|
87
|
+
instance_type: str = 'process_bigraph.composite.Process'
|
|
88
|
+
) -> Dict[Tuple[str, ...], Any]:
|
|
89
|
+
"""
|
|
90
|
+
Find all paths to instances of a given type in the state.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
state: The full nested state dictionary.
|
|
94
|
+
instance_type: Fully qualified class name to match.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A dictionary mapping full paths (as tuples) to instance-containing subtrees.
|
|
98
|
+
"""
|
|
99
|
+
instances = find_instances(state, instance_type)
|
|
100
|
+
return hierarchy_depth(instances)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_step_triggers(
|
|
104
|
+
path: Union[List[str], Tuple[str, ...]],
|
|
105
|
+
step: Dict[str, Any]
|
|
106
|
+
) -> Dict[Tuple[str, ...], List[Union[List[str], Tuple[str, ...]]]]:
|
|
107
|
+
"""
|
|
108
|
+
Identify which paths, when updated, should trigger the execution of a given step.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
path: Path to the step in the composite model tree.
|
|
112
|
+
step: Step object containing an 'inputs' field with wire mappings.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Mapping from trigger paths to lists of step paths that they trigger.
|
|
116
|
+
"""
|
|
117
|
+
prefix = tuple(path[:-1])
|
|
118
|
+
triggers: Dict[Tuple[str, ...], List[Union[List[str], Tuple[str, ...]]]] = {}
|
|
119
|
+
wire_paths = find_leaves(step['inputs'])
|
|
120
|
+
|
|
121
|
+
for wire in wire_paths:
|
|
122
|
+
trigger_path = resolve_path(prefix + tuple(wire))
|
|
123
|
+
triggers.setdefault(trigger_path, []).append(path)
|
|
124
|
+
|
|
125
|
+
return triggers
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def explode_path(path: Union[List[str], Tuple[str, ...]]) -> List[Tuple[str, ...]]:
|
|
129
|
+
"""
|
|
130
|
+
Break a hierarchical path into all its prefix paths.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
('a', 'b', 'c') → [(), ('a',), ('a', 'b'), ('a', 'b', 'c')]
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
path: A tuple or list representing a path.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A list of prefix paths.
|
|
140
|
+
"""
|
|
141
|
+
explode: Tuple[str, ...] = ()
|
|
142
|
+
paths = [explode]
|
|
143
|
+
for node in path:
|
|
144
|
+
explode = explode + (node,)
|
|
145
|
+
paths.append(explode)
|
|
146
|
+
return paths
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def merge_collections(
|
|
150
|
+
existing: Optional[MutableMapping[str, Any]],
|
|
151
|
+
new: Optional[MutableMapping[str, Any]]
|
|
152
|
+
) -> MutableMapping[str, Any]:
|
|
153
|
+
"""
|
|
154
|
+
Merge two nested structures (dicts or lists), combining compatible elements in-place.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
existing: An existing collection to merge into.
|
|
158
|
+
new: A new collection to merge.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The merged structure.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
Exception: If types are incompatible or mergeable fields conflict.
|
|
165
|
+
"""
|
|
166
|
+
existing = existing or {}
|
|
167
|
+
new = new or {}
|
|
168
|
+
|
|
169
|
+
for key, value in new.items():
|
|
170
|
+
if key in existing:
|
|
171
|
+
if isinstance(existing[key], dict) and isinstance(value, Mapping):
|
|
172
|
+
merge_collections(existing[key], value)
|
|
173
|
+
elif isinstance(existing[key], list) and isinstance(value, Sequence):
|
|
174
|
+
existing[key].extend(value)
|
|
175
|
+
else:
|
|
176
|
+
raise Exception(
|
|
177
|
+
f"Cannot merge conflicting types or values for key '{key}':\n"
|
|
178
|
+
f"existing={existing[key]}\nnew={value}"
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
existing[key] = value
|
|
182
|
+
|
|
183
|
+
return existing
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def empty_front(time: float) -> Dict[str, Any]:
|
|
187
|
+
"""
|
|
188
|
+
Generate a default front buffer for a process.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
time: The current simulation time.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
A dictionary with time and an empty update field.
|
|
195
|
+
"""
|
|
196
|
+
return {'time': time, 'update': {}}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def find_leaves(tree_structure, path=None):
|
|
200
|
+
"""
|
|
201
|
+
Recursively find all leaf paths in a nested dictionary structure.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
tree_structure (any): A nested structure of dicts/lists/tuples.
|
|
205
|
+
path (tuple or None): Current traversal path (for recursion).
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
list: List of leaf paths as tuples.
|
|
209
|
+
"""
|
|
210
|
+
leaves = []
|
|
211
|
+
path = ()
|
|
212
|
+
|
|
213
|
+
if tree_structure is None:
|
|
214
|
+
pass
|
|
215
|
+
elif isinstance(tree_structure, list):
|
|
216
|
+
leaves = tree_structure
|
|
217
|
+
elif isinstance(tree_structure, tuple):
|
|
218
|
+
leaves.append(tree_structure)
|
|
219
|
+
else:
|
|
220
|
+
for key, value in tree_structure.items():
|
|
221
|
+
if isinstance(value, dict):
|
|
222
|
+
subleaves = find_leaves(value, path + (key,))
|
|
223
|
+
leaves.extend(subleaves)
|
|
224
|
+
else:
|
|
225
|
+
leaves.append(path + tuple(value))
|
|
226
|
+
|
|
227
|
+
return leaves
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def build_step_network(steps):
|
|
231
|
+
"""
|
|
232
|
+
Build the data dependency graph among steps.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
steps: A mapping of step identifiers to their instance/config.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
- ancestors: A mapping from step keys to their input/output paths.
|
|
239
|
+
- nodes: A mapping from paths to sets of steps that are dependent on them.
|
|
240
|
+
"""
|
|
241
|
+
ancestors = {
|
|
242
|
+
step_key: {'input_paths': None, 'output_paths': None}
|
|
243
|
+
for step_key in steps
|
|
244
|
+
}
|
|
245
|
+
nodes = {}
|
|
246
|
+
|
|
247
|
+
for step_key, step in steps.items():
|
|
248
|
+
schema = step['instance'].interface()
|
|
249
|
+
assert_interface(schema)
|
|
250
|
+
|
|
251
|
+
# Compute input paths once per step
|
|
252
|
+
if ancestors[step_key]['input_paths'] is None:
|
|
253
|
+
ancestors[step_key]['input_paths'] = find_leaves(step['inputs'])
|
|
254
|
+
|
|
255
|
+
# Compute output paths once per step
|
|
256
|
+
if ancestors[step_key]['output_paths'] is None:
|
|
257
|
+
ancestors[step_key]['output_paths'] = find_leaves(step.get('outputs', {}))
|
|
258
|
+
|
|
259
|
+
input_paths = ancestors[step_key]['input_paths'] or []
|
|
260
|
+
output_paths = ancestors[step_key]['output_paths'] or []
|
|
261
|
+
|
|
262
|
+
# Track which steps consume/produce each path
|
|
263
|
+
for input_path in input_paths:
|
|
264
|
+
path = tuple(input_path)
|
|
265
|
+
nodes.setdefault(path, {'before': set(), 'after': set()})
|
|
266
|
+
nodes[path]['after'].add(step_key)
|
|
267
|
+
|
|
268
|
+
for output_path in output_paths:
|
|
269
|
+
if output_path not in input_paths:
|
|
270
|
+
path = tuple(output_path)
|
|
271
|
+
nodes.setdefault(path, {'before': set(), 'after': set()})
|
|
272
|
+
nodes[path]['before'].add(step_key)
|
|
273
|
+
|
|
274
|
+
return ancestors, nodes
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def build_trigger_state(nodes):
|
|
278
|
+
"""
|
|
279
|
+
Initialize the trigger state from dependency nodes.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
nodes: Dependency graph nodes with 'before' and 'after' sets.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
A mapping of paths to the set of steps waiting on those paths.
|
|
286
|
+
"""
|
|
287
|
+
return {key: value['before'].copy() for key, value in nodes.items()}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def find_downstream(steps, nodes, upstream):
|
|
291
|
+
"""
|
|
292
|
+
Given a set of updated steps, identify all downstream steps that depend on them.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
steps: Step metadata with input/output info.
|
|
296
|
+
nodes: Dependency graph.
|
|
297
|
+
upstream: Initial set of triggered step paths.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Set of all steps affected directly or transitively.
|
|
301
|
+
"""
|
|
302
|
+
downstream = set(upstream)
|
|
303
|
+
visited = set([])
|
|
304
|
+
previous_len = -1
|
|
305
|
+
|
|
306
|
+
while len(downstream) > len(visited) and len(visited) > previous_len:
|
|
307
|
+
previous_len = len(visited)
|
|
308
|
+
down = set([])
|
|
309
|
+
for step_path in downstream:
|
|
310
|
+
if step_path not in visited:
|
|
311
|
+
step_outputs = steps[step_path]['output_paths']
|
|
312
|
+
if step_outputs is None:
|
|
313
|
+
step_outputs = [] # Ensure step_outputs is always an iterable
|
|
314
|
+
for output in step_outputs:
|
|
315
|
+
for subpath in explode_path(output):
|
|
316
|
+
if subpath in nodes:
|
|
317
|
+
for dependent in nodes[subpath]['after']:
|
|
318
|
+
down.add(dependent)
|
|
319
|
+
visited.add(step_path)
|
|
320
|
+
downstream |= down
|
|
321
|
+
|
|
322
|
+
return downstream
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def determine_steps(steps, remaining, fulfilled):
|
|
326
|
+
"""
|
|
327
|
+
Determine which steps are eligible to run, based on current fulfilled triggers.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
steps: Step metadata.
|
|
331
|
+
remaining: Set of step paths not yet run.
|
|
332
|
+
fulfilled: Map of data paths to steps waiting for fulfillment.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
- to_run: List of ready step paths.
|
|
336
|
+
- remaining: Updated remaining steps.
|
|
337
|
+
- fulfilled: Updated fulfilled structure.
|
|
338
|
+
"""
|
|
339
|
+
to_run = []
|
|
340
|
+
|
|
341
|
+
for step_path in list(remaining):
|
|
342
|
+
step_inputs = steps[step_path].get('input_paths', []) or []
|
|
343
|
+
if all(len(fulfilled[input]) == 0 for input in step_inputs):
|
|
344
|
+
to_run.append(step_path)
|
|
345
|
+
|
|
346
|
+
for step_path in to_run:
|
|
347
|
+
remaining.remove(step_path)
|
|
348
|
+
step_outputs = steps[step_path].get('output_paths', []) or []
|
|
349
|
+
for output in step_outputs:
|
|
350
|
+
if step_path in fulfilled.get(output, set()):
|
|
351
|
+
fulfilled[output].remove(step_path)
|
|
352
|
+
|
|
353
|
+
return to_run, remaining, fulfilled
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def interval_time_precision(timestep: float) -> int:
|
|
357
|
+
"""
|
|
358
|
+
Compute the number of decimal places required to represent the given timestep.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
timestep: Time interval as float.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The number of digits after the decimal point.
|
|
365
|
+
"""
|
|
366
|
+
return len(str(timestep).split('.')[1]) if '.' in str(timestep) else 0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ===============
|
|
370
|
+
# Process Classes
|
|
371
|
+
# ===============
|
|
372
|
+
|
|
373
|
+
class SyncUpdate:
|
|
374
|
+
"""
|
|
375
|
+
Wrapper for synchronous process updates.
|
|
376
|
+
|
|
377
|
+
This object encapsulates an update dictionary and provides a `.get()` method
|
|
378
|
+
for compatibility with deferred or lazy update execution pipelines.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
def __init__(self, update: Dict[str, Any]) -> None:
|
|
382
|
+
"""
|
|
383
|
+
Args:
|
|
384
|
+
update: The process update to wrap.
|
|
385
|
+
"""
|
|
386
|
+
self.update = update
|
|
387
|
+
|
|
388
|
+
def get(self) -> Dict[str, Any]:
|
|
389
|
+
"""
|
|
390
|
+
Returns:
|
|
391
|
+
The stored process update.
|
|
392
|
+
"""
|
|
393
|
+
return self.update
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class Open(Edge):
|
|
397
|
+
METHOD_COMMANDS = (
|
|
398
|
+
'initial_state', 'inputs', 'outputs', 'update')
|
|
399
|
+
|
|
400
|
+
ATTRIBUTE_READ_COMMANDS = (
|
|
401
|
+
'config', 'composition', 'state')
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def __init__(self, config=None, core=None):
|
|
405
|
+
self._command_result: Any = None
|
|
406
|
+
self._pending_command: Optional[
|
|
407
|
+
Tuple[str, Optional[tuple], Optional[dict]]] = None
|
|
408
|
+
|
|
409
|
+
super().__init__(config, core=core)
|
|
410
|
+
|
|
411
|
+
def pre_send_command(
|
|
412
|
+
self, command: str, args: Optional[tuple], kwargs:
|
|
413
|
+
Optional[dict]) -> None:
|
|
414
|
+
'''Run pre-checks before starting a command.
|
|
415
|
+
|
|
416
|
+
This method should be called at the start of every
|
|
417
|
+
implementation of :py:meth:`send_command`.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
command: The name of the command to run.
|
|
421
|
+
args: A tuple of positional arguments for the command.
|
|
422
|
+
kwargs: A dictionary of keyword arguments for the command.
|
|
423
|
+
|
|
424
|
+
Raises:
|
|
425
|
+
RuntimeError: Raised when a user tries to send a command
|
|
426
|
+
while a previous command is still pending (i.e. the user
|
|
427
|
+
hasn't called :py:meth:`get_command_result` yet for the
|
|
428
|
+
previous command).
|
|
429
|
+
'''
|
|
430
|
+
if self._pending_command:
|
|
431
|
+
raise RuntimeError(
|
|
432
|
+
f'Trying to send command {(command, args, kwargs)} but '
|
|
433
|
+
f'command {self._pending_command} is still pending.')
|
|
434
|
+
self._pending_command = command, args, kwargs
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def send_command(
|
|
438
|
+
self, command: str, args: Optional[tuple] = None,
|
|
439
|
+
kwargs: Optional[dict] = None,
|
|
440
|
+
run_pre_check: bool = True) -> None:
|
|
441
|
+
'''Handle :term:`process commands`.
|
|
442
|
+
|
|
443
|
+
This method handles the commands listed in
|
|
444
|
+
:py:attr:`METHOD_COMMANDS` by passing ``args``
|
|
445
|
+
and ``kwargs`` to the method of ``self`` with the name
|
|
446
|
+
of the command and saving the return value as the result.
|
|
447
|
+
|
|
448
|
+
This method handles the commands listed in
|
|
449
|
+
:py:attr:`ATTRIBUTE_READ_COMMANDS` by returning the attribute of
|
|
450
|
+
``self`` with the name matching the command, and it handles the
|
|
451
|
+
commands listed in :py:attr:`ATTRIBUTE_WRITE_COMMANDS` by
|
|
452
|
+
setting the attribute in the command to the first argument in
|
|
453
|
+
``args``. The command must be named ``set_attr`` for attribute
|
|
454
|
+
``attr``.
|
|
455
|
+
|
|
456
|
+
To add support for a custom command, override this function in
|
|
457
|
+
your subclass. Each command is defined by a name (a string)
|
|
458
|
+
and accepts both positional and keyword arguments. Any custom
|
|
459
|
+
commands you add should have associated methods such that:
|
|
460
|
+
|
|
461
|
+
* The command name matches the method name.
|
|
462
|
+
* The command and method accept the same positional and keyword
|
|
463
|
+
arguments.
|
|
464
|
+
* The command and method return the same values.
|
|
465
|
+
|
|
466
|
+
If all of the above are satisfied, you can use
|
|
467
|
+
:py:meth:`Process.run_command_method` to handle the command.
|
|
468
|
+
|
|
469
|
+
Your implementation of this function needs to handle all the
|
|
470
|
+
commands you want to support. When presented with an unknown
|
|
471
|
+
command, you should call the superclass method, which will
|
|
472
|
+
either handle the command or call its superclass method. At the
|
|
473
|
+
top of this recursive chain, this ``Process.send_command()``
|
|
474
|
+
method handles some built-in commands and will raise an error
|
|
475
|
+
for unknown commands.
|
|
476
|
+
|
|
477
|
+
Any overrides of this method must also call
|
|
478
|
+
:py:meth:`pre_send_command` at the start of the method. This
|
|
479
|
+
call will check that no command is currently pending to avoid
|
|
480
|
+
confusing behavior when multiple commands are started without
|
|
481
|
+
intervening retrievals of command results. Since your overriding
|
|
482
|
+
method will have already performed the pre-check, it should pass
|
|
483
|
+
``run_pre_check=False`` when calling the superclass method.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
command: The name of the command to run.
|
|
487
|
+
args: A tuple of positional arguments for the command.
|
|
488
|
+
kwargs: A dictionary of keyword arguments for the command.
|
|
489
|
+
run_pre_check: Whether to run the pre-checks implemented in
|
|
490
|
+
:py:meth:`pre_send_command`. This should be left at its
|
|
491
|
+
default value unless the pre-checks have already been
|
|
492
|
+
performed (e.g. if this method is being called by a
|
|
493
|
+
subclass's overriding method.)
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
None. This method just starts the command running.
|
|
497
|
+
|
|
498
|
+
Raises:
|
|
499
|
+
ValueError: For unknown commands.
|
|
500
|
+
'''
|
|
501
|
+
if run_pre_check:
|
|
502
|
+
self.pre_send_command(command, args, kwargs)
|
|
503
|
+
args = args or tuple()
|
|
504
|
+
kwargs = kwargs or {}
|
|
505
|
+
if command in self.METHOD_COMMANDS:
|
|
506
|
+
self._command_result = self.run_command_method(
|
|
507
|
+
command, args, kwargs)
|
|
508
|
+
elif command in self.ATTRIBUTE_READ_COMMANDS:
|
|
509
|
+
self._command_result = getattr(self, command)
|
|
510
|
+
# elif command in self.ATTRIBUTE_WRITE_COMMANDS:
|
|
511
|
+
# assert command.startswith('set_')
|
|
512
|
+
# assert args
|
|
513
|
+
# setattr(self, command[len('set_'):], args[0])
|
|
514
|
+
else:
|
|
515
|
+
raise ValueError(
|
|
516
|
+
f'Process {self} does not understand the process '
|
|
517
|
+
f'command {command}')
|
|
518
|
+
|
|
519
|
+
def run_command_method(
|
|
520
|
+
self, command: str, args: tuple, kwargs: dict) -> Any:
|
|
521
|
+
'''Run a command whose name and interface match a method.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
command: The command name, which must equal to a method of
|
|
525
|
+
``self``.
|
|
526
|
+
args: The positional arguments to pass to the method.
|
|
527
|
+
kwargs: The keywords arguments for the method.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
The result of calling ``self.command(*args, **kwargs)`` is
|
|
531
|
+
returned for command ``command``.
|
|
532
|
+
'''
|
|
533
|
+
return getattr(self, command)(*args, **kwargs)
|
|
534
|
+
|
|
535
|
+
def get_command_result(self) -> Any:
|
|
536
|
+
'''Retrieve the result from the last-run command.
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
The result of the last command run. Note that this method
|
|
540
|
+
should only be called once immediately after each call to
|
|
541
|
+
:py:meth:`send_command`.
|
|
542
|
+
|
|
543
|
+
Raises:
|
|
544
|
+
RuntimeError: When there is no command pending. This can
|
|
545
|
+
happen when this method is called twice without an
|
|
546
|
+
intervening call to :py:meth:`send_command`.
|
|
547
|
+
'''
|
|
548
|
+
if not self._pending_command:
|
|
549
|
+
raise RuntimeError(
|
|
550
|
+
'Trying to retrieve command result, but no command is '
|
|
551
|
+
'pending.')
|
|
552
|
+
self._pending_command = None
|
|
553
|
+
result = self._command_result
|
|
554
|
+
self._command_result = None
|
|
555
|
+
return result
|
|
556
|
+
|
|
557
|
+
def run_command(
|
|
558
|
+
self, command: str, args: Optional[tuple] = None,
|
|
559
|
+
kwargs: Optional[dict] = None) -> Any:
|
|
560
|
+
'''Helper function that sends a command and returns result.'''
|
|
561
|
+
self.send_command(command, args, kwargs)
|
|
562
|
+
return self.get_command_result()
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class Step(Open):
|
|
566
|
+
"""
|
|
567
|
+
Step base class.
|
|
568
|
+
|
|
569
|
+
A `Step` is a stateless, non-temporal computational unit within a composite process.
|
|
570
|
+
It is triggered when its data dependencies are satisfied, functioning like a reaction
|
|
571
|
+
or transformation rule.
|
|
572
|
+
|
|
573
|
+
Override the `.update()` method to define custom behavior.
|
|
574
|
+
"""
|
|
575
|
+
# TODO: support trigger every time as well as dependency trigger
|
|
576
|
+
|
|
577
|
+
def invoke(self, state: Dict[str, Any], _: Optional[float] = None) -> SyncUpdate:
|
|
578
|
+
"""
|
|
579
|
+
Run the step using the given state and return its update.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
state: The input state to compute the update from.
|
|
583
|
+
_: Ignored time interval placeholder (not used by steps).
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
A SyncUpdate object containing the update dictionary.
|
|
587
|
+
"""
|
|
588
|
+
update = self.update(state)
|
|
589
|
+
return SyncUpdate(update)
|
|
590
|
+
|
|
591
|
+
def register_shared(self, instance):
|
|
592
|
+
"""
|
|
593
|
+
Register a reference to a shared instance, e.g., for access to core or context.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
instance: A reference to the external object being shared with the step.
|
|
597
|
+
"""
|
|
598
|
+
self.instance = instance
|
|
599
|
+
|
|
600
|
+
def update(self, state: Dict[str, Any], interval=None) -> Dict[str, Any]:
|
|
601
|
+
"""
|
|
602
|
+
Compute and return the update for the step.
|
|
603
|
+
|
|
604
|
+
Override this method in subclasses to define the step's logic.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
state: The current simulation state at the step's inputs.
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
A dictionary representing the update to apply.
|
|
611
|
+
"""
|
|
612
|
+
return {}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class Process(Open):
|
|
616
|
+
"""
|
|
617
|
+
Process base class.
|
|
618
|
+
|
|
619
|
+
A `Process` is a temporal unit of computation that operates on state and advances in time.
|
|
620
|
+
Each subclass must implement the `update()` method and optionally the `invoke()` method.
|
|
621
|
+
|
|
622
|
+
Processes are stateful and typically used for simulations of continuous or discrete dynamics.
|
|
623
|
+
"""
|
|
624
|
+
|
|
625
|
+
def invoke(self, state: Dict[str, Any], interval: float):
|
|
626
|
+
"""
|
|
627
|
+
Execute the process update for a given state and time interval.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
state: The current simulation state for this process.
|
|
631
|
+
interval: The time step over which to apply the update.
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
A SyncUpdate containing the update result.
|
|
635
|
+
"""
|
|
636
|
+
update = self.update(state, interval)
|
|
637
|
+
return SyncUpdate(update)
|
|
638
|
+
|
|
639
|
+
def update(self, state: Dict[str, Any], interval: float) -> Dict[str, Any]:
|
|
640
|
+
"""
|
|
641
|
+
Override this method to implement the process logic.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
state: The current simulation state at the process ports.
|
|
645
|
+
interval: The time step over which to simulate.
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
A dictionary representing the update to apply to the state.
|
|
649
|
+
"""
|
|
650
|
+
return {}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def as_step(inputs, outputs, core=None):
|
|
654
|
+
"""
|
|
655
|
+
Decorator to create a Step from a function named update_*.
|
|
656
|
+
If core is provided, registers under the name *.
|
|
657
|
+
"""
|
|
658
|
+
def decorator(func):
|
|
659
|
+
assert func.__name__.startswith('update_'), "Function name must be of the form update_*"
|
|
660
|
+
step_name = func.__name__[len('update_'):]
|
|
661
|
+
|
|
662
|
+
class FunctionStep(Step):
|
|
663
|
+
def inputs(self):
|
|
664
|
+
return inputs
|
|
665
|
+
|
|
666
|
+
def outputs(self):
|
|
667
|
+
return outputs
|
|
668
|
+
|
|
669
|
+
def update(self, state):
|
|
670
|
+
return func(state)
|
|
671
|
+
|
|
672
|
+
FunctionStep.__name__ = step_name + 'Step'
|
|
673
|
+
|
|
674
|
+
if core is not None:
|
|
675
|
+
core.register_process(step_name, FunctionStep)
|
|
676
|
+
|
|
677
|
+
return FunctionStep
|
|
678
|
+
|
|
679
|
+
return decorator
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def as_process(inputs, outputs, core=None):
|
|
683
|
+
"""
|
|
684
|
+
Decorator to create a Process from a function named update_*.
|
|
685
|
+
If core is provided, registers under the name *.
|
|
686
|
+
"""
|
|
687
|
+
def decorator(func):
|
|
688
|
+
assert func.__name__.startswith('update_'), "Function name must be of the form update_*"
|
|
689
|
+
process_name = func.__name__[len('update_'):]
|
|
690
|
+
|
|
691
|
+
class FunctionProcess(Process):
|
|
692
|
+
def __init__(self, config=None, core=None):
|
|
693
|
+
super().__init__(config=config, core=core)
|
|
694
|
+
|
|
695
|
+
def inputs(self):
|
|
696
|
+
return inputs
|
|
697
|
+
|
|
698
|
+
def outputs(self):
|
|
699
|
+
return outputs
|
|
700
|
+
|
|
701
|
+
def update(self, state, interval):
|
|
702
|
+
return func(state, interval)
|
|
703
|
+
|
|
704
|
+
FunctionProcess.__name__ = process_name + 'Process'
|
|
705
|
+
|
|
706
|
+
if core is not None:
|
|
707
|
+
core.register_process(process_name, FunctionProcess)
|
|
708
|
+
|
|
709
|
+
return FunctionProcess
|
|
710
|
+
|
|
711
|
+
return decorator
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class ProcessEnsemble(Process):
|
|
715
|
+
"""
|
|
716
|
+
ProcessEnsemble base class.
|
|
717
|
+
|
|
718
|
+
A container for multiple sub-processes that exposes a combined interface by unifying
|
|
719
|
+
their inputs and outputs. Useful when combining multiple related processes into a single one.
|
|
720
|
+
"""
|
|
721
|
+
|
|
722
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None, core: Optional[Any] = None) -> None:
|
|
723
|
+
"""
|
|
724
|
+
Args:
|
|
725
|
+
config: Configuration dictionary for the ensemble process.
|
|
726
|
+
core: Optional shared core/context for schema operations and initialization.
|
|
727
|
+
"""
|
|
728
|
+
super().__init__(config=config, core=core)
|
|
729
|
+
|
|
730
|
+
def union_interface(self) -> Dict[str, Dict[str, Any]]:
|
|
731
|
+
"""
|
|
732
|
+
Generate a unified interface by combining all inputs_*/outputs_* methods defined in the subclass.
|
|
733
|
+
|
|
734
|
+
Returns:
|
|
735
|
+
A dictionary with 'inputs' and 'outputs' schemas merged from all sub-process interfaces.
|
|
736
|
+
"""
|
|
737
|
+
union_inputs: Dict[str, Any] = {}
|
|
738
|
+
union_outputs: Dict[str, Any] = {}
|
|
739
|
+
|
|
740
|
+
for attr_name in dir(self):
|
|
741
|
+
if attr_name.startswith('inputs_'):
|
|
742
|
+
inputs_func = getattr(self, attr_name)
|
|
743
|
+
if callable(inputs_func):
|
|
744
|
+
inputs = inputs_func()
|
|
745
|
+
union_inputs = self.core.resolve_schemas(union_inputs, inputs)
|
|
746
|
+
|
|
747
|
+
if attr_name.startswith('outputs_'):
|
|
748
|
+
outputs_func = getattr(self, attr_name)
|
|
749
|
+
if callable(outputs_func):
|
|
750
|
+
outputs = outputs_func()
|
|
751
|
+
union_outputs = self.core.resolve_schemas(union_outputs, outputs)
|
|
752
|
+
|
|
753
|
+
return {
|
|
754
|
+
'inputs': union_inputs,
|
|
755
|
+
'outputs': union_outputs
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
class Defer:
|
|
760
|
+
"""
|
|
761
|
+
Defer a computation by holding a reference to a function and its arguments
|
|
762
|
+
until a later time when `.get()` is called.
|
|
763
|
+
|
|
764
|
+
This is used to delay the application of a function to a value until all
|
|
765
|
+
required data is available, typically used for processing deferred updates
|
|
766
|
+
in simulation pipelines.
|
|
767
|
+
|
|
768
|
+
Attributes:
|
|
769
|
+
defer (SupportsGet): An object that supports `.get()` and returns the input to the function.
|
|
770
|
+
f (Callable[[Any, Any], Any]): A binary function to apply to the result of `defer.get()` and `args`.
|
|
771
|
+
args (Any): Arguments passed to the function alongside the deferred value.
|
|
772
|
+
"""
|
|
773
|
+
|
|
774
|
+
def __init__(
|
|
775
|
+
self,
|
|
776
|
+
defer,
|
|
777
|
+
f,
|
|
778
|
+
args
|
|
779
|
+
) -> None:
|
|
780
|
+
"""
|
|
781
|
+
Args:
|
|
782
|
+
defer: Any object that implements `.get()` and returns a value.
|
|
783
|
+
f: A function that takes two arguments: the result of `defer.get()` and `args`.
|
|
784
|
+
args: A secondary argument passed to `f`.
|
|
785
|
+
"""
|
|
786
|
+
self.defer = defer
|
|
787
|
+
self.f = f
|
|
788
|
+
self.args = args
|
|
789
|
+
|
|
790
|
+
def get(self) -> Any:
|
|
791
|
+
"""
|
|
792
|
+
Perform the deferred computation by calling the stored function with the
|
|
793
|
+
deferred result and provided arguments.
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
The result of `f(defer.get(), args)`.
|
|
797
|
+
"""
|
|
798
|
+
return self.f(self.defer.get(), self.args)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def match_star_path(
|
|
802
|
+
path: Tuple[str, ...],
|
|
803
|
+
star_path: Tuple[str, ...]
|
|
804
|
+
) -> bool:
|
|
805
|
+
"""
|
|
806
|
+
Compare two paths where elements in `star_path` may contain wildcards (*).
|
|
807
|
+
|
|
808
|
+
Args:
|
|
809
|
+
path: A tuple representing the actual path (e.g., ('cells', 'A', 'growth')).
|
|
810
|
+
star_path: A tuple that may contain '*' wildcards to match any segment.
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
True if the paths match, treating '*' as a wildcard; False otherwise.
|
|
814
|
+
|
|
815
|
+
Example:
|
|
816
|
+
match_star_path(('cells', 'A', 'growth'), ('cells', '*', 'growth')) # True
|
|
817
|
+
match_star_path(('cells', 'A'), ('cells', '*', 'growth')) # False
|
|
818
|
+
"""
|
|
819
|
+
for element, star_element in zip(path, star_path):
|
|
820
|
+
if star_element != "*" and element != star_element:
|
|
821
|
+
return False
|
|
822
|
+
return True
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
class Composite(Process):
|
|
826
|
+
"""
|
|
827
|
+
A Composite process contains a dynamic network of child Processes and Steps
|
|
828
|
+
connected via a schema and bridge. It manages time, state, dependencies, and
|
|
829
|
+
update propagation during simulation.
|
|
830
|
+
"""
|
|
831
|
+
|
|
832
|
+
config_schema = {
|
|
833
|
+
'composition': 'schema',
|
|
834
|
+
'state': 'tree[any]',
|
|
835
|
+
'interface': {
|
|
836
|
+
'inputs': 'schema',
|
|
837
|
+
'outputs': 'schema'
|
|
838
|
+
},
|
|
839
|
+
'bridge': {
|
|
840
|
+
'inputs': 'wires',
|
|
841
|
+
'outputs': 'wires'
|
|
842
|
+
},
|
|
843
|
+
'global_time_precision': 'maybe[float]'
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
# ==============================
|
|
848
|
+
# Initialization & Configuration
|
|
849
|
+
# ==============================
|
|
850
|
+
|
|
851
|
+
def initialize(self, config: Optional[Dict[str, Any]] = None) -> None:
|
|
852
|
+
"""
|
|
853
|
+
Initialize the composite model from its config.
|
|
854
|
+
|
|
855
|
+
This method:
|
|
856
|
+
- Adds `global_time` to schema/state if missing
|
|
857
|
+
- Generates the full composition/state tree
|
|
858
|
+
- Finds all step/process instances
|
|
859
|
+
- Resolves the schema bridge
|
|
860
|
+
- Prepares the step execution network
|
|
861
|
+
- Computes initial front (per-process timeline)
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
config: Optional override configuration (usually not needed).
|
|
865
|
+
"""
|
|
866
|
+
|
|
867
|
+
# Get the initial composition schema from config.
|
|
868
|
+
initial_composition = self.config.get('composition', {})
|
|
869
|
+
|
|
870
|
+
# Ensure 'global_time' is explicitly declared in the schema.
|
|
871
|
+
if 'global_time' not in initial_composition:
|
|
872
|
+
initial_composition['global_time'] = 'float'
|
|
873
|
+
|
|
874
|
+
# Get the initial state from config.
|
|
875
|
+
initial_state = self.config.get('state', {})
|
|
876
|
+
|
|
877
|
+
# Ensure the initial simulation state has a global_time initialized.
|
|
878
|
+
if 'global_time' not in initial_state:
|
|
879
|
+
initial_state['global_time'] = 0.0
|
|
880
|
+
|
|
881
|
+
# Generate internal schema and state structures using the core engine.
|
|
882
|
+
self.composition, self.state = self.core.generate(
|
|
883
|
+
initial_composition,
|
|
884
|
+
initial_state)
|
|
885
|
+
|
|
886
|
+
# Load the bridge configuration, which defines how inputs/outputs connect to the world.
|
|
887
|
+
self.bridge = self.config.get('bridge', {})
|
|
888
|
+
|
|
889
|
+
# initialize an empty front for finding the instance paths
|
|
890
|
+
self.front = {}
|
|
891
|
+
|
|
892
|
+
# Identify all Process and Step instances in the state tree.
|
|
893
|
+
self.find_instance_paths(self.state)
|
|
894
|
+
|
|
895
|
+
# Merge both process and step paths into a single edge dictionary.
|
|
896
|
+
self.edge_paths = {**self.process_paths, **self.step_paths}
|
|
897
|
+
|
|
898
|
+
# Initialize each process/step's state and accumulate it into a unified state tree.
|
|
899
|
+
edge_state: Dict[str, Any] = {}
|
|
900
|
+
for path, edge in self.edge_paths.items():
|
|
901
|
+
# Generate the initial state for this specific edge (process or step).
|
|
902
|
+
initial = self.core.initialize_edge_state(
|
|
903
|
+
self.composition,
|
|
904
|
+
path,
|
|
905
|
+
edge)
|
|
906
|
+
|
|
907
|
+
# Merge the new edge state with the global state tree, checking for conflicts.
|
|
908
|
+
try:
|
|
909
|
+
edge_state = deep_merge(edge_state, initial)
|
|
910
|
+
except Exception:
|
|
911
|
+
raise Exception(
|
|
912
|
+
f'initial state from edge does not match initial state from other edges:\n'
|
|
913
|
+
f'{path}\n{edge}\n{edge_state}'
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
# Apply the merged edge_state into the global state and update instance paths.
|
|
917
|
+
self.merge(self.composition, edge_state)
|
|
918
|
+
|
|
919
|
+
# Wire the input/output schema for the Composite from the bridge config.
|
|
920
|
+
self.process_schema = {
|
|
921
|
+
port: self.core.wire_schema(self.composition, self.bridge[port])
|
|
922
|
+
for port in ['inputs', 'outputs']
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
# Set the global time precision used to round step time advances.
|
|
926
|
+
self.global_time_precision = self.config.get('global_time_precision')
|
|
927
|
+
|
|
928
|
+
# Initialize a "front" dictionary tracking the next update time and update data per process.
|
|
929
|
+
self.front = {
|
|
930
|
+
path: empty_front(self.state['global_time'])
|
|
931
|
+
for path in self.process_paths
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
# A buffer for updates to be emitted at the composite's output interface.
|
|
935
|
+
self.bridge_updates: List[Any] = []
|
|
936
|
+
|
|
937
|
+
# Build the dependency network between steps and determine which steps should run first.
|
|
938
|
+
self.build_step_network()
|
|
939
|
+
|
|
940
|
+
# Run all steps that are ready on the first cycle.
|
|
941
|
+
self.run_steps(self.to_run)
|
|
942
|
+
|
|
943
|
+
@classmethod
|
|
944
|
+
def load(cls, path: str, core: Optional[Any] = None) -> "Composite":
|
|
945
|
+
"""
|
|
946
|
+
Load a Composite from a saved JSON file.
|
|
947
|
+
|
|
948
|
+
Args:
|
|
949
|
+
path: Path to the saved composition file.
|
|
950
|
+
core: Optional core context providing deserialization.
|
|
951
|
+
|
|
952
|
+
Returns:
|
|
953
|
+
A new Composite instance.
|
|
954
|
+
"""
|
|
955
|
+
with open(path) as data:
|
|
956
|
+
document = json.load(data)
|
|
957
|
+
composition = document['composition']
|
|
958
|
+
document['composition'] = core.deserialize('schema', composition)
|
|
959
|
+
return cls(document, core=core)
|
|
960
|
+
|
|
961
|
+
def clean_front(self, state):
|
|
962
|
+
self.find_instance_paths(state)
|
|
963
|
+
|
|
964
|
+
def find_instance_paths(self, state: Dict[str, Any]) -> None:
|
|
965
|
+
"""
|
|
966
|
+
Identify all Step and Process instances in the current state.
|
|
967
|
+
|
|
968
|
+
Populates:
|
|
969
|
+
- self.process_paths
|
|
970
|
+
- self.step_paths
|
|
971
|
+
"""
|
|
972
|
+
self.process_paths = find_instance_paths(state, 'process_bigraph.composite.Process')
|
|
973
|
+
self.step_paths = find_instance_paths(state, 'process_bigraph.composite.Step')
|
|
974
|
+
|
|
975
|
+
all_paths = set(
|
|
976
|
+
list(self.process_paths.keys()) +
|
|
977
|
+
list(self.step_paths.keys()))
|
|
978
|
+
|
|
979
|
+
front_paths = set(
|
|
980
|
+
self.front.keys())
|
|
981
|
+
|
|
982
|
+
for removed_key in front_paths.difference(all_paths):
|
|
983
|
+
# do we want to do anything with these?
|
|
984
|
+
removed_front = self.front.pop(removed_key)
|
|
985
|
+
|
|
986
|
+
def merge(self, schema: Dict[str, Any], state: Dict[str, Any], path: Optional[List[str]] = None) -> None:
|
|
987
|
+
"""
|
|
988
|
+
Merge a new schema/state subtree into the Composite.
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
schema: Schema dictionary to merge.
|
|
992
|
+
state: State dictionary to merge.
|
|
993
|
+
path: Path where merge should occur (default: root).
|
|
994
|
+
"""
|
|
995
|
+
path = path or []
|
|
996
|
+
self.composition, self.state = self.core.merge(
|
|
997
|
+
self.composition,
|
|
998
|
+
self.state,
|
|
999
|
+
path,
|
|
1000
|
+
schema,
|
|
1001
|
+
state)
|
|
1002
|
+
self.find_instance_paths(self.state)
|
|
1003
|
+
|
|
1004
|
+
def merge_schema(
|
|
1005
|
+
self,
|
|
1006
|
+
schema: Dict[str, Any],
|
|
1007
|
+
path: Optional[List[str]] = None
|
|
1008
|
+
) -> None:
|
|
1009
|
+
"""
|
|
1010
|
+
Merge a new schema subtree into the current composite schema and regenerate state.
|
|
1011
|
+
|
|
1012
|
+
Args:
|
|
1013
|
+
schema: The schema subtree to merge.
|
|
1014
|
+
path: Optional path at which to merge the schema (defaults to root).
|
|
1015
|
+
"""
|
|
1016
|
+
path = path or []
|
|
1017
|
+
|
|
1018
|
+
# Set the new schema subtree at the given path
|
|
1019
|
+
scoped_schema = set_path({}, path, schema)
|
|
1020
|
+
|
|
1021
|
+
# Merge it into the existing schema
|
|
1022
|
+
self.composition = self.core.merge_schemas(self.composition, scoped_schema)
|
|
1023
|
+
|
|
1024
|
+
# Re-generate state based on the new schema structure
|
|
1025
|
+
self.composition, self.state = self.core.generate(self.composition, self.state)
|
|
1026
|
+
|
|
1027
|
+
# Re-scan the state tree for processes and steps
|
|
1028
|
+
self.find_instance_paths(self.state)
|
|
1029
|
+
|
|
1030
|
+
def apply(self, update: Dict[str, Any], path: Optional[List[str]] = None) -> None:
|
|
1031
|
+
"""
|
|
1032
|
+
Apply an update to the current state.
|
|
1033
|
+
|
|
1034
|
+
Args:
|
|
1035
|
+
update: A state update dictionary.
|
|
1036
|
+
path: Optional path to scope the update under.
|
|
1037
|
+
"""
|
|
1038
|
+
path = path or []
|
|
1039
|
+
scoped_update = set_path({}, path, update)
|
|
1040
|
+
self.state = self.core.apply(self.composition, self.state, scoped_update)
|
|
1041
|
+
self.find_instance_paths(self.state)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
# ===================
|
|
1045
|
+
# Serialization & I/O
|
|
1046
|
+
# ===================
|
|
1047
|
+
|
|
1048
|
+
def serialize_state(self) -> Dict[str, Any]:
|
|
1049
|
+
"""
|
|
1050
|
+
Serialize the internal state using the core serializer.
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
A serialized representation of the current state.
|
|
1054
|
+
"""
|
|
1055
|
+
return self.core.serialize(self.composition, self.state)
|
|
1056
|
+
|
|
1057
|
+
def serialize_schema(self) -> Dict[str, Any]:
|
|
1058
|
+
"""
|
|
1059
|
+
Serialize the composition (schema) using the core serializer.
|
|
1060
|
+
|
|
1061
|
+
Returns:
|
|
1062
|
+
A serialized schema representation.
|
|
1063
|
+
"""
|
|
1064
|
+
return self.core.serialize('schema', self.composition)
|
|
1065
|
+
|
|
1066
|
+
def save(
|
|
1067
|
+
self,
|
|
1068
|
+
filename: str = 'composite.json',
|
|
1069
|
+
outdir: str = 'out',
|
|
1070
|
+
schema: bool = False,
|
|
1071
|
+
state: bool = False
|
|
1072
|
+
) -> None:
|
|
1073
|
+
"""
|
|
1074
|
+
Save the composite to a JSON file.
|
|
1075
|
+
|
|
1076
|
+
Args:
|
|
1077
|
+
filename: Output filename.
|
|
1078
|
+
outdir: Output directory.
|
|
1079
|
+
schema: Whether to include the serialized schema.
|
|
1080
|
+
state: Whether to include the serialized state.
|
|
1081
|
+
"""
|
|
1082
|
+
if not schema and not state:
|
|
1083
|
+
schema = state = True
|
|
1084
|
+
|
|
1085
|
+
document = {}
|
|
1086
|
+
if state:
|
|
1087
|
+
document['state'] = self.serialize_state()
|
|
1088
|
+
if schema:
|
|
1089
|
+
document['composition'] = self.serialize_schema()
|
|
1090
|
+
|
|
1091
|
+
os.makedirs(outdir, exist_ok=True)
|
|
1092
|
+
filepath = os.path.join(outdir, filename)
|
|
1093
|
+
with open(filepath, 'w') as f:
|
|
1094
|
+
json.dump(document, f, indent=4)
|
|
1095
|
+
print(f"Saved composite to {filepath}")
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
# ==================
|
|
1099
|
+
# Interface & Wiring
|
|
1100
|
+
# ==================
|
|
1101
|
+
|
|
1102
|
+
def inputs(self) -> Dict[str, Any]:
|
|
1103
|
+
"""Return the composite's input schema (wired to the bridge)."""
|
|
1104
|
+
return self.process_schema.get('inputs', {})
|
|
1105
|
+
|
|
1106
|
+
def outputs(self) -> Dict[str, Any]:
|
|
1107
|
+
"""Return the composite's output schema (wired to the bridge)."""
|
|
1108
|
+
return self.process_schema.get('outputs', {})
|
|
1109
|
+
|
|
1110
|
+
def read_bridge(self, state: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
1111
|
+
"""
|
|
1112
|
+
View the external bridge output ports using the current or provided state.
|
|
1113
|
+
|
|
1114
|
+
This method uses the composite's interface and bridge configuration to extract
|
|
1115
|
+
the substate that corresponds to external output ports.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
state: Optional state dictionary. If not provided, uses `self.state`.
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
A dictionary of output values from the bridge view, or None if not found.
|
|
1122
|
+
"""
|
|
1123
|
+
state = state or self.state
|
|
1124
|
+
|
|
1125
|
+
bridge_view = self.core.view(
|
|
1126
|
+
self.interface()['outputs'],
|
|
1127
|
+
self.bridge['outputs'],
|
|
1128
|
+
(),
|
|
1129
|
+
top_schema=self.composition,
|
|
1130
|
+
top_state=state
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
return bridge_view
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
# =======================
|
|
1137
|
+
# Step Network Management
|
|
1138
|
+
# =======================
|
|
1139
|
+
|
|
1140
|
+
def build_step_network(self) -> None:
|
|
1141
|
+
"""
|
|
1142
|
+
Construct the internal dependency network for all registered steps.
|
|
1143
|
+
|
|
1144
|
+
This includes:
|
|
1145
|
+
- Finding trigger paths for each step based on their input wires
|
|
1146
|
+
- Registering wildcard triggers (e.g. `*`-based patterns)
|
|
1147
|
+
- Building a graph of data dependencies between steps
|
|
1148
|
+
- Initializing tracking structures for trigger state and pending steps
|
|
1149
|
+
- Populating the `self.to_run` queue with steps ready to execute
|
|
1150
|
+
"""
|
|
1151
|
+
self.step_triggers: Dict[Tuple[str, ...], List[Union[str, Tuple[str, ...]]]] = {}
|
|
1152
|
+
self.star_triggers: Dict[Tuple[str, ...], List[Union[str, Tuple[str, ...]]]] = {}
|
|
1153
|
+
|
|
1154
|
+
# Collect triggers for each step's input schema
|
|
1155
|
+
for step_path, step in self.step_paths.items():
|
|
1156
|
+
step_triggers = find_step_triggers(step_path, step)
|
|
1157
|
+
self.step_triggers = merge_collections(self.step_triggers, step_triggers)
|
|
1158
|
+
|
|
1159
|
+
# Identify wildcard-based triggers (those containing '*')
|
|
1160
|
+
for trigger_path in self.step_triggers:
|
|
1161
|
+
if "*" in trigger_path:
|
|
1162
|
+
self.star_triggers[trigger_path] = self.step_triggers[trigger_path]
|
|
1163
|
+
|
|
1164
|
+
# Track which steps have already executed in the current cycle
|
|
1165
|
+
self.steps_run: Set[Union[str, Tuple[str, ...]]] = set()
|
|
1166
|
+
|
|
1167
|
+
# Build the step execution dependency graph
|
|
1168
|
+
self.step_dependencies, self.node_dependencies = build_step_network(self.step_paths)
|
|
1169
|
+
|
|
1170
|
+
# Initialize trigger fulfillment state and steps remaining
|
|
1171
|
+
self.reset_step_state(self.step_paths)
|
|
1172
|
+
|
|
1173
|
+
# Compute the initial set of runnable steps
|
|
1174
|
+
self.to_run = self.cycle_step_state()
|
|
1175
|
+
|
|
1176
|
+
self.clean_front(self.state)
|
|
1177
|
+
|
|
1178
|
+
def reset_step_state(
|
|
1179
|
+
self,
|
|
1180
|
+
step_paths: Dict[Union[str, Tuple[str, ...]], Any]
|
|
1181
|
+
) -> None:
|
|
1182
|
+
"""
|
|
1183
|
+
Reset the trigger tracking state for a given set of steps.
|
|
1184
|
+
|
|
1185
|
+
Args:
|
|
1186
|
+
step_paths: A dictionary of step paths (as keys).
|
|
1187
|
+
"""
|
|
1188
|
+
# Start with a fresh trigger state from the dependency graph
|
|
1189
|
+
self.trigger_state = build_trigger_state(self.node_dependencies)
|
|
1190
|
+
|
|
1191
|
+
# Track steps still waiting to be executed in this cycle
|
|
1192
|
+
self.steps_remaining: Set[Union[str, Tuple[str, ...]]] = set(step_paths)
|
|
1193
|
+
|
|
1194
|
+
def cycle_step_state(self) -> List[Union[str, Tuple[str, ...]]]:
|
|
1195
|
+
"""
|
|
1196
|
+
Evaluate the current trigger state and determine which steps can run.
|
|
1197
|
+
|
|
1198
|
+
Returns:
|
|
1199
|
+
A list of step paths that are ready to be invoked in this cycle.
|
|
1200
|
+
"""
|
|
1201
|
+
to_run, self.steps_remaining, self.trigger_state = determine_steps(
|
|
1202
|
+
self.step_dependencies,
|
|
1203
|
+
self.steps_remaining,
|
|
1204
|
+
self.trigger_state
|
|
1205
|
+
)
|
|
1206
|
+
return to_run
|
|
1207
|
+
|
|
1208
|
+
def trigger_steps(self, update_paths: List[Tuple[str, ...]]) -> None:
|
|
1209
|
+
"""
|
|
1210
|
+
Determine and run step processes triggered by recent state updates.
|
|
1211
|
+
|
|
1212
|
+
Args:
|
|
1213
|
+
update_paths: Paths in the state that were updated.
|
|
1214
|
+
"""
|
|
1215
|
+
steps_to_run: List[Tuple[str, ...]] = []
|
|
1216
|
+
|
|
1217
|
+
for update_path in update_paths:
|
|
1218
|
+
for path in explode_path(update_path):
|
|
1219
|
+
# Check direct trigger matches
|
|
1220
|
+
step_paths = self.step_triggers.get(path, [])
|
|
1221
|
+
|
|
1222
|
+
# Also handle wildcard (*) path matches
|
|
1223
|
+
if self.star_triggers:
|
|
1224
|
+
for star_trigger, star_steps in self.star_triggers.items():
|
|
1225
|
+
if match_star_path(path, star_trigger):
|
|
1226
|
+
step_paths.extend(star_steps)
|
|
1227
|
+
|
|
1228
|
+
# Add unrun steps to the execution queue
|
|
1229
|
+
for step_path in step_paths:
|
|
1230
|
+
if step_path is not None and step_path not in self.steps_run:
|
|
1231
|
+
steps_to_run.append(step_path)
|
|
1232
|
+
self.steps_run.add(step_path)
|
|
1233
|
+
|
|
1234
|
+
# Identify downstream steps dependent on triggered ones
|
|
1235
|
+
steps_to_run = find_downstream(
|
|
1236
|
+
self.step_dependencies,
|
|
1237
|
+
self.node_dependencies,
|
|
1238
|
+
steps_to_run
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
self.reset_step_state(steps_to_run)
|
|
1242
|
+
to_run = self.cycle_step_state()
|
|
1243
|
+
self.run_steps(to_run)
|
|
1244
|
+
|
|
1245
|
+
def run_steps(self, step_paths: List[Tuple[str, ...]]) -> None:
|
|
1246
|
+
"""
|
|
1247
|
+
Execute a list of step processes, apply their updates, and handle cascading triggers.
|
|
1248
|
+
|
|
1249
|
+
Args:
|
|
1250
|
+
step_paths: A list of step path tuples to run.
|
|
1251
|
+
"""
|
|
1252
|
+
if step_paths:
|
|
1253
|
+
updates = []
|
|
1254
|
+
|
|
1255
|
+
for step_path in step_paths:
|
|
1256
|
+
step = get_path(self.state, step_path)
|
|
1257
|
+
state = self.core.view_edge(
|
|
1258
|
+
self.composition, self.state, step_path, 'inputs'
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
# Steps are always invoked with interval = -1.0
|
|
1262
|
+
step_update = self.process_update(
|
|
1263
|
+
step_path, step, state, -1.0, 'outputs'
|
|
1264
|
+
)
|
|
1265
|
+
updates.append(step_update)
|
|
1266
|
+
|
|
1267
|
+
update_paths = self.apply_updates(updates)
|
|
1268
|
+
self.expire_process_paths(update_paths)
|
|
1269
|
+
|
|
1270
|
+
to_run = self.cycle_step_state()
|
|
1271
|
+
|
|
1272
|
+
if to_run:
|
|
1273
|
+
self.run_steps(to_run)
|
|
1274
|
+
else:
|
|
1275
|
+
self.steps_run = set()
|
|
1276
|
+
else:
|
|
1277
|
+
self.steps_run = set()
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
# ====================
|
|
1281
|
+
# Simulation Execution
|
|
1282
|
+
# ====================
|
|
1283
|
+
|
|
1284
|
+
def run(self, interval: float, force_complete: bool = False) -> None:
|
|
1285
|
+
"""
|
|
1286
|
+
Advance simulation by running processes until a target time is reached.
|
|
1287
|
+
|
|
1288
|
+
The method loops through all registered processes and executes their updates
|
|
1289
|
+
incrementally based on their configured interval. Updates are applied and
|
|
1290
|
+
steps are triggered accordingly.
|
|
1291
|
+
|
|
1292
|
+
Args:
|
|
1293
|
+
interval: Time interval to simulate.
|
|
1294
|
+
force_complete: If True, forces all processes to reach the end time.
|
|
1295
|
+
"""
|
|
1296
|
+
end_time = self.state['global_time'] + interval
|
|
1297
|
+
|
|
1298
|
+
while self.state['global_time'] < end_time or force_complete:
|
|
1299
|
+
full_step = math.inf
|
|
1300
|
+
|
|
1301
|
+
# Run each process and compute the minimum time step that advances simulation
|
|
1302
|
+
for path in self.process_paths:
|
|
1303
|
+
process = get_path(self.state, path)
|
|
1304
|
+
full_step = self.run_process(
|
|
1305
|
+
path, process, end_time, full_step, force_complete)
|
|
1306
|
+
|
|
1307
|
+
if full_step == math.inf:
|
|
1308
|
+
# No process ran — jump to the next scheduled process time
|
|
1309
|
+
next_event = end_time
|
|
1310
|
+
for path in self.front.keys():
|
|
1311
|
+
if self.front[path]['time'] < next_event:
|
|
1312
|
+
next_event = self.front[path]['time']
|
|
1313
|
+
self.state['global_time'] = next_event
|
|
1314
|
+
|
|
1315
|
+
elif self.state['global_time'] + full_step <= end_time:
|
|
1316
|
+
# At least one process ran — advance time and apply its update
|
|
1317
|
+
self.state['global_time'] += full_step
|
|
1318
|
+
updates = []
|
|
1319
|
+
paths = []
|
|
1320
|
+
|
|
1321
|
+
for path, advance in self.front.items():
|
|
1322
|
+
if advance['time'] <= self.state['global_time'] and advance['update']:
|
|
1323
|
+
updates.append(advance['update'])
|
|
1324
|
+
advance['update'] = {}
|
|
1325
|
+
paths.append(path)
|
|
1326
|
+
|
|
1327
|
+
update_paths = self.apply_updates(updates)
|
|
1328
|
+
self.expire_process_paths(update_paths)
|
|
1329
|
+
self.trigger_steps(update_paths)
|
|
1330
|
+
|
|
1331
|
+
else:
|
|
1332
|
+
# All remaining process events are beyond end_time
|
|
1333
|
+
self.state['global_time'] = end_time
|
|
1334
|
+
|
|
1335
|
+
if force_complete and self.state['global_time'] == end_time:
|
|
1336
|
+
force_complete = False
|
|
1337
|
+
|
|
1338
|
+
def run_process(
|
|
1339
|
+
self,
|
|
1340
|
+
path: Union[str, Tuple[str, ...]],
|
|
1341
|
+
process: Dict[str, Any],
|
|
1342
|
+
end_time: float,
|
|
1343
|
+
full_step: float,
|
|
1344
|
+
force_complete: bool
|
|
1345
|
+
) -> float:
|
|
1346
|
+
"""
|
|
1347
|
+
Run a process at a given path and determine its next scheduled time.
|
|
1348
|
+
|
|
1349
|
+
This updates the `self.front` to store when the process is due next,
|
|
1350
|
+
and captures its update as a deferred computation.
|
|
1351
|
+
|
|
1352
|
+
Args:
|
|
1353
|
+
path: The path to the process in the state/composition tree.
|
|
1354
|
+
process: The dictionary representing the process (must contain 'interval').
|
|
1355
|
+
end_time: The simulation time to run up to.
|
|
1356
|
+
full_step: The current smallest time step among all processes.
|
|
1357
|
+
force_complete: If True, forces the process to reach `end_time` exactly.
|
|
1358
|
+
|
|
1359
|
+
Returns:
|
|
1360
|
+
The updated `full_step`, i.e., the shortest remaining time across all processes.
|
|
1361
|
+
"""
|
|
1362
|
+
# Initialize the front buffer for this process if missing
|
|
1363
|
+
if path not in self.front:
|
|
1364
|
+
self.front[path] = empty_front(self.state['global_time'])
|
|
1365
|
+
|
|
1366
|
+
process_time = self.front[path]['time']
|
|
1367
|
+
|
|
1368
|
+
if process_time <= self.state['global_time']:
|
|
1369
|
+
# Use future state if already scheduled and saved
|
|
1370
|
+
if 'future' in self.front[path]:
|
|
1371
|
+
future_front = self.front[path].pop('future')
|
|
1372
|
+
process_interval = future_front['interval']
|
|
1373
|
+
state = future_front['state']
|
|
1374
|
+
else:
|
|
1375
|
+
# Otherwise, slice the current state for the process
|
|
1376
|
+
state = self.core.view_edge(self.composition, self.state, path)
|
|
1377
|
+
process_interval = process['interval']
|
|
1378
|
+
|
|
1379
|
+
# Determine the target time for the next update
|
|
1380
|
+
future = (
|
|
1381
|
+
min(process_time + process_interval, end_time)
|
|
1382
|
+
if force_complete
|
|
1383
|
+
else process_time + process_interval
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
# Apply rounding if global time precision is set
|
|
1387
|
+
if self.global_time_precision:
|
|
1388
|
+
future = round(future, self.global_time_precision)
|
|
1389
|
+
|
|
1390
|
+
# Compute how long this process would advance
|
|
1391
|
+
interval = future - self.state['global_time']
|
|
1392
|
+
if interval < full_step:
|
|
1393
|
+
full_step = interval
|
|
1394
|
+
|
|
1395
|
+
# Only proceed if the next step occurs within the target range
|
|
1396
|
+
if future <= end_time:
|
|
1397
|
+
update = self.process_update(path, process, state, process_interval)
|
|
1398
|
+
|
|
1399
|
+
# Store the update to apply when simulation reaches `future` time
|
|
1400
|
+
self.front[path]['time'] = future
|
|
1401
|
+
self.front[path]['update'] = update
|
|
1402
|
+
|
|
1403
|
+
else:
|
|
1404
|
+
# This process is scheduled in the future — ensure we don't skip ahead
|
|
1405
|
+
process_delay = process_time - self.state['global_time']
|
|
1406
|
+
if process_delay < full_step:
|
|
1407
|
+
full_step = process_delay
|
|
1408
|
+
|
|
1409
|
+
return full_step
|
|
1410
|
+
|
|
1411
|
+
def process_update(
|
|
1412
|
+
self,
|
|
1413
|
+
path: Union[str, Tuple[str, ...]],
|
|
1414
|
+
process: Dict[str, Any],
|
|
1415
|
+
states: Dict[str, Any],
|
|
1416
|
+
interval: float,
|
|
1417
|
+
ports_key: str = 'outputs'
|
|
1418
|
+
) -> Defer:
|
|
1419
|
+
"""
|
|
1420
|
+
Start generating a process's update and wrap it in a deferred transformation.
|
|
1421
|
+
|
|
1422
|
+
This is similar to invoking a process directly, but it delays transformation
|
|
1423
|
+
into absolute state terms until `.get()` is called on the returned `Defer` object.
|
|
1424
|
+
|
|
1425
|
+
Args:
|
|
1426
|
+
path: The path to the process in the state/composition tree.
|
|
1427
|
+
process: The dictionary representing the process instance (must include 'instance').
|
|
1428
|
+
states: The current state values at the process’s ports.
|
|
1429
|
+
interval: The time interval to simulate.
|
|
1430
|
+
ports_key: Which port ('inputs' or 'outputs') to use when projecting the update.
|
|
1431
|
+
|
|
1432
|
+
Returns:
|
|
1433
|
+
A `Defer` object that, when resolved, transforms the update to absolute paths.
|
|
1434
|
+
"""
|
|
1435
|
+
# Strip schema-specific metadata from the state
|
|
1436
|
+
clean_state = strip_schema_keys(states)
|
|
1437
|
+
|
|
1438
|
+
# Invoke the process and retrieve a wrapped SyncUpdate object
|
|
1439
|
+
update = process['instance'].invoke(clean_state, interval)
|
|
1440
|
+
|
|
1441
|
+
# This nested function projects the update into the global state at the given path
|
|
1442
|
+
def defer_project(update_result: Any, args: Tuple[Any, Any, Union[str, Tuple[str, ...]]]) -> Any:
|
|
1443
|
+
schema, state, process_path = args
|
|
1444
|
+
return self.core.project_edge(schema, state, process_path, update_result, ports_key)
|
|
1445
|
+
|
|
1446
|
+
# Return a deferred object that will project the update when requested
|
|
1447
|
+
return Defer(update, defer_project, (self.composition, self.state, path))
|
|
1448
|
+
|
|
1449
|
+
def apply_updates(self, updates: List["Defer"]) -> List[Union[str, Tuple[str, ...]]]:
|
|
1450
|
+
"""
|
|
1451
|
+
Apply a series of deferred updates and record the resulting bridge outputs.
|
|
1452
|
+
|
|
1453
|
+
For each update in the list, the deferred `.get()` method is called, which
|
|
1454
|
+
may return a single update or a list of updates. Each is then applied to the
|
|
1455
|
+
composite's state, and corresponding bridge outputs are captured.
|
|
1456
|
+
|
|
1457
|
+
Args:
|
|
1458
|
+
updates: A list of `Defer` objects representing delayed update functions.
|
|
1459
|
+
|
|
1460
|
+
Returns:
|
|
1461
|
+
A list of update paths (used to determine which processes to refresh).
|
|
1462
|
+
"""
|
|
1463
|
+
update_paths = []
|
|
1464
|
+
|
|
1465
|
+
for defer in updates:
|
|
1466
|
+
# Resolve deferred computation to get update(s)
|
|
1467
|
+
series = defer.get()
|
|
1468
|
+
if series is None:
|
|
1469
|
+
continue
|
|
1470
|
+
if not isinstance(series, list):
|
|
1471
|
+
series = [series]
|
|
1472
|
+
|
|
1473
|
+
for update in series:
|
|
1474
|
+
# if update and isinstance(update, dict) and 'environment' in update and update['environment'] and isinstance(update['environment'], dict) and '_react' in update['environment']:
|
|
1475
|
+
# import ipdb; ipdb.set_trace()
|
|
1476
|
+
|
|
1477
|
+
# Extract all hierarchical paths touched by this update
|
|
1478
|
+
paths = hierarchy_depth(update)
|
|
1479
|
+
update_paths.extend(paths.keys())
|
|
1480
|
+
|
|
1481
|
+
# Apply update directly to the internal state
|
|
1482
|
+
self.state = self.core.apply_update(self.composition, self.state, update)
|
|
1483
|
+
|
|
1484
|
+
# Read updated bridge outputs, if available
|
|
1485
|
+
bridge_update = self.read_bridge(update)
|
|
1486
|
+
if bridge_update:
|
|
1487
|
+
self.bridge_updates.append(bridge_update)
|
|
1488
|
+
|
|
1489
|
+
# Refresh process and step instance paths
|
|
1490
|
+
self.find_instance_paths(self.state)
|
|
1491
|
+
|
|
1492
|
+
return update_paths
|
|
1493
|
+
|
|
1494
|
+
def expire_process_paths(self, update_paths: List[Union[str, Tuple[str, ...]]]) -> None:
|
|
1495
|
+
"""
|
|
1496
|
+
Invalidate and refresh process paths if affected by recent updates.
|
|
1497
|
+
|
|
1498
|
+
This is used to ensure that processes are rediscovered if a state update
|
|
1499
|
+
altered a region where a process instance may be added, removed, or replaced.
|
|
1500
|
+
|
|
1501
|
+
Args:
|
|
1502
|
+
update_paths: A list of hierarchical paths that were modified.
|
|
1503
|
+
"""
|
|
1504
|
+
for update_path in update_paths:
|
|
1505
|
+
for process_path in self.process_paths.copy():
|
|
1506
|
+
# Match if update path completely overlaps the process path prefix
|
|
1507
|
+
updated = all(update == process for update, process in zip(update_path, process_path))
|
|
1508
|
+
if updated:
|
|
1509
|
+
self.find_instance_paths(self.state)
|
|
1510
|
+
return # Exit early after one match, as paths are re-evaluated
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
# ====================
|
|
1514
|
+
# Update Integration
|
|
1515
|
+
# ====================
|
|
1516
|
+
# This section handles how updates from processes and steps are applied to
|
|
1517
|
+
# the global state, how downstream effects are triggered, and how bridge
|
|
1518
|
+
# outputs are collected.
|
|
1519
|
+
|
|
1520
|
+
def update(self, state: Dict[str, Any], interval: float) -> List[Dict[str, Any]]:
|
|
1521
|
+
"""
|
|
1522
|
+
Project input state, run the simulation interval, and return bridge updates.
|
|
1523
|
+
|
|
1524
|
+
This is the main entry point for executing a time step of the composite.
|
|
1525
|
+
It performs:
|
|
1526
|
+
- Input projection using the bridge schema
|
|
1527
|
+
- Merging projected input into state
|
|
1528
|
+
- Executing processes for the given time interval
|
|
1529
|
+
- Returning updates for the bridge output
|
|
1530
|
+
|
|
1531
|
+
Args:
|
|
1532
|
+
state: Input state to project into the composite.
|
|
1533
|
+
interval: Time interval to simulate.
|
|
1534
|
+
|
|
1535
|
+
Returns:
|
|
1536
|
+
A list of updates generated for the bridge outputs.
|
|
1537
|
+
"""
|
|
1538
|
+
projection = self.core.project(
|
|
1539
|
+
self.interface()['inputs'],
|
|
1540
|
+
self.bridge['inputs'],
|
|
1541
|
+
[],
|
|
1542
|
+
state
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
self.merge({}, projection)
|
|
1546
|
+
self.run(interval)
|
|
1547
|
+
|
|
1548
|
+
updates = self.bridge_updates
|
|
1549
|
+
self.bridge_updates = []
|
|
1550
|
+
|
|
1551
|
+
return updates
|