python-drs 0.1.0__py3-none-any.whl → 0.1.2__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 CHANGED
@@ -1,49 +1,40 @@
1
1
  import logging
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
4
4
 
5
5
  # Configure a NullHandler to prevent "No handler found" warnings
6
6
  # Users of the library can configure their own logging handlers
7
7
  logging.getLogger(__name__).addHandler(logging.NullHandler())
8
8
 
9
- from .module import Module, DataSource
10
- from .engine import DRSEngine, SimulationResult
11
- from .variables import Variable, Level, Timer, Expression
12
- from .data_source import DataPoint
13
- from .flow import Flow
9
+ from .module import Module
10
+ from .components import Storage, Processor
11
+ from .engine import DRSEngine, SimResult
12
+ from .variables import Variable, Level, Timer
14
13
  from .telemetry import Telemetry
15
- from .exceptions import StateMutationError, DeadlockError
16
- from .config import DRSConfig, EngineConfig
17
- from .callbacks import Callback, ProgressBarCallback
18
- from .serialize import (
19
- save_state,
20
- load_state,
21
- export_architecture,
22
- save_checkpoint,
23
- load_checkpoint,
14
+ from .plot import (
15
+ Dashboard,
16
+ plot_series,
17
+ plot_time_series,
18
+ plot_safety_margin,
19
+ plot_dual_axis_step,
20
+ apply_plot_style,
24
21
  )
25
22
 
26
23
  __all__ = [
27
24
  "DRSEngine",
28
- "SimulationResult",
29
- "Callback",
30
- "ProgressBarCallback",
25
+ "SimResult",
31
26
  "Variable",
32
27
  "Level",
33
28
  "Timer",
34
- "Expression",
35
- "DataPoint",
36
- "DataSource",
37
29
  "Module",
38
- "Flow",
30
+ "Storage",
31
+ "Processor",
39
32
  "Telemetry",
40
- "StateMutationError",
41
- "DeadlockError",
42
- "DRSConfig",
43
- "EngineConfig",
44
- "save_state",
45
- "load_state",
46
- "export_architecture",
47
- "save_checkpoint",
48
- "load_checkpoint",
33
+ "Dashboard",
34
+ "plot_series",
35
+ "plot_time_series",
36
+ "plot_safety_margin",
37
+ "plot_dual_axis_step",
38
+ "apply_plot_style",
49
39
  ]
40
+
drs/engine.py CHANGED
@@ -1,447 +1,128 @@
1
- import math
2
- import logging
3
- import random
4
1
  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__)
2
+ from typing import Any, Callable, List, Optional
16
3
 
17
4
 
18
5
  @dataclass
19
- class SimulationResult:
6
+ class SimResult:
20
7
  """Encapsulates the final results of a simulation run."""
21
8
 
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.")
9
+ steps: int
10
+ duration: float
11
+ history: Optional[Any] = None
12
+ terminated_reason: str = "until_reached"
57
13
 
58
14
  def summary(self) -> str:
59
15
  """Returns a string summary of the simulation run."""
60
16
  lines = [
61
- f"--- Simulation Summary ---",
17
+ "--- Simulation Summary ---",
62
18
  f"Termination Reason : {self.terminated_reason}",
63
- f"Simulated Time : {self.sim_time:.2f}",
64
- f"Wall Clock Time : {self.duration:.4f} seconds",
19
+ f"Simulated Time : {self.duration:.2f}",
65
20
  f"Engine Steps : {self.steps:,}",
66
21
  ]
67
22
  if self.history is not None:
68
23
  lines.append(f"Telemetry Records : {len(self.history):,}")
69
24
  return "\n".join(lines)
70
25
 
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
26
 
80
27
  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
28
 
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
- """
29
+ def __init__(self, max_step_size: float = 1.0):
30
+ self.max_step_size = float(max_step_size)
31
+ self.components: List[Any] = []
32
+ self._step_handlers: List[Callable[[float], None]] = []
33
+ self.telemetry: Optional[Any] = None
34
+ self.current_time: float = 0.0
93
35
 
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
36
+ def register(self, *components: Any) -> None:
37
+ """Registers components to be simulated."""
38
+ for comp in components:
39
+ if comp not in self.components:
40
+ self.components.append(comp)
144
41
 
145
42
  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
- """
43
+ """Attach a Telemetry object to the engine."""
151
44
  self.telemetry = telemetry
152
45
 
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.
46
+ def variables(self):
47
+ """Yield every variable owned by the registered components.
175
48
 
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.
49
+ Lets ``Telemetry(model=engine)`` record the full state of the flat
50
+ leaf components without passing a wrapper module.
180
51
  """
52
+ seen = set()
53
+ for comp in self.components:
54
+ for variable in comp.variables():
55
+ if id(variable) not in seen:
56
+ seen.add(id(variable))
57
+ yield variable
58
+
59
+ def on_step(self, fn: Callable[[float], None]) -> Callable[[float], None]:
60
+ """Decorator to register a control policy callback."""
61
+ self._step_handlers.append(fn)
62
+ return fn
63
+
64
+ def run(
65
+ self, until: Optional[float] = None, max_time: Optional[float] = None
66
+ ) -> SimResult:
67
+ if until is None:
68
+ if max_time is not None:
69
+ until = max_time
70
+ else:
71
+ raise ValueError("Must specify 'until' or 'max_time'")
72
+ current_time = 0.0
73
+ self.current_time = current_time
74
+ steps = 0
75
+
76
+ terminated_reason = "until_reached"
181
77
 
182
- if self._seed is not None:
183
- random.seed(self._seed)
184
- try:
185
- import numpy as np
78
+ if self.telemetry is not None:
79
+ self.telemetry.snapshot(current_time)
186
80
 
187
- np.random.seed(self._seed)
188
- except ImportError:
189
- pass
81
+ while current_time < until:
82
+ self.current_time = current_time
190
83
 
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()
84
+ # Check if terminating condition is met
85
+ terminated = False
86
+ for comp in self.components:
87
+ if comp.is_terminating_condition_met():
88
+ terminated = True
89
+ break
90
+ if terminated:
91
+ terminated_reason = "condition_met"
92
+ break
199
93
 
200
- try:
201
- self._current_max_time = max_time
202
- for cb in self.callbacks:
203
- cb.on_simulation_start(self)
94
+ # 1. Execute control policies at time t
95
+ for handler in self._step_handlers:
96
+ handler(current_time)
204
97
 
205
- self._consecutive_zero_dt_count = 0
206
- termination_reason = "unknown"
207
- steps = 0
208
- start_time = time.time()
98
+ # 2. Determine time to next state boundary across all components
99
+ dt = self.max_step_size
209
100
 
210
- while True:
211
- if self.model.is_terminating_condition_met():
212
- termination_reason = "condition_met"
213
- break
101
+ # Don't step past simulation end
102
+ if current_time + dt > until:
103
+ dt = until - current_time
214
104
 
215
- for cb in self.callbacks:
216
- cb.on_step_start(self)
105
+ for comp in self.components:
106
+ event_dt = comp.time_to_event()
107
+ if event_dt >= 0.0:
108
+ dt = min(dt, event_dt)
217
109
 
218
- if self.current_time >= max_time:
219
- termination_reason = "max_time_reached"
220
- break
110
+ # 3. Tell each component to step forward by dt
111
+ for comp in self.components:
112
+ comp.step(dt)
221
113
 
222
- self._step(max_time)
223
- steps += 1
114
+ current_time += dt
115
+ self.current_time = current_time
116
+ steps += 1
224
117
 
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)
118
+ if self.telemetry is not None:
119
+ self.telemetry.snapshot(current_time)
230
120
 
231
- end_time = time.time()
232
121
  df = self.telemetry.to_dataframe() if self.telemetry else None
233
122
 
234
- result = SimulationResult(
235
- model=self.model,
236
- config=self.config,
237
- duration=end_time - start_time,
123
+ return SimResult(
238
124
  steps=steps,
239
- sim_time=self.current_time,
125
+ duration=current_time,
240
126
  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
127
+ terminated_reason=terminated_reason,
128
+ )