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,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
|
+
}
|
simweave/viz/__init__.py
ADDED
|
@@ -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
|
+
]
|
simweave/viz/_plotly.py
ADDED
|
@@ -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
|