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/__init__.py +49 -0
- drs/_execution_context.py +89 -0
- drs/callbacks.py +97 -0
- drs/config.py +25 -0
- drs/data_source.py +49 -0
- drs/engine.py +447 -0
- drs/exceptions.py +33 -0
- drs/flow.py +24 -0
- drs/module.py +497 -0
- drs/plot.py +254 -0
- drs/serialize.py +408 -0
- drs/telemetry.py +172 -0
- drs/variables.py +547 -0
- python_drs-0.1.0.dist-info/METADATA +106 -0
- python_drs-0.1.0.dist-info/RECORD +18 -0
- python_drs-0.1.0.dist-info/WHEEL +5 -0
- python_drs-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_drs-0.1.0.dist-info/top_level.txt +1 -0
drs/engine.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import time
|
|
6
|
+
from typing import Tuple, Optional, Any
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from .variables import Variable, Level
|
|
9
|
+
from .module import Module
|
|
10
|
+
from ._execution_context import ExecutionContext
|
|
11
|
+
from .exceptions import DeadlockError, ThresholdConfigurationError
|
|
12
|
+
from .config import EngineConfig
|
|
13
|
+
from .callbacks import Callback, ProgressBarCallback
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SimulationResult:
|
|
20
|
+
"""Encapsulates the final results of a simulation run."""
|
|
21
|
+
|
|
22
|
+
model: Module
|
|
23
|
+
config: Any
|
|
24
|
+
duration: float # wall time
|
|
25
|
+
steps: int # number of engine ticks
|
|
26
|
+
sim_time: float # simulation time reached
|
|
27
|
+
history: Optional["pd.DataFrame"] # telemetry data
|
|
28
|
+
terminated_reason: str # "max_time", "condition_met", "deadlock", etc.
|
|
29
|
+
events: Optional[list] = None # events log
|
|
30
|
+
|
|
31
|
+
def print_event_timeline(self):
|
|
32
|
+
"""Prints the formatted event timeline if events exist."""
|
|
33
|
+
if not self.events:
|
|
34
|
+
print("No events logged.")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
print("\n--- Event Audit Trail ---")
|
|
38
|
+
for e in self.events:
|
|
39
|
+
details_str = ", ".join(f"{k}={v}" for k, v in e.details.items())
|
|
40
|
+
print(f"t={e.time:<6.2f} | {e.event_type:<15} | [{e.source}] {details_str}")
|
|
41
|
+
print("-------------------------\n")
|
|
42
|
+
|
|
43
|
+
def plot(self, *args, **kwargs):
|
|
44
|
+
"""Helper to plot telemetry data using pandas."""
|
|
45
|
+
if self.history is None or self.history.empty:
|
|
46
|
+
logger.warning("No telemetry data to plot.")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
import matplotlib.pyplot as plt
|
|
51
|
+
|
|
52
|
+
ax = self.history.plot(*args, **kwargs)
|
|
53
|
+
plt.show()
|
|
54
|
+
return ax
|
|
55
|
+
except ImportError:
|
|
56
|
+
logger.error("matplotlib is required for plotting.")
|
|
57
|
+
|
|
58
|
+
def summary(self) -> str:
|
|
59
|
+
"""Returns a string summary of the simulation run."""
|
|
60
|
+
lines = [
|
|
61
|
+
f"--- Simulation Summary ---",
|
|
62
|
+
f"Termination Reason : {self.terminated_reason}",
|
|
63
|
+
f"Simulated Time : {self.sim_time:.2f}",
|
|
64
|
+
f"Wall Clock Time : {self.duration:.4f} seconds",
|
|
65
|
+
f"Engine Steps : {self.steps:,}",
|
|
66
|
+
]
|
|
67
|
+
if self.history is not None:
|
|
68
|
+
lines.append(f"Telemetry Records : {len(self.history):,}")
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
def save(self, path: str):
|
|
72
|
+
"""Saves telemetry history to a CSV file."""
|
|
73
|
+
if self.history is not None:
|
|
74
|
+
self.history.to_csv(path, index=False)
|
|
75
|
+
logger.info(f"Saved telemetry to {path}")
|
|
76
|
+
else:
|
|
77
|
+
logger.warning("No telemetry data to save.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DRSEngine:
|
|
81
|
+
"""The runner that manages the external simulation loop.
|
|
82
|
+
|
|
83
|
+
The DRSEngine drives the simulation forward. It evaluates the model to
|
|
84
|
+
determine rates and thresholds, calculates the time until the next event,
|
|
85
|
+
and advances the system state to that precise moment in time.
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
model (Module): The root module of the simulation.
|
|
89
|
+
current_time (float): The current simulation time.
|
|
90
|
+
max_step_size (float): The maximum allowed time step (dt).
|
|
91
|
+
max_deadlock_steps (int): The maximum consecutive zero-time steps allowed.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
model: Module,
|
|
97
|
+
config: Optional[EngineConfig] = None,
|
|
98
|
+
progress_bar: bool = False,
|
|
99
|
+
log_level: Optional[str] = None,
|
|
100
|
+
callbacks: Optional[list[Callback]] = None,
|
|
101
|
+
seed: Optional[int] = None,
|
|
102
|
+
**kwargs,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Initialize the DRS Engine.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
model (Module): The root Module of your simulation.
|
|
109
|
+
config (Optional[EngineConfig]): Configuration for the engine.
|
|
110
|
+
progress_bar (bool): If True, attaches a Rich progress bar callback.
|
|
111
|
+
log_level (Optional[str]): If provided, configures structured logging at this level.
|
|
112
|
+
callbacks (Optional[list[Callback]]): Custom callbacks to attach.
|
|
113
|
+
seed (Optional[int]): If provided, seeds random and numpy.random for determinism.
|
|
114
|
+
**kwargs: Overrides for configuration parameters.
|
|
115
|
+
"""
|
|
116
|
+
self.model = model
|
|
117
|
+
self._seed = seed
|
|
118
|
+
|
|
119
|
+
if log_level:
|
|
120
|
+
logging.basicConfig(level=log_level.upper())
|
|
121
|
+
|
|
122
|
+
self.callbacks = callbacks or []
|
|
123
|
+
if progress_bar:
|
|
124
|
+
self.callbacks.append(ProgressBarCallback())
|
|
125
|
+
|
|
126
|
+
if config is None:
|
|
127
|
+
config = EngineConfig()
|
|
128
|
+
|
|
129
|
+
for k, v in kwargs.items():
|
|
130
|
+
if hasattr(config, k):
|
|
131
|
+
setattr(config, k, v)
|
|
132
|
+
|
|
133
|
+
self.config = config
|
|
134
|
+
self.current_time = 0.0
|
|
135
|
+
self.max_step_size = (
|
|
136
|
+
self.config.max_step_size
|
|
137
|
+
) # TODO: why do we have this? why is it not inf by default?
|
|
138
|
+
self.max_deadlock_steps = self.config.max_deadlock_steps
|
|
139
|
+
self.strict_mode = self.config.strict_mode
|
|
140
|
+
self._orphaned_warned_ids = set()
|
|
141
|
+
self.telemetry = None
|
|
142
|
+
self.step_count = 0
|
|
143
|
+
self._resuming = False
|
|
144
|
+
|
|
145
|
+
def attach_telemetry(self, telemetry: Any) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Attach a Telemetry object to the engine.
|
|
148
|
+
|
|
149
|
+
The engine will automatically trigger snapshots at the end of every time step.
|
|
150
|
+
"""
|
|
151
|
+
self.telemetry = telemetry
|
|
152
|
+
|
|
153
|
+
def save_checkpoint(self, filepath: str) -> None:
|
|
154
|
+
"""Save the full engine and model state to a JSON file."""
|
|
155
|
+
from .serialize import save_checkpoint
|
|
156
|
+
|
|
157
|
+
save_checkpoint(self, filepath)
|
|
158
|
+
|
|
159
|
+
def load_checkpoint(self, filepath: str) -> None:
|
|
160
|
+
"""Load the full engine and model state from a JSON file."""
|
|
161
|
+
from .serialize import load_checkpoint
|
|
162
|
+
|
|
163
|
+
load_checkpoint(self, filepath)
|
|
164
|
+
|
|
165
|
+
def run(self, max_time: float) -> SimulationResult:
|
|
166
|
+
"""
|
|
167
|
+
Execute the main simulation loop.
|
|
168
|
+
|
|
169
|
+
The loop repeatedly zeros rates, calls the model's `forward()` pass to
|
|
170
|
+
evaluate states, calculates the time until the next threshold is hit
|
|
171
|
+
(`dt`), and integrates all variables forward by `dt`.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
max_time (float): The maximum simulation time to run until.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
RuntimeError: If the engine encounters a deadlock (too many consecutive
|
|
178
|
+
zero-time steps).
|
|
179
|
+
ValueError: If the calculated time delta (`dt`) is negative.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
if self._seed is not None:
|
|
183
|
+
random.seed(self._seed)
|
|
184
|
+
try:
|
|
185
|
+
import numpy as np
|
|
186
|
+
|
|
187
|
+
np.random.seed(self._seed)
|
|
188
|
+
except ImportError:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
ExecutionContext.push(self.model)
|
|
192
|
+
ExecutionContext.set_engine(self)
|
|
193
|
+
if not getattr(self, "_resuming", False):
|
|
194
|
+
self.step_count = 0
|
|
195
|
+
self.model.initialize_state()
|
|
196
|
+
else:
|
|
197
|
+
self._resuming = False
|
|
198
|
+
ExecutionContext.pop()
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
self._current_max_time = max_time
|
|
202
|
+
for cb in self.callbacks:
|
|
203
|
+
cb.on_simulation_start(self)
|
|
204
|
+
|
|
205
|
+
self._consecutive_zero_dt_count = 0
|
|
206
|
+
termination_reason = "unknown"
|
|
207
|
+
steps = 0
|
|
208
|
+
start_time = time.time()
|
|
209
|
+
|
|
210
|
+
while True:
|
|
211
|
+
if self.model.is_terminating_condition_met():
|
|
212
|
+
termination_reason = "condition_met"
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
for cb in self.callbacks:
|
|
216
|
+
cb.on_step_start(self)
|
|
217
|
+
|
|
218
|
+
if self.current_time >= max_time:
|
|
219
|
+
termination_reason = "max_time_reached"
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
self._step(max_time)
|
|
223
|
+
steps += 1
|
|
224
|
+
|
|
225
|
+
if self.telemetry:
|
|
226
|
+
self.telemetry.snapshot(self.current_time)
|
|
227
|
+
self.model._run_post_step_hooks(self.current_time)
|
|
228
|
+
finally:
|
|
229
|
+
ExecutionContext.set_engine(None)
|
|
230
|
+
|
|
231
|
+
end_time = time.time()
|
|
232
|
+
df = self.telemetry.to_dataframe() if self.telemetry else None
|
|
233
|
+
|
|
234
|
+
result = SimulationResult(
|
|
235
|
+
model=self.model,
|
|
236
|
+
config=self.config,
|
|
237
|
+
duration=end_time - start_time,
|
|
238
|
+
steps=steps,
|
|
239
|
+
sim_time=self.current_time,
|
|
240
|
+
history=df,
|
|
241
|
+
events=self.telemetry.events if self.telemetry else None,
|
|
242
|
+
terminated_reason=termination_reason,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
for cb in self.callbacks:
|
|
246
|
+
cb.on_complete(self, result)
|
|
247
|
+
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
def _step(self, max_time: float) -> None:
|
|
251
|
+
"""
|
|
252
|
+
[INTERNAL] Perform a single tick of the engine.
|
|
253
|
+
|
|
254
|
+
Evaluates the model, calculates the time until the next event,
|
|
255
|
+
and integrates variables forward.
|
|
256
|
+
"""
|
|
257
|
+
self.model._zero_rates()
|
|
258
|
+
self.model()
|
|
259
|
+
|
|
260
|
+
current_variables = list(self.model.variables())
|
|
261
|
+
self._check_orphaned_thresholds(current_variables)
|
|
262
|
+
|
|
263
|
+
if self.telemetry:
|
|
264
|
+
self.telemetry.snapshot(self.current_time)
|
|
265
|
+
|
|
266
|
+
self.model._run_post_step_hooks(self.current_time)
|
|
267
|
+
|
|
268
|
+
dt, trigger_var, is_upper = self._calculate_min_dt(current_variables)
|
|
269
|
+
|
|
270
|
+
if trigger_var is not None:
|
|
271
|
+
if self.telemetry is not None:
|
|
272
|
+
threshold_hit = (
|
|
273
|
+
trigger_var.upper_threshold
|
|
274
|
+
if is_upper
|
|
275
|
+
else trigger_var.lower_threshold
|
|
276
|
+
)
|
|
277
|
+
self.telemetry.log_event(
|
|
278
|
+
time=self.current_time + dt,
|
|
279
|
+
event_type="THRESHOLD",
|
|
280
|
+
source="DRSEngine",
|
|
281
|
+
details={
|
|
282
|
+
"variable": trigger_var.name,
|
|
283
|
+
"threshold": threshold_hit,
|
|
284
|
+
"rate": trigger_var.rate,
|
|
285
|
+
"direction": "upper" if is_upper else "lower",
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
for cb in self.callbacks:
|
|
289
|
+
cb.on_threshold(self, trigger_var, is_upper)
|
|
290
|
+
|
|
291
|
+
dt = min(dt, self.max_step_size)
|
|
292
|
+
dt = min(dt, max_time - self.current_time)
|
|
293
|
+
|
|
294
|
+
if dt == 0.0:
|
|
295
|
+
self._consecutive_zero_dt_count += 1
|
|
296
|
+
if self._consecutive_zero_dt_count > self.max_deadlock_steps:
|
|
297
|
+
self._handle_deadlock(current_variables, trigger_var)
|
|
298
|
+
else:
|
|
299
|
+
self._consecutive_zero_dt_count = 0
|
|
300
|
+
|
|
301
|
+
if dt < 0:
|
|
302
|
+
raise ValueError("Time delta (dt) cannot be negative.")
|
|
303
|
+
|
|
304
|
+
logger.debug(
|
|
305
|
+
f"Advancing time by {dt:.4f} to {self.current_time + dt:.4f} (Trigger: {trigger_var.name if trigger_var else 'None'})"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
self.current_time += dt
|
|
309
|
+
self.step_count += 1
|
|
310
|
+
for var in current_variables:
|
|
311
|
+
if hasattr(var, "_update"):
|
|
312
|
+
var._update(dt)
|
|
313
|
+
|
|
314
|
+
def _handle_deadlock(
|
|
315
|
+
self, current_variables: list[Variable], trigger_var: Optional[Variable]
|
|
316
|
+
) -> None:
|
|
317
|
+
"""
|
|
318
|
+
[INTERNAL] Handle the case where the engine ping-pongs between states without advancing time.
|
|
319
|
+
"""
|
|
320
|
+
state_dump = "\n--- Engine State at Deadlock ---\n"
|
|
321
|
+
for v in current_variables:
|
|
322
|
+
rate_val = getattr(v, "rate", "N/A")
|
|
323
|
+
lower_val = getattr(v, "lower_threshold", "N/A")
|
|
324
|
+
upper_val = getattr(v, "upper_threshold", "N/A")
|
|
325
|
+
state_dump += f"{v.name}: value={v.value}, rate={rate_val}, bounds=[{lower_val}, {upper_val}]\n"
|
|
326
|
+
|
|
327
|
+
for cb in self.callbacks:
|
|
328
|
+
cb.on_deadlock(self)
|
|
329
|
+
|
|
330
|
+
if self.telemetry is not None:
|
|
331
|
+
self.telemetry.log_event(
|
|
332
|
+
time=self.current_time,
|
|
333
|
+
event_type="DEADLOCK",
|
|
334
|
+
source="DRSEngine",
|
|
335
|
+
details={
|
|
336
|
+
"trigger_var": trigger_var.name if trigger_var else "None",
|
|
337
|
+
"trigger_val": trigger_var.value if trigger_var else "None",
|
|
338
|
+
"trigger_rate": getattr(trigger_var, "rate", "N/A")
|
|
339
|
+
if trigger_var
|
|
340
|
+
else "None",
|
|
341
|
+
},
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
raise DeadlockError(
|
|
345
|
+
f"Maximum consecutive zero-time steps ({self.max_deadlock_steps}) reached. "
|
|
346
|
+
f"The simulation is ping-ponging between states without advancing time. "
|
|
347
|
+
f"Last trigger: '{trigger_var.name if trigger_var else 'None'}' "
|
|
348
|
+
f"(value={trigger_var.value if trigger_var else 'None'}, "
|
|
349
|
+
f"rate={getattr(trigger_var, 'rate', 'N/A') if trigger_var else 'None'}).\n{state_dump}",
|
|
350
|
+
state_dump=state_dump,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def _check_orphaned_thresholds(self, variables: list[Variable]) -> None:
|
|
354
|
+
"""
|
|
355
|
+
[INTERNAL] Warn once per variable about thresholds set but rate=0.
|
|
356
|
+
|
|
357
|
+
Power User Note: This helps catch logic bugs where a state transition
|
|
358
|
+
threshold is set but the state is not actually changing, meaning the
|
|
359
|
+
event will never fire.
|
|
360
|
+
"""
|
|
361
|
+
for var in variables:
|
|
362
|
+
if not isinstance(var, Level):
|
|
363
|
+
continue
|
|
364
|
+
if id(var) in self._orphaned_warned_ids:
|
|
365
|
+
continue
|
|
366
|
+
rate = var._rate
|
|
367
|
+
has_threshold = (
|
|
368
|
+
var.lower_threshold != -math.inf or var.upper_threshold != math.inf
|
|
369
|
+
)
|
|
370
|
+
if has_threshold and rate == 0.0:
|
|
371
|
+
self._orphaned_warned_ids.add(id(var))
|
|
372
|
+
owner_name = type(var._owner).__name__ if var._owner else "unknown"
|
|
373
|
+
msg = (
|
|
374
|
+
f"Orphaned threshold: '{var.name}' (owned by {owner_name}) "
|
|
375
|
+
f"has lower_threshold={var.lower_threshold}, "
|
|
376
|
+
f"upper_threshold={var.upper_threshold} "
|
|
377
|
+
f"but rate=0.0. This threshold will never trigger."
|
|
378
|
+
)
|
|
379
|
+
if self.strict_mode:
|
|
380
|
+
raise ThresholdConfigurationError(msg)
|
|
381
|
+
logger.warning(msg)
|
|
382
|
+
|
|
383
|
+
def _calculate_min_dt(
|
|
384
|
+
self, variables: list[Variable]
|
|
385
|
+
) -> Tuple[float, Optional[Variable], bool]:
|
|
386
|
+
"""
|
|
387
|
+
[INTERNAL] Determine the time step (dt) to the next event/threshold.
|
|
388
|
+
|
|
389
|
+
Power User Note: Evaluates all variables in the system to find the
|
|
390
|
+
closest future threshold hit based on current rates.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
variables (list[Variable]): A list of all variables in the system.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Tuple[float, Optional[Variable], bool]:
|
|
397
|
+
- min_dt: The time until the next event.
|
|
398
|
+
- trigger_var: The variable that will hit its threshold.
|
|
399
|
+
- is_upper: True if hitting upper_threshold, False if lower_threshold.
|
|
400
|
+
"""
|
|
401
|
+
min_dt = math.inf
|
|
402
|
+
trigger_var = None
|
|
403
|
+
is_upper = True
|
|
404
|
+
|
|
405
|
+
for var in variables:
|
|
406
|
+
dt_for_var = math.inf
|
|
407
|
+
var_is_upper = True
|
|
408
|
+
|
|
409
|
+
if hasattr(var, "rate"):
|
|
410
|
+
rate = var.rate
|
|
411
|
+
if rate > 0:
|
|
412
|
+
dt_for_var = (var.upper_threshold - var.value) / rate
|
|
413
|
+
elif rate < 0:
|
|
414
|
+
dt_for_var = (var.value - var.lower_threshold) / abs(rate)
|
|
415
|
+
var_is_upper = False
|
|
416
|
+
|
|
417
|
+
if -1e-12 <= dt_for_var < min_dt:
|
|
418
|
+
min_dt = max(0.0, dt_for_var)
|
|
419
|
+
trigger_var = var
|
|
420
|
+
is_upper = var_is_upper
|
|
421
|
+
|
|
422
|
+
if min_dt == math.inf:
|
|
423
|
+
orphaned = []
|
|
424
|
+
for var in variables:
|
|
425
|
+
if not isinstance(var, Level):
|
|
426
|
+
continue
|
|
427
|
+
rate = var._rate
|
|
428
|
+
has_threshold = (
|
|
429
|
+
var.lower_threshold != -math.inf or var.upper_threshold != math.inf
|
|
430
|
+
)
|
|
431
|
+
if has_threshold and rate == 0.0:
|
|
432
|
+
owner_name = type(var._owner).__name__ if var._owner else "unknown"
|
|
433
|
+
orphaned.append(f"'{var.name}' ({owner_name})")
|
|
434
|
+
if orphaned and id(None) not in self._orphaned_warned_ids:
|
|
435
|
+
self._orphaned_warned_ids.add(id(None))
|
|
436
|
+
msg = (
|
|
437
|
+
f"No threshold events pending. "
|
|
438
|
+
f"Variables with thresholds but rate=0: "
|
|
439
|
+
f"{', '.join(orphaned)}. "
|
|
440
|
+
f"Simulation will advance at max_step_size={self.max_step_size}."
|
|
441
|
+
)
|
|
442
|
+
if self.strict_mode:
|
|
443
|
+
raise ThresholdConfigurationError(msg)
|
|
444
|
+
logger.warning(msg)
|
|
445
|
+
return 1.0, None, True
|
|
446
|
+
|
|
447
|
+
return min_dt, trigger_var, is_upper
|
drs/exceptions.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class DRSError(Exception):
|
|
2
|
+
"""Base class for all DRS framework exceptions."""
|
|
3
|
+
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StateMutationError(DRSError):
|
|
8
|
+
"""Raised when a module attempts to illegally mutate state."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str):
|
|
11
|
+
# Capture the call stack from ExecutionContext
|
|
12
|
+
from ._execution_context import ExecutionContext
|
|
13
|
+
stack = getattr(ExecutionContext._local, "stack", [])
|
|
14
|
+
if stack:
|
|
15
|
+
stack_str = " -> ".join([type(mod).__name__ for mod in stack])
|
|
16
|
+
self.message = f"{message}\n\nModule Call Stack:\n{stack_str}"
|
|
17
|
+
else:
|
|
18
|
+
self.message = message
|
|
19
|
+
super().__init__(self.message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DeadlockError(DRSError):
|
|
23
|
+
"""Raised when the engine fails to advance time."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, state_dump: str = ""):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.state_dump = state_dump
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ThresholdConfigurationError(DRSError):
|
|
31
|
+
"""Raised when a threshold is configured but cannot be reached."""
|
|
32
|
+
|
|
33
|
+
pass
|
drs/flow.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Optional, Generic, TypeVar
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .module import Module
|
|
7
|
+
|
|
8
|
+
T = TypeVar('T')
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Flow(Generic[T]):
|
|
12
|
+
"""A unified data structure for tracking physical flows between modules.
|
|
13
|
+
|
|
14
|
+
Flows represent the movement of continuous quantities (e.g., volume, data, energy)
|
|
15
|
+
between components in the simulation. When returned from a module's `forward()`
|
|
16
|
+
pass, the engine automatically tracks the edge between the producing and consuming modules.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
value (T): The underlying quantity or value of the flow (often a float).
|
|
20
|
+
_source (Optional[Module]): [INTERNAL] The module that generated this flow.
|
|
21
|
+
Automatically populated by the engine.
|
|
22
|
+
"""
|
|
23
|
+
value: T
|
|
24
|
+
_source: Optional["Module"] = None
|