From 23cd76c96ec3ab7bcc6a55cdb2ac5e162f07ddd4 Mon Sep 17 00:00:00 2001 From: Kemal Yaylali Date: Sat, 15 Aug 2026 23:26:46 +0100 Subject: [PATCH] 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. --- ashvale/led.py | 66 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/ashvale/led.py b/ashvale/led.py index 8e8b8a9..099a544 100644 --- a/ashvale/led.py +++ b/ashvale/led.py @@ -102,7 +102,14 @@ def _mix(a, b, t: 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 t = min(max((x - edge0) / (edge1 - edge0), 0.0), 1.0) 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)) high = _smoothstep(0.0, 45.0, elev) - sky = _mix((0.36, 0.55, 0.78), (0.60, 0.80, 1.00), 1.0 - cloud) - cv.buf += np.asarray(sky) * (0.10 + 0.13 * high) + # After dark the same geometry becomes a moon: cool palette, rays pulled + # 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) - tip = (1.00, 0.45, 0.05) + sky = _mix((0.36, 0.55, 0.78), (0.60, 0.80, 1.00), 1.0 - cloud) + 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 = np.exp(-(RADIUS ** 2) / (1.5 + 0.5 * high)) cv.wash(disc * 0.95, core) 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 # 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(), ForecastRibbon(), Barometer()] self.alert = Alert() - self._glyph: Optional[str] = None - self._show_glyph = False + self._glyph: str = "sun" + self._show_glyph = True self._idx = 0 self._scene_started = 0.0 self._prev: Optional[Scene] = None @@ -782,28 +796,30 @@ class LedDisplay: # ------------------------------------------------------------ loop @staticmethod - def _pick_glyph(s: Dict) -> Optional[str]: + def _pick_glyph(s: Dict) -> str: """Which weather is this, from measurement and forecast only. - Deliberately hysteresis-free thresholds on quantities that are already - smoothed upstream: rain probability comes from the Zambretti prior blended - with the online learner, temperature is the Kalman level, and the solar - elevation is computed not guessed. So the glyph changes when the weather - changes, not when a sensor twitches. + Always returns a glyph. The first version gated each one behind narrow + conditions and returned None otherwise, which on a warm dry night meant + none of them ever qualified and the panel silently fell back to the + ambient scenes. A forecast symbol is not an exception, it is the default, + 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) temp = s.get("temp", 10.0) - elev = s.get("solar_elevation", -20.0) - cloud = s.get("cloud", 0.5) 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")): - return "snowflake" - if rain >= 0.45 or cond in ("rain", "wet", "stormy"): + if temp <= 1.5: + return "snowflake" if (rain >= 0.20 or wet or cond == "unsettled") else "sun" + if rain >= 0.30 or wet: return "umbrella" - if elev > 3.0 and cloud < 0.55 and rain < 0.30: - return "sun" - return None + return "sun" def _advance(self, now: float, s: Dict) -> None: alerting = s["health"] != "ok" or s["retrain"] @@ -823,7 +839,7 @@ class LedDisplay: if glyph != self._glyph: self._prev = self._current() self._glyph = glyph - self._show_glyph = glyph is not None + self._show_glyph = True self._fade_started = now self._scene_started = now return @@ -839,10 +855,8 @@ class LedDisplay: # Hand back to the informational scenes for one turn. self._show_glyph = False self._idx = (self._idx + 1) % len(self.scenes) - elif self._glyph is not None: - self._show_glyph = True else: - self._idx = (self._idx + 1) % len(self.scenes) + self._show_glyph = True def _current(self) -> Scene: if self._alerting: