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,317 @@
1
+ """FIFO queue and priority queue primitives.
2
+
3
+ Queues are :class:`~simweave.core.entity.Entity` subclasses so they register
4
+ with the environment and receive ``tick(dt, env)``. During a tick a queue
5
+ ages the items it contains. Balking and reneging (via
6
+ :class:`EntityProperties <simweave.discrete.properties.EntityProperties>`) are
7
+ handled on :meth:`enqueue` and :meth:`tick` respectively.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import heapq
13
+ from collections import deque
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from simweave.core.entity import Entity
18
+ from simweave.core.logging import get_logger
19
+
20
+ log = get_logger("discrete.queue")
21
+
22
+
23
+ class Queue(Entity):
24
+ """Bounded FIFO queue.
25
+
26
+ Parameters
27
+ ----------
28
+ maxlen:
29
+ Maximum number of items held. Extra arrivals are dropped (and the
30
+ drop counter is incremented).
31
+ name:
32
+ Human-readable name.
33
+ next_q:
34
+ Where forwarded items go -- another Queue/Service or the sentinel
35
+ string ``"terminus"`` meaning "remove from the system".
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ maxlen: int = 10,
41
+ name: str | None = None,
42
+ next_q: "Queue | str" = "terminus",
43
+ ) -> None:
44
+ super().__init__(name=name)
45
+ if maxlen <= 0:
46
+ raise ValueError("Queue maxlen must be positive.")
47
+ self._deque: deque[Entity] = deque(maxlen=maxlen)
48
+ self.maxlen = maxlen
49
+ self._next_q: "Queue | str" = "terminus"
50
+ self.next_q = next_q # validate via setter
51
+ self.dropped_count: int = 0
52
+ self.reneged_count: int = 0
53
+ self.balked_count: int = 0
54
+ # Metrics accumulated over the lifetime of the queue.
55
+ self.cumulative_length_time: float = 0.0 # integral of len(q) * dt
56
+ self.cumulative_wait_time: float = 0.0 # sum of total_wait_time on departure
57
+ self.arrivals: int = 0
58
+ self.departures: int = 0
59
+
60
+ # ------------------------------------------------------------------
61
+ # next_q management
62
+ # ------------------------------------------------------------------
63
+ @property
64
+ def next_q(self) -> "Queue | str":
65
+ return self._next_q
66
+
67
+ @next_q.setter
68
+ def next_q(self, value: "Queue | str") -> None:
69
+ if value == "terminus" or isinstance(value, Queue):
70
+ self._next_q = value
71
+ else:
72
+ raise TypeError(
73
+ f"next_q must be a Queue or 'terminus'; got {type(value).__name__}"
74
+ )
75
+
76
+ # ------------------------------------------------------------------
77
+ # Basic container protocol
78
+ # ------------------------------------------------------------------
79
+ def __len__(self) -> int:
80
+ return len(self._deque)
81
+
82
+ def __iter__(self):
83
+ return iter(self._deque)
84
+
85
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
86
+ nq = self._next_q if isinstance(self._next_q, str) else self._next_q.name
87
+ return f"{type(self).__name__}(name={self.name!r}, len={len(self)}/{self.maxlen}, next={nq})"
88
+
89
+ @property
90
+ def is_full(self) -> bool:
91
+ return len(self._deque) >= self.maxlen
92
+
93
+ @property
94
+ def is_empty(self) -> bool:
95
+ return len(self._deque) == 0
96
+
97
+ def peek(self) -> Entity | None:
98
+ return self._deque[0] if self._deque else None
99
+
100
+ # ------------------------------------------------------------------
101
+ # Core operations
102
+ # ------------------------------------------------------------------
103
+ def enqueue(self, item: Entity) -> bool:
104
+ """Attempt to append an item. Returns ``True`` on success.
105
+
106
+ Honours ``item.sim_properties.balk_on_length`` if present.
107
+ """
108
+ sim_props = getattr(item, "sim_properties", None)
109
+ balk = (
110
+ getattr(sim_props, "balk_on_length", None)
111
+ if sim_props is not None
112
+ else None
113
+ )
114
+ if balk is not None and len(self._deque) >= balk:
115
+ self.balked_count += 1
116
+ log.debug(
117
+ "%s: %s balked (len %d >= %d).",
118
+ self.name,
119
+ item.name,
120
+ len(self._deque),
121
+ balk,
122
+ )
123
+ return False
124
+
125
+ if self.is_full:
126
+ self.dropped_count += 1
127
+ log.debug("%s: dropped %s (full).", self.name, item.name)
128
+ return False
129
+
130
+ item.current_wait_time = 0.0
131
+ self._deque.append(item)
132
+ self.arrivals += 1
133
+ return True
134
+
135
+ def dequeue(self) -> Entity:
136
+ if not self._deque:
137
+ raise IndexError(f"{self.name}: dequeue from empty queue")
138
+ item = self._deque.popleft()
139
+ self.departures += 1
140
+ self.cumulative_wait_time += item.total_wait_time
141
+ return item
142
+
143
+ def renege_tail(self) -> Entity:
144
+ if not self._deque:
145
+ raise IndexError(f"{self.name}: renege from empty queue")
146
+ item = self._deque.pop()
147
+ self.reneged_count += 1
148
+ return item
149
+
150
+ def forward(self, override_target: "Queue | str | None" = None) -> bool:
151
+ """Dequeue head and enqueue into ``override_target`` (or ``self.next_q``).
152
+
153
+ Returns ``True`` if forwarded, ``False`` if downstream is blocked (in
154
+ which case the head item is *not* removed).
155
+ """
156
+ target = override_target if override_target is not None else self._next_q
157
+ if not self._deque:
158
+ return False
159
+ if target == "terminus":
160
+ self.dequeue()
161
+ return True
162
+ assert isinstance(target, Queue)
163
+ if target.is_full:
164
+ return False
165
+ item = self._deque.popleft()
166
+ self.departures += 1
167
+ self.cumulative_wait_time += item.total_wait_time
168
+ accepted = target.enqueue(item)
169
+ if not accepted:
170
+ # Shouldn't normally happen because we checked is_full, but handle
171
+ # balking on the downstream queue: silently drop.
172
+ self.dropped_count += 1
173
+ return True
174
+
175
+ # ------------------------------------------------------------------
176
+ # Tick-loop integration
177
+ # ------------------------------------------------------------------
178
+ def tick(self, dt: float, env) -> None:
179
+ super().tick(dt, env)
180
+ # Time-integrate length for Little's law calculations.
181
+ self.cumulative_length_time += len(self._deque) * dt
182
+ # Age waiting items and handle reneging.
183
+ to_renege: list[Entity] = []
184
+ for item in list(self._deque):
185
+ item.age += dt
186
+ item.current_wait_time += dt
187
+ item.total_wait_time += dt
188
+ renege_after = None
189
+ sim_props = getattr(item, "sim_properties", None)
190
+ if sim_props is not None:
191
+ renege_after = getattr(sim_props, "renege_after", None)
192
+ if renege_after is not None and item.current_wait_time >= renege_after:
193
+ to_renege.append(item)
194
+ for item in to_renege:
195
+ try:
196
+ self._deque.remove(item)
197
+ self.reneged_count += 1
198
+ log.debug(
199
+ "%s: %s reneged after %.2f.",
200
+ self.name,
201
+ item.name,
202
+ item.current_wait_time,
203
+ )
204
+ except ValueError:
205
+ pass
206
+
207
+ def has_work(self, env) -> bool:
208
+ # A plain queue does not perform work; items just wait.
209
+ return False
210
+
211
+ # ------------------------------------------------------------------
212
+ # Metrics
213
+ # ------------------------------------------------------------------
214
+ def average_length(self, elapsed: float) -> float:
215
+ """Mean queue length over the elapsed simulation time (L in Little's law)."""
216
+ if elapsed <= 0:
217
+ return 0.0
218
+ return self.cumulative_length_time / elapsed
219
+
220
+ def average_wait(self) -> float:
221
+ """Mean residence time of completed items (W in Little's law)."""
222
+ if self.departures == 0:
223
+ return 0.0
224
+ return self.cumulative_wait_time / self.departures
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Priority queue
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ @dataclass(order=True)
233
+ class _PItem:
234
+ priority: float
235
+ seq: int
236
+ item: Any = field(compare=False)
237
+
238
+
239
+ class PriorityQueue(Queue):
240
+ """Min-heap priority queue. Lower priority values dequeue first.
241
+
242
+ Items are wrapped in ``(priority, seq, item)`` tuples internally so the
243
+ payload doesn't need to be comparable.
244
+ """
245
+
246
+ def __init__(
247
+ self,
248
+ maxlen: int = 10,
249
+ name: str | None = None,
250
+ next_q: "Queue | str" = "terminus",
251
+ ) -> None:
252
+ super().__init__(maxlen=maxlen, name=name, next_q=next_q)
253
+ self._heap: list[_PItem] = []
254
+ self._seq_counter = 0
255
+ # Drop the inherited deque; we store in _heap. We keep len() consistent
256
+ # by overriding __len__.
257
+
258
+ def __len__(self) -> int:
259
+ return len(self._heap)
260
+
261
+ def __iter__(self):
262
+ return (p.item for p in self._heap)
263
+
264
+ def peek(self) -> Entity | None:
265
+ return self._heap[0].item if self._heap else None
266
+
267
+ @property
268
+ def is_full(self) -> bool:
269
+ return len(self._heap) >= self.maxlen
270
+
271
+ @property
272
+ def is_empty(self) -> bool:
273
+ return not self._heap
274
+
275
+ def enqueue(self, item: Entity, priority: float = 0.0) -> bool: # type: ignore[override]
276
+ if self.is_full:
277
+ self.dropped_count += 1
278
+ return False
279
+ item.current_wait_time = 0.0
280
+ heapq.heappush(
281
+ self._heap, _PItem(priority=priority, seq=self._seq_counter, item=item)
282
+ )
283
+ self._seq_counter += 1
284
+ self.arrivals += 1
285
+ return True
286
+
287
+ def dequeue(self) -> Entity: # type: ignore[override]
288
+ if not self._heap:
289
+ raise IndexError(f"{self.name}: dequeue from empty priority queue")
290
+ wrapped = heapq.heappop(self._heap)
291
+ self.departures += 1
292
+ self.cumulative_wait_time += wrapped.item.total_wait_time
293
+ return wrapped.item
294
+
295
+ def forward(self, override_target=None) -> bool: # type: ignore[override]
296
+ target = override_target if override_target is not None else self._next_q
297
+ if not self._heap:
298
+ return False
299
+ if target == "terminus":
300
+ self.dequeue()
301
+ return True
302
+ assert isinstance(target, Queue)
303
+ if target.is_full:
304
+ return False
305
+ item = self.dequeue()
306
+ target.enqueue(item)
307
+ return True
308
+
309
+ def tick(self, dt: float, env) -> None: # type: ignore[override]
310
+ # Skip Queue.tick deque iteration; operate on our heap.
311
+ Entity.tick(self, dt, env)
312
+ self.cumulative_length_time += len(self._heap) * dt
313
+ for wrapper in self._heap:
314
+ item = wrapper.item
315
+ item.age += dt
316
+ item.current_wait_time += dt
317
+ item.total_wait_time += dt
@@ -0,0 +1,89 @@
1
+ """Reusable resources and resource pools.
2
+
3
+ A :class:`Resource` is an :class:`Entity` that a :class:`Service` may acquire
4
+ before serving an item and release afterwards. Resources are held in a
5
+ :class:`ResourcePool`, which is itself a :class:`Queue` so it enjoys the
6
+ same tick semantics and bookkeeping.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from simweave.core.entity import Entity
12
+ from simweave.discrete.queues import Queue
13
+
14
+
15
+ class Resource(Entity):
16
+ """A reusable resource that can be checked out of a pool."""
17
+
18
+ def __init__(
19
+ self, name: str | None = None, home_pool: "ResourcePool | str" = "terminus"
20
+ ) -> None:
21
+ super().__init__(name=name)
22
+ self.home_pool: "ResourcePool | str" = home_pool
23
+ self.times_acquired: int = 0
24
+ self.busy_time: float = 0.0
25
+ self._is_busy: bool = False
26
+
27
+ @property
28
+ def is_busy(self) -> bool:
29
+ return self._is_busy
30
+
31
+ def release(self, to: "ResourcePool | None" = None) -> None:
32
+ """Return the resource to ``to`` or its home pool."""
33
+ target = to if to is not None else self.home_pool
34
+ self._is_busy = False
35
+ if isinstance(target, ResourcePool):
36
+ target.deposit(self)
37
+ elif target == "terminus":
38
+ return
39
+ else:
40
+ raise TypeError(
41
+ "Resource.release target must be a ResourcePool or 'terminus'."
42
+ )
43
+
44
+ def tick(self, dt: float, env) -> None:
45
+ super().tick(dt, env)
46
+ if self._is_busy:
47
+ self.busy_time += dt
48
+
49
+
50
+ class ResourcePool(Queue):
51
+ """Pool of interchangeable resources that a Service can check out."""
52
+
53
+ def __init__(self, maxlen: int = 10, name: str | None = None) -> None:
54
+ super().__init__(maxlen=maxlen, name=name, next_q="terminus")
55
+
56
+ def deposit(self, resource: Resource) -> None:
57
+ """Return a resource to the pool. Raises if the pool is at capacity."""
58
+ if not isinstance(resource, Resource):
59
+ raise TypeError("ResourcePool only accepts Resource instances.")
60
+ resource.home_pool = self
61
+ if self.is_full:
62
+ raise RuntimeError(f"{self.name}: pool full when returning {resource.name}")
63
+ self._deque.append(resource)
64
+ self.arrivals += 1
65
+
66
+ # Alias for legacy naming.
67
+ add_resource = deposit
68
+
69
+ def try_acquire(self) -> Resource | None:
70
+ """Non-blocking acquire. Returns a Resource or ``None`` if empty."""
71
+ if not self._deque:
72
+ return None
73
+ resource = self._deque.popleft()
74
+ assert isinstance(resource, Resource)
75
+ resource.home_pool = self
76
+ resource._is_busy = True
77
+ resource.times_acquired += 1
78
+ self.departures += 1
79
+ return resource
80
+
81
+ def release(self, resource: Resource) -> None:
82
+ """Release a previously acquired resource back to this pool."""
83
+ resource.release(to=self)
84
+
85
+ def tick(self, dt: float, env) -> None:
86
+ super().tick(dt, env)
87
+ # Propagate tick to each idle resource so busy_time stays accurate
88
+ # when they are NOT in the pool (those will be ticked by the Service).
89
+ # Idle resources sitting here don't accrue busy_time.
@@ -0,0 +1,228 @@
1
+ """Multi-channel Service and arrival generator.
2
+
3
+ A :class:`Service` is a :class:`Queue` (the pre-service buffer) plus a set of
4
+ independent :class:`_WorkChannel` instances. Each channel processes one item
5
+ at a time; if a completed item cannot be forwarded downstream (because the
6
+ next queue is full) it sits in a "blocked-completion" slot until it clears.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Callable
12
+
13
+ import numpy as np
14
+
15
+ from simweave.core.entity import Entity
16
+ from simweave.core.logging import get_logger
17
+ from simweave.discrete.queues import Queue
18
+ from simweave.discrete.resources import ResourcePool
19
+
20
+ if TYPE_CHECKING:
21
+ from simweave.core.environment import SimEnvironment
22
+
23
+ log = get_logger("discrete.service")
24
+
25
+
26
+ class _WorkChannel:
27
+ """Internal single-item processing slot within a Service."""
28
+
29
+ __slots__ = ("parent", "idx", "current", "blocked", "held_resource", "busy_time")
30
+
31
+ def __init__(self, parent: "Service", idx: int) -> None:
32
+ self.parent = parent
33
+ self.idx = idx
34
+ self.current: Entity | None = None
35
+ self.blocked: Entity | None = None # completed but next_q was full
36
+ self.held_resource = None
37
+ self.busy_time: float = 0.0
38
+
39
+ def is_busy(self) -> bool:
40
+ return self.current is not None or self.blocked is not None
41
+
42
+ def tick(self, dt: float, env) -> None:
43
+ # 1. Try to clear any blocked completed item first.
44
+ if self.blocked is not None:
45
+ if self._try_forward(self.blocked):
46
+ self._release_resource()
47
+ self.blocked = None
48
+
49
+ # 2. If idle, pull new work from the buffer.
50
+ if self.current is None and self.blocked is None:
51
+ self._try_start(env)
52
+
53
+ # 3. Process the current item, if any.
54
+ if self.current is not None:
55
+ step = min(self.current.remaining_service_time, dt)
56
+ self.current.remaining_service_time -= step
57
+ self.current.age += step
58
+ self.busy_time += step
59
+ if self.current.remaining_service_time <= 1e-12:
60
+ item = self.current
61
+ self.current = None
62
+ if self._try_forward(item):
63
+ self._release_resource()
64
+ else:
65
+ self.blocked = item
66
+
67
+ def _try_start(self, env) -> None:
68
+ if not self.parent._deque:
69
+ return
70
+ if self.parent.resources is not None:
71
+ r = self.parent.resources.try_acquire()
72
+ if r is None:
73
+ return
74
+ self.held_resource = r
75
+ item = self.parent.dequeue()
76
+ rng = getattr(self.parent, "rng", None)
77
+ sim_props = getattr(item, "sim_properties", None)
78
+ if sim_props is not None and hasattr(sim_props, "draw_service_time"):
79
+ item.remaining_service_time = sim_props.draw_service_time(rng)
80
+ elif item.remaining_service_time <= 0:
81
+ item.remaining_service_time = self.parent.default_service_time
82
+ self.current = item
83
+
84
+ def _try_forward(self, item: Entity) -> bool:
85
+ target = self.parent.next_q
86
+ if target == "terminus":
87
+ self.parent._record_completion(item)
88
+ return True
89
+ assert isinstance(target, Queue)
90
+ if target.is_full:
91
+ return False
92
+ accepted = target.enqueue(item)
93
+ if accepted:
94
+ self.parent._record_completion(item)
95
+ return accepted
96
+
97
+ def _release_resource(self) -> None:
98
+ if self.held_resource is not None:
99
+ self.parent.resources.release(self.held_resource)
100
+ self.held_resource = None
101
+
102
+
103
+ class Service(Queue):
104
+ """Multi-channel server with optional resource pool.
105
+
106
+ Parameters
107
+ ----------
108
+ capacity:
109
+ Number of parallel work channels (servers).
110
+ buffer_size:
111
+ Max queue length *before* service. Further arrivals are dropped.
112
+ next_q:
113
+ Where to forward completed items.
114
+ resources:
115
+ Optional :class:`ResourcePool` from which to acquire one resource per
116
+ item served. If ``None``, the service is unconstrained.
117
+ default_service_time:
118
+ Fallback when an arriving entity has no ``sim_properties``.
119
+ rng:
120
+ Optional numpy Generator used when drawing service times.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ capacity: int = 1,
126
+ buffer_size: int = 10,
127
+ next_q: "Queue | str" = "terminus",
128
+ resources: ResourcePool | None = None,
129
+ default_service_time: float = 1.0,
130
+ rng: np.random.Generator | None = None,
131
+ name: str | None = None,
132
+ ) -> None:
133
+ super().__init__(maxlen=buffer_size, name=name, next_q=next_q)
134
+ if capacity < 1:
135
+ raise ValueError("Service capacity must be >= 1.")
136
+ self.capacity = capacity
137
+ self.resources = resources
138
+ self.default_service_time = float(default_service_time)
139
+ self.rng = rng if rng is not None else np.random.default_rng()
140
+ self.channels = [_WorkChannel(self, i) for i in range(capacity)]
141
+ self.completed_count: int = 0
142
+ self.completed_total_time: float = (
143
+ 0.0 # sum of (wait + service) across completed
144
+ )
145
+
146
+ # ------------------------------------------------------------------
147
+ # next_q propagation to channels is implicit: they read parent.next_q.
148
+ # ------------------------------------------------------------------
149
+
150
+ def tick(self, dt: float, env) -> None:
151
+ # First tick the work channels (they pull from the buffer).
152
+ for ch in self.channels:
153
+ ch.tick(dt, env)
154
+ # Then tick the buffer's own age/renege bookkeeping (parent Queue).
155
+ super().tick(dt, env)
156
+
157
+ def has_work(self, env) -> bool:
158
+ return any(ch.is_busy() for ch in self.channels) or bool(self._deque)
159
+
160
+ # ------------------------------------------------------------------
161
+ # Metrics
162
+ # ------------------------------------------------------------------
163
+ def utilisation(self, elapsed: float) -> float:
164
+ """Average utilisation across all channels over ``elapsed`` time."""
165
+ if elapsed <= 0 or self.capacity == 0:
166
+ return 0.0
167
+ busy = sum(ch.busy_time for ch in self.channels)
168
+ return busy / (self.capacity * elapsed)
169
+
170
+ def average_residence(self) -> float:
171
+ """Mean total time (waiting + service) for completed items."""
172
+ if self.completed_count == 0:
173
+ return 0.0
174
+ return self.completed_total_time / self.completed_count
175
+
176
+ def _record_completion(self, item: Entity) -> None:
177
+ self.completed_count += 1
178
+ self.completed_total_time += item.total_wait_time + item.age # rough summary
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Arrival generator
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ class ArrivalGenerator(Entity):
187
+ """Generates new entities according to an inter-arrival distribution.
188
+
189
+ On each tick it adds to an internal clock and, whenever the clock passes
190
+ the next scheduled arrival, invokes ``factory(env)`` to mint a new
191
+ entity and pushes it into ``target``. Multiple arrivals within a single
192
+ tick are handled correctly.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ interarrival: Callable[[np.random.Generator], float],
198
+ factory: Callable[[SimEnvironment], Entity],
199
+ target: Queue,
200
+ rng: np.random.Generator | None = None,
201
+ name: str | None = None,
202
+ ) -> None:
203
+ super().__init__(name=name)
204
+ self.interarrival = interarrival
205
+ self.factory = factory
206
+ self.target = target
207
+ self.rng = rng if rng is not None else np.random.default_rng()
208
+ self._countdown: float = float(self.interarrival(self.rng))
209
+ self.generated: int = 0
210
+ self.rejected: int = 0
211
+
212
+ def tick(self, dt: float, env) -> None:
213
+ super().tick(dt, env)
214
+ remaining = dt
215
+ while remaining >= self._countdown:
216
+ remaining -= self._countdown
217
+ entity = self.factory(env)
218
+ if self.target.enqueue(entity):
219
+ self.generated += 1
220
+ else:
221
+ self.rejected += 1
222
+ self._countdown = float(self.interarrival(self.rng))
223
+ self._countdown -= remaining
224
+
225
+ def has_work(self, env) -> bool:
226
+ # The generator always has "work" in the sense that another arrival
227
+ # is pending.
228
+ return True
@@ -0,0 +1,5 @@
1
+ """Monte Carlo harness."""
2
+
3
+ from simweave.mc.runner import MCResult, run_monte_carlo, run_batched_mc
4
+
5
+ __all__ = ["MCResult", "run_monte_carlo", "run_batched_mc"]