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/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