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:
2026-06-30 15:52:49 -04:00
parent deef305c63
commit d893ff383a
8 changed files with 326 additions and 32 deletions
+49 -14
View File
@@ -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)