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
simweave/viz/plots.py ADDED
@@ -0,0 +1,618 @@
1
+ """Plot helpers for simweave.
2
+
3
+ Every helper:
4
+
5
+ * Lazy-imports plotly so the core package stays cheap.
6
+ * Returns a ``plotly.graph_objects.Figure`` so EdgeWeave (or any other
7
+ JS frontend) can serialise via ``fig.to_json()`` and render natively.
8
+ * Applies the current default theme unless ``theme=`` is passed.
9
+
10
+ All time / value arrays are accepted as plain numpy or Python lists; the
11
+ helpers do not require a specific simweave object as long as the inputs
12
+ have the documented shape.
13
+
14
+ Public API
15
+ ----------
16
+ * :func:`plot_state_trajectories` -- continuous SimulationResult.
17
+ * :func:`plot_phase_portrait` -- 2-D phase portrait of a SimulationResult.
18
+ * :func:`plot_queue_length` -- queue length over time from a recorder.
19
+ * :func:`plot_service_utilisation` -- per-channel busy time + aggregate
20
+ utilisation from a recorder.
21
+ * :func:`plot_warehouse_stock` -- per-SKU stock levels from a recorder.
22
+ * :func:`plot_mc_fan` -- percentile fan chart for a Monte Carlo trajectory
23
+ ensemble (accepts ``MCResult``, raw 2D array, or ``(times, samples)``).
24
+ * :func:`plot_agent_path` -- agent traversal over a 2-D graph.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Any, Iterable, Sequence
30
+
31
+ import numpy as np
32
+
33
+ from simweave.viz import _plotly
34
+ from simweave.viz.themes import apply_theme
35
+
36
+
37
+ # --------------------------------------------------------------------------- #
38
+ # Continuous: state trajectories + phase portrait #
39
+ # --------------------------------------------------------------------------- #
40
+
41
+
42
+ def plot_state_trajectories(
43
+ result: Any,
44
+ channels: Sequence[int] | None = None,
45
+ *,
46
+ theme: str | None = None,
47
+ title: str | None = None,
48
+ ) -> Any:
49
+ """Line plot of each state channel vs ``result.time``.
50
+
51
+ Parameters
52
+ ----------
53
+ result:
54
+ A :class:`~simweave.continuous.solver.SimulationResult` or any
55
+ object exposing ``.time``, ``.state`` (shape ``(T, n)``) and
56
+ ``.state_labels``.
57
+ channels:
58
+ Optional indices of state channels to include. Default: all.
59
+ theme:
60
+ Theme name. Default: the global default (see
61
+ :func:`simweave.viz.set_default_theme`).
62
+ title:
63
+ Optional figure title.
64
+ """
65
+ go = _plotly.require_go()
66
+ time = np.asarray(result.time)
67
+ state = np.asarray(result.state)
68
+ labels = list(getattr(result, "state_labels", ()) or [
69
+ f"x{i}" for i in range(state.shape[1])
70
+ ])
71
+ idxs = list(channels) if channels is not None else list(range(state.shape[1]))
72
+
73
+ fig = go.Figure()
74
+ for i in idxs:
75
+ fig.add_trace(
76
+ go.Scatter(
77
+ x=time,
78
+ y=state[:, i],
79
+ mode="lines",
80
+ name=labels[i] if i < len(labels) else f"x{i}",
81
+ )
82
+ )
83
+ sys_name = getattr(result, "system_name", "system")
84
+ fig.update_layout(
85
+ title=title or f"{sys_name} -- state trajectories",
86
+ xaxis_title="time",
87
+ yaxis_title="state",
88
+ legend_title_text="channel",
89
+ )
90
+ return apply_theme(fig, theme)
91
+
92
+
93
+ def plot_phase_portrait(
94
+ result: Any,
95
+ x_idx: int = 0,
96
+ y_idx: int = 1,
97
+ *,
98
+ theme: str | None = None,
99
+ title: str | None = None,
100
+ ) -> Any:
101
+ """Plot ``state[:, x_idx]`` vs ``state[:, y_idx]`` with a start marker."""
102
+ go = _plotly.require_go()
103
+ state = np.asarray(result.state)
104
+ if state.shape[1] <= max(x_idx, y_idx):
105
+ raise IndexError(
106
+ f"phase portrait needs at least {max(x_idx, y_idx) + 1} state channels; "
107
+ f"got {state.shape[1]}."
108
+ )
109
+ labels = list(getattr(result, "state_labels", ()) or [
110
+ f"x{i}" for i in range(state.shape[1])
111
+ ])
112
+ xs = state[:, x_idx]
113
+ ys = state[:, y_idx]
114
+
115
+ fig = go.Figure()
116
+ fig.add_trace(
117
+ go.Scatter(
118
+ x=xs,
119
+ y=ys,
120
+ mode="lines",
121
+ name="trajectory",
122
+ )
123
+ )
124
+ fig.add_trace(
125
+ go.Scatter(
126
+ x=[xs[0]],
127
+ y=[ys[0]],
128
+ mode="markers",
129
+ marker={"size": 10, "symbol": "circle"},
130
+ name="start",
131
+ )
132
+ )
133
+ fig.add_trace(
134
+ go.Scatter(
135
+ x=[xs[-1]],
136
+ y=[ys[-1]],
137
+ mode="markers",
138
+ marker={"size": 10, "symbol": "x"},
139
+ name="end",
140
+ )
141
+ )
142
+ sys_name = getattr(result, "system_name", "system")
143
+ fig.update_layout(
144
+ title=title or f"{sys_name} -- phase portrait",
145
+ xaxis_title=labels[x_idx] if x_idx < len(labels) else f"x{x_idx}",
146
+ yaxis_title=labels[y_idx] if y_idx < len(labels) else f"y{y_idx}",
147
+ )
148
+ return apply_theme(fig, theme)
149
+
150
+
151
+ # --------------------------------------------------------------------------- #
152
+ # Discrete: queue length and service utilisation (via recorders) #
153
+ # --------------------------------------------------------------------------- #
154
+
155
+
156
+ def plot_queue_length(
157
+ recorder: Any,
158
+ *,
159
+ theme: str | None = None,
160
+ title: str | None = None,
161
+ ) -> Any:
162
+ """Step plot of queue length over time.
163
+
164
+ Parameters
165
+ ----------
166
+ recorder:
167
+ A :class:`~simweave.viz.recorders.QueueLengthRecorder` (or anything
168
+ exposing ``.times`` and ``.lengths``).
169
+ """
170
+ go = _plotly.require_go()
171
+ times = np.asarray(recorder.times)
172
+ lengths = np.asarray(recorder.lengths)
173
+ qname = getattr(recorder.queue, "name", "queue") if hasattr(recorder, "queue") else "queue"
174
+
175
+ fig = go.Figure()
176
+ fig.add_trace(
177
+ go.Scatter(
178
+ x=times,
179
+ y=lengths,
180
+ mode="lines",
181
+ line={"shape": "hv"},
182
+ name=qname,
183
+ )
184
+ )
185
+ fig.update_layout(
186
+ title=title or f"queue length: {qname}",
187
+ xaxis_title="time",
188
+ yaxis_title="length",
189
+ )
190
+ return apply_theme(fig, theme)
191
+
192
+
193
+ def plot_service_utilisation(
194
+ recorder: Any,
195
+ *,
196
+ theme: str | None = None,
197
+ title: str | None = None,
198
+ ) -> Any:
199
+ """Aggregate utilisation line + per-channel busy-time lines.
200
+
201
+ Parameters
202
+ ----------
203
+ recorder:
204
+ A :class:`~simweave.viz.recorders.ServiceUtilisationRecorder`.
205
+ """
206
+ go = _plotly.require_go()
207
+ times = np.asarray(recorder.times)
208
+ util = np.asarray(recorder.utilisation)
209
+ busy = np.asarray(recorder.busy_time) # (T, n_channels)
210
+ sname = (
211
+ getattr(recorder.service, "name", "service")
212
+ if hasattr(recorder, "service")
213
+ else "service"
214
+ )
215
+
216
+ fig = go.Figure()
217
+ fig.add_trace(
218
+ go.Scatter(
219
+ x=times,
220
+ y=util,
221
+ mode="lines",
222
+ name="aggregate utilisation",
223
+ line={"width": 3},
224
+ )
225
+ )
226
+ if busy.ndim == 2:
227
+ for ch in range(busy.shape[1]):
228
+ fig.add_trace(
229
+ go.Scatter(
230
+ x=times,
231
+ y=busy[:, ch],
232
+ mode="lines",
233
+ name=f"channel {ch} busy_time",
234
+ line={"dash": "dot"},
235
+ yaxis="y2",
236
+ )
237
+ )
238
+ fig.update_layout(
239
+ title=title or f"service utilisation: {sname}",
240
+ xaxis_title="time",
241
+ yaxis={"title": "aggregate utilisation", "range": [0, 1]},
242
+ yaxis2={
243
+ "title": "cumulative busy time per channel",
244
+ "overlaying": "y",
245
+ "side": "right",
246
+ },
247
+ legend={"orientation": "h", "y": -0.2},
248
+ )
249
+ return apply_theme(fig, theme)
250
+
251
+
252
+ # --------------------------------------------------------------------------- #
253
+ # Supply chain: warehouse stock #
254
+ # --------------------------------------------------------------------------- #
255
+
256
+
257
+ def plot_warehouse_stock(
258
+ recorder: Any,
259
+ sku_indices: Sequence[int] | None = None,
260
+ *,
261
+ theme: str | None = None,
262
+ title: str | None = None,
263
+ show_reorder_points: bool = True,
264
+ ) -> Any:
265
+ """Per-SKU stock-level lines with optional reorder-point dashes.
266
+
267
+ Parameters
268
+ ----------
269
+ recorder:
270
+ A :class:`~simweave.viz.recorders.WarehouseStockRecorder`.
271
+ sku_indices:
272
+ Optional iterable of SKU indices to include. Default: all.
273
+ show_reorder_points:
274
+ If True, draws a horizontal dashed line at each SKU's reorder
275
+ point in the same colour family as its stock trace.
276
+ """
277
+ go = _plotly.require_go()
278
+ times = np.asarray(recorder.times)
279
+ stock = np.asarray(recorder.stock) # (T, n_skus)
280
+ names = list(recorder.sku_names)
281
+ rop = np.asarray(recorder.reorder_points)
282
+
283
+ idxs = list(sku_indices) if sku_indices is not None else list(range(stock.shape[1]))
284
+
285
+ fig = go.Figure()
286
+ for i in idxs:
287
+ sku = names[i] if i < len(names) else f"sku{i}"
288
+ fig.add_trace(
289
+ go.Scatter(
290
+ x=times,
291
+ y=stock[:, i],
292
+ mode="lines",
293
+ name=sku,
294
+ )
295
+ )
296
+ if show_reorder_points and i < rop.size:
297
+ fig.add_trace(
298
+ go.Scatter(
299
+ x=[times[0], times[-1]],
300
+ y=[rop[i], rop[i]],
301
+ mode="lines",
302
+ name=f"{sku} reorder",
303
+ line={"dash": "dash", "width": 1},
304
+ showlegend=False,
305
+ hoverinfo="skip",
306
+ )
307
+ )
308
+ wname = (
309
+ getattr(recorder.warehouse, "name", "warehouse")
310
+ if hasattr(recorder, "warehouse")
311
+ else "warehouse"
312
+ )
313
+ fig.update_layout(
314
+ title=title or f"warehouse stock: {wname}",
315
+ xaxis_title="time",
316
+ yaxis_title="stock level",
317
+ legend_title_text="SKU",
318
+ )
319
+ return apply_theme(fig, theme)
320
+
321
+
322
+ # --------------------------------------------------------------------------- #
323
+ # Monte Carlo: percentile fan #
324
+ # --------------------------------------------------------------------------- #
325
+
326
+
327
+ def _coerce_mc_input(
328
+ obj: Any,
329
+ times: Sequence[float] | np.ndarray | None,
330
+ ) -> tuple[np.ndarray, np.ndarray]:
331
+ """Return ``(times, samples)`` where samples is ``(n_runs, n_time)``.
332
+
333
+ Accepts:
334
+ * ``MCResult`` whose ``.samples`` is shape ``(n_runs, n_time)``
335
+ * raw 2-D ndarray ``(n_runs, n_time)``
336
+ * a ``(times, samples)`` tuple
337
+ """
338
+ if isinstance(obj, tuple) and len(obj) == 2:
339
+ t_in, s_in = obj
340
+ return np.asarray(t_in, dtype=float), np.asarray(s_in, dtype=float)
341
+
342
+ samples_attr = getattr(obj, "samples", None)
343
+ samples = np.asarray(samples_attr if samples_attr is not None else obj, dtype=float)
344
+ if samples.ndim != 2:
345
+ raise ValueError(
346
+ "plot_mc_fan expects an (n_runs, n_time) 2-D array of trajectories; "
347
+ f"got shape {samples.shape}."
348
+ )
349
+ if times is None:
350
+ t_arr = np.arange(samples.shape[1], dtype=float)
351
+ else:
352
+ t_arr = np.asarray(times, dtype=float)
353
+ if t_arr.size != samples.shape[1]:
354
+ raise ValueError(
355
+ f"times length ({t_arr.size}) does not match sample width "
356
+ f"({samples.shape[1]})."
357
+ )
358
+ return t_arr, samples
359
+
360
+
361
+ def plot_mc_fan(
362
+ mc_or_array: Any,
363
+ times: Sequence[float] | np.ndarray | None = None,
364
+ percentiles: Sequence[float] = (5, 25, 50, 75, 95),
365
+ *,
366
+ theme: str | None = None,
367
+ title: str | None = None,
368
+ show_mean: bool = True,
369
+ ) -> Any:
370
+ """Percentile fan chart for a Monte Carlo trajectory ensemble.
371
+
372
+ Parameters
373
+ ----------
374
+ mc_or_array:
375
+ ``MCResult`` (with 2-D ``.samples``), raw ``(n_runs, n_time)``
376
+ ndarray, or a ``(times, samples)`` tuple.
377
+ times:
378
+ Optional time axis. Ignored if ``mc_or_array`` is a tuple.
379
+ Defaults to ``arange(n_time)`` if not supplied.
380
+ percentiles:
381
+ Percentiles to draw. The median (or 50th percentile if present)
382
+ is rendered as a solid line; the rest form symmetric shaded bands
383
+ from outside-in.
384
+ show_mean:
385
+ If True, overlays the per-time mean as a thin dashed line.
386
+ """
387
+ go = _plotly.require_go()
388
+ t, samples = _coerce_mc_input(mc_or_array, times)
389
+
390
+ pct = sorted(float(p) for p in percentiles)
391
+ if not pct:
392
+ raise ValueError("percentiles must be non-empty.")
393
+ qs = np.percentile(samples, pct, axis=0) # shape (len(pct), n_time)
394
+
395
+ # Pair outside-in for shading: (lo, hi) -> band; if odd count, the middle one
396
+ # becomes the median line.
397
+ bands: list[tuple[int, int]] = []
398
+ i, j = 0, len(pct) - 1
399
+ while i < j:
400
+ bands.append((i, j))
401
+ i += 1
402
+ j -= 1
403
+ median_idx = i if (i == j) else None
404
+
405
+ fig = go.Figure()
406
+ n_bands = len(bands)
407
+ for k, (lo, hi) in enumerate(bands):
408
+ # Outer band fades the most; inner band darkest.
409
+ opacity = 0.15 + 0.45 * (k / max(n_bands - 1, 1))
410
+ # Lower trace (invisible line, will be filled to)
411
+ fig.add_trace(
412
+ go.Scatter(
413
+ x=t,
414
+ y=qs[lo],
415
+ mode="lines",
416
+ line={"width": 0},
417
+ showlegend=False,
418
+ hoverinfo="skip",
419
+ )
420
+ )
421
+ fig.add_trace(
422
+ go.Scatter(
423
+ x=t,
424
+ y=qs[hi],
425
+ mode="lines",
426
+ line={"width": 0},
427
+ fill="tonexty",
428
+ fillcolor=f"rgba(31, 119, 180, {opacity:.3f})",
429
+ name=f"P{pct[lo]:g}-P{pct[hi]:g}",
430
+ hoverinfo="skip",
431
+ )
432
+ )
433
+ if median_idx is not None:
434
+ fig.add_trace(
435
+ go.Scatter(
436
+ x=t,
437
+ y=qs[median_idx],
438
+ mode="lines",
439
+ line={"width": 2},
440
+ name=f"P{pct[median_idx]:g} (median)",
441
+ )
442
+ )
443
+ if show_mean:
444
+ fig.add_trace(
445
+ go.Scatter(
446
+ x=t,
447
+ y=samples.mean(axis=0),
448
+ mode="lines",
449
+ line={"dash": "dash", "width": 1.5},
450
+ name="mean",
451
+ )
452
+ )
453
+
454
+ scenario = (
455
+ getattr(mc_or_array, "scenario_name", None)
456
+ if not isinstance(mc_or_array, (tuple, np.ndarray))
457
+ else None
458
+ )
459
+ fig.update_layout(
460
+ title=title
461
+ or (f"Monte Carlo fan: {scenario}" if scenario else "Monte Carlo fan"),
462
+ xaxis_title="time",
463
+ yaxis_title="value",
464
+ )
465
+ return apply_theme(fig, theme)
466
+
467
+
468
+ # --------------------------------------------------------------------------- #
469
+ # Agents: traversal of a 2-D graph #
470
+ # --------------------------------------------------------------------------- #
471
+
472
+
473
+ def _node_xy(node: Any, graph: Any | None) -> tuple[float, float] | None:
474
+ """Best-effort extraction of an (x, y) for a node.
475
+
476
+ Handles:
477
+ * tuple ``(r, c)`` -> ``(c, r)`` (col=x, row=y; row inverted not required
478
+ here since we let plotly pick the axis direction)
479
+ * networkx graph with ``nodes[n]['pos']`` -> use that
480
+ * anything else -> None
481
+ """
482
+ if isinstance(node, tuple) and len(node) == 2:
483
+ try:
484
+ return float(node[1]), float(node[0])
485
+ except (TypeError, ValueError):
486
+ pass
487
+ if graph is not None and hasattr(graph, "nodes"):
488
+ try:
489
+ data = graph.nodes[node] # networkx-style
490
+ pos = data.get("pos")
491
+ if pos is not None and len(pos) >= 2:
492
+ return float(pos[0]), float(pos[1])
493
+ except Exception:
494
+ pass
495
+ return None
496
+
497
+
498
+ def plot_agent_path(
499
+ agent: Any,
500
+ graph: Any | None = None,
501
+ *,
502
+ show_graph: bool = True,
503
+ theme: str | None = None,
504
+ title: str | None = None,
505
+ ) -> Any:
506
+ """Render an agent's traversal over a 2-D graph.
507
+
508
+ Parameters
509
+ ----------
510
+ agent:
511
+ A :class:`~simweave.agents.agent.Agent` (or any object exposing
512
+ ``.history`` of ``(t, node)`` pairs and ``.name``).
513
+ graph:
514
+ Optional graph to draw underneath the path. Defaults to
515
+ ``agent.graph``. Pass ``show_graph=False`` to skip drawing it.
516
+ show_graph:
517
+ If True, draws faint lines for every edge of the graph.
518
+ """
519
+ go = _plotly.require_go()
520
+ g = graph if graph is not None else getattr(agent, "graph", None)
521
+
522
+ history: Iterable[tuple[float, Any]] = getattr(agent, "history", []) or []
523
+ times: list[float] = []
524
+ xs: list[float] = []
525
+ ys: list[float] = []
526
+ nodes: list[Any] = []
527
+ for t, node in history:
528
+ xy = _node_xy(node, g)
529
+ if xy is None:
530
+ continue
531
+ times.append(float(t))
532
+ xs.append(xy[0])
533
+ ys.append(xy[1])
534
+ nodes.append(node)
535
+
536
+ fig = go.Figure()
537
+
538
+ if show_graph and g is not None:
539
+ edge_x: list[Any] = []
540
+ edge_y: list[Any] = []
541
+ edges_iter = (
542
+ g.edges() if hasattr(g, "edges") and callable(getattr(g, "edges")) else []
543
+ )
544
+ for edge in edges_iter:
545
+ # simweave Graph.edges() yields (u, v, data); networkx also (u, v) or 3-tup.
546
+ u, v = edge[0], edge[1]
547
+ uxy = _node_xy(u, g)
548
+ vxy = _node_xy(v, g)
549
+ if uxy is None or vxy is None:
550
+ continue
551
+ edge_x.extend([uxy[0], vxy[0], None])
552
+ edge_y.extend([uxy[1], vxy[1], None])
553
+ if edge_x:
554
+ fig.add_trace(
555
+ go.Scatter(
556
+ x=edge_x,
557
+ y=edge_y,
558
+ mode="lines",
559
+ line={"color": "rgba(150,150,150,0.4)", "width": 1},
560
+ hoverinfo="skip",
561
+ showlegend=False,
562
+ name="graph",
563
+ )
564
+ )
565
+
566
+ if xs:
567
+ fig.add_trace(
568
+ go.Scatter(
569
+ x=xs,
570
+ y=ys,
571
+ mode="lines+markers",
572
+ marker={"size": 7},
573
+ line={"width": 2},
574
+ name=getattr(agent, "name", "agent"),
575
+ hovertext=[f"t={tt:.2f}\n{n}" for tt, n in zip(times, nodes)],
576
+ hoverinfo="text",
577
+ )
578
+ )
579
+ fig.add_trace(
580
+ go.Scatter(
581
+ x=[xs[0]],
582
+ y=[ys[0]],
583
+ mode="markers",
584
+ marker={"size": 12, "symbol": "circle"},
585
+ name="start",
586
+ showlegend=True,
587
+ )
588
+ )
589
+ fig.add_trace(
590
+ go.Scatter(
591
+ x=[xs[-1]],
592
+ y=[ys[-1]],
593
+ mode="markers",
594
+ marker={"size": 12, "symbol": "x"},
595
+ name="end",
596
+ showlegend=True,
597
+ )
598
+ )
599
+
600
+ aname = getattr(agent, "name", "agent")
601
+ fig.update_layout(
602
+ title=title or f"agent path: {aname}",
603
+ xaxis_title="x",
604
+ yaxis_title="y",
605
+ yaxis={"scaleanchor": "x", "scaleratio": 1},
606
+ )
607
+ return apply_theme(fig, theme)
608
+
609
+
610
+ __all__ = [
611
+ "plot_state_trajectories",
612
+ "plot_phase_portrait",
613
+ "plot_queue_length",
614
+ "plot_service_utilisation",
615
+ "plot_warehouse_stock",
616
+ "plot_mc_fan",
617
+ "plot_agent_path",
618
+ ]