simweave 0.4.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.
- simweave/__init__.py +198 -0
- simweave/agents/__init__.py +23 -0
- simweave/agents/agent.py +161 -0
- simweave/agents/compass.py +48 -0
- simweave/agents/routing.py +113 -0
- simweave/continuous/__init__.py +31 -0
- simweave/continuous/solver.py +237 -0
- simweave/continuous/systems/__init__.py +14 -0
- simweave/continuous/systems/mass_spring_damper.py +43 -0
- simweave/continuous/systems/pendulum.py +49 -0
- simweave/continuous/systems/quarter_car.py +59 -0
- simweave/continuous/systems/rlc.py +44 -0
- simweave/continuous/systems/thermal.py +127 -0
- simweave/core/__init__.py +18 -0
- simweave/core/clock.py +64 -0
- simweave/core/entity.py +74 -0
- simweave/core/environment.py +121 -0
- simweave/core/logging.py +41 -0
- simweave/core/scheduler.py +85 -0
- simweave/currency/__init__.py +64 -0
- simweave/currency/codes.py +257 -0
- simweave/currency/format.py +92 -0
- simweave/currency/fx.py +126 -0
- simweave/currency/money.py +328 -0
- simweave/discrete/__init__.py +28 -0
- simweave/discrete/properties.py +113 -0
- simweave/discrete/queues.py +317 -0
- simweave/discrete/resources.py +89 -0
- simweave/discrete/services.py +228 -0
- simweave/mc/__init__.py +5 -0
- simweave/mc/runner.py +151 -0
- simweave/spatial/__init__.py +5 -0
- simweave/spatial/graph.py +147 -0
- simweave/supplychain/__init__.py +9 -0
- simweave/supplychain/inventory.py +77 -0
- simweave/supplychain/optimization.py +197 -0
- simweave/supplychain/warehouse.py +153 -0
- simweave/units/__init__.py +25 -0
- simweave/units/si.py +213 -0
- simweave/viz/__init__.py +77 -0
- simweave/viz/_plotly.py +35 -0
- simweave/viz/plots.py +618 -0
- simweave/viz/recorders.py +172 -0
- simweave/viz/themes.py +187 -0
- simweave-0.4.0.dist-info/METADATA +304 -0
- simweave-0.4.0.dist-info/RECORD +48 -0
- simweave-0.4.0.dist-info/WHEEL +5 -0
- simweave-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""First-order state-space ODE solver and plug-in system protocol.
|
|
2
|
+
|
|
3
|
+
Two use modes:
|
|
4
|
+
|
|
5
|
+
1. Standalone: call :func:`simulate` with a system, time span, and ``dt``.
|
|
6
|
+
Returns a :class:`SimulationResult` with full time/state arrays.
|
|
7
|
+
2. Hybrid: wrap a system in :class:`ContinuousProcess` and register with a
|
|
8
|
+
:class:`~simweave.core.environment.SimEnvironment`. The environment clock
|
|
9
|
+
then drives the integration. Useful when discrete events and continuous
|
|
10
|
+
dynamics share a timeline (e.g. a vehicle queued at a junction whose
|
|
11
|
+
suspension is still integrating over bumps).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Callable, Iterable, Optional, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from simweave.core.entity import Entity
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ArrayLike = np.ndarray
|
|
25
|
+
InputFunc = Callable[[float], np.ndarray | float | int | None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class SimulationResult:
|
|
30
|
+
time: np.ndarray
|
|
31
|
+
state: np.ndarray
|
|
32
|
+
state_labels: tuple[str, ...]
|
|
33
|
+
system_name: str
|
|
34
|
+
method: str
|
|
35
|
+
|
|
36
|
+
def final_state(self) -> np.ndarray:
|
|
37
|
+
return self.state[-1].copy()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class SupportsDynamics(Protocol):
|
|
42
|
+
"""Protocol for plug-in dynamic systems."""
|
|
43
|
+
|
|
44
|
+
def initial_state(self) -> np.ndarray: ...
|
|
45
|
+
|
|
46
|
+
def derivatives(
|
|
47
|
+
self,
|
|
48
|
+
t: float,
|
|
49
|
+
state: np.ndarray,
|
|
50
|
+
inputs: np.ndarray | float | int | None = None,
|
|
51
|
+
) -> np.ndarray: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DynamicSystem:
|
|
55
|
+
"""Base class for first-order state-space dynamic systems."""
|
|
56
|
+
|
|
57
|
+
def initial_state(self) -> np.ndarray:
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
|
|
60
|
+
def derivatives(
|
|
61
|
+
self,
|
|
62
|
+
t: float,
|
|
63
|
+
state: np.ndarray,
|
|
64
|
+
inputs: np.ndarray | float | int | None = None,
|
|
65
|
+
) -> np.ndarray:
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
def inputs(self, t: float) -> np.ndarray | float | int | None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def state_labels(self) -> tuple[str, ...]:
|
|
72
|
+
x0 = self.initial_state()
|
|
73
|
+
return tuple(f"x{i}" for i in range(x0.size))
|
|
74
|
+
|
|
75
|
+
def validate_state(self, state: np.ndarray) -> None:
|
|
76
|
+
if state.ndim != 1:
|
|
77
|
+
raise ValueError("State must be a 1D array.")
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def name(self) -> str:
|
|
81
|
+
return self.__class__.__name__
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _as_state_vector(state: Iterable[float] | np.ndarray) -> np.ndarray:
|
|
85
|
+
arr = np.asarray(state, dtype=float)
|
|
86
|
+
if arr.ndim != 1:
|
|
87
|
+
raise ValueError("State must be coercible to a 1D float array.")
|
|
88
|
+
return arr
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_inputs(
|
|
92
|
+
system: SupportsDynamics,
|
|
93
|
+
external_inputs: Optional[InputFunc],
|
|
94
|
+
t: float,
|
|
95
|
+
) -> np.ndarray | float | int | None:
|
|
96
|
+
if external_inputs is not None:
|
|
97
|
+
return external_inputs(t)
|
|
98
|
+
if hasattr(system, "inputs"):
|
|
99
|
+
return system.inputs(t) # type: ignore[attr-defined]
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _step_euler(system, t, x, dt, external_inputs):
|
|
104
|
+
u = _resolve_inputs(system, external_inputs, t)
|
|
105
|
+
dx = np.asarray(system.derivatives(t, x, u), dtype=float)
|
|
106
|
+
return x + dt * dx
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _step_rk4(system, t, x, dt, external_inputs):
|
|
110
|
+
u1 = _resolve_inputs(system, external_inputs, t)
|
|
111
|
+
k1 = np.asarray(system.derivatives(t, x, u1), dtype=float)
|
|
112
|
+
u2 = _resolve_inputs(system, external_inputs, t + 0.5 * dt)
|
|
113
|
+
k2 = np.asarray(
|
|
114
|
+
system.derivatives(t + 0.5 * dt, x + 0.5 * dt * k1, u2), dtype=float
|
|
115
|
+
)
|
|
116
|
+
k3 = np.asarray(
|
|
117
|
+
system.derivatives(t + 0.5 * dt, x + 0.5 * dt * k2, u2), dtype=float
|
|
118
|
+
)
|
|
119
|
+
u4 = _resolve_inputs(system, external_inputs, t + dt)
|
|
120
|
+
k4 = np.asarray(system.derivatives(t + dt, x + dt * k3, u4), dtype=float)
|
|
121
|
+
return x + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_STEPPERS = {"euler": _step_euler, "rk4": _step_rk4}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def simulate(
|
|
128
|
+
system: SupportsDynamics,
|
|
129
|
+
t_span: tuple[float, float],
|
|
130
|
+
dt: float,
|
|
131
|
+
x0: Iterable[float] | np.ndarray | None = None,
|
|
132
|
+
method: str = "rk4",
|
|
133
|
+
inputs: Optional[InputFunc] = None,
|
|
134
|
+
) -> SimulationResult:
|
|
135
|
+
"""Integrate a system over ``t_span`` with fixed step ``dt``."""
|
|
136
|
+
t0, tf = t_span
|
|
137
|
+
if dt <= 0:
|
|
138
|
+
raise ValueError("dt must be positive.")
|
|
139
|
+
if tf <= t0:
|
|
140
|
+
raise ValueError("t_span must satisfy tf > t0.")
|
|
141
|
+
if method not in _STEPPERS:
|
|
142
|
+
raise ValueError(f"Unknown method '{method}'. Use one of {tuple(_STEPPERS)}")
|
|
143
|
+
|
|
144
|
+
x_init = _as_state_vector(system.initial_state() if x0 is None else x0)
|
|
145
|
+
if hasattr(system, "validate_state"):
|
|
146
|
+
system.validate_state(x_init) # type: ignore[attr-defined]
|
|
147
|
+
|
|
148
|
+
n_steps = int(np.floor((tf - t0) / dt)) + 1
|
|
149
|
+
time = t0 + np.arange(n_steps + 1, dtype=float) * dt
|
|
150
|
+
time = time[time <= tf + 1e-12]
|
|
151
|
+
|
|
152
|
+
state = np.zeros((time.size, x_init.size), dtype=float)
|
|
153
|
+
state[0] = x_init
|
|
154
|
+
|
|
155
|
+
stepper = _STEPPERS[method]
|
|
156
|
+
for i in range(1, time.size):
|
|
157
|
+
state[i] = stepper(system, time[i - 1], state[i - 1], dt, inputs)
|
|
158
|
+
|
|
159
|
+
labels = (
|
|
160
|
+
tuple(system.state_labels())
|
|
161
|
+
if hasattr(system, "state_labels")
|
|
162
|
+
else tuple(f"x{i}" for i in range(x_init.size))
|
|
163
|
+
)
|
|
164
|
+
return SimulationResult(
|
|
165
|
+
time=time,
|
|
166
|
+
state=state,
|
|
167
|
+
state_labels=labels,
|
|
168
|
+
system_name=getattr(system, "name", system.__class__.__name__),
|
|
169
|
+
method=method,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Hybrid wrapper: turn a DynamicSystem into a SimEnvironment process.
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ContinuousProcess(Entity):
|
|
179
|
+
"""Drive a DynamicSystem from a SimEnvironment's tick loop.
|
|
180
|
+
|
|
181
|
+
Each tick, integrates ``n_substeps`` sub-intervals of length ``dt / n_substeps``.
|
|
182
|
+
The previous state is appended to ``history_state`` with the corresponding
|
|
183
|
+
time in ``history_time``.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
system: SupportsDynamics,
|
|
189
|
+
method: str = "rk4",
|
|
190
|
+
n_substeps: int = 1,
|
|
191
|
+
inputs: InputFunc | None = None,
|
|
192
|
+
name: str | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
super().__init__(
|
|
195
|
+
name=name or getattr(system, "name", system.__class__.__name__)
|
|
196
|
+
)
|
|
197
|
+
if method not in _STEPPERS:
|
|
198
|
+
raise ValueError(f"Unknown method '{method}'")
|
|
199
|
+
if n_substeps < 1:
|
|
200
|
+
raise ValueError("n_substeps must be >= 1")
|
|
201
|
+
self.system = system
|
|
202
|
+
self.method = method
|
|
203
|
+
self.n_substeps = n_substeps
|
|
204
|
+
self.inputs = inputs
|
|
205
|
+
self.state = _as_state_vector(system.initial_state())
|
|
206
|
+
self._stepper = _STEPPERS[method]
|
|
207
|
+
self.history_time: list[float] = []
|
|
208
|
+
self.history_state: list[np.ndarray] = []
|
|
209
|
+
|
|
210
|
+
def on_register(self, env) -> None:
|
|
211
|
+
super().on_register(env)
|
|
212
|
+
self.history_time.append(env.clock.t)
|
|
213
|
+
self.history_state.append(self.state.copy())
|
|
214
|
+
|
|
215
|
+
def tick(self, dt: float, env) -> None:
|
|
216
|
+
super().tick(dt, env)
|
|
217
|
+
sub_dt = dt / self.n_substeps
|
|
218
|
+
t = env.clock.t
|
|
219
|
+
for _ in range(self.n_substeps):
|
|
220
|
+
self.state = self._stepper(self.system, t, self.state, sub_dt, self.inputs)
|
|
221
|
+
t += sub_dt
|
|
222
|
+
self.history_time.append(env.clock.t + dt)
|
|
223
|
+
self.history_state.append(self.state.copy())
|
|
224
|
+
|
|
225
|
+
def has_work(self, env) -> bool:
|
|
226
|
+
return True # continuous dynamics always "have work"
|
|
227
|
+
|
|
228
|
+
def result(self) -> SimulationResult:
|
|
229
|
+
return SimulationResult(
|
|
230
|
+
time=np.asarray(self.history_time),
|
|
231
|
+
state=np.asarray(self.history_state),
|
|
232
|
+
state_labels=tuple(self.system.state_labels())
|
|
233
|
+
if hasattr(self.system, "state_labels")
|
|
234
|
+
else tuple(f"x{i}" for i in range(self.state.size)),
|
|
235
|
+
system_name=getattr(self.system, "name", self.system.__class__.__name__),
|
|
236
|
+
method=self.method,
|
|
237
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from simweave.continuous.systems.mass_spring_damper import MassSpringDamper
|
|
2
|
+
from simweave.continuous.systems.pendulum import SimplePendulum
|
|
3
|
+
from simweave.continuous.systems.quarter_car import QuarterCarModel
|
|
4
|
+
from simweave.continuous.systems.rlc import SeriesRLC
|
|
5
|
+
from simweave.continuous.systems.thermal import ThermalRC, TwoMassThermal
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MassSpringDamper",
|
|
9
|
+
"SimplePendulum",
|
|
10
|
+
"QuarterCarModel",
|
|
11
|
+
"SeriesRLC",
|
|
12
|
+
"ThermalRC",
|
|
13
|
+
"TwoMassThermal",
|
|
14
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from simweave.continuous.solver import DynamicSystem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MassSpringDamper(DynamicSystem):
|
|
9
|
+
"""Single degree-of-freedom mass-spring-damper.
|
|
10
|
+
|
|
11
|
+
State x = [displacement, velocity]
|
|
12
|
+
Equation: m x'' + c x' + k x = F(t)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
mass: float,
|
|
18
|
+
damping: float,
|
|
19
|
+
stiffness: float,
|
|
20
|
+
x0: tuple[float, float] = (0.0, 0.0),
|
|
21
|
+
):
|
|
22
|
+
if mass <= 0:
|
|
23
|
+
raise ValueError("mass must be positive")
|
|
24
|
+
self.mass = float(mass)
|
|
25
|
+
self.damping = float(damping)
|
|
26
|
+
self.stiffness = float(stiffness)
|
|
27
|
+
self._x0 = np.asarray(x0, dtype=float)
|
|
28
|
+
|
|
29
|
+
def initial_state(self) -> np.ndarray:
|
|
30
|
+
return self._x0.copy()
|
|
31
|
+
|
|
32
|
+
def state_labels(self) -> tuple[str, str]:
|
|
33
|
+
return ("x", "x_dot")
|
|
34
|
+
|
|
35
|
+
def derivatives(
|
|
36
|
+
self, t: float, state: np.ndarray, inputs: float | int | None = None
|
|
37
|
+
) -> np.ndarray:
|
|
38
|
+
displacement, velocity = state
|
|
39
|
+
force = 0.0 if inputs is None else float(inputs)
|
|
40
|
+
acceleration = (
|
|
41
|
+
force - self.damping * velocity - self.stiffness * displacement
|
|
42
|
+
) / self.mass
|
|
43
|
+
return np.array([velocity, acceleration], dtype=float)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from simweave.continuous.solver import DynamicSystem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SimplePendulum(DynamicSystem):
|
|
9
|
+
"""Simple pendulum with optional viscous damping and external torque.
|
|
10
|
+
|
|
11
|
+
State x = [theta, theta_dot]
|
|
12
|
+
Equation:
|
|
13
|
+
theta'' + (c / (m l^2)) theta' + (g/l) sin(theta) = tau / (m l^2)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
length: float,
|
|
19
|
+
mass: float = 1.0,
|
|
20
|
+
gravity: float = 9.81,
|
|
21
|
+
damping: float = 0.0,
|
|
22
|
+
x0: tuple[float, float] = (0.0, 0.0),
|
|
23
|
+
):
|
|
24
|
+
if length <= 0 or mass <= 0:
|
|
25
|
+
raise ValueError("length and mass must be positive")
|
|
26
|
+
self.length = float(length)
|
|
27
|
+
self.mass = float(mass)
|
|
28
|
+
self.gravity = float(gravity)
|
|
29
|
+
self.damping = float(damping)
|
|
30
|
+
self._x0 = np.asarray(x0, dtype=float)
|
|
31
|
+
self._inertia = self.mass * self.length**2
|
|
32
|
+
|
|
33
|
+
def initial_state(self) -> np.ndarray:
|
|
34
|
+
return self._x0.copy()
|
|
35
|
+
|
|
36
|
+
def state_labels(self) -> tuple[str, str]:
|
|
37
|
+
return ("theta", "theta_dot")
|
|
38
|
+
|
|
39
|
+
def derivatives(
|
|
40
|
+
self, t: float, state: np.ndarray, inputs: float | int | None = None
|
|
41
|
+
) -> np.ndarray:
|
|
42
|
+
theta, theta_dot = state
|
|
43
|
+
torque = 0.0 if inputs is None else float(inputs)
|
|
44
|
+
theta_ddot = (
|
|
45
|
+
torque
|
|
46
|
+
- self.damping * theta_dot
|
|
47
|
+
- self.mass * self.gravity * self.length * np.sin(theta)
|
|
48
|
+
) / self._inertia
|
|
49
|
+
return np.array([theta_dot, theta_ddot], dtype=float)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from simweave.continuous.solver import DynamicSystem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QuarterCarModel(DynamicSystem):
|
|
9
|
+
"""Two degree-of-freedom quarter-car suspension model.
|
|
10
|
+
|
|
11
|
+
State vector x = [z_s, z_s_dot, z_u, z_u_dot]
|
|
12
|
+
where:
|
|
13
|
+
z_s: sprung mass displacement
|
|
14
|
+
z_u: unsprung mass displacement
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
sprung_mass: float,
|
|
20
|
+
unsprung_mass: float,
|
|
21
|
+
suspension_stiffness: float,
|
|
22
|
+
damping: float,
|
|
23
|
+
tyre_stiffness: float,
|
|
24
|
+
x0: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
|
|
25
|
+
):
|
|
26
|
+
if sprung_mass <= 0 or unsprung_mass <= 0:
|
|
27
|
+
raise ValueError("Masses must be positive")
|
|
28
|
+
self.m_s = float(sprung_mass)
|
|
29
|
+
self.m_u = float(unsprung_mass)
|
|
30
|
+
self.k_s = float(suspension_stiffness)
|
|
31
|
+
self.c_s = float(damping)
|
|
32
|
+
self.k_t = float(tyre_stiffness)
|
|
33
|
+
self._x0 = np.asarray(x0, dtype=float)
|
|
34
|
+
|
|
35
|
+
def initial_state(self) -> np.ndarray:
|
|
36
|
+
return self._x0.copy()
|
|
37
|
+
|
|
38
|
+
def state_labels(self) -> tuple[str, str, str, str]:
|
|
39
|
+
return ("z_s", "z_s_dot", "z_u", "z_u_dot")
|
|
40
|
+
|
|
41
|
+
def derivatives(
|
|
42
|
+
self, t: float, state: np.ndarray, inputs: float | int | None = None
|
|
43
|
+
) -> np.ndarray:
|
|
44
|
+
z_s, z_s_dot, z_u, z_u_dot = state
|
|
45
|
+
z_r = 0.0 if inputs is None else float(inputs)
|
|
46
|
+
|
|
47
|
+
suspension_deflection = z_s - z_u
|
|
48
|
+
suspension_velocity = z_s_dot - z_u_dot
|
|
49
|
+
|
|
50
|
+
z_s_ddot = (
|
|
51
|
+
-self.k_s * suspension_deflection - self.c_s * suspension_velocity
|
|
52
|
+
) / self.m_s
|
|
53
|
+
z_u_ddot = (
|
|
54
|
+
self.k_s * suspension_deflection
|
|
55
|
+
+ self.c_s * suspension_velocity
|
|
56
|
+
- self.k_t * (z_u - z_r)
|
|
57
|
+
) / self.m_u
|
|
58
|
+
|
|
59
|
+
return np.array([z_s_dot, z_s_ddot, z_u_dot, z_u_ddot], dtype=float)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from simweave.continuous.solver import DynamicSystem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SeriesRLC(DynamicSystem):
|
|
9
|
+
"""Series RLC circuit in charge-current form.
|
|
10
|
+
|
|
11
|
+
State x = [q, i]
|
|
12
|
+
Equation:
|
|
13
|
+
L q'' + R q' + (1/C) q = V(t)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
resistance: float,
|
|
19
|
+
inductance: float,
|
|
20
|
+
capacitance: float,
|
|
21
|
+
x0: tuple[float, float] = (0.0, 0.0),
|
|
22
|
+
):
|
|
23
|
+
if inductance <= 0 or capacitance <= 0:
|
|
24
|
+
raise ValueError("inductance and capacitance must be positive")
|
|
25
|
+
self.resistance = float(resistance)
|
|
26
|
+
self.inductance = float(inductance)
|
|
27
|
+
self.capacitance = float(capacitance)
|
|
28
|
+
self._x0 = np.asarray(x0, dtype=float)
|
|
29
|
+
|
|
30
|
+
def initial_state(self) -> np.ndarray:
|
|
31
|
+
return self._x0.copy()
|
|
32
|
+
|
|
33
|
+
def state_labels(self) -> tuple[str, str]:
|
|
34
|
+
return ("charge", "current")
|
|
35
|
+
|
|
36
|
+
def derivatives(
|
|
37
|
+
self, t: float, state: np.ndarray, inputs: float | int | None = None
|
|
38
|
+
) -> np.ndarray:
|
|
39
|
+
charge, current = state
|
|
40
|
+
voltage = 0.0 if inputs is None else float(inputs)
|
|
41
|
+
current_dot = (
|
|
42
|
+
voltage - self.resistance * current - charge / self.capacitance
|
|
43
|
+
) / self.inductance
|
|
44
|
+
return np.array([current, current_dot], dtype=float)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Lumped-capacitance thermal models.
|
|
2
|
+
|
|
3
|
+
Two models live here:
|
|
4
|
+
|
|
5
|
+
* :class:`ThermalRC` -- a single-body thermal RC circuit. State is a
|
|
6
|
+
single temperature `T` that exchanges heat with an ambient `T_inf`
|
|
7
|
+
through a thermal resistance `R_th`, and stores energy in a thermal
|
|
8
|
+
capacitance `C_th`. Equation:
|
|
9
|
+
|
|
10
|
+
C_th * dT/dt = (T_inf - T) / R_th + Q_in(t)
|
|
11
|
+
|
|
12
|
+
Useful for batteries, electronics packages, and any first-order
|
|
13
|
+
thermal behaviour.
|
|
14
|
+
|
|
15
|
+
* :class:`TwoMassThermal` -- two coupled lumps (e.g. a CPU core and a
|
|
16
|
+
heat sink) with internal conductance `k_12` between them and external
|
|
17
|
+
resistance from the second mass to ambient.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from simweave.continuous.solver import DynamicSystem
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ThermalRC(DynamicSystem):
|
|
28
|
+
"""First-order lumped-capacitance thermal model.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
thermal_resistance:
|
|
33
|
+
R_th in K/W. Larger => slower equilibration with ambient.
|
|
34
|
+
thermal_capacitance:
|
|
35
|
+
C_th in J/K. Larger => more thermal inertia.
|
|
36
|
+
ambient_temperature:
|
|
37
|
+
T_inf in Kelvin (or any consistent unit). Default 293.15 K (20 C).
|
|
38
|
+
initial_temperature:
|
|
39
|
+
Starting body temperature, same unit as ``ambient_temperature``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
thermal_resistance: float,
|
|
45
|
+
thermal_capacitance: float,
|
|
46
|
+
ambient_temperature: float = 293.15,
|
|
47
|
+
initial_temperature: float = 293.15,
|
|
48
|
+
) -> None:
|
|
49
|
+
if thermal_resistance <= 0 or thermal_capacitance <= 0:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"thermal_resistance and thermal_capacitance must be positive."
|
|
52
|
+
)
|
|
53
|
+
self.R_th = float(thermal_resistance)
|
|
54
|
+
self.C_th = float(thermal_capacitance)
|
|
55
|
+
self.T_inf = float(ambient_temperature)
|
|
56
|
+
self._x0 = np.array([float(initial_temperature)], dtype=float)
|
|
57
|
+
|
|
58
|
+
def initial_state(self) -> np.ndarray:
|
|
59
|
+
return self._x0.copy()
|
|
60
|
+
|
|
61
|
+
def state_labels(self) -> tuple[str, ...]:
|
|
62
|
+
return ("temperature",)
|
|
63
|
+
|
|
64
|
+
def derivatives(
|
|
65
|
+
self, t: float, state: np.ndarray, inputs: float | int | None = None
|
|
66
|
+
) -> np.ndarray:
|
|
67
|
+
T = state[0]
|
|
68
|
+
heat_in = 0.0 if inputs is None else float(inputs)
|
|
69
|
+
dTdt = ((self.T_inf - T) / self.R_th + heat_in) / self.C_th
|
|
70
|
+
return np.array([dTdt], dtype=float)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def time_constant(self) -> float:
|
|
74
|
+
"""First-order time constant tau = R_th * C_th (seconds)."""
|
|
75
|
+
return self.R_th * self.C_th
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TwoMassThermal(DynamicSystem):
|
|
79
|
+
"""Two-lump thermal model: core <-> sink <-> ambient.
|
|
80
|
+
|
|
81
|
+
State: [T_core, T_sink], both in the same unit as ambient.
|
|
82
|
+
Q_in(t) is applied to the core. Sink dissipates to ambient via
|
|
83
|
+
``R_sink_to_ambient``; core couples to sink via conductance
|
|
84
|
+
``k_core_to_sink`` (W/K).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
C_core: float,
|
|
90
|
+
C_sink: float,
|
|
91
|
+
k_core_to_sink: float,
|
|
92
|
+
R_sink_to_ambient: float,
|
|
93
|
+
ambient_temperature: float = 293.15,
|
|
94
|
+
initial_core: float = 293.15,
|
|
95
|
+
initial_sink: float = 293.15,
|
|
96
|
+
) -> None:
|
|
97
|
+
for name, val in (
|
|
98
|
+
("C_core", C_core),
|
|
99
|
+
("C_sink", C_sink),
|
|
100
|
+
("k_core_to_sink", k_core_to_sink),
|
|
101
|
+
("R_sink_to_ambient", R_sink_to_ambient),
|
|
102
|
+
):
|
|
103
|
+
if val <= 0:
|
|
104
|
+
raise ValueError(f"{name} must be positive.")
|
|
105
|
+
self.C_core = float(C_core)
|
|
106
|
+
self.C_sink = float(C_sink)
|
|
107
|
+
self.k_cs = float(k_core_to_sink)
|
|
108
|
+
self.R_sa = float(R_sink_to_ambient)
|
|
109
|
+
self.T_inf = float(ambient_temperature)
|
|
110
|
+
self._x0 = np.array([initial_core, initial_sink], dtype=float)
|
|
111
|
+
|
|
112
|
+
def initial_state(self) -> np.ndarray:
|
|
113
|
+
return self._x0.copy()
|
|
114
|
+
|
|
115
|
+
def state_labels(self) -> tuple[str, ...]:
|
|
116
|
+
return ("core_temperature", "sink_temperature")
|
|
117
|
+
|
|
118
|
+
def derivatives(
|
|
119
|
+
self, t: float, state: np.ndarray, inputs: float | int | None = None
|
|
120
|
+
) -> np.ndarray:
|
|
121
|
+
T_c, T_s = state
|
|
122
|
+
Q_in = 0.0 if inputs is None else float(inputs)
|
|
123
|
+
q_cs = self.k_cs * (T_c - T_s) # core -> sink
|
|
124
|
+
q_sa = (T_s - self.T_inf) / self.R_sa # sink -> ambient
|
|
125
|
+
dTc = (Q_in - q_cs) / self.C_core
|
|
126
|
+
dTs = (q_cs - q_sa) / self.C_sink
|
|
127
|
+
return np.array([dTc, dTs], dtype=float)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Core runtime: clock, scheduler, entity, environment, logging."""
|
|
2
|
+
|
|
3
|
+
from simweave.core.clock import Clock
|
|
4
|
+
from simweave.core.scheduler import EventQueue, ScheduledEvent
|
|
5
|
+
from simweave.core.entity import Entity
|
|
6
|
+
from simweave.core.environment import SimEnvironment, Process
|
|
7
|
+
from simweave.core.logging import configure, get_logger
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Clock",
|
|
11
|
+
"EventQueue",
|
|
12
|
+
"ScheduledEvent",
|
|
13
|
+
"Entity",
|
|
14
|
+
"SimEnvironment",
|
|
15
|
+
"Process",
|
|
16
|
+
"configure",
|
|
17
|
+
"get_logger",
|
|
18
|
+
]
|
simweave/core/clock.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Simulation clock.
|
|
2
|
+
|
|
3
|
+
simweave uses a fixed-step *atomic* clock as its time authority. Every process
|
|
4
|
+
registered with the :class:`~simweave.core.environment.SimEnvironment` is
|
|
5
|
+
invoked once per tick with the clock's current ``dt``.
|
|
6
|
+
|
|
7
|
+
Atomic time is a design choice that makes Monte Carlo aggregation trivial:
|
|
8
|
+
every replicate shares the same time grid, so summaries collapse to array
|
|
9
|
+
reductions rather than ragged joins. Where sparse events would otherwise waste
|
|
10
|
+
cycles, the environment can cooperate with the optional ``EventQueue`` to
|
|
11
|
+
fast-forward idle gaps (see :mod:`simweave.core.scheduler`).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Clock:
|
|
21
|
+
"""Fixed-step simulation clock.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
start:
|
|
26
|
+
Initial simulation time.
|
|
27
|
+
dt:
|
|
28
|
+
Fixed timestep. Units are whatever the rest of the model uses
|
|
29
|
+
(typically seconds, but can be hours, days, or dimensionless ticks).
|
|
30
|
+
end:
|
|
31
|
+
Optional end time. If provided, :meth:`is_finished` returns ``True``
|
|
32
|
+
once ``t >= end``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
start: float = 0.0
|
|
36
|
+
dt: float = 1.0
|
|
37
|
+
end: float | None = None
|
|
38
|
+
t: float = 0.0
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if self.dt <= 0:
|
|
42
|
+
raise ValueError("Clock.dt must be positive.")
|
|
43
|
+
if self.end is not None and self.end <= self.start:
|
|
44
|
+
raise ValueError("Clock.end must be strictly greater than start.")
|
|
45
|
+
self.t = self.start
|
|
46
|
+
|
|
47
|
+
def advance(self, dt: float | None = None) -> None:
|
|
48
|
+
"""Advance the clock by ``dt`` (defaults to ``self.dt``)."""
|
|
49
|
+
step = self.dt if dt is None else dt
|
|
50
|
+
if step < 0:
|
|
51
|
+
raise ValueError("Cannot advance clock by a negative interval.")
|
|
52
|
+
self.t += step
|
|
53
|
+
|
|
54
|
+
def jump_to(self, t: float) -> None:
|
|
55
|
+
"""Jump the clock forward to absolute time ``t`` (no backward jumps)."""
|
|
56
|
+
if t < self.t:
|
|
57
|
+
raise ValueError("Cannot rewind the clock.")
|
|
58
|
+
self.t = t
|
|
59
|
+
|
|
60
|
+
def reset(self) -> None:
|
|
61
|
+
self.t = self.start
|
|
62
|
+
|
|
63
|
+
def is_finished(self) -> bool:
|
|
64
|
+
return self.end is not None and self.t >= self.end
|