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,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"))
@@ -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))