From 186c1984c0ad2f139df5cbccefc0413b78804224 Mon Sep 17 00:00:00 2001 From: Mariusz Pacek Date: Sat, 24 Oct 2026 17:00:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20weather,=20PV=20estimate=20and=20IM?= =?UTF-8?q?GW=20warnings=20on=20Podgl=C4=85d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend GET /api/weather: Open-Meteo forecast for three days, a PV estimate calibrated on the last 14 measured days (Deye + Fronius daily energy over forecast radiation), and IMGW warnings for Krosno (TERYT 1862, else 1807). Cached 30 and 10 minutes, stale answer on failure. Frontend: Pogoda card, warning banners, and a hint on Bateria to keep the battery full on a storm or strong-wind warning. Co-Authored-By: Claude Opus 5.5 --- backend/README.md | 10 + .../src/main/java/home/backend/Backend.java | 23 +- .../home/backend/adapters/http/ApiServer.java | 4 +- .../influx/InfluxHistoryRepository.java | 42 +--- .../backend/adapters/influx/InfluxHttp.java | 47 ++++ .../adapters/influx/InfluxPvEnergy.java | 51 ++++ .../backend/adapters/weather/HttpJson.java | 42 ++++ .../backend/adapters/weather/ImgwClient.java | 76 ++++++ .../adapters/weather/OpenMeteoClient.java | 68 ++++++ .../adapters/weather/WeatherSettings.java | 35 +++ .../java/home/backend/application/Cached.java | 47 ++++ .../backend/application/ForecastSource.java | 11 + .../application/PvEnergyRepository.java | 12 + .../backend/application/WarningSource.java | 12 + .../backend/application/WeatherService.java | 98 ++++++++ .../java/home/backend/domain/Forecast.java | 13 + .../home/backend/domain/PvCalibration.java | 32 +++ .../home/backend/domain/WeatherCodes.java | 37 +++ .../java/home/backend/domain/WeatherView.java | 27 ++ .../backend/adapters/http/ApiServerTest.java | 41 +++- .../influx/InfluxHistoryRepositoryTest.java | 2 +- .../adapters/influx/InfluxPvEnergyTest.java | 89 +++++++ .../adapters/weather/WeatherClientsTest.java | 185 ++++++++++++++ .../application/WeatherServiceTest.java | 231 ++++++++++++++++++ .../backend/domain/WeatherDomainTest.java | 84 +++++++ backend/src/test/resources/api/weather.json | 14 ++ .../test/resources/weather/imgw-warnings.json | 80 ++++++ .../test/resources/weather/open-meteo.json | 1 + docs/spec.md | 2 + frontend/README.md | 2 + frontend/src/Battery.tsx | 5 + frontend/src/Overview.tsx | 4 + frontend/src/Weather.test.tsx | 126 ++++++++++ frontend/src/Weather.tsx | 107 ++++++++ frontend/src/types.ts | 31 +++ 35 files changed, 1648 insertions(+), 43 deletions(-) create mode 100644 backend/src/main/java/home/backend/adapters/influx/InfluxHttp.java create mode 100644 backend/src/main/java/home/backend/adapters/influx/InfluxPvEnergy.java create mode 100644 backend/src/main/java/home/backend/adapters/weather/HttpJson.java create mode 100644 backend/src/main/java/home/backend/adapters/weather/ImgwClient.java create mode 100644 backend/src/main/java/home/backend/adapters/weather/OpenMeteoClient.java create mode 100644 backend/src/main/java/home/backend/adapters/weather/WeatherSettings.java create mode 100644 backend/src/main/java/home/backend/application/Cached.java create mode 100644 backend/src/main/java/home/backend/application/ForecastSource.java create mode 100644 backend/src/main/java/home/backend/application/PvEnergyRepository.java create mode 100644 backend/src/main/java/home/backend/application/WarningSource.java create mode 100644 backend/src/main/java/home/backend/application/WeatherService.java create mode 100644 backend/src/main/java/home/backend/domain/Forecast.java create mode 100644 backend/src/main/java/home/backend/domain/PvCalibration.java create mode 100644 backend/src/main/java/home/backend/domain/WeatherCodes.java create mode 100644 backend/src/main/java/home/backend/domain/WeatherView.java create mode 100644 backend/src/test/java/home/backend/adapters/influx/InfluxPvEnergyTest.java create mode 100644 backend/src/test/java/home/backend/adapters/weather/WeatherClientsTest.java create mode 100644 backend/src/test/java/home/backend/application/WeatherServiceTest.java create mode 100644 backend/src/test/java/home/backend/domain/WeatherDomainTest.java create mode 100644 backend/src/test/resources/api/weather.json create mode 100644 backend/src/test/resources/weather/imgw-warnings.json create mode 100644 backend/src/test/resources/weather/open-meteo.json create mode 100644 frontend/src/Weather.test.tsx create mode 100644 frontend/src/Weather.tsx diff --git a/backend/README.md b/backend/README.md index 77376d8..08cad37 100644 --- a/backend/README.md +++ b/backend/README.md @@ -22,6 +22,14 @@ Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as `{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013). +`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name +Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`, +`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that +ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of +`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor +times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min; +when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key. + Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start without the two URLs. Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`. @@ -34,6 +42,8 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us | `GATEWAY_TIMEOUT` | `5` | seconds | | `INFLUX_TIMEOUT` | `10` | seconds | | `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 | +| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast | +| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count | | `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below | ```bash diff --git a/backend/src/main/java/home/backend/Backend.java b/backend/src/main/java/home/backend/Backend.java index 09291af..a6bf985 100644 --- a/backend/src/main/java/home/backend/Backend.java +++ b/backend/src/main/java/home/backend/Backend.java @@ -5,7 +5,12 @@ import home.backend.adapters.gateway.GatewayHttpClient; import home.backend.adapters.gateway.GatewayHttpCommands; import home.backend.adapters.http.ApiServer; import home.backend.adapters.influx.InfluxHistoryRepository; +import home.backend.adapters.influx.InfluxHttp; +import home.backend.adapters.influx.InfluxPvEnergy; import home.backend.adapters.influx.InfluxSettings; +import home.backend.adapters.weather.ImgwClient; +import home.backend.adapters.weather.OpenMeteoClient; +import home.backend.adapters.weather.WeatherSettings; import home.backend.application.BatteryService; import home.backend.application.EnergyDetailsService; import home.backend.application.EnergyService; @@ -15,14 +20,19 @@ import home.backend.application.HistoryService; import home.backend.application.LeaseRenewer; import home.backend.application.PiSensorService; import home.backend.application.SetpointService; +import home.backend.application.WeatherService; import java.io.IOException; +import java.net.URI; import java.time.Clock; +import java.time.Duration; import java.util.Objects; /** Wires the adapters and services together; the only place that knows all of them. */ final class Backend { + private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10); + private Backend() { } @@ -33,7 +43,8 @@ final class Backend { */ static ApiServer assemble(Settings settings, Clock clock) throws IOException { var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout()); - var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout())); + var influxHttp = new InfluxHttp(new InfluxSettings(settings.influxUrl(), settings.influxTimeout())); + var influx = new InfluxHistoryRepository(influxHttp); var commands = commands(settings); var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile())); return new ApiServer(settings.port(), @@ -42,7 +53,15 @@ final class Backend { new EnergyDetailsService(gateway, clock), new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")), new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock), - new BatteryService(gateway, commands, clock)); + new BatteryService(gateway, commands, clock), weather(influxHttp, clock)); + } + + private static WeatherService weather(InfluxHttp influx, Clock clock) { + var place = WeatherSettings.fromEnvironment(System.getenv()); + return new WeatherService( + new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT), + new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WEATHER_TIMEOUT), + new InfluxPvEnergy(influx), clock); } /** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */ diff --git a/backend/src/main/java/home/backend/adapters/http/ApiServer.java b/backend/src/main/java/home/backend/adapters/http/ApiServer.java index e078b59..672163f 100644 --- a/backend/src/main/java/home/backend/adapters/http/ApiServer.java +++ b/backend/src/main/java/home/backend/adapters/http/ApiServer.java @@ -17,6 +17,7 @@ import home.backend.application.HeatPumpService; import home.backend.application.HistoryService; import home.backend.application.PiSensorService; import home.backend.application.SetpointService; +import home.backend.application.WeatherService; import home.backend.domain.BatteryChange; import home.backend.domain.BatteryResult; import home.backend.domain.CommandResult.Status; @@ -87,7 +88,7 @@ public class ApiServer { public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, EnergyDetailsService energyDetails, HealthService health, HistoryService history, SetpointService setpoints, PiSensorService piSensor, - BatteryService battery) throws IOException { + BatteryService battery, WeatherService weather) throws IOException { server = HttpServer.create(new InetSocketAddress(port), 0); server.setExecutor(executor); // Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010). @@ -105,6 +106,7 @@ public class ApiServer { route("/api/battery/reserve", exchange -> handle(exchange, Map.of( "POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery))))); api("/api/pi-sensor", query -> piSensor.current()); + api("/api/weather", query -> weather.current()); api("/api/health", query -> health.current()); route("/api/history", exchange -> handleHistory(exchange, history)); route("/api/setpoints", exchange -> handle(exchange, Map.of( diff --git a/backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java b/backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java index 2519c31..54ae7ea 100644 --- a/backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java +++ b/backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java @@ -5,12 +5,6 @@ import home.backend.domain.HistoryView.Point; import home.backend.domain.Series; import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.List; @@ -22,12 +16,10 @@ import java.util.concurrent.Executors; /** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */ public final class InfluxHistoryRepository implements HistoryRepository { - private final InfluxSettings settings; - private final HttpClient http; + private final InfluxHttp influx; - public InfluxHistoryRepository(InfluxSettings settings) { - this.settings = settings; - this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build(); + public InfluxHistoryRepository(InfluxHttp influx) { + this.influx = influx; } @Override @@ -59,37 +51,11 @@ public final class InfluxHistoryRepository implements HistoryRepository { private Map part(Source source, Instant from, Instant to, long stepMinutes) throws IOException { String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes); - return InfluxResponse.parse(fetch(source.database(), query)); + return InfluxResponse.parse(influx.query(source.database(), query)); } private static long stepMinutes(Instant from, Instant to, int maxPoints) { long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints); return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis())); } - - private String fetch(String database, String query) throws IOException { - HttpRequest request = HttpRequest.newBuilder(queryUri(database, query)) - .timeout(settings.requestTimeout()) - .GET() - .build(); - try { - HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { - throw new IOException("InfluxDB answered HTTP " + response.statusCode()); - } - return response.body(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("InfluxDB request interrupted", e); - } - } - - private URI queryUri(String database, String query) { - String base = settings.baseUri().toString().replaceAll("/+$", ""); - return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query)); - } - - private static String encode(String value) { - return URLEncoder.encode(value, StandardCharsets.UTF_8); - } } diff --git a/backend/src/main/java/home/backend/adapters/influx/InfluxHttp.java b/backend/src/main/java/home/backend/adapters/influx/InfluxHttp.java new file mode 100644 index 0000000..30bd08b --- /dev/null +++ b/backend/src/main/java/home/backend/adapters/influx/InfluxHttp.java @@ -0,0 +1,47 @@ +package home.backend.adapters.influx; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +/** The HTTP {@code /query} endpoint of InfluxDB 1.x: sends a query, returns the JSON body. */ +public final class InfluxHttp { + + private final InfluxSettings settings; + private final HttpClient http; + + public InfluxHttp(InfluxSettings settings) { + this.settings = settings; + this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build(); + } + + String query(String database, String query) throws IOException { + HttpRequest request = HttpRequest.newBuilder(queryUri(database, query)) + .timeout(settings.requestTimeout()) + .GET() + .build(); + try { + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("InfluxDB answered HTTP " + response.statusCode()); + } + return response.body(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("InfluxDB request interrupted", e); + } + } + + private URI queryUri(String database, String query) { + String base = settings.baseUri().toString().replaceAll("/+$", ""); + return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query)); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/backend/src/main/java/home/backend/adapters/influx/InfluxPvEnergy.java b/backend/src/main/java/home/backend/adapters/influx/InfluxPvEnergy.java new file mode 100644 index 0000000..6b49dc9 --- /dev/null +++ b/backend/src/main/java/home/backend/adapters/influx/InfluxPvEnergy.java @@ -0,0 +1,51 @@ +package home.backend.adapters.influx; + +import home.backend.application.PvEnergyRepository; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Map; +import java.util.TreeMap; + +/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */ +public final class InfluxPvEnergy implements PvEnergyRepository { + + private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw"); + private static final String DATABASE = "electricity"; + + private final InfluxHttp influx; + + public InfluxPvEnergy(InfluxHttp influx) { + this.influx = influx; + } + + @Override + public Map dailyKwh(LocalDate from, LocalDate to) throws IOException { + Map deye = dailyMax("deye", "pv_today_kwh", from, to); + Map fronius = dailyMax("fronius", "energy_day_wh", from, to); + Map total = new TreeMap<>(); + deye.forEach((day, kwh) -> { + Double wh = fronius.get(day); + if (wh != null) { + total.put(day, kwh + wh / 1000); + } + }); + return total; + } + + private Map dailyMax(String measurement, String field, LocalDate from, LocalDate to) + throws IOException { + String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= " + + millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"; + Map days = new TreeMap<>(); + InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) -> + days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max)); + return days; + } + + private static long millis(LocalDate day) { + return day.atStartOfDay(ZONE).toInstant().toEpochMilli(); + } +} diff --git a/backend/src/main/java/home/backend/adapters/weather/HttpJson.java b/backend/src/main/java/home/backend/adapters/weather/HttpJson.java new file mode 100644 index 0000000..fdf88df --- /dev/null +++ b/backend/src/main/java/home/backend/adapters/weather/HttpJson.java @@ -0,0 +1,42 @@ +package home.backend.adapters.weather; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** GET of a public JSON document, with a timeout on connecting and on the whole request. */ +final class HttpJson { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final HttpClient http; + private final Duration timeout; + + HttpJson(Duration timeout) { + this.http = HttpClient.newBuilder().connectTimeout(timeout).build(); + this.timeout = timeout; + } + + JsonNode get(URI uri) throws IOException { + HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build(); + try { + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode()); + } + return JSON.readTree(response.body()); + } catch (JsonProcessingException e) { + throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("request to " + uri.getHost() + " interrupted", e); + } + } +} diff --git a/backend/src/main/java/home/backend/adapters/weather/ImgwClient.java b/backend/src/main/java/home/backend/adapters/weather/ImgwClient.java new file mode 100644 index 0000000..ecf072b --- /dev/null +++ b/backend/src/main/java/home/backend/adapters/weather/ImgwClient.java @@ -0,0 +1,76 @@ +package home.backend.adapters.weather; + +import com.fasterxml.jackson.databind.JsonNode; +import home.backend.application.WarningSource; +import home.backend.domain.WeatherView.Warning; + +import java.io.IOException; +import java.net.URI; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning + * that is malformed (no degree or times) is left out rather than failing the others. + */ +public final class ImgwClient implements WarningSource { + + private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw"); + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final HttpJson http; + private final URI uri; + private final List teryt; + + public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) { + this.http = new HttpJson(timeout); + this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo"); + this.teryt = place.teryt(); + } + + @Override + public List warnings() throws IOException { + JsonNode root = http.get(uri); + if (!root.isArray()) { + throw new IOException("IMGW returned no list of warnings"); + } + List ours = new ArrayList<>(); + for (JsonNode item : root) { + if (covers(item.path("teryt"))) { + warning(item).ifPresent(ours::add); + } + } + return ours; + } + + private boolean covers(JsonNode codes) { + for (JsonNode code : codes) { + if (teryt.contains(code.asText())) { + return true; + } + } + return false; + } + + private static Optional warning(JsonNode item) { + try { + return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(), + Integer.parseInt(item.path("stopien").asText()), + time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")), + item.path("tresc").asText())); + } catch (NumberFormatException | DateTimeException e) { + return Optional.empty(); + } + } + + private static OffsetDateTime time(JsonNode text) { + return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime(); + } +} diff --git a/backend/src/main/java/home/backend/adapters/weather/OpenMeteoClient.java b/backend/src/main/java/home/backend/adapters/weather/OpenMeteoClient.java new file mode 100644 index 0000000..c265483 --- /dev/null +++ b/backend/src/main/java/home/backend/adapters/weather/OpenMeteoClient.java @@ -0,0 +1,68 @@ +package home.backend.adapters.weather; + +import com.fasterxml.jackson.databind.JsonNode; +import home.backend.application.ForecastSource; +import home.backend.domain.Forecast; +import home.backend.domain.Forecast.DailyForecast; + +import java.io.IOException; +import java.net.URI; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.LocalDate; +import java.util.Map; +import java.util.TreeMap; + +/** Open-Meteo (free, no key): daily values for the past 14 days and the next 3, and the temperature now. */ +public final class OpenMeteoClient implements ForecastSource { + + private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum," + + "shortwave_radiation_sum,sunshine_duration"; + + private final HttpJson http; + private final URI uri; + + public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) { + this.http = new HttpJson(timeout); + this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude=" + + place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY + + "¤t=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=3"); + } + + @Override + public Forecast forecast() throws IOException { + JsonNode root = http.get(uri); + JsonNode daily = root.path("daily"); + JsonNode dates = daily.path("time"); + if (!dates.isArray() || dates.isEmpty()) { + throw new IOException("Open-Meteo returned no daily forecast"); + } + Map days = new TreeMap<>(); + for (int i = 0; i < dates.size(); i++) { + days.put(date(dates.get(i).asText()), new DailyForecast( + integer(daily, "weather_code", i), number(daily, "temperature_2m_max", i), + number(daily, "temperature_2m_min", i), number(daily, "precipitation_sum", i), + number(daily, "shortwave_radiation_sum", i), number(daily, "sunshine_duration", i))); + } + JsonNode now = root.path("current").path("temperature_2m"); + return new Forecast(now.isNumber() ? now.asDouble() : null, days); + } + + private static LocalDate date(String text) throws IOException { + try { + return LocalDate.parse(text); + } catch (DateTimeException e) { + throw new IOException("Open-Meteo returned a bad date: " + text, e); + } + } + + private static Double number(JsonNode daily, String name, int i) { + JsonNode value = daily.path(name).path(i); + return value.isNumber() ? value.asDouble() : null; + } + + private static Integer integer(JsonNode daily, String name, int i) { + JsonNode value = daily.path(name).path(i); + return value.isNumber() ? value.asInt() : null; + } +} diff --git a/backend/src/main/java/home/backend/adapters/weather/WeatherSettings.java b/backend/src/main/java/home/backend/adapters/weather/WeatherSettings.java new file mode 100644 index 0000000..8fba1cf --- /dev/null +++ b/backend/src/main/java/home/backend/adapters/weather/WeatherSettings.java @@ -0,0 +1,35 @@ +package home.backend.adapters.weather; + +import java.util.List; +import java.util.Map; + +/** + * Where the house is and which IMGW areas (TERYT powiat codes) count as ours. Krosno is a city with + * powiat rights, 1862; 1807 is the surrounding powiat krośnieński, which warnings may name instead. + */ +public record WeatherSettings(double latitude, double longitude, List teryt) { + + public static WeatherSettings fromEnvironment(Map env) { + return new WeatherSettings( + coordinate(env, "WEATHER_LAT", 49.684, 90), + coordinate(env, "WEATHER_LON", 21.770, 180), + List.of(env.getOrDefault("WEATHER_TERYT", "1862,1807").strip().split("\\s*,\\s*"))); + } + + private static double coordinate(Map env, String name, double fallback, double limit) { + String value = env.get(name); + if (value == null || value.isBlank()) { + return fallback; + } + try { + double number = Double.parseDouble(value.strip()); + if (Math.abs(number) <= limit) { + return number; + } + } catch (NumberFormatException e) { + // reported below with the same message + } + throw new IllegalArgumentException(name + " must be a number from -" + limit + " to " + limit + + ", got: " + value); + } +} diff --git a/backend/src/main/java/home/backend/application/Cached.java b/backend/src/main/java/home/backend/application/Cached.java new file mode 100644 index 0000000..a337e6f --- /dev/null +++ b/backend/src/main/java/home/backend/application/Cached.java @@ -0,0 +1,47 @@ +package home.backend.application; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; + +/** One remote answer kept for a while; when a refresh fails the older answer is served and marked stale. */ +final class Cached { + + interface Fetch { + T fetch() throws IOException; + } + + record Entry(T value, boolean stale) { + } + + private final Duration ttl; + private final Clock clock; + private final Fetch source; + private T value; + private Instant fetchedAt; + + Cached(Duration ttl, Clock clock, Fetch source) { + this.ttl = ttl; + this.clock = clock; + this.source = source; + } + + /** @throws IOException when the refresh fails and there is nothing older to serve */ + synchronized Entry get() throws IOException { + Instant now = clock.instant(); + if (value != null && now.isBefore(fetchedAt.plus(ttl))) { + return new Entry<>(value, false); + } + try { + value = source.fetch(); + fetchedAt = now; + return new Entry<>(value, false); + } catch (IOException e) { + if (value == null) { + throw e; + } + return new Entry<>(value, true); + } + } +} diff --git a/backend/src/main/java/home/backend/application/ForecastSource.java b/backend/src/main/java/home/backend/application/ForecastSource.java new file mode 100644 index 0000000..a4a3e63 --- /dev/null +++ b/backend/src/main/java/home/backend/application/ForecastSource.java @@ -0,0 +1,11 @@ +package home.backend.application; + +import home.backend.domain.Forecast; + +import java.io.IOException; + +/** The weather forecast service: the past 14 days and the next 3. */ +public interface ForecastSource { + + Forecast forecast() throws IOException; +} diff --git a/backend/src/main/java/home/backend/application/PvEnergyRepository.java b/backend/src/main/java/home/backend/application/PvEnergyRepository.java new file mode 100644 index 0000000..158d11b --- /dev/null +++ b/backend/src/main/java/home/backend/application/PvEnergyRepository.java @@ -0,0 +1,12 @@ +package home.backend.application; + +import java.io.IOException; +import java.time.LocalDate; +import java.util.Map; + +/** What the panels produced, from InfluxDB. */ +public interface PvEnergyRepository { + + /** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */ + Map dailyKwh(LocalDate from, LocalDate to) throws IOException; +} diff --git a/backend/src/main/java/home/backend/application/WarningSource.java b/backend/src/main/java/home/backend/application/WarningSource.java new file mode 100644 index 0000000..d27cdc7 --- /dev/null +++ b/backend/src/main/java/home/backend/application/WarningSource.java @@ -0,0 +1,12 @@ +package home.backend.application; + +import home.backend.domain.WeatherView.Warning; + +import java.io.IOException; +import java.util.List; + +/** Weather warnings that cover the house, whether in force now or announced. */ +public interface WarningSource { + + List warnings() throws IOException; +} diff --git a/backend/src/main/java/home/backend/application/WeatherService.java b/backend/src/main/java/home/backend/application/WeatherService.java new file mode 100644 index 0000000..e8d54f3 --- /dev/null +++ b/backend/src/main/java/home/backend/application/WeatherService.java @@ -0,0 +1,98 @@ +package home.backend.application; + +import home.backend.application.Cached.Entry; +import home.backend.domain.Forecast; +import home.backend.domain.Forecast.DailyForecast; +import home.backend.domain.PvCalibration; +import home.backend.domain.WeatherCodes; +import home.backend.domain.WeatherView; +import home.backend.domain.WeatherView.Day; +import home.backend.domain.WeatherView.Now; +import home.backend.domain.WeatherView.Warning; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */ +public class WeatherService { + + private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw"); + private static final int CALIBRATION_DAYS = 14; + + private record Calibrated(Forecast forecast, Double factor) { + } + + private final Clock clock; + private final PvEnergyRepository pv; + private final Cached forecast; + private final Cached> warnings; + + public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv, + Clock clock) { + this.clock = clock; + this.pv = pv; + this.forecast = new Cached<>(Duration.ofMinutes(30), clock, () -> calibrated(forecastSource.forecast())); + this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings); + } + + /** @throws IOException when there is no forecast at all; warnings alone may be missing. */ + public WeatherView current() throws IOException { + Entry weather = forecast.get(); + Entry> warned = warnings(); + LocalDate today = LocalDate.now(clock.withZone(ZONE)); + Calibrated w = weather.value(); + return new WeatherView(clock.instant(), + day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)), + new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(), + weather.stale() || warned.stale()); + } + + private Entry> warnings() { + try { + return warnings.get(); + } catch (IOException e) { + return new Entry<>(List.of(), true); + } + } + + private List inForce(List all) { + return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList(); + } + + private Calibrated calibrated(Forecast fetched) { + LocalDate today = LocalDate.now(clock.withZone(ZONE)); + Map radiation = new HashMap<>(); + fetched.days().forEach((date, day) -> { + if (day.radiationMj() != null) { + radiation.put(date, day.radiationMj()); + } + }); + try { + var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today); + return new Calibrated(fetched, PvCalibration.factor(measured, radiation)); + } catch (IOException e) { + return new Calibrated(fetched, null); + } + } + + private static Day day(Calibrated w, LocalDate date) throws IOException { + DailyForecast d = w.forecast().days().get(date); + if (d == null) { + throw new IOException("forecast has no " + date); + } + Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj()); + Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600); + return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(), + d.precipitationMm(), sunshine, kwh); + } + + private static double round(double value) { + return Math.round(value * 10) / 10.0; + } +} diff --git a/backend/src/main/java/home/backend/domain/Forecast.java b/backend/src/main/java/home/backend/domain/Forecast.java new file mode 100644 index 0000000..cd7957d --- /dev/null +++ b/backend/src/main/java/home/backend/domain/Forecast.java @@ -0,0 +1,13 @@ +package home.backend.domain; + +import java.time.LocalDate; +import java.util.Map; + +/** What the forecast service says: the temperature now and a record per calendar day, past days included. */ +public record Forecast(Double temperature, Map days) { + + /** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */ + public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm, + Double radiationMj, Double sunshineSeconds) { + } +} diff --git a/backend/src/main/java/home/backend/domain/PvCalibration.java b/backend/src/main/java/home/backend/domain/PvCalibration.java new file mode 100644 index 0000000..46ffba7 --- /dev/null +++ b/backend/src/main/java/home/backend/domain/PvCalibration.java @@ -0,0 +1,32 @@ +package home.backend.domain; + +import java.time.LocalDate; +import java.util.Map; + +/** + * The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth: + * measured kWh over forecast MJ/m², summed over the days that have both. + */ +public final class PvCalibration { + + static final int MIN_DAYS = 5; + + private PvCalibration() { + } + + /** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */ + public static Double factor(Map measuredKwh, Map radiationMj) { + double kwh = 0; + double mj = 0; + int days = 0; + for (var day : measuredKwh.entrySet()) { + Double radiation = radiationMj.get(day.getKey()); + if (radiation != null) { + kwh += day.getValue(); + mj += radiation; + days++; + } + } + return days < MIN_DAYS || mj <= 0 ? null : kwh / mj; + } +} diff --git a/backend/src/main/java/home/backend/domain/WeatherCodes.java b/backend/src/main/java/home/backend/domain/WeatherCodes.java new file mode 100644 index 0000000..716b2a7 --- /dev/null +++ b/backend/src/main/java/home/backend/domain/WeatherCodes.java @@ -0,0 +1,37 @@ +package home.backend.domain; + +/** WMO weather interpretation codes as Polish text. */ +public final class WeatherCodes { + + private WeatherCodes() { + } + + public static String describe(Integer code) { + if (code == null) { + return "Brak danych"; + } + return switch (code) { + case 0 -> "Bezchmurnie"; + case 1 -> "Przeważnie słonecznie"; + case 2 -> "Częściowe zachmurzenie"; + case 3 -> "Pochmurno"; + case 45, 48 -> "Mgła"; + case 51, 53, 55 -> "Mżawka"; + case 56, 57 -> "Marznąca mżawka"; + case 61 -> "Słaby deszcz"; + case 63 -> "Deszcz"; + case 65 -> "Silny deszcz"; + case 66, 67 -> "Marznący deszcz"; + case 71 -> "Słaby śnieg"; + case 73 -> "Śnieg"; + case 75 -> "Intensywny śnieg"; + case 77 -> "Ziarna śnieżne"; + case 80, 81 -> "Przelotny deszcz"; + case 82 -> "Ulewa"; + case 85, 86 -> "Przelotny śnieg"; + case 95 -> "Burza"; + case 96, 99 -> "Burza z gradem"; + default -> "Brak danych"; + }; + } +} diff --git a/backend/src/main/java/home/backend/domain/WeatherView.java b/backend/src/main/java/home/backend/domain/WeatherView.java new file mode 100644 index 0000000..b413b5c --- /dev/null +++ b/backend/src/main/java/home/backend/domain/WeatherView.java @@ -0,0 +1,27 @@ +package home.backend.domain; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * Weather at the house: three days, the temperature now and the IMGW warnings in force or announced. + * {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while + * there are too few measured days to calibrate. {@code stale} means part of it is an older answer + * because a source could not be reached. + */ +public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now, + List warnings, Double pvFactor, boolean stale) { + + public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin, + Double precipitationMm, Double sunshineHours, Double pvForecastKwh) { + } + + public record Now(Double temperature) { + } + + /** {@code level} is IMGW's degree 1 to 3. */ + public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) { + } +} diff --git a/backend/src/test/java/home/backend/adapters/http/ApiServerTest.java b/backend/src/test/java/home/backend/adapters/http/ApiServerTest.java index f21e5b8..f48acfc 100644 --- a/backend/src/test/java/home/backend/adapters/http/ApiServerTest.java +++ b/backend/src/test/java/home/backend/adapters/http/ApiServerTest.java @@ -26,9 +26,12 @@ import home.backend.application.HeatPumpService; import home.backend.application.HistoryRepository; import home.backend.application.HistoryService; import home.backend.application.PiSensorService; +import home.backend.application.WeatherService; +import home.backend.domain.Forecast; import home.backend.domain.GatewaySnapshot; import home.backend.domain.GatewaySnapshot.DeviceSnapshot; import home.backend.domain.GatewaySnapshot.Measurement; +import home.backend.domain.WeatherView.Warning; import home.backend.domain.HistoryView.Point; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -44,11 +47,14 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -178,7 +184,9 @@ class ApiServerTest { new EnergyDetailsService(gateway, clock), new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock), new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock), - new BatteryService(gateway, batteryCommands, clock)); + new BatteryService(gateway, batteryCommands, clock), + new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()), + (from, to) -> exampleMeasuredPv(), clock)); server.start(); } @@ -400,6 +408,37 @@ class ApiServerTest { assertTrue(batterySent.isEmpty()); } + private static Forecast exampleForecast() { + Map days = new TreeMap<>(); + for (int i = 0; i < 5; i++) { + days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0)); + } + days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0)); + days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0)); + days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0)); + return new Forecast(10.6, days); + } + + /** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */ + private static Map exampleMeasuredPv() { + Map measured = new HashMap<>(); + for (int i = 0; i < 5; i++) { + measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0); + } + return measured; + } + + private static Warning exampleWarning() { + return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"), + OffsetDateTime.parse("2026-10-25T18:00:00+01:00"), + "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h."); + } + + @Test + void weatherMatchesExample() throws Exception { + assertMatchesExample("/api/weather", "weather"); + } + @Test void piSensorMatchesExample()throws Exception { assertMatchesExample("/api/pi-sensor", "pi-sensor"); diff --git a/backend/src/test/java/home/backend/adapters/influx/InfluxHistoryRepositoryTest.java b/backend/src/test/java/home/backend/adapters/influx/InfluxHistoryRepositoryTest.java index 50baaf7..49f62f1 100644 --- a/backend/src/test/java/home/backend/adapters/influx/InfluxHistoryRepositoryTest.java +++ b/backend/src/test/java/home/backend/adapters/influx/InfluxHistoryRepositoryTest.java @@ -216,7 +216,7 @@ class InfluxHistoryRepositoryTest { private InfluxHistoryRepository repository(Duration timeout) { URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort()); - return new InfluxHistoryRepository(new InfluxSettings(base, timeout)); + return new InfluxHistoryRepository(new InfluxHttp(new InfluxSettings(base, timeout))); } private static String series(Object... timeAndValue) { diff --git a/backend/src/test/java/home/backend/adapters/influx/InfluxPvEnergyTest.java b/backend/src/test/java/home/backend/adapters/influx/InfluxPvEnergyTest.java new file mode 100644 index 0000000..de5496e --- /dev/null +++ b/backend/src/test/java/home/backend/adapters/influx/InfluxPvEnergyTest.java @@ -0,0 +1,89 @@ +package home.backend.adapters.influx; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InfluxPvEnergyTest { + + private static final LocalDate FROM = LocalDate.parse("2026-10-19"); + private static final LocalDate TO = LocalDate.parse("2026-10-24"); + + private HttpServer server; + private InfluxPvEnergy pv; + private final List queries = new ArrayList<>(); + private int status = 200; + /** Day buckets start at Warsaw midnight: 2026-10-19 is 2026-10-18T22:00Z. */ + private String deye = rows("1792360800000", 22.5, "1792447200000", 8.0); + private String fronius = rows("1792360800000", 4400.0, "1792533600000", 100.0); + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/query", exchange -> { + String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8); + queries.add(query); + byte[] bytes = (query.contains("\"deye\"") ? deye : fronius).getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + var settings = new InfluxSettings(URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + Duration.ofSeconds(2)); + pv = new InfluxPvEnergy(new InfluxHttp(settings)); + } + + @AfterEach + void stop() { + server.stop(0); + } + + private static String rows(String t1, double v1, String t2, double v2) { + return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"max\"]," + + "\"values\":[[" + t1 + "," + v1 + "],[" + t2 + "," + v2 + "]]}]}]}"; + } + + @Test + void addsTheDeyeAndFroniusDayCountersPerLocalDay() throws IOException { + Map days = pv.dailyKwh(FROM, TO); + + assertEquals(Map.of(FROM, 22.5 + 4.4), days); + } + + @Test + void asksForTheDailyMaximumOfEachCounterInWarsawDays() throws IOException { + pv.dailyKwh(FROM, TO); + + assertEquals(2, queries.size()); + String deyeQuery = queries.stream().filter(q -> q.contains("\"deye\"")).findFirst().orElseThrow(); + assertTrue(deyeQuery.contains("db=electricity"), deyeQuery); + assertTrue(deyeQuery.contains("SELECT max(\"pv_today_kwh\") FROM \"deye\" WHERE time >= 1792360800000ms" + + " AND time < 1792792800000ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"), deyeQuery); + assertTrue(queries.stream().anyMatch(q -> q.contains("max(\"energy_day_wh\") FROM \"fronius\"")), queries.toString()); + } + + @Test + void failureIsAnIoError() { + status = 500; + + assertThrows(IOException.class, () -> pv.dailyKwh(FROM, TO)); + } +} diff --git a/backend/src/test/java/home/backend/adapters/weather/WeatherClientsTest.java b/backend/src/test/java/home/backend/adapters/weather/WeatherClientsTest.java new file mode 100644 index 0000000..74aebf5 --- /dev/null +++ b/backend/src/test/java/home/backend/adapters/weather/WeatherClientsTest.java @@ -0,0 +1,185 @@ +package home.backend.adapters.weather; + +import com.sun.net.httpserver.HttpServer; +import home.backend.domain.Forecast; +import home.backend.domain.WeatherView.Warning; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** The two clients against a local server that answers with responses recorded from the real services. */ +class WeatherClientsTest { + + private static final WeatherSettings PLACE = WeatherSettings.fromEnvironment(Map.of()); + private static final Duration TIMEOUT = Duration.ofSeconds(2); + + private HttpServer server; + private URI base; + private final List requests = new ArrayList<>(); + private int status = 200; + private String body = ""; + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/", exchange -> { + requests.add(exchange.getRequestURI().toString()); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + base = URI.create("http://127.0.0.1:" + server.getAddress().getPort()); + } + + @AfterEach + void stop() { + server.stop(0); + } + + private static String fixture(String name) throws IOException { + return Files.readString(Path.of("src/test/resources/weather", name)); + } + + @Test + void readsTheOpenMeteoForecastForTheHouse() throws IOException { + body = fixture("open-meteo.json"); + + Forecast forecast = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast(); + + assertEquals(10.6, forecast.temperature()); + assertEquals(17, forecast.days().size()); + var today = forecast.days().get(LocalDate.parse("2026-10-24")); + assertEquals(3, today.weatherCode()); + assertEquals(10.9, today.tMax()); + assertEquals(3.8, today.tMin()); + assertEquals(6.88, today.radiationMj()); + assertEquals(7151.32, today.sunshineSeconds()); + assertEquals(8.4, forecast.days().get(LocalDate.parse("2026-10-26")).precipitationMm()); + String request = requests.getFirst(); + for (String part : List.of("latitude=49.684", "longitude=21.77", "timezone=Europe%2FWarsaw", + "past_days=14", "shortwave_radiation_sum", "sunshine_duration")) { + assertEquals(true, request.contains(part), part + " in " + request); + } + } + + @Test + void aMissingValueInTheForecastIsNull() throws IOException { + body = """ + {"daily":{"time":["2026-10-24"],"weather_code":[null],"temperature_2m_max":[1.5]}}"""; + + var day = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast().days().get(LocalDate.parse("2026-10-24")); + + assertNull(day.weatherCode()); + assertEquals(1.5, day.tMax()); + assertNull(day.tMin()); + assertNull(day.radiationMj()); + } + + @Test + void anOpenMeteoFailureIsAnIoError() { + body = "{\"error\":true}"; + status = 500; + var client = new OpenMeteoClient(base, PLACE, TIMEOUT); + assertThrows(IOException.class, client::forecast); + + status = 200; + body = "not json"; + assertThrows(IOException.class, client::forecast); + + body = "{\"daily\":{}}"; + assertThrows(IOException.class, client::forecast); + } + + @Test + void keepsWarningsThatCoverTheCityOrThePowiatAndMapsThem() throws IOException { + body = fixture("imgw-warnings.json"); + + List warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings(); + + assertEquals("/api/data/warningsmeteo", requests.getFirst()); + assertEquals(2, warnings.size()); + Warning city = warnings.get(0); + assertEquals("Silny wiatr", city.event()); + assertEquals(2, city.level()); + assertEquals(OffsetDateTime.parse("2026-10-25T08:00:00+01:00"), city.from()); + assertEquals(OffsetDateTime.parse("2026-10-25T18:00:00+01:00"), city.to()); + assertEquals("Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, " + + "w porywach do 90 km/h, z południa.", city.text()); + assertEquals(1, warnings.get(1).level()); + } + + @Test + void otherAreasAreIgnoredAndTerytIsConfigurable() throws IOException { + body = fixture("imgw-warnings.json"); + var cityOnly = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("1862")); + var elsewhere = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("2804")); + + assertEquals(1, new ImgwClient(base, cityOnly, TIMEOUT).warnings().size()); + assertEquals(1, new ImgwClient(base, elsewhere, TIMEOUT).warnings().size()); + assertEquals(0, new ImgwClient(base, new WeatherSettings(0, 0, List.of("0000")), TIMEOUT).warnings().size()); + } + + @Test + void aMalformedWarningIsSkippedNotFatal() throws IOException { + body = """ + [{"nazwa_zdarzenia":"Burze","stopien":"x","obowiazuje_od":"2026-10-25 08:00:00", + "obowiazuje_do":"2026-10-25 18:00:00","tresc":"a","teryt":["1862"]}, + {"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"soon", + "obowiazuje_do":"2026-10-25 18:00:00","tresc":"b","teryt":["1862"]}, + {"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"2026-10-25 08:00:00", + "obowiazuje_do":"2026-10-25 18:00:00","tresc":"c","teryt":["1862"]}]"""; + + List warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings(); + + assertEquals(1, warnings.size()); + assertEquals("c", warnings.getFirst().text()); + } + + @Test + void anImgwFailureIsAnIoError() { + body = "{}"; + var client = new ImgwClient(base, PLACE, TIMEOUT); + assertThrows(IOException.class, client::warnings); + + status = 503; + assertThrows(IOException.class, client::warnings); + } + + @Test + void settingsDefaultToKrosnoAndRejectNonsense() { + assertEquals(49.684, PLACE.latitude()); + assertEquals(21.770, PLACE.longitude()); + assertEquals(List.of("1862", "1807"), PLACE.teryt()); + + var custom = WeatherSettings.fromEnvironment( + Map.of("WEATHER_LAT", "50.1", "WEATHER_LON", "-20", "WEATHER_TERYT", "1465, 1401")); + assertEquals(50.1, custom.latitude()); + assertEquals(-20.0, custom.longitude()); + assertEquals(List.of("1465", "1401"), custom.teryt()); + + assertThrows(IllegalArgumentException.class, + () -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LAT", "north"))); + assertThrows(IllegalArgumentException.class, + () -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LON", "200"))); + } +} diff --git a/backend/src/test/java/home/backend/application/WeatherServiceTest.java b/backend/src/test/java/home/backend/application/WeatherServiceTest.java new file mode 100644 index 0000000..e061ed2 --- /dev/null +++ b/backend/src/test/java/home/backend/application/WeatherServiceTest.java @@ -0,0 +1,231 @@ +package home.backend.application; + +import home.backend.domain.Forecast; +import home.backend.domain.Forecast.DailyForecast; +import home.backend.domain.WeatherView; +import home.backend.domain.WeatherView.Warning; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WeatherServiceTest { + + private static final Instant START = Instant.parse("2026-10-24T10:00:00Z"); + private static final LocalDate TODAY = LocalDate.parse("2026-10-24"); + + private Instant now = START; + private final Clock clock = new Clock() { + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return Clock.fixed(now, zone); + } + + @Override + public Instant instant() { + return now; + } + }; + + private final AtomicInteger forecastCalls = new AtomicInteger(); + private final AtomicInteger warningCalls = new AtomicInteger(); + private boolean forecastDown; + private boolean warningsDown; + private boolean influxDown; + private int measuredDays = 5; + private List warned = List.of(warning(START.plus(Duration.ofHours(8)))); + + private final WeatherService service = new WeatherService( + () -> { + forecastCalls.incrementAndGet(); + if (forecastDown) { + throw new IOException("open-meteo down"); + } + return forecast(); + }, + () -> { + warningCalls.incrementAndGet(); + if (warningsDown) { + throw new IOException("imgw down"); + } + return warned; + }, + (from, to) -> { + if (influxDown) { + throw new IOException("influx down"); + } + assertEquals(TODAY.minusDays(14), from); + assertEquals(TODAY, to); + Map measured = new HashMap<>(); + for (int i = 1; i <= measuredDays; i++) { + measured.put(TODAY.minusDays(i), 15.0); + } + return measured; + }, + clock); + + private static Forecast forecast() { + Map days = new TreeMap<>(); + for (int i = 1; i <= 14; i++) { + days.put(TODAY.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0)); + } + days.put(TODAY, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0)); + days.put(TODAY.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null)); + days.put(TODAY.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0)); + return new Forecast(8.5, days); + } + + private static Warning warning(Instant until) { + return new Warning("Burza", 2, OffsetDateTime.ofInstant(START, ZoneOffset.UTC), + OffsetDateTime.ofInstant(until, ZoneOffset.UTC), "text"); + } + + @Test + void shapesThreeDaysAndEstimatesPvFromTheFactor() throws IOException { + WeatherView view = service.current(); + + assertEquals(START, view.time()); + assertEquals(8.5, view.now().temperature()); + assertEquals(3.0, view.pvFactor(), 1e-9); + assertEquals(TODAY, view.today().date()); + assertEquals("Bezchmurnie", view.today().description()); + assertEquals(12.34, view.today().tMax()); + assertEquals(1.5, view.today().sunshineHours()); + assertEquals(18.0, view.today().pvForecastKwh()); + assertEquals(TODAY.plusDays(2), view.dayAfter().date()); + assertEquals("Deszcz", view.tomorrow().description()); + assertEquals(5.5, view.tomorrow().precipitationMm()); + assertNull(view.tomorrow().pvForecastKwh()); + assertEquals(3.0, view.dayAfter().pvForecastKwh()); + assertEquals("Brak danych", view.dayAfter().description()); + assertFalse(view.stale()); + } + + @Test + void usesTheWarsawDateNearMidnight() throws IOException { + now = Instant.parse("2026-10-23T22:30:00Z"); + + assertEquals(TODAY, service.current().today().date()); + } + + @Test + void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException { + measuredDays = 4; + + WeatherView view = service.current(); + + assertNull(view.pvFactor()); + assertNull(view.today().pvForecastKwh()); + assertEquals(12.34, view.today().tMax()); + } + + @Test + void anUnreachableInfluxGivesNoEstimateButStillTheWeather() throws IOException { + influxDown = true; + + WeatherView view = service.current(); + + assertNull(view.pvFactor()); + assertEquals(0, view.today().weatherCode()); + assertFalse(view.stale()); + } + + @Test + void keepsTheForecastFor30MinutesAndWarningsFor10() throws IOException { + service.current(); + now = START.plus(Duration.ofMinutes(9)); + service.current(); + assertEquals(1, warningCalls.get()); + + now = START.plus(Duration.ofMinutes(10)); + service.current(); + assertEquals(2, warningCalls.get()); + assertEquals(1, forecastCalls.get()); + + now = START.plus(Duration.ofMinutes(30)); + service.current(); + assertEquals(2, forecastCalls.get()); + } + + @Test + void servesTheOlderForecastMarkedStaleWhenTheRefreshFails() throws IOException { + service.current(); + forecastDown = true; + now = START.plus(Duration.ofMinutes(31)); + + WeatherView view = service.current(); + + assertTrue(view.stale()); + assertEquals(8.5, view.now().temperature()); + assertEquals(3.0, view.pvFactor(), 1e-9); + } + + @Test + void servesTheOlderWarningsMarkedStaleWhenTheRefreshFails() throws IOException { + service.current(); + warningsDown = true; + now = START.plus(Duration.ofMinutes(11)); + + WeatherView view = service.current(); + + assertTrue(view.stale()); + assertEquals(1, view.warnings().size()); + } + + @Test + void withoutAnyForecastItFails() { + forecastDown = true; + + assertThrows(IOException.class, service::current); + } + + @Test + void withoutAnyWarningsItStillAnswersMarkedStale() throws IOException { + warningsDown = true; + + WeatherView view = service.current(); + + assertTrue(view.warnings().isEmpty()); + assertTrue(view.stale()); + assertEquals(12.34, view.today().tMax()); + } + + @Test + void dropsWarningsThatHaveEnded() throws IOException { + warned = List.of(warning(START.plus(Duration.ofMinutes(5))), warning(START.plus(Duration.ofHours(8)))); + + assertEquals(2, service.current().warnings().size()); + + now = START.plus(Duration.ofMinutes(6)); + assertEquals(1, service.current().warnings().size()); + } + + @Test + void aForecastWithoutTodayFails() { + var short_ = new WeatherService(() -> new Forecast(null, Map.of()), List::of, (from, to) -> Map.of(), clock); + + assertThrows(IOException.class, short_::current); + } +} diff --git a/backend/src/test/java/home/backend/domain/WeatherDomainTest.java b/backend/src/test/java/home/backend/domain/WeatherDomainTest.java new file mode 100644 index 0000000..766b988 --- /dev/null +++ b/backend/src/test/java/home/backend/domain/WeatherDomainTest.java @@ -0,0 +1,84 @@ +package home.backend.domain; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class WeatherDomainTest { + + private static final LocalDate DAY = LocalDate.parse("2026-10-10"); + + @Test + void describesWmoCodesInPolish() { + assertEquals("Bezchmurnie", WeatherCodes.describe(0)); + assertEquals("Pochmurno", WeatherCodes.describe(3)); + assertEquals("Mgła", WeatherCodes.describe(45)); + assertEquals("Mżawka", WeatherCodes.describe(53)); + assertEquals("Deszcz", WeatherCodes.describe(63)); + assertEquals("Śnieg", WeatherCodes.describe(73)); + assertEquals("Przelotny deszcz", WeatherCodes.describe(81)); + assertEquals("Burza", WeatherCodes.describe(95)); + assertEquals("Burza z gradem", WeatherCodes.describe(99)); + } + + @Test + void anUnknownOrMissingCodeIsNoData() { + assertEquals("Brak danych", WeatherCodes.describe(42)); + assertEquals("Brak danych", WeatherCodes.describe(null)); + } + + @Test + void calibratesAsTotalMeasuredOverTotalRadiation() { + var measured = new HashMap(); + var radiation = new HashMap(); + for (int i = 0; i < 5; i++) { + measured.put(DAY.plusDays(i), 10.0 + i); + radiation.put(DAY.plusDays(i), 5.0 + i); + } + + // 60 kWh over 35 MJ/m², not the mean of the daily ratios. + assertEquals(60.0 / 35.0, PvCalibration.factor(measured, radiation), 1e-9); + } + + @Test + void daysMissingEitherValueDoNotCount() { + Map measured = new HashMap<>(); + Map radiation = new HashMap<>(); + for (int i = 0; i < 5; i++) { + measured.put(DAY.plusDays(i), 10.0); + radiation.put(DAY.plusDays(i), 5.0); + } + measured.put(DAY.plusDays(9), 1000.0); + radiation.put(DAY.plusDays(8), 1000.0); + + assertEquals(2.0, PvCalibration.factor(measured, radiation), 1e-9); + } + + @Test + void fewerThanFiveDaysGiveNoFactor() { + Map measured = new HashMap<>(); + Map radiation = new HashMap<>(); + for (int i = 0; i < 4; i++) { + measured.put(DAY.plusDays(i), 10.0); + radiation.put(DAY.plusDays(i), 5.0); + } + + assertNull(PvCalibration.factor(measured, radiation)); + } + + @Test + void noRadiationGivesNoFactor() { + Map days = new HashMap<>(); + for (int i = 0; i < 5; i++) { + days.put(DAY.plusDays(i), 0.0); + } + + assertNull(PvCalibration.factor(Map.of(), days)); + assertNull(PvCalibration.factor(days, days)); + } +} diff --git a/backend/src/test/resources/api/weather.json b/backend/src/test/resources/api/weather.json new file mode 100644 index 0000000..de91b3b --- /dev/null +++ b/backend/src/test/resources/api/weather.json @@ -0,0 +1,14 @@ +{ + "time": "2026-10-23T22:14:00Z", + "today": {"date": "2026-10-24", "weatherCode": 3, "description": "Pochmurno", "tMax": 10.9, "tMin": 3.8, + "precipitationMm": 0.0, "sunshineHours": 2.0, "pvForecastKwh": 18.0}, + "tomorrow": {"date": "2026-10-25", "weatherCode": 51, "description": "Mżawka", "tMax": 13.2, "tMin": 4.0, + "precipitationMm": 0.3, "sunshineHours": 7.9, "pvForecastKwh": 9.0}, + "dayAfter": {"date": "2026-10-26", "weatherCode": 61, "description": "Słaby deszcz", "tMax": 9.6, "tMin": 4.1, + "precipitationMm": 8.4, "sunshineHours": 0.0, "pvForecastKwh": 4.2}, + "now": {"temperature": 10.6}, + "warnings": [{"event": "Silny wiatr", "level": 2, "from": "2026-10-25T08:00:00+01:00", + "to": "2026-10-25T18:00:00+01:00", "text": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h."}], + "pvFactor": 2.0, + "stale": false +} diff --git a/backend/src/test/resources/weather/imgw-warnings.json b/backend/src/test/resources/weather/imgw-warnings.json new file mode 100644 index 0000000..ddda3b4 --- /dev/null +++ b/backend/src/test/resources/weather/imgw-warnings.json @@ -0,0 +1,80 @@ +[ + { + "id": "Sk20261024094211181", + "nazwa_zdarzenia": "Silny wiatr", + "stopien": "2", + "prawdopodobienstwo": "75", + "obowiazuje_do": "2026-10-25 18:00:00", + "obowiazuje_od": "2026-10-25 08:00:00", + "opublikowano": "2026-10-24 11:42:00", + "tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, w porywach do 90 km/h, z południa.", + "komentarz": "Brak.", + "biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie", + "teryt": [ + "0221", + "0207", + "0206", + "0212", + "0261", + "0265", + "1862" + ] + }, + { + "id": "Sk20261024094212544", + "nazwa_zdarzenia": "Silny wiatr", + "stopien": "1", + "prawdopodobienstwo": "75", + "obowiazuje_do": "2026-10-25 18:00:00", + "obowiazuje_od": "2026-10-25 08:00:00", + "opublikowano": "2026-10-24 11:42:00", + "tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 85 km/h, z południa.", + "komentarz": "Brak.", + "biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie", + "teryt": [ + "1807" + ] + }, + { + "id": "Sk20261024094213861", + "nazwa_zdarzenia": "Silny wiatr", + "stopien": "1", + "prawdopodobienstwo": "75", + "obowiazuje_do": "2026-10-25 19:00:00", + "obowiazuje_od": "2026-10-25 12:00:00", + "opublikowano": "2026-10-24 11:42:00", + "tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 75 km/h, z południa.", + "komentarz": "Brak.", + "biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie", + "teryt": [ + "2804", + "2861", + "2802" + ] + }, + { + "id": "Sk20261024094216930", + "nazwa_zdarzenia": "Silny wiatr", + "stopien": "1", + "prawdopodobienstwo": "80", + "obowiazuje_do": "2026-10-25 21:00:00", + "obowiazuje_od": "2026-10-25 10:00:00", + "opublikowano": "2026-10-24 11:42:00", + "tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 80 km/h, z południa.", + "komentarz": "Brak.", + "biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie", + "teryt": [ + "1205", + "2402", + "1210", + "1217", + "1207", + "2461", + "2403", + "1211", + "1215", + "1262", + "2417" + ] + } +] \ No newline at end of file diff --git a/backend/src/test/resources/weather/open-meteo.json b/backend/src/test/resources/weather/open-meteo.json new file mode 100644 index 0000000..837172d --- /dev/null +++ b/backend/src/test/resources/weather/open-meteo.json @@ -0,0 +1 @@ +{"latitude":49.68106,"longitude":21.75624,"generationtime_ms":0.3905296325683594,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":282.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C"},"current":{"time":"2026-10-24T16:45","interval":900,"temperature_2m":10.6},"daily_units":{"time":"iso8601","weather_code":"wmo code","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm","shortwave_radiation_sum":"MJ/m²","sunshine_duration":"s"},"daily":{"time":["2026-10-10","2026-10-11","2026-10-12","2026-10-13","2026-10-14","2026-10-15","2026-10-16","2026-10-17","2026-10-18","2026-10-19","2026-10-20","2026-10-21","2026-10-22","2026-10-23","2026-10-24","2026-10-25","2026-10-26"],"weather_code":[3,63,3,3,51,51,3,51,3,3,3,3,63,3,3,51,61],"temperature_2m_max":[17.8,14.4,12.6,10.3,13.3,11.9,12.8,11.0,13.0,10.6,15.5,19.1,11.1,13.5,10.9,13.2,9.6],"temperature_2m_min":[8.7,8.8,5.1,3.3,0.8,5.5,4.6,3.0,6.8,6.6,7.8,7.3,8.4,4.6,3.8,4.0,4.1],"precipitation_sum":[0.00,15.00,0.00,0.00,0.50,0.30,0.00,0.10,0.00,0.00,0.00,0.00,27.70,0.00,0.00,0.30,8.40],"shortwave_radiation_sum":[9.44,3.23,10.22,6.92,8.53,5.71,9.71,4.36,9.99,2.90,8.72,10.19,0.20,10.33,6.88,8.92,2.10],"sunshine_duration":[14400.00,0.00,12427.89,0.00,21009.37,355.43,7848.32,0.00,11931.89,0.00,11020.14,33520.17,0.00,34772.52,7151.32,28314.23,0.00]}} \ No newline at end of file diff --git a/docs/spec.md b/docs/spec.md index 43fc479..27074b1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -95,6 +95,8 @@ Functional: - F8 Battery settings (ADR-013, off until enabled): change the Deye's Time of Use switch and the six slots (start, SOC, grid charge) from the Bateria view, and the app's backup reserve (default 50 %, floor 30 %) that every enabled slot SOC must hold. Refused when the settings reading is older than 30 min or changed meanwhile; read back, no restore. +- F9 Weather and warnings: a 3-day forecast (Open-Meteo) with a PV estimate calibrated on the last 14 measured days, and IMGW warnings for Krosno, as a card and banners on Podgląd; a storm or wind warning hints on Bateria to keep the battery full. Read-only, from the backend in Kraków. + Safety, for every write: - S1 Only allowlisted registers, each with min, max and step. diff --git a/frontend/README.md b/frontend/README.md index 285b408..72d3f0d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -23,3 +23,5 @@ opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falo In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI checks the gateway's rules first (levels from the reserve to 100 %, starts increasing). + +Podgląd shows the "Pogoda" card (3 days, PV estimate) and one banner per IMGW warning from `api/weather`; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself). diff --git a/frontend/src/Battery.tsx b/frontend/src/Battery.tsx index d2aa13e..62a6ec6 100644 --- a/frontend/src/Battery.tsx +++ b/frontend/src/Battery.tsx @@ -5,6 +5,7 @@ import { usePoll } from "./poll"; import type { Battery as BatteryData } from "./types"; import { Reserve, Schedule, type Notice } from "./BatterySettings"; import { Banner, Card, Freshness, Loading, Row } from "./ui"; +import { threatensPower, useWeather } from "./Weather"; /** What the battery does, in one word: + discharge / − charge. */ export function batteryAction(watts: number | null) { @@ -89,10 +90,14 @@ function Totals({ b }: { b: BatteryData }) { export function Battery() { const { data: b, offline } = usePoll("api/battery"); const [notice, setNotice] = useState(null); + const weather = useWeather(); if (!b) return <>{!offline && }; return ( <> + {weather?.warnings.some(threatensPower) && ( + Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię). + )} {notice && {notice.text}}
diff --git a/frontend/src/Overview.tsx b/frontend/src/Overview.tsx index e338257..a2290f4 100644 --- a/frontend/src/Overview.tsx +++ b/frontend/src/Overview.tsx @@ -7,6 +7,7 @@ import { Legend, TimeChart, toRows, type Line } from "./TimeChart"; import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types"; import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui"; import { EnergyToday, gridText } from "./Energy"; +import { useWeather, WeatherCard, WeatherWarnings } from "./Weather"; const HEADLINES: Record = { IDLE: "Pompa odpoczywa", @@ -171,6 +172,7 @@ function NoReading() { export function Overview() { const { data: hp, offline } = usePoll("api/heat-pump"); const { data: e } = usePoll("api/energy"); + const weather = useWeather(); const now = hp?.time ? new Date(hp.time) : null; const title = ( ALARM pompy ciepła. {hp.alarmText ? `${hp.alarmText}. ` : ""}Sprawdź sterownik w domu.
)} + {weather && } {hp.time == null ? : (
} className="lg:col-span-2 lg:row-span-2"> @@ -228,6 +231,7 @@ export function Overview() {
+ {weather && } diff --git a/frontend/src/Weather.test.tsx b/frontend/src/Weather.test.tsx new file mode 100644 index 0000000..f82ca38 --- /dev/null +++ b/frontend/src/Weather.test.tsx @@ -0,0 +1,126 @@ +import { render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { example, serve } from "./fixtures"; +import type { HeatPump, Weather } from "./types"; + +const weather = () => example("weather"); +const warning = (event: string, level: number) => ({ ...weather().warnings[0], event, level }); +const history = { series: "x", unit: "°C", points: [] }; + +async function overview(patch: Partial = {}) { + serve({ "api/heat-pump": { ...example("heat-pump"), time: new Date().toISOString() }, + "api/energy": example("energy"), "api/history": history, "api/pi-sensor": example("pi-sensor"), + "api/weather": { ...weather(), ...patch } }); + const { Overview } = await import("./Overview"); + render(); +} + +beforeEach(() => { + location.hash = ""; +}); + +describe("Pogoda card", () => { + it("shows three days with temperatures, rain and the PV estimate", async () => { + await overview(); + + const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!; + const [today, tomorrow, dayAfter] = within(card).getAllByRole("listitem"); + expect(card).toHaveTextContent("teraz 10,6 °C"); + expect(today).toHaveTextContent("Dziś"); + expect(today).toHaveTextContent("Pochmurno"); + expect(today).toHaveTextContent("10,9 °C / 3,8 °C"); + expect(today).toHaveTextContent("Opady: 0,0 mm"); + expect(today).toHaveTextContent("PV ok. 18 kWh"); + expect(tomorrow).toHaveTextContent("Jutro"); + expect(tomorrow).toHaveTextContent("Opady: 0,3 mm"); + expect(dayAfter).toHaveTextContent("Pojutrze"); + expect(dayAfter).toHaveTextContent("PV ok. 4 kWh"); + }); + + it("draws the picture of the weather code", async () => { + await overview(); + + const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!; + expect(within(card).getAllByRole("img").map((i) => i.getAttribute("aria-label"))).toEqual(["chmury", "deszcz", "deszcz"]); + }); + + it("leaves out the PV estimate when the backend has none", async () => { + const w = weather(); + await overview({ pvFactor: null, today: { ...w.today, pvForecastKwh: null } }); + + const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!; + expect(within(card).getAllByText(/PV ok\./)).toHaveLength(2); + }); + + it("says when the weather is older than it should be", async () => { + await overview({ stale: true }); + + expect(await screen.findByText(/pokazuję starsze dane/)).toBeInTheDocument(); + }); + + it("is absent without an answer from the weather API", async () => { + serve({ "api/heat-pump": { ...example("heat-pump"), time: new Date().toISOString() }, + "api/energy": example("energy"), "api/history": history, "api/pi-sensor": example("pi-sensor") }); + const { Overview } = await import("./Overview"); + render(); + + await screen.findByRole("heading", { name: "Salon" }); + expect(screen.queryByRole("heading", { name: "Pogoda" })).not.toBeInTheDocument(); + }); +}); + +describe("IMGW warnings on the overview", () => { + it("shows each warning as a banner with its degree, period and text", async () => { + await overview(); + + const banner = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/); + expect(banner.closest("[role=alert]")).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru"); + expect(banner.closest("[role=alert]")).toHaveClass("border-orange-500"); + }); + + it("colours the degrees yellow, orange and red", async () => { + await overview({ warnings: [warning("Mróz", 1), warning("Upał", 2), warning("Burza", 3)] }); + + await screen.findByText(/stopień 3: Burza/); + expect(screen.getByText(/stopień 1: Mróz/).closest("[role=alert]")).toHaveClass("border-yellow-500"); + expect(screen.getByText(/stopień 2: Upał/).closest("[role=alert]")).toHaveClass("border-orange-500"); + expect(screen.getByText(/stopień 3: Burza/).closest("[role=alert]")).toHaveClass("border-alarm"); + }); + + it("shows no banner without warnings", async () => { + await overview({ warnings: [] }); + + await screen.findByRole("heading", { name: "Pogoda" }); + expect(screen.queryByText(/Ostrzeżenie IMGW/)).not.toBeInTheDocument(); + }); +}); + +describe("IMGW hint on the battery", () => { + async function battery(warnings: Weather["warnings"]) { + serve({ "api/battery": example("battery"), "api/weather": { ...weather(), warnings } }); + const { Battery } = await import("./Battery"); + render(); + await screen.findByText("Bateria teraz"); + } + + const HINT = "Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię)."; + + it("suggests keeping the battery full for strong wind", async () => { + await battery([warning("Silny wiatr", 1)]); + + expect(await screen.findByText(HINT)).toBeInTheDocument(); + }); + + it("suggests it for a thunderstorm too", async () => { + await battery([warning("Burze z gradem", 2)]); + + expect(await screen.findByText(HINT)).toBeInTheDocument(); + }); + + it("stays quiet for other warnings", async () => { + await battery([warning("Upał", 2)]); + + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText(HINT)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/Weather.tsx b/frontend/src/Weather.tsx new file mode 100644 index 0000000..59674a4 --- /dev/null +++ b/frontend/src/Weather.tsx @@ -0,0 +1,107 @@ +import { celsius, dateTime, number } from "./format"; +import { Icon } from "./icons"; +import { usePoll } from "./poll"; +import type { Weather, WeatherDay, WeatherWarning } from "./types"; +import { Card } from "./ui"; + +const WEATHER_MS = 5 * 60000; // the backend keeps the forecast 30 min and the warnings 10 min + +export const useWeather = () => usePoll("api/weather", WEATHER_MS).data; + +type Kind = "sun" | "partly" | "cloud" | "fog" | "rain" | "snow" | "storm"; + +// WMO weather interpretation codes grouped by what the picture shows. +function kindOf(code: number | null): Kind | null { + if (code == null) return null; + if (code <= 1) return "sun"; + if (code === 2) return "partly"; + if (code === 3) return "cloud"; + if (code === 45 || code === 48) return "fog"; + if (code >= 95) return "storm"; + if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow"; + return "rain"; +} + +const CLOUD = "M7 18a4 4 0 0 1-.6-7.96A5.5 5.5 0 0 1 17 9.5a4.2 4.2 0 0 1 0 8.5z"; +const SUN = "M12 15a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 11.5h2M20 11.5h2M4.9 18.1l1.4-1.4M17.7 5.3l1.4-1.4"; + +const PICTURES: Record = { + sun: { label: "słońce", paths: [SUN], colour: "text-amber-500" }, + partly: { label: "słońce za chmurami", paths: ["M9 3v1.5M3.6 5.6l1.1 1.1M2 11h1.5M14.4 5.6l-1.1 1.1", "M7 20a3.5 3.5 0 0 1-.4-7A4.8 4.8 0 0 1 16 12.5a3.7 3.7 0 0 1 0 7.5z"], colour: "text-amber-500" }, + cloud: { label: "chmury", paths: [CLOUD], colour: "text-soft" }, + fog: { label: "mgła", paths: ["M4 9h16M3 13h18M5 17h14M8 21h8"], colour: "text-soft" }, + rain: { label: "deszcz", paths: [CLOUD, "M8 21l1-2M12 21l1-2M16 21l1-2"], colour: "text-cold" }, + snow: { label: "śnieg", paths: [CLOUD, "M8 20.5h.01M12 21h.01M16 20.5h.01M10 23h.01M14 23h.01"], colour: "text-cold" }, + storm: { label: "burza", paths: [CLOUD, "M12.5 14l-2.5 4h3l-1.5 4"], colour: "text-alarm" }, +}; + +export function WeatherIcon({ code }: { code: number | null }) { + const kind = kindOf(code); + if (!kind) return —; + const { label, paths, colour } = PICTURES[kind]; + return ( + + {paths.map((d) => )} + + ); +} + +const DAY_NAMES = ["Dziś", "Jutro", "Pojutrze"]; + +function Day({ name, day }: { name: string; day: WeatherDay }) { + const rain = day.precipitationMm; + return ( +
  • + {name} + + {day.description} + {celsius(day.tMax)} / {celsius(day.tMin)} + Opady: {rain == null ? "—" : `${number(rain, 1)} mm`} + {day.pvForecastKwh != null && ( + PV ok. {number(day.pvForecastKwh)} kWh + )} +
  • + ); +} + +export function WeatherCard({ weather }: { weather: Weather }) { + const days = [weather.today, weather.tomorrow, weather.dayAfter]; + return ( + teraz {celsius(weather.now.temperature)}} + className="lg:col-span-2"> +
      + {days.map((day, i) => )} +
    + {weather.stale &&

    Nie udało się odświeżyć pogody, pokazuję starsze dane.

    } +
    + ); +} + +const LEVELS: Record = { + 1: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950/60 dark:text-yellow-200", + 2: "border-orange-500 bg-orange-50 text-orange-900 dark:bg-orange-950/60 dark:text-orange-200", + 3: "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200", +}; + +/** The IMGW warnings, one banner each, coloured by degree. The text is IMGW's own, shown as text. */ +export function WeatherWarnings({ warnings }: { warnings: WeatherWarning[] }) { + return ( + <> + {warnings.map((w) => ( +
    + +
    +

    Ostrzeżenie IMGW, stopień {w.level}: {w.event}

    +

    {dateTime(w.from)} – {dateTime(w.to)}

    +

    {w.text}

    +
    +
    + ))} + + ); +} + +/** A thunderstorm or strong wind can cut the power, which is when the battery is the house's UPS. */ +export const threatensPower = (w: WeatherWarning) => /burz|silny wiatr/i.test(w.event); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2bf49bc..43ce576 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -139,6 +139,37 @@ export interface Health { devices: { name: string; up: boolean; lastSuccess: string | null; lastError: string | null }[]; } +export interface WeatherDay { + date: string; + weatherCode: number | null; // WMO code + description: string; + tMax: number | null; + tMin: number | null; + precipitationMm: number | null; + sunshineHours: number | null; + pvForecastKwh: number | null; // null until the backend has enough measured days to calibrate +} + +/** An IMGW warning covering the house, in force or announced; level 1 to 3. */ +export interface WeatherWarning { + event: string; + level: number; + from: string; + to: string; + text: string; +} + +export interface Weather { + time: string; + today: WeatherDay; + tomorrow: WeatherDay; + dayAfter: WeatherDay; + now: { temperature: number | null }; + warnings: WeatherWarning[]; + pvFactor: number | null; + stale: boolean; +} + export interface Setpoint { name: string; register: number; From e4481eeeae3f73afaf7b6c2f915265a9d8004a49 Mon Sep 17 00:00:00 2001 From: Mariusz Pacek Date: Sat, 24 Oct 2026 17:05:01 +0200 Subject: [PATCH 2/2] chore(ci): rerun after a Maven download reset Co-Authored-By: Claude Opus 5.5