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,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
|
+
)
|
simweave/currency/fx.py
ADDED
|
@@ -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)
|