This commit is contained in:
Patrick Haßel 2025-02-28 11:32:50 +01:00
parent 2882184a82
commit 906be87e50
20 changed files with 554 additions and 10 deletions

View File

@ -4,7 +4,7 @@ import {map, Subscription} from 'rxjs';
import {StompService} from '@stomp/ng2-stompjs';
import {FromJson, Next} from './types';
const DEV_TO_PROD = true;
const DEV_TO_PROD = false;
@Injectable({
providedIn: 'root'

View File

@ -1,3 +1,5 @@
import {FromJson} from './types';
export function validateString(value: any): string {
if (typeof value !== 'string') {
throw new Error('Not a string: ' + value);
@ -11,3 +13,11 @@ export function validateNumber(value: any): number {
}
return value as number;
}
export function validateDate(value: any): Date {
return new Date(validateString(value));
}
export function validateList<T>(value: any[], fromJson: FromJson<T>): T[] {
return value.map(fromJson);
}

View File

@ -1,3 +1,5 @@
<app-weather-diagram></app-weather-diagram>
<app-electro-power></app-electro-power>
<app-electro-energy></app-electro-energy>

View File

@ -1,12 +1,14 @@
import { Component } from '@angular/core';
import {ElectroEnergyComponent} from "../electro/energy/electro-energy.component";
import {ElectroPowerComponent} from "../electro/power/electro-power.component";
import {WeatherDiagramComponent} from '../weather/weather-diagram/weather-diagram.component';
@Component({
selector: 'app-dashboard',
imports: [
ElectroEnergyComponent,
ElectroPowerComponent
ElectroPowerComponent,
WeatherDiagramComponent
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.less'

View File

@ -1,17 +1,17 @@
<div class="bar">
<div class="part purchase" *ngIf="barPurchasePercent" [style.width]="barPurchasePercent.value + '%'">
<div class="part purchase" *ngIf="barPurchasePercent" [style.width]="barPurchasePercent.formatted2">
<div class="text">
{{ _purchase?.formatted }}<br>
{{ purchasePercent?.formatted }}
</div>
</div>
<div class="part self" *ngIf="barSelfPercent" [style.width]="barSelfPercent.value + '%'">
<div class="part self" *ngIf="barSelfPercent" [style.width]="barSelfPercent.formatted2">
<div class="text">
{{ _self?.formatted }}<br>
{{ selfPercent?.formatted }}
</div>
</div>
<div class="part delivery" *ngIf="barDeliveryPercent" [style.width]="barDeliveryPercent.value + '%'">
<div class="part delivery" *ngIf="barDeliveryPercent" [style.width]="barDeliveryPercent.formatted2">
<div class="text">
{{ _delivery?.formatted }}<br>
{{ deliveryPercent?.formatted }}

View File

@ -12,6 +12,14 @@ export class Unit {
static readonly PERCENT = new Unit('PERCENT', "%");
static readonly CLOUD_COVER_PERCENT = new Unit('CLOUD_COVER_PERCENT', '%');
static readonly IRRADIATION_WH_M2 = new Unit('IRRADIATION_WH_M2', 'Wh/m²');
static readonly IRRADIATION_KWH_M2 = new Unit('IRRADIATION_KWH_M2', 'kWh/m²');
static readonly PRECIPITATION_MM = new Unit('PRECIPITATION_MM', 'mm');
private constructor(
readonly name: string,
readonly unit: string,

View File

@ -11,8 +11,22 @@ export class Value {
//
}
static fromJson2(json: any, locale: string): Value {
return new Value(
validateNumber(json['value']),
Unit.fromJson(json['unit']),
validateNumber(json['decimals']),
locale
);
}
static fromJson(value: any, unit: Unit, decimals: number, locale: string): Value {
return new Value(validateNumber(value), unit, decimals, locale);
return new Value(
validateNumber(value),
unit,
decimals,
locale
);
}
get zero(): boolean {
@ -20,7 +34,15 @@ export class Value {
}
get formatted(): string {
return `${this.value.toLocaleString(this.locale, {maximumFractionDigits: this.decimals, minimumFractionDigits: this.decimals})} ${this.unit.unit}`;
return `${(this.localeString)} ${this.unit.unit}`;
}
get formatted2(): string {
return `${(this.localeString)}${this.unit.unit}`;
}
get localeString(): string {
return this.value.toLocaleString(this.locale, {maximumFractionDigits: this.decimals, minimumFractionDigits: this.decimals});
}
negate() {
@ -55,11 +77,12 @@ export class Value {
return new Value(0, this.unit, this.decimals, this.locale);
}
percent(other: Value | undefined): Value | undefined {
if (!other || other.value === 0) {
percent(other: Value | number | undefined): Value | undefined {
const v = other instanceof Value ? other.value : typeof other === "number" ? other : 0;
if (v === 0) {
return undefined;
}
return new Value(this.value / other.value * 100, Unit.PERCENT, 0, this.locale);
return new Value(this.value / v * 100, Unit.PERCENT, 0, this.locale);
}
}

View File

@ -0,0 +1,27 @@
import {Value} from "../../value/Value";
import {validateDate, validateList} from "../../core/validators";
import {WeatherHour} from "./WeatherHour";
export class WeatherDay {
constructor(
readonly date: Date,
readonly hours: WeatherHour[],
readonly clouds: Value,
readonly irradiation: Value,
readonly precipitation: Value,
) {
//
}
static fromJson(json: any, locale: string): WeatherDay {
return new WeatherDay(
validateDate(json['date']),
validateList(json['hours'], hour => WeatherHour.fromJson(hour, locale)),
Value.fromJson2(json['clouds'], locale),
Value.fromJson2(json['irradiation'], locale),
Value.fromJson2(json['precipitation'], locale),
);
}
}

View File

@ -0,0 +1,24 @@
import {Value} from '../../value/Value';
import {validateDate} from '../../core/validators';
export class WeatherHour {
constructor(
readonly date: Date,
readonly clouds: Value,
readonly irradiation: Value,
readonly precipitation: Value,
) {
//
}
static fromJson(json: any, locale: string): WeatherHour {
return new WeatherHour(
validateDate(json['date']),
Value.fromJson2(json['clouds'], locale),
Value.fromJson2(json['irradiation'], locale),
Value.fromJson2(json['precipitation'], locale),
);
}
}

View File

@ -0,0 +1,12 @@
<div class="numberTable">
<div class="title">
Wetter
</div>
<div class="day">
<div class="hour" *ngFor="let hour of hours">
<div class="bar clouds" [style.height]="clouds(hour)"></div>
<div class="bar precipitation" [style.height]="precipitation(hour)"></div>
<div class="bar irradiation" [style.height]="irradiation(hour)"></div>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
.day {
display: flex;
height: 3em;
background-color: #2d4255;
.hour {
position: relative;
display: flex;
align-items: flex-end;
width: 100%; // any width will do (flex cares about real with)
.bar {
position: absolute;
width: 100%;
opacity: 0.5;
}
.clouds {
background-color: white;
}
.irradiation {
background-color: yellow;
}
.precipitation {
background-color: blue;
}
}
}

View File

@ -0,0 +1,77 @@
import {Component, OnInit} from '@angular/core';
import {NgForOf} from '@angular/common';
import {WeatherHour} from './WeatherHour';
import {WeatherService} from './weather.service';
import {WeatherDay} from './WeatherDay';
const PAST_HOURS_COUNT = 0;
const DAY_COUNT = 7;
@Component({
selector: 'app-weather-diagram',
imports: [
NgForOf
],
templateUrl: './weather-diagram.component.html',
styleUrl: './weather-diagram.component.less'
})
export class WeatherDiagramComponent implements OnInit {
protected days: WeatherDay[] = [];
protected hours: WeatherHour[] = [];
constructor(
readonly weatherService: WeatherService,
) {
//
}
ngOnInit(): void {
this.weatherService.all(all => {
this.days = all;
this.updateHours();
})
}
clouds(hour: WeatherHour): string {
return (hour.clouds?.value || 0) + '%';
}
irradiation(hour: WeatherHour): string {
return (hour.irradiation.percent(1000)?.value || 0) + '%';
}
precipitation(hour: WeatherHour) {
return (hour.precipitation.percent(15)?.value || 0) + '%';
}
private updateHours() {
const nowHour = new Date();
nowHour.setMinutes(0);
nowHour.setSeconds(0);
nowHour.setMilliseconds(0);
const firstHour = new Date(nowHour);
firstHour.setHours(firstHour.getHours() - PAST_HOURS_COUNT);
const endHour = new Date(firstHour);
endHour.setHours(endHour.getHours() + 24 * DAY_COUNT);
this.hours = [];
const currentHour = new Date(firstHour);
for (const day of this.days) {
for (const hour of day.hours) {
if (hour.date.getTime() === currentHour.getTime()) {
this.hours.push(hour);
currentHour.setHours(currentHour.getHours() + 1);
if (currentHour.getTime() >= endHour.getTime()) {
return;
}
}
}
}
}
}

View File

@ -0,0 +1,22 @@
import {Inject, Injectable, LOCALE_ID} from '@angular/core';
import {ApiService} from '../../core/api.service';
import {Next} from '../../core/types';
import {WeatherDay} from './WeatherDay';
@Injectable({
providedIn: 'root'
})
export class WeatherService {
constructor(
readonly api: ApiService,
@Inject(LOCALE_ID) readonly locale: string,
) {
//
}
all(next: Next<WeatherDay[]>) {
return this.api.getList(['Weather', 'all'], json => WeatherDay.fromJson(json, this.locale), next);
}
}

View File

@ -38,6 +38,13 @@ public enum Unit {
SUN_DC("Δ°C"),
UNIT_PERCENT("%"),
CLOUD_COVER_PERCENT("%"),
IRRADIATION_WH_M2("Wh/m²"),
IRRADIATION_KWH_M2("kWh/m²", 1000, IRRADIATION_WH_M2),
PRECIPITATION_MM("mm"),
;
public final String unit;

View File

@ -2,17 +2,31 @@ package de.ph87.data.value;
import lombok.*;
import java.util.*;
import java.util.function.*;
@Data
public class Value {
public final double value;
public final Unit unit;
public final int decimals;
public Value(final double value, @NonNull final Unit unit) {
this.value = value;
this.unit = unit;
this.decimals = 1;
}
public Value(final double value, @NonNull final Unit unit, final int decimals) {
this.value = value;
this.unit = unit;
this.decimals = decimals;
}
@NonNull
public Value as(@NonNull final Unit target) {
if (this.unit == target) {
return this;
@ -23,4 +37,21 @@ public class Value {
return new Value(value * this.unit.factor / target.factor, target);
}
@NonNull
public static <T> Value sum(@NonNull final List<T> hours, @NonNull final Function<T, Value> map, @NonNull final Unit unit, final int decimals) {
final double sum = hours.stream().map(map).map(v -> v.as(unit)).map(Value::getValue).reduce(Double::sum).orElse(0.0);
return new Value(sum, unit, decimals);
}
@NonNull
public static <T> Value avg(@NonNull final List<T> hours, @NonNull final Function<T, Value> map, @NonNull final Unit unit, final int decimals) {
final double avg = sum(hours, map, unit, decimals).as(unit).value / hours.size();
return new Value(avg, unit, decimals);
}
@Override
public String toString() {
return "%%.%df%%s".formatted(decimals).formatted(value, unit.unit);
}
}

View File

@ -0,0 +1,65 @@
package de.ph87.data.weather;
import com.fasterxml.jackson.annotation.*;
import lombok.*;
import java.time.*;
import java.util.*;
@Data
@SuppressWarnings("unused")
@JsonIgnoreProperties(ignoreUnknown = true)
public class BrightSkyDto {
private List<Weather> weather;
@Getter
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Weather {
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());
}
}
}

View File

@ -0,0 +1,22 @@
package de.ph87.data.weather;
import lombok.*;
import org.springframework.boot.context.properties.*;
import org.springframework.stereotype.*;
@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;
}

View File

@ -0,0 +1,25 @@
package de.ph87.data.weather;
import lombok.*;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("Weather")
public class WeatherController {
private final WeatherService weatherService;
@GetMapping("update")
public void update() {
weatherService.update();
}
@GetMapping("all")
public List<WeatherDay> all() {
return weatherService.all();
}
}

View File

@ -0,0 +1,79 @@
package de.ph87.data.weather;
import de.ph87.data.value.Value;
import de.ph87.data.value.*;
import lombok.*;
import java.io.*;
import java.time.*;
import java.util.*;
import java.util.stream.*;
@Data
@ToString(includeFieldNames = false)
public class WeatherDay {
@NonNull
public final LocalDate date;
@NonNull
@ToString.Exclude
public final List<Hour> hours;
@NonNull
public final Value clouds;
@NonNull
public final Value irradiation;
@NonNull
public final Value precipitation;
public WeatherDay(@NonNull final BrightSkyDto dto) throws IOException {
if (dto.getWeather().size() != 25) {
throw new IOException("Expected 25 hours. But received: %d:\n %s".formatted(dto.getWeather().size(), dto.getWeather().stream().map(BrightSkyDto.Weather::toString).collect(Collectors.joining("\n "))));
}
date = dto.getWeather().getFirst().getTimestamp().toLocalDate();
hours = dto.getWeather().stream().map(Hour::new).filter(h -> h.date.toLocalDate().equals(date)).toList();
clouds = Value.avg(hours, Hour::getClouds, Unit.CLOUD_COVER_PERCENT, 0);
irradiation = Value.sum(hours, Hour::getIrradiation, Unit.IRRADIATION_KWH_M2, 1);
precipitation = Value.sum(hours, Hour::getPrecipitation, Unit.PRECIPITATION_MM, 0);
validate();
}
private void validate() throws IOException {
ZonedDateTime date = ZonedDateTime.of(this.date, LocalTime.MIDNIGHT, ZoneId.systemDefault());
for (final Hour hour : hours) {
if (hour.date.compareTo(date) != 0) {
throw new IOException("Invalid Hour-Date: expected=%s, actual=%s".formatted(date, hour.date));
}
date = date.plusHours(1);
}
}
@Data
@ToString(includeFieldNames = false)
public static class Hour {
@NonNull
public final ZonedDateTime date;
@NonNull
public final Value clouds;
@NonNull
public final Value irradiation;
@NonNull
public final Value precipitation;
public Hour(@NonNull final BrightSkyDto.Weather dto) {
date = dto.getTimestamp();
clouds = new Value(dto.getCloud_cover(), Unit.CLOUD_COVER_PERCENT);
irradiation = new Value(dto.getSolar() * 1000, Unit.IRRADIATION_WH_M2);
precipitation = new Value(dto.getPrecipitation(), Unit.PRECIPITATION_MM);
}
}
}

View File

@ -0,0 +1,76 @@
package de.ph87.data.weather;
import com.fasterxml.jackson.databind.*;
import lombok.*;
import lombok.extern.slf4j.*;
import org.springframework.boot.context.event.*;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.*;
import org.springframework.stereotype.*;
import java.io.*;
import java.net.*;
import java.nio.charset.*;
import java.time.*;
import java.time.format.*;
import java.util.*;
@Slf4j
@Service
@EnableScheduling
@RequiredArgsConstructor
public class WeatherService {
private List<WeatherDay> days = new ArrayList<>();
private final WeatherConfig weatherConfig;
private final ObjectMapper objectMapper;
@NonNull
public List<WeatherDay> all() {
return new ArrayList<>(days);
}
@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<WeatherDay> newDays = new ArrayList<>();
log.debug("Updating Weather...");
for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) {
final WeatherDay weatherDay = new WeatherDay(fetchDay(day));
newDays.add(weatherDay);
if (log.isDebugEnabled()) {
log.debug(" {}:", weatherDay);
for (WeatherDay.Hour hour : weatherDay.getHours()) {
log.debug(" %s: %4s clouds, %9s, %4s".formatted(hour.date.toLocalTime(), hour.getClouds(), hour.getIrradiation(), hour.getPrecipitation()));
}
}
}
days = newDays;
log.info("Weather update complete");
} catch (IOException e) {
log.error(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);
}
}