BulletLab 0.1.2__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.
- bulletlab/__init__.py +48 -0
- bulletlab/core/__init__.py +10 -0
- bulletlab/core/simulation.py +377 -0
- bulletlab/core/world.py +173 -0
- bulletlab/logging/__init__.py +9 -0
- bulletlab/logging/csv_writer.py +54 -0
- bulletlab/logging/json_writer.py +70 -0
- bulletlab/logging/logger.py +258 -0
- bulletlab/plotting/__init__.py +9 -0
- bulletlab/plotting/live_plot.py +364 -0
- bulletlab/robot/__init__.py +12 -0
- bulletlab/robot/joint.py +402 -0
- bulletlab/robot/link.py +401 -0
- bulletlab/robot/robot.py +533 -0
- bulletlab/telemetry/__init__.py +10 -0
- bulletlab/telemetry/channel.py +124 -0
- bulletlab/telemetry/manager.py +227 -0
- bulletlab/ui/__init__.py +31 -0
- bulletlab/ui/app.py +543 -0
- bulletlab/ui/panels/__init__.py +16 -0
- bulletlab/ui/panels/console.py +189 -0
- bulletlab/ui/panels/explorer.py +144 -0
- bulletlab/ui/panels/plots.py +143 -0
- bulletlab/ui/panels/properties.py +265 -0
- bulletlab/ui/panels/telemetry.py +105 -0
- bulletlab/ui/widgets.py +348 -0
- bulletlab/utils/__init__.py +26 -0
- bulletlab/utils/math_utils.py +183 -0
- bulletlab/utils/timer.py +107 -0
- bulletlab/utils/urdf_utils.py +114 -0
- bulletlab-0.1.2.dist-info/METADATA +284 -0
- bulletlab-0.1.2.dist-info/RECORD +35 -0
- bulletlab-0.1.2.dist-info/WHEEL +5 -0
- bulletlab-0.1.2.dist-info/licenses/LICENSE +21 -0
- bulletlab-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON/NDJSON writer backend for DataLogger.
|
|
3
|
+
|
|
4
|
+
Internal module — used by :class:`~bulletlab.logging.logger.DataLogger`.
|
|
5
|
+
Writes newline-delimited JSON (one JSON object per line) for streaming-friendly
|
|
6
|
+
log files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JsonWriter:
|
|
17
|
+
"""Writes log data as newline-delimited JSON (NDJSON format).
|
|
18
|
+
|
|
19
|
+
Each call to :meth:`write_row` appends a single JSON object to the file.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
filepath: Output file path.
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
writer = JsonWriter("run1.json")
|
|
27
|
+
writer.write_row({"t": 0.0, "speed": 1.5, "roll": 0.01})
|
|
28
|
+
writer.close()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, filepath: str | Path) -> None:
|
|
32
|
+
self._filepath = Path(filepath)
|
|
33
|
+
self._file = open(self._filepath, "w", encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
def write_row(self, row: dict[str, Any]) -> None:
|
|
36
|
+
"""Write a single record as a JSON line.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
row: Dictionary of field names to values.
|
|
40
|
+
"""
|
|
41
|
+
self._file.write(json.dumps(row, default=_json_default) + "\n")
|
|
42
|
+
|
|
43
|
+
def flush(self) -> None:
|
|
44
|
+
"""Flush the write buffer to disk."""
|
|
45
|
+
self._file.flush()
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
"""Close the file handle."""
|
|
49
|
+
if not self._file.closed:
|
|
50
|
+
self._file.flush()
|
|
51
|
+
self._file.close()
|
|
52
|
+
|
|
53
|
+
def __del__(self) -> None:
|
|
54
|
+
self.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _json_default(obj: Any) -> Any:
|
|
58
|
+
"""Fallback JSON serializer for numpy types and similar."""
|
|
59
|
+
try:
|
|
60
|
+
import numpy as np # noqa: PLC0415
|
|
61
|
+
|
|
62
|
+
if isinstance(obj, (np.integer,)):
|
|
63
|
+
return int(obj)
|
|
64
|
+
if isinstance(obj, (np.floating,)):
|
|
65
|
+
return float(obj)
|
|
66
|
+
if isinstance(obj, np.ndarray):
|
|
67
|
+
return obj.tolist()
|
|
68
|
+
except ImportError:
|
|
69
|
+
pass
|
|
70
|
+
return str(obj)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DataLogger – records time-series experiment data to CSV or JSON files.
|
|
3
|
+
|
|
4
|
+
The DataLogger watches callable data sources and writes them to a file on
|
|
5
|
+
every call to :meth:`step`. Supports CSV and newline-delimited JSON (NDJSON)
|
|
6
|
+
formats, determined by the file extension.
|
|
7
|
+
|
|
8
|
+
Example::
|
|
9
|
+
|
|
10
|
+
from bulletlab.logging import DataLogger
|
|
11
|
+
|
|
12
|
+
logger = DataLogger()
|
|
13
|
+
logger.watch("speed", lambda: robot.speed)
|
|
14
|
+
logger.watch("roll", lambda: robot.roll)
|
|
15
|
+
logger.watch("height", lambda: robot.base_position[2])
|
|
16
|
+
logger.start("experiment_01.csv")
|
|
17
|
+
|
|
18
|
+
for _ in range(1000):
|
|
19
|
+
sim.step()
|
|
20
|
+
logger.step()
|
|
21
|
+
|
|
22
|
+
logger.stop()
|
|
23
|
+
|
|
24
|
+
Context manager usage::
|
|
25
|
+
|
|
26
|
+
with DataLogger() as logger:
|
|
27
|
+
logger.watch("speed", lambda: robot.speed)
|
|
28
|
+
logger.start("run.csv")
|
|
29
|
+
for _ in range(1000):
|
|
30
|
+
sim.step()
|
|
31
|
+
logger.step()
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import time
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Callable
|
|
39
|
+
|
|
40
|
+
from bulletlab.logging.csv_writer import CsvWriter
|
|
41
|
+
from bulletlab.logging.json_writer import JsonWriter
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DataLogger:
|
|
45
|
+
"""Records experiment data from callable sources to CSV or JSON files.
|
|
46
|
+
|
|
47
|
+
Channels can be registered before or after calling :meth:`start`.
|
|
48
|
+
The logger automatically determines the file format from the extension:
|
|
49
|
+
``.csv`` → CSV, ``.json`` or ``.ndjson`` → NDJSON.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
include_timestamp: If ``True``, prepend a ``"t"`` column with the
|
|
53
|
+
wall-clock time of each step. Defaults to ``True``.
|
|
54
|
+
include_step: If ``True``, prepend a ``"step"`` column with the step
|
|
55
|
+
index. Defaults to ``True``.
|
|
56
|
+
|
|
57
|
+
Example::
|
|
58
|
+
|
|
59
|
+
logger = DataLogger()
|
|
60
|
+
logger.watch("speed", lambda: robot.speed)
|
|
61
|
+
logger.start("run1.csv")
|
|
62
|
+
for _ in range(1000):
|
|
63
|
+
sim.step()
|
|
64
|
+
logger.step()
|
|
65
|
+
logger.stop()
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
include_timestamp: bool = True,
|
|
71
|
+
include_step: bool = True,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._channels: dict[str, Callable[[], Any]] = {}
|
|
74
|
+
self._writer: CsvWriter | JsonWriter | None = None
|
|
75
|
+
self._step_count: int = 0
|
|
76
|
+
self._include_timestamp = include_timestamp
|
|
77
|
+
self._include_step = include_step
|
|
78
|
+
self._start_time: float = 0.0
|
|
79
|
+
self._running: bool = False
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Channel registration
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def watch(self, name: str, source: Callable[[], Any]) -> "DataLogger":
|
|
86
|
+
"""Register a data source to log.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
name: Column name in the output file.
|
|
90
|
+
source: Callable returning the current value.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
self, for method chaining.
|
|
94
|
+
|
|
95
|
+
Example::
|
|
96
|
+
|
|
97
|
+
logger.watch("speed", lambda: robot.speed)
|
|
98
|
+
logger.watch("roll", lambda: robot.roll)
|
|
99
|
+
"""
|
|
100
|
+
self._channels[name] = source
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def unwatch(self, name: str) -> None:
|
|
104
|
+
"""Remove a previously registered channel.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
name: Channel name to remove.
|
|
108
|
+
"""
|
|
109
|
+
self._channels.pop(name, None)
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Lifecycle
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def start(self, filepath: str | Path) -> "DataLogger":
|
|
116
|
+
"""Open the output file and begin logging.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
filepath: Path to the output file. Extension determines format:
|
|
120
|
+
``.csv`` for CSV, ``.json`` / ``.ndjson`` for NDJSON.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
self, for method chaining.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
RuntimeError: If the logger is already running.
|
|
127
|
+
|
|
128
|
+
Example::
|
|
129
|
+
|
|
130
|
+
logger.start("run1.csv")
|
|
131
|
+
"""
|
|
132
|
+
if self._running:
|
|
133
|
+
raise RuntimeError("DataLogger is already running. Call stop() first.")
|
|
134
|
+
|
|
135
|
+
filepath = Path(filepath)
|
|
136
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
ext = filepath.suffix.lower()
|
|
138
|
+
|
|
139
|
+
fieldnames = self._build_fieldnames()
|
|
140
|
+
|
|
141
|
+
if ext in (".json", ".ndjson", ".jsonl"):
|
|
142
|
+
self._writer = JsonWriter(filepath)
|
|
143
|
+
else:
|
|
144
|
+
# Default to CSV
|
|
145
|
+
self._writer = CsvWriter(filepath, fieldnames)
|
|
146
|
+
|
|
147
|
+
self._step_count = 0
|
|
148
|
+
self._start_time = time.monotonic()
|
|
149
|
+
self._running = True
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def stop(self) -> None:
|
|
153
|
+
"""Flush and close the output file.
|
|
154
|
+
|
|
155
|
+
Example::
|
|
156
|
+
|
|
157
|
+
logger.stop()
|
|
158
|
+
"""
|
|
159
|
+
if self._writer is not None:
|
|
160
|
+
self._writer.close()
|
|
161
|
+
self._writer = None
|
|
162
|
+
self._running = False
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Data recording
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def step(self, t: float | None = None) -> dict[str, Any]:
|
|
169
|
+
"""Record one row of data from all watched sources.
|
|
170
|
+
|
|
171
|
+
Call this once per simulation step. If not started, this is a no-op.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
t: Timestamp override. If ``None``, uses wall-clock elapsed time
|
|
175
|
+
since :meth:`start` was called.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The recorded row as a dictionary.
|
|
179
|
+
|
|
180
|
+
Example::
|
|
181
|
+
|
|
182
|
+
for _ in range(1000):
|
|
183
|
+
sim.step()
|
|
184
|
+
logger.step()
|
|
185
|
+
"""
|
|
186
|
+
if not self._running or self._writer is None:
|
|
187
|
+
return {}
|
|
188
|
+
|
|
189
|
+
timestamp = t if t is not None else (time.monotonic() - self._start_time)
|
|
190
|
+
row: dict[str, Any] = {}
|
|
191
|
+
|
|
192
|
+
if self._include_step:
|
|
193
|
+
row["step"] = self._step_count
|
|
194
|
+
if self._include_timestamp:
|
|
195
|
+
row["t"] = round(timestamp, 6)
|
|
196
|
+
|
|
197
|
+
for name, source in self._channels.items():
|
|
198
|
+
try:
|
|
199
|
+
val = source()
|
|
200
|
+
except Exception:
|
|
201
|
+
val = float("nan")
|
|
202
|
+
row[name] = val
|
|
203
|
+
|
|
204
|
+
self._writer.write_row(row)
|
|
205
|
+
self._step_count += 1
|
|
206
|
+
return row
|
|
207
|
+
|
|
208
|
+
def flush(self) -> None:
|
|
209
|
+
"""Manually flush buffered data to disk."""
|
|
210
|
+
if self._writer is not None:
|
|
211
|
+
self._writer.flush()
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# Helpers
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def _build_fieldnames(self) -> list[str]:
|
|
218
|
+
"""Build the ordered list of field names including meta-columns."""
|
|
219
|
+
fields: list[str] = []
|
|
220
|
+
if self._include_step:
|
|
221
|
+
fields.append("step")
|
|
222
|
+
if self._include_timestamp:
|
|
223
|
+
fields.append("t")
|
|
224
|
+
fields.extend(self._channels.keys())
|
|
225
|
+
return fields
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# State
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def is_running(self) -> bool:
|
|
233
|
+
"""``True`` if the logger is currently recording."""
|
|
234
|
+
return self._running
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def step_count(self) -> int:
|
|
238
|
+
"""Number of rows recorded since :meth:`start` was last called."""
|
|
239
|
+
return self._step_count
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def channel_names(self) -> list[str]:
|
|
243
|
+
"""Names of all registered channels."""
|
|
244
|
+
return list(self._channels.keys())
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Context manager
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def __enter__(self) -> "DataLogger":
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def __exit__(self, *_: object) -> None:
|
|
254
|
+
self.stop()
|
|
255
|
+
|
|
256
|
+
def __repr__(self) -> str:
|
|
257
|
+
status = "running" if self._running else "stopped"
|
|
258
|
+
return f"DataLogger({status}, channels={list(self._channels.keys())}, steps={self._step_count})"
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePlot – real-time data visualization using PyQtGraph.
|
|
3
|
+
|
|
4
|
+
LivePlot opens a PyQtGraph window and plots live data from callable sources.
|
|
5
|
+
Multiple traces can be added with custom colors. The plot supports zoom,
|
|
6
|
+
pan (via PyQtGraph's native interaction), pause, resume, and image export.
|
|
7
|
+
|
|
8
|
+
Example::
|
|
9
|
+
|
|
10
|
+
from bulletlab.plotting import LivePlot
|
|
11
|
+
|
|
12
|
+
plot = LivePlot(title="Robot Telemetry", max_points=500)
|
|
13
|
+
plot.watch("Speed", lambda: robot.speed, color="#00ff88")
|
|
14
|
+
plot.watch("Roll", lambda: robot.roll, color="#ff4488")
|
|
15
|
+
plot.watch("Height", lambda: robot.base_position[2], color="#44aaff")
|
|
16
|
+
plot.start()
|
|
17
|
+
|
|
18
|
+
for _ in range(5000):
|
|
19
|
+
sim.step()
|
|
20
|
+
plot.update()
|
|
21
|
+
|
|
22
|
+
plot.stop()
|
|
23
|
+
|
|
24
|
+
Non-blocking usage::
|
|
25
|
+
|
|
26
|
+
plot.start() # opens window in Qt thread
|
|
27
|
+
# ... simulation loop calls plot.update() each step
|
|
28
|
+
plot.stop() # closes window
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from collections import deque
|
|
36
|
+
from typing import Any, Callable, Optional
|
|
37
|
+
|
|
38
|
+
# PyQtGraph and Qt are optional — graceful fallback if not installed
|
|
39
|
+
try:
|
|
40
|
+
import pyqtgraph as pg
|
|
41
|
+
from pyqtgraph.Qt import QtWidgets, QtCore
|
|
42
|
+
|
|
43
|
+
_HAS_PYQTGRAPH = True
|
|
44
|
+
except ImportError: # pragma: no cover
|
|
45
|
+
_HAS_PYQTGRAPH = False
|
|
46
|
+
pg = None # type: ignore[assignment]
|
|
47
|
+
QtWidgets = None # type: ignore[assignment]
|
|
48
|
+
QtCore = None # type: ignore[assignment]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _PlotSeries:
|
|
52
|
+
"""Internal container for one data series."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
name: str,
|
|
57
|
+
source: Callable[[], Any],
|
|
58
|
+
color: str,
|
|
59
|
+
max_points: int,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.name = name
|
|
62
|
+
self.source = source
|
|
63
|
+
self.color = color
|
|
64
|
+
self.max_points = max_points
|
|
65
|
+
self.timestamps: deque[float] = deque(maxlen=max_points)
|
|
66
|
+
self.values: deque[float] = deque(maxlen=max_points)
|
|
67
|
+
self.curve: Any = None # pyqtgraph PlotDataItem
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LivePlot:
|
|
71
|
+
"""Real-time multi-trace plotting window powered by PyQtGraph.
|
|
72
|
+
|
|
73
|
+
Opens a separate Qt window. The simulation loop must call
|
|
74
|
+
:meth:`update` periodically to push new data and refresh the display.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
title: Window title.
|
|
78
|
+
max_points: Maximum number of data points per trace (older points
|
|
79
|
+
are dropped in a rolling fashion).
|
|
80
|
+
update_interval_ms: Minimum time between display refreshes in
|
|
81
|
+
milliseconds. Reduces overhead when :meth:`update` is called
|
|
82
|
+
faster than needed.
|
|
83
|
+
y_label: Y-axis label.
|
|
84
|
+
x_label: X-axis label.
|
|
85
|
+
|
|
86
|
+
Example::
|
|
87
|
+
|
|
88
|
+
plot = LivePlot(title="Speed vs Time", max_points=300)
|
|
89
|
+
plot.watch("Speed", lambda: robot.speed, color="#00ff88")
|
|
90
|
+
plot.start()
|
|
91
|
+
for _ in range(1000):
|
|
92
|
+
sim.step()
|
|
93
|
+
plot.update()
|
|
94
|
+
plot.stop()
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
title: str = "BulletLab Live Plot",
|
|
100
|
+
max_points: int = 500,
|
|
101
|
+
update_interval_ms: int = 33,
|
|
102
|
+
y_label: str = "Value",
|
|
103
|
+
x_label: str = "Time (s)",
|
|
104
|
+
) -> None:
|
|
105
|
+
self._title = title
|
|
106
|
+
self._max_points = max_points
|
|
107
|
+
self._update_interval = update_interval_ms / 1000.0
|
|
108
|
+
self._y_label = y_label
|
|
109
|
+
self._x_label = x_label
|
|
110
|
+
|
|
111
|
+
self._series: list[_PlotSeries] = []
|
|
112
|
+
self._running = False
|
|
113
|
+
self._paused = False
|
|
114
|
+
self._start_time: float = 0.0
|
|
115
|
+
self._last_refresh: float = 0.0
|
|
116
|
+
|
|
117
|
+
# Qt objects (None until start() is called)
|
|
118
|
+
self._app: Any = None
|
|
119
|
+
self._window: Any = None
|
|
120
|
+
self._plot_widget: Any = None
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Registration
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def watch(
|
|
127
|
+
self,
|
|
128
|
+
name: str,
|
|
129
|
+
source: Callable[[], Any],
|
|
130
|
+
color: str = "#ffffff",
|
|
131
|
+
) -> "LivePlot":
|
|
132
|
+
"""Add a data series to the plot.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
name: Series name (shown in legend).
|
|
136
|
+
source: Callable returning the current value.
|
|
137
|
+
color: Line color as a hex string (e.g. ``"#00ff88"``).
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
self, for method chaining.
|
|
141
|
+
|
|
142
|
+
Example::
|
|
143
|
+
|
|
144
|
+
plot.watch("Speed", lambda: robot.speed, color="#00ff88")
|
|
145
|
+
"""
|
|
146
|
+
series = _PlotSeries(
|
|
147
|
+
name=name,
|
|
148
|
+
source=source,
|
|
149
|
+
color=color,
|
|
150
|
+
max_points=self._max_points,
|
|
151
|
+
)
|
|
152
|
+
self._series.append(series)
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Lifecycle
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def start(self) -> "LivePlot":
|
|
160
|
+
"""Open the plot window.
|
|
161
|
+
|
|
162
|
+
Creates a Qt application if one does not already exist.
|
|
163
|
+
Non-blocking: the window is opened and control returns immediately.
|
|
164
|
+
Call :meth:`update` in your simulation loop to refresh the plot.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
self, for method chaining.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ImportError: If PyQtGraph is not installed.
|
|
171
|
+
|
|
172
|
+
Example::
|
|
173
|
+
|
|
174
|
+
plot.start()
|
|
175
|
+
"""
|
|
176
|
+
if not _HAS_PYQTGRAPH:
|
|
177
|
+
print(
|
|
178
|
+
"[BulletLab] LivePlot: PyQtGraph is not installed. "
|
|
179
|
+
"Install with: pip install pyqtgraph PyQt5"
|
|
180
|
+
)
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
if self._running:
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
# Ensure Qt application exists
|
|
187
|
+
self._app = QtWidgets.QApplication.instance()
|
|
188
|
+
if self._app is None:
|
|
189
|
+
self._app = QtWidgets.QApplication(sys.argv)
|
|
190
|
+
|
|
191
|
+
# Create window
|
|
192
|
+
self._window = pg.GraphicsLayoutWidget(title=self._title, show=True)
|
|
193
|
+
self._window.setWindowTitle(self._title)
|
|
194
|
+
self._window.resize(900, 500)
|
|
195
|
+
|
|
196
|
+
self._plot_widget = self._window.addPlot(
|
|
197
|
+
title=self._title,
|
|
198
|
+
labels={"left": self._y_label, "bottom": self._x_label},
|
|
199
|
+
)
|
|
200
|
+
self._plot_widget.showGrid(x=True, y=True, alpha=0.3)
|
|
201
|
+
self._plot_widget.addLegend()
|
|
202
|
+
|
|
203
|
+
# Create curves for each series
|
|
204
|
+
for series in self._series:
|
|
205
|
+
series.curve = self._plot_widget.plot(
|
|
206
|
+
pen=pg.mkPen(color=series.color, width=2),
|
|
207
|
+
name=series.name,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
self._start_time = time.monotonic()
|
|
211
|
+
self._last_refresh = self._start_time
|
|
212
|
+
self._running = True
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
def stop(self) -> None:
|
|
216
|
+
"""Close the plot window and release resources.
|
|
217
|
+
|
|
218
|
+
Example::
|
|
219
|
+
|
|
220
|
+
plot.stop()
|
|
221
|
+
"""
|
|
222
|
+
self._running = False
|
|
223
|
+
if self._window is not None and _HAS_PYQTGRAPH:
|
|
224
|
+
try:
|
|
225
|
+
self._window.close()
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
self._window = None
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# Update loop
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def update(self, t: float | None = None) -> None:
|
|
235
|
+
"""Sample all data sources and refresh the plot display.
|
|
236
|
+
|
|
237
|
+
Call this once per simulation step. Refresh rate is throttled by
|
|
238
|
+
``update_interval_ms`` to avoid excessive Qt overhead.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
t: Timestamp override. If ``None``, uses wall-clock elapsed time
|
|
242
|
+
since :meth:`start` was called.
|
|
243
|
+
|
|
244
|
+
Example::
|
|
245
|
+
|
|
246
|
+
for _ in range(5000):
|
|
247
|
+
sim.step()
|
|
248
|
+
plot.update()
|
|
249
|
+
"""
|
|
250
|
+
if not self._running or self._paused:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
timestamp = t if t is not None else (time.monotonic() - self._start_time)
|
|
254
|
+
|
|
255
|
+
# Sample all series
|
|
256
|
+
for series in self._series:
|
|
257
|
+
try:
|
|
258
|
+
val = float(series.source())
|
|
259
|
+
except Exception:
|
|
260
|
+
val = float("nan")
|
|
261
|
+
series.timestamps.append(timestamp)
|
|
262
|
+
series.values.append(val)
|
|
263
|
+
|
|
264
|
+
# Throttle display updates
|
|
265
|
+
now = time.monotonic()
|
|
266
|
+
if now - self._last_refresh < self._update_interval:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
self._last_refresh = now
|
|
270
|
+
self._refresh_display()
|
|
271
|
+
|
|
272
|
+
def _refresh_display(self) -> None:
|
|
273
|
+
"""Update the Qt plot curves from buffered data."""
|
|
274
|
+
if not _HAS_PYQTGRAPH or self._plot_widget is None:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
import numpy as np
|
|
278
|
+
|
|
279
|
+
for series in self._series:
|
|
280
|
+
if series.curve is not None and len(series.timestamps) > 0:
|
|
281
|
+
x = np.array(list(series.timestamps))
|
|
282
|
+
y = np.array(list(series.values))
|
|
283
|
+
series.curve.setData(x=x, y=y)
|
|
284
|
+
|
|
285
|
+
# Process Qt events
|
|
286
|
+
try:
|
|
287
|
+
self._app.processEvents()
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
# Controls
|
|
293
|
+
# ------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def pause(self) -> None:
|
|
296
|
+
"""Pause data sampling and display updates.
|
|
297
|
+
|
|
298
|
+
Example::
|
|
299
|
+
|
|
300
|
+
plot.pause()
|
|
301
|
+
"""
|
|
302
|
+
self._paused = True
|
|
303
|
+
|
|
304
|
+
def resume(self) -> None:
|
|
305
|
+
"""Resume a paused plot.
|
|
306
|
+
|
|
307
|
+
Example::
|
|
308
|
+
|
|
309
|
+
plot.resume()
|
|
310
|
+
"""
|
|
311
|
+
self._paused = False
|
|
312
|
+
|
|
313
|
+
def clear(self) -> None:
|
|
314
|
+
"""Clear all data buffers (but keep series registrations).
|
|
315
|
+
|
|
316
|
+
Example::
|
|
317
|
+
|
|
318
|
+
plot.clear()
|
|
319
|
+
"""
|
|
320
|
+
for series in self._series:
|
|
321
|
+
series.timestamps.clear()
|
|
322
|
+
series.values.clear()
|
|
323
|
+
|
|
324
|
+
def export(self, filepath: str) -> None:
|
|
325
|
+
"""Export the current plot as an image.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
filepath: Output file path. Supported formats: PNG, JPG (via Qt).
|
|
329
|
+
|
|
330
|
+
Example::
|
|
331
|
+
|
|
332
|
+
plot.export("speed_plot.png")
|
|
333
|
+
"""
|
|
334
|
+
if not _HAS_PYQTGRAPH or self._window is None:
|
|
335
|
+
print("[BulletLab] LivePlot: Cannot export — plot window not open.")
|
|
336
|
+
return
|
|
337
|
+
try:
|
|
338
|
+
exporter = pg.exporters.ImageExporter(self._plot_widget)
|
|
339
|
+
exporter.export(filepath)
|
|
340
|
+
except Exception as exc:
|
|
341
|
+
print(f"[BulletLab] LivePlot export failed: {exc}")
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
# State
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def is_running(self) -> bool:
|
|
349
|
+
"""``True`` if the plot window is open."""
|
|
350
|
+
return self._running
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def is_paused(self) -> bool:
|
|
354
|
+
"""``True`` if sampling is paused."""
|
|
355
|
+
return self._paused
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def series_names(self) -> list[str]:
|
|
359
|
+
"""Names of all registered data series."""
|
|
360
|
+
return [s.name for s in self._series]
|
|
361
|
+
|
|
362
|
+
def __repr__(self) -> str:
|
|
363
|
+
status = "running" if self._running else "stopped"
|
|
364
|
+
return f"LivePlot({self._title!r}, {status}, series={self.series_names})"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BulletLab robot subpackage.
|
|
3
|
+
|
|
4
|
+
Provides the Robot, Joint, and Link classes for interacting with simulated
|
|
5
|
+
robots as structured Python objects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from bulletlab.robot.robot import Robot
|
|
9
|
+
from bulletlab.robot.joint import Joint, JointType
|
|
10
|
+
from bulletlab.robot.link import Link
|
|
11
|
+
|
|
12
|
+
__all__ = ["Robot", "Joint", "JointType", "Link"]
|