python-drs 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.
drs/module.py ADDED
@@ -0,0 +1,497 @@
1
+ import math
2
+ from typing import Iterator, Any, Optional
3
+ from .variables import Variable, Level, Timer
4
+ from ._execution_context import ExecutionContext
5
+ from .data_source import DataPoint
6
+ from .flow import Flow
7
+
8
+
9
+ class Module:
10
+ """Base class for all DRS models and sub-components.
11
+
12
+ Modules are the fundamental building blocks of a simulation. They automatically
13
+ register any `Variable` or `Module` assigned as an attribute, mimicking the
14
+ behavior of PyTorch's `nn.Module`.
15
+
16
+ Attributes:
17
+ parent (Optional[Module]): The parent module that owns this module.
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ """Initialize the module and its internal registries."""
22
+ self._variables = {}
23
+ self._modules = {}
24
+ self.parent = None
25
+ self._post_step_hooks = []
26
+ self._dependencies = []
27
+ self._dep_seen = set()
28
+ self._flow_dependencies = []
29
+ self._flow_dep_seen = set()
30
+ self._data_dependencies = []
31
+ self._data_dep_seen = set()
32
+
33
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
34
+ """
35
+ Execute the forward pass while managing the ExecutionContext.
36
+
37
+ This method acts as a wrapper around `forward()`. It pushes this module
38
+ onto the execution stack, validates that inter-module communication uses
39
+ only `drs.Flow` or `drs.DataPoint`, records dependency edges, and then
40
+ pops the execution stack.
41
+
42
+ Args:
43
+ *args: Positional arguments passed to `forward()`.
44
+ **kwargs: Keyword arguments passed to `forward()`.
45
+
46
+ Returns:
47
+ Any: The result of the `forward()` pass.
48
+
49
+ Raises:
50
+ RuntimeError: If invalid types are passed or returned.
51
+ """
52
+ caller = ExecutionContext.get_current()
53
+ ExecutionContext.push(self)
54
+ try:
55
+ if caller is not None and caller is not self:
56
+
57
+ def validate_drs_type(arg, arg_name):
58
+ if arg is None:
59
+ return
60
+ if isinstance(arg, (tuple, list)):
61
+ for item in arg:
62
+ validate_drs_type(item, arg_name)
63
+ return
64
+
65
+ if not isinstance(arg, (Flow, Variable, DataPoint)):
66
+ raise RuntimeError(
67
+ f"Hidden Dependency Error: '{type(caller).__name__}' passed an untracked type "
68
+ f"'{type(arg).__name__}' to '{type(self).__name__}' for {arg_name}. "
69
+ f"Inter-module arguments MUST be drs.Flow (physics) or drs.Variable (control)."
70
+ )
71
+
72
+ for i, arg in enumerate(args):
73
+ validate_drs_type(arg, f"positional arg {i}")
74
+ for key, val in kwargs.items():
75
+ validate_drs_type(val, f"keyword arg '{key}'")
76
+
77
+ for arg in args:
78
+ if isinstance(arg, Flow) and arg._source is not None:
79
+ ExecutionContext.record_flow_edge(arg._source, self)
80
+ self._record_flow_edge(arg._source)
81
+ elif isinstance(arg, DataPoint) and arg._source is not None:
82
+ self._record_data_edge(arg._source)
83
+ for v in kwargs.values():
84
+ if isinstance(v, Flow) and v._source is not None:
85
+ ExecutionContext.record_flow_edge(v._source, self)
86
+ self._record_flow_edge(v._source)
87
+ elif isinstance(v, DataPoint) and v._source is not None:
88
+ self._record_data_edge(v._source)
89
+
90
+ result = self.forward(*args, **kwargs)
91
+
92
+ if isinstance(result, tuple):
93
+ for res in result:
94
+ if not isinstance(res, (Flow, DataPoint)):
95
+ raise RuntimeError(
96
+ f"Tuple returned from '{type(self).__name__}.forward()' "
97
+ f"must contain only drs.Flow or drs.DataPoint objects."
98
+ )
99
+ res._source = self
100
+ return result
101
+
102
+ if isinstance(self, DataSource):
103
+ if result is None:
104
+ raise RuntimeError(
105
+ f"'{type(self).__name__}.forward()' must return drs.DataPoint or drs.Flow. "
106
+ f"DataSource subclasses cannot return None."
107
+ )
108
+ if not isinstance(result, (Flow, DataPoint)):
109
+ raise RuntimeError(
110
+ f"'{type(self).__name__}.forward()' returned "
111
+ f"'{type(result).__name__}', not drs.Flow or drs.DataPoint."
112
+ )
113
+
114
+ if result is not None and not isinstance(result, (Flow, DataPoint)):
115
+ raise RuntimeError(
116
+ f"'{type(self).__name__}.forward()' returned "
117
+ f"'{type(result).__name__}', not a drs.Flow or drs.DataPoint. "
118
+ f"Inter-module communication must use drs.Flow or drs.DataPoint."
119
+ )
120
+
121
+ if isinstance(result, (Flow, DataPoint)):
122
+ result._source = self
123
+
124
+ return result
125
+ finally:
126
+ ExecutionContext.pop()
127
+
128
+ def forward(self, *args: Any, **kwargs: Any) -> Any:
129
+ """
130
+ Define the physics and logic of the module.
131
+
132
+ This method must be implemented by all subclasses. It is called on every
133
+ time step to update rates and determine flows.
134
+
135
+ Args:
136
+ *args: Positional arguments.
137
+ **kwargs: Keyword arguments.
138
+
139
+ Returns:
140
+ Any: Typically a `drs.Flow`, `drs.DataPoint`, or a tuple of them.
141
+ """
142
+ raise NotImplementedError("Module subclasses must implement forward()")
143
+
144
+ # TODO: add @property def current_time(self) -> float
145
+ # This would implicitly grab ExecutionContext.get_engine().current_time.
146
+ # Benefit: Allows modules/controllers to read simulation time natively
147
+ # (e.g. `if self.current_time > 10.0:`) without needing to explicitly instantiate and track a Timer variable.
148
+
149
+ def __setattr__(self, name: str, value: Any) -> None:
150
+ if name.startswith("_"):
151
+ super().__setattr__(name, value)
152
+ return
153
+
154
+ if hasattr(self, "_variables"):
155
+ self._variables.pop(name, None)
156
+ if hasattr(self, "_modules") and name != "parent":
157
+ self._modules.pop(name, None)
158
+
159
+ if isinstance(value, Variable):
160
+ if not hasattr(self, "_variables"):
161
+ raise AttributeError(
162
+ "Cannot assign variable before Module.__init__() call"
163
+ )
164
+ self._variables[name] = value
165
+ value._owner = self
166
+ elif isinstance(value, Module) and name != "parent":
167
+ if not hasattr(self, "_modules"):
168
+ raise AttributeError(
169
+ "Cannot assign module before Module.__init__() call"
170
+ )
171
+ self._modules[name] = value
172
+ if not getattr(value, "parent", None):
173
+ value.parent = self
174
+
175
+ super().__setattr__(name, value)
176
+
177
+ def _record_incoming_edge(self, variable: Variable) -> None:
178
+ """
179
+ [INTERNAL] Record that this module reads 'variable' (owned by another module).
180
+
181
+ Power User Note: Automatically called by the ExecutionContext to build graphs.
182
+ """
183
+ if variable._owner is not None and variable._owner is not self:
184
+ key = (id(variable._owner), id(variable))
185
+ if key not in self._dep_seen:
186
+ self._dep_seen.add(key)
187
+ self._dependencies.append((variable._owner, variable))
188
+
189
+ def _record_flow_edge(self, source_module: "Module") -> None:
190
+ """
191
+ [INTERNAL] Record that this module received a Flow from source_module.
192
+
193
+ Power User Note: Automatically called by the ExecutionContext to build flow graphs.
194
+ """
195
+ if source_module is not None and source_module is not self:
196
+ key = id(source_module)
197
+ if key not in self._flow_dep_seen:
198
+ self._flow_dep_seen.add(key)
199
+ self._flow_dependencies.append(source_module)
200
+
201
+ def _record_data_edge(self, source_module: "Module") -> None:
202
+ """
203
+ [INTERNAL] Record that this module received a DataPoint from source_module.
204
+
205
+ Power User Note: Automatically called by the ExecutionContext to build data graphs.
206
+ """
207
+ if source_module is not None and source_module is not self:
208
+ key = id(source_module)
209
+ if key not in self._data_dep_seen:
210
+ self._data_dep_seen.add(key)
211
+ self._data_dependencies.append(source_module)
212
+
213
+ def variables(self) -> Iterator[Variable]:
214
+ """
215
+ Recursively yield all variables owned by this module and its sub-modules.
216
+
217
+ Returns:
218
+ Iterator[Variable]: An iterator over all unique variables in the hierarchy.
219
+ """
220
+ seen = set()
221
+
222
+ def _get_vars(module):
223
+ for var in module._variables.values():
224
+ var_id = id(var)
225
+ if var_id not in seen:
226
+ seen.add(var_id)
227
+ yield var
228
+ for mod in module._modules.values():
229
+ yield from _get_vars(mod)
230
+
231
+ yield from _get_vars(self)
232
+
233
+ def modules(self) -> Iterator["Module"]:
234
+ """
235
+ Recursively yield this module and all nested sub-modules.
236
+
237
+ Returns:
238
+ Iterator[Module]: An iterator over the module hierarchy.
239
+ """
240
+ for _, module in self.named_modules():
241
+ yield module
242
+
243
+ def named_modules(self, prefix: str = "") -> Iterator[tuple[str, "Module"]]:
244
+ """
245
+ Recursively yield `(path, module)` pairs using PyTorch-style names.
246
+
247
+ Args:
248
+ prefix (str): The prefix to prepend to the paths.
249
+
250
+ Returns:
251
+ Iterator[tuple[str, Module]]: An iterator of `(path, module)` tuples.
252
+ """
253
+ seen = set()
254
+
255
+ def _get_modules(module, module_prefix):
256
+ module_id = id(module)
257
+ if module_id in seen:
258
+ return
259
+ seen.add(module_id)
260
+ yield module_prefix, module
261
+
262
+ for name, sub_mod in module._modules.items():
263
+ sub_prefix = name if not module_prefix else f"{module_prefix}.{name}"
264
+ yield from _get_modules(sub_mod, sub_prefix)
265
+
266
+ yield from _get_modules(self, prefix)
267
+
268
+ def _zero_rates(self) -> None:
269
+ """
270
+ [INTERNAL] Zero out rates and remove thresholds for all Levels before the next rate update.
271
+ """
272
+ for var in self.variables():
273
+ if isinstance(var, Level):
274
+ var._rate = 0.0
275
+ var.upper_threshold = math.inf
276
+ var.lower_threshold = -math.inf
277
+
278
+ def initialize_state(self) -> None:
279
+ """
280
+ Override this to set up initial state before the simulation starts.
281
+
282
+ This is called once by the engine before the first time step.
283
+ """
284
+ pass
285
+
286
+ def is_terminating_condition_met(self) -> bool:
287
+ """
288
+ Override this to define custom stopping conditions.
289
+
290
+ Returns:
291
+ bool: True if the simulation should stop, False otherwise.
292
+ """
293
+ return False
294
+
295
+ def state_dict(self, prefix: str = "") -> dict[str, Any]:
296
+ """
297
+ Returns a dictionary containing the entire state of the module.
298
+ Keys are dotted paths to variable values (e.g., 'submodule.variable.value').
299
+ """
300
+ state = {}
301
+ for name, var in self._variables.items():
302
+ key_base = f"{prefix}.{name}" if prefix else name
303
+ state[f"{key_base}.value"] = var.value
304
+
305
+ for name, module in self._modules.items():
306
+ module_prefix = f"{prefix}.{name}" if prefix else name
307
+ state.update(module.state_dict(prefix=module_prefix))
308
+
309
+ return state
310
+
311
+ def load_state_dict(self, state_dict: dict[str, Any]) -> None:
312
+ """
313
+ Copies state from state_dict into this module and its descendants.
314
+ """
315
+ for key, value in state_dict.items():
316
+ if not key.endswith(".value"):
317
+ continue
318
+ key_base = key[:-6] # remove .value
319
+ parts = key_base.split(".")
320
+ var_name = parts[-1]
321
+ mod_path = parts[:-1]
322
+
323
+ # Navigate to the correct module
324
+ current_mod = self
325
+ try:
326
+ for part in mod_path:
327
+ current_mod = current_mod._modules[part]
328
+ current_mod._variables[var_name].value = value
329
+ except KeyError:
330
+ import logging
331
+
332
+ logging.getLogger(__name__).warning(
333
+ f"Key '{key}' found in state_dict but not in module hierarchy."
334
+ )
335
+
336
+ def to_dict(self, root: Optional["Module"] = None) -> dict[str, Any]:
337
+ """
338
+ Returns a structural JSON-serializable representation of the module architecture.
339
+ """
340
+ from .variables import Level, Expression, serialize_val
341
+
342
+ if root is None:
343
+ root = self
344
+
345
+ # TODO: Why is expresison not included in serialize_val?
346
+ def _to_dict_serialize_val(val: Any) -> Any:
347
+ if isinstance(val, Expression):
348
+ return {"equation": val.get_equation()}
349
+ return serialize_val(val)
350
+
351
+ def get_module_path(rt: Module, target: Module) -> str:
352
+ if target is rt:
353
+ return ""
354
+ for path, mod in rt.named_modules():
355
+ if mod is target:
356
+ return path
357
+ return getattr(target, "name", type(target).__name__)
358
+
359
+ children = {}
360
+ for name, mod in self._modules.items():
361
+ children[name] = mod.to_dict(root)
362
+
363
+ variables = {}
364
+ for name, var in self._variables.items():
365
+ var_info = {
366
+ "class": type(var).__name__,
367
+ "value": _to_dict_serialize_val(var._value),
368
+ }
369
+ if isinstance(var, Level):
370
+ var_info["rate"] = _to_dict_serialize_val(var._rate)
371
+ var_info["lower_threshold"] = _to_dict_serialize_val(
372
+ var.lower_threshold
373
+ )
374
+ var_info["upper_threshold"] = _to_dict_serialize_val(
375
+ var.upper_threshold
376
+ )
377
+ variables[name] = var_info
378
+
379
+ layout = getattr(self, "layout", getattr(self, "metadata", {}))
380
+ if not isinstance(layout, dict):
381
+ layout = {"value": str(layout)}
382
+
383
+ attributes = {}
384
+ import json
385
+ from .variables import Variable
386
+
387
+ # Exclude reserved framework attributes
388
+ RESERVED_KEYS = {
389
+ "parent",
390
+ "config",
391
+ "telemetry",
392
+ "layout",
393
+ "global_time",
394
+ }
395
+
396
+ for k, v in self.__dict__.items():
397
+ if k.startswith("_"):
398
+ continue
399
+ if k in RESERVED_KEYS:
400
+ continue
401
+ if isinstance(v, (Module, Variable)):
402
+ continue
403
+ if isinstance(v, (int, float, str, bool, list, dict)) or v is None:
404
+ try:
405
+ json.dumps(v)
406
+ attributes[k] = v
407
+ except Exception:
408
+ pass
409
+
410
+ flow_inputs = []
411
+ for src in self._flow_dependencies:
412
+ flow_inputs.append(get_module_path(root, src))
413
+
414
+ data_inputs = []
415
+ for src in self._data_dependencies:
416
+ data_inputs.append(get_module_path(root, src))
417
+
418
+ variable_reads = []
419
+ for src_mod, var in self._dependencies:
420
+ variable_reads.append(
421
+ {"module": get_module_path(root, src_mod), "variable": var.name}
422
+ )
423
+
424
+ connections = {
425
+ "flow_inputs": flow_inputs,
426
+ "data_inputs": data_inputs,
427
+ "variable_reads": variable_reads,
428
+ }
429
+
430
+ return {
431
+ "class": type(self).__name__,
432
+ "layout": layout,
433
+ "variables": variables,
434
+ "attributes": attributes,
435
+ "children": children,
436
+ "connections": connections,
437
+ }
438
+
439
+ def register_post_step_hook(self, hook_fn: Any) -> None:
440
+ """Registers a callback to be run after every engine step."""
441
+ self._post_step_hooks.append(hook_fn)
442
+
443
+ def _run_post_step_hooks(self, current_time: float) -> None:
444
+ """
445
+ [INTERNAL] Execute all registered post-step callback functions.
446
+
447
+ Power User Note: Called automatically by the DRSEngine after time integration.
448
+ """
449
+ for hook in self._post_step_hooks:
450
+ hook(current_time)
451
+
452
+ def get_dependency_graph(self) -> list:
453
+ """
454
+ Get all recorded read dependencies from this module and all sub-modules.
455
+
456
+ Returns:
457
+ list[tuple[Module, Variable]]: A list of `(source_module, variable)` pairs
458
+ representing cross-module reads.
459
+ """
460
+ result = []
461
+ for mod in self.modules():
462
+ result.extend(mod._dependencies)
463
+ return result
464
+
465
+
466
+ # NOTE: could remove and use just modules and Flow instead of DataSource and DataPoint. May be better.
467
+ class DataSource(Module):
468
+ """Yields ``DataPoint`` batches one at a time.
469
+
470
+ Subclass and implement ``__next__`` to define the data stream.
471
+ Raise ``StopIteration`` when the stream is exhausted::
472
+
473
+ class MySource(DataSource):
474
+ def __init__(self):
475
+ super().__init__()
476
+ self._data = [DataPoint(x=1), DataPoint(x=2)]
477
+ self._index = 0
478
+
479
+ def __next__(self) -> DataPoint:
480
+ if self._index >= len(self._data):
481
+ raise StopIteration
482
+ point = self._data[self._index]
483
+ self._index += 1
484
+ return point
485
+ """
486
+
487
+ def __init__(self) -> None:
488
+ """Initialize the DataSource."""
489
+ super().__init__()
490
+
491
+ def __iter__(self) -> Iterator[DataPoint]:
492
+ """Return the iterator object itself."""
493
+ return self
494
+
495
+ def __next__(self) -> DataPoint:
496
+ """Yield the next DataPoint in the sequence."""
497
+ raise StopIteration