New graph plotter

This commit is contained in:
2026-03-19 18:47:45 +01:00
parent 182cc429bb
commit f5f47c26fd
2 changed files with 215 additions and 91 deletions

278
plot.py
View File

@@ -1,10 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import json
import re import re
import sys
import threading import threading
import time import time
from collections import deque from collections import deque
from pathlib import Path
from queue import Empty, Queue from queue import Empty, Queue
try: try:
@@ -22,26 +23,46 @@ except ImportError as exc:
) from exc ) from exc
IMU_RE = re.compile(
r"\[<IMU>\]:\s*(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)"
)
BACKGROUND = (18, 20, 24) BACKGROUND = (18, 20, 24)
GRID = (48, 54, 64) GRID = (48, 54, 64)
AXIS = (140, 148, 160) AXIS = (140, 148, 160)
ACC_X_TRACE = (90, 170, 255)
ACC_Y_TRACE = (80, 220, 140)
ACC_Z_TRACE = (255, 200, 60)
GYR_X_TRACE = (240, 80, 80)
GYR_Y_TRACE = (220, 80, 220)
GYR_Z_TRACE = (240, 240, 240)
TEXT = (230, 235, 240) TEXT = (230, 235, 240)
ERROR = (255, 110, 110) ERROR = (255, 110, 110)
LINE_RE = re.compile(r"\[<([^>]+)>\]:\s*(.*)")
DEFAULT_CONFIG = {
"title": "IMU stream",
"history_seconds": 10.0,
"graphs": [
{
"title": "Accelerometer",
"streams": [
{"key": "acc_x", "label": "ACC X", "color": [90, 170, 255]},
{"key": "acc_y", "label": "ACC Y", "color": [80, 220, 140]},
{"key": "acc_z", "label": "ACC Z", "color": [255, 200, 60]},
],
},
{
"title": "Gyroscope",
"streams": [
{"key": "gyr_x", "label": "GYR X", "color": [240, 80, 80]},
{"key": "gyr_y", "label": "GYR Y", "color": [220, 80, 220]},
{"key": "gyr_z", "label": "GYR Z", "color": [240, 240, 240]},
],
},
],
"sources": [
{
"tag": "IMU",
"fields": ["acc_x", "acc_y", "acc_z", "gyr_x", "gyr_y", "gyr_z"],
}
],
}
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Read BMI160 IMU values from a serial port and draw them live." description="Read tagged serial values and draw them live from a JSON config."
) )
parser.add_argument("port", help="Serial port, for example /dev/ttyUSB0 or COM3") parser.add_argument("port", help="Serial port, for example /dev/ttyUSB0 or COM3")
parser.add_argument( parser.add_argument(
@@ -51,11 +72,18 @@ def parse_args() -> argparse.Namespace:
default=115200, default=115200,
help="Serial baudrate (default: 115200)", help="Serial baudrate (default: 115200)",
) )
parser.add_argument(
"-c",
"--config",
type=Path,
default=None,
help="Path to JSON plot config (default: built-in IMU config)",
)
parser.add_argument( parser.add_argument(
"--history-seconds", "--history-seconds",
type=float, type=float,
default=10.0, default=None,
help="Initial visible history window in seconds (default: 10)", help="Initial visible history window in seconds (overrides config)",
) )
parser.add_argument( parser.add_argument(
"--max-samples", "--max-samples",
@@ -78,25 +106,83 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args() return parser.parse_args()
def serial_reader(port: str, baudrate: int, output: Queue) -> None: def load_config(config_path: Path | None) -> dict:
if config_path is None:
return DEFAULT_CONFIG
with config_path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def validate_config(config: dict) -> tuple[dict[str, list[str]], list[dict], list[str]]:
sources = config.get("sources")
graphs = config.get("graphs")
if not isinstance(sources, list) or not sources:
raise SystemExit("Config must contain a non-empty `sources` list.")
if not isinstance(graphs, list) or not graphs:
raise SystemExit("Config must contain a non-empty `graphs` list.")
tag_to_fields: dict[str, list[str]] = {}
for source in sources:
tag = source.get("tag")
fields = source.get("fields")
if not isinstance(tag, str) or not tag:
raise SystemExit("Each source must have a non-empty string `tag`.")
if not isinstance(fields, list) or not fields or not all(isinstance(field, str) for field in fields):
raise SystemExit(f"Source `{tag}` must have a non-empty string list `fields`.")
tag_to_fields[tag] = fields
stream_keys: list[str] = []
for graph in graphs:
streams = graph.get("streams")
if not isinstance(streams, list) or not streams:
raise SystemExit("Each graph must have a non-empty `streams` list.")
for stream in streams:
key = stream.get("key")
if not isinstance(key, str) or not key:
raise SystemExit("Each stream must have a non-empty string `key`.")
stream_keys.append(key)
return tag_to_fields, graphs, stream_keys
def serial_reader(port: str, baudrate: int, tag_to_fields: dict[str, list[str]], output: Queue) -> None:
try: try:
with serial.Serial(port, baudrate=baudrate, timeout=1) as ser: with serial.Serial(port, baudrate=baudrate, timeout=1) as ser:
while True: while True:
raw_line = ser.readline() raw_line = ser.readline()
if not raw_line: if not raw_line:
continue continue
line = raw_line.decode("utf-8", errors="replace").strip() line = raw_line.decode("utf-8", errors="replace").strip()
timestamp = time.monotonic() match = LINE_RE.search(line)
match = IMU_RE.search(line)
if not match: if not match:
continue continue
values = [int(group) for group in match.groups()] tag, payload = match.groups()
for stream_name, value in zip( fields = tag_to_fields.get(tag)
("acc_x", "acc_y", "acc_z", "gyr_x", "gyr_y", "gyr_z"), if fields is None:
values, continue
):
output.put((stream_name, timestamp, value)) parts = payload.split()
if len(parts) != len(fields):
output.put(
(
"error",
f"Tag {tag} expected {len(fields)} values, got {len(parts)}: {payload}",
)
)
continue
try:
values = [int(part) for part in parts]
except ValueError:
output.put(("error", f"Tag {tag} has non-integer payload: {payload}"))
continue
timestamp = time.monotonic()
for key, value in zip(fields, values):
output.put((key, timestamp, value))
except serial.SerialException as exc: except serial.SerialException as exc:
output.put(("error", f"Serial error: {exc}")) output.put(("error", f"Serial error: {exc}"))
@@ -117,7 +203,8 @@ def drain_queue(
continue continue
stream_name, timestamp, value = item stream_name, timestamp, value = item
sample_sets[stream_name].append((timestamp, value)) if stream_name in sample_sets:
sample_sets[stream_name].append((timestamp, value))
def draw_grid( def draw_grid(
@@ -182,7 +269,10 @@ def draw_trace(
def visible_range( def visible_range(
sample_sets: dict[str, deque[tuple[float, int]]], stream_names: tuple[str, ...], now: float, view_span: float sample_sets: dict[str, deque[tuple[float, int]]],
stream_names: list[str],
now: float,
view_span: float,
) -> tuple[float, float]: ) -> tuple[float, float]:
max_abs = 1.0 max_abs = 1.0
@@ -201,31 +291,35 @@ def zoom(view_span: float, direction: int, initial_span: float) -> float:
return min(max(initial_span * 20.0, 60.0), view_span * 1.2) return min(max(initial_span * 20.0, 60.0), view_span * 1.2)
def color_tuple(color_value: list[int]) -> tuple[int, int, int]:
if not isinstance(color_value, list) or len(color_value) != 3:
raise SystemExit("Each stream color must be a 3-element RGB list.")
return tuple(int(component) for component in color_value)
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
sample_sets = { config = load_config(args.config)
"acc_x": deque(maxlen=args.max_samples), tag_to_fields, graphs, stream_keys = validate_config(config)
"acc_y": deque(maxlen=args.max_samples), sample_sets = {key: deque(maxlen=args.max_samples) for key in stream_keys}
"acc_z": deque(maxlen=args.max_samples),
"gyr_x": deque(maxlen=args.max_samples),
"gyr_y": deque(maxlen=args.max_samples),
"gyr_z": deque(maxlen=args.max_samples),
}
queue: Queue = Queue() queue: Queue = Queue()
thread = threading.Thread( thread = threading.Thread(
target=serial_reader, args=(args.port, args.baudrate, queue), daemon=True target=serial_reader,
args=(args.port, args.baudrate, tag_to_fields, queue),
daemon=True,
) )
thread.start() thread.start()
pygame.init() pygame.init()
pygame.display.set_caption(f"IMU stream: {args.port} @ {args.baudrate}") pygame.display.set_caption(f"{config.get('title', 'Serial plotter')}: {args.port} @ {args.baudrate}")
screen = pygame.display.set_mode((args.width, args.height), pygame.RESIZABLE) screen = pygame.display.set_mode((args.width, args.height), pygame.RESIZABLE)
font = pygame.font.SysFont("monospace", 18) font = pygame.font.SysFont("monospace", 18)
small_font = pygame.font.SysFont("monospace", 14) small_font = pygame.font.SysFont("monospace", 14)
clock = pygame.time.Clock() clock = pygame.time.Clock()
view_span = max(args.history_seconds, 1.0) initial_span = float(args.history_seconds or config.get("history_seconds", 10.0))
view_span = max(initial_span, 1.0)
error_message = None error_message = None
running = True running = True
@@ -234,12 +328,12 @@ def main() -> int:
if event.type == pygame.QUIT: if event.type == pygame.QUIT:
running = False running = False
elif event.type == pygame.MOUSEWHEEL: elif event.type == pygame.MOUSEWHEEL:
view_span = zoom(view_span, event.y, args.history_seconds) view_span = zoom(view_span, event.y, initial_span)
elif event.type == pygame.MOUSEBUTTONDOWN: elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 4: if event.button == 4:
view_span = zoom(view_span, 1, args.history_seconds) view_span = zoom(view_span, 1, initial_span)
elif event.button == 5: elif event.button == 5:
view_span = zoom(view_span, -1, args.history_seconds) view_span = zoom(view_span, -1, initial_span)
queued_error = drain_queue(queue, sample_sets) queued_error = drain_queue(queue, sample_sets)
if queued_error is not None: if queued_error is not None:
@@ -247,74 +341,76 @@ def main() -> int:
width, height = screen.get_size() width, height = screen.get_size()
now = time.monotonic() now = time.monotonic()
graph_count = len(graphs)
plot_width = max(100, width - 100) plot_width = max(100, width - 100)
panel_height = max(100, (height - 140) // 2) available_height = max(100, height - 120 - (graph_count - 1) * 30)
acc_rect = pygame.Rect(70, 40, plot_width, panel_height) panel_height = max(100, available_height // graph_count)
gyr_rect = pygame.Rect(70, acc_rect.bottom + 40, plot_width, panel_height)
acc_y_min, acc_y_max = visible_range(
sample_sets, ("acc_x", "acc_y", "acc_z"), now, view_span
)
gyr_y_min, gyr_y_max = visible_range(
sample_sets, ("gyr_x", "gyr_y", "gyr_z"), now, view_span
)
screen.fill(BACKGROUND) screen.fill(BACKGROUND)
draw_grid(screen, acc_rect, small_font, view_span, acc_y_min, acc_y_max, "Accelerometer")
draw_grid(screen, gyr_rect, small_font, view_span, gyr_y_min, gyr_y_max, "Gyroscope")
latest_acc_x = draw_trace( latest_values: dict[str, int | None] = {}
screen, acc_rect, sample_sets["acc_x"], view_span, now, acc_y_min, acc_y_max, ACC_X_TRACE top = 40
) for graph in graphs:
latest_acc_y = draw_trace( rect = pygame.Rect(70, top, plot_width, panel_height)
screen, acc_rect, sample_sets["acc_y"], view_span, now, acc_y_min, acc_y_max, ACC_Y_TRACE stream_defs = graph["streams"]
) stream_names = [stream["key"] for stream in stream_defs]
latest_acc_z = draw_trace( y_min, y_max = visible_range(sample_sets, stream_names, now, view_span)
screen, acc_rect, sample_sets["acc_z"], view_span, now, acc_y_min, acc_y_max, ACC_Z_TRACE draw_grid(
) screen,
latest_gyr_x = draw_trace( rect,
screen, gyr_rect, sample_sets["gyr_x"], view_span, now, gyr_y_min, gyr_y_max, GYR_X_TRACE small_font,
) view_span,
latest_gyr_y = draw_trace( y_min,
screen, gyr_rect, sample_sets["gyr_y"], view_span, now, gyr_y_min, gyr_y_max, GYR_Y_TRACE y_max,
) graph.get("title", "Graph"),
latest_gyr_z = draw_trace( )
screen, gyr_rect, sample_sets["gyr_z"], view_span, now, gyr_y_min, gyr_y_max, GYR_Z_TRACE for stream in stream_defs:
) latest_values[stream["key"]] = draw_trace(
screen,
rect,
sample_sets[stream["key"]],
view_span,
now,
y_min,
y_max,
color_tuple(stream["color"]),
)
top = rect.bottom + 30
header = f"port={args.port} baud={args.baudrate} zoom={view_span:.1f}s" header = f"port={args.port} baud={args.baudrate} zoom={view_span:.1f}s"
if latest_acc_x is not None: for graph in graphs:
header += f" acc=({latest_acc_x}, {latest_acc_y}, {latest_acc_z})" graph_parts = []
if latest_gyr_x is not None: for stream in graph["streams"]:
header += f" gyr=({latest_gyr_x}, {latest_gyr_y}, {latest_gyr_z})" latest_value = latest_values.get(stream["key"])
if latest_value is not None:
graph_parts.append(f"{stream.get('label', stream['key'])}={latest_value}")
if graph_parts:
header += " | " + " ".join(graph_parts)
screen.blit(font.render(header, True, TEXT), (10, 8)) screen.blit(font.render(header, True, TEXT), (10, 8))
screen.blit( screen.blit(
small_font.render("mouse wheel: zoom horizontal axis", True, AXIS), small_font.render("mouse wheel: zoom horizontal axis", True, AXIS),
(10, height - 24), (10, height - 24),
) )
legend_items = (
("ACC X", ACC_X_TRACE),
("ACC Y", ACC_Y_TRACE),
("ACC Z", ACC_Z_TRACE),
("GYR X", GYR_X_TRACE),
("GYR Y", GYR_Y_TRACE),
("GYR Z", GYR_Z_TRACE),
)
legend_x = 10 legend_x = 10
legend_y = 34 legend_y = 34
for label, color in legend_items: for graph in graphs:
pygame.draw.line( for stream in graph["streams"]:
screen, color = color_tuple(stream["color"])
color, label = stream.get("label", stream["key"])
(legend_x, legend_y + 9), pygame.draw.line(
(legend_x + 24, legend_y + 9), screen,
3, color,
) (legend_x, legend_y + 9),
screen.blit(small_font.render(label, True, TEXT), (legend_x + 30, legend_y)) (legend_x + 24, legend_y + 9),
legend_x += 105 3,
)
screen.blit(small_font.render(label, True, TEXT), (legend_x + 30, legend_y))
legend_x += max(90, 42 + len(label) * 10)
if error_message: if error_message:
error_surface = font.render(error_message, True, ERROR) error_surface = font.render(error_message, True, ERROR)
screen.blit(error_surface, (10, 56)) screen.blit(error_surface, (10, height - 52))
pygame.display.flip() pygame.display.flip()
clock.tick(60) clock.tick(60)

28
plot_config.json Normal file
View File

@@ -0,0 +1,28 @@
{
"title": "BBot IMU",
"history_seconds": 10.0,
"sources": [
{
"tag": "IMU",
"fields": ["acc_x", "acc_y", "acc_z", "gyr_x", "gyr_y", "gyr_z"]
}
],
"graphs": [
{
"title": "Accelerometer",
"streams": [
{ "key": "acc_x", "label": "ACC X", "color": [90, 170, 255] },
{ "key": "acc_y", "label": "ACC Y", "color": [80, 220, 140] },
{ "key": "acc_z", "label": "ACC Z", "color": [255, 200, 60] }
]
},
{
"title": "Gyroscope",
"streams": [
{ "key": "gyr_x", "label": "GYR X", "color": [240, 80, 80] },
{ "key": "gyr_y", "label": "GYR Y", "color": [220, 80, 220] },
{ "key": "gyr_z", "label": "GYR Z", "color": [240, 240, 240] }
]
}
]
}