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/variables.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import enum
|
|
3
|
+
from typing import Any, Union
|
|
4
|
+
from ._execution_context import ExecutionContext
|
|
5
|
+
from .exceptions import StateMutationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def serialize_val(val: Any) -> Any:
|
|
9
|
+
if isinstance(val, enum.Enum):
|
|
10
|
+
return val.name
|
|
11
|
+
if (
|
|
12
|
+
hasattr(val, "name")
|
|
13
|
+
and hasattr(val, "id")
|
|
14
|
+
and not isinstance(val, (int, float, str, bool))
|
|
15
|
+
):
|
|
16
|
+
return {"__type__": type(val).__name__, "name": val.name}
|
|
17
|
+
if isinstance(val, float):
|
|
18
|
+
if math.isinf(val):
|
|
19
|
+
return "Infinity" if val > 0 else "-Infinity"
|
|
20
|
+
elif math.isnan(val):
|
|
21
|
+
return "NaN"
|
|
22
|
+
return val
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def deserialize_val(val: Any) -> Any:
|
|
26
|
+
if val == "Infinity":
|
|
27
|
+
return math.inf
|
|
28
|
+
elif val == "-Infinity":
|
|
29
|
+
return -math.inf
|
|
30
|
+
elif val == "NaN":
|
|
31
|
+
return math.nan
|
|
32
|
+
return val
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Expression:
|
|
36
|
+
"""AST node for tracking mathematical dependencies between Variables."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, op: str, left: Any, right: Any):
|
|
39
|
+
self.op = op
|
|
40
|
+
self.left = left
|
|
41
|
+
self.right = right
|
|
42
|
+
|
|
43
|
+
def evaluate(self) -> float:
|
|
44
|
+
def get_val(node):
|
|
45
|
+
if isinstance(node, Expression):
|
|
46
|
+
return node.evaluate()
|
|
47
|
+
if hasattr(node, "_sim_value"):
|
|
48
|
+
return node._sim_value()
|
|
49
|
+
return node
|
|
50
|
+
|
|
51
|
+
l_val = get_val(self.left)
|
|
52
|
+
r_val = get_val(self.right)
|
|
53
|
+
|
|
54
|
+
if self.op == "neg":
|
|
55
|
+
return -l_val
|
|
56
|
+
if self.op == "pos":
|
|
57
|
+
return +l_val
|
|
58
|
+
if self.op == "abs":
|
|
59
|
+
return abs(l_val)
|
|
60
|
+
if self.op == "add":
|
|
61
|
+
return l_val + r_val
|
|
62
|
+
if self.op == "sub":
|
|
63
|
+
return l_val - r_val
|
|
64
|
+
if self.op == "mul":
|
|
65
|
+
return l_val * r_val
|
|
66
|
+
if self.op == "div":
|
|
67
|
+
return l_val / r_val if r_val != 0 else 0.0
|
|
68
|
+
if self.op == "gt":
|
|
69
|
+
return l_val > r_val
|
|
70
|
+
if self.op == "lt":
|
|
71
|
+
return l_val < r_val
|
|
72
|
+
if self.op == "ge":
|
|
73
|
+
return l_val >= r_val
|
|
74
|
+
if self.op == "le":
|
|
75
|
+
return l_val <= r_val
|
|
76
|
+
if self.op == "eq":
|
|
77
|
+
return l_val == r_val
|
|
78
|
+
if self.op == "ne":
|
|
79
|
+
return l_val != r_val
|
|
80
|
+
if self.op == "pow":
|
|
81
|
+
return l_val**r_val
|
|
82
|
+
return 0.0
|
|
83
|
+
|
|
84
|
+
def get_sources(self) -> list:
|
|
85
|
+
sources = set()
|
|
86
|
+
for side in (self.left, self.right):
|
|
87
|
+
if hasattr(side, "get_sources"):
|
|
88
|
+
sources.update(side.get_sources())
|
|
89
|
+
elif isinstance(side, Variable):
|
|
90
|
+
sources.add(side)
|
|
91
|
+
return list(sources)
|
|
92
|
+
|
|
93
|
+
def get_equation(self) -> str:
|
|
94
|
+
op_chars = {
|
|
95
|
+
"add": "+",
|
|
96
|
+
"sub": "-",
|
|
97
|
+
"mul": "*",
|
|
98
|
+
"div": "/",
|
|
99
|
+
"gt": ">",
|
|
100
|
+
"lt": "<",
|
|
101
|
+
"ge": ">=",
|
|
102
|
+
"le": "<=",
|
|
103
|
+
"eq": "==",
|
|
104
|
+
"ne": "!=",
|
|
105
|
+
"pow": "**",
|
|
106
|
+
}
|
|
107
|
+
unary_chars = {"neg": "-", "pos": "+", "abs": "|"}
|
|
108
|
+
|
|
109
|
+
def format_node(node):
|
|
110
|
+
if isinstance(node, Expression):
|
|
111
|
+
return node.get_equation()
|
|
112
|
+
if hasattr(node, "name"):
|
|
113
|
+
mod = getattr(node, "_owner", None)
|
|
114
|
+
if mod and hasattr(mod, "name"):
|
|
115
|
+
return f"{mod.name}.{node.name}"
|
|
116
|
+
elif mod:
|
|
117
|
+
return f"{type(mod).__name__}.{node.name}"
|
|
118
|
+
return node.name
|
|
119
|
+
return str(node)
|
|
120
|
+
|
|
121
|
+
if self.op in unary_chars:
|
|
122
|
+
l = unary_chars[self.op]
|
|
123
|
+
r = unary_chars[self.op] if self.op == "abs" else ""
|
|
124
|
+
return f"({l}{format_node(self.left)}{r})"
|
|
125
|
+
return f"({format_node(self.left)} {op_chars.get(self.op, '?')} {format_node(self.right)})"
|
|
126
|
+
|
|
127
|
+
def __bool__(self):
|
|
128
|
+
raise TypeError(
|
|
129
|
+
f"Cannot use Expression ('{self.get_equation()}') as a boolean. "
|
|
130
|
+
f"Use `.value` for immediate evaluation or `drs.Where()` for symbolic branching."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def __neg__(self):
|
|
134
|
+
return Expression("neg", self, None)
|
|
135
|
+
|
|
136
|
+
def __pos__(self):
|
|
137
|
+
return Expression("pos", self, None)
|
|
138
|
+
|
|
139
|
+
def __abs__(self):
|
|
140
|
+
return Expression("abs", self, None)
|
|
141
|
+
|
|
142
|
+
def __add__(self, other):
|
|
143
|
+
return Expression("add", self, other)
|
|
144
|
+
|
|
145
|
+
def __sub__(self, other):
|
|
146
|
+
return Expression("sub", self, other)
|
|
147
|
+
|
|
148
|
+
def __mul__(self, other):
|
|
149
|
+
return Expression("mul", self, other)
|
|
150
|
+
|
|
151
|
+
def __truediv__(self, other):
|
|
152
|
+
return Expression("div", self, other)
|
|
153
|
+
|
|
154
|
+
def __radd__(self, other):
|
|
155
|
+
return Expression("add", other, self)
|
|
156
|
+
|
|
157
|
+
def __rsub__(self, other):
|
|
158
|
+
return Expression("sub", other, self)
|
|
159
|
+
|
|
160
|
+
def __rmul__(self, other):
|
|
161
|
+
return Expression("mul", other, self)
|
|
162
|
+
|
|
163
|
+
def __rtruediv__(self, other):
|
|
164
|
+
return Expression("div", other, self)
|
|
165
|
+
|
|
166
|
+
def __gt__(self, other):
|
|
167
|
+
return Expression("gt", self, other)
|
|
168
|
+
|
|
169
|
+
def __lt__(self, other):
|
|
170
|
+
return Expression("lt", self, other)
|
|
171
|
+
|
|
172
|
+
def __ge__(self, other):
|
|
173
|
+
return Expression("ge", self, other)
|
|
174
|
+
|
|
175
|
+
def __le__(self, other):
|
|
176
|
+
return Expression("le", self, other)
|
|
177
|
+
|
|
178
|
+
def __eq__(self, other):
|
|
179
|
+
return Expression("eq", self, other)
|
|
180
|
+
|
|
181
|
+
def __ne__(self, other):
|
|
182
|
+
return Expression("ne", self, other)
|
|
183
|
+
|
|
184
|
+
def __pow__(self, other):
|
|
185
|
+
return Expression("pow", self, other)
|
|
186
|
+
|
|
187
|
+
def __rpow__(self, other):
|
|
188
|
+
return Expression("pow", other, self)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Variable:
|
|
192
|
+
"""Base class for all domain variables.
|
|
193
|
+
|
|
194
|
+
Variables hold named state and belong to a specific `Module` owner. They ensure
|
|
195
|
+
that state is tracked properly through the execution context and prevent
|
|
196
|
+
cross-module mutation.
|
|
197
|
+
|
|
198
|
+
Attributes:
|
|
199
|
+
name (str): The unique name of the variable.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, name: str, initial_value: Any = 0.0) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Initialize a new Variable.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
name: The unique name of the variable.
|
|
208
|
+
initial_value: The starting value (default: 0.0).
|
|
209
|
+
"""
|
|
210
|
+
self.name = name
|
|
211
|
+
self._value = initial_value
|
|
212
|
+
self._owner = None
|
|
213
|
+
|
|
214
|
+
def _sim_value(self) -> Any:
|
|
215
|
+
if isinstance(self._value, Expression):
|
|
216
|
+
return self._value.evaluate()
|
|
217
|
+
return self._value
|
|
218
|
+
|
|
219
|
+
def get_sources(self) -> list:
|
|
220
|
+
return [self]
|
|
221
|
+
|
|
222
|
+
def _record_read_dependency(self) -> None:
|
|
223
|
+
"""
|
|
224
|
+
[INTERNAL] Record that the current executing module has read this variable.
|
|
225
|
+
|
|
226
|
+
Power User Note: This is called automatically by the `value` getter. It
|
|
227
|
+
interfaces with the ExecutionContext to build the dependency graph.
|
|
228
|
+
"""
|
|
229
|
+
current = ExecutionContext.get_current()
|
|
230
|
+
if current is not None and current is not self._owner:
|
|
231
|
+
current._record_incoming_edge(self)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def value(self) -> Any:
|
|
235
|
+
"""
|
|
236
|
+
Get the current value of the variable.
|
|
237
|
+
|
|
238
|
+
Reading this automatically records a dependency edge in the execution context,
|
|
239
|
+
linking the module that read it to the module that owns it.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Any: The underlying value of the variable.
|
|
243
|
+
"""
|
|
244
|
+
self._record_read_dependency()
|
|
245
|
+
if ExecutionContext.is_tracing():
|
|
246
|
+
return self
|
|
247
|
+
return self._sim_value()
|
|
248
|
+
|
|
249
|
+
@value.setter
|
|
250
|
+
def value(self, val: Any) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Set the value of the variable.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
val (Any): The new value to set.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
RuntimeError: If a module attempts to mutate a variable it does not own.
|
|
259
|
+
"""
|
|
260
|
+
current = ExecutionContext.get_current()
|
|
261
|
+
if current is not None and current is not self._owner:
|
|
262
|
+
raise StateMutationError(
|
|
263
|
+
f"Illegal Mutation: {type(current).__name__} tried to mutate "
|
|
264
|
+
f"'{self.name}' owned by {type(self._owner).__name__}. "
|
|
265
|
+
f"Modules must communicate by passing Flows. Do not mutate state directly!"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if self._value != val:
|
|
269
|
+
engine = ExecutionContext.get_engine()
|
|
270
|
+
if engine and getattr(engine, "telemetry", None):
|
|
271
|
+
engine.telemetry.log_event(
|
|
272
|
+
time=engine.current_time,
|
|
273
|
+
event_type="STATE_CHANGE",
|
|
274
|
+
source=type(current).__name__ if current else "External",
|
|
275
|
+
details={
|
|
276
|
+
"variable": self.name,
|
|
277
|
+
"old_value": self._value,
|
|
278
|
+
"new_value": val,
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
self._value = val
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def rate(self) -> float:
|
|
285
|
+
raise AttributeError(
|
|
286
|
+
f"'{type(self).__name__}' has no attribute 'rate'. "
|
|
287
|
+
f"Only drs.Level supports .rate. Use drs.Level() for quantities that flow."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
@rate.setter
|
|
291
|
+
def rate(self, val: Union[float, tuple[float, float, float]]) -> None:
|
|
292
|
+
raise AttributeError(
|
|
293
|
+
f"Cannot set .rate on '{type(self).__name__}'. "
|
|
294
|
+
f"Only drs.Level supports .rate."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _unary(self, op: str):
|
|
298
|
+
self._record_read_dependency()
|
|
299
|
+
if ExecutionContext.is_tracing():
|
|
300
|
+
return Expression(op, self, None)
|
|
301
|
+
l_val = self._sim_value()
|
|
302
|
+
if op == "neg":
|
|
303
|
+
return -l_val
|
|
304
|
+
if op == "pos":
|
|
305
|
+
return +l_val
|
|
306
|
+
if op == "abs":
|
|
307
|
+
return abs(l_val)
|
|
308
|
+
return NotImplemented
|
|
309
|
+
|
|
310
|
+
def _op(self, op: str, other):
|
|
311
|
+
self._record_read_dependency()
|
|
312
|
+
if isinstance(other, Variable):
|
|
313
|
+
other._record_read_dependency()
|
|
314
|
+
if ExecutionContext.is_tracing():
|
|
315
|
+
return Expression(op, self, other)
|
|
316
|
+
r_val = other._sim_value() if isinstance(other, Variable) else other
|
|
317
|
+
if isinstance(r_val, Expression):
|
|
318
|
+
r_val = r_val.evaluate()
|
|
319
|
+
l_val = self._sim_value()
|
|
320
|
+
if op == "add":
|
|
321
|
+
return l_val + r_val
|
|
322
|
+
if op == "sub":
|
|
323
|
+
return l_val - r_val
|
|
324
|
+
if op == "mul":
|
|
325
|
+
return l_val * r_val
|
|
326
|
+
if op == "div":
|
|
327
|
+
return l_val / r_val if r_val != 0 else 0.0
|
|
328
|
+
if op == "gt":
|
|
329
|
+
return l_val > r_val
|
|
330
|
+
if op == "lt":
|
|
331
|
+
return l_val < r_val
|
|
332
|
+
if op == "ge":
|
|
333
|
+
return l_val >= r_val
|
|
334
|
+
if op == "le":
|
|
335
|
+
return l_val <= r_val
|
|
336
|
+
if op == "eq":
|
|
337
|
+
return l_val == r_val
|
|
338
|
+
if op == "ne":
|
|
339
|
+
return l_val != r_val
|
|
340
|
+
if op == "pow":
|
|
341
|
+
return l_val**r_val
|
|
342
|
+
return NotImplemented
|
|
343
|
+
|
|
344
|
+
def _rop(self, op: str, other):
|
|
345
|
+
self._record_read_dependency()
|
|
346
|
+
if isinstance(other, Variable):
|
|
347
|
+
other._record_read_dependency()
|
|
348
|
+
if ExecutionContext.is_tracing():
|
|
349
|
+
return Expression(op, other, self)
|
|
350
|
+
l_val = other._sim_value() if isinstance(other, Variable) else other
|
|
351
|
+
if isinstance(l_val, Expression):
|
|
352
|
+
l_val = l_val.evaluate()
|
|
353
|
+
r_val = self._sim_value()
|
|
354
|
+
if op == "add":
|
|
355
|
+
return l_val + r_val
|
|
356
|
+
if op == "sub":
|
|
357
|
+
return l_val - r_val
|
|
358
|
+
if op == "mul":
|
|
359
|
+
return l_val * r_val
|
|
360
|
+
if op == "div":
|
|
361
|
+
return l_val / r_val if r_val != 0 else 0.0
|
|
362
|
+
if op == "pow":
|
|
363
|
+
return l_val**r_val
|
|
364
|
+
return NotImplemented
|
|
365
|
+
|
|
366
|
+
def __neg__(self):
|
|
367
|
+
return self._unary("neg")
|
|
368
|
+
|
|
369
|
+
def __pos__(self):
|
|
370
|
+
return self._unary("pos")
|
|
371
|
+
|
|
372
|
+
def __abs__(self):
|
|
373
|
+
return self._unary("abs")
|
|
374
|
+
|
|
375
|
+
def __add__(self, other):
|
|
376
|
+
return self._op("add", other)
|
|
377
|
+
|
|
378
|
+
def __sub__(self, other):
|
|
379
|
+
return self._op("sub", other)
|
|
380
|
+
|
|
381
|
+
def __mul__(self, other):
|
|
382
|
+
return self._op("mul", other)
|
|
383
|
+
|
|
384
|
+
def __truediv__(self, other):
|
|
385
|
+
return self._op("div", other)
|
|
386
|
+
|
|
387
|
+
def __radd__(self, other):
|
|
388
|
+
return self._rop("add", other)
|
|
389
|
+
|
|
390
|
+
def __rsub__(self, other):
|
|
391
|
+
return self._rop("sub", other)
|
|
392
|
+
|
|
393
|
+
def __rmul__(self, other):
|
|
394
|
+
return self._rop("mul", other)
|
|
395
|
+
|
|
396
|
+
def __rtruediv__(self, other):
|
|
397
|
+
return self._rop("div", other)
|
|
398
|
+
|
|
399
|
+
def __gt__(self, other):
|
|
400
|
+
return self._op("gt", other)
|
|
401
|
+
|
|
402
|
+
def __lt__(self, other):
|
|
403
|
+
return self._op("lt", other)
|
|
404
|
+
|
|
405
|
+
def __ge__(self, other):
|
|
406
|
+
return self._op("ge", other)
|
|
407
|
+
|
|
408
|
+
def __le__(self, other):
|
|
409
|
+
return self._op("le", other)
|
|
410
|
+
|
|
411
|
+
def __eq__(self, other):
|
|
412
|
+
return self._op("eq", other)
|
|
413
|
+
|
|
414
|
+
def __ne__(self, other):
|
|
415
|
+
return self._op("ne", other)
|
|
416
|
+
|
|
417
|
+
def __pow__(self, other):
|
|
418
|
+
return self._op("pow", other)
|
|
419
|
+
|
|
420
|
+
def __rpow__(self, other):
|
|
421
|
+
return self._rop("pow", other)
|
|
422
|
+
|
|
423
|
+
def __hash__(self) -> int:
|
|
424
|
+
return id(self)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class Level(Variable):
|
|
428
|
+
"""A variable that accumulates over time based on a rate.
|
|
429
|
+
|
|
430
|
+
Levels are the primary way to model physical quantities that flow or change
|
|
431
|
+
continuously over time (e.g., mass in a stockpile, energy in a battery).
|
|
432
|
+
|
|
433
|
+
Attributes:
|
|
434
|
+
upper_threshold (float): The maximum limit for the level. The engine will
|
|
435
|
+
stop exactly at this boundary. Defaults to math.inf.
|
|
436
|
+
lower_threshold (float): The minimum limit for the level. Defaults to -math.inf.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
def __init__(
|
|
440
|
+
self, name: str, initial_value: float = 0.0, rate: float = 0.0
|
|
441
|
+
) -> None:
|
|
442
|
+
"""
|
|
443
|
+
Initialize a new Level.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
name: The unique name of the level.
|
|
447
|
+
initial_value: The starting value (default: 0.0).
|
|
448
|
+
rate: The initial rate of change (default: 0.0).
|
|
449
|
+
"""
|
|
450
|
+
super().__init__(name, initial_value)
|
|
451
|
+
self._rate = rate
|
|
452
|
+
self.upper_threshold = math.inf
|
|
453
|
+
self.lower_threshold = -math.inf
|
|
454
|
+
self._rate_set_by = None
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def rate(self) -> float:
|
|
458
|
+
"""
|
|
459
|
+
Get the current rate of change.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
float: The rate at which the level is currently accumulating per time unit.
|
|
463
|
+
"""
|
|
464
|
+
self._record_read_dependency()
|
|
465
|
+
if ExecutionContext.is_tracing():
|
|
466
|
+
return self._rate
|
|
467
|
+
if isinstance(self._rate, Expression):
|
|
468
|
+
return self._rate.evaluate()
|
|
469
|
+
return self._rate
|
|
470
|
+
|
|
471
|
+
@rate.setter
|
|
472
|
+
def rate(self, val: Union[float, tuple[float, float, float]]) -> None:
|
|
473
|
+
"""
|
|
474
|
+
Set the rate of change.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
val (Any): Can be a single float representing the new rate, or a tuple
|
|
478
|
+
of `(rate, lower_threshold, upper_threshold)`.
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
ValueError: If a tuple is provided but it does not have exactly 3 elements.
|
|
482
|
+
"""
|
|
483
|
+
current_actor = ExecutionContext.get_current()
|
|
484
|
+
if current_actor is not None and current_actor is not self._owner:
|
|
485
|
+
if hasattr(current_actor, "_record_incoming_edge"):
|
|
486
|
+
current_actor._record_incoming_edge(self)
|
|
487
|
+
|
|
488
|
+
# Rate override guardrail
|
|
489
|
+
if (
|
|
490
|
+
current_actor is not None
|
|
491
|
+
and self._rate_set_by is not None
|
|
492
|
+
and self._rate_set_by is not current_actor
|
|
493
|
+
):
|
|
494
|
+
raise StateMutationError(
|
|
495
|
+
f"Rate Conflict: '{type(current_actor).__name__}' attempted to set the rate of "
|
|
496
|
+
f"'{self.name}', but it was already set by '{type(self._rate_set_by).__name__}' "
|
|
497
|
+
f"during this time step. Multiple modules cannot control the rate of the same Level."
|
|
498
|
+
)
|
|
499
|
+
self._rate_set_by = current_actor
|
|
500
|
+
|
|
501
|
+
if isinstance(val, tuple):
|
|
502
|
+
if len(val) == 3:
|
|
503
|
+
self._rate, self.lower_threshold, self.upper_threshold = val
|
|
504
|
+
else:
|
|
505
|
+
raise ValueError(f"Rate tuple must be (rate, lower, upper), got {val}")
|
|
506
|
+
else:
|
|
507
|
+
self._rate = val
|
|
508
|
+
|
|
509
|
+
def _update(self, dt: float) -> None:
|
|
510
|
+
"""
|
|
511
|
+
[INTERNAL] Step the level forward in time based on its current rate.
|
|
512
|
+
|
|
513
|
+
Power User Note: This is called automatically by the DRSEngine. Do not call this
|
|
514
|
+
manually unless you are implementing a custom time-stepping loop.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
dt (float): The amount of time to simulate.
|
|
518
|
+
"""
|
|
519
|
+
self.value += self.rate * dt
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class Timer(Level):
|
|
523
|
+
"""A specialized level used to track time.
|
|
524
|
+
|
|
525
|
+
Timers are simply Levels that accumulate at a default rate of 1.0 (or -1.0 for countdowns).
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
def __init__(
|
|
529
|
+
self, name: str, initial_value: float = 0.0, rate: float = 1.0
|
|
530
|
+
) -> None:
|
|
531
|
+
"""
|
|
532
|
+
Initialize a Timer.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
name: The unique name of the timer.
|
|
536
|
+
initial_value: The starting time value (default: 0.0).
|
|
537
|
+
rate: The speed of time (default: 1.0).
|
|
538
|
+
"""
|
|
539
|
+
super().__init__(name, initial_value, rate)
|
|
540
|
+
|
|
541
|
+
def reset(self) -> None:
|
|
542
|
+
"""
|
|
543
|
+
Reset the timer value back to 0.0.
|
|
544
|
+
|
|
545
|
+
This sets the absolute value of the timer to 0, but does not modify the rate.
|
|
546
|
+
"""
|
|
547
|
+
self.value = 0.0
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-drs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A PyTorch-inspired, event-driven Discrete Rate Simulation framework.
|
|
5
|
+
Author: Jonathan Lamontagne Kratz
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Jonathan Lamontagne Kratz
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/epicgamer17/python-drs
|
|
29
|
+
Project-URL: Repository, https://github.com/epicgamer17/python-drs
|
|
30
|
+
Project-URL: Documentation, https://github.com/epicgamer17/python-drs/tree/main/docs
|
|
31
|
+
Keywords: simulation,discrete-rate,drs,discrete-event,modeling,mining,continuous-flow
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Topic :: Scientific/Engineering
|
|
41
|
+
Requires-Python: >=3.9
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Dist: numpy>=1.21
|
|
45
|
+
Requires-Dist: pandas>=1.3
|
|
46
|
+
Requires-Dist: matplotlib>=3.4
|
|
47
|
+
Requires-Dist: seaborn>=0.12
|
|
48
|
+
Provides-Extra: progress
|
|
49
|
+
Requires-Dist: rich>=13.0; extra == "progress"
|
|
50
|
+
Provides-Extra: dev
|
|
51
|
+
Requires-Dist: build; extra == "dev"
|
|
52
|
+
Requires-Dist: twine; extra == "dev"
|
|
53
|
+
Requires-Dist: pytest; extra == "dev"
|
|
54
|
+
Dynamic: license-file
|
|
55
|
+
|
|
56
|
+
# python-drs
|
|
57
|
+
|
|
58
|
+
A PyTorch-inspired, event-driven **Discrete Rate Simulation (DRS)** framework for modeling systems where material flows continuously over time.
|
|
59
|
+
|
|
60
|
+
Instead of ticking through time at fixed intervals, DRS calculates *exactly* when the next limit (threshold) will be hit, jumps the simulation clock to that precise moment, and triggers the matching state transition. The result is a simulation that runs in a fraction of the time of fixed-step models — and never misses a limit.
|
|
61
|
+
|
|
62
|
+
## Features
|
|
63
|
+
|
|
64
|
+
- **Event-driven time stepping** — simulate years of operation in seconds
|
|
65
|
+
- **PyTorch-style architecture** — `Module`, `Variable`, and `Level` compose into hierarchies with automatic dependency tracking
|
|
66
|
+
- **Built-in telemetry** — every state change is logged and plotted without custom tracking code
|
|
67
|
+
- **Fail-fast guardrails** — the engine stops you from breaking the physics of your model (e.g. draining an empty stockpile)
|
|
68
|
+
- **Applicable to any continuous-flow system** — mining supply chains, water pipes, electrical grids, traffic, and more
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install python-drs
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Optional extras:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install python-drs[progress] # Rich progress bar
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Quickstart
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from drs import DRSEngine, Module, Level
|
|
86
|
+
|
|
87
|
+
class Stockpile(Module):
|
|
88
|
+
def forward(self):
|
|
89
|
+
# Fill at 50 units per time step
|
|
90
|
+
self.ore.rate = 50.0
|
|
91
|
+
|
|
92
|
+
model = Stockpile()
|
|
93
|
+
model.ore = Level("Ore", initial_value=100.0)
|
|
94
|
+
|
|
95
|
+
engine = DRSEngine(model)
|
|
96
|
+
result = engine.run(max_time=20.0)
|
|
97
|
+
print(result.summary())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Documentation
|
|
101
|
+
|
|
102
|
+
Full guides, tutorials, and API reference live in the [`docs/`](https://github.com/epicgamer17/python-drs/tree/main/docs) directory.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
drs/__init__.py,sha256=UkCagrq2kLPVFgVawNy9fo04hc-0Jt_z7pQZKms3ADk,1178
|
|
2
|
+
drs/_execution_context.py,sha256=wJCq3P8e9ETx_GyP7Cg_s8KRqzDZxd14PnMD3O8x4ho,2696
|
|
3
|
+
drs/callbacks.py,sha256=W-oAj36F6emr9QPxrh7Gl3niJECmrQ2AEyf3DjGzPkI,3604
|
|
4
|
+
drs/config.py,sha256=AetXaQRpHftPZLfY8SLpIdGBVjARKKNsSKUs935iR0s,768
|
|
5
|
+
drs/data_source.py,sha256=SoVPWCr3hzdEKkqFpcov5amTaA4dVAWf_qBqKgdAByk,1612
|
|
6
|
+
drs/engine.py,sha256=nt9b-zhsdXTvfaIao8rlSk8ObF4M9cVdh3apzKGIG0Y,16362
|
|
7
|
+
drs/exceptions.py,sha256=bKO054dsU5vUVw5Hv6jG6Y7DJ8j-SfYpTnbBqUB8HH8,1015
|
|
8
|
+
drs/flow.py,sha256=xBTzj4txCf9lUqvJyko0i2ZrWuR-hrpNp3-MHaxOZGY,882
|
|
9
|
+
drs/module.py,sha256=5_Z7Vw12sqUKxBOYLPxF1PoxXtIQQivDDk_eWENiKZs,18317
|
|
10
|
+
drs/plot.py,sha256=tYZ9Rv3N-f-CRFIVKc5yXXf4FmaF4Uu7LIVD3ShJ418,6938
|
|
11
|
+
drs/serialize.py,sha256=XcuEhOGZCoBjM6FVDM-5kfoOb0LJIWbACkOczL-cX4s,14764
|
|
12
|
+
drs/telemetry.py,sha256=1XuUpiqO9jL1ZqMGgUgEz8P57nFg1YlNqKJkDW_qLfI,6336
|
|
13
|
+
drs/variables.py,sha256=w9KI0Z8KtMpefBUlBjaddpaQaoFqXjF_9xQoJlio-zc,16834
|
|
14
|
+
python_drs-0.1.0.dist-info/licenses/LICENSE,sha256=tPAvFtSTwLleSk2ZL8TnhfG-XUSOO3T98VnS3xZgwP0,1082
|
|
15
|
+
python_drs-0.1.0.dist-info/METADATA,sha256=E1jSOV488U-mi01u_MbCZ9NTuG5EiPq8d9Wrg5t47uU,4333
|
|
16
|
+
python_drs-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
17
|
+
python_drs-0.1.0.dist-info/top_level.txt,sha256=adIZh9e3GYNwobkEd7ymv5xbZR-L54aXZYZSx0rW7Cs,4
|
|
18
|
+
python_drs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Lamontagne Kratz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
drs
|