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,92 @@
1
+ """Formatting for :class:`Money`.
2
+
3
+ Two paths:
4
+
5
+ * **ASCII default** (always available): ``"GBP 1,234.50"``. No locale
6
+ assumptions — readable in logs, CSVs, and anywhere a BOM-sensitive
7
+ consumer might see it.
8
+ * **Locale-aware via babel** (optional, ``simweave[intl]``):
9
+ ``"£1,234.50"`` with the currency symbol and digit grouping of the
10
+ user's locale.
11
+
12
+ The format specifier protocol supported by ``f"{m:...}"``:
13
+
14
+ * ``""`` -> default ASCII (``GBP 1,234.50``)
15
+ * ``"r"`` -> round to currency precision first, then default ASCII
16
+ * ``"raw"`` -> raw ``Decimal`` text, no grouping, no currency tag
17
+ * Anything starting with a locale code containing ``_`` (e.g.
18
+ ``"en_GB"``) -> locale-aware via babel; raises ``ImportError`` if
19
+ ``simweave[intl]`` not installed.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from decimal import ROUND_HALF_EVEN, Decimal
25
+ from typing import TYPE_CHECKING
26
+
27
+ from simweave.currency.codes import get_decimals
28
+
29
+ if TYPE_CHECKING:
30
+ from simweave.currency.money import Money
31
+
32
+
33
+ def _ascii_format(money: "Money", round_first: bool) -> str:
34
+ decimals = get_decimals(money.currency)
35
+ amount = money.amount
36
+ if round_first:
37
+ quant = Decimal(10) ** -decimals
38
+ amount = amount.quantize(quant, rounding=ROUND_HALF_EVEN)
39
+ # Split sign and magnitude so grouping applies to the digits only.
40
+ sign = "-" if amount < 0 else ""
41
+ magnitude = abs(amount)
42
+ if decimals == 0:
43
+ body = f"{magnitude:,.0f}"
44
+ else:
45
+ body = f"{magnitude:,.{decimals}f}"
46
+ return f"{sign}{money.currency} {body}"
47
+
48
+
49
+ def _babel_format(money: "Money", locale: str) -> str:
50
+ try:
51
+ from babel.numbers import format_currency
52
+ except ImportError as exc: # pragma: no cover - optional extra
53
+ raise ImportError(
54
+ "Locale-aware money formatting requires babel. "
55
+ "Install with `pip install simweave[intl]`."
56
+ ) from exc
57
+ return format_currency(money.amount, money.currency, locale=locale)
58
+
59
+
60
+ def format_money(money: "Money", spec: str = "", locale: str | None = None) -> str:
61
+ """Format a :class:`Money` for display.
62
+
63
+ Parameters
64
+ ----------
65
+ money:
66
+ The value to render.
67
+ spec:
68
+ Format specifier. See module docstring.
69
+ locale:
70
+ Optional locale identifier (e.g. ``"en_GB"``). Overrides ``spec``
71
+ when provided. Requires ``simweave[intl]`` (babel).
72
+
73
+ Returns
74
+ -------
75
+ str
76
+ The formatted string.
77
+ """
78
+ if locale is not None:
79
+ return _babel_format(money, locale)
80
+ if not spec:
81
+ return _ascii_format(money, round_first=True)
82
+ if spec == "r":
83
+ return _ascii_format(money, round_first=True)
84
+ if spec == "raw":
85
+ # Full-precision decimal text, no currency tag, no grouping.
86
+ return str(money.amount)
87
+ if "_" in spec:
88
+ return _babel_format(money, spec)
89
+ raise ValueError(
90
+ f"Unknown Money format spec: {spec!r}. "
91
+ f"Use '' / 'r' / 'raw' or a locale like 'en_GB'."
92
+ )
@@ -0,0 +1,126 @@
1
+ """Foreign exchange conversion protocol and built-in strategies.
2
+
3
+ simweave deliberately **does not ship live FX rates.** Network data,
4
+ API keys, and rate-limits don't belong in a simulation kernel. Users
5
+ supply their own converter — either a static in-memory table for
6
+ deterministic testing, or a callable wrapper around whatever service
7
+ their application uses (Bloomberg, OANDA, ECB reference rates, etc.).
8
+
9
+ The :class:`FXConverter` protocol keeps this contract minimal: one
10
+ method, ``rate(source, target, at=None) -> Decimal``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from decimal import Decimal
16
+ from typing import TYPE_CHECKING, Callable, Mapping, Protocol, runtime_checkable
17
+
18
+ if TYPE_CHECKING:
19
+ from datetime import datetime
20
+
21
+
22
+ @runtime_checkable
23
+ class FXConverter(Protocol):
24
+ """Protocol for anything that can quote an FX rate.
25
+
26
+ ``rate(source, target, at=None)`` returns the number of ``target``
27
+ units per one unit of ``source``. A rate of ``1.27`` for
28
+ ``("GBP", "USD")`` means one pound buys 1.27 dollars.
29
+ """
30
+
31
+ def rate(
32
+ self,
33
+ source: str,
34
+ target: str,
35
+ at: "datetime | None" = None,
36
+ ) -> Decimal: ...
37
+
38
+
39
+ class StaticFXConverter:
40
+ """Fixed-rate converter backed by an in-memory table.
41
+
42
+ Automatically handles inverses: if ``("GBP", "USD") -> 1.27`` is
43
+ registered, ``rate("USD", "GBP")`` returns ``1 / 1.27`` without
44
+ needing an explicit entry.
45
+
46
+ Parameters
47
+ ----------
48
+ rates:
49
+ Mapping of ``(source, target)`` tuples to the rate. Rates may
50
+ be ``Decimal``, ``float``, ``int``, or a numeric string; all
51
+ are coerced to ``Decimal`` via ``Decimal(str(value))``.
52
+ """
53
+
54
+ __slots__ = ("_rates",)
55
+
56
+ def __init__(
57
+ self,
58
+ rates: Mapping[tuple[str, str], Decimal | float | int | str],
59
+ ) -> None:
60
+ self._rates: dict[tuple[str, str], Decimal] = {}
61
+ for (src, tgt), r in rates.items():
62
+ key = (src.upper(), tgt.upper())
63
+ if key[0] == key[1]:
64
+ # Identity rate is always 1 — silently ignore explicit entry.
65
+ continue
66
+ self._rates[key] = Decimal(str(r))
67
+
68
+ def rate(
69
+ self,
70
+ source: str,
71
+ target: str,
72
+ at: "datetime | None" = None, # noqa: ARG002 - signature compatibility
73
+ ) -> Decimal:
74
+ src = source.upper()
75
+ tgt = target.upper()
76
+ if src == tgt:
77
+ return Decimal("1")
78
+ if (src, tgt) in self._rates:
79
+ return self._rates[(src, tgt)]
80
+ if (tgt, src) in self._rates:
81
+ return Decimal("1") / self._rates[(tgt, src)]
82
+ raise KeyError(
83
+ f"No FX rate defined for {src} -> {tgt}. "
84
+ f"Provide it explicitly or register the inverse."
85
+ )
86
+
87
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
88
+ return f"StaticFXConverter({len(self._rates)} rates)"
89
+
90
+
91
+ class CallableFXConverter:
92
+ """Wrap an arbitrary callable as an :class:`FXConverter`.
93
+
94
+ The wrapped callable must accept ``(source, target, at)`` and return
95
+ something coercible to ``Decimal`` (via ``Decimal(str(...))``).
96
+ Useful when you already have a rate-lookup function.
97
+
98
+ Example
99
+ -------
100
+ >>> def my_lookup(src, tgt, at=None):
101
+ ... return 1.27 if (src, tgt) == ("GBP", "USD") else 1.0
102
+ >>> fx = CallableFXConverter(my_lookup)
103
+ >>> fx.rate("GBP", "USD")
104
+ Decimal('1.27')
105
+ """
106
+
107
+ __slots__ = ("_fn",)
108
+
109
+ def __init__(
110
+ self,
111
+ fn: Callable[[str, str, "datetime | None"], Decimal | float | int | str],
112
+ ) -> None:
113
+ self._fn = fn
114
+
115
+ def rate(
116
+ self,
117
+ source: str,
118
+ target: str,
119
+ at: "datetime | None" = None,
120
+ ) -> Decimal:
121
+ src = source.upper()
122
+ tgt = target.upper()
123
+ if src == tgt:
124
+ return Decimal("1")
125
+ raw = self._fn(src, tgt, at)
126
+ return Decimal(str(raw))
@@ -0,0 +1,328 @@
1
+ """The :class:`Money` type — a currency-tagged Decimal value.
2
+
3
+ Design notes
4
+ ------------
5
+ * **Decimal everywhere, no float drift.** Amounts are stored as
6
+ :class:`decimal.Decimal`. Integer, float, and string inputs are
7
+ coerced through ``Decimal(str(value))`` to avoid the classic
8
+ ``Decimal(0.1) -> Decimal('0.1000000000000000055511151231257827021181583404541015625')``
9
+ surprise.
10
+ * **Banker's rounding at display/quantisation time.** Computation keeps
11
+ full precision; rounding is only applied when the caller asks for it
12
+ (``round_to_currency`` or ``format_money``). ``ROUND_HALF_EVEN`` is
13
+ the default — configurable via ``rounding=`` where supported.
14
+ * **Cross-currency arithmetic refuses.** ``GBP + USD`` raises
15
+ :class:`CurrencyMismatchError` rather than silently coercing. Users
16
+ must call ``money.to(target, converter)`` with an explicit FX
17
+ converter to change currencies.
18
+ * **Scalar-only multiplication.** ``Money * int/float/Decimal`` returns
19
+ a ``Money`` in the same currency. ``Money * Money`` is ill-defined
20
+ dimensionally (square-pounds?) and raises.
21
+ * **Ratios are dimensionless.** ``Money / Money`` (same currency)
22
+ returns a plain ``float``. Different currencies raise.
23
+ * **Negatives allowed.** Debts, refunds, and signed cashflows are
24
+ legitimate — no ``ValueError`` on negative amounts.
25
+ * **Frozen, hashable.** Two ``Money`` values are equal iff both
26
+ currency and amount match exactly. Hash is consistent with equality.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from decimal import Decimal, ROUND_HALF_EVEN, InvalidOperation
33
+ from typing import TYPE_CHECKING, Any, Union
34
+
35
+ from simweave.currency.codes import get_decimals, is_valid_currency
36
+
37
+ if TYPE_CHECKING:
38
+ from datetime import datetime
39
+
40
+ from simweave.currency.fx import FXConverter
41
+
42
+
43
+ Numeric = Union[int, float, Decimal, str]
44
+
45
+
46
+ class CurrencyMismatchError(TypeError):
47
+ """Raised when an operation requires two Money values to share a currency
48
+ but they don't (e.g. ``GBP + USD``).
49
+
50
+ Subclasses :class:`TypeError` so that ``except TypeError`` catches it in
51
+ legacy code, while new code can ``except CurrencyMismatchError`` for
52
+ precise handling.
53
+ """
54
+
55
+
56
+ def _coerce_amount(value: Any) -> Decimal:
57
+ """Coerce ``value`` to Decimal without float-binary drift."""
58
+ if isinstance(value, Decimal):
59
+ return value
60
+ if isinstance(value, bool):
61
+ # bool is a subclass of int — refuse it explicitly; "True pounds"
62
+ # is almost certainly a bug.
63
+ raise TypeError("Money amount cannot be a bool.")
64
+ if isinstance(value, int):
65
+ return Decimal(value)
66
+ if isinstance(value, float):
67
+ # Route through str so 0.1 becomes Decimal('0.1'), not the binary
68
+ # expansion.
69
+ return Decimal(str(value))
70
+ if isinstance(value, str):
71
+ try:
72
+ return Decimal(value.strip())
73
+ except InvalidOperation as exc:
74
+ raise ValueError(f"Cannot parse {value!r} as a decimal amount.") from exc
75
+ raise TypeError(
76
+ f"Money amount must be numeric (int/float/Decimal/str); got {type(value).__name__}."
77
+ )
78
+
79
+
80
+ def _normalise_code(code: Any) -> str:
81
+ if not isinstance(code, str):
82
+ raise TypeError(f"Currency code must be a string; got {type(code).__name__}.")
83
+ up = code.strip().upper()
84
+ if not up:
85
+ raise ValueError("Currency code must not be empty.")
86
+ return up
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class Money:
91
+ """A currency-tagged monetary amount.
92
+
93
+ Parameters
94
+ ----------
95
+ amount:
96
+ The numeric amount. Accepts ``int``, ``float``, ``Decimal``, or
97
+ ``str``. Float inputs are routed via :class:`str` to avoid
98
+ binary-float drift.
99
+ currency:
100
+ ISO 4217 three-letter code (case-insensitive; normalised to
101
+ uppercase). Must be either a known ISO 4217 code or a code
102
+ previously registered via
103
+ :func:`simweave.currency.codes.register_custom`.
104
+
105
+ Examples
106
+ --------
107
+ >>> from simweave.currency import Money
108
+ >>> Money(100, "GBP") + Money("50.25", "GBP")
109
+ Money(Decimal('150.25'), 'GBP')
110
+ >>> Money(100, "GBP") * 3
111
+ Money(Decimal('300'), 'GBP')
112
+ """
113
+
114
+ amount: Decimal
115
+ currency: str
116
+
117
+ # ------------------------------------------------------------------
118
+ # Construction
119
+ # ------------------------------------------------------------------
120
+ def __post_init__(self) -> None:
121
+ # Coerce and validate. Frozen dataclass requires object.__setattr__.
122
+ object.__setattr__(self, "amount", _coerce_amount(self.amount))
123
+ code = _normalise_code(self.currency)
124
+ if not is_valid_currency(code):
125
+ raise ValueError(
126
+ f"Unknown currency code: {code!r}. "
127
+ f"Use simweave.currency.register_custom({code!r}, decimals=...) "
128
+ f"if intentional."
129
+ )
130
+ object.__setattr__(self, "currency", code)
131
+
132
+ # ------------------------------------------------------------------
133
+ # Rounding / quantisation
134
+ # ------------------------------------------------------------------
135
+ @property
136
+ def decimals(self) -> int:
137
+ """Canonical minor-unit decimal places for this currency."""
138
+ return get_decimals(self.currency)
139
+
140
+ def round_to_currency(self, rounding: str = ROUND_HALF_EVEN) -> "Money":
141
+ """Return a new ``Money`` rounded to the currency's minor-unit
142
+ precision. Defaults to banker's rounding (``ROUND_HALF_EVEN``)."""
143
+ quant = Decimal(10) ** -self.decimals
144
+ return Money(self.amount.quantize(quant, rounding=rounding), self.currency)
145
+
146
+ # ------------------------------------------------------------------
147
+ # Sign / absolute
148
+ # ------------------------------------------------------------------
149
+ def __neg__(self) -> "Money":
150
+ return Money(-self.amount, self.currency)
151
+
152
+ def __pos__(self) -> "Money":
153
+ return self
154
+
155
+ def __abs__(self) -> "Money":
156
+ return Money(abs(self.amount), self.currency)
157
+
158
+ def is_negative(self) -> bool:
159
+ return self.amount < 0
160
+
161
+ def is_zero(self) -> bool:
162
+ return self.amount == 0
163
+
164
+ # ------------------------------------------------------------------
165
+ # Additive operators
166
+ # ------------------------------------------------------------------
167
+ def _check_same_currency(self, other: "Money", op: str) -> None:
168
+ if self.currency != other.currency:
169
+ raise CurrencyMismatchError(
170
+ f"Cannot {op} {self.currency} and {other.currency}: "
171
+ f"different currencies. Use .to(target, converter) to convert."
172
+ )
173
+
174
+ def __add__(self, other: object) -> "Money":
175
+ if not isinstance(other, Money):
176
+ return NotImplemented
177
+ self._check_same_currency(other, "+")
178
+ return Money(self.amount + other.amount, self.currency)
179
+
180
+ def __radd__(self, other: object) -> "Money":
181
+ # Permit sum([...], start=Money(0, "GBP")) without weird interactions;
182
+ # but refuse bare int 0 which is what ``sum()`` defaults to, because
183
+ # mixing currencies via sum() with default start is a common bug.
184
+ if other == 0:
185
+ raise TypeError(
186
+ "Cannot add Money to bare 0; pass start=Money(0, currency) "
187
+ "to sum() to accumulate Money values."
188
+ )
189
+ return NotImplemented
190
+
191
+ def __sub__(self, other: object) -> "Money":
192
+ if not isinstance(other, Money):
193
+ return NotImplemented
194
+ self._check_same_currency(other, "-")
195
+ return Money(self.amount - other.amount, self.currency)
196
+
197
+ # ------------------------------------------------------------------
198
+ # Multiplicative operators
199
+ # ------------------------------------------------------------------
200
+ def __mul__(self, other: object) -> "Money":
201
+ if isinstance(other, Money):
202
+ raise TypeError(
203
+ "Money * Money is not dimensionally meaningful. "
204
+ "Multiply Money by a scalar (int/float/Decimal) instead."
205
+ )
206
+ if isinstance(other, bool):
207
+ raise TypeError("Cannot multiply Money by bool.")
208
+ if isinstance(other, (int, float, Decimal, str)):
209
+ return Money(self.amount * _coerce_amount(other), self.currency)
210
+ return NotImplemented
211
+
212
+ def __rmul__(self, other: object) -> "Money":
213
+ return self.__mul__(other)
214
+
215
+ def __truediv__(self, other: object) -> Union["Money", float]:
216
+ if isinstance(other, Money):
217
+ self._check_same_currency(other, "/")
218
+ if other.amount == 0:
219
+ raise ZeroDivisionError("Division by zero Money.")
220
+ return float(self.amount / other.amount)
221
+ if isinstance(other, bool):
222
+ raise TypeError("Cannot divide Money by bool.")
223
+ if isinstance(other, (int, float, Decimal, str)):
224
+ divisor = _coerce_amount(other)
225
+ if divisor == 0:
226
+ raise ZeroDivisionError("Division by zero.")
227
+ return Money(self.amount / divisor, self.currency)
228
+ return NotImplemented
229
+
230
+ def __floordiv__(self, other: object) -> Union["Money", int]:
231
+ if isinstance(other, Money):
232
+ self._check_same_currency(other, "//")
233
+ if other.amount == 0:
234
+ raise ZeroDivisionError("Floor-division by zero Money.")
235
+ return int(self.amount // other.amount)
236
+ if isinstance(other, (int, float, Decimal, str)) and not isinstance(
237
+ other, bool
238
+ ):
239
+ divisor = _coerce_amount(other)
240
+ if divisor == 0:
241
+ raise ZeroDivisionError("Floor-division by zero.")
242
+ return Money(self.amount // divisor, self.currency)
243
+ return NotImplemented
244
+
245
+ # ------------------------------------------------------------------
246
+ # Comparison
247
+ # ------------------------------------------------------------------
248
+ def __eq__(self, other: object) -> bool:
249
+ if not isinstance(other, Money):
250
+ return NotImplemented
251
+ return self.currency == other.currency and self.amount == other.amount
252
+
253
+ def __hash__(self) -> int:
254
+ return hash((type(self).__name__, self.currency, self.amount))
255
+
256
+ def _check_comparable(self, other: object, op: str) -> "Money":
257
+ if not isinstance(other, Money):
258
+ raise TypeError(
259
+ f"Cannot compare Money with {type(other).__name__} using {op!r}."
260
+ )
261
+ self._check_same_currency(other, op)
262
+ return other
263
+
264
+ def __lt__(self, other: object) -> bool:
265
+ other_m = self._check_comparable(other, "<")
266
+ return self.amount < other_m.amount
267
+
268
+ def __le__(self, other: object) -> bool:
269
+ other_m = self._check_comparable(other, "<=")
270
+ return self.amount <= other_m.amount
271
+
272
+ def __gt__(self, other: object) -> bool:
273
+ other_m = self._check_comparable(other, ">")
274
+ return self.amount > other_m.amount
275
+
276
+ def __ge__(self, other: object) -> bool:
277
+ other_m = self._check_comparable(other, ">=")
278
+ return self.amount >= other_m.amount
279
+
280
+ # ------------------------------------------------------------------
281
+ # Conversion
282
+ # ------------------------------------------------------------------
283
+ def to(
284
+ self,
285
+ target: str,
286
+ converter: "FXConverter",
287
+ at: "datetime | None" = None,
288
+ ) -> "Money":
289
+ """Convert to ``target`` currency using ``converter.rate(...)``.
290
+
291
+ Short-circuits to ``self`` (no-op) when the target currency
292
+ already matches. Preserves full Decimal precision; call
293
+ :meth:`round_to_currency` afterwards if you want to quantise
294
+ to the target's minor-unit precision.
295
+
296
+ Parameters
297
+ ----------
298
+ target:
299
+ ISO 4217 code to convert to.
300
+ converter:
301
+ Any object satisfying :class:`simweave.currency.FXConverter`.
302
+ at:
303
+ Optional datetime passed through to the converter (for
304
+ historical-rate lookups). Converters may ignore it.
305
+ """
306
+ target_code = _normalise_code(target)
307
+ if target_code == self.currency:
308
+ return self
309
+ rate = converter.rate(self.currency, target_code, at=at)
310
+ rate_d = _coerce_amount(rate)
311
+ return Money(self.amount * rate_d, target_code)
312
+
313
+ # ------------------------------------------------------------------
314
+ # Representation
315
+ # ------------------------------------------------------------------
316
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
317
+ return f"Money({self.amount!r}, {self.currency!r})"
318
+
319
+ def __str__(self) -> str:
320
+ # Delayed import avoids a circular dependency at module load time.
321
+ from simweave.currency.format import format_money
322
+
323
+ return format_money(self)
324
+
325
+ def __format__(self, spec: str) -> str:
326
+ from simweave.currency.format import format_money
327
+
328
+ return format_money(self, spec=spec)
@@ -0,0 +1,28 @@
1
+ """Discrete-event primitives: queues, services, resources, arrival generators."""
2
+
3
+ from simweave.discrete.properties import (
4
+ EntityProperties,
5
+ exponential,
6
+ uniform,
7
+ normal,
8
+ deterministic,
9
+ set_default_seed,
10
+ )
11
+ from simweave.discrete.queues import Queue, PriorityQueue
12
+ from simweave.discrete.resources import Resource, ResourcePool
13
+ from simweave.discrete.services import Service, ArrivalGenerator
14
+
15
+ __all__ = [
16
+ "EntityProperties",
17
+ "exponential",
18
+ "uniform",
19
+ "normal",
20
+ "deterministic",
21
+ "set_default_seed",
22
+ "Queue",
23
+ "PriorityQueue",
24
+ "Resource",
25
+ "ResourcePool",
26
+ "Service",
27
+ "ArrivalGenerator",
28
+ ]
@@ -0,0 +1,113 @@
1
+ """Service-time distributions and per-entity sim properties.
2
+
3
+ The previous ``simProperites`` class conflated the distribution configuration
4
+ with a ``dt`` attribute that gets overwritten whenever ``next_service_time()``
5
+ is called. Here we split the two: ``ServiceTime`` knows how to *draw*; an
6
+ :class:`EntityProperties` record carries the distribution plus any static
7
+ attributes the modeller wants to track.
8
+
9
+ Every distribution accepts an optional ``rng`` (``numpy.random.Generator``)
10
+ so that Monte Carlo runs can be seeded independently without touching the
11
+ legacy global ``np.random`` state.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Callable
18
+
19
+ import numpy as np
20
+
21
+
22
+ RNG = np.random.Generator
23
+ Distribution = Callable[[RNG], float]
24
+
25
+
26
+ def exponential(mean: float) -> Distribution:
27
+ """Exponential inter-arrival / service time."""
28
+ if mean <= 0:
29
+ raise ValueError("Exponential mean must be positive.")
30
+
31
+ def draw(rng: RNG) -> float:
32
+ return float(rng.exponential(mean))
33
+
34
+ return draw
35
+
36
+
37
+ def uniform(low: float, high: float) -> Distribution:
38
+ if high <= low:
39
+ raise ValueError("Uniform requires high > low.")
40
+
41
+ def draw(rng: RNG) -> float:
42
+ return float(rng.uniform(low, high))
43
+
44
+ return draw
45
+
46
+
47
+ def normal(mean: float, std: float, *, clip_nonnegative: bool = True) -> Distribution:
48
+ """Normal distribution; by default clips at 0 since negative service times are nonsense."""
49
+ if std <= 0:
50
+ raise ValueError("normal std must be positive.")
51
+
52
+ def draw(rng: RNG) -> float:
53
+ x = float(rng.normal(mean, std))
54
+ return max(0.0, x) if clip_nonnegative else x
55
+
56
+ return draw
57
+
58
+
59
+ def deterministic(value: float) -> Distribution:
60
+ if value < 0:
61
+ raise ValueError("deterministic value cannot be negative.")
62
+
63
+ def draw(rng: RNG) -> float:
64
+ return float(value)
65
+
66
+ return draw
67
+
68
+
69
+ @dataclass
70
+ class EntityProperties:
71
+ """Per-entity sim properties. Pass the same instance to many entities.
72
+
73
+ Attributes
74
+ ----------
75
+ entity_type:
76
+ Free-form tag, useful for stratified summaries.
77
+ service_time:
78
+ Distribution drawn whenever a Service pulls this entity into a work
79
+ channel.
80
+ balk_on_length:
81
+ If the waiting queue has more than this many items on arrival, the
82
+ entity refuses to join. ``None`` disables balking.
83
+ renege_after:
84
+ If the entity's ``current_wait_time`` exceeds this threshold, it
85
+ leaves the queue. ``None`` disables reneging.
86
+ extras:
87
+ Arbitrary additional fields the modeller wants to track.
88
+ """
89
+
90
+ entity_type: str = "default"
91
+ service_time: Distribution = field(default_factory=lambda: exponential(1.0))
92
+ balk_on_length: int | None = None
93
+ renege_after: float | None = None
94
+ extras: dict[str, Any] = field(default_factory=dict)
95
+
96
+ def draw_service_time(self, rng: RNG | None = None) -> float:
97
+ if rng is None:
98
+ rng = _default_rng()
99
+ return float(self.service_time(rng))
100
+
101
+
102
+ # A shared default generator so users can omit ``rng`` in tests / quick demos.
103
+ _DEFAULT_RNG = np.random.default_rng()
104
+
105
+
106
+ def _default_rng() -> RNG:
107
+ return _DEFAULT_RNG
108
+
109
+
110
+ def set_default_seed(seed: int) -> None:
111
+ """Replace the module-level generator with a seeded one."""
112
+ global _DEFAULT_RNG
113
+ _DEFAULT_RNG = np.random.default_rng(seed)