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
simweave/core/entity.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Entity base class.
|
|
2
|
+
|
|
3
|
+
Everything that persists across ticks in simweave -- items, resources, agents,
|
|
4
|
+
queues, services -- inherits from :class:`Entity`. The contract is small:
|
|
5
|
+
|
|
6
|
+
* ``tick(dt, env)`` is called once per simulation tick for registered entities
|
|
7
|
+
(processes). The default implementation just ages the entity.
|
|
8
|
+
* ``has_work(env)`` reports whether this entity has pending work *right now*;
|
|
9
|
+
used by the environment's skip-idle-gaps fast path.
|
|
10
|
+
|
|
11
|
+
Items flowing through queues do not need to be registered themselves; they
|
|
12
|
+
ride along with whatever process owns them. Queues and services age their
|
|
13
|
+
contained items in their own ``tick``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from itertools import count
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from simweave.core.environment import SimEnvironment
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Entity:
|
|
26
|
+
"""Base class for simulation entities."""
|
|
27
|
+
|
|
28
|
+
_id_counter = count(0)
|
|
29
|
+
|
|
30
|
+
def __init__(self, name: str | None = None) -> None:
|
|
31
|
+
self.id: int = next(Entity._id_counter)
|
|
32
|
+
self.name: str = (
|
|
33
|
+
name if name is not None else f"{type(self).__name__}_{self.id}"
|
|
34
|
+
)
|
|
35
|
+
self.created_at: float | None = None
|
|
36
|
+
self.age: float = 0.0
|
|
37
|
+
# Queue residency bookkeeping.
|
|
38
|
+
self.current_wait_time: float = 0.0
|
|
39
|
+
self.total_wait_time: float = 0.0
|
|
40
|
+
# Populated by a Service when pulling this entity for processing.
|
|
41
|
+
self.remaining_service_time: float = 0.0
|
|
42
|
+
|
|
43
|
+
def on_register(self, env: "SimEnvironment") -> None:
|
|
44
|
+
"""Hook called when this entity is registered with an environment."""
|
|
45
|
+
if self.created_at is None:
|
|
46
|
+
self.created_at = env.clock.t
|
|
47
|
+
|
|
48
|
+
def tick(self, dt: float, env: "SimEnvironment") -> None:
|
|
49
|
+
"""Advance this entity by ``dt`` simulation seconds.
|
|
50
|
+
|
|
51
|
+
Subclasses should call ``super().tick(dt, env)`` first (to keep the
|
|
52
|
+
age counter correct) before doing their own logic.
|
|
53
|
+
"""
|
|
54
|
+
self.age += dt
|
|
55
|
+
|
|
56
|
+
def has_work(self, env: "SimEnvironment") -> bool:
|
|
57
|
+
"""Report whether this entity has pending work.
|
|
58
|
+
|
|
59
|
+
The default is ``False`` -- plain items waiting in a queue are not
|
|
60
|
+
themselves "doing work" in the skip-idle-gaps sense; the queue/service
|
|
61
|
+
holding them is.
|
|
62
|
+
"""
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Dunder conveniences
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
69
|
+
return f"{type(self).__name__}(name={self.name!r}, id={self.id})"
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def reset_id_counter(cls) -> None:
|
|
73
|
+
"""Reset the shared ID counter. Useful between replicates in tests."""
|
|
74
|
+
cls._id_counter = count(0)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Simulation environment: clock + event queue + process registry.
|
|
2
|
+
|
|
3
|
+
A :class:`SimEnvironment` owns the authoritative clock and an optional event
|
|
4
|
+
queue. Processes (anything with a ``tick(dt, env)`` method) are registered so
|
|
5
|
+
they step together each tick. An optional ``graph`` attribute exists as a
|
|
6
|
+
convenience for spatial/agent simulations that need a shared topology.
|
|
7
|
+
|
|
8
|
+
Run styles
|
|
9
|
+
----------
|
|
10
|
+
* ``env.run(until)`` -- step the clock by ``dt`` until ``t >= until``.
|
|
11
|
+
* ``env.run(until, skip_idle_gaps=True)`` -- if nothing has work in the
|
|
12
|
+
current tick and the next pending event is strictly in the future, jump the
|
|
13
|
+
clock straight to that event. This degenerates to pure fixed-step when
|
|
14
|
+
events are dense, and approaches DEVS efficiency when they are sparse.
|
|
15
|
+
* ``env.step()`` -- advance exactly one tick (useful in tests).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Callable, Iterable, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
from simweave.core.clock import Clock
|
|
23
|
+
from simweave.core.scheduler import EventQueue, ScheduledEvent
|
|
24
|
+
from simweave.core.logging import get_logger
|
|
25
|
+
|
|
26
|
+
log = get_logger("env")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class Process(Protocol):
|
|
31
|
+
"""A process that participates in the tick loop."""
|
|
32
|
+
|
|
33
|
+
def tick(self, dt: float, env: "SimEnvironment") -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SimEnvironment:
|
|
37
|
+
"""Container for a clock, an event queue, and registered processes."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
start: float = 0.0,
|
|
42
|
+
dt: float = 1.0,
|
|
43
|
+
end: float | None = None,
|
|
44
|
+
graph: Any = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.clock = Clock(start=start, dt=dt, end=end)
|
|
47
|
+
self.events = EventQueue()
|
|
48
|
+
self.graph = graph
|
|
49
|
+
self._processes: list[Process] = []
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Process registry
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
def register(self, process: Process) -> Process:
|
|
55
|
+
"""Register a process so it is ticked each simulation step."""
|
|
56
|
+
if not hasattr(process, "tick"):
|
|
57
|
+
raise TypeError("Registered process must implement tick(dt, env).")
|
|
58
|
+
self._processes.append(process)
|
|
59
|
+
on_register = getattr(process, "on_register", None)
|
|
60
|
+
if callable(on_register):
|
|
61
|
+
on_register(self)
|
|
62
|
+
return process
|
|
63
|
+
|
|
64
|
+
def register_all(self, processes: Iterable[Process]) -> None:
|
|
65
|
+
for p in processes:
|
|
66
|
+
self.register(p)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def processes(self) -> tuple[Process, ...]:
|
|
70
|
+
return tuple(self._processes)
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Event scheduling helpers
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
def schedule_at(
|
|
76
|
+
self, time: float, callback: Callable[..., Any], *args: Any, **kwargs: Any
|
|
77
|
+
) -> ScheduledEvent:
|
|
78
|
+
return self.events.schedule(time, callback, *args, **kwargs)
|
|
79
|
+
|
|
80
|
+
def schedule_after(
|
|
81
|
+
self, delay: float, callback: Callable[..., Any], *args: Any, **kwargs: Any
|
|
82
|
+
) -> ScheduledEvent:
|
|
83
|
+
return self.events.schedule(self.clock.t + delay, callback, *args, **kwargs)
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Running
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
def step(self) -> None:
|
|
89
|
+
"""Process events due at ``t``, tick every process, then advance."""
|
|
90
|
+
for evt in self.events.pop_due(self.clock.t):
|
|
91
|
+
evt.callback(*evt.args, **evt.kwargs)
|
|
92
|
+
for p in self._processes:
|
|
93
|
+
p.tick(self.clock.dt, self)
|
|
94
|
+
self.clock.advance()
|
|
95
|
+
|
|
96
|
+
def run(self, until: float | None = None, skip_idle_gaps: bool = False) -> None:
|
|
97
|
+
"""Run the simulation until ``until`` or ``clock.end``."""
|
|
98
|
+
if until is None:
|
|
99
|
+
until = self.clock.end
|
|
100
|
+
if until is None:
|
|
101
|
+
raise ValueError("Pass `until` or set clock.end before calling run().")
|
|
102
|
+
if until <= self.clock.t:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
while self.clock.t < until:
|
|
106
|
+
if skip_idle_gaps and not self._any_has_work():
|
|
107
|
+
next_evt = self.events.peek_time()
|
|
108
|
+
if next_evt is not None and next_evt > self.clock.t:
|
|
109
|
+
self.clock.jump_to(min(next_evt, until))
|
|
110
|
+
continue
|
|
111
|
+
# Nothing has work and no future events -> nothing will change.
|
|
112
|
+
if next_evt is None:
|
|
113
|
+
log.debug(
|
|
114
|
+
"No work and no pending events; halting early at t=%s.",
|
|
115
|
+
self.clock.t,
|
|
116
|
+
)
|
|
117
|
+
break
|
|
118
|
+
self.step()
|
|
119
|
+
|
|
120
|
+
def _any_has_work(self) -> bool:
|
|
121
|
+
return any(p.has_work(self) for p in self._processes if hasattr(p, "has_work"))
|
simweave/core/logging.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Lightweight logging helpers.
|
|
2
|
+
|
|
3
|
+
The previous ``print_display`` class has been retired in favour of stdlib
|
|
4
|
+
``logging``. Import ``get_logger(name)`` from here for a consistent simweave
|
|
5
|
+
logger hierarchy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
_ROOT = "simweave"
|
|
13
|
+
_configured = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def configure(
|
|
17
|
+
level: int | str = logging.INFO,
|
|
18
|
+
fmt: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
19
|
+
force: bool = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Configure the simweave logger hierarchy. Safe to call many times."""
|
|
22
|
+
global _configured
|
|
23
|
+
if _configured and not force:
|
|
24
|
+
return
|
|
25
|
+
handler = logging.StreamHandler()
|
|
26
|
+
handler.setFormatter(logging.Formatter(fmt))
|
|
27
|
+
root = logging.getLogger(_ROOT)
|
|
28
|
+
root.handlers.clear()
|
|
29
|
+
root.addHandler(handler)
|
|
30
|
+
root.setLevel(level)
|
|
31
|
+
root.propagate = False
|
|
32
|
+
_configured = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
36
|
+
"""Return a logger under the ``simweave`` namespace."""
|
|
37
|
+
if name is None or name == _ROOT:
|
|
38
|
+
return logging.getLogger(_ROOT)
|
|
39
|
+
if name.startswith(_ROOT + "."):
|
|
40
|
+
return logging.getLogger(name)
|
|
41
|
+
return logging.getLogger(f"{_ROOT}.{name}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Optional heap-based event queue.
|
|
2
|
+
|
|
3
|
+
The atomic-clock core ticks every process each timestep. Sometimes that's
|
|
4
|
+
wasteful -- e.g. a warehouse that reorders once a week does not want to be
|
|
5
|
+
polled 10080 times in between. The :class:`EventQueue` lets processes post
|
|
6
|
+
callbacks to fire at (or after) a given simulation time. The environment
|
|
7
|
+
drains any events whose scheduled time has been reached at the start of each
|
|
8
|
+
tick.
|
|
9
|
+
|
|
10
|
+
When combined with ``SimEnvironment.run(skip_idle_gaps=True)``, this also
|
|
11
|
+
powers a DEVS-style fast path: if every registered process reports
|
|
12
|
+
``has_work() is False`` and an event is pending at ``t_next``, the clock
|
|
13
|
+
fast-forwards directly to ``t_next`` rather than grinding through empty
|
|
14
|
+
ticks.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import heapq
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(order=True)
|
|
25
|
+
class ScheduledEvent:
|
|
26
|
+
"""Heap-ordered event record.
|
|
27
|
+
|
|
28
|
+
``seq`` is a tiebreaker so that two events at the same ``time`` fire in
|
|
29
|
+
insertion order, giving deterministic behaviour for simultaneous events.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
time: float
|
|
33
|
+
seq: int
|
|
34
|
+
callback: Callable[..., Any] = field(compare=False)
|
|
35
|
+
args: tuple = field(compare=False, default=())
|
|
36
|
+
kwargs: dict = field(compare=False, default_factory=dict)
|
|
37
|
+
cancelled: bool = field(compare=False, default=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EventQueue:
|
|
41
|
+
"""Min-heap priority queue of scheduled callbacks."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self._heap: list[ScheduledEvent] = []
|
|
45
|
+
self._seq: int = 0
|
|
46
|
+
|
|
47
|
+
def __len__(self) -> int:
|
|
48
|
+
return sum(1 for e in self._heap if not e.cancelled)
|
|
49
|
+
|
|
50
|
+
def __bool__(self) -> bool:
|
|
51
|
+
return len(self) > 0
|
|
52
|
+
|
|
53
|
+
def schedule(
|
|
54
|
+
self, time: float, callback: Callable[..., Any], *args: Any, **kwargs: Any
|
|
55
|
+
) -> ScheduledEvent:
|
|
56
|
+
"""Schedule ``callback(*args, **kwargs)`` to fire at simulation ``time``."""
|
|
57
|
+
evt = ScheduledEvent(
|
|
58
|
+
time=float(time), seq=self._seq, callback=callback, args=args, kwargs=kwargs
|
|
59
|
+
)
|
|
60
|
+
heapq.heappush(self._heap, evt)
|
|
61
|
+
self._seq += 1
|
|
62
|
+
return evt
|
|
63
|
+
|
|
64
|
+
def peek_time(self) -> float | None:
|
|
65
|
+
"""Return the time of the next non-cancelled event, or ``None``."""
|
|
66
|
+
self._drop_cancelled()
|
|
67
|
+
return self._heap[0].time if self._heap else None
|
|
68
|
+
|
|
69
|
+
def pop_due(self, now: float):
|
|
70
|
+
"""Yield every non-cancelled event whose time is ``<= now``."""
|
|
71
|
+
self._drop_cancelled()
|
|
72
|
+
while self._heap and self._heap[0].time <= now:
|
|
73
|
+
evt = heapq.heappop(self._heap)
|
|
74
|
+
if evt.cancelled:
|
|
75
|
+
continue
|
|
76
|
+
yield evt
|
|
77
|
+
self._drop_cancelled()
|
|
78
|
+
|
|
79
|
+
def cancel(self, event: ScheduledEvent) -> None:
|
|
80
|
+
"""Flag an event as cancelled without paying heap-removal cost."""
|
|
81
|
+
event.cancelled = True
|
|
82
|
+
|
|
83
|
+
def _drop_cancelled(self) -> None:
|
|
84
|
+
while self._heap and self._heap[0].cancelled:
|
|
85
|
+
heapq.heappop(self._heap)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""simweave.currency: Decimal-backed Money, ISO 4217 codes, FX conversion.
|
|
2
|
+
|
|
3
|
+
Public surface
|
|
4
|
+
--------------
|
|
5
|
+
* :class:`Money` — frozen, currency-tagged ``Decimal`` value with
|
|
6
|
+
strict same-currency arithmetic and explicit FX conversion.
|
|
7
|
+
* :class:`CurrencyMismatchError` — raised on cross-currency ops.
|
|
8
|
+
* :class:`FXConverter` — Protocol for rate lookup.
|
|
9
|
+
* :class:`StaticFXConverter` — in-memory rate table with automatic
|
|
10
|
+
inverse lookup. Use for deterministic tests and fixed-rate models.
|
|
11
|
+
* :class:`CallableFXConverter` — adapts an arbitrary callable to the
|
|
12
|
+
:class:`FXConverter` protocol.
|
|
13
|
+
* :func:`format_money` — ASCII default formatter, optional locale-aware
|
|
14
|
+
path via ``simweave[intl]`` (babel).
|
|
15
|
+
* :func:`register_custom`, :func:`unregister_custom` — escape hatch for
|
|
16
|
+
crypto, in-game, or test-fixture currency codes.
|
|
17
|
+
* :func:`is_valid_currency`, :func:`get_decimals`, :func:`list_codes` —
|
|
18
|
+
registry introspection helpers.
|
|
19
|
+
|
|
20
|
+
Design contract in short
|
|
21
|
+
------------------------
|
|
22
|
+
* Decimal everywhere; float inputs routed via ``str()`` to avoid
|
|
23
|
+
binary-float drift.
|
|
24
|
+
* Banker's rounding (``ROUND_HALF_EVEN``) at display / quantisation
|
|
25
|
+
time; full precision preserved during computation.
|
|
26
|
+
* Cross-currency arithmetic refuses — use ``money.to(target, fx)``.
|
|
27
|
+
* Scalar * Money allowed; Money * Money refused.
|
|
28
|
+
* Money / Money (same currency) returns ``float``; Money / scalar
|
|
29
|
+
returns Money.
|
|
30
|
+
* Negatives are legal (debts, refunds, signed cashflows).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from simweave.currency.codes import (
|
|
34
|
+
get_decimals,
|
|
35
|
+
is_valid_currency,
|
|
36
|
+
list_codes,
|
|
37
|
+
register_custom,
|
|
38
|
+
unregister_custom,
|
|
39
|
+
)
|
|
40
|
+
from simweave.currency.format import format_money
|
|
41
|
+
from simweave.currency.fx import (
|
|
42
|
+
CallableFXConverter,
|
|
43
|
+
FXConverter,
|
|
44
|
+
StaticFXConverter,
|
|
45
|
+
)
|
|
46
|
+
from simweave.currency.money import CurrencyMismatchError, Money
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
# Core value type
|
|
50
|
+
"Money",
|
|
51
|
+
"CurrencyMismatchError",
|
|
52
|
+
# FX
|
|
53
|
+
"FXConverter",
|
|
54
|
+
"StaticFXConverter",
|
|
55
|
+
"CallableFXConverter",
|
|
56
|
+
# Formatting
|
|
57
|
+
"format_money",
|
|
58
|
+
# Registry
|
|
59
|
+
"register_custom",
|
|
60
|
+
"unregister_custom",
|
|
61
|
+
"is_valid_currency",
|
|
62
|
+
"get_decimals",
|
|
63
|
+
"list_codes",
|
|
64
|
+
]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""ISO 4217 currency registry.
|
|
2
|
+
|
|
3
|
+
Maps an uppercase three-letter code (e.g. ``"GBP"``) to the number of
|
|
4
|
+
minor-unit decimal places defined by ISO 4217 for that currency. A
|
|
5
|
+
conservative subset of the ~180 active codes is baked in — enough to
|
|
6
|
+
cover >99% of the world's tradable currencies without dragging in
|
|
7
|
+
deprecated codes or fund/metal codes (XAU, XDR) that aren't "money"
|
|
8
|
+
in any operational sense.
|
|
9
|
+
|
|
10
|
+
For anything outside this registry (crypto, in-game currencies, test
|
|
11
|
+
fixtures), use :func:`register_custom`.
|
|
12
|
+
|
|
13
|
+
The registry is process-local: custom registrations do not persist
|
|
14
|
+
across Python processes. This is deliberate — hard-coding crypto rates
|
|
15
|
+
or custom codes across team members would be an anti-pattern.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
# ISO 4217 minor-unit exponents. Listed by decimal places for readability.
|
|
21
|
+
_ISO_4217: dict[str, int] = {
|
|
22
|
+
# 0 decimal places (no minor units)
|
|
23
|
+
"BIF": 0,
|
|
24
|
+
"CLP": 0,
|
|
25
|
+
"DJF": 0,
|
|
26
|
+
"GNF": 0,
|
|
27
|
+
"ISK": 0,
|
|
28
|
+
"JPY": 0,
|
|
29
|
+
"KMF": 0,
|
|
30
|
+
"KRW": 0,
|
|
31
|
+
"PYG": 0,
|
|
32
|
+
"RWF": 0,
|
|
33
|
+
"UGX": 0,
|
|
34
|
+
"UYI": 0,
|
|
35
|
+
"VND": 0,
|
|
36
|
+
"VUV": 0,
|
|
37
|
+
"XAF": 0,
|
|
38
|
+
"XOF": 0,
|
|
39
|
+
"XPF": 0,
|
|
40
|
+
# 2 decimal places (the vast majority)
|
|
41
|
+
"AED": 2,
|
|
42
|
+
"AFN": 2,
|
|
43
|
+
"ALL": 2,
|
|
44
|
+
"AMD": 2,
|
|
45
|
+
"ANG": 2,
|
|
46
|
+
"AOA": 2,
|
|
47
|
+
"ARS": 2,
|
|
48
|
+
"AUD": 2,
|
|
49
|
+
"AWG": 2,
|
|
50
|
+
"AZN": 2,
|
|
51
|
+
"BAM": 2,
|
|
52
|
+
"BBD": 2,
|
|
53
|
+
"BDT": 2,
|
|
54
|
+
"BGN": 2,
|
|
55
|
+
"BMD": 2,
|
|
56
|
+
"BND": 2,
|
|
57
|
+
"BOB": 2,
|
|
58
|
+
"BRL": 2,
|
|
59
|
+
"BSD": 2,
|
|
60
|
+
"BWP": 2,
|
|
61
|
+
"BYN": 2,
|
|
62
|
+
"BZD": 2,
|
|
63
|
+
"CAD": 2,
|
|
64
|
+
"CDF": 2,
|
|
65
|
+
"CHF": 2,
|
|
66
|
+
"CNY": 2,
|
|
67
|
+
"COP": 2,
|
|
68
|
+
"CRC": 2,
|
|
69
|
+
"CUP": 2,
|
|
70
|
+
"CVE": 2,
|
|
71
|
+
"CZK": 2,
|
|
72
|
+
"DKK": 2,
|
|
73
|
+
"DOP": 2,
|
|
74
|
+
"DZD": 2,
|
|
75
|
+
"EGP": 2,
|
|
76
|
+
"ERN": 2,
|
|
77
|
+
"ETB": 2,
|
|
78
|
+
"EUR": 2,
|
|
79
|
+
"FJD": 2,
|
|
80
|
+
"FKP": 2,
|
|
81
|
+
"GBP": 2,
|
|
82
|
+
"GEL": 2,
|
|
83
|
+
"GHS": 2,
|
|
84
|
+
"GIP": 2,
|
|
85
|
+
"GMD": 2,
|
|
86
|
+
"GTQ": 2,
|
|
87
|
+
"GYD": 2,
|
|
88
|
+
"HKD": 2,
|
|
89
|
+
"HNL": 2,
|
|
90
|
+
"HRK": 2,
|
|
91
|
+
"HTG": 2,
|
|
92
|
+
"HUF": 2,
|
|
93
|
+
"IDR": 2,
|
|
94
|
+
"ILS": 2,
|
|
95
|
+
"INR": 2,
|
|
96
|
+
"IRR": 2,
|
|
97
|
+
"JMD": 2,
|
|
98
|
+
"KES": 2,
|
|
99
|
+
"KGS": 2,
|
|
100
|
+
"KHR": 2,
|
|
101
|
+
"KPW": 2,
|
|
102
|
+
"KYD": 2,
|
|
103
|
+
"KZT": 2,
|
|
104
|
+
"LAK": 2,
|
|
105
|
+
"LBP": 2,
|
|
106
|
+
"LKR": 2,
|
|
107
|
+
"LRD": 2,
|
|
108
|
+
"LSL": 2,
|
|
109
|
+
"MAD": 2,
|
|
110
|
+
"MDL": 2,
|
|
111
|
+
"MGA": 2,
|
|
112
|
+
"MKD": 2,
|
|
113
|
+
"MMK": 2,
|
|
114
|
+
"MNT": 2,
|
|
115
|
+
"MOP": 2,
|
|
116
|
+
"MRU": 2,
|
|
117
|
+
"MUR": 2,
|
|
118
|
+
"MVR": 2,
|
|
119
|
+
"MWK": 2,
|
|
120
|
+
"MXN": 2,
|
|
121
|
+
"MYR": 2,
|
|
122
|
+
"MZN": 2,
|
|
123
|
+
"NAD": 2,
|
|
124
|
+
"NGN": 2,
|
|
125
|
+
"NIO": 2,
|
|
126
|
+
"NOK": 2,
|
|
127
|
+
"NPR": 2,
|
|
128
|
+
"NZD": 2,
|
|
129
|
+
"PAB": 2,
|
|
130
|
+
"PEN": 2,
|
|
131
|
+
"PGK": 2,
|
|
132
|
+
"PHP": 2,
|
|
133
|
+
"PKR": 2,
|
|
134
|
+
"PLN": 2,
|
|
135
|
+
"QAR": 2,
|
|
136
|
+
"RON": 2,
|
|
137
|
+
"RSD": 2,
|
|
138
|
+
"RUB": 2,
|
|
139
|
+
"SAR": 2,
|
|
140
|
+
"SBD": 2,
|
|
141
|
+
"SCR": 2,
|
|
142
|
+
"SDG": 2,
|
|
143
|
+
"SEK": 2,
|
|
144
|
+
"SGD": 2,
|
|
145
|
+
"SHP": 2,
|
|
146
|
+
"SLE": 2,
|
|
147
|
+
"SOS": 2,
|
|
148
|
+
"SRD": 2,
|
|
149
|
+
"SSP": 2,
|
|
150
|
+
"STN": 2,
|
|
151
|
+
"SVC": 2,
|
|
152
|
+
"SYP": 2,
|
|
153
|
+
"SZL": 2,
|
|
154
|
+
"THB": 2,
|
|
155
|
+
"TJS": 2,
|
|
156
|
+
"TMT": 2,
|
|
157
|
+
"TOP": 2,
|
|
158
|
+
"TRY": 2,
|
|
159
|
+
"TTD": 2,
|
|
160
|
+
"TWD": 2,
|
|
161
|
+
"TZS": 2,
|
|
162
|
+
"UAH": 2,
|
|
163
|
+
"USD": 2,
|
|
164
|
+
"UYU": 2,
|
|
165
|
+
"UZS": 2,
|
|
166
|
+
"VES": 2,
|
|
167
|
+
"WST": 2,
|
|
168
|
+
"XCD": 2,
|
|
169
|
+
"YER": 2,
|
|
170
|
+
"ZAR": 2,
|
|
171
|
+
"ZMW": 2,
|
|
172
|
+
"ZWL": 2,
|
|
173
|
+
# 3 decimal places (Middle East oil currencies)
|
|
174
|
+
"BHD": 3,
|
|
175
|
+
"IQD": 3,
|
|
176
|
+
"JOD": 3,
|
|
177
|
+
"KWD": 3,
|
|
178
|
+
"LYD": 3,
|
|
179
|
+
"OMR": 3,
|
|
180
|
+
"TND": 3,
|
|
181
|
+
# 4 decimal places (rare, mostly historic settlement)
|
|
182
|
+
"CLF": 4,
|
|
183
|
+
"UYW": 4,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# User-registered custom codes.
|
|
187
|
+
_CUSTOM: dict[str, int] = {}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_valid_currency(code: str) -> bool:
|
|
191
|
+
"""Return True if ``code`` is a known ISO 4217 or custom-registered code."""
|
|
192
|
+
if not isinstance(code, str):
|
|
193
|
+
return False
|
|
194
|
+
up = code.upper()
|
|
195
|
+
return up in _ISO_4217 or up in _CUSTOM
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_decimals(code: str) -> int:
|
|
199
|
+
"""Return the canonical decimal places for ``code``.
|
|
200
|
+
|
|
201
|
+
Raises
|
|
202
|
+
------
|
|
203
|
+
KeyError
|
|
204
|
+
If the code is not a known ISO 4217 or custom-registered code.
|
|
205
|
+
"""
|
|
206
|
+
up = code.upper()
|
|
207
|
+
if up in _CUSTOM:
|
|
208
|
+
return _CUSTOM[up]
|
|
209
|
+
if up in _ISO_4217:
|
|
210
|
+
return _ISO_4217[up]
|
|
211
|
+
raise KeyError(
|
|
212
|
+
f"Unknown currency code: {code!r}. "
|
|
213
|
+
f"Use simweave.currency.register_custom({code!r}, decimals=...) if intentional."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def register_custom(code: str, decimals: int) -> None:
|
|
218
|
+
"""Register a non-ISO currency code (crypto, in-game currency, test fixture).
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
code:
|
|
223
|
+
Uppercase identifier. Overwrites a prior custom registration but
|
|
224
|
+
refuses to shadow a real ISO 4217 code.
|
|
225
|
+
decimals:
|
|
226
|
+
Non-negative number of minor-unit decimal places.
|
|
227
|
+
|
|
228
|
+
Examples
|
|
229
|
+
--------
|
|
230
|
+
>>> from simweave.currency import register_custom, Money
|
|
231
|
+
>>> register_custom("BTC", decimals=8)
|
|
232
|
+
>>> Money("0.12345678", "BTC").amount
|
|
233
|
+
Decimal('0.12345678')
|
|
234
|
+
"""
|
|
235
|
+
if not isinstance(code, str) or not code.strip():
|
|
236
|
+
raise ValueError("Currency code must be a non-empty string.")
|
|
237
|
+
up = code.upper()
|
|
238
|
+
if up in _ISO_4217:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"{up!r} is an ISO 4217 code; refuse to shadow with register_custom."
|
|
241
|
+
)
|
|
242
|
+
if not isinstance(decimals, int) or decimals < 0:
|
|
243
|
+
raise ValueError("decimals must be a non-negative integer.")
|
|
244
|
+
_CUSTOM[up] = decimals
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def unregister_custom(code: str) -> None:
|
|
248
|
+
"""Remove a previously registered custom code. No-op if absent."""
|
|
249
|
+
_CUSTOM.pop(code.upper(), None)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def list_codes(*, include_custom: bool = True) -> tuple[str, ...]:
|
|
253
|
+
"""Return a sorted tuple of all currently known codes."""
|
|
254
|
+
codes = set(_ISO_4217)
|
|
255
|
+
if include_custom:
|
|
256
|
+
codes.update(_CUSTOM)
|
|
257
|
+
return tuple(sorted(codes))
|