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.
Files changed (48) hide show
  1. simweave/__init__.py +198 -0
  2. simweave/agents/__init__.py +23 -0
  3. simweave/agents/agent.py +161 -0
  4. simweave/agents/compass.py +48 -0
  5. simweave/agents/routing.py +113 -0
  6. simweave/continuous/__init__.py +31 -0
  7. simweave/continuous/solver.py +237 -0
  8. simweave/continuous/systems/__init__.py +14 -0
  9. simweave/continuous/systems/mass_spring_damper.py +43 -0
  10. simweave/continuous/systems/pendulum.py +49 -0
  11. simweave/continuous/systems/quarter_car.py +59 -0
  12. simweave/continuous/systems/rlc.py +44 -0
  13. simweave/continuous/systems/thermal.py +127 -0
  14. simweave/core/__init__.py +18 -0
  15. simweave/core/clock.py +64 -0
  16. simweave/core/entity.py +74 -0
  17. simweave/core/environment.py +121 -0
  18. simweave/core/logging.py +41 -0
  19. simweave/core/scheduler.py +85 -0
  20. simweave/currency/__init__.py +64 -0
  21. simweave/currency/codes.py +257 -0
  22. simweave/currency/format.py +92 -0
  23. simweave/currency/fx.py +126 -0
  24. simweave/currency/money.py +328 -0
  25. simweave/discrete/__init__.py +28 -0
  26. simweave/discrete/properties.py +113 -0
  27. simweave/discrete/queues.py +317 -0
  28. simweave/discrete/resources.py +89 -0
  29. simweave/discrete/services.py +228 -0
  30. simweave/mc/__init__.py +5 -0
  31. simweave/mc/runner.py +151 -0
  32. simweave/spatial/__init__.py +5 -0
  33. simweave/spatial/graph.py +147 -0
  34. simweave/supplychain/__init__.py +9 -0
  35. simweave/supplychain/inventory.py +77 -0
  36. simweave/supplychain/optimization.py +197 -0
  37. simweave/supplychain/warehouse.py +153 -0
  38. simweave/units/__init__.py +25 -0
  39. simweave/units/si.py +213 -0
  40. simweave/viz/__init__.py +77 -0
  41. simweave/viz/_plotly.py +35 -0
  42. simweave/viz/plots.py +618 -0
  43. simweave/viz/recorders.py +172 -0
  44. simweave/viz/themes.py +187 -0
  45. simweave-0.4.0.dist-info/METADATA +304 -0
  46. simweave-0.4.0.dist-info/RECORD +48 -0
  47. simweave-0.4.0.dist-info/WHEEL +5 -0
  48. 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