From e88ff035cfd9d6017aa48999463923f66eb5c0b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Ha=C3=9Fel?= Date: Sun, 28 Sep 2025 19:59:50 +0200 Subject: [PATCH] Weather --- src/main/angular/src/app/app.config.ts | 2 + .../app/dashboard/dashboard.component.html | 3 + .../src/app/dashboard/dashboard.component.ts | 4 +- .../app/plot/editor/plot-editor.component.ts | 1 - .../src/app/plot/plot/plot.component.ts | 1 - .../angular/src/app/weather/WeatherHour.ts | 25 +++ .../app/weather/plot/weather-component.html | 3 + .../app/weather/plot/weather-component.less | 4 + .../src/app/weather/plot/weather-component.ts | 204 ++++++++++++++++++ .../src/app/weather/weather-service.ts | 20 ++ .../de/ph87/data/weather/BrightSkyDto.java | 81 +++++++ .../de/ph87/data/weather/WeatherConfig.java | 22 ++ .../ph87/data/weather/WeatherController.java | 24 +++ .../de/ph87/data/weather/WeatherHour.java | 36 ++++ .../de/ph87/data/weather/WeatherService.java | 77 +++++++ 15 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 src/main/angular/src/app/weather/WeatherHour.ts create mode 100644 src/main/angular/src/app/weather/plot/weather-component.html create mode 100644 src/main/angular/src/app/weather/plot/weather-component.less create mode 100644 src/main/angular/src/app/weather/plot/weather-component.ts create mode 100644 src/main/angular/src/app/weather/weather-service.ts create mode 100644 src/main/java/de/ph87/data/weather/BrightSkyDto.java create mode 100644 src/main/java/de/ph87/data/weather/WeatherConfig.java create mode 100644 src/main/java/de/ph87/data/weather/WeatherController.java create mode 100644 src/main/java/de/ph87/data/weather/WeatherHour.java create mode 100644 src/main/java/de/ph87/data/weather/WeatherService.java diff --git a/src/main/angular/src/app/app.config.ts b/src/main/angular/src/app/app.config.ts index aebfddd..4ac6c4b 100644 --- a/src/main/angular/src/app/app.config.ts +++ b/src/main/angular/src/app/app.config.ts @@ -1,6 +1,8 @@ import {ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection} from '@angular/core'; import {provideRouter} from '@angular/router'; +import 'chartjs-adapter-date-fns'; + import {routes} from './app.routes'; import {provideHttpClient} from '@angular/common/http'; import {stompServiceFactory} from './COMMON'; diff --git a/src/main/angular/src/app/dashboard/dashboard.component.html b/src/main/angular/src/app/dashboard/dashboard.component.html index e9ecc46..55a86ca 100644 --- a/src/main/angular/src/app/dashboard/dashboard.component.html +++ b/src/main/angular/src/app/dashboard/dashboard.component.html @@ -1,4 +1,7 @@
+
+ +
@for (plot of plots; track plot.id) {
diff --git a/src/main/angular/src/app/dashboard/dashboard.component.ts b/src/main/angular/src/app/dashboard/dashboard.component.ts index dced2b7..53b9fee 100644 --- a/src/main/angular/src/app/dashboard/dashboard.component.ts +++ b/src/main/angular/src/app/dashboard/dashboard.component.ts @@ -2,11 +2,13 @@ import {Component} from '@angular/core'; import {PlotService} from '../plot/plot.service'; import {Plot} from '../plot/Plot'; import {PlotComponent} from '../plot/plot/plot.component'; +import {WeatherComponent} from '../weather/plot/weather-component'; @Component({ selector: 'app-dashboard', imports: [ - PlotComponent + PlotComponent, + WeatherComponent ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.less' diff --git a/src/main/angular/src/app/plot/editor/plot-editor.component.ts b/src/main/angular/src/app/plot/editor/plot-editor.component.ts index da7dd75..0e7f3a5 100644 --- a/src/main/angular/src/app/plot/editor/plot-editor.component.ts +++ b/src/main/angular/src/app/plot/editor/plot-editor.component.ts @@ -1,7 +1,6 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {BarController, BarElement, CategoryScale, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js'; import {SeriesService} from "../../series/series.service"; -import 'chartjs-adapter-date-fns'; import {SeriesType} from '../../series/SeriesType'; import {PlotService} from '../plot.service'; import {Plot} from '../Plot'; diff --git a/src/main/angular/src/app/plot/plot/plot.component.ts b/src/main/angular/src/app/plot/plot/plot.component.ts index 4fd1b8a..787fec3 100644 --- a/src/main/angular/src/app/plot/plot/plot.component.ts +++ b/src/main/angular/src/app/plot/plot/plot.component.ts @@ -1,6 +1,5 @@ import {AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild} from '@angular/core'; import {BarController, BarElement, CategoryScale, Chart, ChartDataset, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js'; -import 'chartjs-adapter-date-fns'; import {toAvg, toBool, toDelta, toMax, toMin} from '../../series/MinMaxAvg'; import {SeriesType} from '../../series/SeriesType'; import {Plot} from '../Plot'; diff --git a/src/main/angular/src/app/weather/WeatherHour.ts b/src/main/angular/src/app/weather/WeatherHour.ts new file mode 100644 index 0000000..67e7f87 --- /dev/null +++ b/src/main/angular/src/app/weather/WeatherHour.ts @@ -0,0 +1,25 @@ +import {validateDate, validateNumber} from '../COMMON'; + +export class WeatherHour { + + constructor( + readonly date: Date, + readonly clouds: number, + readonly irradiation: number, + readonly precipitation: number, + readonly temperature: number, + ) { + // + } + + static fromJson(json: any): WeatherHour { + return new WeatherHour( + validateDate(json.date), + validateNumber(json.clouds), + validateNumber(json.irradiation), + validateNumber(json.precipitation), + validateNumber(json.temperature), + ); + } + +} diff --git a/src/main/angular/src/app/weather/plot/weather-component.html b/src/main/angular/src/app/weather/plot/weather-component.html new file mode 100644 index 0000000..4585a0f --- /dev/null +++ b/src/main/angular/src/app/weather/plot/weather-component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/main/angular/src/app/weather/plot/weather-component.less b/src/main/angular/src/app/weather/plot/weather-component.less new file mode 100644 index 0000000..cbe226c --- /dev/null +++ b/src/main/angular/src/app/weather/plot/weather-component.less @@ -0,0 +1,4 @@ +.container { + width: 100%; + height: 100%; +} diff --git a/src/main/angular/src/app/weather/plot/weather-component.ts b/src/main/angular/src/app/weather/plot/weather-component.ts new file mode 100644 index 0000000..ec8df5e --- /dev/null +++ b/src/main/angular/src/app/weather/plot/weather-component.ts @@ -0,0 +1,204 @@ +import {AfterViewInit, Component, ElementRef, ViewChild} from '@angular/core'; +import {WeatherService} from '../weather-service'; +import {WeatherHour} from '../WeatherHour'; +import {Chart} from 'chart.js'; + +import {de} from 'date-fns/locale'; +import {format} from 'date-fns'; + +export function toPoint(f: (hour: WeatherHour) => number): (hour: WeatherHour) => { x: number, y: number } { + return (hour: WeatherHour) => { + return { + x: hour.date.getTime(), + y: f(hour), + }; + }; +} + +const TEMPERATURE_HIGH_COLOR = '#FF0000'; +const TEMPERATURE_LOW_COLOR = '#0000FF'; +const TEMPERATURE_LOW_THRESHOLD = 0; +const PRECIPITATION_COLOR = '#0000FF'; +const CLOUDS_COLOR = '#cccccc'; +const SUN_COLOR = '#ffc400'; + +@Component({ + selector: 'app-weather-component', + imports: [], + templateUrl: './weather-component.html', + styleUrl: './weather-component.less' +}) +export class WeatherComponent implements AfterViewInit { + + @ViewChild('chartCanvas') + protected canvasRef!: ElementRef; + + @ViewChild('container') + protected chartContainer!: ElementRef; + + private chart!: Chart; + + constructor( + readonly weatherService: WeatherService, + ) { + // + } + + ngAfterViewInit(): void { + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'line', + data: { + datasets: [], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { + display: true, + }, + tooltip: { + position: 'nearest', + mode: 'index', + usePointStyle: true, + callbacks: { + title: function (items) { + const date = items[0].parsed.x as unknown as Date; + return format(date, 'EE dd.MM. HH:mm', {locale: de}); + }, + }, + }, + title: { + display: true, + text: "Wetter", + } + }, + scales: { + x: { + type: 'time', + time: { + unit: "day", + displayFormats: { + day: "EE dd.MM" + } + }, + adapters: { + date: { + locale: de, + } + }, + }, + y_temperature: { + position: "right", + title: { + display: true, + text: "Temperatur [°C]", + color: TEMPERATURE_HIGH_COLOR, + }, + ticks: { + color: TEMPERATURE_HIGH_COLOR, + }, + min: 0, + max: 35, + }, + y_precipitation: { + title: { + display: true, + text: "Niederschlag [mm]", + color: PRECIPITATION_COLOR, + }, + ticks: { + color: PRECIPITATION_COLOR, + }, + min: 0, + max: 15, + }, + y_sun: { + position: "right", + title: { + display: true, + text: "Sonne [kWh/m²]", + color: SUN_COLOR, + }, + ticks: { + color: SUN_COLOR, + }, + min: 0, + max: 1000, + }, + y_clouds: { + display: false, + title: { + display: true, + text: "Wolkenbedeckung [%]", + color: CLOUDS_COLOR, + }, + ticks: { + color: CLOUDS_COLOR, + }, + min: 0, + max: 100, + }, + }, + }, + }); + this.weatherService.all(hours => { + const now = Date.now(); + const filtered = hours.filter(h => h.date.getTime() >= now); + this.chart.data.datasets.push({ + label: "Niederschlag", + type: "bar", + yAxisID: "y_precipitation", + data: filtered.map(toPoint(h => h.precipitation)), + borderColor: PRECIPITATION_COLOR, + backgroundColor: PRECIPITATION_COLOR + '66', + borderWidth: 0, + pointStyle: "rect", + }); + this.chart.data.datasets.push({ + label: "Sonne", + type: "line", + fill: "origin", + yAxisID: "y_sun", + data: filtered.map(toPoint(h => h.irradiation)), + borderColor: SUN_COLOR, + backgroundColor: SUN_COLOR + '88', + borderWidth: 0, + pointRadius: 0, + }); + this.chart.data.datasets.push({ + label: "Temperatur", + type: "line", + yAxisID: "y_temperature", + data: filtered.map(toPoint(h => h.temperature)), + borderColor: function (context) { + const value = context.dataset.data[context.dataIndex]; + return value !== null && typeof value === 'object' && value.hasOwnProperty("y") && value.y >= TEMPERATURE_LOW_THRESHOLD ? TEMPERATURE_HIGH_COLOR : TEMPERATURE_LOW_COLOR; + }, + backgroundColor: TEMPERATURE_HIGH_COLOR + '66', + fill: { + target: {value: TEMPERATURE_LOW_THRESHOLD}, + above: TEMPERATURE_HIGH_COLOR + '66', + below: TEMPERATURE_LOW_COLOR + '66', + }, + borderWidth: 0, + pointRadius: 5, + pointStyle: "cross", + }); + this.chart.data.datasets.push({ + label: "Wolken", + type: "line", + fill: "origin", + yAxisID: "y_clouds", + data: filtered.map(toPoint(h => h.clouds)), + borderColor: CLOUDS_COLOR, + backgroundColor: CLOUDS_COLOR + '88', + borderWidth: 0, + pointRadius: 0, + }); + this.chart.update(); + }); + } + +} diff --git a/src/main/angular/src/app/weather/weather-service.ts b/src/main/angular/src/app/weather/weather-service.ts new file mode 100644 index 0000000..be77f96 --- /dev/null +++ b/src/main/angular/src/app/weather/weather-service.ts @@ -0,0 +1,20 @@ +import {Injectable} from '@angular/core'; +import {ApiService, CrudService, Next} from '../COMMON'; +import {WeatherHour} from './WeatherHour'; + +@Injectable({ + providedIn: 'root' +}) +export class WeatherService extends CrudService { + + constructor( + api: ApiService, + ) { + super(api, ['Weather'], WeatherHour.fromJson); + } + + all(next: Next) { + this.getList(['all'], next); + } + +} diff --git a/src/main/java/de/ph87/data/weather/BrightSkyDto.java b/src/main/java/de/ph87/data/weather/BrightSkyDto.java new file mode 100644 index 0000000..9f991e3 --- /dev/null +++ b/src/main/java/de/ph87/data/weather/BrightSkyDto.java @@ -0,0 +1,81 @@ +package de.ph87.data.weather; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.TimeZone; + +@Data +@SuppressWarnings("unused") +@JsonIgnoreProperties(ignoreUnknown = true) +public class BrightSkyDto { + + private List weather; + + @Getter + @ToString + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Hour { + + private ZonedDateTime timestamp; + + private int source_id; + + private Double precipitation; + + private Double pressure_msl; + + private Double sunshine; + + private Double temperature; + + private Double wind_direction; + + private Double wind_speed; + + private Integer cloud_cover; + + private Double dew_point; + + private Double relative_humidity; + + private Double visibility; + + private Double wind_gust_direction; + + private Double wind_gust_speed; + + private String condition; + + private Double precipitation_probability; + + private Double precipitation_probability_6h; + + private Double solar; + + private String icon; + + public void setTimestamp(@NonNull final ZonedDateTime timestamp) { + this.timestamp = timestamp.withZoneSameInstant(TimeZone.getDefault().toZoneId()); + } + + public double getSolar() { + return solar == null ? 0.0 : solar; + } + + public double getCloud_cover() { + return cloud_cover == null ? 0.0 : cloud_cover; + } + + public double getPrecipitation() { + return precipitation == null ? 0.0 : precipitation; + } + + } + +} diff --git a/src/main/java/de/ph87/data/weather/WeatherConfig.java b/src/main/java/de/ph87/data/weather/WeatherConfig.java new file mode 100644 index 0000000..8a05b25 --- /dev/null +++ b/src/main/java/de/ph87/data/weather/WeatherConfig.java @@ -0,0 +1,22 @@ +package de.ph87.data.weather; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "de.ph87.data.weather") +public class WeatherConfig { + + private String urlPattern = "https://api.brightsky.dev/weather?date={date}&lat={latitude}&lon={longitude}&units=dwd"; + + private double latitude = 49.320789191091194; + + private double longitude = 7.102111982262271; + + private int pastDays = 9; + + private int futureDays = 9; + +} diff --git a/src/main/java/de/ph87/data/weather/WeatherController.java b/src/main/java/de/ph87/data/weather/WeatherController.java new file mode 100644 index 0000000..1b59384 --- /dev/null +++ b/src/main/java/de/ph87/data/weather/WeatherController.java @@ -0,0 +1,24 @@ +package de.ph87.data.weather; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("Weather") +public class WeatherController { + + private final WeatherService weatherService; + + @GetMapping("all") + public List all() { + return weatherService.all(); + } + +} diff --git a/src/main/java/de/ph87/data/weather/WeatherHour.java b/src/main/java/de/ph87/data/weather/WeatherHour.java new file mode 100644 index 0000000..4f2d68b --- /dev/null +++ b/src/main/java/de/ph87/data/weather/WeatherHour.java @@ -0,0 +1,36 @@ +package de.ph87.data.weather; + +import lombok.Data; +import lombok.NonNull; +import lombok.ToString; + +import java.time.ZonedDateTime; + +@Data +@ToString(includeFieldNames = false) +public class WeatherHour { + + @NonNull + public final ZonedDateTime date; + + @NonNull + public final double clouds; + + @NonNull + public final double irradiation; + + @NonNull + public final double precipitation; + + @NonNull + public final double temperature; + + public WeatherHour(@NonNull final BrightSkyDto.Hour dto) { + date = dto.getTimestamp(); + clouds = dto.getCloud_cover(); + irradiation = dto.getSolar() * 1000; + precipitation = dto.getPrecipitation(); + temperature = (dto.getTemperature()); + } + +} diff --git a/src/main/java/de/ph87/data/weather/WeatherService.java b/src/main/java/de/ph87/data/weather/WeatherService.java new file mode 100644 index 0000000..f42c75c --- /dev/null +++ b/src/main/java/de/ph87/data/weather/WeatherService.java @@ -0,0 +1,77 @@ +package de.ph87.data.weather; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@EnableScheduling +@RequiredArgsConstructor +public class WeatherService { + + private List hours = new ArrayList<>(); + + private final WeatherConfig weatherConfig; + + private final ObjectMapper objectMapper; + + @NonNull + public List all() { + return new ArrayList<>(hours); + } + + @Scheduled(cron = "0 0 * * * *") + @EventListener(ApplicationStartedEvent.class) + public void update() { + try { + final LocalDate today = LocalDate.now(); + final LocalDate first = today.minusDays(weatherConfig.getPastDays()); + final LocalDate end = today.plusDays(weatherConfig.getFutureDays()); + final List hours = new ArrayList<>(); + log.debug("Updating Weather..."); + for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) { + fetchDay(day).getWeather().stream().map(WeatherHour::new).forEach(hours::add); + } + this.hours = hours; + log.info("Weather update complete"); + } catch (Exception e) { + log.error("Failed fetching Weather data: {}", e.toString()); + } + } + + @NonNull + public BrightSkyDto fetchDay(@NonNull final LocalDate day) throws IOException { + final String url = weatherConfig + .getUrlPattern() + .replace("{date}", ZonedDateTime.of(day, LocalTime.MIDNIGHT, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + .replace("{latitude}", weatherConfig.getLatitude() + "") + .replace("{longitude}", weatherConfig.getLongitude() + ""); + final HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); + final int responseCode = connection.getResponseCode(); + final byte[] bytes = connection.getInputStream().readAllBytes(); + if (responseCode / 100 != 2) { + throw new IOException("responseCode=%d, message: %s".formatted(responseCode, new String(bytes, StandardCharsets.UTF_8))); + } + return objectMapper.readValue(bytes, BrightSkyDto.class); + } + +}