Gauge redline zones + C/F units toggle + cleaner dials
Gauges: - Optional per-metric warning zones (warn_hi/redline_hi/warn_lo/redline_lo) in the profile schema; gauges draw colored redline/warn bands and color the needle + readout by zone. Default neutral when unset (no false redline). - Removed the value progress-arc fill (it dominated the dial / looked wrong) -> clean tach face: bezel, ticks, numeric scale, needle, redline band, readout. - Auto-derivation rejected: bad direction/threshold vary per metric, so zones are config (with a sensible neutral default). Units: - New Units menu: Temperature C / F. Converts gauges, graph, table, and PID browser (values, scale, zones, unit labels) at display time; data stays C. Ford 6.0 profile: zones for ICP (red<500), FICM (red<40/amber40-48/green48+), ECT/EOT (high redline), RPM (redline 3800), boost, battery; tightened FICM (38-52) and battery (9-15) ranges so redline bands land sensibly. Docs: profiles/PROFILE_SPEC.md -- canonical, AI-agent-ready profile spec (schema, formula language, zones, confidence, rules); README points to it. Validated headless: zones parse/classify, F conversion (112C->233.6F, zones converted), gauges render; obdcore + diagnostics tests 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:
+60
-10
@@ -42,6 +42,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self.curves = {}
|
||||
self._color_i = 0
|
||||
self._theme = "dark"
|
||||
self._temp_f = False # display temperatures in F instead of C
|
||||
|
||||
self._build_menubar()
|
||||
self._build_connection_bar()
|
||||
@@ -97,6 +98,15 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
viewm.addSeparator()
|
||||
self.theme_act = self._act(viewm, "Light Theme", self._toggle_theme, checkable=True)
|
||||
|
||||
unitsm = mb.addMenu("&Units")
|
||||
self._temp_grp = QtGui.QActionGroup(self); self._temp_grp.setExclusive(True)
|
||||
self.temp_c_act = self._act(unitsm, "Temperature: °C", lambda: self._set_temp(False),
|
||||
checkable=True)
|
||||
self.temp_f_act = self._act(unitsm, "Temperature: °F", lambda: self._set_temp(True),
|
||||
checkable=True)
|
||||
self._temp_grp.addAction(self.temp_c_act); self._temp_grp.addAction(self.temp_f_act)
|
||||
self.temp_c_act.setChecked(True)
|
||||
|
||||
helpm = mb.addMenu("&Help")
|
||||
self._act(helpm, "About OBDash", self._about)
|
||||
self._act(helpm, "PID Confidence Legend", self._legend)
|
||||
@@ -525,7 +535,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
p = self.ctl.reg.get(key)
|
||||
color = CURVE_COLORS[self._color_i % len(CURVE_COLORS)]; self._color_i += 1
|
||||
self.curves[key] = color # curves maps key -> color
|
||||
self._graph().add_curve(key, f"{p.name} ({p.unit})", p.unit, color)
|
||||
self._graph().add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color)
|
||||
self._refresh_gauges()
|
||||
|
||||
def _remove_curve(self, key):
|
||||
@@ -543,15 +553,51 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
active = self._graph()
|
||||
for key, color in self.curves.items():
|
||||
p = self.ctl.reg.get(key)
|
||||
active.add_curve(key, f"{p.name} ({p.unit})", p.unit, color)
|
||||
active.add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color)
|
||||
self._apply_theme()
|
||||
self._redraw_curves(static=not self.ctl.connected)
|
||||
|
||||
# ---------- display units (C / F) ----------
|
||||
def _is_temp(self, p):
|
||||
return p.unit == "C"
|
||||
|
||||
def _dval(self, p, v):
|
||||
if v is None:
|
||||
return None
|
||||
return v * 9 / 5 + 32 if (self._temp_f and self._is_temp(p)) else v
|
||||
|
||||
def _dunit(self, p):
|
||||
return "F" if (self._temp_f and self._is_temp(p)) else p.unit
|
||||
|
||||
def _dzones(self, p):
|
||||
z = {"warn_hi": p.warn_hi, "redline_hi": p.redline_hi,
|
||||
"warn_lo": p.warn_lo, "redline_lo": p.redline_lo}
|
||||
if self._temp_f and self._is_temp(p):
|
||||
z = {k: (None if x is None else x * 9 / 5 + 32) for k, x in z.items()}
|
||||
return z
|
||||
|
||||
def _set_temp(self, to_f):
|
||||
if to_f == self._temp_f:
|
||||
return
|
||||
self._temp_f = to_f
|
||||
# graph: relabel + rescale by rebuilding curves on the active widget
|
||||
self.multi.clear(); self.single.clear()
|
||||
active = self._graph()
|
||||
for key, color in self.curves.items():
|
||||
p = self.ctl.reg.get(key)
|
||||
active.add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color)
|
||||
for key, r in getattr(self, "_table_row", {}).items():
|
||||
self.table.item(r, 2).setText(self._dunit(self.ctl.reg.get(key)))
|
||||
self._refresh_gauges()
|
||||
if self.ctl.connected:
|
||||
self._tick()
|
||||
|
||||
def _refresh_gauges(self):
|
||||
specs = []
|
||||
for key, color in self.curves.items():
|
||||
p = self.ctl.reg.get(key)
|
||||
specs.append((key, p.name, p.unit, p.vmin, p.vmax, color))
|
||||
specs.append((key, p.name, self._dunit(p), self._dval(p, p.vmin),
|
||||
self._dval(p, p.vmax), color, self._dzones(p)))
|
||||
self.gauges.rebuild(specs)
|
||||
|
||||
# ---------- view ----------
|
||||
@@ -584,7 +630,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self._table_row[p.key] = r
|
||||
self.table.setItem(r, 0, QtWidgets.QTableWidgetItem(p.name))
|
||||
self.table.setItem(r, 1, QtWidgets.QTableWidgetItem("--"))
|
||||
self.table.setItem(r, 2, QtWidgets.QTableWidgetItem(p.unit))
|
||||
self.table.setItem(r, 2, QtWidgets.QTableWidgetItem(self._dunit(p)))
|
||||
self.table.setItem(r, 3, QtWidgets.QTableWidgetItem("--"))
|
||||
self.table.setItem(r, 4, QtWidgets.QTableWidgetItem("--"))
|
||||
self.table.setItem(r, 5, QtWidgets.QTableWidgetItem(p.confidence))
|
||||
@@ -674,7 +720,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
if normalize and p.vmax != p.vmin:
|
||||
ys.append((v - p.vmin) / (p.vmax - p.vmin) * 100.0)
|
||||
else:
|
||||
ys.append(v)
|
||||
ys.append(self._dval(p, v))
|
||||
active.set_data(key, xs, ys)
|
||||
if normalize:
|
||||
self.single.set_y_label("% of range")
|
||||
@@ -686,19 +732,23 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
for key, it in self._items.items():
|
||||
v = self.ctl.store.latest(key)
|
||||
p = self.ctl.reg.get(key)
|
||||
txt = "--" if v is None else f"{v:g} {p.unit}".strip()
|
||||
dv = self._dval(p, v)
|
||||
txt = "--" if dv is None else f"{dv:g} {self._dunit(p)}".strip()
|
||||
it.setText(1, txt)
|
||||
if key in self._table_row:
|
||||
r = self._table_row[key]
|
||||
lo, hi = self.ctl.store.minmax(key)
|
||||
self.table.item(r, 1).setText("--" if v is None else f"{v:g}")
|
||||
self.table.item(r, 3).setText("--" if lo is None else f"{lo:g}")
|
||||
self.table.item(r, 4).setText("--" if hi is None else f"{hi:g}")
|
||||
dlo, dhi = self._dval(p, lo), self._dval(p, hi)
|
||||
self.table.item(r, 1).setText("--" if dv is None else f"{dv:g}")
|
||||
self.table.item(r, 3).setText("--" if dlo is None else f"{dlo:g}")
|
||||
self.table.item(r, 4).setText("--" if dhi is None else f"{dhi:g}")
|
||||
self.tree.blockSignals(False)
|
||||
if self.stack.currentIndex() == 2: # gauge view
|
||||
for key in self.curves:
|
||||
p = self.ctl.reg.get(key)
|
||||
lo, hi = self.ctl.store.minmax(key)
|
||||
self.gauges.set_value(key, self.ctl.store.latest(key), peak=hi)
|
||||
self.gauges.set_value(key, self._dval(p, self.ctl.store.latest(key)),
|
||||
peak=self._dval(p, hi))
|
||||
else:
|
||||
self._redraw_curves()
|
||||
|
||||
|
||||
+49
-14
@@ -203,13 +203,20 @@ 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"):
|
||||
_AMBER = "#e6a23c"
|
||||
_RED = "#e6194B"
|
||||
_NEEDLE = {"ok": "#e8e8e8", "warn": _AMBER, "crit": _RED}
|
||||
|
||||
def __init__(self, name, unit, vmin, vmax, accent="#3cb44b", zones=None):
|
||||
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)
|
||||
z = zones or {}
|
||||
self.warn_hi = z.get("warn_hi"); self.redline_hi = z.get("redline_hi")
|
||||
self.warn_lo = z.get("warn_lo"); self.redline_lo = z.get("redline_lo")
|
||||
self.value = None
|
||||
self.peak = None
|
||||
self.setMinimumSize(172, 176)
|
||||
@@ -222,6 +229,19 @@ class ArcGauge(QtWidgets.QWidget):
|
||||
def _frac(self, v):
|
||||
return max(0.0, min(1.0, (v - self.vmin) / (self.vmax - self.vmin)))
|
||||
|
||||
def _zone(self, v):
|
||||
if v is None:
|
||||
return "ok"
|
||||
if self.redline_hi is not None and v >= self.redline_hi:
|
||||
return "crit"
|
||||
if self.redline_lo is not None and v <= self.redline_lo:
|
||||
return "crit"
|
||||
if self.warn_hi is not None and v >= self.warn_hi:
|
||||
return "warn"
|
||||
if self.warn_lo is not None and v <= self.warn_lo:
|
||||
return "warn"
|
||||
return "ok"
|
||||
|
||||
# gauge geometry: a 270-degree dial, gap at the bottom (like a tach)
|
||||
_START = 225.0 # value=min angle (math degrees, 0=3 o'clock, CCW+)
|
||||
_SWEEP = 270.0 # clockwise as value rises
|
||||
@@ -248,14 +268,26 @@ class ArcGauge(QtWidgets.QWidget):
|
||||
p.setBrush(QtGui.QColor("#0c0c0c"))
|
||||
p.drawEllipse(QtCore.QPointF(cx, cy), R, R)
|
||||
|
||||
# value progress arc just inside the rim
|
||||
rimrect = QtCore.QRectF(cx - R + 7, cy - R + 7, 2 * (R - 7), 2 * (R - 7))
|
||||
if self.value is not None:
|
||||
pen = QtGui.QPen(QtGui.QColor(self.accent), 4)
|
||||
# warning / redline zone bands on the outer ring (like a tach redline)
|
||||
ringrect = QtCore.QRectF(cx - R + 3, cy - R + 3, 2 * (R - 3), 2 * (R - 3))
|
||||
|
||||
def band(fa, fb, color):
|
||||
sa = self._ang(fa); span = self._ang(fb) - self._ang(fa)
|
||||
pen = QtGui.QPen(QtGui.QColor(color), 4)
|
||||
pen.setCapStyle(QtCore.Qt.FlatCap)
|
||||
p.setPen(pen)
|
||||
p.drawArc(rimrect, int(self._START * 16),
|
||||
int(-self._SWEEP * self._frac(self.value) * 16))
|
||||
p.drawArc(ringrect, int(sa * 16), int(span * 16))
|
||||
|
||||
if self.redline_hi is not None:
|
||||
band(self._frac(self.redline_hi), 1.0, self._RED)
|
||||
if self.warn_hi is not None:
|
||||
top = self._frac(self.redline_hi) if self.redline_hi is not None else 1.0
|
||||
band(self._frac(self.warn_hi), top, self._AMBER)
|
||||
if self.redline_lo is not None:
|
||||
band(0.0, self._frac(self.redline_lo), self._RED)
|
||||
if self.warn_lo is not None:
|
||||
bot = self._frac(self.redline_lo) if self.redline_lo is not None else 0.0
|
||||
band(bot, self._frac(self.warn_lo), self._AMBER)
|
||||
|
||||
# tick marks + numeric scale
|
||||
majors = 6
|
||||
@@ -282,7 +314,8 @@ class ArcGauge(QtWidgets.QWidget):
|
||||
p.setPen(QtGui.QPen(QtGui.QColor("#e6c84b"), 2))
|
||||
p.drawLine(pt(a, R - 3), pt(a, R - 14))
|
||||
|
||||
# needle
|
||||
zcol = QtGui.QColor(self._NEEDLE[self._zone(self.value)])
|
||||
# needle (colored by zone: white=ok, amber=warn, red=redline)
|
||||
if self.value is not None:
|
||||
a = self._ang(self._frac(self.value))
|
||||
tip = pt(a, R - 16)
|
||||
@@ -293,14 +326,14 @@ class ArcGauge(QtWidgets.QWidget):
|
||||
path.moveTo(b1); path.lineTo(tip); path.lineTo(b2); path.lineTo(tail)
|
||||
path.closeSubpath()
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.setBrush(QtGui.QColor("#e6194B"))
|
||||
p.setBrush(zcol)
|
||||
p.drawPath(path)
|
||||
# hub
|
||||
p.setBrush(QtGui.QColor("#999")); p.setPen(QtCore.Qt.NoPen)
|
||||
p.drawEllipse(QtCore.QPointF(cx, cy), 5, 5)
|
||||
|
||||
# digital readout (lower face)
|
||||
p.setPen(QtGui.QColor("#fff"))
|
||||
# digital readout (lower face) -- colored by zone
|
||||
p.setPen(zcol)
|
||||
fnt.setPointSize(13); fnt.setBold(True); p.setFont(fnt)
|
||||
val = "--" if self.value is None else f"{self.value:g}"
|
||||
p.drawText(QtCore.QRectF(cx - 55, cy + R * 0.30, 110, 22),
|
||||
@@ -328,15 +361,17 @@ class GaugeGrid(QtWidgets.QScrollArea):
|
||||
self._cols = 4
|
||||
|
||||
def rebuild(self, specs):
|
||||
"""specs: list of (key, name, unit, vmin, vmax, accent)."""
|
||||
"""specs: list of (key, name, unit, vmin, vmax, accent, zones)."""
|
||||
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)
|
||||
for i, spec in enumerate(specs):
|
||||
key, name, unit, vmin, vmax, accent = spec[:6]
|
||||
zones = spec[6] if len(spec) > 6 else None
|
||||
g = ArcGauge(name, unit, vmin, vmax, accent, zones)
|
||||
self._gauges[key] = g
|
||||
self._grid.addWidget(g, i // self._cols, i % self._cols)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user