Merge PR #86: feat: weather, PV forecast and IMGW warnings
This commit is contained in:
commit
378f4f6623
32 files changed
+1595
-3
No files matched your search
@@ -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
|
||||
+ "¤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<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]}}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user