P2: true multi-axis overlay + gauge view

gui/widgets.py:
- MultiAxisPlot -- overlay with one Y axis PER UNIT (psi/V/rpm/C/%), linked
  ViewBoxes on X, so mixed-scale signals are readable at true values
  (base left axis + up to 4 right axes).
- SinglePlot -- one shared axis for the Normalize (% of range) mode.
- ArcGauge -- 270deg arc gauge with peak tick + numeric readout, own dark bg.
- GaugeGrid -- scrollable grid of gauges.

gui/main.py:
- Graph page is now a multi-axis/single-axis sub-stack; Normalize toggles
  between true multi-axis (raw) and single-axis (%). curves map key->color;
  plot ops route to the active graph widget.
- Gauge View menu enabled (3rd center page); gauges update on tick with peak.
- Theme applies to both plot widgets; profile switch clears graphs/gauges.

Fix: ArcGauge QPen built via setCapStyle (the QPen(...cap=...) kwarg segfaults
PySide6). Validated headless: driving preset -> 6 unit groups across 5 axes,
gauge view renders, normalize round-trips, profile-switch clears cleanly.
obdcore + diagnostics tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
This commit is contained in:
2026-06-30 15:07:57 -04:00
parent 717d160f65
commit f2308cd4eb
6 changed files with 329 additions and 38 deletions
+245
View File
@@ -0,0 +1,245 @@
"""Custom widgets for the ford-obd GUI: a unit-grouped multi-axis plot, a
simple single-axis plot, and an arc gauge / gauge grid.
All three plot/gauge containers share a small interface so the main window can
swap between them: add_curve(key,name,unit,color) / set_data(key,xs,ys) /
remove_curve(key) / clear().
"""
import math
from PySide6 import QtCore, QtGui, QtWidgets
import pyqtgraph as pg
MAX_EXTRA_AXES = 4 # base left axis + up to 4 right axes (5 units shown)
class SinglePlot(QtWidgets.QWidget):
"""One shared Y axis. Used for the Normalize (% of range) mode."""
def __init__(self):
super().__init__()
lay = QtWidgets.QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
self.pw = pg.PlotWidget()
self.pw.addLegend(offset=(10, 10))
self.pw.showGrid(x=True, y=True, alpha=0.25)
self.pw.setLabel("bottom", "time", units="s")
lay.addWidget(self.pw)
self._curves = {}
def set_y_label(self, text):
self.pw.setLabel("left", text)
def add_curve(self, key, name, unit, color):
self._curves[key] = self.pw.plot([], [], name=name, pen=pg.mkPen(color, width=2))
def set_data(self, key, xs, ys):
c = self._curves.get(key)
if c is not None:
c.setData(xs, ys)
def remove_curve(self, key):
c = self._curves.pop(key, None)
if c is not None:
self.pw.removeItem(c)
leg = self.pw.plotItem.legend
if leg:
try:
leg.removeItem(c)
except Exception:
pass
def clear(self):
for key in list(self._curves):
self.remove_curve(key)
def set_background(self, bg):
self.pw.setBackground(bg)
class MultiAxisPlot(QtWidgets.QWidget):
"""Overlay with one Y axis PER UNIT (psi / V / rpm / C / %), so mixed-scale
signals are all readable at their true values. Curves are grouped by unit;
each extra unit gets its own right-hand axis + linked ViewBox."""
def __init__(self):
super().__init__()
lay = QtWidgets.QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
self.glw = pg.GraphicsLayoutWidget()
lay.addWidget(self.glw)
self.p = self.glw.addPlot()
self.p.showGrid(x=True, y=True, alpha=0.2)
self.p.setLabel("bottom", "time", units="s")
self.legend = self.p.addLegend(offset=(10, 10))
self.base_vb = self.p.vb
self._units = {} # unit -> {vb, ax, base}
self._curves = {} # key -> {curve, unit}
self._next_col = 3
self.base_vb.sigResized.connect(self._sync)
def _ensure_unit(self, unit):
e = self._units.get(unit)
if e is not None:
return e
if not self._units: # first unit -> base left axis
self.p.setLabel("left", unit)
e = {"vb": self.base_vb, "ax": self.p.getAxis("left"), "base": True}
elif len(self._units) - 1 < MAX_EXTRA_AXES: # add a right axis
vb = pg.ViewBox()
ax = pg.AxisItem("right")
self.p.layout.addItem(ax, 2, self._next_col)
self._next_col += 1
self.p.scene().addItem(vb)
ax.linkToView(vb)
vb.setXLink(self.p)
ax.setLabel(unit)
e = {"vb": vb, "ax": ax, "base": False}
self._sync()
else: # out of axes -> reuse base
e = self._units[next(iter(self._units))]
self._units[unit] = e
return e
def add_curve(self, key, name, unit, color):
e = self._ensure_unit(unit)
curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=2), name=name)
e["vb"].addItem(curve)
self.legend.addItem(curve, name)
self._curves[key] = {"curve": curve, "unit": unit}
def set_data(self, key, xs, ys):
c = self._curves.get(key)
if c is not None:
c["curve"].setData(xs, ys)
def remove_curve(self, key):
c = self._curves.pop(key, None)
if not c:
return
e = self._units.get(c["unit"])
if e:
e["vb"].removeItem(c["curve"])
try:
self.legend.removeItem(c["curve"])
except Exception:
pass
def clear(self):
for key in list(self._curves):
self.remove_curve(key)
def set_y_label(self, _text):
pass # multi-axis labels itself per unit
def set_background(self, bg):
self.glw.setBackground(bg)
def _sync(self):
rect = self.base_vb.sceneBoundingRect()
for e in self._units.values():
if not e["base"]:
e["vb"].setGeometry(rect)
e["vb"].linkedViewChanged(self.base_vb, e["vb"].XAxis)
class ArcGauge(QtWidgets.QWidget):
"""A 270-degree arc gauge for a single signal: filled arc to the current
value, peak tick, big numeric readout, name + unit."""
def __init__(self, name, unit, vmin, vmax, accent="#3cb44b"):
super().__init__()
self.name = name
self.unit = unit
self.vmin = float(vmin)
self.vmax = float(vmax) if vmax != vmin else float(vmin) + 1.0
self.accent = QtGui.QColor(accent)
self.value = None
self.peak = None
self.setMinimumSize(150, 130)
def set_value(self, v, peak=None):
self.value = v
self.peak = peak
self.update()
def _frac(self, v):
return max(0.0, min(1.0, (v - self.vmin) / (self.vmax - self.vmin)))
def paintEvent(self, _ev):
p = QtGui.QPainter(self)
p.setRenderHint(QtGui.QPainter.Antialiasing)
w, h = self.width(), self.height()
p.fillRect(self.rect(), QtGui.QColor("#141414")) # own dark bg (theme-proof)
m = 14
side = min(w, h - 18)
rect = QtCore.QRectF((w - side) / 2 + m / 2, m / 2, side - m, side - m)
start, span = 225 * 16, -270 * 16 # 270deg sweep, top-open down
def arc_pen(color, width):
pen = QtGui.QPen(QtGui.QColor(color), width)
pen.setCapStyle(QtCore.Qt.RoundCap)
return pen
p.setPen(arc_pen("#333", 9))
p.drawArc(rect, start, span)
if self.value is not None:
frac = self._frac(self.value)
p.setPen(arc_pen(self.accent, 9))
p.drawArc(rect, start, int(span * frac))
if self.peak is not None and self.peak != self.value:
pf = self._frac(self.peak)
ang = (225 - 270 * pf)
p.setPen(QtGui.QPen(QtGui.QColor("#e6c84b"), 2))
cx, cy = rect.center().x(), rect.center().y()
r1, r2 = side / 2 - m - 9, side / 2 - m + 2
a = math.radians(ang)
p.drawLine(QtCore.QPointF(cx + r1 * math.cos(a), cy - r1 * math.sin(a)),
QtCore.QPointF(cx + r2 * math.cos(a), cy - r2 * math.sin(a)))
p.setPen(QtGui.QColor("#eee"))
f = p.font(); f.setPointSize(15); f.setBold(True); p.setFont(f)
val = "--" if self.value is None else f"{self.value:g}"
p.drawText(rect, QtCore.Qt.AlignCenter, val)
f.setPointSize(8); f.setBold(False); p.setFont(f)
p.setPen(QtGui.QColor("#999"))
p.drawText(QtCore.QRectF(0, h - 16, w, 14), QtCore.Qt.AlignCenter,
f"{self.name} ({self.unit})" if self.unit else self.name)
p.end()
class GaugeGrid(QtWidgets.QScrollArea):
"""Scrollable grid of ArcGauges, one per signal."""
def __init__(self):
super().__init__()
self.setWidgetResizable(True)
self.setStyleSheet("QScrollArea, QWidget { background: #111; }")
self._inner = QtWidgets.QWidget()
self._grid = QtWidgets.QGridLayout(self._inner)
self._grid.setSpacing(8)
self.setWidget(self._inner)
self._gauges = {}
self._cols = 4
def rebuild(self, specs):
"""specs: list of (key, name, unit, vmin, vmax, accent)."""
while self._grid.count():
item = self._grid.takeAt(0)
wdg = item.widget()
if wdg:
wdg.deleteLater()
self._gauges = {}
for i, (key, name, unit, vmin, vmax, accent) in enumerate(specs):
g = ArcGauge(name, unit, vmin, vmax, accent)
self._gauges[key] = g
self._grid.addWidget(g, i // self._cols, i % self._cols)
def keys(self):
return set(self._gauges)
def set_value(self, key, v, peak=None):
g = self._gauges.get(key)
if g is not None:
g.set_value(v, peak)