From 5819739fcb8f9789ac69fb663ae416c417f6dcac Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 17 Jul 2026 19:59:17 +0700 Subject: [PATCH] feat: Zambretti forecast with trend classification Co-Authored-By: Claude Opus 4.8 --- WeatherPredictor/WeatherPredictor.ino | 12 ++++ WeatherPredictor/forecast.cpp | 100 ++++++++++++++++++++++++++ WeatherPredictor/forecast.h | 15 ++++ 3 files changed, 127 insertions(+) create mode 100644 WeatherPredictor/forecast.cpp create mode 100644 WeatherPredictor/forecast.h diff --git a/WeatherPredictor/WeatherPredictor.ino b/WeatherPredictor/WeatherPredictor.ino index 44692bd..ceafb31 100644 --- a/WeatherPredictor/WeatherPredictor.ino +++ b/WeatherPredictor/WeatherPredictor.ino @@ -4,6 +4,7 @@ #include "display_ui.h" #include "sensors.h" #include "rtc_time.h" +#include "forecast.h" static void i2cScan() { Serial.println(F("I2C scan:")); @@ -34,6 +35,17 @@ void setup() { Serial.println(F("RTC lost power -> setting to build time")); rtcSet(RtcTime{2026, 7, 17, 12, 0, 0}); // temporary; NTP will correct later } + + // --- forecast self-test (remove after Task 8) --- + struct { float p; Trend tr; } cases[] = { + {1030, TREND_STEADY}, {1030, TREND_RISING}, {1030, TREND_FALLING}, + {1000, TREND_STEADY}, {1000, TREND_FALLING}, {970, TREND_FALLING}, + }; + for (auto& c : cases) { + Forecast f = computeForecast(c.p, c.tr, 7); + Serial.printf("p=%.0f trend=%d -> %c [%s] (%s)\n", + c.p, c.tr, f.letter, f.text, categoryShort(f.category)); + } } void loop() { diff --git a/WeatherPredictor/forecast.cpp b/WeatherPredictor/forecast.cpp new file mode 100644 index 0000000..caccdd2 --- /dev/null +++ b/WeatherPredictor/forecast.cpp @@ -0,0 +1,100 @@ +#include +#include "config.h" +#include "forecast.h" + +// Full Zambretti phrases, index 0='A' .. 25='Z'. +static const char* const ZTEXT[26] = { + "Settled fine weather", // A + "Fine weather", // B + "Becoming fine", // C + "Fine, becoming less settled", // D + "Fine, possibly showers", // E + "Fairly fine, improving", // F + "Fairly fine, possibly showers early", // G + "Fairly fine, showers later", // H + "Showery early, improving", // I + "Changeable, improving", // J + "Fairly fine, showers likely", // K + "Rather unsettled, clearing later", // L + "Unsettled, probably improving", // M + "Showery, bright intervals", // N + "Showery, becoming unsettled", // O + "Changeable, some rain", // P + "Unsettled, short fine intervals", // Q + "Unsettled, rain later", // R + "Unsettled, rain at times", // S + "Very unsettled, finer at times", // T + "Rain at times, worse later", // U + "Rain at times, becoming very unsettled", // V + "Rain at frequent intervals", // W + "Very unsettled, rain", // X + "Stormy, possibly improving", // Y + "Stormy, much rain" // Z +}; + +// Map a constrained Zambretti number to a letter, per trend (G6EJD tables). +static char letterRising(int z) { const char* m = "ABCFGIJLMQTYZ"; return m[z - 1]; } // z 1..13 +static char letterFalling(int z) { const char* m = "ABDHORUXZ"; return m[z - 1]; } // z 1..9 +static char letterSteady(int z) { const char* m = "ABEKNPSWXZ"; return m[z - 1]; } // z 1..10 + +static int clampi(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); } + +static WxCategory categoryOf(char letter) { + switch (letter) { + case 'A': case 'B': case 'C': case 'F': return WX_FINE; + case 'E': case 'G': case 'I': case 'J': case 'K': + case 'M': case 'N': case 'Q': return WX_FAIR; + case 'D': case 'H': case 'L': case 'O': case 'P': return WX_CHANGEABLE; + case 'R': case 'S': case 'T': case 'U': case 'V': case 'W': + case 'X': return WX_RAIN; + case 'Y': case 'Z': return WX_STORM; + default: return WX_UNKNOWN; + } +} + +Trend classifyTrend(float d) { + if (d > TREND_THRESHOLD_HPA) return TREND_RISING; + if (d < -TREND_THRESHOLD_HPA) return TREND_FALLING; + return TREND_STEADY; +} + +Forecast computeForecast(float p, Trend trend, int month) { + bool winter = (month < 4 || month > 9); // northern hemisphere + int z; + char letter; + + if (trend == TREND_RISING) { + z = (int)lround(-0.1449 * p + 150.18); + if (winter) z += 1; + z = clampi(z, 1, 13); + letter = letterRising(z); + } else if (trend == TREND_FALLING) { + z = (int)lround(0.0000257935 * p * p * p + - 0.078482105 * p * p + + 79.4582219457 * p + - 26762.7164899421); + if (winter) z -= 1; + z = clampi(z, 1, 9); + letter = letterFalling(z); + } else { + z = (int)lround(0.0000258964 * p * p * p + - 0.07753778137 * p * p + + 77.2287820569 * p + - 25582.130426005); + z = clampi(z, 1, 10); + letter = letterSteady(z); + } + + return Forecast{ letter, ZTEXT[letter - 'A'], categoryOf(letter) }; +} + +const char* categoryShort(WxCategory c) { + switch (c) { + case WX_FINE: return "Fine"; + case WX_FAIR: return "Fair"; + case WX_CHANGEABLE: return "Changeable"; + case WX_RAIN: return "Rain"; + case WX_STORM: return "Storm"; + default: return "..."; + } +} diff --git a/WeatherPredictor/forecast.h b/WeatherPredictor/forecast.h new file mode 100644 index 0000000..40db7c7 --- /dev/null +++ b/WeatherPredictor/forecast.h @@ -0,0 +1,15 @@ +#pragma once +#include + +enum Trend { TREND_FALLING = -1, TREND_STEADY = 0, TREND_RISING = 1 }; +enum WxCategory { WX_FINE, WX_FAIR, WX_CHANGEABLE, WX_RAIN, WX_STORM, WX_UNKNOWN }; + +struct Forecast { + char letter; // 'A'..'Z' + const char* text; // full Zambretti phrase + WxCategory category; // for display icon + short label +}; + +Trend classifyTrend(float deltaHpa3h); +Forecast computeForecast(float mslHpa, Trend trend, int month); +const char* categoryShort(WxCategory c);