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,153 @@
1
+ """Warehouse entity that consumes inventory and replenishes from a parent.
2
+
3
+ Simulation model (per tick):
4
+ 1. Count down any outstanding reorder lead times; receive completed orders.
5
+ 2. If a SKU's stock level is at or below its reorder point AND no order is
6
+ already in flight, raise a new order for ``batchsize``. If the parent
7
+ warehouse has stock, decrement them; otherwise record a backorder.
8
+
9
+ Use :meth:`decrement_vector` (vectorised consumption) each tick to represent
10
+ demand. Wire two Warehouses via ``parent_warehouse=`` to form multi-echelon
11
+ supply chains; the outermost one uses the sentinel string ``"industry"``
12
+ for infinite replenishment.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+ from simweave.core.entity import Entity
20
+ from simweave.core.logging import get_logger
21
+ from simweave.supplychain.inventory import InventoryItems
22
+
23
+ log = get_logger("supplychain.warehouse")
24
+
25
+
26
+ class Warehouse(Entity):
27
+ """Multi-SKU warehouse with (R, Q) reorder policy."""
28
+
29
+ def __init__(
30
+ self,
31
+ inventory: InventoryItems,
32
+ name: str | None = None,
33
+ parent_warehouse: "Warehouse | str" = "industry",
34
+ ) -> None:
35
+ super().__init__(name=name)
36
+ self.inv = inventory
37
+ self.parent: "Warehouse | str" = parent_warehouse
38
+
39
+ n = inventory.n_items
40
+ self._reorder_time_remaining = np.zeros(n, dtype=float)
41
+ self._orders_placed_bool = np.zeros(n, dtype=bool)
42
+ self._reorders_volume = np.zeros(n, dtype=float)
43
+ self._lifetime_total_orders = inventory.stock_level.copy()
44
+ self._lifetime_backorders = np.zeros(n, dtype=float)
45
+ self._demand_rate: np.ndarray | None = None
46
+
47
+ # ------------------------------------------------------------------
48
+ # Stock ops
49
+ # ------------------------------------------------------------------
50
+ def increment_by_idx(self, idx: int, amount: float = 1.0) -> None:
51
+ self.inv.stock_level[idx] += amount
52
+
53
+ def increment_vector(self, vec: np.ndarray) -> None:
54
+ self.inv.stock_level = self.inv.stock_level + np.asarray(vec, dtype=float)
55
+
56
+ def decrement_by_idx(self, idx: int, amount: float = 1.0) -> bool:
57
+ if self.inv.stock_level[idx] >= amount:
58
+ self.inv.stock_level[idx] -= amount
59
+ return True
60
+ log.debug(
61
+ "%s: item %s unavailable (have %.2f want %.2f).",
62
+ self.name,
63
+ self.inv.part_names[idx],
64
+ self.inv.stock_level[idx],
65
+ amount,
66
+ )
67
+ return False
68
+
69
+ def decrement_vector(self, vec: np.ndarray) -> np.ndarray:
70
+ """Vectorised consumption; returns a boolean mask of which SKUs succeeded."""
71
+ vec = np.asarray(vec, dtype=float)
72
+ avail = self.inv.stock_level >= vec
73
+ self.inv.stock_level = self.inv.stock_level - vec * avail.astype(float)
74
+ if not avail.all():
75
+ log.debug("%s: %d SKUs unavailable this tick.", self.name, (~avail).sum())
76
+ return avail
77
+
78
+ def check_items_available(self, requested: np.ndarray) -> np.ndarray:
79
+ return self.inv.stock_level >= np.asarray(requested, dtype=float)
80
+
81
+ # ------------------------------------------------------------------
82
+ # Reorder bookkeeping
83
+ # ------------------------------------------------------------------
84
+ def _receive_pending(self, elapsed: float = 1.0) -> None:
85
+ self._reorder_time_remaining = np.maximum(
86
+ 0.0, self._reorder_time_remaining - elapsed
87
+ )
88
+ arrived = (self._reorder_time_remaining == 0) & (self._reorders_volume > 0)
89
+ if arrived.any():
90
+ deliveries = self._reorders_volume * arrived
91
+ self.increment_vector(deliveries)
92
+ self._reorders_volume = self._reorders_volume - deliveries
93
+ self._orders_placed_bool[arrived] = False
94
+
95
+ def _place_reorders(self) -> None:
96
+ need_reorder = (self.inv.stock_level <= self.inv.reorder_points) & (
97
+ ~self._orders_placed_bool
98
+ )
99
+ if not need_reorder.any():
100
+ return
101
+ reorders = self.inv.batchsize * need_reorder
102
+
103
+ if self.parent == "industry":
104
+ # Infinite replenishment.
105
+ pass
106
+ else:
107
+ assert isinstance(self.parent, Warehouse)
108
+ avail = self.parent.check_items_available(reorders)
109
+ # Decrement parent for the subset that is available.
110
+ self.parent.inv.stock_level = (
111
+ self.parent.inv.stock_level - reorders * avail.astype(float)
112
+ )
113
+ if not avail.all():
114
+ missing = ~avail & need_reorder
115
+ if missing.any():
116
+ self._lifetime_backorders[missing] += reorders[missing]
117
+ need_reorder = need_reorder & avail
118
+ reorders = reorders * avail.astype(float)
119
+
120
+ self._orders_placed_bool = self._orders_placed_bool | need_reorder
121
+ self._reorders_volume = self._reorders_volume + reorders
122
+ self._reorder_time_remaining[need_reorder] = self.inv.newbuy_leadtimes[
123
+ need_reorder
124
+ ]
125
+ self._lifetime_total_orders = self._lifetime_total_orders + reorders
126
+
127
+ def process_orders(self, elapsed: float = 1.0) -> None:
128
+ """Tick the reorder logic by ``elapsed`` time units."""
129
+ self._receive_pending(elapsed)
130
+ self._place_reorders()
131
+
132
+ # ------------------------------------------------------------------
133
+ # Simulation tick integration
134
+ # ------------------------------------------------------------------
135
+ def tick(self, dt: float, env) -> None:
136
+ super().tick(dt, env)
137
+ # Default tick just counts down pending orders + places new.
138
+ # Consumption is driven by external demand -- users call
139
+ # decrement_vector() from a demand generator process.
140
+ self.process_orders(elapsed=dt)
141
+
142
+ def has_work(self, env) -> bool:
143
+ return bool((self._reorders_volume > 0).any() or self._orders_placed_bool.any())
144
+
145
+ # ------------------------------------------------------------------
146
+ # Post-hoc estimates
147
+ # ------------------------------------------------------------------
148
+ def estimate_demand_rate(self, total_sim_time: float) -> np.ndarray:
149
+ """Crude demand rate = lifetime orders / total_sim_time."""
150
+ if total_sim_time <= 0:
151
+ raise ValueError("total_sim_time must be positive.")
152
+ self._demand_rate = self._lifetime_total_orders / total_sim_time
153
+ return self._demand_rate
@@ -0,0 +1,25 @@
1
+ """Dimensional quantities with SI exponent tracking."""
2
+
3
+ from simweave.units.si import (
4
+ SIUnit,
5
+ Distance,
6
+ Velocity,
7
+ Acceleration,
8
+ Mass,
9
+ Force,
10
+ Area,
11
+ Volume,
12
+ TimeUnit,
13
+ )
14
+
15
+ __all__ = [
16
+ "SIUnit",
17
+ "Distance",
18
+ "Velocity",
19
+ "Acceleration",
20
+ "Mass",
21
+ "Force",
22
+ "Area",
23
+ "Volume",
24
+ "TimeUnit",
25
+ ]
simweave/units/si.py ADDED
@@ -0,0 +1,213 @@
1
+ """SI unit classes with exponent-tracked dimensional analysis.
2
+
3
+ Every unit carries a 7-tuple of SI exponents in the order
4
+ (metre, kilogram, ampere, kelvin, mole, candela, second). Multiplication and
5
+ division compose exponents; the result is re-typed to a known concrete class
6
+ when the exponents match one of the registered shapes (e.g. m * m -> Area).
7
+ Addition and subtraction are only permitted between operands of the same
8
+ concrete type.
9
+
10
+ This module is pure-Python and has no numpy dependency so it can be used in
11
+ tight inner loops without allocating ndarrays.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import ClassVar
18
+
19
+ _SI_SYMBOLS = ("m", "kg", "A", "K", "mol", "cd", "s")
20
+
21
+
22
+ def _unit_string(exponents: tuple[int, ...]) -> str:
23
+ parts: list[str] = []
24
+ for symbol, exp in zip(_SI_SYMBOLS, exponents):
25
+ if exp == 0:
26
+ continue
27
+ parts.append(symbol if exp == 1 else f"{symbol}^{exp}")
28
+ return "*".join(parts) if parts else "dimensionless"
29
+
30
+
31
+ def _known_unit(exponents: tuple[int, ...]):
32
+ return _KNOWN_BY_EXP.get(exponents)
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class SIUnit:
37
+ """Generic SI quantity with exponent-tracked dimensional analysis."""
38
+
39
+ value: float
40
+ unit: str = "dimensionless"
41
+ exponents: list[int] = field(default_factory=lambda: [0] * 7)
42
+
43
+ def __post_init__(self) -> None:
44
+ if isinstance(self.value, SIUnit):
45
+ self.value = self.value.value
46
+ self.value = float(self.value)
47
+ if len(self.exponents) != 7:
48
+ raise ValueError("SI exponents must have length 7.")
49
+
50
+ # -- representation ------------------------------------------------
51
+ def __str__(self) -> str:
52
+ return f"{self.value} [{self.unit}]"
53
+
54
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
55
+ return f"{type(self).__name__}({self.value}, unit={self.unit!r})"
56
+
57
+ # -- additive ------------------------------------------------------
58
+ def _check_same_dim(self, other: "SIUnit") -> None:
59
+ if tuple(self.exponents) != tuple(other.exponents):
60
+ raise TypeError(
61
+ f"Cannot add/subtract {self.unit} and {other.unit}: different dimensions."
62
+ )
63
+
64
+ def __add__(self, other: object) -> "SIUnit":
65
+ if not isinstance(other, SIUnit):
66
+ raise TypeError(
67
+ f"Cannot add {type(self).__name__} and {type(other).__name__}."
68
+ )
69
+ self._check_same_dim(other)
70
+ cls = type(self) if type(self) is type(other) else SIUnit
71
+ if cls is SIUnit:
72
+ return SIUnit(self.value + other.value, self.unit, list(self.exponents))
73
+ return cls(self.value + other.value) # type: ignore[arg-type]
74
+
75
+ def __sub__(self, other: object) -> "SIUnit":
76
+ if not isinstance(other, SIUnit):
77
+ raise TypeError(
78
+ f"Cannot subtract {type(other).__name__} from {type(self).__name__}."
79
+ )
80
+ self._check_same_dim(other)
81
+ cls = type(self) if type(self) is type(other) else SIUnit
82
+ if cls is SIUnit:
83
+ return SIUnit(self.value - other.value, self.unit, list(self.exponents))
84
+ return cls(self.value - other.value) # type: ignore[arg-type]
85
+
86
+ # -- multiplicative -----------------------------------------------
87
+ def __mul__(self, other: object) -> "SIUnit":
88
+ if isinstance(other, (int, float)):
89
+ return (
90
+ type(self)(self.value * float(other))
91
+ if type(self) is not SIUnit
92
+ else SIUnit(self.value * float(other), self.unit, list(self.exponents))
93
+ )
94
+ if isinstance(other, SIUnit):
95
+ new_exp = tuple(a + b for a, b in zip(self.exponents, other.exponents))
96
+ cls = _known_unit(new_exp)
97
+ if cls is None:
98
+ return SIUnit(
99
+ self.value * other.value, _unit_string(new_exp), list(new_exp)
100
+ )
101
+ return cls(self.value * other.value)
102
+ raise TypeError(f"Unsupported multiplication with {type(other).__name__}.")
103
+
104
+ def __rmul__(self, other: object) -> "SIUnit":
105
+ return self.__mul__(other)
106
+
107
+ def __truediv__(self, other: object) -> "SIUnit":
108
+ if isinstance(other, (int, float)):
109
+ return (
110
+ type(self)(self.value / float(other))
111
+ if type(self) is not SIUnit
112
+ else SIUnit(self.value / float(other), self.unit, list(self.exponents))
113
+ )
114
+ if isinstance(other, SIUnit):
115
+ new_exp = tuple(a - b for a, b in zip(self.exponents, other.exponents))
116
+ cls = _known_unit(new_exp)
117
+ if cls is None:
118
+ return SIUnit(
119
+ self.value / other.value, _unit_string(new_exp), list(new_exp)
120
+ )
121
+ return cls(self.value / other.value)
122
+ raise TypeError(f"Unsupported division with {type(other).__name__}.")
123
+
124
+ def __eq__(self, other: object) -> bool:
125
+ if not isinstance(other, SIUnit):
126
+ return NotImplemented
127
+ return (
128
+ tuple(self.exponents) == tuple(other.exponents)
129
+ and self.value == other.value
130
+ )
131
+
132
+ def __hash__(self) -> int:
133
+ return hash((type(self), self.value, tuple(self.exponents)))
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Concrete dimensioned units. The existing public names are preserved so that
138
+ # the quarter-car / pendulum / RLC examples remain compatible.
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ class Distance(SIUnit):
143
+ def __init__(self, value: float | SIUnit):
144
+ super().__init__(value=value, unit="m", exponents=[1, 0, 0, 0, 0, 0, 0])
145
+
146
+
147
+ class Velocity(SIUnit):
148
+ def __init__(self, value: float | SIUnit):
149
+ super().__init__(value=value, unit="m/s", exponents=[1, 0, 0, 0, 0, 0, -1])
150
+
151
+
152
+ class Acceleration(SIUnit):
153
+ def __init__(self, value: float | SIUnit):
154
+ super().__init__(value=value, unit="m/s^2", exponents=[1, 0, 0, 0, 0, 0, -2])
155
+
156
+
157
+ class Mass(SIUnit):
158
+ def __init__(self, value: float | SIUnit):
159
+ super().__init__(value=value, unit="kg", exponents=[0, 1, 0, 0, 0, 0, 0])
160
+
161
+
162
+ class Force(SIUnit):
163
+ def __init__(self, value: float | SIUnit):
164
+ super().__init__(value=value, unit="N", exponents=[1, 1, 0, 0, 0, 0, -2])
165
+
166
+
167
+ class Area(SIUnit):
168
+ def __init__(self, value: float | SIUnit):
169
+ super().__init__(value=value, unit="m^2", exponents=[2, 0, 0, 0, 0, 0, 0])
170
+
171
+
172
+ class Volume(SIUnit):
173
+ def __init__(self, value: float | SIUnit):
174
+ super().__init__(value=value, unit="m^3", exponents=[3, 0, 0, 0, 0, 0, 0])
175
+
176
+
177
+ class TimeUnit(SIUnit):
178
+ """Time, stored canonically in seconds regardless of input unit."""
179
+
180
+ _SCALE_MAP: ClassVar[dict[str, float]] = {
181
+ "s": 1.0,
182
+ "sec": 1.0,
183
+ "ms": 1e-3,
184
+ "mins": 60.0,
185
+ "min": 60.0,
186
+ "hrs": 3600.0,
187
+ "h": 3600.0,
188
+ "days": 86400.0,
189
+ "day": 86400.0,
190
+ }
191
+
192
+ def __init__(self, value: float | SIUnit, unit: str = "s"):
193
+ if isinstance(value, SIUnit):
194
+ value = value.value
195
+ if unit not in self._SCALE_MAP:
196
+ raise ValueError(f"Unsupported time unit: {unit}")
197
+ super().__init__(
198
+ value=float(value) * self._SCALE_MAP[unit],
199
+ unit="s",
200
+ exponents=[0, 0, 0, 0, 0, 0, 1],
201
+ )
202
+
203
+
204
+ _KNOWN_BY_EXP: dict[tuple[int, ...], type[SIUnit]] = {
205
+ (1, 0, 0, 0, 0, 0, 0): Distance,
206
+ (1, 0, 0, 0, 0, 0, -1): Velocity,
207
+ (1, 0, 0, 0, 0, 0, -2): Acceleration,
208
+ (0, 1, 0, 0, 0, 0, 0): Mass,
209
+ (2, 0, 0, 0, 0, 0, 0): Area,
210
+ (3, 0, 0, 0, 0, 0, 0): Volume,
211
+ (1, 1, 0, 0, 0, 0, -2): Force,
212
+ (0, 0, 0, 0, 0, 0, 1): TimeUnit,
213
+ }
@@ -0,0 +1,77 @@
1
+ """``simweave.viz`` -- plotly-based visualisation helpers.
2
+
3
+ Every helper returns a ``plotly.graph_objects.Figure`` so JS frontends
4
+ (such as EdgeWeave) can consume the figure via ``fig.to_json()`` without
5
+ losing structure. Themes are applied via a small registry so users can
6
+ switch between light, dark and custom palettes without rebuilding plots.
7
+
8
+ Importing this module is cheap; plotly is only required when a plot
9
+ helper is actually called. Install via the optional extra::
10
+
11
+ pip install simweave[viz]
12
+
13
+ Quick start::
14
+
15
+ from simweave.viz import (
16
+ plot_state_trajectories,
17
+ set_default_theme,
18
+ )
19
+ from simweave.continuous import simulate, MassSpringDamper
20
+
21
+ res = simulate(MassSpringDamper(), t_span=(0, 10), dt=0.01)
22
+ set_default_theme("dark")
23
+ fig = plot_state_trajectories(res)
24
+ fig.show() # or fig.write_html("msd.html"), or fig.to_json()
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from simweave.viz.themes import (
30
+ Theme,
31
+ apply_theme,
32
+ available_themes,
33
+ get_default_theme,
34
+ get_theme,
35
+ register_theme,
36
+ set_default_theme,
37
+ )
38
+ from simweave.viz.recorders import (
39
+ QueueLengthRecorder,
40
+ ServiceUtilisationRecorder,
41
+ WarehouseStockRecorder,
42
+ )
43
+ from simweave.viz.plots import (
44
+ plot_agent_path,
45
+ plot_mc_fan,
46
+ plot_phase_portrait,
47
+ plot_queue_length,
48
+ plot_service_utilisation,
49
+ plot_state_trajectories,
50
+ plot_warehouse_stock,
51
+ )
52
+ from simweave.viz._plotly import have_plotly
53
+
54
+ __all__ = [
55
+ # Themes
56
+ "Theme",
57
+ "apply_theme",
58
+ "available_themes",
59
+ "get_default_theme",
60
+ "get_theme",
61
+ "register_theme",
62
+ "set_default_theme",
63
+ # Recorders
64
+ "QueueLengthRecorder",
65
+ "ServiceUtilisationRecorder",
66
+ "WarehouseStockRecorder",
67
+ # Plot helpers
68
+ "plot_agent_path",
69
+ "plot_mc_fan",
70
+ "plot_phase_portrait",
71
+ "plot_queue_length",
72
+ "plot_service_utilisation",
73
+ "plot_state_trajectories",
74
+ "plot_warehouse_stock",
75
+ # Probe
76
+ "have_plotly",
77
+ ]
@@ -0,0 +1,35 @@
1
+ """Internal helper: lazy import plotly with a friendly error.
2
+
3
+ simweave's core does not depend on plotly. Users who want viz install via
4
+ the optional extra ``simweave[viz]``. Importing :mod:`simweave.viz` at the
5
+ top level is cheap; the heavy import only happens when a plot helper is
6
+ actually called.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ _INSTALL_HINT = (
15
+ "simweave.viz requires plotly. Install with `pip install simweave[viz]` "
16
+ "or `pip install plotly>=5.18`."
17
+ )
18
+
19
+
20
+ def require_go() -> Any:
21
+ """Return ``plotly.graph_objects`` or raise a clear ImportError."""
22
+ try:
23
+ import plotly.graph_objects as go # type: ignore[import-not-found]
24
+ except ImportError as e: # pragma: no cover - exercised when plotly absent
25
+ raise ImportError(_INSTALL_HINT) from e
26
+ return go
27
+
28
+
29
+ def have_plotly() -> bool:
30
+ """Cheap probe: does the current environment have plotly available?"""
31
+ try:
32
+ import plotly # noqa: F401
33
+ except ImportError:
34
+ return False
35
+ return True