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 +22 -31
- drs/engine.py +86 -405
- drs/module.py +63 -454
- drs/plot.py +232 -120
- drs/telemetry.py +12 -79
- drs/variables.py +114 -407
- python_drs-0.1.2.dist-info/METADATA +182 -0
- python_drs-0.1.2.dist-info/RECORD +11 -0
- drs/_execution_context.py +0 -89
- drs/callbacks.py +0 -97
- drs/config.py +0 -25
- drs/data_source.py +0 -49
- drs/exceptions.py +0 -33
- drs/flow.py +0 -24
- drs/serialize.py +0 -408
- python_drs-0.1.0.dist-info/METADATA +0 -106
- python_drs-0.1.0.dist-info/RECORD +0 -18
- {python_drs-0.1.0.dist-info → python_drs-0.1.2.dist-info}/WHEEL +0 -0
- {python_drs-0.1.0.dist-info → python_drs-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {python_drs-0.1.0.dist-info → python_drs-0.1.2.dist-info}/top_level.txt +0 -0
drs/__init__.py
CHANGED
|
@@ -1,49 +1,40 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
__version__ = "0.1.
|
|
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
|
|
10
|
-
from .
|
|
11
|
-
from .
|
|
12
|
-
from .
|
|
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 .
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
30
|
+
"Storage",
|
|
31
|
+
"Processor",
|
|
39
32
|
"Telemetry",
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
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
|
|
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
|
|
6
|
+
class SimResult:
|
|
20
7
|
"""Encapsulates the final results of a simulation run."""
|
|
21
8
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
17
|
+
"--- Simulation Summary ---",
|
|
62
18
|
f"Termination Reason : {self.terminated_reason}",
|
|
63
|
-
f"Simulated Time : {self.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
154
|
-
"""
|
|
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
|
-
|
|
177
|
-
|
|
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.
|
|
183
|
-
|
|
184
|
-
try:
|
|
185
|
-
import numpy as np
|
|
78
|
+
if self.telemetry is not None:
|
|
79
|
+
self.telemetry.snapshot(current_time)
|
|
186
80
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
pass
|
|
81
|
+
while current_time < until:
|
|
82
|
+
self.current_time = current_time
|
|
190
83
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
self.
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
break
|
|
101
|
+
# Don't step past simulation end
|
|
102
|
+
if current_time + dt > until:
|
|
103
|
+
dt = until - current_time
|
|
214
104
|
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
110
|
+
# 3. Tell each component to step forward by dt
|
|
111
|
+
for comp in self.components:
|
|
112
|
+
comp.step(dt)
|
|
221
113
|
|
|
222
|
-
|
|
223
|
-
|
|
114
|
+
current_time += dt
|
|
115
|
+
self.current_time = current_time
|
|
116
|
+
steps += 1
|
|
224
117
|
|
|
225
|
-
if self.telemetry:
|
|
226
|
-
self.telemetry.snapshot(
|
|
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
|
-
|
|
235
|
-
model=self.model,
|
|
236
|
-
config=self.config,
|
|
237
|
-
duration=end_time - start_time,
|
|
123
|
+
return SimResult(
|
|
238
124
|
steps=steps,
|
|
239
|
-
|
|
125
|
+
duration=current_time,
|
|
240
126
|
history=df,
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
)
|