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,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
|