Merge PR #86: feat: weather, PV forecast and IMGW warnings
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
Release / Images (push) Successful in 3m1s

This commit is contained in:
root committed 2026-10-24 17:15:27 +02:00
commit 378f4f6623
32 files changed
+1595 -3

No files matched your search

+10
View File
@@ -33,6 +33,14 @@ inverter holds), 400 bad body, 503 gateway unreachable or without battery comman
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
`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`.
@@ -45,6 +53,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
@@ -6,7 +6,11 @@ import home.backend.adapters.gateway.GatewayHttpCommands;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.influx.InfluxAuditRepository;
import home.backend.adapters.influx.InfluxHistoryRepository;
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.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
@@ -18,14 +22,19 @@ import home.backend.application.StatsService;
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() {
}
@@ -47,7 +56,16 @@ final class Backend {
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 AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock));
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
weather(influxSettings, clock));
}
private static WeatherService weather(InfluxSettings 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). */
@@ -19,6 +19,7 @@ import home.backend.application.HistoryService;
import home.backend.application.StatsService;
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;
@@ -89,7 +90,8 @@ public class ApiServer {
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery, AuditService audit, StatsService stats) throws IOException {
BatteryService battery, AuditService audit, StatsService stats, 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).
@@ -107,6 +109,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/audit", exchange -> handleAudit(exchange, audit));
@@ -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 InfluxClient influx;
public InfluxPvEnergy(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
Map<LocalDate, Double> 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<LocalDate, Double> 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<LocalDate, Double> 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();
}
}
@@ -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<String> 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);
}
}
}
@@ -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<String> 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<Warning> warnings() throws IOException {
JsonNode root = http.get(uri);
if (!root.isArray()) {
throw new IOException("IMGW returned no list of warnings");
}
List<Warning> 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> 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();
}
}
@@ -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
+ "&current=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<LocalDate, DailyForecast> 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;
}
}
@@ -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<String> teryt) {
public static WeatherSettings fromEnvironment(Map<String, String> 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<String, String> 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);
}
}
@@ -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<T> {
interface Fetch<T> {
T fetch() throws IOException;
}
record Entry<T>(T value, boolean stale) {
}
private final Duration ttl;
private final Clock clock;
private final Fetch<T> source;
private T value;
private Instant fetchedAt;
Cached(Duration ttl, Clock clock, Fetch<T> 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<T> 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);
}
}
}
@@ -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;
}
@@ -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<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
}
@@ -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<Warning> warnings() throws IOException;
}
@@ -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<Calibrated> forecast;
private final Cached<List<Warning>> 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<Calibrated> weather = forecast.get();
Entry<List<Warning>> 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<List<Warning>> warnings() {
try {
return warnings.get();
} catch (IOException e) {
return new Entry<>(List.of(), true);
}
}
private List<Warning> inForce(List<Warning> 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<LocalDate, Double> 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;
}
}
@@ -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<LocalDate, DailyForecast> 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) {
}
}
@@ -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<LocalDate, Double> measuredKwh, Map<LocalDate, Double> 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;
}
}
@@ -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";
};
}
}
@@ -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<Warning> 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) {
}
}
@@ -30,9 +30,12 @@ import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
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.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
@@ -49,11 +52,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;
@@ -196,7 +202,9 @@ class ApiServerTest {
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 AuditService(auditRows(), clock), new StatsService(samples, clock));
new AuditService(auditRows(), clock), new StatsService(samples, clock),
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
(from, to) -> exampleMeasuredPv(), clock));
server.start();
}
@@ -439,6 +447,37 @@ class ApiServerTest {
assertTrue(batterySent.isEmpty());
}
private static Forecast exampleForecast() {
Map<LocalDate, Forecast.DailyForecast> 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<LocalDate, Double> exampleMeasuredPv() {
Map<LocalDate, Double> 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");
@@ -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<String> 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(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<LocalDate, Double> 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));
}
}
@@ -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<String> 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<Warning> 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<Warning> 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")));
}
}
@@ -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<Warning> 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<LocalDate, Double> 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<LocalDate, DailyForecast> 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);
}
}
@@ -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<LocalDate, Double>();
var radiation = new HashMap<LocalDate, Double>();
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<LocalDate, Double> measured = new HashMap<>();
Map<LocalDate, Double> 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<LocalDate, Double> measured = new HashMap<>();
Map<LocalDate, Double> 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<LocalDate, Double> 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));
}
}
@@ -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
}
@@ -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"
]
}
]
@@ -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]}}
+2
View File
@@ -96,6 +96,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.
+2
View File
@@ -26,6 +26,8 @@ In the Bateria view, `editable` from `api/battery` (the gateway takes battery ch
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).
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
+5
View File
@@ -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<BatteryData>("api/battery");
const [notice, setNotice] = useState<Notice>(null);
const weather = useWeather();
if (!b) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
return (
<>
<Freshness offline={offline} time={b.time} stale={b.stale} />
{weather?.warnings.some(threatensPower) && (
<Banner tone="warn">Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).</Banner>
)}
{notice && <Banner tone={notice.ok ? "ok" : "bad"}>{notice.text}</Banner>}
<div className="grid gap-4 lg:grid-cols-2">
<div className="lg:col-span-2"><Now b={b} /></div>
+4
View File
@@ -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<PumpState, string> = {
IDLE: "Pompa odpoczywa",
@@ -171,6 +172,7 @@ function NoReading() {
export function Overview() {
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
const { data: e } = usePoll<Energy>("api/energy");
const weather = useWeather();
const now = hp?.time ? new Date(hp.time) : null;
const title = (
<PageTitle title="Dom"
@@ -188,6 +190,7 @@ export function Overview() {
<span>ALARM pompy ciepła. {hp.alarmText ? `${hp.alarmText}. ` : ""}Sprawdź sterownik w domu.</span>
</div>
)}
{weather && <WeatherWarnings warnings={weather.warnings} />}
{hp.time == null ? <NoReading /> : (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2">
@@ -228,6 +231,7 @@ export function Overview() {
<Row label="Bateria" value={percent(e?.batterySocPct)} />
</Tile>
</div>
{weather && <WeatherCard weather={weather} />}
<TemperatureChart />
<PiSensorCard />
<EnergyToday e={e} />
+126
View File
@@ -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>("weather");
const warning = (event: string, level: number) => ({ ...weather().warnings[0], event, level });
const history = { series: "x", unit: "°C", points: [] };
async function overview(patch: Partial<Weather> = {}) {
serve({ "api/heat-pump": { ...example<HeatPump>("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(<Overview />);
}
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<HeatPump>("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(<Overview />);
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(<Battery />);
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();
});
});
+107
View File
@@ -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<Weather>("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<Kind, { label: string; paths: string[]; colour: string }> = {
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 <span className="grid size-10 place-items-center text-soft">—</span>;
const { label, paths, colour } = PICTURES[kind];
return (
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`size-10 ${colour}`} fill="none"
stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
{paths.map((d) => <path key={d} d={d} />)}
</svg>
);
}
const DAY_NAMES = ["Dziś", "Jutro", "Pojutrze"];
function Day({ name, day }: { name: string; day: WeatherDay }) {
const rain = day.precipitationMm;
return (
<li className="flex flex-col items-center gap-1 text-center">
<b>{name}</b>
<WeatherIcon code={day.weatherCode} />
<span className="text-sm text-soft">{day.description}</span>
<span className="tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</span>
<span className="text-sm text-soft">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
{day.pvForecastKwh != null && (
<span className="text-sm font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</span>
)}
</li>
);
}
export function WeatherCard({ weather }: { weather: Weather }) {
const days = [weather.today, weather.tomorrow, weather.dayAfter];
return (
<Card title="Pogoda" aside={<span className="text-sm text-soft">teraz {celsius(weather.now.temperature)}</span>}
className="lg:col-span-2">
<ul className="grid grid-cols-3 gap-2">
{days.map((day, i) => <Day key={day.date} name={DAY_NAMES[i]} day={day} />)}
</ul>
{weather.stale && <p className="mt-3 text-xs text-soft">Nie udało się odświeżyć pogody, pokazuję starsze dane.</p>}
</Card>
);
}
const LEVELS: Record<number, string> = {
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) => (
<div key={`${w.event}${w.level}${w.from}`} role="alert"
className={`mb-4 flex items-start gap-3 rounded-xl border-2 px-4 py-3 ${LEVELS[w.level] ?? LEVELS[1]}`}>
<Icon name="alert" className="size-6 shrink-0" />
<div>
<p className="font-semibold">Ostrzeżenie IMGW, stopień {w.level}: {w.event}</p>
<p className="text-sm">{dateTime(w.from)} – {dateTime(w.to)}</p>
<p className="mt-1 text-sm">{w.text}</p>
</div>
</div>
))}
</>
);
}
/** 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);
+31
View File
@@ -154,6 +154,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;