Fix: the weather glyphs never appeared, and day/night was inverted

Two bugs, both found by checking the real board rather than the test harness.

The glyphs never showed. _pick_glyph gated each one behind narrow conditions
and returned None otherwise, so on the Pi's actual state (27.3 C, rain
probability 0.024, condition settled, solar elevation -20.3) nothing qualified
and the panel silently fell back to the ambient scenes every time. A forecast
symbol is the default, not an exception, so it now always returns one of the
three: cold wins, then wet, then fair.

Day and night were inverted. night = _smoothstep(2.0, -8.0, elev) passes a
descending range, and _smoothstep treated edge1 <= edge0 as a degenerate step
returning the opposite of the intent, so the panel drew a moon at midday and a
sun at midnight. Caught by rendering it and looking, not by reading it.
_smoothstep now handles descending ranges, and only the degenerate equal-edge
case takes the step branch.

The fair-weather glyph also needed to survive after dark or it vanishes for
half of every day, which is how it went missing in the first place. Same
geometry, cool palette, rays drawn in to a halo.
This commit is contained in:
2026-08-15 23:26:46 +01:00
parent 3c99cd53e3
commit 23cd76c96e
+40 -26
View File
@@ -102,7 +102,14 @@ def _mix(a, b, t: float):
def _smoothstep(edge0: float, edge1: float, x: float) -> float: def _smoothstep(edge0: float, edge1: float, x: float) -> float:
if edge1 <= edge0: """Hermite ramp between two edges. Handles a descending range.
The descending case is not decoration: writing _smoothstep(2, -8, elev) to
mean "1 when the sun is well down" silently returned the exact inverse when
this fell through to the degenerate branch, and the panel drew a moon at
midday and a sun at midnight.
"""
if edge0 == edge1:
return 0.0 if x < edge0 else 1.0 return 0.0 if x < edge0 else 1.0
t = min(max((x - edge0) / (edge1 - edge0), 0.0), 1.0) t = min(max((x - edge0) / (edge1 - edge0), 0.0), 1.0)
return t * t * (3.0 - 2.0 * t) return t * t * (3.0 - 2.0 * t)
@@ -518,18 +525,25 @@ class SunBurst(Scene):
cloud = float(np.clip(s.get("cloud", 0.3), 0.0, 1.0)) cloud = float(np.clip(s.get("cloud", 0.3), 0.0, 1.0))
high = _smoothstep(0.0, 45.0, elev) high = _smoothstep(0.0, 45.0, elev)
sky = _mix((0.36, 0.55, 0.78), (0.60, 0.80, 1.00), 1.0 - cloud) # After dark the same geometry becomes a moon: cool palette, rays pulled
cv.buf += np.asarray(sky) * (0.10 + 0.13 * high) # in to a halo. Without this the fair-weather symbol simply vanishes for
# half of every day, which is how the glyphs went missing in the first
# place.
night = _smoothstep(2.0, -8.0, elev)
core = (1.00, 0.80, 0.00) sky = _mix((0.36, 0.55, 0.78), (0.60, 0.80, 1.00), 1.0 - cloud)
tip = (1.00, 0.45, 0.05) sky = _mix(sky, (0.03, 0.04, 0.16), night)
cv.buf += np.asarray(sky) * (0.10 + 0.13 * high + 0.06 * night)
core = _mix((1.00, 0.80, 0.00), (0.80, 0.86, 1.00), night)
tip = _mix((1.00, 0.45, 0.05), (0.45, 0.60, 0.95), night)
# Disc: a soft radial falloff, not a stamped circle. # Disc: a soft radial falloff, not a stamped circle.
disc = np.exp(-(RADIUS ** 2) / (1.5 + 0.5 * high)) disc = np.exp(-(RADIUS ** 2) / (1.5 + 0.5 * high))
cv.wash(disc * 0.95, core) cv.wash(disc * 0.95, core)
breathe = 0.5 + 0.5 * math.sin(t * 1.1) breathe = 0.5 + 0.5 * math.sin(t * 1.1)
reach = 1.6 + 1.9 * high + 0.45 * breathe reach = (1.6 + 1.9 * high + 0.45 * breathe) * (1.0 - 0.55 * night)
spin = t * 0.30 spin = t * 0.30
# Eight-fold symmetry is one cosine, so the rays are a single field # Eight-fold symmetry is one cosine, so the rays are a single field
@@ -726,8 +740,8 @@ class LedDisplay:
self.scenes: List[Scene] = [Aurora(), SolarSky(), Precipitation(), self.scenes: List[Scene] = [Aurora(), SolarSky(), Precipitation(),
ForecastRibbon(), Barometer()] ForecastRibbon(), Barometer()]
self.alert = Alert() self.alert = Alert()
self._glyph: Optional[str] = None self._glyph: str = "sun"
self._show_glyph = False self._show_glyph = True
self._idx = 0 self._idx = 0
self._scene_started = 0.0 self._scene_started = 0.0
self._prev: Optional[Scene] = None self._prev: Optional[Scene] = None
@@ -782,28 +796,30 @@ class LedDisplay:
# ------------------------------------------------------------ loop # ------------------------------------------------------------ loop
@staticmethod @staticmethod
def _pick_glyph(s: Dict) -> Optional[str]: def _pick_glyph(s: Dict) -> str:
"""Which weather is this, from measurement and forecast only. """Which weather is this, from measurement and forecast only.
Deliberately hysteresis-free thresholds on quantities that are already Always returns a glyph. The first version gated each one behind narrow
smoothed upstream: rain probability comes from the Zambretti prior blended conditions and returned None otherwise, which on a warm dry night meant
with the online learner, temperature is the Kalman level, and the solar none of them ever qualified and the panel silently fell back to the
elevation is computed not guessed. So the glyph changes when the weather ambient scenes. A forecast symbol is not an exception, it is the default,
changes, not when a sensor twitches. so this reads like a weather app: cold wins, then wet, then fair.
The thresholds sit on quantities that are already smoothed upstream. Rain
probability is the Zambretti prior blended with the online learner and
temperature is the Kalman level, so the glyph changes when the weather
changes rather than when a sensor twitches.
""" """
rain = s.get("rain_prob", 0.0) rain = s.get("rain_prob", 0.0)
temp = s.get("temp", 10.0) temp = s.get("temp", 10.0)
elev = s.get("solar_elevation", -20.0)
cloud = s.get("cloud", 0.5)
cond = s.get("condition", "changeable") cond = s.get("condition", "changeable")
wet = cond in ("rain", "wet", "stormy")
if temp <= 1.5 and (rain >= 0.30 or cond in ("unsettled", "rain", "wet", "stormy")): if temp <= 1.5:
return "snowflake" return "snowflake" if (rain >= 0.20 or wet or cond == "unsettled") else "sun"
if rain >= 0.45 or cond in ("rain", "wet", "stormy"): if rain >= 0.30 or wet:
return "umbrella" return "umbrella"
if elev > 3.0 and cloud < 0.55 and rain < 0.30: return "sun"
return "sun"
return None
def _advance(self, now: float, s: Dict) -> None: def _advance(self, now: float, s: Dict) -> None:
alerting = s["health"] != "ok" or s["retrain"] alerting = s["health"] != "ok" or s["retrain"]
@@ -823,7 +839,7 @@ class LedDisplay:
if glyph != self._glyph: if glyph != self._glyph:
self._prev = self._current() self._prev = self._current()
self._glyph = glyph self._glyph = glyph
self._show_glyph = glyph is not None self._show_glyph = True
self._fade_started = now self._fade_started = now
self._scene_started = now self._scene_started = now
return return
@@ -839,10 +855,8 @@ class LedDisplay:
# Hand back to the informational scenes for one turn. # Hand back to the informational scenes for one turn.
self._show_glyph = False self._show_glyph = False
self._idx = (self._idx + 1) % len(self.scenes) self._idx = (self._idx + 1) % len(self.scenes)
elif self._glyph is not None:
self._show_glyph = True
else: else:
self._idx = (self._idx + 1) % len(self.scenes) self._show_glyph = True
def _current(self) -> Scene: def _current(self) -> Scene:
if self._alerting: if self._alerting: