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.
@@ -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,9 @@
1
+ """
2
+ BulletLab plotting subpackage.
3
+
4
+ Provides LivePlot for real-time data visualization using PyQtGraph.
5
+ """
6
+
7
+ from bulletlab.plotting.live_plot import LivePlot
8
+
9
+ __all__ = ["LivePlot"]
@@ -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"]